From e0caf7d17e6797938fbdde1f578cbb13f6b0cf77 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 29 May 2026 13:39:26 +0900 Subject: [PATCH] feat: type system reminder appends --- crates/pod/src/ipc/interceptor.rs | 78 +++++++++++-- crates/session-store/src/lib.rs | 2 +- crates/session-store/src/system_item.rs | 139 +++++++++++++++++++++++- 3 files changed, 204 insertions(+), 15 deletions(-) diff --git a/crates/pod/src/ipc/interceptor.rs b/crates/pod/src/ipc/interceptor.rs index 6857b514..3f2721fb 100644 --- a/crates/pod/src/ipc/interceptor.rs +++ b/crates/pod/src/ipc/interceptor.rs @@ -22,7 +22,7 @@ use tracing::info; use tracing::warn; use crate::compact::state::CompactState; -use session_store::SystemItem; +use session_store::{SystemItem, SystemReminder}; use tools::{TaskEntry, TaskStatus, TaskStore}; use crate::hook::{ @@ -198,9 +198,10 @@ impl PodInterceptor { } self.task_reminder_state.note_reminder(); - Some(SystemItem::TaskReminder { - body: render_task_reminder(&active_tasks), - }) + Some( + SystemReminder::task_inactivity(render_task_reminder_body(&active_tasks)) + .into_system_item(), + ) } } @@ -208,9 +209,9 @@ fn is_task_management_tool(name: &str) -> bool { TASK_MANAGEMENT_TOOL_NAMES.contains(&name) } -fn render_task_reminder(active_tasks: &[TaskEntry]) -> String { +fn render_task_reminder_body(active_tasks: &[TaskEntry]) -> String { let mut body = String::from( - "\nActive session tasks are still open. If progress changed, call TaskUpdate.\n", + "Active session tasks are still open. If progress changed, call TaskUpdate.\n", ); for task in active_tasks { body.push_str(&format!( @@ -218,8 +219,7 @@ fn render_task_reminder(active_tasks: &[TaskEntry]) -> String { task.taskid, task.status, task.subject )); } - body.push_str(""); - body + body.trim_end_matches('\n').to_string() } #[async_trait] @@ -430,6 +430,7 @@ mod tests { use super::*; use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest}; + use session_store::SystemReminderSource; struct CountingHook(Arc); @@ -666,14 +667,48 @@ mod tests { let items = interceptor.pending_history_appends().await; assert_eq!(items.len(), 1); let body = items[0].as_text().unwrap_or_default(); - assert!(body.contains("")); - assert!(body.contains("")); + assert_eq!(body.matches("").count(), 1); + assert_eq!(body.matches("").count(), 1); assert!(body.contains("taskid 1")); assert!(body.contains("pending")); assert!(body.contains("keep going")); assert!(!body.contains("long task description")); } + #[test] + fn task_reminder_system_item_retains_source() { + let task_store = TaskStore::new(); + task_store.create("typed".into(), String::new()); + let interceptor = + interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new())); + + for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 { + assert!(interceptor.task_reminder_system_item().is_none()); + } + let item = interceptor.task_reminder_system_item().unwrap(); + match item { + SystemItem::TaskReminder { source, body } => { + assert_eq!(source, SystemReminderSource::TaskInactivity); + assert_eq!(body.matches("").count(), 1); + assert_eq!(body.matches("").count(), 1); + assert!(body.contains("typed")); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() { + let task_store = TaskStore::new(); + let task = task_store.create("body".into(), String::new()); + let body = render_task_reminder_body(&[task]); + + assert!(!body.contains("")); + assert!(!body.contains("")); + assert!(body.contains("TaskUpdate")); + assert!(body.contains("taskid 1")); + } + #[test] fn task_reminder_state_starts_with_initial_cooldown_elapsed() { let state = TaskReminderState::new(); @@ -797,6 +832,29 @@ mod tests { assert_eq!(ctx.len(), 1, "pre_llm_request must not inject reminders"); } + #[tokio::test] + async fn pre_llm_request_does_not_touch_task_reminder_lane() { + let task_store = TaskStore::new(); + task_store.create("lane".into(), String::new()); + let interceptor = + interceptor_for_task_reminders(task_store, Arc::new(TaskReminderState::new())); + let mut ctx = vec![Item::user_message("hi")]; + + for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 { + assert!(interceptor.pending_history_appends().await.is_empty()); + } + let action = interceptor.pre_llm_request(&mut ctx).await; + + assert!(matches!(action, PreRequestAction::Continue)); + assert_eq!(ctx.len(), 1, "pre_llm_request must not inject reminders"); + let pending = interceptor.pending_history_appends().await; + assert_eq!( + pending.len(), + 1, + "reminders stay in pending_history_appends" + ); + } + #[tokio::test] async fn pre_llm_request_does_not_touch_pending_notifies() { // The drain lane has moved to `pending_history_appends`; diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index da11e4dc..892c98b8 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -59,7 +59,7 @@ pub use segment_log::{ collect_state, }; pub use store::{Store, StoreError}; -pub use system_item::{SystemItem, render_pod_event}; +pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_pod_event}; /// Session identifier — the fork-tree root. UUID v7 (time-ordered). /// diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 5ba621cb..6e75bac2 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -22,6 +22,86 @@ use llm_worker::llm_client::types::Item; use protocol::PodEvent; use serde::{Deserialize, Serialize}; +const SYSTEM_REMINDER_OPEN: &str = ""; +const SYSTEM_REMINDER_CLOSE: &str = ""; + +/// Source policy that produced a durable `` input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SystemReminderSource { + /// Session task inactivity reminder emitted after task-management tools + /// have gone unused for the configured request threshold. + TaskInactivity, +} + +fn default_task_reminder_source() -> SystemReminderSource { + SystemReminderSource::TaskInactivity +} + +/// Typed pending system reminder before it is committed as a [`SystemItem`]. +/// +/// System reminders are durable input: producers must append the rendered +/// `SystemItem` through worker history before the next LLM request. They are +/// not transient UI notices and must not be prompt-cache/context-only +/// injections, because then the model could react to input that is absent from +/// persisted history on later turns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SystemReminder { + source: SystemReminderSource, + body: String, +} + +impl SystemReminder { + /// Build a task-inactivity reminder from an unwrapped body. + pub fn task_inactivity(body: impl Into) -> Self { + Self::new(SystemReminderSource::TaskInactivity, body) + } + + /// Build a reminder from an unwrapped body. If a caller passes a body that + /// is already exactly wrapped in `` tags, normalize it back + /// to the inner body so rendering still wraps exactly once. + pub fn new(source: SystemReminderSource, body: impl Into) -> Self { + let body = normalize_unwrapped_system_reminder_body(body.into()); + Self { source, body } + } + + pub fn source(&self) -> SystemReminderSource { + self.source + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn rendered_body(&self) -> String { + render_system_reminder(&self.body) + } + + pub fn into_system_item(self) -> SystemItem { + match self.source { + SystemReminderSource::TaskInactivity => SystemItem::TaskReminder { + source: self.source, + body: self.rendered_body(), + }, + } + } +} + +fn normalize_unwrapped_system_reminder_body(body: String) -> String { + let trimmed = body.trim(); + if let Some(inner) = trimmed + .strip_prefix(SYSTEM_REMINDER_OPEN) + .and_then(|rest| rest.strip_suffix(SYSTEM_REMINDER_CLOSE)) + { + return inner.trim_matches('\n').to_string(); + } + body +} + +fn render_system_reminder(body: &str) -> String { + format!("{SYSTEM_REMINDER_OPEN}\n{body}\n{SYSTEM_REMINDER_CLOSE}") +} + /// One agent-injected system item, tagged by origin. /// /// Each variant carries the kind-specific raw data clients use for @@ -69,9 +149,13 @@ pub enum SystemItem { Workflow { slug: String, body: String }, /// Task-management inactivity reminder inserted before an LLM request. - /// `body` is the exact LLM-context text wrapped in a - /// `` block. - TaskReminder { body: String }, + /// `source` is the policy that produced this durable reminder; `body` is + /// the exact LLM-context text wrapped in a `` block. + TaskReminder { + #[serde(default = "default_task_reminder_source")] + source: SystemReminderSource, + body: String, + }, /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the @@ -89,7 +173,7 @@ impl SystemItem { SystemItem::FileAttachment { body, .. } => body.clone(), SystemItem::Knowledge { body, .. } => body.clone(), SystemItem::Workflow { body, .. } => body.clone(), - SystemItem::TaskReminder { body } => body.clone(), + SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::Interrupt { body } => body.clone(), } } @@ -172,6 +256,53 @@ mod tests { assert_eq!(item.history_text(), "[File: src/main.rs]\nfn main() {}"); } + #[test] + fn system_reminder_renders_body_once() { + let reminder = SystemReminder::task_inactivity("remember tasks"); + assert_eq!( + reminder.rendered_body(), + "\nremember tasks\n" + ); + + let already_wrapped = SystemReminder::task_inactivity( + "\nremember tasks\n", + ); + assert_eq!(already_wrapped.body(), "remember tasks"); + assert_eq!( + already_wrapped.rendered_body(), + "\nremember tasks\n" + ); + } + + #[test] + fn system_reminder_source_is_retained_in_system_item() { + let item = SystemReminder::task_inactivity("remember tasks").into_system_item(); + match item { + SystemItem::TaskReminder { source, body } => { + assert_eq!(source, SystemReminderSource::TaskInactivity); + assert_eq!( + body, + "\nremember tasks\n" + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn task_reminder_deserialization_defaults_legacy_source() { + let parsed: SystemItem = serde_json::from_str( + r#"{"kind":"task_reminder","body":"\nbody\n"}"#, + ) + .unwrap(); + match parsed { + SystemItem::TaskReminder { source, .. } => { + assert_eq!(source, SystemReminderSource::TaskInactivity); + } + other => panic!("unexpected: {other:?}"), + } + } + #[test] fn round_trip_via_json() { let item = SystemItem::FileAttachment {