feat: type system reminder appends
This commit is contained in:
parent
c7272ce01f
commit
e0caf7d17e
|
|
@ -22,7 +22,7 @@ use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use session_store::SystemItem;
|
use session_store::{SystemItem, SystemReminder};
|
||||||
use tools::{TaskEntry, TaskStatus, TaskStore};
|
use tools::{TaskEntry, TaskStatus, TaskStore};
|
||||||
|
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
|
|
@ -198,9 +198,10 @@ impl PodInterceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.task_reminder_state.note_reminder();
|
self.task_reminder_state.note_reminder();
|
||||||
Some(SystemItem::TaskReminder {
|
Some(
|
||||||
body: render_task_reminder(&active_tasks),
|
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)
|
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(
|
let mut body = String::from(
|
||||||
"<system-reminder>\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 {
|
for task in active_tasks {
|
||||||
body.push_str(&format!(
|
body.push_str(&format!(
|
||||||
|
|
@ -218,8 +219,7 @@ fn render_task_reminder(active_tasks: &[TaskEntry]) -> String {
|
||||||
task.taskid, task.status, task.subject
|
task.taskid, task.status, task.subject
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
body.push_str("</system-reminder>");
|
body.trim_end_matches('\n').to_string()
|
||||||
body
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -430,6 +430,7 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
|
use crate::hook::{Hook, HookRegistryBuilder, PreLlmRequest};
|
||||||
|
use session_store::SystemReminderSource;
|
||||||
|
|
||||||
struct CountingHook(Arc<AtomicUsize>);
|
struct CountingHook(Arc<AtomicUsize>);
|
||||||
|
|
||||||
|
|
@ -666,14 +667,48 @@ mod tests {
|
||||||
let items = interceptor.pending_history_appends().await;
|
let items = interceptor.pending_history_appends().await;
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
let body = items[0].as_text().unwrap_or_default();
|
let body = items[0].as_text().unwrap_or_default();
|
||||||
assert!(body.contains("<system-reminder>"));
|
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||||
assert!(body.contains("</system-reminder>"));
|
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
||||||
assert!(body.contains("taskid 1"));
|
assert!(body.contains("taskid 1"));
|
||||||
assert!(body.contains("pending"));
|
assert!(body.contains("pending"));
|
||||||
assert!(body.contains("keep going"));
|
assert!(body.contains("keep going"));
|
||||||
assert!(!body.contains("long task description"));
|
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("<system-reminder>").count(), 1);
|
||||||
|
assert_eq!(body.matches("</system-reminder>").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("<system-reminder>"));
|
||||||
|
assert!(!body.contains("</system-reminder>"));
|
||||||
|
assert!(body.contains("TaskUpdate"));
|
||||||
|
assert!(body.contains("taskid 1"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_reminder_state_starts_with_initial_cooldown_elapsed() {
|
fn task_reminder_state_starts_with_initial_cooldown_elapsed() {
|
||||||
let state = TaskReminderState::new();
|
let state = TaskReminderState::new();
|
||||||
|
|
@ -797,6 +832,29 @@ mod tests {
|
||||||
assert_eq!(ctx.len(), 1, "pre_llm_request must not inject reminders");
|
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]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_does_not_touch_pending_notifies() {
|
async fn pre_llm_request_does_not_touch_pending_notifies() {
|
||||||
// The drain lane has moved to `pending_history_appends`;
|
// The drain lane has moved to `pending_history_appends`;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub use segment_log::{
|
||||||
collect_state,
|
collect_state,
|
||||||
};
|
};
|
||||||
pub use store::{Store, StoreError};
|
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).
|
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,86 @@ use llm_worker::llm_client::types::Item;
|
||||||
use protocol::PodEvent;
|
use protocol::PodEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
||||||
|
const SYSTEM_REMINDER_CLOSE: &str = "</system-reminder>";
|
||||||
|
|
||||||
|
/// Source policy that produced a durable `<system-reminder>` 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<String>) -> 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 `<system-reminder>` tags, normalize it back
|
||||||
|
/// to the inner body so rendering still wraps exactly once.
|
||||||
|
pub fn new(source: SystemReminderSource, body: impl Into<String>) -> 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.
|
/// One agent-injected system item, tagged by origin.
|
||||||
///
|
///
|
||||||
/// Each variant carries the kind-specific raw data clients use for
|
/// Each variant carries the kind-specific raw data clients use for
|
||||||
|
|
@ -69,9 +149,13 @@ pub enum SystemItem {
|
||||||
Workflow { slug: String, body: String },
|
Workflow { slug: String, body: String },
|
||||||
|
|
||||||
/// Task-management inactivity reminder inserted before an LLM request.
|
/// Task-management inactivity reminder inserted before an LLM request.
|
||||||
/// `body` is the exact LLM-context text wrapped in a
|
/// `source` is the policy that produced this durable reminder; `body` is
|
||||||
/// `<system-reminder>` block.
|
/// the exact LLM-context text wrapped in a `<system-reminder>` block.
|
||||||
TaskReminder { body: String },
|
TaskReminder {
|
||||||
|
#[serde(default = "default_task_reminder_source")]
|
||||||
|
source: SystemReminderSource,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Synthetic note inserted after an interrupted turn before the next
|
/// Synthetic note inserted after an interrupted turn before the next
|
||||||
/// user input. `body` is the exact LLM-context text explaining that the
|
/// user input. `body` is the exact LLM-context text explaining that the
|
||||||
|
|
@ -89,7 +173,7 @@ impl SystemItem {
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
SystemItem::Knowledge { body, .. } => body.clone(),
|
SystemItem::Knowledge { body, .. } => body.clone(),
|
||||||
SystemItem::Workflow { body, .. } => body.clone(),
|
SystemItem::Workflow { body, .. } => body.clone(),
|
||||||
SystemItem::TaskReminder { body } => body.clone(),
|
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { 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() {}");
|
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(),
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
|
||||||
|
let already_wrapped = SystemReminder::task_inactivity(
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>",
|
||||||
|
);
|
||||||
|
assert_eq!(already_wrapped.body(), "remember tasks");
|
||||||
|
assert_eq!(
|
||||||
|
already_wrapped.rendered_body(),
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
"<system-reminder>\nremember tasks\n</system-reminder>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_reminder_deserialization_defaults_legacy_source() {
|
||||||
|
let parsed: SystemItem = serde_json::from_str(
|
||||||
|
r#"{"kind":"task_reminder","body":"<system-reminder>\nbody\n</system-reminder>"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
SystemItem::TaskReminder { source, .. } => {
|
||||||
|
assert_eq!(source, SystemReminderSource::TaskInactivity);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_via_json() {
|
fn round_trip_via_json() {
|
||||||
let item = SystemItem::FileAttachment {
|
let item = SystemItem::FileAttachment {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user