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::sqlite::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        }]
53    }
54
55    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
56        Ok(None)
57    }
58
59    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
60        if call.tool_id != Self::TOOL_NAME {
61            return Ok(None);
62        }
63        let params: ReadOverflowParams = deserialize_params(&call.params)?;
64
65        let id = params.id.strip_prefix("overflow:").unwrap_or(&params.id);
66
67        if uuid::Uuid::parse_str(id).is_err() {
68            return Err(ToolError::InvalidParams {
69                message: "id must be a valid UUID".to_owned(),
70            });
71        }
72
73        let Some(conv_id) = self.conversation_id else {
74            return Err(ToolError::Execution(std::io::Error::other(
75                "overflow entry not found or expired",
76            )));
77        };
78
79        match self.sqlite.load_overflow(id, conv_id).await {
80            Ok(Some(bytes)) => {
81                let text = String::from_utf8_lossy(&bytes).into_owned();
82                Ok(Some(ToolOutput {
83                    tool_name: Self::TOOL_NAME.to_owned(),
84                    summary: text,
85                    blocks_executed: 1,
86                    filter_stats: None,
87                    diff: None,
88                    streamed: false,
89                    terminal_id: None,
90                    locations: None,
91                    raw_response: None,
92                }))
93            }
94            Ok(None) => Err(ToolError::Execution(std::io::Error::other(
95                "overflow entry not found or expired",
96            ))),
97            Err(e) => Err(ToolError::Execution(std::io::Error::other(format!(
98                "failed to load overflow: {e}"
99            )))),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use zeph_memory::sqlite::SqliteStore;
108
109    async fn make_store_with_conv() -> (Arc<SqliteStore>, i64) {
110        let store = SqliteStore::new(":memory:")
111            .await
112            .expect("SqliteStore::new");
113        let cid = store
114            .create_conversation()
115            .await
116            .expect("create_conversation");
117        (Arc::new(store), cid.0)
118    }
119
120    fn make_call(id: &str) -> ToolCall {
121        let mut params = serde_json::Map::new();
122        params.insert("id".into(), serde_json::Value::String(id.to_owned()));
123        ToolCall {
124            tool_id: "read_overflow".to_owned(),
125            params,
126        }
127    }
128
129    #[tokio::test]
130    async fn tool_definitions_returns_one_tool() {
131        let (store, _) = make_store_with_conv().await;
132        let exec = OverflowToolExecutor::new(store);
133        let defs = exec.tool_definitions();
134        assert_eq!(defs.len(), 1);
135        assert_eq!(defs[0].id.as_ref(), OverflowToolExecutor::TOOL_NAME);
136    }
137
138    #[tokio::test]
139    async fn execute_always_returns_none() {
140        let (store, _) = make_store_with_conv().await;
141        let exec = OverflowToolExecutor::new(store);
142        let result = exec.execute("anything").await.unwrap();
143        assert!(result.is_none());
144    }
145
146    #[tokio::test]
147    async fn unknown_tool_returns_none() {
148        let (store, _) = make_store_with_conv().await;
149        let exec = OverflowToolExecutor::new(store);
150        let call = ToolCall {
151            tool_id: "other_tool".to_owned(),
152            params: serde_json::Map::new(),
153        };
154        let result = exec.execute_tool_call(&call).await.unwrap();
155        assert!(result.is_none());
156    }
157
158    #[tokio::test]
159    async fn invalid_uuid_returns_error() {
160        let (store, cid) = make_store_with_conv().await;
161        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
162        let call = make_call("not-a-uuid");
163        let err = exec.execute_tool_call(&call).await.unwrap_err();
164        assert!(matches!(err, ToolError::InvalidParams { .. }));
165    }
166
167    #[tokio::test]
168    async fn overflow_prefix_accepted_and_stripped() {
169        let (store, cid) = make_store_with_conv().await;
170        let content = b"prefixed overflow content";
171        let uuid = store
172            .save_overflow(cid, content)
173            .await
174            .expect("save_overflow");
175
176        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
177        let call = make_call(&format!("overflow:{uuid}"));
178        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
179        assert_eq!(result.summary.as_bytes(), content);
180    }
181
182    #[tokio::test]
183    async fn bare_uuid_still_accepted() {
184        let (store, cid) = make_store_with_conv().await;
185        let content = b"bare uuid content";
186        let uuid = store
187            .save_overflow(cid, content)
188            .await
189            .expect("save_overflow");
190
191        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
192        let call = make_call(&uuid);
193        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
194        assert_eq!(result.summary.as_bytes(), content);
195    }
196
197    #[tokio::test]
198    async fn invalid_uuid_with_overflow_prefix_returns_error() {
199        let (store, cid) = make_store_with_conv().await;
200        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
201        let call = make_call("overflow:not-a-uuid");
202        let err = exec.execute_tool_call(&call).await.unwrap_err();
203        assert!(matches!(err, ToolError::InvalidParams { .. }));
204    }
205
206    #[tokio::test]
207    async fn missing_entry_returns_error() {
208        let (store, cid) = make_store_with_conv().await;
209        let exec = OverflowToolExecutor::new(store).with_conversation(cid);
210        let call = make_call("00000000-0000-0000-0000-000000000000");
211        let err = exec.execute_tool_call(&call).await.unwrap_err();
212        assert!(matches!(err, ToolError::Execution(_)));
213    }
214
215    #[tokio::test]
216    async fn no_conversation_returns_error() {
217        let (store, cid) = make_store_with_conv().await;
218        let uuid = store.save_overflow(cid, b"data").await.expect("save");
219        // Executor without conversation_id must return error (not panic).
220        let exec = OverflowToolExecutor::new(store);
221        let call = make_call(&uuid);
222        let err = exec.execute_tool_call(&call).await.unwrap_err();
223        assert!(matches!(err, ToolError::Execution(_)));
224    }
225
226    #[tokio::test]
227    async fn valid_entry_returns_content() {
228        let (store, cid) = make_store_with_conv().await;
229        let content = b"full tool output content";
230        let uuid = store
231            .save_overflow(cid, content)
232            .await
233            .expect("save_overflow");
234
235        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
236        let call = make_call(&uuid);
237        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
238        assert_eq!(result.tool_name, OverflowToolExecutor::TOOL_NAME);
239        assert_eq!(result.summary.as_bytes(), content);
240    }
241
242    #[tokio::test]
243    async fn cross_conversation_access_denied() {
244        let (store, cid1) = make_store_with_conv().await;
245        let cid2 = store
246            .create_conversation()
247            .await
248            .expect("create_conversation")
249            .0;
250        let uuid = store.save_overflow(cid1, b"secret").await.expect("save");
251        // Executor bound to cid2 must not retrieve cid1's overflow.
252        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid2);
253        let call = make_call(&uuid);
254        let err = exec.execute_tool_call(&call).await.unwrap_err();
255        assert!(
256            matches!(err, ToolError::Execution(_)),
257            "must not access overflow from a different conversation"
258        );
259    }
260
261    #[tokio::test]
262    async fn read_overflow_output_is_not_reoverflowed() {
263        // Verify that the tool returns raw content regardless of size.
264        // The caller (native.rs) is responsible for skipping overflow for read_overflow results.
265        let (store, cid) = make_store_with_conv().await;
266        let big_content = "x".repeat(100_000).into_bytes();
267        let uuid = store
268            .save_overflow(cid, &big_content)
269            .await
270            .expect("save_overflow");
271
272        let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
273        let call = make_call(&uuid);
274        let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
275        assert_eq!(
276            result.summary.len(),
277            100_000,
278            "full content must be returned"
279        );
280    }
281}