Skip to main content

bamboo_server_tools/
compact.rs

1use async_trait::async_trait;
2use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6/// Server-side tool for manually triggering conversation context compression.
7///
8/// Unlike automatic compression (which triggers based on token thresholds), this
9/// tool lets the model or user proactively compress history at natural task boundaries.
10/// It bypasses the exposure gate and always forces a compression cycle.
11pub 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        // No required fields — instructions is optional
213        assert!(
214            schema.get("required").is_none(),
215            "instructions should be optional"
216        );
217    }
218}