Compare commits

..

7 Commits

12 changed files with 944 additions and 94 deletions

View File

@ -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 ツールの注意機構(無アクティビティで `<system-reminder>` ナッジ) → [tickets/session-todo-reminder.md](tickets/session-todo-reminder.md)
- ワークスペースのメモリーをLintするヘッドレスCLI
- system-reminder 注入機構の汎用化2件目の利用者が出た時に検討。タグ形式 `<system-reminder>...</system-reminder>` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針)

View File

@ -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

View File

@ -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!(

View File

@ -95,6 +95,11 @@ pub struct Pod<C: LlmClient, St: Store> {
/// tools so that Pod-owned operations (e.g. compaction) can consult
/// the recency of touched files.
tracker: Option<tools::Tracker>,
/// 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<C: LlmClient, St: Store> Pod<C, St> {
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None,
task_store: tools::TaskStore::new(),
system_prompt_template: None,
alerter: None,
event_tx: None,
@ -479,6 +485,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
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<C: LlmClient, St: Store> Pod<C, St> {
.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<C: LlmClient, St: Store> Pod<C, St> {
.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<C: LlmClient, St: Store> Pod<C, St> {
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<St: Store> Pod<Box<dyn LlmClient>, 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<St: Store> Pod<Box<dyn LlmClient>, 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<St: Store> Pod<Box<dyn LlmClient>, 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<St: Store> Pod<Box<dyn LlmClient>, 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<WorkerResult> 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.");

View File

@ -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<llm_worker::tool::ToolDefinition> {
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
}

688
crates/tools/src/task.rs Normal file
View File

@ -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<TaskEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct TaskStore {
inner: Arc<Mutex<Inner>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TaskSnapshot {
pub tasks: Vec<TaskEntry>,
}
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<TaskEntry> {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.tasks
.clone()
}
pub fn get(&self, taskid: u64) -> Option<TaskEntry> {
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<TaskStatus>,
subject: Option<String>,
description: Option<String>,
) -> Result<TaskEntry, TaskStoreError> {
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::<TaskCreateParams>(arguments) {
let _ = self.create(params.subject, params.description);
}
}
"TaskUpdate" => {
if let Ok(params) = serde_json::from_str::<TaskUpdateParams>(arguments) {
let _ = self.update(
params.taskid,
params.status,
params.subject,
params.description,
);
}
}
_ => {}
},
_ => {}
}
}
}
pub fn replace_with(&self, tasks: Vec<TaskEntry>) {
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<TaskStatus>,
#[serde(default)]
subject: Option<String>,
#[serde(default)]
description: Option<String>,
}
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<ToolOutput, ToolError> {
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<ToolOutput, ToolError> {
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<ToolOutput, ToolError> {
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<ToolOutput, ToolError> {
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<Vec<TaskEntry>> {
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<dyn Tool> = 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<dyn Tool> = 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<dyn Tool> = 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<dyn Tool> = Arc::new(TaskUpdateTool {
store: store.clone(),
});
(meta, tool)
})
}
pub fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
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<dyn Tool> {
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());
}
}

View File

@ -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};

View File

@ -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<dyn Tool>)>,
@ -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)
}

View File

@ -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");
}

View File

@ -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` が一定リクエスト呼ばれていない場合に限り、`<system-reminder>` で揮発的に思い出させる。「やったつもり」抑止と、トークン浪費・LLM の自律性侵害のバランスを取るため、毎リクエスト押し戻しはしない。
## 前提
- `tickets/session-todo.md` の TaskStore と `TaskCreate` / `TaskUpdate` / `TaskList` / `TaskGet` ツールが利用可能であること
- `pre_llm_request` 相当のフックを Pod が持つこと無ければ本ticket で導入)
## 方針
- **`pre_llm_request` Interceptor として実装**。直近の user message に `<system-reminder>` ブロックを揮発的に append するだけ。履歴・ログには載せない
- **system-reminder 注入の汎用化はやらない**。利用者が Task 1機構しかない段階で抽象を立てないCLAUDE.md「概念の追加は不在が問題になってから」。ただし「タグ形式は `<system-reminder>...</system-reminder>` で揃える」「履歴は汚さない」の2点は本実装で確立し、将来の追加機構が同じ規約に乗れるようにする
- **発火はナッジ型**。N リクエスト無アクティビティで初めて発火し、cooldown も持つ
## 要件
### Interceptor
- `pre_llm_request``Vec<Item>` を受け取り、以下の 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])の末尾に `<system-reminder>` ブロックを append し、現在の active Task リストを `taskid` / `status` / `subject` を含む簡潔な形式で列挙する。`description` は長大化を避けるため省略してよい
- 履歴 (`Worker` の保持する `Vec<Item>`) は変更しない。リクエスト送信時の Vec のみ加工する
- active Task が空の場合は何も差し込まない(忘却防止が目的なので、思い出させる対象が無いなら不要)
## 完了条件
- 直近 N リクエスト連続で `TaskCreate` / `TaskUpdate` が呼ばれず、かつ active Task が残っている場合に限り、`pre_llm_request` で `<system-reminder>` が直近 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 に追記してから実装するとブレが減る。実装完了時に完了条件を再確認。

View File

@ -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<Item>` を受けて直近 user message に `<system-reminder>` を append する」は、`crates/pod/src/ipc/interceptor.rs` の既存 `pre_llm_request` レーンに自然に乗る。Worker history を変更せず、リクエスト送信用 `Vec<Item>` のみ加工する方針は、既存の `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 側は両許容で問題なし)。
- 「揮発的 = 履歴を汚さない」「タグ形式 `<system-reminder>...</system-reminder>` で揃える」「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<TaskStore>` を増やす想定で良いと思うが、実装時に `Tracker` の例 (`attach_tracker`) と同様、Pod から Interceptor への手渡しを統一してほしい。
- **resume 時の発火タイミング。** 0 リセットを採るなら、resume 直後の最初のリクエストでは出ない。これは spec 上「最大 N 遅れるだけ」と許容済み。実装時は単にこの選択を README/コメントに残してほしい。
- **`<system-reminder>` の本文。** 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 妥当性のみの判定)。

View File

@ -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<Mutex<Vec<TodoItem>>>` ベースの `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「概念の追加は不在が問題になってから」。ただし「タグ形式は `<system-reminder>...</system-reminder>` で揃える」点は本実装で確立し、将来の追加機構が同じ規約に乗れるようにする
## 要件
### `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-reminder>` ブロックを含む新規 system message Item を返す
- Worker はこれを `worker.history` に append し、その後の per-request clone でリクエストにも含める。永続化 / resume / compaction は通常 Item と同じ扱い
- ブロック内には現在の TODO リストを、status を含む簡潔な形式で列挙する
- TODO が空の場合は空の `Vec<Item>` を返し、何も差し込まない
- 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 の差し込みがカバーされる
## 範囲外
- 差分更新 APIadd / 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 レーン)