1use 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 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(¶ms.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 ..Default::default()
96 }))
97 }
98 Ok(None) => Err(ToolError::Execution(std::io::Error::other(
99 "overflow entry not found or expired",
100 ))),
101 Err(e) => Err(ToolError::Execution(std::io::Error::other(format!(
102 "failed to load overflow: {e}"
103 )))),
104 }
105 }
106
107 zeph_tools::tool_executor_no_inner_defaults!();
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use std::assert_matches;
114 use zeph_memory::store::SqliteStore;
115
116 async fn make_store_with_conv() -> (Arc<SqliteStore>, i64) {
117 let store = SqliteStore::new(":memory:")
118 .await
119 .expect("SqliteStore::new");
120 let cid = store
121 .create_conversation()
122 .await
123 .expect("create_conversation");
124 (Arc::new(store), cid.0)
125 }
126
127 fn make_call(id: &str) -> ToolCall {
128 let mut params = serde_json::Map::new();
129 params.insert("id".into(), serde_json::Value::String(id.to_owned()));
130 ToolCall {
131 tool_id: zeph_common::ToolName::new("read_overflow"),
132 params,
133 caller_id: None,
134 context: None,
135
136 tool_call_id: String::new(),
137 skill_name: None,
138 }
139 }
140
141 #[tokio::test]
142 async fn tool_definitions_returns_one_tool() {
143 let (store, _) = make_store_with_conv().await;
144 let exec = OverflowToolExecutor::new(store);
145 let defs = exec.tool_definitions();
146 assert_eq!(defs.len(), 1);
147 assert_eq!(defs[0].id.as_ref(), OverflowToolExecutor::TOOL_NAME);
148 }
149
150 #[tokio::test]
151 async fn execute_always_returns_none() {
152 let (store, _) = make_store_with_conv().await;
153 let exec = OverflowToolExecutor::new(store);
154 let result = exec.execute("anything").await.unwrap();
155 assert!(result.is_none());
156 }
157
158 #[tokio::test]
159 async fn unknown_tool_returns_none() {
160 let (store, _) = make_store_with_conv().await;
161 let exec = OverflowToolExecutor::new(store);
162 let call = ToolCall {
163 tool_id: zeph_common::ToolName::new("other_tool"),
164 params: serde_json::Map::new(),
165 caller_id: None,
166 context: None,
167
168 tool_call_id: String::new(),
169 skill_name: None,
170 };
171 let result = exec.execute_tool_call(&call).await.unwrap();
172 assert!(result.is_none());
173 }
174
175 #[tokio::test]
176 async fn invalid_uuid_returns_error() {
177 let (store, cid) = make_store_with_conv().await;
178 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
179 let call = make_call("not-a-uuid");
180 let err = exec.execute_tool_call(&call).await.unwrap_err();
181 assert_matches!(err, ToolError::InvalidParams { .. });
182 }
183
184 #[tokio::test]
185 async fn overflow_prefix_accepted_and_stripped() {
186 let (store, cid) = make_store_with_conv().await;
187 let content = b"prefixed overflow 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(&format!("overflow:{uuid}"));
195 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
196 assert_eq!(result.summary.as_bytes(), content);
197 }
198
199 #[tokio::test]
200 async fn bare_uuid_still_accepted() {
201 let (store, cid) = make_store_with_conv().await;
202 let content = b"bare uuid content";
203 let uuid = store
204 .save_overflow(cid, content)
205 .await
206 .expect("save_overflow");
207
208 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
209 let call = make_call(&uuid);
210 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
211 assert_eq!(result.summary.as_bytes(), content);
212 }
213
214 #[tokio::test]
215 async fn invalid_uuid_with_overflow_prefix_returns_error() {
216 let (store, cid) = make_store_with_conv().await;
217 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
218 let call = make_call("overflow:not-a-uuid");
219 let err = exec.execute_tool_call(&call).await.unwrap_err();
220 assert_matches!(err, ToolError::InvalidParams { .. });
221 }
222
223 #[tokio::test]
224 async fn missing_entry_returns_error() {
225 let (store, cid) = make_store_with_conv().await;
226 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
227 let call = make_call("00000000-0000-0000-0000-000000000000");
228 let err = exec.execute_tool_call(&call).await.unwrap_err();
229 assert_matches!(err, ToolError::Execution(_));
230 }
231
232 #[tokio::test]
233 async fn no_conversation_returns_error() {
234 let (store, cid) = make_store_with_conv().await;
235 let uuid = store.save_overflow(cid, b"data").await.expect("save");
236 let exec = OverflowToolExecutor::new(store);
238 let call = make_call(&uuid);
239 let err = exec.execute_tool_call(&call).await.unwrap_err();
240 assert_matches!(err, ToolError::Execution(_));
241 }
242
243 #[tokio::test]
244 async fn valid_entry_returns_content() {
245 let (store, cid) = make_store_with_conv().await;
246 let content = b"full tool output content";
247 let uuid = store
248 .save_overflow(cid, content)
249 .await
250 .expect("save_overflow");
251
252 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
253 let call = make_call(&uuid);
254 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
255 assert_eq!(result.tool_name, OverflowToolExecutor::TOOL_NAME);
256 assert_eq!(result.summary.as_bytes(), content);
257 }
258
259 #[tokio::test]
260 async fn cross_conversation_access_denied() {
261 let (store, cid1) = make_store_with_conv().await;
262 let cid2 = store
263 .create_conversation()
264 .await
265 .expect("create_conversation")
266 .0;
267 let uuid = store.save_overflow(cid1, b"secret").await.expect("save");
268 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid2);
270 let call = make_call(&uuid);
271 let err = exec.execute_tool_call(&call).await.unwrap_err();
272 assert!(
273 matches!(err, ToolError::Execution(_)),
274 "must not access overflow from a different conversation"
275 );
276 }
277
278 #[tokio::test]
279 async fn read_overflow_output_is_not_reoverflowed() {
280 let (store, cid) = make_store_with_conv().await;
283 let big_content = "x".repeat(100_000).into_bytes();
284 let uuid = store
285 .save_overflow(cid, &big_content)
286 .await
287 .expect("save_overflow");
288
289 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
290 let call = make_call(&uuid);
291 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
292 assert_eq!(
293 result.summary.len(),
294 100_000,
295 "full content must be returned"
296 );
297 }
298}