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