Skip to main content

supercode_harness/tools/
context_budget.rs

1//! BP-3 (catalog row "Context-budget tools", cx§1's `token_budget`
2//! feature): `get_context_remaining` and `new_context`, the MODEL's doors
3//! onto two mechanisms the agent already owns.
4//!
5//! Neither tool implements accounting or clearing of its own. BP-4 built
6//! both, for the operator's `/context` and `/handoff`:
7//!
8//! * `crate::agent::Agent::context_usage` — the live context-window
9//!   accounting, over the very estimates the context guard enforces, so
10//!   what the model is told and what refuses an oversized turn can never
11//!   disagree. [`GetContextRemainingTool`] reports exactly that struct.
12//! * `crate::agent::Agent::new_context` — the in-session fresh window
13//!   (system prompt + a handoff marker naming the objective + the curated
14//!   recent tail, with the set-aside turns kept in the transcript sidecar
15//!   whenever one is attached). [`NewContextTool`] asks for exactly that.
16//!
17//! Both travel through ONE shared [`ContextBudget`], held as an `Arc` on
18//! [`crate::tools::ToolContext`]: the agent publishes the accounting when
19//! the tool asks for it, and the tool parks its fresh-window request there
20//! for the agent to apply the moment the tool round ends — so the very next
21//! model request is the fresh window.
22//!
23//! **Why the park-and-apply split.** A `Tool::execute` sees its arguments
24//! and the ambient context, never the agent's transcript. Keeping the
25//! rewrite in the one place that owns `history` is the same reason
26//! `tool_search`/`expand_reduction` are agent intrinsics — and it is what
27//! keeps the model's `new_context` and the operator's `/handoff` running
28//! the same code, not two drifting copies of one idea.
29
30use std::sync::Mutex;
31
32use async_trait::async_trait;
33use serde::Deserialize;
34use serde_json::{json, Value};
35
36use crate::error::{Error, Result};
37use crate::tools::{Tool, ToolContext};
38
39/// Registered name of the remaining-budget tool.
40pub const GET_CONTEXT_REMAINING: &str = "get_context_remaining";
41
42/// Registered name of the fresh-window tool.
43pub const NEW_CONTEXT: &str = "new_context";
44
45/// One parked fresh-window request, as the model stated it. The fields are
46/// exactly `Agent::new_context`'s parameters — this type carries a request
47/// across the tool/agent boundary, it does not add semantics of its own.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct NewContextRequest {
50    /// The objective the fresh window opens with.
51    pub objective: String,
52    /// How many trailing messages to keep. `None` uses the same
53    /// token-budget-derived count `core.compaction.keep_recent_tokens`
54    /// governs, so the keep-set is curated by the budget the rest of the
55    /// compaction machinery uses rather than a number the model guessed.
56    pub keep_recent: Option<usize>,
57}
58
59/// The shared context accounting the two tools read and write.
60#[derive(Debug, Default)]
61pub struct ContextBudget {
62    /// The last `Agent::context_usage()` the agent published, serialized.
63    usage: Mutex<Option<Value>>,
64    pending: Mutex<Option<NewContextRequest>>,
65}
66
67impl ContextBudget {
68    /// A budget with nothing published yet.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Publish the agent's `ContextUsage` (already serialized, so this
74    /// module needs no dependency on the agent's own types).
75    pub fn publish(&self, usage: Value) {
76        if let Ok(mut slot) = self.usage.lock() {
77            *slot = Some(usage);
78        }
79    }
80
81    /// The last published accounting, if the agent has published one.
82    pub fn snapshot(&self) -> Option<Value> {
83        self.usage.lock().ok().and_then(|slot| slot.clone())
84    }
85
86    /// Park a fresh-window request for the agent to apply.
87    pub fn request_new_context(&self, request: NewContextRequest) {
88        if let Ok(mut pending) = self.pending.lock() {
89            *pending = Some(request);
90        }
91    }
92
93    /// Take the parked request, if any (the agent calls this once per tool
94    /// round).
95    pub fn take_new_context(&self) -> Option<NewContextRequest> {
96        self.pending.lock().ok().and_then(|mut p| p.take())
97    }
98}
99
100/// `get_context_remaining` — how much of the context window is left.
101#[derive(Debug, Default)]
102pub struct GetContextRemainingTool;
103
104#[async_trait]
105impl Tool for GetContextRemainingTool {
106    fn name(&self) -> &str {
107        GET_CONTEXT_REMAINING
108    }
109    fn description(&self) -> &str {
110        "Report how much of the model's context window this conversation is using and how many \
111         tokens remain (messages, tool schemas, the reply reserve, and whether the next request \
112         would still fit). Use it before starting something long, or to decide whether to call \
113         new_context."
114    }
115    fn parameters(&self) -> Value {
116        json!({"type": "object", "properties": {}, "additionalProperties": false})
117    }
118    fn structured_output(&self) -> bool {
119        true
120    }
121    async fn execute(&self, _args: Value, ctx: &ToolContext) -> Result<String> {
122        let Some(usage) = ctx.context_budget.snapshot() else {
123            return Err(Error::tool(
124                self.name(),
125                "no context accounting is available for this session (this tool reports the \
126                 running agent's own figures, and none have been published)",
127            ));
128        };
129        Ok(usage.to_string())
130    }
131}
132
133#[derive(Debug, Deserialize)]
134struct NewContextArgs {
135    objective: String,
136    #[serde(default)]
137    keep_recent: Option<usize>,
138}
139
140/// `new_context` — continue in a fresh window seeded with an objective and
141/// the curated recent tail.
142#[derive(Debug, Default)]
143pub struct NewContextTool;
144
145#[async_trait]
146impl Tool for NewContextTool {
147    fn name(&self) -> &str {
148        NEW_CONTEXT
149    }
150    fn description(&self) -> &str {
151        "Continue this session in a fresh context window: state the objective the new window \
152         opens with. The system prompt, a handoff marker carrying that objective, and the most \
153         recent messages are kept; everything earlier is set aside (and stays in the session's \
154         transcript). Takes effect immediately after this tool round."
155    }
156    fn parameters(&self) -> Value {
157        json!({
158            "type": "object",
159            "properties": {
160                "objective": {
161                    "type": "string",
162                    "description": "What the fresh window is for — the one paragraph the new \
163                                    context opens with."
164                },
165                "keep_recent": {
166                    "type": "integer",
167                    "minimum": 0,
168                    "description": "How many of the most recent messages to keep. Omit to use \
169                                    the session's own keep-recent token budget."
170                }
171            },
172            "required": ["objective"],
173            "additionalProperties": false
174        })
175    }
176    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
177        let a: NewContextArgs =
178            serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
179                tool: self.name().to_string(),
180                message: e.to_string(),
181            })?;
182        let objective = a.objective.trim().to_string();
183        if objective.is_empty() {
184            return Err(Error::InvalidArguments {
185                tool: self.name().to_string(),
186                message: "state an objective for the fresh context window".to_string(),
187            });
188        }
189        ctx.context_budget.request_new_context(NewContextRequest {
190            objective: objective.clone(),
191            keep_recent: a.keep_recent,
192        });
193        Ok(format!(
194            "A fresh context window is queued and takes effect before your next turn. \
195             Objective: {objective}. The system prompt, a handoff marker with that objective, \
196             and the recent tail are kept; earlier turns are set aside."
197        ))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[tokio::test]
206    async fn remaining_reports_the_agents_published_accounting_verbatim() {
207        let ctx = ToolContext::new(std::env::temp_dir());
208        ctx.context_budget.publish(json!({
209            "model": "test/model",
210            "context_limit": 200_000,
211            "projected_tokens": 30_000,
212            "remaining_tokens": 166_000,
213            "used_pct": 15,
214            "fits": true,
215        }));
216        let out = GetContextRemainingTool
217            .execute(json!({}), &ctx)
218            .await
219            .unwrap();
220        let v: Value = serde_json::from_str(&out).unwrap();
221        assert_eq!(v["remaining_tokens"], 166_000);
222        assert_eq!(v["used_pct"], 15);
223        assert_eq!(v["fits"], true);
224    }
225
226    #[tokio::test]
227    async fn remaining_refuses_before_anything_is_published() {
228        let ctx = ToolContext::new(std::env::temp_dir());
229        let err = GetContextRemainingTool
230            .execute(json!({}), &ctx)
231            .await
232            .expect_err("nothing published yet");
233        assert!(err.to_string().contains("no context accounting"), "{err}");
234    }
235
236    #[tokio::test]
237    async fn new_context_parks_a_request_for_the_agent() {
238        let ctx = ToolContext::new(std::env::temp_dir());
239        let out = NewContextTool
240            .execute(
241                json!({"objective": "finish the parser", "keep_recent": 2}),
242                &ctx,
243            )
244            .await
245            .unwrap();
246        assert!(out.contains("finish the parser"), "{out}");
247        let parked = ctx.context_budget.take_new_context().expect("parked");
248        assert_eq!(
249            parked,
250            NewContextRequest {
251                objective: "finish the parser".into(),
252                keep_recent: Some(2),
253            }
254        );
255        assert!(
256            ctx.context_budget.take_new_context().is_none(),
257            "taken once"
258        );
259    }
260
261    #[tokio::test]
262    async fn new_context_defers_the_keep_set_to_the_session_budget_by_default() {
263        let ctx = ToolContext::new(std::env::temp_dir());
264        NewContextTool
265            .execute(json!({"objective": "ship it"}), &ctx)
266            .await
267            .unwrap();
268        assert_eq!(
269            ctx.context_budget.take_new_context().unwrap().keep_recent,
270            None
271        );
272    }
273
274    #[tokio::test]
275    async fn new_context_needs_an_objective() {
276        let ctx = ToolContext::new(std::env::temp_dir());
277        let err = NewContextTool
278            .execute(json!({"objective": "   "}), &ctx)
279            .await
280            .expect_err("blank objective must be refused");
281        assert!(err.to_string().contains("state an objective"), "{err}");
282        assert!(ctx.context_budget.take_new_context().is_none());
283    }
284}