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