Skip to main content

zeph_core/
overflow_tools.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::sync::Arc;
5
6use zeph_memory::store::SqliteStore;
7use zeph_tools::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params};
8use zeph_tools::registry::{InvocationHint, ToolDef};
9
10#[derive(Debug, Clone, serde::Deserialize, schemars::JsonSchema)]
11struct ReadOverflowParams {
12    /// The bare UUID from the overflow notice. The `overflow:` prefix is accepted but stripped automatically.
13    id: String,
14}
15
16pub struct OverflowToolExecutor {
17    sqlite: Arc<SqliteStore>,
18    conversation_id: Option<i64>,
19}
20
21impl OverflowToolExecutor {
22    pub const TOOL_NAME: &'static str = "read_overflow";
23
24    #[must_use]
25    pub fn new(sqlite: Arc<SqliteStore>) -> Self {
26        Self {
27            sqlite,
28            conversation_id: None,
29        }
30    }
31
32    #[must_use]
33    pub fn with_conversation(mut self, conversation_id: i64) -> Self {
34        self.conversation_id = Some(conversation_id);
35        self
36    }
37}
38
39impl ToolExecutor for OverflowToolExecutor {
40    fn tool_definitions(&self) -> Vec<ToolDef> {
41        vec![ToolDef {
42            id: Self::TOOL_NAME.into(),
43            description: "Retrieve the full content of a tool output that was truncated due to \
44                size. Use when a previous tool result contains an overflow notice. \
45                Parameters: id (string, required) — the bare UUID from the notice \
46                (e.g. '550e8400-e29b-41d4-a716-446655440000'). \
47                Returns: full original tool output text. Errors: NotFound if the \
48                overflow entry has expired or does not exist."
49                .into(),
50            schema: schemars::schema_for!(ReadOverflowParams),
51            invocation: InvocationHint::ToolCall,
52            output_schema: None,
53            server_id: None,
54        }]
55    }
56
57    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
58        Ok(None)
59    }
60
61    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
62        if call.tool_id != Self::TOOL_NAME {
63            return Ok(None);
64        }
65        let params: ReadOverflowParams = deserialize_params(&call.params)?;
66
67        let id = params.id.strip_prefix("overflow:").unwrap_or(&params.id);
68
69        if uuid::Uuid::parse_str(id).is_err() {
70            return Err(ToolError::InvalidParams {
71                message: "id must be a valid UUID".to_owned(),
72            });
73        }
74
75        let Some(conv_id) = self.conversation_id else {
76            return Err(ToolError::Execution(std::io::Error::other(
77                "overflow entry not found or expired",
78            )));
79        };
80
81        match self.sqlite.load_overflow(id, conv_id).await {
82            Ok(Some(bytes)) => {
83                let text = String::from_utf8_lossy(&bytes).into_owned();
84                Ok(Some(ToolOutput {
85                    tool_name: zeph_common::ToolName::new(Self::TOOL_NAME),
86                    summary: text,
87                    blocks_executed: 1,
88                    filter_stats: None,
89                    diff: None,
90                    streamed: false,
91                    terminal_id: None,
92                    locations: None,
93                    raw_response: None,
94                    claim_source: None,
95                }))
96            }
97            Ok(None) => Err(ToolError::Execution(std::io::Error::other(
98                "overflow entry not found or expired",
99            ))),
100            Err(e) => Err(ToolError::Execution(std::io::Error::other(format!(
101                "failed to load overflow: {e}"
102            )))),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::assert_matches;
111    use zeph_memory::store::SqliteStore;
112
113    async fn make_store_with_conv() -> (Arc<SqliteStore>, i64) {
114        let store = SqliteStore::new(":memory:")
115            .await
116            .expect("SqliteStore::new");
117        let cid = store
118            .create_conversation()
119            .await
120            .expect("create_conversation");
121        (Arc::new(store), cid.0)
122    }
123
124    fn make_call(id: &str) -> ToolCall {
125        let mut params = serde_json::Map::new();
126        params.insert("id".into(), serde_json::Value::String(id.to_owned()));
127        ToolCall {
128            tool_id: zeph_common::ToolName::new("read_overflow"),
129            params,
130            caller_id: None,
131            context: None,
132
133            tool_call_id: String::new(),
134            skill_name: None,
135        }
136    }
137
138    #[tokio::test]
139    async fn tool_definitions_returns_one_tool() {
140        let (store, _) = make_store_with_conv().await;
141        let exec = OverflowToolExecutor::new(store);
142        let defs = exec.tool_definitions();
143        assert_eq!(defs.len(), 1);
144        assert_eq!(defs[0].id.as_ref(), OverflowToolExecutor::TOOL_NAME);
145    }
146
147    #[tokio::test]
148    async fn execute_always_returns_none() {
149        let (store, _) = make_store_with_conv().await;
150        let exec = OverflowToolExecutor::new(store);
151        let result = exec.execute("anything").await.unwrap();
152        assert!(result.is_none());
153    }
154
155    #[tokio::test]
156    async fn unknown_tool_returns_none() {
157        let (store, _) = make_store_with_conv().await;
158        let exec = OverflowToolExecutor::new(store);
159        let call = ToolCall {
160            tool_id: zeph_common::ToolName::new("other_tool"),
161            params: serde_json::Map::new(),
162            caller_id: None,
163            context: None,
164
165            tool_call_id: String::new(),
166            skill_name: None,
167        };
168        let result = exec.execute_tool_call(&call).await.unwrap();
169        assert!(result.is_none());
170    }
171
172    #[tokio::test]
173    async fn invalid_uuid_returns_error() {
174        let (store, cid) = make_store_with_conv().await;
175        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
176        let call = make_call("not-a-uuid");
177        let err = exec.execute_tool_call(&call).await.unwrap_err();
178        assert_matches!(err, ToolError::InvalidParams { .. });
179    }
180
181    #[tokio::test]
182    async fn overflow_prefix_accepted_and_stripped() {
183        let (store, cid) = make_store_with_conv().await;
184        let content = b"prefixed overflow content";
185        let uuid = store
186            .save_overflow(cid, content)
187            .await
188            .expect("save_overflow");
189
190        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
191        let call = make_call(&format!("overflow:{uuid}"));
192        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
193        assert_eq!(result.summary.as_bytes(), content);
194    }
195
196    #[tokio::test]
197    async fn bare_uuid_still_accepted() {
198        let (store, cid) = make_store_with_conv().await;
199        let content = b"bare uuid content";
200        let uuid = store
201            .save_overflow(cid, content)
202            .await
203            .expect("save_overflow");
204
205        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
206        let call = make_call(&uuid);
207        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
208        assert_eq!(result.summary.as_bytes(), content);
209    }
210
211    #[tokio::test]
212    async fn invalid_uuid_with_overflow_prefix_returns_error() {
213        let (store, cid) = make_store_with_conv().await;
214        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
215        let call = make_call("overflow:not-a-uuid");
216        let err = exec.execute_tool_call(&call).await.unwrap_err();
217        assert_matches!(err, ToolError::InvalidParams { .. });
218    }
219
220    #[tokio::test]
221    async fn missing_entry_returns_error() {
222        let (store, cid) = make_store_with_conv().await;
223        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
224        let call = make_call("00000000-0000-0000-0000-000000000000");
225        let err = exec.execute_tool_call(&call).await.unwrap_err();
226        assert_matches!(err, ToolError::Execution(_));
227    }
228
229    #[tokio::test]
230    async fn no_conversation_returns_error() {
231        let (store, cid) = make_store_with_conv().await;
232        let uuid = store.save_overflow(cid, b"data").await.expect("save");
233        // Executor without conversation_id must return error (not panic).
234        let exec = OverflowToolExecutor::new(store);
235        let call = make_call(&uuid);
236        let err = exec.execute_tool_call(&call).await.unwrap_err();
237        assert_matches!(err, ToolError::Execution(_));
238    }
239
240    #[tokio::test]
241    async fn valid_entry_returns_content() {
242        let (store, cid) = make_store_with_conv().await;
243        let content = b"full tool output content";
244        let uuid = store
245            .save_overflow(cid, content)
246            .await
247            .expect("save_overflow");
248
249        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
250        let call = make_call(&uuid);
251        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
252        assert_eq!(result.tool_name, OverflowToolExecutor::TOOL_NAME);
253        assert_eq!(result.summary.as_bytes(), content);
254    }
255
256    #[tokio::test]
257    async fn cross_conversation_access_denied() {
258        let (store, cid1) = make_store_with_conv().await;
259        let cid2 = store
260            .create_conversation()
261            .await
262            .expect("create_conversation")
263            .0;
264        let uuid = store.save_overflow(cid1, b"secret").await.expect("save");
265        // Executor bound to cid2 must not retrieve cid1's overflow.
266        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid2);
267        let call = make_call(&uuid);
268        let err = exec.execute_tool_call(&call).await.unwrap_err();
269        assert!(
270            matches!(err, ToolError::Execution(_)),
271            "must not access overflow from a different conversation"
272        );
273    }
274
275    #[tokio::test]
276    async fn read_overflow_output_is_not_reoverflowed() {
277        // Verify that the tool returns raw content regardless of size.
278        // The caller (native.rs) is responsible for skipping overflow for read_overflow results.
279        let (store, cid) = make_store_with_conv().await;
280        let big_content = "x".repeat(100_000).into_bytes();
281        let uuid = store
282            .save_overflow(cid, &big_content)
283            .await
284            .expect("save_overflow");
285
286        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
287        let call = make_call(&uuid);
288        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
289        assert_eq!(
290            result.summary.len(),
291            100_000,
292            "full content must be returned"
293        );
294    }
295}