1pub mod knowledge;
7
8use crate::context::SomaContext;
9use crate::protocol::{ToolCallResult, ToolDefinition};
10use serde_json::json;
11
12pub fn all_tools() -> Vec<ToolDefinition> {
14 vec![
15 ToolDefinition {
17 name: "list_filters".into(),
18 description: "List available filter source files in the project directory.".into(),
19 input_schema: json!({
20 "type": "object",
21 "properties": {
22 "path": { "type": "string", "description": "Project directory path (optional, uses configured default)" }
23 }
24 }),
25 },
26 ToolDefinition {
27 name: "read_filter_source".into(),
28 description: "Read the source code of a filter file.".into(),
29 input_schema: json!({
30 "type": "object",
31 "properties": {
32 "file_path": { "type": "string", "description": "Path to the filter source file" }
33 },
34 "required": ["file_path"]
35 }),
36 },
37 ToolDefinition {
38 name: "write_filter_source".into(),
39 description: "Write or update filter source code. Creates a backup before writing."
40 .into(),
41 input_schema: json!({
42 "type": "object",
43 "properties": {
44 "file_path": { "type": "string", "description": "Path to the filter source file" },
45 "content": { "type": "string", "description": "New file content" }
46 },
47 "required": ["file_path", "content"]
48 }),
49 },
50 ToolDefinition {
52 name: "run_pipeline".into(),
53 description: "Build a computation graph out of the project's filters and RUN it. \
54 Each node names a filter as `module.Class`, `path/to/file.py:Class`, or a \
55 bare class name found in the files list_filters returns; `config` is its \
56 constructor keywords. Edges connect node ids. The graph is fitted and then \
57 forwarded on `input`, in a Python subprocess rooted at the project, so the \
58 filters you just read with read_filter_source are the ones that run. \
59 Tracked by default: the result carries a run_dir, and kb_summarize_run will \
60 read it back. This EXECUTES project code."
61 .into(),
62 input_schema: json!({
63 "type": "object",
64 "properties": {
65 "nodes": {
66 "type": "array",
67 "description": "Graph nodes, in any order",
68 "items": {
69 "type": "object",
70 "properties": {
71 "id": { "type": "string", "description": "Node id, used by edges" },
72 "filter": { "type": "string", "description": "module.Class | path.py:Class | ClassName" },
73 "config": { "type": "object", "description": "Constructor keyword arguments" },
74 "target": { "type": "string", "description": "Worker tag, for a distributed node" }
75 },
76 "required": ["id", "filter"]
77 }
78 },
79 "edges": {
80 "type": "array",
81 "description": "Directed edges as [from_id, to_id] pairs",
82 "items": { "type": "array", "items": { "type": "string" } }
83 },
84 "input": { "description": "Input data: a number, a list, a nested list, or an object" },
85 "y": { "description": "Targets, for a supervised fit" },
86 "fit": { "type": "boolean", "description": "Fit before forwarding (default true)" },
87 "cache": { "type": "string", "enum": ["memory", "tiered", "none"], "description": "Cache backend (default memory)" },
88 "track": { "type": "boolean", "description": "Record into the experiment pool (default true)" },
89 "name": { "type": "string", "description": "Run name, as it appears in the pool" },
90 "tags": { "type": "array", "items": { "type": "string" } },
91 "params": { "type": "object", "description": "Hyperparameters that live outside the graph, recorded with the run" }
92 },
93 "required": ["nodes", "input"]
94 }),
95 },
96 ToolDefinition {
97 name: "run_study".into(),
98 description: "Search a graph's hyperparameters and RUN the trials. Same node spec \
99 as run_pipeline, with one difference: any config value written as \
100 {\"__search__\": {\"low\": 1e-4, \"high\": 1e-1, \"scale\": \"log\"}} or \
101 {\"__search__\": {\"choices\": [...]}} becomes a dimension to search. The \
102 graph is rebuilt per trial with the sampled values, fitted, and forwarded; \
103 its output is the objective — a number, or an object from which `metric` is \
104 read (soma.library.Eval emits one). Returns the best trial and a run_dir. \
105 This EXECUTES project code, n_trials times."
106 .into(),
107 input_schema: json!({
108 "type": "object",
109 "properties": {
110 "nodes": {
111 "type": "array",
112 "description": "Graph nodes; mark searched values with {\"__search__\": {...}}",
113 "items": {
114 "type": "object",
115 "properties": {
116 "id": { "type": "string" },
117 "filter": { "type": "string" },
118 "config": { "type": "object" }
119 },
120 "required": ["id", "filter"]
121 }
122 },
123 "edges": { "type": "array", "items": { "type": "array", "items": { "type": "string" } } },
124 "input": { "description": "Input data, the same for every trial" },
125 "y": { "description": "Targets, for a supervised fit" },
126 "name": { "type": "string", "description": "Study name" },
127 "strategy": { "type": "string", "enum": ["grid", "random", "bayesian"], "description": "Sampler (default random)" },
128 "n_trials": { "type": "integer", "description": "How many trials to run (default 10)" },
129 "metric": { "type": "string", "description": "Key to read from the graph's output when it is an object (default \"score\")" },
130 "direction": { "type": "string", "enum": ["minimize", "maximize"], "description": "Default minimize" },
131 "seed": { "type": "integer", "description": "Seeds the sampler, so the search repeats" },
132 "cache": { "type": "string", "enum": ["memory", "tiered", "none"] }
133 },
134 "required": ["nodes", "input"]
135 }),
136 },
137 ToolDefinition {
139 name: "record_experiment".into(),
140 description: "Record an experiment by hand. Runs started with graph.track_run() or \
141 study.run() record themselves — with a conclusion, an architecture fingerprint \
142 and a lineage — so use this only for work soma did not execute. To add a \
143 finding to an existing experiment, use kb_record_conclusion instead."
144 .into(),
145 input_schema: json!({
146 "type": "object",
147 "properties": {
148 "id": { "type": "string" },
149 "name": { "type": "string" },
150 "hypothesis": { "type": "string", "description": "What you expected, and why" },
151 "research_line": { "type": "string", "description": "Groups related experiments; inherited from the parent when one is given" },
152 "pipeline_summary": { "type": "string", "description": "One line describing the topology, e.g. 'scaler → encoder → head'" },
153 "params": { "type": "object", "description": "Hyperparameters; a later variant diffs against these" },
154 "metrics": { "type": "object", "description": "Final numeric results" },
155 "parent": { "type": "string", "description": "Experiment this one was derived from. Setting it computes the move between them." },
156 "objective": { "type": "string", "description": "Metric being optimized, so improvement can be judged" },
157 "run_dir": { "type": "string", "description": "Directory holding raw artifacts, if any" },
158 "tags": { "type": "array", "items": { "type": "string" } },
159 "notes": { "type": "string", "description": "What you concluded" }
160 },
161 "required": ["id", "name"]
162 }),
163 },
164 ToolDefinition {
166 name: "kb_find_similar".into(),
167 description: "Find past experiments bearing on the problem at hand — the first \
168 thing to call before designing anything. Ranks by text relevance, \
169 architectural resemblance, recency and importance. Dead ends rank too: not \
170 repeating a failure saves as much time as repeating a success. Each hit \
171 carries its conclusion, the move that produced it, and a run_dir you can read \
172 directly."
173 .into(),
174 input_schema: json!({
175 "type": "object",
176 "properties": {
177 "query": { "type": "string", "description": "What you are trying to do, in words. Names, filters, metrics and symptoms all match." },
178 "like_run": { "type": "string", "description": "Experiment id whose architecture to match. Combine with query, or use alone to find structurally similar work." },
179 "limit": { "type": "integer", "default": 5, "description": "1-50" },
180 "research_line": { "type": "string", "description": "Restrict to one line" },
181 "tags": { "type": "array", "items": { "type": "string" }, "description": "Every listed tag must be present" },
182 "half_life_days": { "type": "number", "default": 30, "description": "Raise it to weight old work more heavily" }
183 }
184 }),
185 },
186 ToolDefinition {
187 name: "kb_lineage".into(),
188 description: "The experiment tree around one run: ancestors above, descendants \
189 below, and on every edge the change that produced the child from its parent \
190 plus what it did to the metrics. This is how you see what has already been \
191 tried from a given starting point."
192 .into(),
193 input_schema: json!({
194 "type": "object",
195 "properties": {
196 "id": { "type": "string", "description": "Experiment or run id" }
197 },
198 "required": ["id"]
199 }),
200 },
201 ToolDefinition {
202 name: "kb_diff".into(),
203 description: "Compare two experiments: what differs in architecture, parameters \
204 and code, what each metric did, and what it cost (wall time, cache hits). \
205 They need not be related — this works on any two ids."
206 .into(),
207 input_schema: json!({
208 "type": "object",
209 "properties": {
210 "a": { "type": "string", "description": "Baseline experiment id" },
211 "b": { "type": "string", "description": "Variant experiment id" }
212 },
213 "required": ["a", "b"]
214 }),
215 },
216 ToolDefinition {
217 name: "kb_record_conclusion".into(),
218 description: "Retain what you learned about a run: why it worked, why it did not, \
219 what to try next. Appended as a separate amendment — the original record is \
220 never rewritten — and indexed so a later kb_find_similar surfaces it. Worth \
221 doing for failures especially."
222 .into(),
223 input_schema: json!({
224 "type": "object",
225 "properties": {
226 "run_id": { "type": "string", "description": "Experiment being annotated" },
227 "notes": { "type": "string", "description": "What you concluded" },
228 "hypothesis": { "type": "string", "description": "The hypothesis this run turned out to be testing" },
229 "tags": { "type": "array", "items": { "type": "string" } }
230 },
231 "required": ["run_id", "notes"]
232 }),
233 },
234 ToolDefinition {
235 name: "kb_branch_from".into(),
236 description: "Point .soma/HEAD at an existing run so the NEXT run records itself \
237 as its child. Use it to go back and try a different variation instead of \
238 continuing from the last thing that happened to run. Creates a sibling \
239 branch; it never moves or rewrites existing history."
240 .into(),
241 input_schema: json!({
242 "type": "object",
243 "properties": {
244 "run_id": { "type": "string", "description": "Run to branch from. Must exist under .soma/runs/." }
245 },
246 "required": ["run_id"]
247 }),
248 },
249 ToolDefinition {
250 name: "kb_summarize_run".into(),
251 description: "Read a run directory and summarize it: outcome, metrics, slowest \
252 node, cache effectiveness, health flags, trials. Works on runs recorded \
253 before the experiment pool existed and on runs that crashed before writing a \
254 journal line, and reports what it could not read rather than staying silent."
255 .into(),
256 input_schema: json!({
257 "type": "object",
258 "properties": {
259 "run_id": { "type": "string", "description": "Run id under .soma/runs/, or a path to a run directory" }
260 },
261 "required": ["run_id"]
262 }),
263 },
264 ToolDefinition {
265 name: "kb_stats".into(),
266 description: "How much this project has recorded and how usable it is: totals, \
267 date span, research lines, and honest coverage — what fraction of records \
268 carry a conclusion, a lineage, an architecture. Call it first when you do not \
269 know whether the pool is worth querying."
270 .into(),
271 input_schema: json!({ "type": "object", "properties": {} }),
272 },
273 ToolDefinition {
274 name: "query_knowledge_base".into(),
275 description: "Search experiments in the knowledge base by text query.".into(),
276 input_schema: json!({
277 "type": "object",
278 "properties": {
279 "query": { "type": "string", "description": "Search query" },
280 "max_results": { "type": "integer", "default": 10 }
281 },
282 "required": ["query"]
283 }),
284 },
285 ToolDefinition {
286 name: "get_trajectory".into(),
287 description: "Get the metric trajectory for a research line.".into(),
288 input_schema: json!({
289 "type": "object",
290 "properties": {
291 "research_line": { "type": "string" },
292 "metric": { "type": "string" }
293 },
294 "required": ["research_line", "metric"]
295 }),
296 },
297 ToolDefinition {
298 name: "get_change_points".into(),
299 description: "Detect significant changes in experiment metrics.".into(),
300 input_schema: json!({
301 "type": "object",
302 "properties": {
303 "research_line": { "type": "string" },
304 "metric": { "type": "string" },
305 "threshold": { "type": "number", "default": 0.05 }
306 },
307 "required": ["research_line", "metric"]
308 }),
309 },
310 ToolDefinition {
311 name: "list_research_lines".into(),
312 description: "List all research lines with trend analysis.".into(),
313 input_schema: json!({
314 "type": "object",
315 "properties": {}
316 }),
317 },
318 ToolDefinition {
319 name: "promising_lines".into(),
320 description: "Get research lines that are improving.".into(),
321 input_schema: json!({
322 "type": "object",
323 "properties": {
324 "metric": { "type": "string", "description": "Metric to evaluate" }
325 },
326 "required": ["metric"]
327 }),
328 },
329 ToolDefinition {
331 name: "create_research_line".into(),
332 description: "Create a new research line for tracking experiments.".into(),
333 input_schema: json!({
334 "type": "object",
335 "properties": {
336 "name": { "type": "string" },
337 "description": { "type": "string" }
338 },
339 "required": ["name"]
340 }),
341 },
342 ToolDefinition {
343 name: "generate_report".into(),
344 description: "Generate a markdown report for a research line.".into(),
345 input_schema: json!({
346 "type": "object",
347 "properties": {
348 "research_line": { "type": "string" }
349 },
350 "required": ["research_line"]
351 }),
352 },
353 ]
354}
355
356pub fn dispatch(
363 ctx: &mut SomaContext,
364 tool_name: &str,
365 params: &serde_json::Value,
366) -> ToolCallResult {
367 if reads_knowledge(tool_name) {
368 ctx.refresh_kb();
369 }
370 match tool_name {
371 "kb_find_similar" => knowledge::find_similar(ctx, params),
373 "kb_lineage" => knowledge::lineage(ctx, params),
374 "kb_diff" => knowledge::diff(ctx, params),
375 "kb_record_conclusion" => knowledge::record_conclusion(ctx, params),
376 "kb_branch_from" => knowledge::branch_from(ctx, params),
377 "kb_summarize_run" => knowledge::summarize_run(ctx, params),
378 "kb_stats" => knowledge::stats(ctx, params),
379 "list_filters" => ctx.list_filters(params),
381 "read_filter_source" => ctx.read_filter_source(params),
382 "write_filter_source" => ctx.write_filter_source(params),
383 "run_pipeline" => ctx.run_pipeline(params),
385 "run_study" => ctx.run_study(params),
386 "record_experiment" => ctx.record_experiment(params),
388 "query_knowledge_base" => ctx.query_knowledge_base(params),
389 "get_trajectory" => ctx.get_trajectory(params),
390 "get_change_points" => ctx.get_change_points(params),
391 "list_research_lines" => ctx.list_research_lines(params),
392 "promising_lines" => ctx.promising_lines(params),
393 "create_research_line" => ctx.create_research_line(params),
395 "generate_report" => ctx.generate_report(params),
396 _ => ToolCallResult::error(format!("Unknown tool: {tool_name}")),
397 }
398}
399
400fn reads_knowledge(tool_name: &str) -> bool {
403 tool_name.starts_with("kb_")
404 || matches!(
405 tool_name,
406 "query_knowledge_base"
407 | "get_trajectory"
408 | "get_change_points"
409 | "list_research_lines"
410 | "promising_lines"
411 | "generate_report"
412 | "record_experiment"
413 )
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn every_tool_is_defined_exactly_once_and_dispatches() {
422 let tools = all_tools();
423 let mut names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
424 names.sort();
425 let unique = {
426 let mut u = names.clone();
427 u.dedup();
428 u
429 };
430 assert_eq!(names, unique, "duplicate tool name");
431
432 let dir = tempfile::tempdir().unwrap();
433 for tool in &tools {
434 let mut ctx = SomaContext::with_env_override(dir.path(), None);
435 let result = dispatch(&mut ctx, &tool.name, &serde_json::json!({}));
436 let text = result.content_text();
438 assert!(
439 !text.contains("Unknown tool"),
440 "{} is defined but not dispatched",
441 tool.name
442 );
443 }
444 assert!(
445 dispatch(
446 &mut SomaContext::with_env_override(dir.path(), None),
447 "kb_nope",
448 &serde_json::json!({})
449 )
450 .content_text()
451 .contains("Unknown tool")
452 );
453 }
454
455 #[test]
456 fn the_pool_tools_are_all_registered() {
457 let names: Vec<String> = all_tools().into_iter().map(|t| t.name).collect();
458 for expected in [
459 "kb_find_similar",
460 "kb_lineage",
461 "kb_diff",
462 "kb_record_conclusion",
463 "kb_branch_from",
464 "kb_summarize_run",
465 "kb_stats",
466 ] {
467 assert!(names.contains(&expected.to_string()), "missing {expected}");
468 }
469 assert_eq!(names.len(), 20, "13 original tools + 7 pool tools");
470 }
471
472 #[test]
473 fn the_execution_tools_say_that_they_execute() {
474 for name in ["run_pipeline", "run_study"] {
479 let tool = all_tools().into_iter().find(|t| t.name == name).unwrap();
480 assert!(
481 !tool.description.contains("NOT IMPLEMENTED"),
482 "{name} still claims to do nothing: {}",
483 tool.description
484 );
485 assert!(
486 tool.description.contains("EXECUTES project code"),
487 "{name} must announce that it executes: {}",
488 tool.description
489 );
490 let props = tool.input_schema.get("properties").unwrap();
493 assert!(props.get("nodes").is_some(), "{name} takes no nodes");
494 assert!(props.get("input").is_some(), "{name} takes no input");
495 }
496 }
497
498 #[test]
499 fn knowledge_reads_are_refreshed_and_code_tools_are_not() {
500 for name in ["kb_find_similar", "query_knowledge_base", "generate_report"] {
501 assert!(reads_knowledge(name), "{name} should refresh");
502 }
503 for name in ["list_filters", "read_filter_source", "run_pipeline"] {
504 assert!(!reads_knowledge(name), "{name} should not refresh");
505 }
506 }
507}