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