1use serde_json::{json, Value};
16
17use crate::serve::{QueryArgs, Request, SearchArgs, SearchEpisodesArgs, TraverseArgs};
18
19const MIN_LIMIT: u64 = 1;
22const MAX_LIMIT: u64 = 50;
23const MAX_EPISODE_LIMIT: u64 = 20;
24const MAX_TRAVERSE_DEPTH: u64 = 4;
27
28const DEFAULT_ENTITY_LIMIT: u64 = 8;
29const DEFAULT_EPISODE_LIMIT: u64 = 5;
30const DEFAULT_TRAVERSE_DEPTH: u64 = 2;
31const QUERY_GRAPH_DEPTH: u32 = 1;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InvalidArguments(pub String);
41
42impl std::fmt::Display for InvalidArguments {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(&self.0)
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Tool {
51 Search,
53 Query,
55 Traverse,
57 Episodes,
59 Status,
61}
62
63pub const ALL: [Tool; 5] = [
68 Tool::Query,
69 Tool::Search,
70 Tool::Episodes,
71 Tool::Traverse,
72 Tool::Status,
73];
74
75impl Tool {
76 #[must_use]
78 pub fn from_name(name: &str) -> Option<Self> {
79 ALL.into_iter().find(|tool| tool.name() == name)
80 }
81
82 #[must_use]
84 pub const fn name(self) -> &'static str {
85 match self {
86 Tool::Search => "recall_search",
87 Tool::Query => "recall_query",
88 Tool::Traverse => "recall_traverse",
89 Tool::Episodes => "recall_episodes",
90 Tool::Status => "recall_status",
91 }
92 }
93
94 #[must_use]
96 pub const fn title(self) -> &'static str {
97 match self {
98 Tool::Search => "Search memory for entities",
99 Tool::Query => "Recall from memory",
100 Tool::Traverse => "Explore an entity's relationships",
101 Tool::Episodes => "Search past conversations",
102 Tool::Status => "Memory graph status",
103 }
104 }
105
106 #[must_use]
108 pub const fn description(self) -> &'static str {
109 match self {
110 Tool::Query => {
111 "Recall what you already know about something, from your own long-term memory of \
112 past sessions. This is the default memory lookup and usually the right first \
113 call. It runs a semantic search over the distilled knowledge graph, expands one \
114 hop along the relationships around each hit, and (by default) also returns the \
115 conversation fragments the knowledge came from. Reach for it whenever the user \
116 refers to something outside this conversation — \"the approach we settled on\", \
117 \"like we did last time\", \"my usual setup\" — or before asserting that you \
118 have no prior context. Returns each entity with its type, a one-line abstract, \
119 a retrieval score, and whether it was matched directly or pulled in through a \
120 relationship."
121 }
122 Tool::Search => {
123 "Semantic search over the entities in your long-term memory: the people, \
124 projects, tools, services, decisions, preferences and concepts distilled from \
125 past conversations. Matching is by meaning, not keywords, so \"how do we ship \
126 releases\" finds entities about CI, tagging and deployment. Returns names, \
127 types, abstracts and retrieval scores — a compact map of what is known. Use \
128 this when you want the inventory of relevant entities and nothing more; use \
129 recall_query when you also want their relationships and the original \
130 conversation text."
131 }
132 Tool::Episodes => {
133 "Search the raw conversation fragments (episodes) stored in memory, rather than \
134 the distilled entities. Each result is a dated chunk of a past session with its \
135 session id and text. Use it when you need what was actually said — exact \
136 wording, a command, a number, a snippet of code — instead of a summarised fact, \
137 or when recall_search and recall_query come back empty because the topic was \
138 discussed but never distilled into an entity. Episodes are the ground truth the \
139 entities were derived from."
140 }
141 Tool::Traverse => {
142 "Walk the relationships out of one named entity and show them as a tree, with \
143 each edge's confidence. Use it after recall_search or recall_query has given \
144 you an exact entity name, when you need the structure around a fact rather than \
145 more facts: what a project depends on, who decided what, which choice \
146 superseded which. The entity name must match an existing entity exactly. Edges \
147 annotated with a percentage are ones the graph is not fully certain of — that \
148 number is accumulated Bayesian evidence, not a guess — and edges marked \
149 [superseded] describe something that was true once and no longer is."
150 }
151 Tool::Status => {
152 "Report the size and shape of the memory graph: how many entities, relationships \
153 and conversation episodes it holds, plus the entity counts by type. Use it to \
154 tell an empty memory apart from a failed lookup — if a recall returns nothing, \
155 this says whether that means \"never discussed\" or \"nothing has been ingested \
156 yet\". Takes no arguments."
157 }
158 }
159 }
160
161 #[must_use]
163 pub fn input_schema(self) -> Value {
164 match self {
165 Tool::Search => object_schema(
166 json!({
167 "query": {
168 "type": "string",
169 "description": "What to look for, in natural language. A question or a \
170 topic both work; matching is on meaning, not wording."
171 },
172 "limit": limit_schema(
173 MAX_LIMIT,
174 DEFAULT_ENTITY_LIMIT,
175 "Maximum entities to return.",
176 ),
177 }),
178 &["query"],
179 ),
180 Tool::Query => object_schema(
181 json!({
182 "query": {
183 "type": "string",
184 "description": "What you are trying to remember, in natural language. \
185 Phrase it as the actual question — the whole query is \
186 embedded, so more context retrieves better."
187 },
188 "limit": limit_schema(
189 MAX_LIMIT,
190 DEFAULT_ENTITY_LIMIT,
191 "Maximum entities to return.",
192 ),
193 "include_episodes": {
194 "type": "boolean",
195 "description": "Also return the conversation fragments behind the \
196 entities. Defaults to true; set false when you only \
197 need the distilled facts and want a shorter result."
198 },
199 }),
200 &["query"],
201 ),
202 Tool::Episodes => object_schema(
203 json!({
204 "query": {
205 "type": "string",
206 "description": "What was said, in natural language. Matching is on \
207 meaning, so paraphrasing the topic works."
208 },
209 "limit": limit_schema(
210 MAX_EPISODE_LIMIT,
211 DEFAULT_EPISODE_LIMIT,
212 "Maximum conversation fragments to return. Fragments are long; ask for \
213 few.",
214 ),
215 }),
216 &["query"],
217 ),
218 Tool::Traverse => object_schema(
219 json!({
220 "entity": {
221 "type": "string",
222 "description": "Exact name of the entity to start from, as returned by \
223 recall_search or recall_query."
224 },
225 "depth": {
226 "type": "integer",
227 "minimum": MIN_LIMIT,
228 "maximum": MAX_TRAVERSE_DEPTH,
229 "description": "How many relationship hops to follow (1-4). Defaults to \
230 2. Each hop multiplies the size of the answer."
231 },
232 }),
233 &["entity"],
234 ),
235 Tool::Status => json!({
236 "type": "object",
237 "properties": {},
238 "additionalProperties": false
239 }),
240 }
241 }
242
243 #[must_use]
245 pub fn descriptor(self) -> Value {
246 json!({
247 "name": self.name(),
248 "title": self.title(),
249 "description": self.description(),
250 "inputSchema": self.input_schema(),
251 })
252 }
253
254 pub fn request(self, arguments: &Value) -> Result<Request, InvalidArguments> {
259 let args = normalize(self, arguments)?;
260 let request = match self {
261 Tool::Search => Request::Search(SearchArgs {
262 query: required_text(&args, "query")?,
263 limit: bounded_int(&args, "limit", DEFAULT_ENTITY_LIMIT, MAX_LIMIT)? as usize,
264 entity_type: None,
265 keyword: None,
266 }),
267 Tool::Query => Request::Query(QueryArgs {
268 query: required_text(&args, "query")?,
269 limit: bounded_int(&args, "limit", DEFAULT_ENTITY_LIMIT, MAX_LIMIT)? as usize,
270 entity_type: None,
271 keyword: None,
272 depth: QUERY_GRAPH_DEPTH,
273 episodes: flag(&args, "include_episodes", true)?,
274 }),
275 Tool::Episodes => Request::SearchEpisodes(SearchEpisodesArgs {
276 query: required_text(&args, "query")?,
277 limit: bounded_int(&args, "limit", DEFAULT_EPISODE_LIMIT, MAX_EPISODE_LIMIT)?
278 as usize,
279 }),
280 Tool::Traverse => Request::Traverse(TraverseArgs {
281 entity: required_text(&args, "entity")?,
282 depth: bounded_int(&args, "depth", DEFAULT_TRAVERSE_DEPTH, MAX_TRAVERSE_DEPTH)?
283 as u32,
284 type_filter: None,
285 }),
286 Tool::Status => Request::Status,
287 };
288 Ok(request)
289 }
290}
291
292fn object_schema(properties: Value, required: &[&str]) -> Value {
295 json!({
296 "type": "object",
297 "properties": properties,
298 "required": required,
299 "additionalProperties": false
300 })
301}
302
303fn limit_schema(max: u64, default: u64, purpose: &str) -> Value {
304 json!({
305 "type": "integer",
306 "minimum": MIN_LIMIT,
307 "maximum": max,
308 "description": format!("{purpose} Between {MIN_LIMIT} and {max}; defaults to {default}."),
309 })
310}
311
312fn normalize(tool: Tool, arguments: &Value) -> Result<Value, InvalidArguments> {
318 match arguments {
319 Value::Object(_) => Ok(arguments.clone()),
320 Value::Null => Ok(json!({})),
321 other => Err(InvalidArguments(format!(
322 "{}: `arguments` must be a JSON object, got {}",
323 tool.name(),
324 type_name(other)
325 ))),
326 }
327}
328
329fn required_text(args: &Value, field: &str) -> Result<String, InvalidArguments> {
330 match args.get(field) {
331 Some(Value::String(text)) if !text.trim().is_empty() => Ok(text.trim().to_string()),
332 Some(Value::String(_)) => Err(InvalidArguments(format!(
333 "`{field}` must not be empty — say what you are looking for"
334 ))),
335 Some(other) => Err(InvalidArguments(format!(
336 "`{field}` must be a string, got {}",
337 type_name(other)
338 ))),
339 None => Err(InvalidArguments(format!("`{field}` is required"))),
340 }
341}
342
343fn bounded_int(args: &Value, field: &str, default: u64, max: u64) -> Result<u64, InvalidArguments> {
349 match args.get(field) {
350 None | Some(Value::Null) => Ok(default),
351 Some(Value::Number(number)) => match number.as_u64() {
352 Some(value) => Ok(value.clamp(MIN_LIMIT, max)),
353 None if number.as_i64().is_some() => Ok(MIN_LIMIT),
356 None => Err(InvalidArguments(format!(
357 "`{field}` must be a whole number between {MIN_LIMIT} and {max}"
358 ))),
359 },
360 Some(other) => Err(InvalidArguments(format!(
361 "`{field}` must be a whole number between {MIN_LIMIT} and {max}, got {}",
362 type_name(other)
363 ))),
364 }
365}
366
367fn flag(args: &Value, field: &str, default: bool) -> Result<bool, InvalidArguments> {
368 match args.get(field) {
369 None | Some(Value::Null) => Ok(default),
370 Some(Value::Bool(value)) => Ok(*value),
371 Some(other) => Err(InvalidArguments(format!(
372 "`{field}` must be true or false, got {}",
373 type_name(other)
374 ))),
375 }
376}
377
378fn type_name(value: &Value) -> &'static str {
379 match value {
380 Value::Null => "null",
381 Value::Bool(_) => "a boolean",
382 Value::Number(_) => "a number",
383 Value::String(_) => "a string",
384 Value::Array(_) => "an array",
385 Value::Object(_) => "an object",
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn every_tool_resolves_from_its_own_name() {
395 for tool in ALL {
396 assert_eq!(Tool::from_name(tool.name()), Some(tool));
397 }
398 }
399
400 #[test]
401 fn unknown_names_do_not_resolve() {
402 assert_eq!(Tool::from_name("recall_forget"), None);
403 assert_eq!(Tool::from_name(""), None);
404 }
405
406 #[test]
407 fn descriptors_carry_a_schema_and_a_description() {
408 for tool in ALL {
409 let descriptor = tool.descriptor();
410 assert_eq!(descriptor["name"], tool.name());
411 assert_eq!(descriptor["inputSchema"]["type"], "object");
412 assert!(
413 descriptor["description"].as_str().unwrap().len() > 80,
414 "{} needs a description an agent can choose from",
415 tool.name()
416 );
417 }
418 }
419
420 #[test]
421 fn search_defaults_the_limit() {
422 let request = Tool::Search.request(&json!({ "query": "rust" })).unwrap();
423 assert_eq!(
424 request,
425 Request::Search(SearchArgs {
426 query: "rust".into(),
427 limit: DEFAULT_ENTITY_LIMIT as usize,
428 entity_type: None,
429 keyword: None,
430 })
431 );
432 }
433
434 #[test]
435 fn query_includes_episodes_unless_told_otherwise() {
436 let Request::Query(args) = Tool::Query.request(&json!({ "query": "deploys" })).unwrap()
437 else {
438 panic!("expected a query request");
439 };
440 assert!(args.episodes);
441 assert_eq!(args.depth, QUERY_GRAPH_DEPTH);
442
443 let Request::Query(args) = Tool::Query
444 .request(&json!({ "query": "deploys", "include_episodes": false }))
445 .unwrap()
446 else {
447 panic!("expected a query request");
448 };
449 assert!(!args.episodes);
450 }
451
452 #[test]
453 fn status_ignores_arguments_and_absent_arguments() {
454 assert_eq!(Tool::Status.request(&Value::Null).unwrap(), Request::Status);
455 assert_eq!(
456 Tool::Status.request(&json!({ "noise": 1 })).unwrap(),
457 Request::Status
458 );
459 }
460
461 #[test]
462 fn oversized_limits_clamp_instead_of_failing() {
463 let Request::Search(args) = Tool::Search
464 .request(&json!({ "query": "rust", "limit": 9_000 }))
465 .unwrap()
466 else {
467 panic!("expected a search request");
468 };
469 assert_eq!(args.limit, MAX_LIMIT as usize);
470
471 let Request::Traverse(args) = Tool::Traverse
472 .request(&json!({ "entity": "Rust", "depth": 0 }))
473 .unwrap()
474 else {
475 panic!("expected a traverse request");
476 };
477 assert_eq!(args.depth, MIN_LIMIT as u32);
478 }
479
480 #[test]
481 fn missing_required_arguments_are_reported_by_name() {
482 let error = Tool::Search.request(&json!({})).unwrap_err();
483 assert!(error.to_string().contains("`query` is required"), "{error}");
484
485 let error = Tool::Traverse.request(&json!({ "depth": 2 })).unwrap_err();
486 assert!(
487 error.to_string().contains("`entity` is required"),
488 "{error}"
489 );
490 }
491
492 #[test]
493 fn blank_and_mistyped_arguments_are_rejected() {
494 let error = Tool::Search.request(&json!({ "query": " " })).unwrap_err();
495 assert!(error.to_string().contains("must not be empty"), "{error}");
496
497 let error = Tool::Search.request(&json!({ "query": 12 })).unwrap_err();
498 assert!(error.to_string().contains("must be a string"), "{error}");
499
500 let error = Tool::Search
501 .request(&json!({ "query": "rust", "limit": "many" }))
502 .unwrap_err();
503 assert!(error.to_string().contains("whole number"), "{error}");
504
505 let error = Tool::Query
506 .request(&json!({ "query": "rust", "include_episodes": "yes" }))
507 .unwrap_err();
508 assert!(error.to_string().contains("true or false"), "{error}");
509 }
510
511 #[test]
512 fn non_object_arguments_are_rejected() {
513 let error = Tool::Search.request(&json!([1, 2, 3])).unwrap_err();
514 assert!(
515 error.to_string().contains("must be a JSON object"),
516 "{error}"
517 );
518 }
519
520 #[test]
521 fn schemas_declare_their_required_arguments() {
522 assert_eq!(Tool::Search.input_schema()["required"], json!(["query"]));
523 assert_eq!(Tool::Traverse.input_schema()["required"], json!(["entity"]));
524 assert_eq!(
525 Tool::Status.input_schema()["additionalProperties"],
526 json!(false)
527 );
528 }
529}