supercode_harness/tools/
context_budget.rs1use 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
39pub const GET_CONTEXT_REMAINING: &str = "get_context_remaining";
41
42pub const NEW_CONTEXT: &str = "new_context";
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct NewContextRequest {
50 pub objective: String,
52 pub keep_recent: Option<usize>,
57}
58
59#[derive(Debug, Default)]
61pub struct ContextBudget {
62 usage: Mutex<Option<Value>>,
64 pending: Mutex<Option<NewContextRequest>>,
65}
66
67impl ContextBudget {
68 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn publish(&self, usage: Value) {
76 if let Ok(mut slot) = self.usage.lock() {
77 *slot = Some(usage);
78 }
79 }
80
81 pub fn snapshot(&self) -> Option<Value> {
83 self.usage.lock().ok().and_then(|slot| slot.clone())
84 }
85
86 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 pub fn take_new_context(&self) -> Option<NewContextRequest> {
96 self.pending.lock().ok().and_then(|mut p| p.take())
97 }
98}
99
100#[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#[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}