diff --git a/TODO.md b/TODO.md index 750526c6..deefd83b 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,6 @@ - Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md) - メモリ機構 - 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md) -- セッション内 TODO ツール(注意機構付き) → [tickets/session-todo.md](tickets/session-todo.md) +- セッション内 Task ツールの注意機構(無アクティビティで `` ナッジ) → [tickets/session-todo-reminder.md](tickets/session-todo-reminder.md) - ワークスペースのメモリーをLintするヘッドレスCLI - system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式 `...` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針) diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index b92150ca..4ef0c4e0 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -133,6 +133,7 @@ impl PodController { // Stashed during tool registration below so we can attach a // `PodFsView` to the shared state once the latter exists. let fs_for_view: tools::ScopedFs; + let task_store = pod.task_store(); let scope_change_sink = pod.scope_change_sink(); @@ -266,7 +267,12 @@ impl PodController { // query — keep a clone for the FS view we attach below, // since the tools consume `fs` itself. fs_for_view = fs.clone(); - worker.register_tools(tools::builtin_tools(fs, tracker.clone(), bash_output_dir)); + worker.register_tools(tools::builtin_tools( + fs, + tracker.clone(), + task_store.clone(), + bash_output_dir, + )); // Memory subsystem opt-in. When `[memory]` is present in // the manifest, register the memory-specific Read/Write/Edit diff --git a/crates/pod/src/ipc/interceptor.rs b/crates/pod/src/ipc/interceptor.rs index 7594753d..dfdeb4dc 100644 --- a/crates/pod/src/ipc/interceptor.rs +++ b/crates/pod/src/ipc/interceptor.rs @@ -150,7 +150,7 @@ impl Interceptor for PodInterceptor { // Internal mechanism: between-requests compaction trigger (safety net). if let Some(state) = self.compact_state.as_ref() { - if !state.is_disabled() { + if !state.is_disabled() && !state.just_compacted() { let current = current_tokens.unwrap_or(0); if state.exceeds_request(current) { info!( diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 35ce31d0..f28fb0f8 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -95,6 +95,11 @@ pub struct Pod { /// tools so that Pod-owned operations (e.g. compaction) can consult /// the recency of touched files. tracker: Option, + /// Session-lifetime task store from the builtin `tools` crate. Shared by + /// TaskCreate / TaskUpdate / TaskList / TaskGet and preserved across + /// compaction by keeping the same handle while the Worker history is + /// replaced. Restored Pods reconstruct it by replaying Task* tool calls. + task_store: tools::TaskStore, /// Parsed system-prompt template awaiting first-turn materialisation. /// `Some` until `ensure_system_prompt_materialized` renders it once, /// then `None` forever — including after compaction. @@ -215,6 +220,7 @@ impl Pod { metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), usage_history: Arc::new(Mutex::new(Vec::::new())), tracker: None, + task_store: tools::TaskStore::new(), system_prompt_template: None, alerter: None, event_tx: None, @@ -479,6 +485,18 @@ impl Pod { self.tracker = Some(tracker); } + /// Attach the session-scoped TaskStore from the builtin `tools` crate. + /// Called by the Controller before registering builtin tools so the Pod + /// and Worker share one store. + pub fn attach_task_store(&mut self, task_store: tools::TaskStore) { + self.task_store = task_store; + } + + /// Shared TaskStore handle. + pub fn task_store(&self) -> tools::TaskStore { + self.task_store.clone() + } + /// The attached session-scoped file-operation tracker, if any. pub fn tracker(&self) -> Option<&tools::Tracker> { self.tracker.as_ref() @@ -1314,8 +1332,14 @@ impl Pod { .unwrap_or_default(); // Input text fed to the compact worker. Includes the default - // references and the (pruned) conversation text. - let summary_input = build_summary_input(&items_to_summarise, &default_refs); + // references, current TaskStore snapshot, and the (pruned) + // conversation text. + let task_snapshot_text = self.task_store.snapshot_text(); + let summary_input = build_summary_input( + &items_to_summarise, + &default_refs, + Some(task_snapshot_text.as_str()), + ); // Worker-side state collected by the compact worker's tool calls. let ctx = Arc::new(std::sync::Mutex::new(CompactWorkerContext::with_budget( @@ -1430,9 +1454,15 @@ impl Pod { .filter(|i| i.is_user_message()) .count(); - // Build new history: [summary, ...auto-read, references, ...retained]. + // Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result]. + // The TaskStore snapshot trails the retained items so that, on resume, + // `replay_history` walks any pre-compact Task* calls preserved verbatim + // in retained_items first and the trailing snapshot's `replace_with` + // is the final word — pre-compact `TaskCreate` calls cannot leak as + // duplicate entries. let mut new_history = Vec::with_capacity( 1 + auto_read_messages.len() + + 3 + reference_message.is_some() as usize + retained_items.len(), ); @@ -1444,6 +1474,17 @@ impl Pod { new_history.push(msg); } new_history.extend(retained_items); + new_history.push(Item::system_message(format!( + "[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\ + This is the complete session task list preserved across compaction. \ + The following TaskList tool result presents the same state through the tool lane." + ))); + new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}")); + new_history.push(Item::tool_result_with_content( + "compact-tasklist", + tools::task::snapshot_overview(&self.task_store.list()), + task_snapshot_text.clone(), + )); // Persist as a new compacted session. let old_session_id = self.session_id; @@ -2038,6 +2079,7 @@ impl Pod, St> { metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, + task_store: tools::TaskStore::new(), system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, @@ -2101,6 +2143,7 @@ impl Pod, St> { metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, + task_store: tools::TaskStore::new(), system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, @@ -2211,6 +2254,7 @@ impl Pod, St> { } let extract_pointer = memory::extract::fold_pointer(&state.extensions); + let task_store = tools::TaskStore::from_history(&state.history); let mut pod = Self { manifest, @@ -2227,6 +2271,7 @@ impl Pod, St> { metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), usage_history: Arc::new(Mutex::new(state.usage_history)), tracker: None, + task_store, // Restore replays the saved system_prompt verbatim — no // template re-render on resume. system_prompt_template: None, @@ -2323,7 +2368,11 @@ impl From for PodRunResult { /// Build the compact worker's input: default-reference instructions, /// the list of recently-touched files, and the pruned conversation /// produced by [`build_summary_prompt`]. -fn build_summary_input(items: &[Item], default_refs: &[PathBuf]) -> String { +fn build_summary_input( + items: &[Item], + default_refs: &[PathBuf], + task_snapshot: Option<&str>, +) -> String { let mut out = String::new(); out.push_str( "Summarise the conversation below into a structured summary and nominate \ @@ -2343,6 +2392,16 @@ fn build_summary_input(items: &[Item], default_refs: &[PathBuf]) -> String { } out.push('\n'); } + if let Some(task_snapshot) = task_snapshot { + out.push_str( + "## Current Session TaskStore\n\ + This is the full current task list. Use it as source material for the \ + summary, especially active (pending/inprogress) tasks, but do not edit tasks \ + from the compact worker.\n", + ); + out.push_str(task_snapshot); + out.push_str("\n\n"); + } out.push_str("## Conversation\n"); out.push_str(&build_summary_prompt(items)); out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text."); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 28139410..f4af7a39 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -20,6 +20,7 @@ pub mod error; pub mod scoped_fs; +pub mod task; pub mod tracker; mod bash; @@ -36,6 +37,7 @@ pub use glob::glob_tool; pub use grep::grep_tool; pub use read::read_tool; pub use scoped_fs::ScopedFs; +pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools}; pub use tracker::Tracker; pub use write::write_tool; @@ -53,14 +55,17 @@ pub use write::write_tool; pub fn builtin_tools( fs: ScopedFs, tracker: Tracker, + task_store: TaskStore, bash_output_dir: std::path::PathBuf, ) -> Vec { - vec![ + let mut defs = vec![ read_tool(fs.clone(), tracker.clone()), write_tool(fs.clone(), tracker.clone()), edit_tool(fs.clone(), tracker), glob_tool(fs.clone()), grep_tool(fs.clone()), bash_tool(fs, bash_output_dir), - ] + ]; + defs.extend(task_tools(task_store)); + defs } diff --git a/crates/tools/src/task.rs b/crates/tools/src/task.rs new file mode 100644 index 00000000..e683fe49 --- /dev/null +++ b/crates/tools/src/task.rs @@ -0,0 +1,688 @@ +//! Session-scoped TaskStore and builtin task tools. +//! +//! The store is Pod/session-lifetime state shared by the four Task* tools. It +//! is reconstructed on resume by replaying TaskCreate / TaskUpdate tool-call +//! arguments from persisted history. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use llm_worker::Item; +use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum TaskStatus { + Pending, + Inprogress, + Completed, + Deleted, +} + +impl std::fmt::Display for TaskStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Pending => "pending", + Self::Inprogress => "inprogress", + Self::Completed => "completed", + Self::Deleted => "deleted", + }; + f.write_str(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TaskEntry { + pub taskid: u64, + pub status: TaskStatus, + pub subject: String, + pub description: String, +} + +#[derive(Debug, Default)] +struct Inner { + next_taskid: u64, + tasks: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct TaskStore { + inner: Arc>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TaskSnapshot { + pub tasks: Vec, +} + +impl TaskStore { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(Inner { + next_taskid: 1, + tasks: Vec::new(), + })), + } + } + + pub fn create(&self, subject: String, description: String) -> TaskEntry { + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let task = TaskEntry { + taskid: inner.next_taskid, + status: TaskStatus::Pending, + subject, + description, + }; + inner.next_taskid = inner.next_taskid.saturating_add(1); + inner.tasks.push(task.clone()); + task + } + + pub fn list(&self) -> Vec { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .tasks + .clone() + } + + pub fn get(&self, taskid: u64) -> Option { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .tasks + .iter() + .find(|t| t.taskid == taskid) + .cloned() + } + + pub fn update( + &self, + taskid: u64, + status: Option, + subject: Option, + description: Option, + ) -> Result { + if status.is_none() && subject.is_none() && description.is_none() { + return Err(TaskStoreError::NoFields); + } + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let task = inner + .tasks + .iter_mut() + .find(|t| t.taskid == taskid) + .ok_or(TaskStoreError::Missing(taskid))?; + if let Some(status) = status { + task.status = status; + } + if let Some(subject) = subject { + task.subject = subject; + } + if let Some(description) = description { + task.description = description; + } + Ok(task.clone()) + } + + pub fn snapshot(&self) -> TaskSnapshot { + TaskSnapshot { tasks: self.list() } + } + + pub fn replay_history(&self, history: &[Item]) { + for item in history { + match item { + Item::Message { content, .. } => { + for part in content { + let text = part.as_text(); + if let Some(snapshot) = parse_compact_snapshot_text(text) { + self.replace_with(snapshot); + } + } + } + Item::ToolCall { + name, arguments, .. + } => match name.as_str() { + "TaskCreate" => { + if let Ok(params) = serde_json::from_str::(arguments) { + let _ = self.create(params.subject, params.description); + } + } + "TaskUpdate" => { + if let Ok(params) = serde_json::from_str::(arguments) { + let _ = self.update( + params.taskid, + params.status, + params.subject, + params.description, + ); + } + } + _ => {} + }, + _ => {} + } + } + } + + pub fn replace_with(&self, tasks: Vec) { + let next_taskid = tasks + .iter() + .map(|t| t.taskid) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1); + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.tasks = tasks; + inner.next_taskid = next_taskid; + } + + pub fn from_history(history: &[Item]) -> Self { + let store = Self::new(); + store.replay_history(history); + store + } + + pub fn snapshot_text(&self) -> String { + render_snapshot(&self.list()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskStoreError { + Missing(u64), + NoFields, +} + +impl std::fmt::Display for TaskStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Missing(id) => write!(f, "taskid {id} not found"), + Self::NoFields => { + f.write_str("at least one of status, subject, description is required") + } + } + } +} + +impl std::error::Error for TaskStoreError {} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TaskCreateParams { + /// One-line task subject. + subject: String, + /// Detailed task description. + description: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TaskListParams {} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TaskGetParams { + taskid: u64, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TaskUpdateParams { + taskid: u64, + #[serde(default)] + status: Option, + #[serde(default)] + subject: Option, + #[serde(default)] + description: Option, +} + +struct TaskCreateTool { + store: TaskStore, +} + +struct TaskListTool { + store: TaskStore, +} + +struct TaskGetTool { + store: TaskStore, +} + +struct TaskUpdateTool { + store: TaskStore, +} + +const CREATE_DESCRIPTION: &str = "Create a session-lifetime task. Input only `subject` and \ +`description`; `taskid` is assigned automatically and initial `status` is `pending`."; +const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \ +deleted entries. Takes an empty object as input."; +const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Returns an error if \ +the task does not exist."; +const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task. Provide `taskid` and \ +at least one of `status`, `subject`, or `description`. `status` must be one of `pending`, \ +`inprogress`, `completed`, or `deleted`; deletion is logical (`status = deleted`)."; + +#[async_trait] +impl Tool for TaskCreateTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TaskCreateParams = serde_json::from_str(input_json) + .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?; + let created = self.store.create(params.subject, params.description); + let tasks = self.store.list(); + Ok(task_output( + format!( + "Created task {} ({})\n{}", + created.taskid, + created.status, + snapshot_overview(&tasks) + ), + &created, + &tasks, + )) + } +} + +#[async_trait] +impl Tool for TaskListTool { + async fn execute(&self, input_json: &str) -> Result { + let _: TaskListParams = serde_json::from_str(input_json) + .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?; + let tasks = self.store.list(); + Ok(ToolOutput { + summary: snapshot_overview(&tasks), + content: Some(render_snapshot(&tasks)), + }) + } +} + +#[async_trait] +impl Tool for TaskGetTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TaskGetParams = serde_json::from_str(input_json) + .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskGet input: {e}")))?; + let task = self.store.get(params.taskid).ok_or_else(|| { + ToolError::ExecutionFailed(format!("taskid {} not found", params.taskid)) + })?; + let content = serde_json::to_string_pretty(&task).unwrap_or_else(|_| format!("{task:?}")); + Ok(ToolOutput { + summary: format!("Task {} ({}) {}", task.taskid, task.status, task.subject), + content: Some(content), + }) + } +} + +#[async_trait] +impl Tool for TaskUpdateTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TaskUpdateParams = serde_json::from_str(input_json) + .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskUpdate input: {e}")))?; + let updated = self + .store + .update( + params.taskid, + params.status, + params.subject, + params.description, + ) + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let tasks = self.store.list(); + Ok(task_output( + format!( + "Updated task {} ({})\n{}", + updated.taskid, + updated.status, + snapshot_overview(&tasks) + ), + &updated, + &tasks, + )) + } +} + +fn task_output(summary: String, task: &TaskEntry, tasks: &[TaskEntry]) -> ToolOutput { + let content = serde_json::json!({ + "task": task, + "snapshot": { "tasks": tasks }, + }); + ToolOutput { + summary, + content: Some(serde_json::to_string_pretty(&content).unwrap_or_default()), + } +} + +pub fn snapshot_overview(tasks: &[TaskEntry]) -> String { + let pending = tasks + .iter() + .filter(|t| t.status == TaskStatus::Pending) + .count(); + let inprogress = tasks + .iter() + .filter(|t| t.status == TaskStatus::Inprogress) + .count(); + let completed = tasks + .iter() + .filter(|t| t.status == TaskStatus::Completed) + .count(); + let deleted = tasks + .iter() + .filter(|t| t.status == TaskStatus::Deleted) + .count(); + format!( + "TaskStore: {} task(s) (pending: {pending}, inprogress: {inprogress}, completed: {completed}, deleted: {deleted})", + tasks.len() + ) +} + +pub fn render_snapshot(tasks: &[TaskEntry]) -> String { + let snapshot = TaskSnapshot { + tasks: tasks.to_vec(), + }; + let json = serde_json::to_string_pretty(&snapshot) + .unwrap_or_else(|_| String::from("{\"tasks\":[]}")); + format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json) +} + +fn parse_compact_snapshot_text(text: &str) -> Option> { + if !text.starts_with("[Session TaskStore snapshot]") { + return None; + } + let start_marker = "```json\n"; + let end_marker = "\n```"; + let start = text.find(start_marker)? + start_marker.len(); + let rest = &text[start..]; + let end = rest.find(end_marker)?; + let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?; + Some(snapshot.tasks) +} + +fn task_create_tool(store: TaskStore) -> ToolDefinition { + Arc::new(move || { + let schema = schemars::schema_for!(TaskCreateParams); + let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); + let meta = ToolMeta::new("TaskCreate") + .description(CREATE_DESCRIPTION) + .input_schema(schema_value); + let tool: Arc = Arc::new(TaskCreateTool { + store: store.clone(), + }); + (meta, tool) + }) +} + +fn task_list_tool(store: TaskStore) -> ToolDefinition { + Arc::new(move || { + let schema = schemars::schema_for!(TaskListParams); + let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); + let meta = ToolMeta::new("TaskList") + .description(LIST_DESCRIPTION) + .input_schema(schema_value); + let tool: Arc = Arc::new(TaskListTool { + store: store.clone(), + }); + (meta, tool) + }) +} + +fn task_get_tool(store: TaskStore) -> ToolDefinition { + Arc::new(move || { + let schema = schemars::schema_for!(TaskGetParams); + let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); + let meta = ToolMeta::new("TaskGet") + .description(GET_DESCRIPTION) + .input_schema(schema_value); + let tool: Arc = Arc::new(TaskGetTool { + store: store.clone(), + }); + (meta, tool) + }) +} + +fn task_update_tool(store: TaskStore) -> ToolDefinition { + Arc::new(move || { + let schema = schemars::schema_for!(TaskUpdateParams); + let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); + let meta = ToolMeta::new("TaskUpdate") + .description(UPDATE_DESCRIPTION) + .input_schema(schema_value); + let tool: Arc = Arc::new(TaskUpdateTool { + store: store.clone(), + }); + (meta, tool) + }) +} + +pub fn task_tools(store: TaskStore) -> Vec { + vec![ + task_create_tool(store.clone()), + task_list_tool(store.clone()), + task_get_tool(store.clone()), + task_update_tool(store), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(def: ToolDefinition) -> Arc { + let (_, tool) = def(); + tool + } + + #[tokio::test] + async fn task_tools_create_list_get_update() { + let store = TaskStore::new(); + let create = tool(task_create_tool(store.clone())); + let list = tool(task_list_tool(store.clone())); + let get = tool(task_get_tool(store.clone())); + let update = tool(task_update_tool(store.clone())); + + let out = create + .execute(r#"{"subject":"implement","description":"write code"}"#) + .await + .unwrap(); + assert!(out.summary.contains("Created task 1")); + assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending); + + let out = update + .execute(r#"{"taskid":1,"status":"inprogress","subject":"implement tasks"}"#) + .await + .unwrap(); + assert!(out.summary.contains("Updated task 1")); + let task = store.get(1).unwrap(); + assert_eq!(task.status, TaskStatus::Inprogress); + assert_eq!(task.subject, "implement tasks"); + + let out = get.execute(r#"{"taskid":1}"#).await.unwrap(); + assert!(out.summary.contains("Task 1 (inprogress)")); + assert!(out.content.unwrap().contains("implement tasks")); + + let out = list.execute("{}").await.unwrap(); + assert!(out.summary.contains("1 task(s)")); + let content = out.content.unwrap(); + assert!(content.contains("\"taskid\": 1")); + assert!(content.contains("```json")); + } + + #[tokio::test] + async fn task_update_validates_existing_and_at_least_one_field() { + let store = TaskStore::new(); + store.create("s".into(), "d".into()); + let update = tool(task_update_tool(store)); + + let err = update.execute(r#"{"taskid":1}"#).await.unwrap_err(); + assert!(err.to_string().contains("at least one")); + + let err = update + .execute(r#"{"taskid":99,"status":"deleted"}"#) + .await + .unwrap_err(); + assert!(err.to_string().contains("taskid 99 not found")); + } + + #[test] + fn replay_history_reconstructs_store_and_ignores_malformed_calls() { + let history = vec![ + Item::tool_call("c1", "TaskCreate", r#"{"subject":"a","description":"A"}"#), + Item::tool_call("bad", "TaskCreate", r#"{"subject":1}"#), + Item::tool_call("c2", "TaskCreate", r#"{"subject":"b","description":"B"}"#), + Item::tool_call("u1", "TaskUpdate", r#"{"taskid":2,"status":"completed"}"#), + Item::tool_call("bad2", "TaskUpdate", r#"{"taskid":99,"status":"deleted"}"#), + ]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].taskid, 1); + assert_eq!(tasks[0].status, TaskStatus::Pending); + assert_eq!(tasks[1].taskid, 2); + assert_eq!(tasks[1].status, TaskStatus::Completed); + } + + /// Wrap snapshot text the way `Pod::try_post_run_compact` does, so tests + /// exercise the exact format that goes through the session log. + fn wrap_snapshot_system_message(snapshot: &str) -> String { + format!( + "[Session TaskStore snapshot]\n\n{snapshot}\n\n\ + This is the complete session task list preserved across compaction. \ + The following TaskList tool result presents the same state through the tool lane." + ) + } + + #[test] + fn replay_history_uses_compact_snapshot_and_continues_updates() { + let pre = TaskStore::new(); + pre.create("kept".into(), "from compact".into()); + pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap(); + let history = vec![ + Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())), + Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#), + Item::tool_call( + "c2", + "TaskCreate", + r#"{"subject":"new","description":"after compact"}"#, + ), + ]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].taskid, 1); + assert_eq!(tasks[0].status, TaskStatus::Completed); + assert_eq!(tasks[1].taskid, 2); + assert_eq!(tasks[1].subject, "new"); + } + + #[test] + fn trailing_snapshot_supersedes_pre_compact_taskcreates_in_retained() { + // Mirrors the post-compact layout: pre-compact `TaskCreate` calls are + // preserved verbatim in retained_items, and the snapshot trails them. + // The trailing snapshot must reset the store to the captured state so + // pre-compact `TaskCreate`s do not surface as duplicates. + let pre = TaskStore::new(); + pre.create("A".into(), "A-desc".into()); + pre.update(1, Some(TaskStatus::Completed), None, None).unwrap(); + pre.create("B".into(), "B-desc".into()); + pre.update(2, Some(TaskStatus::Inprogress), None, None).unwrap(); + let history = vec![ + Item::tool_call("c1", "TaskCreate", r#"{"subject":"A","description":"A-desc"}"#), + Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#), + Item::tool_call("c2", "TaskCreate", r#"{"subject":"B","description":"B-desc"}"#), + Item::tool_call("u2", "TaskUpdate", r#"{"taskid":2,"status":"inprogress"}"#), + Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())), + Item::tool_call("compact-tasklist", "TaskList", "{}"), + Item::tool_call( + "c3", + "TaskCreate", + r#"{"subject":"C","description":"after compact"}"#, + ), + ]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 3); + assert_eq!(tasks[0].taskid, 1); + assert_eq!(tasks[0].subject, "A"); + assert_eq!(tasks[0].status, TaskStatus::Completed); + assert_eq!(tasks[1].taskid, 2); + assert_eq!(tasks[1].subject, "B"); + assert_eq!(tasks[1].status, TaskStatus::Inprogress); + assert_eq!(tasks[2].taskid, 3); + assert_eq!(tasks[2].subject, "C"); + } + + #[test] + fn snapshot_round_trips_multiline_subject_and_description() { + // Subject / description with embedded newlines and shape-breaking + // characters must survive snapshot serialization unchanged. + let pre = TaskStore::new(); + pre.create( + "subject with\nembedded newline\n- bullet".into(), + "desc:\n status: not-actually-a-field\n ```code fence```".into(), + ); + pre.update(1, Some(TaskStatus::Inprogress), None, None).unwrap(); + + let history = vec![Item::system_message(wrap_snapshot_system_message( + &pre.snapshot_text(), + ))]; + let store = TaskStore::from_history(&history); + let tasks = store.list(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].subject, "subject with\nembedded newline\n- bullet"); + assert_eq!( + tasks[0].description, + "desc:\n status: not-actually-a-field\n ```code fence```" + ); + assert_eq!(tasks[0].status, TaskStatus::Inprogress); + } + + #[test] + fn synthetic_compact_tasklist_pair_is_well_formed() { + // Mirrors `Pod::try_post_run_compact`'s synthetic insertion: + // a system snapshot message followed by a TaskList tool_call/tool_result + // pair sharing the `compact-tasklist` id. Verify the structural + // contract every provider request builder relies on (matched call_id, + // tool name, content recoverable to the same TaskStore state). + let pre = TaskStore::new(); + pre.create("plan".into(), "do A then B".into()); + + let snapshot_text = pre.snapshot_text(); + let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text)); + let call = Item::tool_call("compact-tasklist", "TaskList", "{}"); + let result = Item::tool_result_with_content( + "compact-tasklist", + snapshot_overview(&pre.list()), + snapshot_text.clone(), + ); + + // The system message embeds a parseable snapshot. + let extracted = system + .as_text() + .and_then(parse_compact_snapshot_text) + .expect("system message should parse as snapshot"); + assert_eq!(extracted, pre.list()); + + // The synthetic call/result pair shares one call_id and carries the + // expected tool name + detailed content. + match (&call, &result) { + ( + Item::ToolCall { + call_id: c_id, + name, + .. + }, + Item::ToolResult { + call_id: r_id, + content, + .. + }, + ) => { + assert_eq!(c_id.as_str(), r_id.as_str()); + assert_eq!(c_id.as_str(), "compact-tasklist"); + assert_eq!(name, "TaskList"); + assert_eq!(content.as_deref(), Some(snapshot_text.as_str())); + } + other => panic!("unexpected synthetic pair shape: {other:?}"), + } + + // Replaying the full triple reconstructs the same TaskStore. + let store = TaskStore::from_history(&[system, call, result]); + assert_eq!(store.list(), pre.list()); + } +} diff --git a/crates/tools/src/tracker.rs b/crates/tools/src/tracker.rs index 36671ecd..2d4b6973 100644 --- a/crates/tools/src/tracker.rs +++ b/crates/tools/src/tracker.rs @@ -32,7 +32,8 @@ //! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime //! let tracker = Tracker::new(); // session lifetime //! let bash_outputs = PathBuf::from("/run/insomnia/bash-output"); -//! let defs = builtin_tools(fs, tracker, bash_outputs); +//! let task_store = tools::TaskStore::new(); +//! let defs = builtin_tools(fs, tracker, task_store, bash_outputs); //! ``` use std::collections::{HashMap, VecDeque}; diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 5f96fef7..0c9c70ac 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -6,7 +6,7 @@ use llm_worker::tool::{Tool, ToolDefinition}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use serde_json::json; use tempfile::TempDir; -use tools::{ScopedFs, Tracker, builtin_tools}; +use tools::{ScopedFs, TaskStore, Tracker, builtin_tools}; struct Registry { entries: Vec<(llm_worker::tool::ToolMeta, Arc)>, @@ -43,7 +43,12 @@ fn setup() -> (TempDir, TempDir, Registry) { let scope = Scope::from_config(&config).unwrap(); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let tracker = Tracker::new(); - let reg = Registry::new(builtin_tools(fs, tracker, spill.path().to_path_buf())); + let reg = Registry::new(builtin_tools( + fs, + tracker, + TaskStore::new(), + spill.path().to_path_buf(), + )); (dir, spill, reg) } diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index 328eea18..9ab3cb68 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -11,7 +11,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolMeta}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use serde_json::json; use tempfile::TempDir; -use tools::{ScopedFs, Tracker, builtin_tools}; +use tools::{ScopedFs, TaskStore, Tracker, builtin_tools}; fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { let base = Scope::writable(workspace).unwrap(); @@ -56,7 +56,12 @@ fn setup() -> (TempDir, TempDir, Registry) { let scope = scope_with_spill(dir.path(), spill.path()); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let tracker = Tracker::new(); - let reg = Registry::new(builtin_tools(fs, tracker, spill.path().to_path_buf())); + let reg = Registry::new(builtin_tools( + fs, + tracker, + TaskStore::new(), + spill.path().to_path_buf(), + )); (dir, spill, reg) } @@ -77,7 +82,21 @@ fn builtin_tools_registers_full_set() { let (_dir, _spill, reg) = setup(); let mut names = reg.names(); names.sort(); - assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); + assert_eq!( + names, + vec![ + "Bash", + "Edit", + "Glob", + "Grep", + "Read", + "TaskCreate", + "TaskGet", + "TaskList", + "TaskUpdate", + "Write" + ] + ); } #[test] @@ -270,16 +289,41 @@ async fn edit_requires_read_across_tools() { #[tokio::test] async fn deterministic_tool_order_is_registration_order() { let (_dir, _spill, reg) = setup(); - // Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash + // Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, TaskCreate, TaskList, TaskGet, TaskUpdate let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); - assert_eq!(names, vec!["Read", "Write", "Edit", "Glob", "Grep", "Bash"]); + assert_eq!( + names, + vec![ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "TaskCreate", + "TaskList", + "TaskGet", + "TaskUpdate" + ] + ); } // Regression: tool name capitalization matches Claude Code reference #[test] fn tool_names_match_reference_spec() { let (_dir, _spill, reg) = setup(); - for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { + for expected in [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "TaskCreate", + "TaskList", + "TaskGet", + "TaskUpdate", + ] { assert!( reg.entries.iter().any(|(m, _)| m.name == expected), "missing tool {expected}" @@ -295,7 +339,12 @@ async fn tracker_recent_files_tracks_read_write_edit() { let scope = scope_with_spill(dir.path(), spill.path()); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let tracker = Tracker::new(); - let reg = Registry::new(builtin_tools(fs, tracker.clone(), spill.path().to_path_buf())); + let reg = Registry::new(builtin_tools( + fs, + tracker.clone(), + TaskStore::new(), + spill.path().to_path_buf(), + )); let a = dir.path().join("a.txt"); let b = dir.path().join("b.txt"); @@ -379,7 +428,10 @@ async fn bash_spilled_file_is_readable_via_read_tool() { let read_body = read_out.content.expect("Read returned content"); // The full 200 lines should be in the saved file even though Bash // returned only the tail of 80. - assert!(read_body.contains("line 1\n"), "missing line 1: {read_body}"); + assert!( + read_body.contains("line 1\n"), + "missing line 1: {read_body}" + ); assert!(read_body.contains("line 200"), "missing line 200"); } diff --git a/tickets/session-todo-reminder.md b/tickets/session-todo-reminder.md new file mode 100644 index 00000000..1847cb4f --- /dev/null +++ b/tickets/session-todo-reminder.md @@ -0,0 +1,67 @@ +# セッション内 Task ツールの注意機構 + +## 背景 + +`tickets/session-todo.md` で導入する Task ツール群があっても、LLM はそれを使わずに作業を続け得る。ツールを呼ばないまま会話が長引くと、 + +- 開始した作業の `inprogress` がずっと放置されたままになる +- 「やったつもり」になって `completed` への更新を忘れる +- そもそも TaskStore の存在を忘れて、構造化を諦めて自由記述に回帰する + +OpenCode の todo は専用の注意機構を持たない(汎用 reminder 経由)。一方 Claude Code は `task_reminder` を「N リクエスト無アクティビティで初めて発火するナッジ型」として実装しており、毎リクエスト押し戻しはしない(`/home/hare/.local/share/claude/versions/2.x` の `du_` / `cu_` 関数、閾値 `Z_8 = { TURNS_SINCE_WRITE: 10, TURNS_BETWEEN_REMINDERS: 10 }`)。 + +Insomnia でも同方針を採り、active Task が残っているのに `TaskCreate` / `TaskUpdate` が一定リクエスト呼ばれていない場合に限り、`` で揮発的に思い出させる。「やったつもり」抑止と、トークン浪費・LLM の自律性侵害のバランスを取るため、毎リクエスト押し戻しはしない。 + +## 前提 + +- `tickets/session-todo.md` の TaskStore と `TaskCreate` / `TaskUpdate` / `TaskList` / `TaskGet` ツールが利用可能であること +- `pre_llm_request` 相当のフックを Pod が持つこと(無ければ本ticket で導入) + +## 方針 + +- **`pre_llm_request` Interceptor として実装**。直近の user message に `` ブロックを揮発的に append するだけ。履歴・ログには載せない +- **system-reminder 注入の汎用化はやらない**。利用者が Task 1機構しかない段階で抽象を立てない(CLAUDE.md「概念の追加は不在が問題になってから」)。ただし「タグ形式は `...` で揃える」「履歴は汚さない」の2点は本実装で確立し、将来の追加機構が同じ規約に乗れるようにする +- **発火はナッジ型**。N リクエスト無アクティビティで初めて発火し、cooldown も持つ + +## 要件 + +### Interceptor + +- `pre_llm_request` で `Vec` を受け取り、以下の AND を満たした場合のみ発動 + - active Task(`pending` または `inprogress`)が1件以上存在する + - 直近 N リクエスト (暫定 N=8) `TaskCreate` / `TaskUpdate` のいずれも呼ばれていない + - 前回 reminder 注入から M リクエスト (暫定 M=8) 以上経過 +- ここで言う「リクエスト」は **LLM への1回の推論呼び出し (= assistant 応答1回)** の単位で数える。ユーザー発火単位ではない。1ユーザー発火内で tool ループが回れば、tool_result を受けて発火する次のリクエストもそれぞれ1としてカウントする +- カウント対象はメインスレッドの assistant 応答に限る。サブエージェント / sidechain の assistant 応答は除外する +- カウンタは Pod 側の session-lifetime 状態として保持する(`requests_since_last_task_management` / `requests_since_last_reminder`)。resume 時は履歴の逆走査で再計算するか 0 リセットで再開する。どちらでも「初回ナッジが最大 N リクエスト遅れる」だけで挙動として致命ではない +- 発動時、直近の user message の content(または content[最終 text part])の末尾に `` ブロックを append し、現在の active Task リストを `taskid` / `status` / `subject` を含む簡潔な形式で列挙する。`description` は長大化を避けるため省略してよい +- 履歴 (`Worker` の保持する `Vec`) は変更しない。リクエスト送信時の Vec のみ加工する +- active Task が空の場合は何も差し込まない(忘却防止が目的なので、思い出させる対象が無いなら不要) + +## 完了条件 + +- 直近 N リクエスト連続で `TaskCreate` / `TaskUpdate` が呼ばれず、かつ active Task が残っている場合に限り、`pre_llm_request` で `` が直近 user message に append される +- `TaskCreate` / `TaskUpdate` のいずれかが呼ばれるとカウンタがリセットされ、再び N リクエスト経過するまでは reminder が出ない +- reminder が一度出たあとは、cooldown M リクエストが経過するまで再注入されない +- active Task が0件の場合は reminder が出ない +- system-reminder の注入は揮発的で、`get_history` / セッションログには現れない +- 単体テストで Interceptor の発火条件(リクエスト回数閾値、active 0件、cooldown、サブエージェント除外)がカバーされる + +## 範囲外 + +- inprogress 滞留検出 / 多重 inprogress 検出など、状態異常ベースの追加トリガ(必要になれば別チケットで追加) +- system-reminder 注入機構の汎用化(`TODO.md` に立項済み、別途検討) +- `TaskCreate` / `TaskUpdate` の戻り値に active Task 全件を埋め込む強化(必要に応じて Tool ticket 側で対応) + +## 参照 + +- 設計指針: `CLAUDE.md`(最小の構造化 / 概念の追加は不在が問題になってから) +- 前提: `tickets/session-todo.md`(Tool 群と TaskStore) +- 参考実装: Claude Code の `task_reminder`(`du_` / `cu_` 関数、閾値 `Z_8 = { TURNS_SINCE_WRITE: 10, TURNS_BETWEEN_REMINDERS: 10 }`) + +## Review + +- 状態: Approve (spec 段階) +- レビュー詳細: [./session-todo-reminder.review.md](./session-todo-reminder.review.md) +- 日付: 2026-05-03 +- 補足: 実装着手前に Non-blocking で挙げた 4 点 (TaskCreate/Update のカウンタリセット契機 / active 取得経路 / reminder 本文 fmt / resume 時のカウンタ扱い) を spec に追記してから実装するとブレが減る。実装完了時に完了条件を再確認。 diff --git a/tickets/session-todo-reminder.review.md b/tickets/session-todo-reminder.review.md new file mode 100644 index 00000000..15cd447c --- /dev/null +++ b/tickets/session-todo-reminder.review.md @@ -0,0 +1,41 @@ +# Review: セッション内 Task ツールの注意機構 + +対象: `tickets/session-todo-reminder.md` (`28fe1da` で新規作成)。実装は未着手 (本レビュー時点でコード変更なし、worktree 内 grep で `task_reminder` / `requests_since_last_task` 等いずれもヒットせず)。 + +本レビューは spec 単独レビュー (実装が始まった時点で完了条件を再確認)。 + +## 前提・要件の確認 (spec の妥当性) + +- 「`pre_llm_request` で `Vec` を受けて直近 user message に `` を append する」は、`crates/pod/src/ipc/interceptor.rs` の既存 `pre_llm_request` レーンに自然に乗る。Worker history を変更せず、リクエスト送信用 `Vec` のみ加工する方針は、既存の `pending_notifies.drain()` と同じパターンで先例あり (`interceptor.rs:147-159`)。spec として実装可能性は高い。 +- TaskStore からの active 件数取得には A の `TaskStore::list()` だけで足り、追加 API 不要。 +- カウンタ (`requests_since_last_task_management` / `requests_since_last_reminder`) を Pod の session-lifetime 状態として持つ方針は、既存 `tool_calls_this_turn: AtomicUsize` (`interceptor.rs`) と同じ素地に追加できる。pre_tool_call で `TaskCreate` / `TaskUpdate` を観測したら片方をリセット、`pre_llm_request` で発火条件を見て両方を増やす、という骨格になる想定。spec の粒度は問題ない。 +- メインスレッド限定 / sub-agent 除外: 主要 worker と spawn pod / compact worker を分離している現実装では「メインの `PodInterceptor` だけがカウンタを持つ」で達成できる。compact worker 側は別 Worker / 別 Interceptor。spec の前提は現実装と一致。 +- resume 時のカウンタ復元: 「履歴の逆走査で再計算 or 0 リセット」のどちらでも実害無しと spec 側で割り切られているのは妥当。0 リセットの方が大幅にシンプルで、初回ナッジが最大 N 遅れるだけ。実装時はこちらを推奨したい (spec 側は両許容で問題なし)。 +- 「揮発的 = 履歴を汚さない」「タグ形式 `...` で揃える」「2 件目の利用者が出た時に汎用化を検討」の 3 点を明文化していて、A と B の隙間に「汎用 reminder 機構」の中途半端な抽象が立たないようにしている。CLAUDE.md の「概念の追加は不在が問題になってから」と整合。 + +## アーキテクチャ・スコープ + +- B は A に対する後続チケットとして適切に分離されている。前提依存 (TaskStore + Task* tools) を `## 前提` に明記しており、A 完了後に着手するという順序付けも自然。 +- 範囲外 (inprogress 滞留 / 多重 inprogress / 注入機構汎用化 / Tool 戻り値の active 全件埋め込み) が列挙されており、本チケットで肥大化しないラインが引けている。 +- 「Pod 側に session-lifetime カウンタを増やす」程度の侵襲で、既存の `crates/pod/src/ipc/interceptor.rs` 内に追加実装が収まる規模感。クレート構成や層境界に新しい歪みは生じない見込み。 +- 「N=8 / M=8 暫定」と数値を明示しているのは判断しやすい。Claude Code が `Z_8 = { TURNS_SINCE_WRITE: 10, TURNS_BETWEEN_REMINDERS: 10 }` という参照値を持つことも書かれており、後で詰めやすい。 + +## 指摘事項 + +### Non-blocking / Follow-up (spec 段階の論点) + +- **「リクエスト = LLM への 1 回の推論呼び出し」のカウント単位が、現 `Interceptor` の `pre_llm_request` 1 呼び出しと一致するか確認が必要。** 現実装の `pre_llm_request` は context 構築直前に必ず通るレーンで、tool ループ内の続行 LLM 呼び出しでも毎回呼ばれる。実装時に「pre_llm_request 突入時刻 = LLM へのリクエスト 1 件」が成立しているかを軽く検証するテストを1本入れてほしい (tool ループ中の発火 cadence のため)。 +- **TaskCreate / TaskUpdate のカウンタリセット契機。** 現状 spec は「呼ばれていない」と書かれているのみ。実装ではどのフックで観測するかの選択 (`pre_tool_call` か `post_tool_call` か) を決めて spec に追記しておくと実装ブレが減る。`pre_tool_call` で名前判定 + リセット、で十分。 +- **active Task の取り出し経路。** spec 上は明示されていないが、Interceptor が TaskStore handle を保持する形になる (Pod 経由で渡す)。`PodInterceptor::new` 等のコンストラクタに `Option` を増やす想定で良いと思うが、実装時に `Tracker` の例 (`attach_tracker`) と同様、Pod から Interceptor への手渡しを統一してほしい。 +- **resume 時の発火タイミング。** 0 リセットを採るなら、resume 直後の最初のリクエストでは出ない。これは spec 上「最大 N 遅れるだけ」と許容済み。実装時は単にこの選択を README/コメントに残してほしい。 +- **`` の本文。** spec は「taskid / status / subject を簡潔に列挙」と書かれているが、A 側の `render_snapshot` を流用するか、reminder 専用に別関数を切るかが未決定。短さを優先するなら専用 fmt が望ましい (description を必ず落とすため)。実装方針を spec に1行追記しておくと安全。 +- **テスト範囲。** spec の完了条件にある「リクエスト回数閾値 / active 0件 / cooldown / サブエージェント除外」は単体テストでカバー可能。実装時はそれぞれ独立ケースで書いてほしい。 + +### Nits + +- 数値 N / M が「暫定 N=8」「暫定 M=8」と明記されているが、変更可能性についてのフォローアップ条項 (例: 運用後に閾値を見直す) があると親切。 +- Claude Code 側の参照値 `Z_8 = { TURNS_SINCE_WRITE: 10, TURNS_BETWEEN_REMINDERS: 10 }` は spec に書かれているので、Insomnia 側 8 を採る理由 (短めにして気付かせやすくする等) を一文添えると将来 tuning しやすい。 + +## 判断 + +**Approve (spec 段階)** — 前提・要件・範囲外の切り分けが妥当で、A の実装規約 (TaskStore handle、揮発的注入、タグ形式) に乗る形で実装可能。アーキテクチャ的歪みも生じない見込み。実装着手前に上記 Non-blocking 4 点 (カウンタリセット契機 / active 取得経路 / 本文 fmt / resume 時のリセット選択) を spec に1〜2行追記してから実装に入ると、実装中の判断ブレが減る。実装完了時に完了条件を再確認する必要がある (本レビューは spec 妥当性のみの判定)。 diff --git a/tickets/session-todo.md b/tickets/session-todo.md deleted file mode 100644 index 6643db08..00000000 --- a/tickets/session-todo.md +++ /dev/null @@ -1,74 +0,0 @@ -# セッション内 TODO ツール - -## 背景 - -長めのタスクを LLM に進めさせる際、Claude Code / OpenCode が備える「セッション内 TODO リスト」相当の機構が無いため、エージェントが自分の作業計画を構造化された形で保持・更新できない。Reasoning や text 出力の中で擬似的に TODO を書くことはできるが、 - -- ターンを跨いだとき直近の TODO 状態が context から押し出される -- compact を跨ぐと完全に消える -- ツール結果ではないため、状態の上書き・部分更新の規約が決まらず、意図と乖離した「やったつもり」を引き起こす - -この用途のために、セッション内に正規化された TODO リストを保持し、ターンごとに LLM へ最新状態を再提示(注意機構)し、compact を跨いで保存される専用ツールを導入する。 - -## 方針 - -- **保存先は `tools` 層の session-lifetime 状態**。`Tracker` と同じ生存スコープで `Pod` が所有。`Arc>>` ベースの `TodoStore` を tool に注入する -- **永続化は専用レーンを持たない**。`tool_call.arguments` がセッションログに既に乗っているため、resume 時には履歴 replay の中で最後の `todo_write` 引数を `TodoStore` に再適用すれば状態が復元される -- **注意機構は `Interceptor::pending_history_appends`**。未完了 TODO がある場合に新規 system message Item として `worker.history` に append する。Notify / PodEvent と同じ lane に乗せ、`history.json` への永続化と resume 後の読み戻しは worker.history 経由で自動的についてくる(→ `tickets/notify-history-persist.md`) -- **system-reminder 注入の汎用化はやらない**。利用者が TODO 1個しかない段階で抽象を立てない(CLAUDE.md「概念の追加は不在が問題になってから」)。ただし「タグ形式は `...` で揃える」点は本実装で確立し、将来の追加機構が同じ規約に乗れるようにする - -## 要件 - -### `todo_write` ツール - -- 入力は TODO リスト全体(全置換)。差分更新は受けない -- 各エントリは `id` / `content` / `status (pending | in_progress | completed)` の 3 フィールド -- `id` は LLM 側が一貫して採番できる文字列。同 id があれば置換、なければ新規。順序は配列順を信頼 -- 戻り値は更新後のスナップショットを summary に含める(次ターンで再確認可能) -- 読み出し専用ツール(`todo_read`)は作らない。注意機構と tool result snapshot で代替 - -### Resume 時の復元 - -- `Pod::resume` の履歴 replay 中に `todo_write` の `tool_call.arguments` を観測したら、`TodoStore` を引数値で上書き -- 専用 LogEntry / Persistence 型は追加しない(`Tracker` と同じ方針) -- `tool_call.arguments` のフォーマットが `todo_write` の引数 schema と乖離した場合(旧バージョンのログ)は、その call を無視してよい - -### Compact 跨ぎ - -- compact 起動時、Pod は現在の `TodoStore` スナップショットを compact worker context に渡す -- compact worker は summary を書く際、未完了 TODO を summary 文に取り込める情報源として参照する(強制ではない) -- compact 後の新セッション開始時、Pod は **`mark_read_required` と同じ system message 注入レーン**に「未完了 TODO スナップショット」を 1 メッセージとして注入する -- 新セッションは空の `TodoStore` で始まる。次に LLM が `todo_write` を呼び出した時点で再構築される(system message に書かれたスナップショットがその拠り所) -- compact worker に TODO 編集権限は与えない(消去・縮約はしない) - -### 注意機構(Interceptor) - -- `pending_history_appends` で未完了 TODO(`pending` または `in_progress`)が 1 件でも存在する場合に発動し、`` ブロックを含む新規 system message Item を返す -- Worker はこれを `worker.history` に append し、その後の per-request clone でリクエストにも含める。永続化 / resume / compaction は通常 Item と同じ扱い -- ブロック内には現在の TODO リストを、status を含む簡潔な形式で列挙する -- TODO が空の場合は空の `Vec` を返し、何も差し込まない -- cooldown は idle 期間に1回 + 反応で counter リセットの設計上、reminder の連続注入は構造的に起きない(仮に複数回出ても、それぞれが「その時点での active TODO snapshot」として履歴に並ぶのは因果として正しい) - -## 完了条件 - -- `todo_write` ツールが builtin tool として登録され、Pod で利用できる -- LLM が `todo_write` を呼ぶと TodoStore が更新され、その後の `pending_history_appends` で system-reminder Item として `worker.history` に append され、リクエストにも含まれる -- セッションを resume すると、最後の `todo_write` の状態から再開される -- compact を跨いでも、未完了 TODO が新セッション冒頭の system message として残る -- 注入された system-reminder Item は `worker.history` / `history.json` / `get_history` のいずれにも現れる(揮発レーンは持たない方針 → `tickets/notify-history-persist.md`) -- 単体テストで `todo_write` の更新挙動 / replay 復元 / Interceptor の差し込みがカバーされる - -## 範囲外 - -- 差分更新 API(add / remove / patch)。全置換のみで十分 -- TODO 階層・優先度・タグ -- TUI / GUI での TODO 状態の可視化(ツール呼び出しのイベントは既に流れているので、クライアント側で表示するかは別軸) -- system-reminder 注入機構の汎用化(`TODO.md` に立項済み、別途検討) -- TODO の永続化を専用 LogEntry に分離する設計(現方針は tool_call replay で復元、追加レーン不要) -- 複数 Pod 間で TODO を共有する仕組み - -## 参照 - -- 設計指針: `CLAUDE.md`(最小の構造化 / 概念の追加は不在が問題になってから) -- 参考実装: Claude Code の TodoWrite、OpenCode の todo tool -- 関連: `crates/tools/src/tracker.rs`(session-lifetime 状態の前例)、`crates/pod/src/compact/worker.rs`(auto-injection レーン)