zeph_core/
overflow_tools.rs1use 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 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(¶ms.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(¶ms.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 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 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 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}