bamboo_server_tools/
compact.rs1use async_trait::async_trait;
2use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6pub struct CompactContextTool;
12
13#[derive(Debug, Deserialize)]
14struct CompactContextArgs {
15 #[serde(default)]
16 instructions: Option<String>,
17}
18
19#[async_trait]
20impl Tool for CompactContextTool {
21 fn name(&self) -> &str {
22 "compact_context"
23 }
24
25 fn description(&self) -> &str {
26 "Manually compress conversation history to free up context window space. \
27 Use at natural task boundaries (after finishing a feature, before starting a new topic). \
28 Optionally provide custom instructions to control what the summary focuses on."
29 }
30
31 fn parameters_schema(&self) -> serde_json::Value {
32 json!({
33 "type": "object",
34 "properties": {
35 "instructions": {
36 "type": "string",
37 "description": "Optional custom instructions for what to focus on in the summary. \
38 Examples: 'Preserve variable names and function signatures', \
39 'Focus on open issues and blockers', 'Keep only active task status'"
40 }
41 }
42 })
43 }
44
45 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
46 ToolClass::MUTATING_SERIAL.promotable()
47 }
48
49 async fn invoke(
50 &self,
51 args: serde_json::Value,
52 _ctx: ToolCtx,
53 ) -> Result<ToolOutcome, ToolError> {
54 let parsed: CompactContextArgs = serde_json::from_value(args.clone()).map_err(|e| {
55 ToolError::Execution(format!("invalid arguments for compact_context: {e}"))
56 })?;
57
58 let instructions_note = parsed
59 .instructions
60 .as_deref()
61 .filter(|s| !s.is_empty())
62 .map(|i| format!(" with instructions: {i}"))
63 .unwrap_or_default();
64
65 Ok(ToolOutcome::Completed(ToolResult {
66 success: true,
67 result: format!(
68 "Context compression requested{}. Compression will be applied before the next turn.",
69 instructions_note
70 ),
71 display_preference: Some("Collapsible".to_string()),
72 images: Vec::new(),
73 }))
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[tokio::test]
82 async fn execute_without_instructions() {
83 let tool = CompactContextTool;
84 let out = tool
85 .invoke(serde_json::json!({}), ToolCtx::none("t"))
86 .await
87 .expect("invoke should succeed");
88 let ToolOutcome::Completed(result) = out else {
89 panic!("expected Completed")
90 };
91
92 assert!(result.success);
93 assert_eq!(
94 result.result,
95 "Context compression requested. Compression will be applied before the next turn."
96 );
97 assert_eq!(result.display_preference.as_deref(), Some("Collapsible"));
98 }
99
100 #[tokio::test]
101 async fn execute_with_instructions() {
102 let tool = CompactContextTool;
103 let out = tool
104 .invoke(
105 serde_json::json!({
106 "instructions": "Preserve all function signatures"
107 }),
108 ToolCtx::none("t"),
109 )
110 .await
111 .expect("invoke should succeed");
112 let ToolOutcome::Completed(result) = out else {
113 panic!("expected Completed")
114 };
115
116 assert!(result.success);
117 assert!(result
118 .result
119 .contains("with instructions: Preserve all function signatures"));
120 }
121
122 #[tokio::test]
123 async fn execute_with_empty_instructions() {
124 let tool = CompactContextTool;
125 let out = tool
126 .invoke(
127 serde_json::json!({
128 "instructions": ""
129 }),
130 ToolCtx::none("t"),
131 )
132 .await
133 .expect("invoke should succeed");
134 let ToolOutcome::Completed(result) = out else {
135 panic!("expected Completed")
136 };
137
138 assert!(result.success);
139 assert!(!result.result.contains("with instructions"));
140 }
141
142 #[tokio::test]
143 async fn execute_with_null_instructions() {
144 let tool = CompactContextTool;
145 let out = tool
146 .invoke(
147 serde_json::json!({
148 "instructions": null
149 }),
150 ToolCtx::none("t"),
151 )
152 .await
153 .expect("invoke should succeed");
154 let ToolOutcome::Completed(result) = out else {
155 panic!("expected Completed")
156 };
157
158 assert!(result.success);
159 assert!(!result.result.contains("with instructions"));
160 }
161
162 #[tokio::test]
163 async fn execute_with_invalid_args_returns_error() {
164 let tool = CompactContextTool;
165 let result = tool
166 .invoke(serde_json::json!("not an object"), ToolCtx::none("t"))
167 .await;
168
169 assert!(result.is_err());
170 match result {
171 Err(ToolError::Execution(msg)) => assert!(msg.contains("invalid arguments")),
172 Err(other) => panic!("expected Execution error, got: {other:?}"),
173 Ok(_) => panic!("expected error"),
174 }
175 }
176
177 #[tokio::test]
178 async fn execute_with_context_delegates_to_execute() {
179 let tool = CompactContextTool;
180 let out = tool
181 .invoke(
182 serde_json::json!({"instructions": "keep task status"}),
183 ToolCtx::none("call_123"),
184 )
185 .await
186 .expect("invoke should succeed");
187 let ToolOutcome::Completed(result) = out else {
188 panic!("expected Completed")
189 };
190
191 assert!(result.success);
192 assert!(result.result.contains("keep task status"));
193 }
194
195 #[test]
196 fn tool_name_is_compact_context() {
197 let tool = CompactContextTool;
198 assert_eq!(tool.name(), "compact_context");
199 }
200
201 #[test]
202 fn parameters_schema_has_instructions_field() {
203 let tool = CompactContextTool;
204 let schema = tool.parameters_schema();
205 let props = schema
206 .get("properties")
207 .expect("schema should have properties");
208 assert!(
209 props.get("instructions").is_some(),
210 "should have instructions property"
211 );
212 assert!(
214 schema.get("required").is_none(),
215 "instructions should be optional"
216 );
217 }
218}