Compare commits
7 Commits
c214ea79d4
...
878e64597e
| Author | SHA1 | Date | |
|---|---|---|---|
| 878e64597e | |||
| 46208a3b45 | |||
| 46390a9006 | |||
| 420f74edc6 | |||
| ceafff92b6 | |||
| 6f2aca84bf | |||
| 28fe1dae1c |
2
TODO.md
2
TODO.md
|
|
@ -15,6 +15,6 @@
|
||||||
- Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md)
|
- 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)
|
- 使用頻度メトリクス + 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
|
- ワークスペースのメモリーをLintするヘッドレスCLI
|
||||||
- system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式 `<system-reminder>...</system-reminder>` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針)
|
- system-reminder 注入機構の汎用化(2件目の利用者が出た時に検討。タグ形式 `<system-reminder>...</system-reminder>` の規約は session-todo-reminder で先行確立。注入された Item は worker.history に append する方針)
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ impl PodController {
|
||||||
// Stashed during tool registration below so we can attach a
|
// Stashed during tool registration below so we can attach a
|
||||||
// `PodFsView` to the shared state once the latter exists.
|
// `PodFsView` to the shared state once the latter exists.
|
||||||
let fs_for_view: tools::ScopedFs;
|
let fs_for_view: tools::ScopedFs;
|
||||||
|
let task_store = pod.task_store();
|
||||||
|
|
||||||
let scope_change_sink = pod.scope_change_sink();
|
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,
|
// query — keep a clone for the FS view we attach below,
|
||||||
// since the tools consume `fs` itself.
|
// since the tools consume `fs` itself.
|
||||||
fs_for_view = fs.clone();
|
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
|
// Memory subsystem opt-in. When `[memory]` is present in
|
||||||
// the manifest, register the memory-specific Read/Write/Edit
|
// the manifest, register the memory-specific Read/Write/Edit
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ impl Interceptor for PodInterceptor {
|
||||||
|
|
||||||
// Internal mechanism: between-requests compaction trigger (safety net).
|
// Internal mechanism: between-requests compaction trigger (safety net).
|
||||||
if let Some(state) = self.compact_state.as_ref() {
|
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);
|
let current = current_tokens.unwrap_or(0);
|
||||||
if state.exceeds_request(current) {
|
if state.exceeds_request(current) {
|
||||||
info!(
|
info!(
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,11 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// tools so that Pod-owned operations (e.g. compaction) can consult
|
/// tools so that Pod-owned operations (e.g. compaction) can consult
|
||||||
/// the recency of touched files.
|
/// the recency of touched files.
|
||||||
tracker: Option<tools::Tracker>,
|
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.
|
/// Parsed system-prompt template awaiting first-turn materialisation.
|
||||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||||
/// then `None` forever — including after compaction.
|
/// 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()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
|
task_store: tools::TaskStore::new(),
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
event_tx: None,
|
||||||
|
|
@ -479,6 +485,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.tracker = Some(tracker);
|
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.
|
/// The attached session-scoped file-operation tracker, if any.
|
||||||
pub fn tracker(&self) -> Option<&tools::Tracker> {
|
pub fn tracker(&self) -> Option<&tools::Tracker> {
|
||||||
self.tracker.as_ref()
|
self.tracker.as_ref()
|
||||||
|
|
@ -1314,8 +1332,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Input text fed to the compact worker. Includes the default
|
// Input text fed to the compact worker. Includes the default
|
||||||
// references and the (pruned) conversation text.
|
// references, current TaskStore snapshot, and the (pruned)
|
||||||
let summary_input = build_summary_input(&items_to_summarise, &default_refs);
|
// 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.
|
// Worker-side state collected by the compact worker's tool calls.
|
||||||
let ctx = Arc::new(std::sync::Mutex::new(CompactWorkerContext::with_budget(
|
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())
|
.filter(|i| i.is_user_message())
|
||||||
.count();
|
.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(
|
let mut new_history = Vec::with_capacity(
|
||||||
1 + auto_read_messages.len()
|
1 + auto_read_messages.len()
|
||||||
|
+ 3
|
||||||
+ reference_message.is_some() as usize
|
+ reference_message.is_some() as usize
|
||||||
+ retained_items.len(),
|
+ retained_items.len(),
|
||||||
);
|
);
|
||||||
|
|
@ -1444,6 +1474,17 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
new_history.push(msg);
|
new_history.push(msg);
|
||||||
}
|
}
|
||||||
new_history.extend(retained_items);
|
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.
|
// Persist as a new compacted session.
|
||||||
let old_session_id = self.session_id;
|
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()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
|
task_store: tools::TaskStore::new(),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: 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()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
|
task_store: tools::TaskStore::new(),
|
||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: 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 extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||||
|
let task_store = tools::TaskStore::from_history(&state.history);
|
||||||
|
|
||||||
let mut pod = Self {
|
let mut pod = Self {
|
||||||
manifest,
|
manifest,
|
||||||
|
|
@ -2227,6 +2271,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||||
tracker: None,
|
tracker: None,
|
||||||
|
task_store,
|
||||||
// Restore replays the saved system_prompt verbatim — no
|
// Restore replays the saved system_prompt verbatim — no
|
||||||
// template re-render on resume.
|
// template re-render on resume.
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
|
|
@ -2323,7 +2368,11 @@ impl From<WorkerResult> for PodRunResult {
|
||||||
/// Build the compact worker's input: default-reference instructions,
|
/// Build the compact worker's input: default-reference instructions,
|
||||||
/// the list of recently-touched files, and the pruned conversation
|
/// the list of recently-touched files, and the pruned conversation
|
||||||
/// produced by [`build_summary_prompt`].
|
/// 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();
|
let mut out = String::new();
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"Summarise the conversation below into a structured summary and nominate \
|
"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');
|
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("## Conversation\n");
|
||||||
out.push_str(&build_summary_prompt(items));
|
out.push_str(&build_summary_prompt(items));
|
||||||
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod scoped_fs;
|
pub mod scoped_fs;
|
||||||
|
pub mod task;
|
||||||
pub mod tracker;
|
pub mod tracker;
|
||||||
|
|
||||||
mod bash;
|
mod bash;
|
||||||
|
|
@ -36,6 +37,7 @@ pub use glob::glob_tool;
|
||||||
pub use grep::grep_tool;
|
pub use grep::grep_tool;
|
||||||
pub use read::read_tool;
|
pub use read::read_tool;
|
||||||
pub use scoped_fs::ScopedFs;
|
pub use scoped_fs::ScopedFs;
|
||||||
|
pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools};
|
||||||
pub use tracker::Tracker;
|
pub use tracker::Tracker;
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|
||||||
|
|
@ -53,14 +55,17 @@ pub use write::write_tool;
|
||||||
pub fn builtin_tools(
|
pub fn builtin_tools(
|
||||||
fs: ScopedFs,
|
fs: ScopedFs,
|
||||||
tracker: Tracker,
|
tracker: Tracker,
|
||||||
|
task_store: TaskStore,
|
||||||
bash_output_dir: std::path::PathBuf,
|
bash_output_dir: std::path::PathBuf,
|
||||||
) -> Vec<llm_worker::tool::ToolDefinition> {
|
) -> Vec<llm_worker::tool::ToolDefinition> {
|
||||||
vec![
|
let mut defs = vec![
|
||||||
read_tool(fs.clone(), tracker.clone()),
|
read_tool(fs.clone(), tracker.clone()),
|
||||||
write_tool(fs.clone(), tracker.clone()),
|
write_tool(fs.clone(), tracker.clone()),
|
||||||
edit_tool(fs.clone(), tracker),
|
edit_tool(fs.clone(), tracker),
|
||||||
glob_tool(fs.clone()),
|
glob_tool(fs.clone()),
|
||||||
grep_tool(fs.clone()),
|
grep_tool(fs.clone()),
|
||||||
bash_tool(fs, bash_output_dir),
|
bash_tool(fs, bash_output_dir),
|
||||||
]
|
];
|
||||||
|
defs.extend(task_tools(task_store));
|
||||||
|
defs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
688
crates/tools/src/task.rs
Normal file
688
crates/tools/src/task.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,8 @@
|
||||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
|
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
|
||||||
//! let tracker = Tracker::new(); // session lifetime
|
//! let tracker = Tracker::new(); // session lifetime
|
||||||
//! let bash_outputs = PathBuf::from("/run/insomnia/bash-output");
|
//! 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};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use llm_worker::tool::{Tool, ToolDefinition};
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tools::{ScopedFs, Tracker, builtin_tools};
|
use tools::{ScopedFs, TaskStore, Tracker, builtin_tools};
|
||||||
|
|
||||||
struct Registry {
|
struct Registry {
|
||||||
entries: Vec<(llm_worker::tool::ToolMeta, Arc<dyn Tool>)>,
|
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 scope = Scope::from_config(&config).unwrap();
|
||||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||||
let tracker = Tracker::new();
|
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)
|
(dir, spill, reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolMeta};
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
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 {
|
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||||
let base = Scope::writable(workspace).unwrap();
|
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 scope = scope_with_spill(dir.path(), spill.path());
|
||||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||||
let tracker = Tracker::new();
|
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)
|
(dir, spill, reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +82,21 @@ fn builtin_tools_registers_full_set() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
let mut names = reg.names();
|
let mut names = reg.names();
|
||||||
names.sort();
|
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]
|
#[test]
|
||||||
|
|
@ -270,16 +289,41 @@ async fn edit_requires_read_across_tools() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deterministic_tool_order_is_registration_order() {
|
async fn deterministic_tool_order_is_registration_order() {
|
||||||
let (_dir, _spill, reg) = setup();
|
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();
|
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
|
// Regression: tool name capitalization matches Claude Code reference
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_names_match_reference_spec() {
|
fn tool_names_match_reference_spec() {
|
||||||
let (_dir, _spill, reg) = setup();
|
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!(
|
assert!(
|
||||||
reg.entries.iter().any(|(m, _)| m.name == expected),
|
reg.entries.iter().any(|(m, _)| m.name == expected),
|
||||||
"missing tool {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 scope = scope_with_spill(dir.path(), spill.path());
|
||||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||||
let tracker = Tracker::new();
|
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 a = dir.path().join("a.txt");
|
||||||
let b = dir.path().join("b.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");
|
let read_body = read_out.content.expect("Read returned content");
|
||||||
// The full 200 lines should be in the saved file even though Bash
|
// The full 200 lines should be in the saved file even though Bash
|
||||||
// returned only the tail of 80.
|
// 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");
|
assert!(read_body.contains("line 200"), "missing line 200");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
67
tickets/session-todo-reminder.md
Normal file
67
tickets/session-todo-reminder.md
Normal 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 に追記してから実装するとブレが減る。実装完了時に完了条件を再確認。
|
||||||
41
tickets/session-todo-reminder.review.md
Normal file
41
tickets/session-todo-reminder.review.md
Normal 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 妥当性のみの判定)。
|
||||||
|
|
@ -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 の差し込みがカバーされる
|
|
||||||
|
|
||||||
## 範囲外
|
|
||||||
|
|
||||||
- 差分更新 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 レーン)
|
|
||||||
Loading…
Reference in New Issue
Block a user