Skip to main content

codetether_agent/tool/
context_reset.rs

1//! `context_reset`: agent-callable Lu et al. reset (arXiv:2510.06727).
2//!
3//! ## What it does
4//!
5//! The agent hands the harness a compressed summary of the session so
6//! far. The tool records the reset decision in the audit log and
7//! returns a marker [`ToolResult`] that the next turn's derivation can
8//! pick up (via `DerivePolicy::Reset`) as the new anchor for the
9//! context window.
10//!
11//! Unlike [`session_recall`], this tool does **not** read disk or run
12//! RLM. The summary text is supplied by the model itself — that is the
13//! whole point of Lu et al.'s semantic: the *policy* decides what to
14//! keep, rather than an external summariser guessing.
15//!
16//! ## When the agent should call it
17//!
18//! * The transcript is getting long and the model wants to proactively
19//!   fold it before the next tool call.
20//! * The agent has just completed a sub-task and wants to hand-off a
21//!   compact state to itself for the next phase.
22//! * The user's next message is about to bring in a large payload
23//!   (large file, long web page) and the agent wants headroom.
24//!
25//! ## Invariants
26//!
27//! * The tool never mutates [`Session::messages`]. The summary enters
28//!   history naturally — as the tool's `ToolResult` on the next turn.
29//! * The returned marker text always starts with `[CONTEXT RESET]` so
30//!   Phase B's derivation policies can recognise it without an
31//!   out-of-band signal.
32//!
33//! ## Examples
34//!
35//! ```rust
36//! use codetether_agent::tool::context_reset::{ContextResetTool, format_reset_marker};
37//!
38//! // Sanity-check the stable marker format the next-turn derivation
39//! // policy can look for.
40//! let body = format_reset_marker("summary body", 42);
41//! assert!(body.starts_with("[CONTEXT RESET]"));
42//! assert!(body.contains("summary body"));
43//! assert!(body.contains("bytes=42"));
44//!
45//! let _ = ContextResetTool;
46//! ```
47
48use super::{Tool, ToolResult};
49use anyhow::Result;
50use async_trait::async_trait;
51use serde_json::{Value, json};
52
53/// Marker prefix that identifies a `context_reset` tool output in the
54/// transcript. Phase B derivations search for this literal when
55/// deciding whether a user-visible summary has already landed.
56pub const RESET_MARKER_PREFIX: &str = "[CONTEXT RESET]";
57
58/// Shape the tool-result payload carrying the agent-authored summary.
59///
60/// Kept as a free function so the exact wire format can be unit-tested
61/// in isolation from the [`Tool`] trait impl.
62pub fn format_reset_marker(summary: &str, bytes: usize) -> String {
63    format!(
64        "{RESET_MARKER_PREFIX}\n\
65         The agent requested a Lu et al. (arXiv:2510.06727) context \
66         reset. Everything older than this point should be treated as \
67         summarised by the text below. The canonical transcript is \
68         preserved on disk; call `session_recall` for dropped \
69         details.\n\
70         \n\
71         bytes={bytes}\n\
72         \n\
73         {summary}"
74    )
75}
76
77/// Agent-callable Lu et al. reset tool.
78pub struct ContextResetTool;
79
80#[async_trait]
81impl Tool for ContextResetTool {
82    fn id(&self) -> &str {
83        "context_reset"
84    }
85
86    fn name(&self) -> &str {
87        "ContextReset"
88    }
89
90    fn description(&self) -> &str {
91        "REQUEST A CONTEXT RESET (Lu et al., arXiv:2510.06727). Call this \
92         when the active transcript is approaching the model's context \
93         window and you want to compress it in your own words before \
94         continuing. Pass a `summary` string distilling the task state, \
95         decisions, open questions, and any must-remember details. The \
96         tool records the reset and returns a `[CONTEXT RESET]` marker \
97         that will become the anchor for the next turn. The canonical \
98         chat history is preserved on disk — call `session_recall` if \
99         you later need a specific detail you dropped."
100    }
101
102    fn parameters(&self) -> Value {
103        json!({
104            "type": "object",
105            "properties": {
106                "summary": {
107                    "type": "string",
108                    "description": "Compressed task state in your own words: \
109                                    goal, key decisions, open questions, \
110                                    must-remember details. This replaces \
111                                    the earlier transcript as the anchor \
112                                    for subsequent turns."
113                }
114            },
115            "required": ["summary"]
116        })
117    }
118
119    async fn execute(&self, args: Value) -> Result<ToolResult> {
120        let summary = match args["summary"].as_str() {
121            Some(s) if !s.trim().is_empty() => s.trim().to_string(),
122            _ => {
123                return Ok(ToolResult::error(
124                    "`summary` is required and must be non-empty",
125                ));
126            }
127        };
128        let bytes = summary.len();
129        tracing::info!(bytes, "context_reset: agent-authored summary accepted");
130        Ok(ToolResult::success(format_reset_marker(&summary, bytes)))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[tokio::test]
139    async fn execute_requires_non_empty_summary() {
140        let tool = ContextResetTool;
141        let result = tool.execute(json!({"summary": ""})).await.unwrap();
142        assert!(!result.success);
143
144        let result = tool.execute(json!({"summary": "   "})).await.unwrap();
145        assert!(!result.success);
146    }
147
148    #[tokio::test]
149    async fn execute_wraps_summary_with_marker() {
150        let tool = ContextResetTool;
151        let result = tool
152            .execute(json!({"summary": "goal: ship it; open: review"}))
153            .await
154            .unwrap();
155        assert!(result.success);
156        assert!(result.output.starts_with(RESET_MARKER_PREFIX));
157        assert!(result.output.contains("goal: ship it"));
158    }
159
160    #[test]
161    fn format_reset_marker_has_stable_prefix_and_body() {
162        let body = format_reset_marker("hello", 5);
163        assert!(body.starts_with(RESET_MARKER_PREFIX));
164        assert!(body.contains("hello"));
165        assert!(body.contains("bytes=5"));
166        assert!(body.contains("session_recall"));
167    }
168
169    #[test]
170    fn marker_prefix_is_the_documented_constant() {
171        assert_eq!(RESET_MARKER_PREFIX, "[CONTEXT RESET]");
172    }
173}