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 {