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 }]
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(¶ms.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 context: None,
130
131 tool_call_id: String::new(),
132 skill_name: None,
133 }
134 }
135
136 #[tokio::test]
137 async fn tool_definitions_returns_one_tool() {
138 let (store, _) = make_store_with_conv().await;
139 let exec = OverflowToolExecutor::new(store);
140 let defs = exec.tool_definitions();
141 assert_eq!(defs.len(), 1);
142 assert_eq!(defs[0].id.as_ref(), OverflowToolExecutor::TOOL_NAME);
143 }
144
145 #[tokio::test]
146 async fn execute_always_returns_none() {
147 let (store, _) = make_store_with_conv().await;
148 let exec = OverflowToolExecutor::new(store);
149 let result = exec.execute("anything").await.unwrap();
150 assert!(result.is_none());
151 }
152
153 #[tokio::test]
154 async fn unknown_tool_returns_none() {
155 let (store, _) = make_store_with_conv().await;
156 let exec = OverflowToolExecutor::new(store);
157 let call = ToolCall {
158 tool_id: zeph_common::ToolName::new("other_tool"),
159 params: serde_json::Map::new(),
160 caller_id: None,
161 context: None,
162
163 tool_call_id: String::new(),
164 skill_name: None,
165 };
166 let result = exec.execute_tool_call(&call).await.unwrap();
167 assert!(result.is_none());
168 }
169
170 #[tokio::test]
171 async fn invalid_uuid_returns_error() {
172 let (store, cid) = make_store_with_conv().await;
173 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
174 let call = make_call("not-a-uuid");
175 let err = exec.execute_tool_call(&call).await.unwrap_err();
176 assert!(matches!(err, ToolError::InvalidParams { .. }));
177 }
178
179 #[tokio::test]
180 async fn overflow_prefix_accepted_and_stripped() {
181 let (store, cid) = make_store_with_conv().await;
182 let content = b"prefixed overflow content";
183 let uuid = store
184 .save_overflow(cid, content)
185 .await
186 .expect("save_overflow");
187
188 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
189 let call = make_call(&format!("overflow:{uuid}"));
190 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
191 assert_eq!(result.summary.as_bytes(), content);
192 }
193
194 #[tokio::test]
195 async fn bare_uuid_still_accepted() {
196 let (store, cid) = make_store_with_conv().await;
197 let content = b"bare uuid content";
198 let uuid = store
199 .save_overflow(cid, content)
200 .await
201 .expect("save_overflow");
202
203 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
204 let call = make_call(&uuid);
205 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
206 assert_eq!(result.summary.as_bytes(), content);
207 }
208
209 #[tokio::test]
210 async fn invalid_uuid_with_overflow_prefix_returns_error() {
211 let (store, cid) = make_store_with_conv().await;
212 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
213 let call = make_call("overflow:not-a-uuid");
214 let err = exec.execute_tool_call(&call).await.unwrap_err();
215 assert!(matches!(err, ToolError::InvalidParams { .. }));
216 }
217
218 #[tokio::test]
219 async fn missing_entry_returns_error() {
220 let (store, cid) = make_store_with_conv().await;
221 let exec = OverflowToolExecutor::new(store).with_conversation(cid);
222 let call = make_call("00000000-0000-0000-0000-000000000000");
223 let err = exec.execute_tool_call(&call).await.unwrap_err();
224 assert!(matches!(err, ToolError::Execution(_)));
225 }
226
227 #[tokio::test]
228 async fn no_conversation_returns_error() {
229 let (store, cid) = make_store_with_conv().await;
230 let uuid = store.save_overflow(cid, b"data").await.expect("save");
231 let exec = OverflowToolExecutor::new(store);
233 let call = make_call(&uuid);
234 let err = exec.execute_tool_call(&call).await.unwrap_err();
235 assert!(matches!(err, ToolError::Execution(_)));
236 }
237
238 #[tokio::test]
239 async fn valid_entry_returns_content() {
240 let (store, cid) = make_store_with_conv().await;
241 let content = b"full tool output content";
242 let uuid = store
243 .save_overflow(cid, content)
244 .await
245 .expect("save_overflow");
246
247 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
248 let call = make_call(&uuid);
249 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
250 assert_eq!(result.tool_name, OverflowToolExecutor::TOOL_NAME);
251 assert_eq!(result.summary.as_bytes(), content);
252 }
253
254 #[tokio::test]
255 async fn cross_conversation_access_denied() {
256 let (store, cid1) = make_store_with_conv().await;
257 let cid2 = store
258 .create_conversation()
259 .await
260 .expect("create_conversation")
261 .0;
262 let uuid = store.save_overflow(cid1, b"secret").await.expect("save");
263 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid2);
265 let call = make_call(&uuid);
266 let err = exec.execute_tool_call(&call).await.unwrap_err();
267 assert!(
268 matches!(err, ToolError::Execution(_)),
269 "must not access overflow from a different conversation"
270 );
271 }
272
273 #[tokio::test]
274 async fn read_overflow_output_is_not_reoverflowed() {
275 let (store, cid) = make_store_with_conv().await;
278 let big_content = "x".repeat(100_000).into_bytes();
279 let uuid = store
280 .save_overflow(cid, &big_content)
281 .await
282 .expect("save_overflow");
283
284 let exec = OverflowToolExecutor::new(Arc::clone(&store)).with_conversation(cid);
285 let call = make_call(&uuid);
286 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
287 assert_eq!(
288 result.summary.len(),
289 100_000,
290 "full content must be returned"
291 );
292 }
293}