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