Skip to main content

bamboo_server/server_tools/
compact.rs

1use async_trait::async_trait;
2use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, 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    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
46        let parsed: CompactContextArgs = serde_json::from_value(args.clone()).map_err(|e| {
47            ToolError::Execution(format!("invalid arguments for compact_context: {e}"))
48        })?;
49
50        let instructions_note = parsed
51            .instructions
52            .as_deref()
53            .filter(|s| !s.is_empty())
54            .map(|i| format!(" with instructions: {i}"))
55            .unwrap_or_default();
56
57        Ok(ToolResult {
58            success: true,
59            result: format!(
60                "Context compression requested{}. Compression will be applied before the next turn.",
61                instructions_note
62            ),
63            display_preference: Some("Collapsible".to_string()),
64        })
65    }
66
67    async fn execute_with_context(
68        &self,
69        args: serde_json::Value,
70        _ctx: ToolExecutionContext<'_>,
71    ) -> Result<ToolResult, ToolError> {
72        self.execute(args).await
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[tokio::test]
81    async fn execute_without_instructions() {
82        let tool = CompactContextTool;
83        let result = tool
84            .execute(serde_json::json!({}))
85            .await
86            .expect("execute should succeed");
87
88        assert!(result.success);
89        assert_eq!(
90            result.result,
91            "Context compression requested. Compression will be applied before the next turn."
92        );
93        assert_eq!(result.display_preference.as_deref(), Some("Collapsible"));
94    }
95
96    #[tokio::test]
97    async fn execute_with_instructions() {
98        let tool = CompactContextTool;
99        let result = tool
100            .execute(serde_json::json!({
101                "instructions": "Preserve all function signatures"
102            }))
103            .await
104            .expect("execute should succeed");
105
106        assert!(result.success);
107        assert!(result
108            .result
109            .contains("with instructions: Preserve all function signatures"));
110    }
111
112    #[tokio::test]
113    async fn execute_with_empty_instructions() {
114        let tool = CompactContextTool;
115        let result = tool
116            .execute(serde_json::json!({
117                "instructions": ""
118            }))
119            .await
120            .expect("execute should succeed");
121
122        assert!(result.success);
123        assert!(!result.result.contains("with instructions"));
124    }
125
126    #[tokio::test]
127    async fn execute_with_null_instructions() {
128        let tool = CompactContextTool;
129        let result = tool
130            .execute(serde_json::json!({
131                "instructions": null
132            }))
133            .await
134            .expect("execute should succeed");
135
136        assert!(result.success);
137        assert!(!result.result.contains("with instructions"));
138    }
139
140    #[tokio::test]
141    async fn execute_with_invalid_args_returns_error() {
142        let tool = CompactContextTool;
143        let result = tool.execute(serde_json::json!("not an object")).await;
144
145        assert!(result.is_err());
146        match result.unwrap_err() {
147            ToolError::Execution(msg) => assert!(msg.contains("invalid arguments")),
148            other => panic!("expected Execution error, got: {other:?}"),
149        }
150    }
151
152    #[tokio::test]
153    async fn execute_with_context_delegates_to_execute() {
154        let tool = CompactContextTool;
155        let result = tool
156            .execute_with_context(
157                serde_json::json!({"instructions": "keep task status"}),
158                ToolExecutionContext::none("call_123"),
159            )
160            .await
161            .expect("execute_with_context should succeed");
162
163        assert!(result.success);
164        assert!(result.result.contains("keep task status"));
165    }
166
167    #[test]
168    fn tool_name_is_compact_context() {
169        let tool = CompactContextTool;
170        assert_eq!(tool.name(), "compact_context");
171    }
172
173    #[test]
174    fn parameters_schema_has_instructions_field() {
175        let tool = CompactContextTool;
176        let schema = tool.parameters_schema();
177        let props = schema
178            .get("properties")
179            .expect("schema should have properties");
180        assert!(
181            props.get("instructions").is_some(),
182            "should have instructions property"
183        );
184        // No required fields — instructions is optional
185        assert!(
186            schema.get("required").is_none(),
187            "instructions should be optional"
188        );
189    }
190}