1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{Duration, Instant};
5
6use api::{
7 read_base_url, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
8 MessageResponse, OutputContentBlock, TernlangClient, StreamEvent as ApiStreamEvent, ToolChoice,
9 ToolDefinition, ToolResultContentBlock,
10};
11use reqwest::blocking::Client;
12use runtime::{
13 edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
14 ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
15 ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
16 RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ToolManifestEntry {
23 pub name: String,
24 pub source: ToolSource,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ToolSource {
29 Base,
30 Conditional,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct ToolRegistry {
35 entries: Vec<ToolManifestEntry>,
36}
37
38impl ToolRegistry {
39 #[must_use]
40 pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
41 Self { entries }
42 }
43
44 #[must_use]
45 pub fn entries(&self) -> &[ToolManifestEntry] {
46 &self.entries
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ToolSpec {
52 pub name: &'static str,
53 pub description: &'static str,
54 pub input_schema: Value,
55 pub required_permission: PermissionMode,
56}
57
58#[must_use]
59#[allow(clippy::too_many_lines)]
60pub fn mvp_tool_specs() -> Vec<ToolSpec> {
61 vec![
62 ToolSpec {
63 name: "bash",
64 description: "Execute a shell command in the current workspace.",
65 input_schema: json!({
66 "type": "object",
67 "properties": {
68 "command": { "type": "string" },
69 "timeout": { "type": "integer", "minimum": 1 },
70 "description": { "type": "string" },
71 "run_in_background": { "type": "boolean" },
72 "dangerouslyDisableSandbox": { "type": "boolean" }
73 },
74 "required": ["command"],
75 "additionalProperties": false
76 }),
77 required_permission: PermissionMode::DangerFullAccess,
78 },
79 ToolSpec {
80 name: "read_file",
81 description: "Read a text file from the workspace.",
82 input_schema: json!({
83 "type": "object",
84 "properties": {
85 "path": { "type": "string" },
86 "offset": { "type": "integer", "minimum": 0 },
87 "limit": { "type": "integer", "minimum": 1 }
88 },
89 "required": ["path"],
90 "additionalProperties": false
91 }),
92 required_permission: PermissionMode::ReadOnly,
93 },
94 ToolSpec {
95 name: "write_file",
96 description: "Write a text file in the workspace.",
97 input_schema: json!({
98 "type": "object",
99 "properties": {
100 "path": { "type": "string" },
101 "content": { "type": "string" }
102 },
103 "required": ["path", "content"],
104 "additionalProperties": false
105 }),
106 required_permission: PermissionMode::WorkspaceWrite,
107 },
108 ToolSpec {
109 name: "edit_file",
110 description: "Replace text in a workspace file.",
111 input_schema: json!({
112 "type": "object",
113 "properties": {
114 "path": { "type": "string" },
115 "old_string": { "type": "string" },
116 "new_string": { "type": "string" },
117 "replace_all": { "type": "boolean" }
118 },
119 "required": ["path", "old_string", "new_string"],
120 "additionalProperties": false
121 }),
122 required_permission: PermissionMode::WorkspaceWrite,
123 },
124 ToolSpec {
125 name: "glob_search",
126 description: "Find files by glob pattern.",
127 input_schema: json!({
128 "type": "object",
129 "properties": {
130 "pattern": { "type": "string" },
131 "path": { "type": "string" }
132 },
133 "required": ["pattern"],
134 "additionalProperties": false
135 }),
136 required_permission: PermissionMode::ReadOnly,
137 },
138 ToolSpec {
139 name: "grep_search",
140 description: "Search file contents with a regex pattern.",
141 input_schema: json!({
142 "type": "object",
143 "properties": {
144 "pattern": { "type": "string" },
145 "path": { "type": "string" },
146 "glob": { "type": "string" },
147 "output_mode": { "type": "string" },
148 "-B": { "type": "integer", "minimum": 0 },
149 "-A": { "type": "integer", "minimum": 0 },
150 "-C": { "type": "integer", "minimum": 0 },
151 "context": { "type": "integer", "minimum": 0 },
152 "-n": { "type": "boolean" },
153 "-i": { "type": "boolean" },
154 "type": { "type": "string" },
155 "head_limit": { "type": "integer", "minimum": 1 },
156 "offset": { "type": "integer", "minimum": 0 },
157 "multiline": { "type": "boolean" }
158 },
159 "required": ["pattern"],
160 "additionalProperties": false
161 }),
162 required_permission: PermissionMode::ReadOnly,
163 },
164 ToolSpec {
165 name: "WebFetch",
166 description:
167 "Fetch a URL, convert it into readable text, and answer a prompt about it.",
168 input_schema: json!({
169 "type": "object",
170 "properties": {
171 "url": { "type": "string", "format": "uri" },
172 "prompt": { "type": "string" }
173 },
174 "required": ["url", "prompt"],
175 "additionalProperties": false
176 }),
177 required_permission: PermissionMode::ReadOnly,
178 },
179 ToolSpec {
180 name: "WebSearch",
181 description: "Search the web for current information and return cited results.",
182 input_schema: json!({
183 "type": "object",
184 "properties": {
185 "query": { "type": "string", "minLength": 2 },
186 "allowed_domains": {
187 "type": "array",
188 "items": { "type": "string" }
189 },
190 "blocked_domains": {
191 "type": "array",
192 "items": { "type": "string" }
193 }
194 },
195 "required": ["query"],
196 "additionalProperties": false
197 }),
198 required_permission: PermissionMode::ReadOnly,
199 },
200 ToolSpec {
201 name: "TodoWrite",
202 description: "Update the structured task list for the current session.",
203 input_schema: json!({
204 "type": "object",
205 "properties": {
206 "todos": {
207 "type": "array",
208 "items": {
209 "type": "object",
210 "properties": {
211 "content": { "type": "string" },
212 "activeForm": { "type": "string" },
213 "status": {
214 "type": "string",
215 "enum": ["pending", "in_progress", "completed"]
216 }
217 },
218 "required": ["content", "activeForm", "status"],
219 "additionalProperties": false
220 }
221 }
222 },
223 "required": ["todos"],
224 "additionalProperties": false
225 }),
226 required_permission: PermissionMode::WorkspaceWrite,
227 },
228 ToolSpec {
229 name: "Skill",
230 description: "Load a local skill definition and its instructions.",
231 input_schema: json!({
232 "type": "object",
233 "properties": {
234 "skill": { "type": "string" },
235 "args": { "type": "string" }
236 },
237 "required": ["skill"],
238 "additionalProperties": false
239 }),
240 required_permission: PermissionMode::ReadOnly,
241 },
242 ToolSpec {
243 name: "Agent",
244 description: "Launch a specialized agent task and persist its handoff metadata.",
245 input_schema: json!({
246 "type": "object",
247 "properties": {
248 "description": { "type": "string" },
249 "prompt": { "type": "string" },
250 "subagent_type": { "type": "string" },
251 "name": { "type": "string" },
252 "model": { "type": "string" }
253 },
254 "required": ["description", "prompt"],
255 "additionalProperties": false
256 }),
257 required_permission: PermissionMode::DangerFullAccess,
258 },
259 ToolSpec {
260 name: "ToolSearch",
261 description: "Search for deferred or specialized tools by exact name or keywords.",
262 input_schema: json!({
263 "type": "object",
264 "properties": {
265 "query": { "type": "string" },
266 "max_results": { "type": "integer", "minimum": 1 }
267 },
268 "required": ["query"],
269 "additionalProperties": false
270 }),
271 required_permission: PermissionMode::ReadOnly,
272 },
273 ToolSpec {
274 name: "NotebookEdit",
275 description: "Replace, insert, or delete a cell in a Jupyter notebook.",
276 input_schema: json!({
277 "type": "object",
278 "properties": {
279 "notebook_path": { "type": "string" },
280 "cell_id": { "type": "string" },
281 "new_source": { "type": "string" },
282 "cell_type": { "type": "string", "enum": ["code", "markdown"] },
283 "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
284 },
285 "required": ["notebook_path"],
286 "additionalProperties": false
287 }),
288 required_permission: PermissionMode::WorkspaceWrite,
289 },
290 ToolSpec {
291 name: "Sleep",
292 description: "Wait for a specified duration without holding a shell process.",
293 input_schema: json!({
294 "type": "object",
295 "properties": {
296 "duration_ms": { "type": "integer", "minimum": 0 }
297 },
298 "required": ["duration_ms"],
299 "additionalProperties": false
300 }),
301 required_permission: PermissionMode::ReadOnly,
302 },
303 ToolSpec {
304 name: "SendUserMessage",
305 description: "Send a message to the user.",
306 input_schema: json!({
307 "type": "object",
308 "properties": {
309 "message": { "type": "string" },
310 "attachments": {
311 "type": "array",
312 "items": { "type": "string" }
313 },
314 "status": {
315 "type": "string",
316 "enum": ["normal", "proactive"]
317 }
318 },
319 "required": ["message", "status"],
320 "additionalProperties": false
321 }),
322 required_permission: PermissionMode::ReadOnly,
323 },
324 ToolSpec {
325 name: "Config",
326 description: "Get or set Claw Code settings.",
327 input_schema: json!({
328 "type": "object",
329 "properties": {
330 "setting": { "type": "string" },
331 "value": {
332 "type": ["string", "boolean", "number"]
333 }
334 },
335 "required": ["setting"],
336 "additionalProperties": false
337 }),
338 required_permission: PermissionMode::WorkspaceWrite,
339 },
340 ToolSpec {
341 name: "StructuredOutput",
342 description: "Return structured output in the requested format.",
343 input_schema: json!({
344 "type": "object",
345 "additionalProperties": true
346 }),
347 required_permission: PermissionMode::ReadOnly,
348 },
349 ToolSpec {
350 name: "REPL",
351 description: "Execute code in a REPL-like subprocess.",
352 input_schema: json!({
353 "type": "object",
354 "properties": {
355 "code": { "type": "string" },
356 "language": { "type": "string" },
357 "timeout_ms": { "type": "integer", "minimum": 1 }
358 },
359 "required": ["code", "language"],
360 "additionalProperties": false
361 }),
362 required_permission: PermissionMode::DangerFullAccess,
363 },
364 ToolSpec {
365 name: "PowerShell",
366 description: "Execute a PowerShell command with optional timeout.",
367 input_schema: json!({
368 "type": "object",
369 "properties": {
370 "command": { "type": "string" },
371 "timeout": { "type": "integer", "minimum": 1 },
372 "description": { "type": "string" },
373 "run_in_background": { "type": "boolean" }
374 },
375 "required": ["command"],
376 "additionalProperties": false
377 }),
378 required_permission: PermissionMode::DangerFullAccess,
379 },
380 ToolSpec {
381 name: "SequentialThinking",
382 description: "A tool that enables structured, multi-step reasoning by allowing you to record and revise thoughts sequentially.",
383 input_schema: json!({
384 "type": "object",
385 "properties": {
386 "thought": { "type": "string" },
387 "thoughtNumber": { "type": "integer", "minimum": 1 },
388 "totalThoughts": { "type": "integer", "minimum": 1 },
389 "nextThoughtNeeded": { "type": "boolean" },
390 "isRevision": { "type": "boolean" },
391 "revisesThoughtNumber": { "type": "integer", "minimum": 1 }
392 },
393 "required": ["thought", "thoughtNumber", "totalThoughts", "nextThoughtNeeded"],
394 "additionalProperties": false
395 }),
396 required_permission: PermissionMode::ReadOnly,
397 },
398 ToolSpec {
399 name: "Memory",
400 description: "Persistent knowledge graph for entities, relations, and observations across sessions.",
401 input_schema: json!({
402 "type": "object",
403 "properties": {
404 "action": { "type": "string", "enum": ["create_entities", "create_relations", "add_observations", "search_nodes"] },
405 "entities": {
406 "type": "array",
407 "items": {
408 "type": "object",
409 "properties": {
410 "name": { "type": "string" },
411 "type": { "type": "string" },
412 "description": { "type": "string" }
413 },
414 "required": ["name", "type", "description"]
415 }
416 },
417 "relations": {
418 "type": "array",
419 "items": {
420 "type": "object",
421 "properties": {
422 "from": { "type": "string" },
423 "to": { "type": "string" },
424 "type": { "type": "string" }
425 },
426 "required": ["from", "to", "type"]
427 }
428 },
429 "observations": {
430 "type": "array",
431 "items": {
432 "type": "object",
433 "properties": {
434 "entityName": { "type": "string" },
435 "contents": { "type": "array", "items": { "type": "string" } }
436 },
437 "required": ["entityName", "contents"]
438 }
439 },
440 "query": { "type": "string" }
441 },
442 "required": ["action"],
443 "additionalProperties": false
444 }),
445 required_permission: PermissionMode::ReadOnly,
446 },
447 ToolSpec {
448 name: "RepoMap",
449 description: "Generate a structured overview of the codebase structure and key symbols.",
450 input_schema: json!({
451 "type": "object",
452 "properties": {
453 "path": { "type": "string" },
454 "depth": { "type": "integer", "minimum": 1, "maximum": 5 },
455 "include_signatures": { "type": "boolean" }
456 },
457 "additionalProperties": false
458 }),
459 required_permission: PermissionMode::ReadOnly,
460 },
461 ToolSpec {
462 name: "vault_write",
463 description: "Save a memory to Albert's persistent vault (~/.albert/vault.jsonl). \
464 Use this to lock in anything worth remembering across sessions — decisions, facts, \
465 user context, project anchors. Include #tags in the content to make recall easier. \
466 Responds with a ternary confirmation.",
467 input_schema: json!({
468 "type": "object",
469 "properties": {
470 "content": {
471 "type": "string",
472 "description": "The memory to store. Include #tags for easier recall later."
473 },
474 "state": {
475 "type": "string",
476 "enum": ["+1", "0", "-1"],
477 "description": "Ternary state: +1 affirm (strong/positive), 0 tend (neutral/ongoing), -1 refrain (caution/negative)."
478 }
479 },
480 "required": ["content"],
481 "additionalProperties": false
482 }),
483 required_permission: PermissionMode::ReadOnly,
484 },
485 ToolSpec {
486 name: "vault_read",
487 description: "Search Albert's persistent vault for memories matching a keyword or #tag. \
488 Returns all matching entries with timestamps and ternary states.",
489 input_schema: json!({
490 "type": "object",
491 "properties": {
492 "query": {
493 "type": "string",
494 "description": "Keyword or #tag to search for."
495 }
496 },
497 "required": ["query"],
498 "additionalProperties": false
499 }),
500 required_permission: PermissionMode::ReadOnly,
501 },
502 ]
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506pub struct ToolResult {
507 pub output: String,
508 pub state: i8, }
510
511pub fn execute_tool(name: &str, input: &Value) -> Result<ToolResult, String> {
512 match name {
513 "bash" => from_value::<BashCommandInput>(input).and_then(run_bash_wrapped),
514 "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file_wrapped),
515 "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file_wrapped),
516 "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file_wrapped),
517 "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search_wrapped),
518 "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search_wrapped),
519 "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch_wrapped),
520 "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search_wrapped),
521 "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write_wrapped),
522 "Skill" => from_value::<SkillInput>(input).and_then(run_skill_wrapped),
523 "create_skill" => from_value::<SkillCreateInput>(input).and_then(run_create_skill_wrapped),
524 "Agent" => from_value::<AgentInput>(input).and_then(run_agent_wrapped),
525 "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search_wrapped),
526 "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit_wrapped),
527 "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep_wrapped),
528 "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief_wrapped),
529 "Config" => from_value::<ConfigInput>(input).and_then(run_config_wrapped),
530 "StructuredOutput" => from_value::<StructuredOutputInput>(input).and_then(run_structured_output_wrapped),
531 "REPL" => from_value::<ReplInput>(input).and_then(run_repl_wrapped),
532 "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell_wrapped),
533 "SequentialThinking" => from_value::<SequentialThinkingInput>(input).and_then(run_sequential_thinking_wrapped),
534 "Memory" => from_value::<MemoryInput>(input).and_then(run_memory_wrapped),
535 "RepoMap" => from_value::<RepoMapInput>(input).and_then(run_repo_map_wrapped),
536 "vault_write" => from_value::<VaultWriteInput>(input).and_then(run_vault_write_wrapped),
537 "vault_read" => from_value::<VaultReadInput>(input).and_then(run_vault_read_wrapped),
538 _ => Err(format!("unsupported tool: {name}")),
539 }
540}
541
542fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
543 serde_json::from_value(input.clone()).map_err(|error| error.to_string())
544}
545
546fn expand_tilde(path: &str) -> String {
547 if path.starts_with("~/") || path == "~" {
548 if let Ok(home) = std::env::var("HOME") {
549 return if path == "~" {
550 home
551 } else {
552 format!("{}{}", home, &path[1..])
553 };
554 }
555 }
556 path.to_string()
557}
558
559#[allow(clippy::needless_pass_by_value)]
560fn run_read_file(input: ReadFileInput) -> Result<String, String> {
561 to_pretty_json(read_file(&expand_tilde(&input.path), input.offset, input.limit).map_err(io_to_string)?)
562}
563
564#[allow(clippy::needless_pass_by_value)]
565fn run_write_file(input: WriteFileInput) -> Result<String, String> {
566 to_pretty_json(write_file(&expand_tilde(&input.path), &input.content).map_err(io_to_string)?)
567}
568
569#[allow(clippy::needless_pass_by_value)]
570fn run_edit_file(input: EditFileInput) -> Result<String, String> {
571 to_pretty_json(
572 edit_file(
573 &expand_tilde(&input.path),
574 &input.old_string,
575 &input.new_string,
576 input.replace_all.unwrap_or(false),
577 )
578 .map_err(io_to_string)?,
579 )
580}
581
582#[allow(clippy::needless_pass_by_value)]
583fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
584 let expanded = input.path.as_deref().map(expand_tilde);
585 to_pretty_json(glob_search(&input.pattern, expanded.as_deref()).map_err(io_to_string)?)
586}
587
588#[allow(clippy::needless_pass_by_value)]
589fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
590 to_pretty_json(grep_search(&input).map_err(io_to_string)?)
591}
592
593#[allow(clippy::needless_pass_by_value)]
594fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
595 to_pretty_json(execute_web_fetch(&input)?)
596}
597
598#[allow(clippy::needless_pass_by_value)]
599fn run_web_search(input: WebSearchInput) -> Result<String, String> {
600 to_pretty_json(execute_web_search(&input)?)
601}
602
603fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
604 to_pretty_json(execute_todo_write(input)?)
605}
606
607fn run_skill(input: SkillInput) -> Result<String, String> {
608 to_pretty_json(execute_skill(input)?)
609}
610
611fn run_create_skill(input: SkillCreateInput) -> Result<String, String> {
612 to_pretty_json(execute_create_skill(input)?)
613}
614
615fn execute_create_skill(input: SkillCreateInput) -> Result<String, String> {
616 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
618 let skill_dir = std::path::PathBuf::from(home).join(".ternlang/skills").join(&input.name);
619 std::fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
620 let skill_file = skill_dir.join("SKILL.md");
621 let logic_file = skill_dir.join("logic.tern");
622
623 let content = format!("description: {}\n\n{}", input.description, input.code);
625
626 std::fs::write(&skill_file, content).map_err(|e| e.to_string())?;
628 std::fs::write(&logic_file, &input.code).map_err(|e| e.to_string())?;
629
630 let bash_input = BashCommandInput {
632 command: format!("ternlang-cli run {} --compile-only", logic_file.display()),
633 timeout: Some(10),
634 description: Some("Compile-only check for new skill".to_string()),
635 run_in_background: Some(false),
636 validation_state: Some(1),
637 namespace_restrictions: None,
638 isolate_network: None,
639 filesystem_mode: None,
640 allowed_mounts: None,
641 };
642
643 match execute_bash(bash_input) {
644 Ok(_) => Ok(format!("Skill {} created and validated at {}", input.name, skill_file.display())),
645 Err(e) => {
646 let _ = std::fs::remove_dir_all(&skill_dir);
648 Err(format!("Skill creation failed validation: {}", e))
649 }
650 }
651}
652
653fn run_agent(input: AgentInput) -> Result<String, String> {
654 to_pretty_json(execute_agent(input)?)
655}
656
657fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
658 to_pretty_json(execute_tool_search(input))
659}
660
661fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
662 to_pretty_json(execute_notebook_edit(input)?)
663}
664
665fn run_sleep(input: SleepInput) -> Result<String, String> {
666 to_pretty_json(execute_sleep(input))
667}
668
669fn run_brief(input: BriefInput) -> Result<String, String> {
670 to_pretty_json(execute_brief(input)?)
671}
672
673fn run_config(input: ConfigInput) -> Result<String, String> {
674 to_pretty_json(execute_config(input)?)
675}
676
677fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
678 to_pretty_json(execute_structured_output(input))
679}
680
681fn run_repl(input: ReplInput) -> Result<String, String> {
682 to_pretty_json(execute_repl(input)?)
683}
684
685fn run_powershell(input: PowerShellInput) -> Result<String, String> {
686 to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
687}
688
689fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
690 serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
691}
692
693#[allow(clippy::needless_pass_by_value)]
694fn io_to_string(error: std::io::Error) -> String {
695 error.to_string()
696}
697
698#[derive(Debug, Deserialize)]
699struct ReadFileInput {
700 path: String,
701 offset: Option<usize>,
702 limit: Option<usize>,
703}
704
705#[derive(Debug, Deserialize)]
706struct WriteFileInput {
707 path: String,
708 content: String,
709}
710
711#[derive(Debug, Deserialize)]
712struct EditFileInput {
713 path: String,
714 old_string: String,
715 new_string: String,
716 replace_all: Option<bool>,
717}
718
719#[derive(Debug, Deserialize)]
720struct GlobSearchInputValue {
721 pattern: String,
722 path: Option<String>,
723}
724
725#[derive(Debug, Deserialize)]
726struct WebFetchInput {
727 url: String,
728 prompt: String,
729}
730
731#[derive(Debug, Deserialize)]
732struct WebSearchInput {
733 query: String,
734 allowed_domains: Option<Vec<String>>,
735 blocked_domains: Option<Vec<String>>,
736}
737
738#[derive(Debug, Deserialize)]
739struct TodoWriteInput {
740 todos: Vec<TodoItem>,
741}
742
743#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
744struct TodoItem {
745 content: String,
746 #[serde(rename = "activeForm")]
747 active_form: String,
748 status: TodoStatus,
749}
750
751#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
752#[serde(rename_all = "snake_case")]
753enum TodoStatus {
754 Pending,
755 InProgress,
756 Completed,
757}
758
759#[derive(Debug, Deserialize)]
760struct SkillCreateInput {
761 name: String,
762 description: String,
763 code: String,
764}
765
766#[derive(Debug, Deserialize)]
767struct SkillInput {
768 skill: String,
769 args: Option<String>,
770}
771
772#[derive(Debug, Deserialize)]
773struct AgentInput {
774 description: String,
775 prompt: String,
776 subagent_type: Option<String>,
777 name: Option<String>,
778 model: Option<String>,
779}
780
781#[derive(Debug, Deserialize)]
782struct ToolSearchInput {
783 query: String,
784 max_results: Option<usize>,
785}
786
787#[derive(Debug, Deserialize)]
788struct NotebookEditInput {
789 notebook_path: String,
790 cell_id: Option<String>,
791 new_source: Option<String>,
792 cell_type: Option<NotebookCellType>,
793 edit_mode: Option<NotebookEditMode>,
794}
795
796#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
797#[serde(rename_all = "lowercase")]
798enum NotebookCellType {
799 Code,
800 Markdown,
801}
802
803#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
804#[serde(rename_all = "lowercase")]
805enum NotebookEditMode {
806 Replace,
807 Insert,
808 Delete,
809}
810
811#[derive(Debug, Deserialize)]
812struct SleepInput {
813 duration_ms: u64,
814}
815
816#[derive(Debug, Deserialize)]
817struct BriefInput {
818 message: String,
819 attachments: Option<Vec<String>>,
820 status: BriefStatus,
821}
822
823#[derive(Debug, Deserialize)]
824#[serde(rename_all = "lowercase")]
825enum BriefStatus {
826 Normal,
827 Proactive,
828}
829
830#[derive(Debug, Deserialize)]
831struct ConfigInput {
832 setting: String,
833 value: Option<ConfigValue>,
834}
835
836#[derive(Debug, Deserialize)]
837#[serde(untagged)]
838enum ConfigValue {
839 String(String),
840 Bool(bool),
841 Number(f64),
842}
843
844#[derive(Debug, Deserialize)]
845#[serde(transparent)]
846struct StructuredOutputInput(BTreeMap<String, Value>);
847
848#[derive(Debug, Deserialize)]
849struct ReplInput {
850 code: String,
851 language: String,
852 timeout_ms: Option<u64>,
853}
854
855#[derive(Debug, Deserialize)]
856struct PowerShellInput {
857 command: String,
858 timeout: Option<u64>,
859 description: Option<String>,
860 run_in_background: Option<bool>,
861}
862
863#[derive(Debug, Serialize)]
864struct WebFetchOutput {
865 bytes: usize,
866 code: u16,
867 #[serde(rename = "codeText")]
868 code_text: String,
869 result: String,
870 #[serde(rename = "durationMs")]
871 duration_ms: u128,
872 url: String,
873}
874
875#[derive(Debug, Serialize)]
876struct WebSearchOutput {
877 query: String,
878 results: Vec<WebSearchResultItem>,
879 #[serde(rename = "durationSeconds")]
880 duration_seconds: f64,
881}
882
883#[derive(Debug, Serialize)]
884struct TodoWriteOutput {
885 #[serde(rename = "oldTodos")]
886 old_todos: Vec<TodoItem>,
887 #[serde(rename = "newTodos")]
888 new_todos: Vec<TodoItem>,
889 #[serde(rename = "verificationNudgeNeeded")]
890 verification_nudge_needed: Option<bool>,
891}
892
893#[derive(Debug, Serialize)]
894struct SkillOutput {
895 skill: String,
896 path: String,
897 args: Option<String>,
898 description: Option<String>,
899 prompt: String,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
903struct AgentOutput {
904 #[serde(rename = "agentId")]
905 agent_id: String,
906 name: String,
907 description: String,
908 #[serde(rename = "subagentType")]
909 subagent_type: Option<String>,
910 model: Option<String>,
911 status: String,
912 #[serde(rename = "outputFile")]
913 output_file: String,
914 #[serde(rename = "manifestFile")]
915 manifest_file: String,
916 #[serde(rename = "createdAt")]
917 created_at: String,
918 #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
919 started_at: Option<String>,
920 #[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
921 completed_at: Option<String>,
922 #[serde(skip_serializing_if = "Option::is_none")]
923 error: Option<String>,
924}
925
926#[derive(Debug, Clone)]
927struct AgentJob {
928 manifest: AgentOutput,
929 prompt: String,
930 system_prompt: Vec<String>,
931 allowed_tools: BTreeSet<String>,
932}
933
934#[derive(Debug, Serialize)]
935struct ToolSearchOutput {
936 matches: Vec<String>,
937 query: String,
938 normalized_query: String,
939 #[serde(rename = "total_deferred_tools")]
940 total_deferred_tools: usize,
941 #[serde(rename = "pending_mcp_servers")]
942 pending_mcp_servers: Option<Vec<String>>,
943}
944
945#[derive(Debug, Serialize)]
946struct NotebookEditOutput {
947 new_source: String,
948 cell_id: Option<String>,
949 cell_type: Option<NotebookCellType>,
950 language: String,
951 edit_mode: String,
952 error: Option<String>,
953 notebook_path: String,
954 original_file: String,
955 updated_file: String,
956}
957
958#[derive(Debug, Serialize)]
959struct SleepOutput {
960 duration_ms: u64,
961 message: String,
962}
963
964#[derive(Debug, Serialize)]
965struct BriefOutput {
966 message: String,
967 attachments: Option<Vec<ResolvedAttachment>>,
968 #[serde(rename = "sentAt")]
969 sent_at: String,
970}
971
972#[derive(Debug, Serialize)]
973struct ResolvedAttachment {
974 path: String,
975 size: u64,
976 #[serde(rename = "isImage")]
977 is_image: bool,
978}
979
980#[derive(Debug, Serialize)]
981struct ConfigOutput {
982 success: bool,
983 operation: Option<String>,
984 setting: Option<String>,
985 value: Option<Value>,
986 #[serde(rename = "previousValue")]
987 previous_value: Option<Value>,
988 #[serde(rename = "newValue")]
989 new_value: Option<Value>,
990 error: Option<String>,
991}
992
993#[derive(Debug, Serialize)]
994struct StructuredOutputResult {
995 data: String,
996 structured_output: BTreeMap<String, Value>,
997}
998
999#[derive(Debug, Serialize)]
1000struct ReplOutput {
1001 language: String,
1002 stdout: String,
1003 stderr: String,
1004 #[serde(rename = "exitCode")]
1005 exit_code: i32,
1006 #[serde(rename = "durationMs")]
1007 duration_ms: u128,
1008}
1009
1010#[derive(Debug, Serialize)]
1011#[serde(untagged)]
1012enum WebSearchResultItem {
1013 SearchResult {
1014 tool_use_id: String,
1015 content: Vec<SearchHit>,
1016 },
1017 Commentary(String),
1018}
1019
1020#[derive(Debug, Serialize)]
1021struct SearchHit {
1022 title: String,
1023 url: String,
1024}
1025
1026fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, String> {
1027 let started = Instant::now();
1028 let client = build_http_client()?;
1029 let request_url = normalize_fetch_url(&input.url)?;
1030 let response = client
1031 .get(request_url.clone())
1032 .send()
1033 .map_err(|error| error.to_string())?;
1034
1035 let status = response.status();
1036 let final_url = response.url().to_string();
1037 let code = status.as_u16();
1038 let code_text = status.canonical_reason().unwrap_or("Unknown").to_string();
1039 let content_type = response
1040 .headers()
1041 .get(reqwest::header::CONTENT_TYPE)
1042 .and_then(|value| value.to_str().ok())
1043 .unwrap_or_default()
1044 .to_string();
1045 let body = response.text().map_err(|error| error.to_string())?;
1046 let bytes = body.len();
1047 let normalized = normalize_fetched_content(&body, &content_type);
1048 let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type);
1049
1050 Ok(WebFetchOutput {
1051 bytes,
1052 code,
1053 code_text,
1054 result,
1055 duration_ms: started.elapsed().as_millis(),
1056 url: final_url,
1057 })
1058}
1059
1060fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String> {
1061 let started = Instant::now();
1062 let client = build_http_client()?;
1063 let search_url = build_search_url(&input.query)?;
1064 let response = client
1065 .get(search_url)
1066 .send()
1067 .map_err(|error| error.to_string())?;
1068
1069 let final_url = response.url().clone();
1070 let html = response.text().map_err(|error| error.to_string())?;
1071 let mut hits = extract_search_hits(&html);
1072
1073 if hits.is_empty() && final_url.host_str().is_some() {
1074 hits = extract_search_hits_from_generic_links(&html);
1075 }
1076
1077 if let Some(allowed) = input.allowed_domains.as_ref() {
1078 hits.retain(|hit| host_matches_list(&hit.url, allowed));
1079 }
1080 if let Some(blocked) = input.blocked_domains.as_ref() {
1081 hits.retain(|hit| !host_matches_list(&hit.url, blocked));
1082 }
1083
1084 dedupe_hits(&mut hits);
1085 hits.truncate(8);
1086
1087 let summary = if hits.is_empty() {
1088 format!("No web search results matched the query {:?}.", input.query)
1089 } else {
1090 let rendered_hits = hits
1091 .iter()
1092 .map(|hit| format!("- [{}]({})", hit.title, hit.url))
1093 .collect::<Vec<_>>()
1094 .join("\n");
1095 format!(
1096 "Search results for {:?}. Include a Sources section in the final answer.\n{}",
1097 input.query, rendered_hits
1098 )
1099 };
1100
1101 Ok(WebSearchOutput {
1102 query: input.query.clone(),
1103 results: vec![
1104 WebSearchResultItem::Commentary(summary),
1105 WebSearchResultItem::SearchResult {
1106 tool_use_id: String::from("web_search_1"),
1107 content: hits,
1108 },
1109 ],
1110 duration_seconds: started.elapsed().as_secs_f64(),
1111 })
1112}
1113
1114fn build_http_client() -> Result<Client, String> {
1115 Client::builder()
1116 .timeout(Duration::from_secs(20))
1117 .redirect(reqwest::redirect::Policy::limited(10))
1118 .user_agent("clawd-rust-tools/0.1")
1119 .build()
1120 .map_err(|error| error.to_string())
1121}
1122
1123fn normalize_fetch_url(url: &str) -> Result<String, String> {
1124 let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?;
1125 if parsed.scheme() == "http" {
1126 let host = parsed.host_str().unwrap_or_default();
1127 if host != "localhost" && host != "127.0.0.1" && host != "::1" {
1128 let mut upgraded = parsed;
1129 upgraded
1130 .set_scheme("https")
1131 .map_err(|()| String::from("failed to upgrade URL to https"))?;
1132 return Ok(upgraded.to_string());
1133 }
1134 }
1135 Ok(parsed.to_string())
1136}
1137
1138fn build_search_url(query: &str) -> Result<reqwest::Url, String> {
1139 if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") {
1140 let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?;
1141 url.query_pairs_mut().append_pair("q", query);
1142 return Ok(url);
1143 }
1144
1145 let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/")
1146 .map_err(|error| error.to_string())?;
1147 url.query_pairs_mut().append_pair("q", query);
1148 Ok(url)
1149}
1150
1151fn normalize_fetched_content(body: &str, content_type: &str) -> String {
1152 if content_type.contains("html") {
1153 html_to_text(body)
1154 } else {
1155 body.trim().to_string()
1156 }
1157}
1158
1159fn summarize_web_fetch(
1160 url: &str,
1161 prompt: &str,
1162 content: &str,
1163 raw_body: &str,
1164 content_type: &str,
1165) -> String {
1166 let lower_prompt = prompt.to_lowercase();
1167 let compact = collapse_whitespace(content);
1168
1169 let detail = if lower_prompt.contains("title") {
1170 extract_title(content, raw_body, content_type).map_or_else(
1171 || preview_text(&compact, 600),
1172 |title| format!("Title: {title}"),
1173 )
1174 } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
1175 preview_text(&compact, 900)
1176 } else {
1177 let preview = preview_text(&compact, 900);
1178 format!("Prompt: {prompt}\nContent preview:\n{preview}")
1179 };
1180
1181 format!("Fetched {url}\n{detail}")
1182}
1183
1184fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option<String> {
1185 if content_type.contains("html") {
1186 let lowered = raw_body.to_lowercase();
1187 if let Some(start) = lowered.find("<title>") {
1188 let after = start + "<title>".len();
1189 if let Some(end_rel) = lowered[after..].find("</title>") {
1190 let title =
1191 collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel]));
1192 if !title.is_empty() {
1193 return Some(title);
1194 }
1195 }
1196 }
1197 }
1198
1199 for line in content.lines() {
1200 let trimmed = line.trim();
1201 if !trimmed.is_empty() {
1202 return Some(trimmed.to_string());
1203 }
1204 }
1205 None
1206}
1207
1208fn html_to_text(html: &str) -> String {
1209 let mut text = String::with_capacity(html.len());
1210 let mut in_tag = false;
1211 let mut previous_was_space = false;
1212
1213 for ch in html.chars() {
1214 match ch {
1215 '<' => in_tag = true,
1216 '>' => in_tag = false,
1217 _ if in_tag => {}
1218 '&' => {
1219 text.push('&');
1220 previous_was_space = false;
1221 }
1222 ch if ch.is_whitespace() => {
1223 if !previous_was_space {
1224 text.push(' ');
1225 previous_was_space = true;
1226 }
1227 }
1228 _ => {
1229 text.push(ch);
1230 previous_was_space = false;
1231 }
1232 }
1233 }
1234
1235 collapse_whitespace(&decode_html_entities(&text))
1236}
1237
1238fn decode_html_entities(input: &str) -> String {
1239 input
1240 .replace("&", "&")
1241 .replace("<", "<")
1242 .replace(">", ">")
1243 .replace(""", "\"")
1244 .replace("'", "'")
1245 .replace(" ", " ")
1246}
1247
1248fn collapse_whitespace(input: &str) -> String {
1249 input.split_whitespace().collect::<Vec<_>>().join(" ")
1250}
1251
1252fn preview_text(input: &str, max_chars: usize) -> String {
1253 if input.chars().count() <= max_chars {
1254 return input.to_string();
1255 }
1256 let shortened = input.chars().take(max_chars).collect::<String>();
1257 format!("{}…", shortened.trim_end())
1258}
1259
1260fn extract_search_hits(html: &str) -> Vec<SearchHit> {
1261 let mut hits = Vec::new();
1262 let mut remaining = html;
1263
1264 while let Some(anchor_start) = remaining.find("result__a") {
1265 let after_class = &remaining[anchor_start..];
1266 let Some(href_idx) = after_class.find("href=") else {
1267 remaining = &after_class[1..];
1268 continue;
1269 };
1270 let href_slice = &after_class[href_idx + 5..];
1271 let Some((url, rest)) = extract_quoted_value(href_slice) else {
1272 remaining = &after_class[1..];
1273 continue;
1274 };
1275 let Some(close_tag_idx) = rest.find('>') else {
1276 remaining = &after_class[1..];
1277 continue;
1278 };
1279 let after_tag = &rest[close_tag_idx + 1..];
1280 let Some(end_anchor_idx) = after_tag.find("</a>") else {
1281 remaining = &after_tag[1..];
1282 continue;
1283 };
1284 let title = html_to_text(&after_tag[..end_anchor_idx]);
1285 if let Some(decoded_url) = decode_duckduckgo_redirect(&url) {
1286 hits.push(SearchHit {
1287 title: title.trim().to_string(),
1288 url: decoded_url,
1289 });
1290 }
1291 remaining = &after_tag[end_anchor_idx + 4..];
1292 }
1293
1294 hits
1295}
1296
1297fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
1298 let mut hits = Vec::new();
1299 let mut remaining = html;
1300
1301 while let Some(anchor_start) = remaining.find("<a") {
1302 let after_anchor = &remaining[anchor_start..];
1303 let Some(href_idx) = after_anchor.find("href=") else {
1304 remaining = &after_anchor[2..];
1305 continue;
1306 };
1307 let href_slice = &after_anchor[href_idx + 5..];
1308 let Some((url, rest)) = extract_quoted_value(href_slice) else {
1309 remaining = &after_anchor[2..];
1310 continue;
1311 };
1312 let Some(close_tag_idx) = rest.find('>') else {
1313 remaining = &after_anchor[2..];
1314 continue;
1315 };
1316 let after_tag = &rest[close_tag_idx + 1..];
1317 let Some(end_anchor_idx) = after_tag.find("</a>") else {
1318 remaining = &after_anchor[2..];
1319 continue;
1320 };
1321 let title = html_to_text(&after_tag[..end_anchor_idx]);
1322 if title.trim().is_empty() {
1323 remaining = &after_tag[end_anchor_idx + 4..];
1324 continue;
1325 }
1326 let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
1327 if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
1328 hits.push(SearchHit {
1329 title: title.trim().to_string(),
1330 url: decoded_url,
1331 });
1332 }
1333 remaining = &after_tag[end_anchor_idx + 4..];
1334 }
1335
1336 hits
1337}
1338
1339fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
1340 let quote = input.chars().next()?;
1341 if quote != '"' && quote != '\'' {
1342 return None;
1343 }
1344 let rest = &input[quote.len_utf8()..];
1345 let end = rest.find(quote)?;
1346 Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..]))
1347}
1348
1349fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
1350 if url.starts_with("http://") || url.starts_with("https://") {
1351 return Some(html_entity_decode_url(url));
1352 }
1353
1354 let joined = if url.starts_with("//") {
1355 format!("https:{url}")
1356 } else if url.starts_with('/') {
1357 format!("https://duckduckgo.com{url}")
1358 } else {
1359 return None;
1360 };
1361
1362 let parsed = reqwest::Url::parse(&joined).ok()?;
1363 if parsed.path() == "/l/" || parsed.path() == "/l" {
1364 for (key, value) in parsed.query_pairs() {
1365 if key == "uddg" {
1366 return Some(html_entity_decode_url(value.as_ref()));
1367 }
1368 }
1369 }
1370 Some(joined)
1371}
1372
1373fn html_entity_decode_url(url: &str) -> String {
1374 decode_html_entities(url)
1375}
1376
1377fn host_matches_list(url: &str, domains: &[String]) -> bool {
1378 let Ok(parsed) = reqwest::Url::parse(url) else {
1379 return false;
1380 };
1381 let Some(host) = parsed.host_str() else {
1382 return false;
1383 };
1384 let host = host.to_ascii_lowercase();
1385 domains.iter().any(|domain| {
1386 let normalized = normalize_domain_filter(domain);
1387 !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}")))
1388 })
1389}
1390
1391fn normalize_domain_filter(domain: &str) -> String {
1392 let trimmed = domain.trim();
1393 let candidate = reqwest::Url::parse(trimmed)
1394 .ok()
1395 .and_then(|url| url.host_str().map(str::to_string))
1396 .unwrap_or_else(|| trimmed.to_string());
1397 candidate
1398 .trim()
1399 .trim_start_matches('.')
1400 .trim_end_matches('/')
1401 .to_ascii_lowercase()
1402}
1403
1404fn dedupe_hits(hits: &mut Vec<SearchHit>) {
1405 let mut seen = BTreeSet::new();
1406 hits.retain(|hit| seen.insert(hit.url.clone()));
1407}
1408
1409fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
1410 validate_todos(&input.todos)?;
1411 let store_path = todo_store_path()?;
1412 let old_todos = if store_path.exists() {
1413 serde_json::from_str::<Vec<TodoItem>>(
1414 &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
1415 )
1416 .map_err(|error| error.to_string())?
1417 } else {
1418 Vec::new()
1419 };
1420
1421 let all_done = input
1422 .todos
1423 .iter()
1424 .all(|todo| matches!(todo.status, TodoStatus::Completed));
1425 let persisted = if all_done {
1426 Vec::new()
1427 } else {
1428 input.todos.clone()
1429 };
1430
1431 if let Some(parent) = store_path.parent() {
1432 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1433 }
1434 std::fs::write(
1435 &store_path,
1436 serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
1437 )
1438 .map_err(|error| error.to_string())?;
1439
1440 let verification_nudge_needed = (all_done
1441 && input.todos.len() >= 3
1442 && !input
1443 .todos
1444 .iter()
1445 .any(|todo| todo.content.to_lowercase().contains("verif")))
1446 .then_some(true);
1447
1448 Ok(TodoWriteOutput {
1449 old_todos,
1450 new_todos: input.todos,
1451 verification_nudge_needed,
1452 })
1453}
1454
1455fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
1456 let skill_path = resolve_skill_path(&input.skill)?;
1457 let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
1458 let description = parse_skill_description(&prompt);
1459
1460 Ok(SkillOutput {
1461 skill: input.skill,
1462 path: skill_path.display().to_string(),
1463 args: input.args,
1464 description,
1465 prompt,
1466 })
1467}
1468
1469fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
1470 if todos.is_empty() {
1471 return Err(String::from("todos must not be empty"));
1472 }
1473 if todos.iter().any(|todo| todo.content.trim().is_empty()) {
1475 return Err(String::from("todo content must not be empty"));
1476 }
1477 if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
1478 return Err(String::from("todo activeForm must not be empty"));
1479 }
1480 Ok(())
1481}
1482
1483fn todo_store_path() -> Result<std::path::PathBuf, String> {
1484 if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
1485 return Ok(std::path::PathBuf::from(path));
1486 }
1487 let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
1488 Ok(cwd.join(".clawd-todos.json"))
1489}
1490
1491fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
1492 let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
1493 if requested.is_empty() {
1494 return Err(String::from("skill must not be empty"));
1495 }
1496
1497 let mut candidates = Vec::new();
1498 if let Ok(codex_home) = std::env::var("CODEX_HOME") {
1499 candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
1500 }
1501 candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
1502
1503 for root in candidates {
1504 let direct = root.join(requested).join("SKILL.md");
1505 if direct.exists() {
1506 return Ok(direct);
1507 }
1508
1509 if let Ok(entries) = std::fs::read_dir(&root) {
1510 for entry in entries.flatten() {
1511 let path = entry.path().join("SKILL.md");
1512 if !path.exists() {
1513 continue;
1514 }
1515 if entry
1516 .file_name()
1517 .to_string_lossy()
1518 .eq_ignore_ascii_case(requested)
1519 {
1520 return Ok(path);
1521 }
1522 }
1523 }
1524 }
1525
1526 Err(format!("unknown skill: {requested}"))
1527}
1528
1529const DEFAULT_AGENT_MODEL: &str = "ternlang-opus-4-6";
1530const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
1531const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
1532
1533fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
1534 execute_agent_with_spawn(input, spawn_agent_job)
1535}
1536
1537fn execute_agent_with_spawn<F>(input: AgentInput, spawn_fn: F) -> Result<AgentOutput, String>
1538where
1539 F: FnOnce(AgentJob) -> Result<(), String>,
1540{
1541 if input.description.trim().is_empty() {
1542 return Err(String::from("description must not be empty"));
1543 }
1544 if input.prompt.trim().is_empty() {
1545 return Err(String::from("prompt must not be empty"));
1546 }
1547
1548 let agent_id = make_agent_id();
1549 let output_dir = agent_store_dir()?;
1550 std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
1551 let output_file = output_dir.join(format!("{agent_id}.md"));
1552 let manifest_file = output_dir.join(format!("{agent_id}.json"));
1553 let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
1554 let model = resolve_agent_model(input.model.as_deref());
1555 let agent_name = input
1556 .name
1557 .as_deref()
1558 .map(slugify_agent_name)
1559 .filter(|name| !name.is_empty())
1560 .unwrap_or_else(|| slugify_agent_name(&input.description));
1561 let created_at = iso8601_now();
1562 let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
1563 let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
1564
1565 let output_contents = format!(
1566 "# Agent Task
1567
1568- id: {}
1569- name: {}
1570- description: {}
1571- subagent_type: {}
1572- created_at: {}
1573
1574## Prompt
1575
1576{}
1577",
1578 agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
1579 );
1580 std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
1581
1582 let manifest = AgentOutput {
1583 agent_id,
1584 name: agent_name,
1585 description: input.description,
1586 subagent_type: Some(normalized_subagent_type),
1587 model: Some(model),
1588 status: String::from("running"),
1589 output_file: output_file.display().to_string(),
1590 manifest_file: manifest_file.display().to_string(),
1591 created_at: created_at.clone(),
1592 started_at: Some(created_at),
1593 completed_at: None,
1594 error: None,
1595 };
1596 write_agent_manifest(&manifest)?;
1597
1598 let manifest_for_spawn = manifest.clone();
1599 let job = AgentJob {
1600 manifest: manifest_for_spawn,
1601 prompt: input.prompt,
1602 system_prompt,
1603 allowed_tools,
1604 };
1605 if let Err(error) = spawn_fn(job) {
1606 let error = format!("failed to spawn sub-agent: {error}");
1607 persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?;
1608 return Err(error);
1609 }
1610
1611 Ok(manifest)
1612}
1613
1614fn spawn_agent_job(job: AgentJob) -> Result<(), String> {
1615 let thread_name = format!("clawd-agent-{}", job.manifest.agent_id);
1616 std::thread::Builder::new()
1617 .name(thread_name)
1618 .spawn(move || {
1619 let result =
1620 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job)));
1621 match result {
1622 Ok(Ok(())) => {}
1623 Ok(Err(error)) => {
1624 let _ =
1625 persist_agent_terminal_state(&job.manifest, "failed", None, Some(error));
1626 }
1627 Err(_) => {
1628 let _ = persist_agent_terminal_state(
1629 &job.manifest,
1630 "failed",
1631 None,
1632 Some(String::from("sub-agent thread panicked")),
1633 );
1634 }
1635 }
1636 })
1637 .map(|_| ())
1638 .map_err(|error| error.to_string())
1639}
1640
1641fn run_agent_job(job: &AgentJob) -> Result<(), String> {
1642 let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
1643 let summary = runtime
1644 .run_turn(job.prompt.clone(), None)
1645 .map_err(|error| error.to_string())?;
1646 let final_text = final_assistant_text(&summary);
1647 persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None)
1648}
1649
1650fn build_agent_runtime(
1651 job: &AgentJob,
1652) -> Result<ConversationRuntime<TernlangRuntimeClient, SubagentToolExecutor>, String> {
1653 let model = job
1654 .manifest
1655 .model
1656 .clone()
1657 .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
1658 let allowed_tools = job.allowed_tools.clone();
1659 let api_client = TernlangRuntimeClient::new(model, allowed_tools.clone())?;
1660 let tool_executor = SubagentToolExecutor::new(allowed_tools);
1661 Ok(ConversationRuntime::new(
1662 Session::new(),
1663 api_client,
1664 tool_executor,
1665 agent_permission_policy(),
1666 job.system_prompt.clone(),
1667 ))
1668}
1669
1670fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
1671 let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
1672 let mut prompt = load_system_prompt(
1673 cwd,
1674 DEFAULT_AGENT_SYSTEM_DATE.to_string(),
1675 std::env::consts::OS,
1676 "unknown",
1677 )
1678 .map_err(|error| error.to_string())?;
1679 prompt.push(format!(
1680 "You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result."
1681 ));
1682 Ok(prompt)
1683}
1684
1685fn resolve_agent_model(model: Option<&str>) -> String {
1686 model
1687 .map(str::trim)
1688 .filter(|model| !model.is_empty())
1689 .unwrap_or(DEFAULT_AGENT_MODEL)
1690 .to_string()
1691}
1692
1693fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
1694 let tools = match subagent_type {
1695 "Explore" => vec![
1696 "read_file",
1697 "glob_search",
1698 "grep_search",
1699 "WebFetch",
1700 "WebSearch",
1701 "ToolSearch",
1702 "Skill",
1703 "StructuredOutput",
1704 ],
1705 "Plan" => vec![
1706 "read_file",
1707 "glob_search",
1708 "grep_search",
1709 "WebFetch",
1710 "WebSearch",
1711 "ToolSearch",
1712 "Skill",
1713 "TodoWrite",
1714 "StructuredOutput",
1715 "SendUserMessage",
1716 ],
1717 "Verification" => vec![
1718 "bash",
1719 "read_file",
1720 "glob_search",
1721 "grep_search",
1722 "WebFetch",
1723 "WebSearch",
1724 "ToolSearch",
1725 "TodoWrite",
1726 "StructuredOutput",
1727 "SendUserMessage",
1728 "PowerShell",
1729 ],
1730 "claw-code-guide" => vec![
1731 "read_file",
1732 "glob_search",
1733 "grep_search",
1734 "WebFetch",
1735 "WebSearch",
1736 "ToolSearch",
1737 "Skill",
1738 "StructuredOutput",
1739 "SendUserMessage",
1740 ],
1741 "statusline-setup" => vec![
1742 "bash",
1743 "read_file",
1744 "write_file",
1745 "edit_file",
1746 "glob_search",
1747 "grep_search",
1748 "ToolSearch",
1749 ],
1750 _ => vec![
1751 "bash",
1752 "read_file",
1753 "write_file",
1754 "edit_file",
1755 "glob_search",
1756 "grep_search",
1757 "WebFetch",
1758 "WebSearch",
1759 "TodoWrite",
1760 "Skill",
1761 "ToolSearch",
1762 "NotebookEdit",
1763 "Sleep",
1764 "SendUserMessage",
1765 "Config",
1766 "StructuredOutput",
1767 "REPL",
1768 "PowerShell",
1769 ],
1770 };
1771 tools.into_iter().map(str::to_string).collect()
1772}
1773
1774fn agent_permission_policy() -> PermissionPolicy {
1775 mvp_tool_specs().into_iter().fold(
1776 PermissionPolicy::new(PermissionMode::DangerFullAccess),
1777 |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
1778 )
1779}
1780
1781fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
1782 std::fs::write(
1783 &manifest.manifest_file,
1784 serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
1785 )
1786 .map_err(|error| error.to_string())
1787}
1788
1789fn persist_agent_terminal_state(
1790 manifest: &AgentOutput,
1791 status: &str,
1792 result: Option<&str>,
1793 error: Option<String>,
1794) -> Result<(), String> {
1795 append_agent_output(
1796 &manifest.output_file,
1797 &format_agent_terminal_output(status, result, error.as_deref()),
1798 )?;
1799 let mut next_manifest = manifest.clone();
1800 next_manifest.status = status.to_string();
1801 next_manifest.completed_at = Some(iso8601_now());
1802 next_manifest.error = error;
1803 write_agent_manifest(&next_manifest)
1804}
1805
1806fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
1807 use std::io::Write as _;
1808
1809 let mut file = std::fs::OpenOptions::new()
1810 .append(true)
1811 .open(path)
1812 .map_err(|error| error.to_string())?;
1813 file.write_all(suffix.as_bytes())
1814 .map_err(|error| error.to_string())
1815}
1816
1817fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String {
1818 let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")];
1819 if let Some(result) = result.filter(|value| !value.trim().is_empty()) {
1820 sections.push(format!("\n### Final response\n\n{}\n", result.trim()));
1821 }
1822 if let Some(error) = error.filter(|value| !value.trim().is_empty()) {
1823 sections.push(format!("\n### Error\n\n{}\n", error.trim()));
1824 }
1825 sections.join("")
1826}
1827
1828struct TernlangRuntimeClient {
1829 runtime: tokio::runtime::Runtime,
1830 client: TernlangClient,
1831 model: String,
1832 allowed_tools: BTreeSet<String>,
1833}
1834
1835impl TernlangRuntimeClient {
1836 fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
1837 let client = TernlangClient::from_env()
1838 .map_err(|error| error.to_string())?
1839 .with_base_url(read_base_url());
1840 Ok(Self {
1841 runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
1842 client,
1843 model,
1844 allowed_tools,
1845 })
1846 }
1847}
1848
1849impl ApiClient for TernlangRuntimeClient {
1850 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
1851 let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
1852 .into_iter()
1853 .map(|spec| ToolDefinition {
1854 name: spec.name.to_string(),
1855 description: Some(spec.description.to_string()),
1856 input_schema: spec.input_schema,
1857 })
1858 .collect::<Vec<_>>();
1859 let message_request = MessageRequest {
1860 model: self.model.clone(),
1861 max_tokens: Some(32_000),
1862 messages: convert_messages(&request.messages),
1863 system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
1864 tools: (!tools.is_empty()).then_some(tools),
1865 tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto),
1866 stream: true,
1867 };
1868
1869 self.runtime.block_on(async {
1870 let mut stream = self
1871 .client
1872 .stream_message(&message_request)
1873 .await
1874 .map_err(|error| RuntimeError::new(error.to_string()))?;
1875 let mut events = Vec::new();
1876 let mut pending_tool: Option<(String, String, String)> = None;
1877 let mut saw_stop = false;
1878
1879 while let Some(event) = stream
1880 .next_event()
1881 .await
1882 .map_err(|error| RuntimeError::new(error.to_string()))?
1883 {
1884 match event {
1885 ApiStreamEvent::MessageStart(start) => {
1886 for block in start.message.content {
1887 push_output_block(block, &mut events, &mut pending_tool, true);
1888 }
1889 }
1890 ApiStreamEvent::ContentBlockStart(start) => {
1891 push_output_block(
1892 start.content_block,
1893 &mut events,
1894 &mut pending_tool,
1895 true,
1896 );
1897 }
1898 ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
1899 ContentBlockDelta::TextDelta { text } => {
1900 if !text.is_empty() {
1901 events.push(AssistantEvent::TextDelta(text));
1902 }
1903 }
1904 ContentBlockDelta::InputJsonDelta { partial_json } => {
1905 if let Some((_, _, input)) = &mut pending_tool {
1906 input.push_str(&partial_json);
1907 }
1908 }
1909 },
1910 ApiStreamEvent::ContentBlockStop(_) => {
1911 if let Some((id, name, input)) = pending_tool.take() {
1912 events.push(AssistantEvent::ToolUse { id, name, input });
1913 }
1914 }
1915 ApiStreamEvent::MessageDelta(delta) => {
1916 events.push(AssistantEvent::Usage(TokenUsage {
1917 input_tokens: delta.usage.input_tokens,
1918 output_tokens: delta.usage.output_tokens,
1919 cache_creation_input_tokens: 0,
1920 cache_read_input_tokens: 0,
1921 }));
1922 }
1923 ApiStreamEvent::MessageStop(_) => {
1924 saw_stop = true;
1925 events.push(AssistantEvent::MessageStop);
1926 }
1927 }
1928 }
1929
1930 if !saw_stop
1931 && events.iter().any(|event| {
1932 matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
1933 || matches!(event, AssistantEvent::ToolUse { .. })
1934 })
1935 {
1936 events.push(AssistantEvent::MessageStop);
1937 }
1938
1939 if events
1940 .iter()
1941 .any(|event| matches!(event, AssistantEvent::MessageStop))
1942 {
1943 return Ok(events);
1944 }
1945
1946 let response = self
1947 .client
1948 .send_message(&MessageRequest {
1949 stream: false,
1950 ..message_request.clone()
1951 })
1952 .await
1953 .map_err(|error| RuntimeError::new(error.to_string()))?;
1954 Ok(response_to_events(response))
1955 })
1956 }
1957}
1958
1959struct SubagentToolExecutor {
1960 allowed_tools: BTreeSet<String>,
1961}
1962
1963impl SubagentToolExecutor {
1964 fn new(allowed_tools: BTreeSet<String>) -> Self {
1965 Self { allowed_tools }
1966 }
1967}
1968
1969impl ToolExecutor for SubagentToolExecutor {
1970 fn execute(&mut self, tool_name: &str, input: &str) -> Result<runtime::ToolResult, ToolError> {
1971 if !self.allowed_tools.contains(tool_name) {
1972 return Err(ToolError::new(format!(
1973 "tool `{tool_name}` is not enabled for this sub-agent"
1974 )));
1975 }
1976 let value = serde_json::from_str(input)
1977 .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
1978 let res = execute_tool(tool_name, &value).map_err(ToolError::new)?;
1979 Ok(runtime::ToolResult {
1980 output: res.output,
1981 state: res.state,
1982 })
1983 }
1984
1985 fn query_memory(&mut self, query: &str) -> Result<String, ToolError> {
1986 let input = serde_json::json!({
1987 "action": "search_nodes",
1988 "query": query
1989 });
1990 let res = execute_tool("Memory", &input).map_err(ToolError::new)?;
1991 Ok(res.output)
1992 }
1993}
1994
1995fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
1996 mvp_tool_specs()
1997 .into_iter()
1998 .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
1999 .collect()
2000}
2001
2002fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
2003 messages
2004 .iter()
2005 .filter_map(|message| {
2006 let role = match message.role {
2007 MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
2008 MessageRole::Assistant => "assistant",
2009 };
2010 let content = message
2011 .blocks
2012 .iter()
2013 .map(|block| match block {
2014 ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
2015 ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
2016 id: id.clone(),
2017 name: name.clone(),
2018 input: serde_json::from_str(input)
2019 .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
2020 },
2021 ContentBlock::ToolResult {
2022 tool_use_id,
2023 output,
2024 is_error,
2025 ..
2026 } => InputContentBlock::ToolResult {
2027 tool_use_id: tool_use_id.clone(),
2028 content: vec![ToolResultContentBlock::Text {
2029 text: output.clone(),
2030 }],
2031 is_error: *is_error,
2032 },
2033 })
2034 .collect::<Vec<_>>();
2035 (!content.is_empty()).then(|| InputMessage {
2036 role: role.to_string(),
2037 content,
2038 })
2039 })
2040 .collect()
2041}
2042
2043fn push_output_block(
2044 block: OutputContentBlock,
2045 events: &mut Vec<AssistantEvent>,
2046 pending_tool: &mut Option<(String, String, String)>,
2047 streaming_tool_input: bool,
2048) {
2049 match block {
2050 OutputContentBlock::Text { text } => {
2051 if !text.is_empty() {
2052 events.push(AssistantEvent::TextDelta(text));
2053 }
2054 }
2055 OutputContentBlock::ToolUse { id, name, input } => {
2056 let initial_input = if streaming_tool_input
2057 && input.is_object()
2058 && input.as_object().is_some_and(serde_json::Map::is_empty)
2059 {
2060 String::new()
2061 } else {
2062 input.to_string()
2063 };
2064 *pending_tool = Some((id, name, initial_input));
2065 }
2066 }
2067}
2068
2069fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
2070 let mut events = Vec::new();
2071 let mut pending_tool = None;
2072
2073 for block in response.content {
2074 push_output_block(block, &mut events, &mut pending_tool, false);
2075 if let Some((id, name, input)) = pending_tool.take() {
2076 events.push(AssistantEvent::ToolUse { id, name, input });
2077 }
2078 }
2079
2080 events.push(AssistantEvent::Usage(TokenUsage {
2081 input_tokens: response.usage.input_tokens,
2082 output_tokens: response.usage.output_tokens,
2083 cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
2084 cache_read_input_tokens: response.usage.cache_read_input_tokens,
2085 }));
2086 events.push(AssistantEvent::MessageStop);
2087 events
2088}
2089
2090fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
2091 summary
2092 .assistant_messages
2093 .last()
2094 .map(|message| {
2095 message
2096 .blocks
2097 .iter()
2098 .filter_map(|block| match block {
2099 ContentBlock::Text { text } => Some(text.as_str()),
2100 _ => None,
2101 })
2102 .collect::<Vec<_>>()
2103 .join("")
2104 })
2105 .unwrap_or_default()
2106}
2107
2108#[allow(clippy::needless_pass_by_value)]
2109fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
2110 let deferred = deferred_tool_specs();
2111 let max_results = input.max_results.unwrap_or(5).max(1);
2112 let query = input.query.trim().to_string();
2113 let normalized_query = normalize_tool_search_query(&query);
2114 let matches = search_tool_specs(&query, max_results, &deferred);
2115
2116 ToolSearchOutput {
2117 matches,
2118 query,
2119 normalized_query,
2120 total_deferred_tools: deferred.len(),
2121 pending_mcp_servers: None,
2122 }
2123}
2124
2125fn deferred_tool_specs() -> Vec<ToolSpec> {
2126 mvp_tool_specs()
2127 .into_iter()
2128 .filter(|spec| {
2129 !matches!(
2130 spec.name,
2131 "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
2132 )
2133 })
2134 .collect()
2135}
2136
2137fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
2138 let lowered = query.to_lowercase();
2139 if let Some(selection) = lowered.strip_prefix("select:") {
2140 return selection
2141 .split(',')
2142 .map(str::trim)
2143 .filter(|part| !part.is_empty())
2144 .filter_map(|wanted| {
2145 let wanted = canonical_tool_token(wanted);
2146 specs
2147 .iter()
2148 .find(|spec| canonical_tool_token(spec.name) == wanted)
2149 .map(|spec| spec.name.to_string())
2150 })
2151 .take(max_results)
2152 .collect();
2153 }
2154
2155 let mut required = Vec::new();
2156 let mut optional = Vec::new();
2157 for term in lowered.split_whitespace() {
2158 if let Some(rest) = term.strip_prefix('+') {
2159 if !rest.is_empty() {
2160 required.push(rest);
2161 }
2162 } else {
2163 optional.push(term);
2164 }
2165 }
2166 let terms = if required.is_empty() {
2167 optional.clone()
2168 } else {
2169 required.iter().chain(optional.iter()).copied().collect()
2170 };
2171
2172 let mut scored = specs
2173 .iter()
2174 .filter_map(|spec| {
2175 let name = spec.name.to_lowercase();
2176 let canonical_name = canonical_tool_token(spec.name);
2177 let normalized_description = normalize_tool_search_query(spec.description);
2178 let haystack = format!(
2179 "{name} {} {canonical_name}",
2180 spec.description.to_lowercase()
2181 );
2182 let normalized_haystack = format!("{canonical_name} {normalized_description}");
2183 if required.iter().any(|term| !haystack.contains(term)) {
2184 return None;
2185 }
2186
2187 let mut score = 0_i32;
2188 for term in &terms {
2189 let canonical_term = canonical_tool_token(term);
2190 if haystack.contains(term) {
2191 score += 2;
2192 }
2193 if name == *term {
2194 score += 8;
2195 }
2196 if name.contains(term) {
2197 score += 4;
2198 }
2199 if canonical_name == canonical_term {
2200 score += 12;
2201 }
2202 if normalized_haystack.contains(&canonical_term) {
2203 score += 3;
2204 }
2205 }
2206
2207 if score == 0 && !lowered.is_empty() {
2208 return None;
2209 }
2210 Some((score, spec.name.to_string()))
2211 })
2212 .collect::<Vec<_>>();
2213
2214 scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
2215 scored
2216 .into_iter()
2217 .map(|(_, name)| name)
2218 .take(max_results)
2219 .collect()
2220}
2221
2222fn normalize_tool_search_query(query: &str) -> String {
2223 query
2224 .trim()
2225 .split(|ch: char| ch.is_whitespace() || ch == ',')
2226 .filter(|term| !term.is_empty())
2227 .map(canonical_tool_token)
2228 .collect::<Vec<_>>()
2229 .join(" ")
2230}
2231
2232fn canonical_tool_token(value: &str) -> String {
2233 let mut canonical = value
2234 .chars()
2235 .filter(char::is_ascii_alphanumeric)
2236 .flat_map(char::to_lowercase)
2237 .collect::<String>();
2238 if let Some(stripped) = canonical.strip_suffix("tool") {
2239 canonical = stripped.to_string();
2240 }
2241 canonical
2242}
2243
2244fn agent_store_dir() -> Result<std::path::PathBuf, String> {
2245 if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
2246 return Ok(std::path::PathBuf::from(path));
2247 }
2248 let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
2249 if let Some(workspace_root) = cwd.ancestors().nth(2) {
2250 return Ok(workspace_root.join(".clawd-agents"));
2251 }
2252 Ok(cwd.join(".clawd-agents"))
2253}
2254
2255fn make_agent_id() -> String {
2256 let nanos = std::time::SystemTime::now()
2257 .duration_since(std::time::UNIX_EPOCH)
2258 .unwrap_or_default()
2259 .as_nanos();
2260 format!("agent-{nanos}")
2261}
2262
2263fn slugify_agent_name(description: &str) -> String {
2264 let mut out = description
2265 .chars()
2266 .map(|ch| {
2267 if ch.is_ascii_alphanumeric() {
2268 ch.to_ascii_lowercase()
2269 } else {
2270 '-'
2271 }
2272 })
2273 .collect::<String>();
2274 while out.contains("--") {
2275 out = out.replace("--", "-");
2276 }
2277 out.trim_matches('-').chars().take(32).collect()
2278}
2279
2280fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
2281 let trimmed = subagent_type.map(str::trim).unwrap_or_default();
2282 if trimmed.is_empty() {
2283 return String::from("general-purpose");
2284 }
2285
2286 match canonical_tool_token(trimmed).as_str() {
2287 "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
2288 "explore" | "explorer" | "exploreagent" => String::from("Explore"),
2289 "plan" | "planagent" => String::from("Plan"),
2290 "verification" | "verificationagent" | "verify" | "verifier" => {
2291 String::from("Verification")
2292 }
2293 "ternlangcodeguide" | "ternlangcodeguideagent" | "guide" => String::from("claw-code-guide"),
2294 "statusline" | "statuslinesetup" => String::from("statusline-setup"),
2295 _ => trimmed.to_string(),
2296 }
2297}
2298
2299fn iso8601_now() -> String {
2300 std::time::SystemTime::now()
2301 .duration_since(std::time::UNIX_EPOCH)
2302 .unwrap_or_default()
2303 .as_secs()
2304 .to_string()
2305}
2306
2307#[allow(clippy::too_many_lines)]
2308fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
2309 let path = std::path::PathBuf::from(&input.notebook_path);
2310 if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
2311 return Err(String::from(
2312 "File must be a Jupyter notebook (.ipynb file).",
2313 ));
2314 }
2315
2316 let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
2317 let mut notebook: serde_json::Value =
2318 serde_json::from_str(&original_file).map_err(|error| error.to_string())?;
2319 let language = notebook
2320 .get("metadata")
2321 .and_then(|metadata| metadata.get("kernelspec"))
2322 .and_then(|kernelspec| kernelspec.get("language"))
2323 .and_then(serde_json::Value::as_str)
2324 .unwrap_or("python")
2325 .to_string();
2326 let cells = notebook
2327 .get_mut("cells")
2328 .and_then(serde_json::Value::as_array_mut)
2329 .ok_or_else(|| String::from("Notebook cells array not found"))?;
2330
2331 let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
2332 let target_index = match input.cell_id.as_deref() {
2333 Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
2334 None if matches!(
2335 edit_mode,
2336 NotebookEditMode::Replace | NotebookEditMode::Delete
2337 ) =>
2338 {
2339 Some(resolve_cell_index(cells, None, edit_mode)?)
2340 }
2341 None => None,
2342 };
2343 let resolved_cell_type = match edit_mode {
2344 NotebookEditMode::Delete => None,
2345 NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
2346 NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
2347 target_index
2348 .and_then(|index| cells.get(index))
2349 .and_then(cell_kind)
2350 .unwrap_or(NotebookCellType::Code)
2351 })),
2352 };
2353 let new_source = require_notebook_source(input.new_source, edit_mode)?;
2354
2355 let cell_id = match edit_mode {
2356 NotebookEditMode::Insert => {
2357 let resolved_cell_type = resolved_cell_type.expect("insert cell type");
2358 let new_id = make_cell_id(cells.len());
2359 let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
2360 let insert_at = target_index.map_or(cells.len(), |index| index + 1);
2361 cells.insert(insert_at, new_cell);
2362 cells
2363 .get(insert_at)
2364 .and_then(|cell| cell.get("id"))
2365 .and_then(serde_json::Value::as_str)
2366 .map(ToString::to_string)
2367 }
2368 NotebookEditMode::Delete => {
2369 let removed = cells.remove(target_index.expect("delete target index"));
2370 removed
2371 .get("id")
2372 .and_then(serde_json::Value::as_str)
2373 .map(ToString::to_string)
2374 }
2375 NotebookEditMode::Replace => {
2376 let resolved_cell_type = resolved_cell_type.expect("replace cell type");
2377 let cell = cells
2378 .get_mut(target_index.expect("replace target index"))
2379 .ok_or_else(|| String::from("Cell index out of range"))?;
2380 cell["source"] = serde_json::Value::Array(source_lines(&new_source));
2381 cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
2382 NotebookCellType::Code => String::from("code"),
2383 NotebookCellType::Markdown => String::from("markdown"),
2384 });
2385 match resolved_cell_type {
2386 NotebookCellType::Code => {
2387 if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
2388 cell["outputs"] = json!([]);
2389 }
2390 if cell.get("execution_count").is_none() {
2391 cell["execution_count"] = serde_json::Value::Null;
2392 }
2393 }
2394 NotebookCellType::Markdown => {
2395 if let Some(object) = cell.as_object_mut() {
2396 object.remove("outputs");
2397 object.remove("execution_count");
2398 }
2399 }
2400 }
2401 cell.get("id")
2402 .and_then(serde_json::Value::as_str)
2403 .map(ToString::to_string)
2404 }
2405 };
2406
2407 let updated_file =
2408 serde_json::to_string_pretty(¬ebook).map_err(|error| error.to_string())?;
2409 std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
2410
2411 Ok(NotebookEditOutput {
2412 new_source,
2413 cell_id,
2414 cell_type: resolved_cell_type,
2415 language,
2416 edit_mode: format_notebook_edit_mode(edit_mode),
2417 error: None,
2418 notebook_path: path.display().to_string(),
2419 original_file,
2420 updated_file,
2421 })
2422}
2423
2424fn require_notebook_source(
2425 source: Option<String>,
2426 edit_mode: NotebookEditMode,
2427) -> Result<String, String> {
2428 match edit_mode {
2429 NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
2430 NotebookEditMode::Insert | NotebookEditMode::Replace => source
2431 .ok_or_else(|| String::from("new_source is required for insert and replace edits")),
2432 }
2433}
2434
2435fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
2436 let mut cell = json!({
2437 "cell_type": match cell_type {
2438 NotebookCellType::Code => "code",
2439 NotebookCellType::Markdown => "markdown",
2440 },
2441 "id": cell_id,
2442 "metadata": {},
2443 "source": source_lines(source),
2444 });
2445 if let Some(object) = cell.as_object_mut() {
2446 match cell_type {
2447 NotebookCellType::Code => {
2448 object.insert(String::from("outputs"), json!([]));
2449 object.insert(String::from("execution_count"), Value::Null);
2450 }
2451 NotebookCellType::Markdown => {}
2452 }
2453 }
2454 cell
2455}
2456
2457fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
2458 cell.get("cell_type")
2459 .and_then(serde_json::Value::as_str)
2460 .map(|kind| {
2461 if kind == "markdown" {
2462 NotebookCellType::Markdown
2463 } else {
2464 NotebookCellType::Code
2465 }
2466 })
2467}
2468
2469#[allow(clippy::needless_pass_by_value)]
2470fn execute_sleep(input: SleepInput) -> SleepOutput {
2471 std::thread::sleep(Duration::from_millis(input.duration_ms));
2472 SleepOutput {
2473 duration_ms: input.duration_ms,
2474 message: format!("Slept for {}ms", input.duration_ms),
2475 }
2476}
2477
2478fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
2479 if input.message.trim().is_empty() {
2480 return Err(String::from("message must not be empty"));
2481 }
2482
2483 let attachments = input
2484 .attachments
2485 .as_ref()
2486 .map(|paths| {
2487 paths
2488 .iter()
2489 .map(|path| resolve_attachment(path))
2490 .collect::<Result<Vec<_>, String>>()
2491 })
2492 .transpose()?;
2493
2494 let message = match input.status {
2495 BriefStatus::Normal | BriefStatus::Proactive => input.message,
2496 };
2497
2498 Ok(BriefOutput {
2499 message,
2500 attachments,
2501 sent_at: iso8601_timestamp(),
2502 })
2503}
2504
2505fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
2506 let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
2507 let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
2508 Ok(ResolvedAttachment {
2509 path: resolved.display().to_string(),
2510 size: metadata.len(),
2511 is_image: is_image_path(&resolved),
2512 })
2513}
2514
2515fn is_image_path(path: &Path) -> bool {
2516 matches!(
2517 path.extension()
2518 .and_then(|ext| ext.to_str())
2519 .map(str::to_ascii_lowercase)
2520 .as_deref(),
2521 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
2522 )
2523}
2524
2525fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
2526 let setting = input.setting.trim();
2527 if setting.is_empty() {
2528 return Err(String::from("setting must not be empty"));
2529 }
2530 let Some(spec) = supported_config_setting(setting) else {
2531 return Ok(ConfigOutput {
2532 success: false,
2533 operation: None,
2534 setting: None,
2535 value: None,
2536 previous_value: None,
2537 new_value: None,
2538 error: Some(format!("Unknown setting: \"{setting}\"")),
2539 });
2540 };
2541
2542 let path = config_file_for_scope(spec.scope)?;
2543 let mut document = read_json_object(&path)?;
2544
2545 if let Some(value) = input.value {
2546 let normalized = normalize_config_value(spec, value)?;
2547 let previous_value = get_nested_value(&document, spec.path).cloned();
2548 set_nested_value(&mut document, spec.path, normalized.clone());
2549 write_json_object(&path, &document)?;
2550 Ok(ConfigOutput {
2551 success: true,
2552 operation: Some(String::from("set")),
2553 setting: Some(setting.to_string()),
2554 value: Some(normalized.clone()),
2555 previous_value,
2556 new_value: Some(normalized),
2557 error: None,
2558 })
2559 } else {
2560 Ok(ConfigOutput {
2561 success: true,
2562 operation: Some(String::from("get")),
2563 setting: Some(setting.to_string()),
2564 value: get_nested_value(&document, spec.path).cloned(),
2565 previous_value: None,
2566 new_value: None,
2567 error: None,
2568 })
2569 }
2570}
2571
2572fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
2573 StructuredOutputResult {
2574 data: String::from("Structured output provided successfully"),
2575 structured_output: input.0,
2576 }
2577}
2578
2579fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
2580 if input.code.trim().is_empty() {
2581 return Err(String::from("code must not be empty"));
2582 }
2583 let _ = input.timeout_ms;
2584 let runtime = resolve_repl_runtime(&input.language)?;
2585 let started = Instant::now();
2586 let output = Command::new(runtime.program)
2587 .args(runtime.args)
2588 .arg(&input.code)
2589 .output()
2590 .map_err(|error| error.to_string())?;
2591
2592 Ok(ReplOutput {
2593 language: input.language,
2594 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2595 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2596 exit_code: output.status.code().unwrap_or(1),
2597 duration_ms: started.elapsed().as_millis(),
2598 })
2599}
2600
2601struct ReplRuntime {
2602 program: &'static str,
2603 args: &'static [&'static str],
2604}
2605
2606fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
2607 match language.trim().to_ascii_lowercase().as_str() {
2608 "python" | "py" => Ok(ReplRuntime {
2609 program: detect_first_command(&["python3", "python"])
2610 .ok_or_else(|| String::from("python runtime not found"))?,
2611 args: &["-c"],
2612 }),
2613 "javascript" | "js" | "node" => Ok(ReplRuntime {
2614 program: detect_first_command(&["node"])
2615 .ok_or_else(|| String::from("node runtime not found"))?,
2616 args: &["-e"],
2617 }),
2618 "sh" | "shell" | "bash" => Ok(ReplRuntime {
2619 program: detect_first_command(&["bash", "sh"])
2620 .ok_or_else(|| String::from("shell runtime not found"))?,
2621 args: &["-lc"],
2622 }),
2623 other => Err(format!("unsupported REPL language: {other}")),
2624 }
2625}
2626
2627fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
2628 commands
2629 .iter()
2630 .copied()
2631 .find(|command| command_exists(command))
2632}
2633
2634#[derive(Clone, Copy)]
2635enum ConfigScope {
2636 Global,
2637 Settings,
2638}
2639
2640#[derive(Clone, Copy)]
2641struct ConfigSettingSpec {
2642 scope: ConfigScope,
2643 kind: ConfigKind,
2644 path: &'static [&'static str],
2645 options: Option<&'static [&'static str]>,
2646}
2647
2648#[derive(Clone, Copy)]
2649enum ConfigKind {
2650 Boolean,
2651 String,
2652}
2653
2654fn supported_config_setting(setting: &str) -> Option<ConfigSettingSpec> {
2655 Some(match setting {
2656 "theme" => ConfigSettingSpec {
2657 scope: ConfigScope::Global,
2658 kind: ConfigKind::String,
2659 path: &["theme"],
2660 options: None,
2661 },
2662 "editorMode" => ConfigSettingSpec {
2663 scope: ConfigScope::Global,
2664 kind: ConfigKind::String,
2665 path: &["editorMode"],
2666 options: Some(&["default", "vim", "emacs"]),
2667 },
2668 "verbose" => ConfigSettingSpec {
2669 scope: ConfigScope::Global,
2670 kind: ConfigKind::Boolean,
2671 path: &["verbose"],
2672 options: None,
2673 },
2674 "preferredNotifChannel" => ConfigSettingSpec {
2675 scope: ConfigScope::Global,
2676 kind: ConfigKind::String,
2677 path: &["preferredNotifChannel"],
2678 options: None,
2679 },
2680 "autoCompactEnabled" => ConfigSettingSpec {
2681 scope: ConfigScope::Global,
2682 kind: ConfigKind::Boolean,
2683 path: &["autoCompactEnabled"],
2684 options: None,
2685 },
2686 "autoMemoryEnabled" => ConfigSettingSpec {
2687 scope: ConfigScope::Settings,
2688 kind: ConfigKind::Boolean,
2689 path: &["autoMemoryEnabled"],
2690 options: None,
2691 },
2692 "autoDreamEnabled" => ConfigSettingSpec {
2693 scope: ConfigScope::Settings,
2694 kind: ConfigKind::Boolean,
2695 path: &["autoDreamEnabled"],
2696 options: None,
2697 },
2698 "fileCheckpointingEnabled" => ConfigSettingSpec {
2699 scope: ConfigScope::Global,
2700 kind: ConfigKind::Boolean,
2701 path: &["fileCheckpointingEnabled"],
2702 options: None,
2703 },
2704 "showTurnDuration" => ConfigSettingSpec {
2705 scope: ConfigScope::Global,
2706 kind: ConfigKind::Boolean,
2707 path: &["showTurnDuration"],
2708 options: None,
2709 },
2710 "terminalProgressBarEnabled" => ConfigSettingSpec {
2711 scope: ConfigScope::Global,
2712 kind: ConfigKind::Boolean,
2713 path: &["terminalProgressBarEnabled"],
2714 options: None,
2715 },
2716 "todoFeatureEnabled" => ConfigSettingSpec {
2717 scope: ConfigScope::Global,
2718 kind: ConfigKind::Boolean,
2719 path: &["todoFeatureEnabled"],
2720 options: None,
2721 },
2722 "model" => ConfigSettingSpec {
2723 scope: ConfigScope::Settings,
2724 kind: ConfigKind::String,
2725 path: &["model"],
2726 options: None,
2727 },
2728 "alwaysThinkingEnabled" => ConfigSettingSpec {
2729 scope: ConfigScope::Settings,
2730 kind: ConfigKind::Boolean,
2731 path: &["alwaysThinkingEnabled"],
2732 options: None,
2733 },
2734 "permissions.defaultMode" => ConfigSettingSpec {
2735 scope: ConfigScope::Settings,
2736 kind: ConfigKind::String,
2737 path: &["permissions", "defaultMode"],
2738 options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]),
2739 },
2740 "language" => ConfigSettingSpec {
2741 scope: ConfigScope::Settings,
2742 kind: ConfigKind::String,
2743 path: &["language"],
2744 options: None,
2745 },
2746 "teammateMode" => ConfigSettingSpec {
2747 scope: ConfigScope::Global,
2748 kind: ConfigKind::String,
2749 path: &["teammateMode"],
2750 options: Some(&["tmux", "in-process", "auto"]),
2751 },
2752 _ => return None,
2753 })
2754}
2755
2756fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result<Value, String> {
2757 let normalized = match (spec.kind, value) {
2758 (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value),
2759 (ConfigKind::Boolean, ConfigValue::String(value)) => {
2760 match value.trim().to_ascii_lowercase().as_str() {
2761 "true" => Value::Bool(true),
2762 "false" => Value::Bool(false),
2763 _ => return Err(String::from("setting requires true or false")),
2764 }
2765 }
2766 (ConfigKind::Boolean, ConfigValue::Number(_)) => {
2767 return Err(String::from("setting requires true or false"))
2768 }
2769 (ConfigKind::String, ConfigValue::String(value)) => Value::String(value),
2770 (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()),
2771 (ConfigKind::String, ConfigValue::Number(value)) => json!(value),
2772 };
2773
2774 if let Some(options) = spec.options {
2775 let Some(as_str) = normalized.as_str() else {
2776 return Err(String::from("setting requires a string value"));
2777 };
2778 if !options.iter().any(|option| option == &as_str) {
2779 return Err(format!(
2780 "Invalid value \"{as_str}\". Options: {}",
2781 options.join(", ")
2782 ));
2783 }
2784 }
2785
2786 Ok(normalized)
2787}
2788
2789fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
2790 let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
2791 Ok(match scope {
2792 ConfigScope::Global => config_home_dir()?.join("settings.json"),
2793 ConfigScope::Settings => cwd.join(".ternlang").join("settings.local.json"),
2794 })
2795}
2796
2797fn config_home_dir() -> Result<PathBuf, String> {
2798 if let Ok(path) = std::env::var("TERNLANG_CONFIG_HOME") {
2799 return Ok(PathBuf::from(path));
2800 }
2801 let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
2802 Ok(PathBuf::from(home).join(".ternlang"))
2803}
2804
2805fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
2806 match std::fs::read_to_string(path) {
2807 Ok(contents) => {
2808 if contents.trim().is_empty() {
2809 return Ok(serde_json::Map::new());
2810 }
2811 serde_json::from_str::<Value>(&contents)
2812 .map_err(|error| error.to_string())?
2813 .as_object()
2814 .cloned()
2815 .ok_or_else(|| String::from("config file must contain a JSON object"))
2816 }
2817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
2818 Err(error) => Err(error.to_string()),
2819 }
2820}
2821
2822fn write_json_object(path: &Path, value: &serde_json::Map<String, Value>) -> Result<(), String> {
2823 if let Some(parent) = path.parent() {
2824 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
2825 }
2826 std::fs::write(
2827 path,
2828 serde_json::to_string_pretty(value).map_err(|error| error.to_string())?,
2829 )
2830 .map_err(|error| error.to_string())
2831}
2832
2833fn get_nested_value<'a>(
2834 value: &'a serde_json::Map<String, Value>,
2835 path: &[&str],
2836) -> Option<&'a Value> {
2837 let (first, rest) = path.split_first()?;
2838 let mut current = value.get(*first)?;
2839 for key in rest {
2840 current = current.as_object()?.get(*key)?;
2841 }
2842 Some(current)
2843}
2844
2845fn set_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str], new_value: Value) {
2846 let (first, rest) = path.split_first().expect("config path must not be empty");
2847 if rest.is_empty() {
2848 root.insert((*first).to_string(), new_value);
2849 return;
2850 }
2851
2852 let entry = root
2853 .entry((*first).to_string())
2854 .or_insert_with(|| Value::Object(serde_json::Map::new()));
2855 if !entry.is_object() {
2856 *entry = Value::Object(serde_json::Map::new());
2857 }
2858 let map = entry.as_object_mut().expect("object inserted");
2859 set_nested_value(map, rest, new_value);
2860}
2861
2862fn iso8601_timestamp() -> String {
2863 if let Ok(output) = Command::new("date")
2864 .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
2865 .output()
2866 {
2867 if output.status.success() {
2868 return String::from_utf8_lossy(&output.stdout).trim().to_string();
2869 }
2870 }
2871 iso8601_now()
2872}
2873
2874#[allow(clippy::needless_pass_by_value)]
2875fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
2876 let _ = &input.description;
2877 let shell = detect_powershell_shell()?;
2878 execute_shell_command(
2879 shell,
2880 &input.command,
2881 input.timeout,
2882 input.run_in_background,
2883 )
2884}
2885
2886fn detect_powershell_shell() -> std::io::Result<&'static str> {
2887 if command_exists("pwsh") {
2888 Ok("pwsh")
2889 } else if command_exists("powershell") {
2890 Ok("powershell")
2891 } else {
2892 Err(std::io::Error::new(
2893 std::io::ErrorKind::NotFound,
2894 "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)",
2895 ))
2896 }
2897}
2898
2899fn command_exists(command: &str) -> bool {
2900 std::process::Command::new("sh")
2901 .arg("-lc")
2902 .arg(format!("command -v {command} >/dev/null 2>&1"))
2903 .status()
2904 .map(|status| status.success())
2905 .unwrap_or(false)
2906}
2907
2908#[allow(clippy::too_many_lines)]
2909fn execute_shell_command(
2910 shell: &str,
2911 command: &str,
2912 timeout: Option<u64>,
2913 run_in_background: Option<bool>,
2914) -> std::io::Result<runtime::BashCommandOutput> {
2915 if run_in_background.unwrap_or(false) {
2916 let child = std::process::Command::new(shell)
2917 .arg("-NoProfile")
2918 .arg("-NonInteractive")
2919 .arg("-Command")
2920 .arg(command)
2921 .stdin(std::process::Stdio::null())
2922 .stdout(std::process::Stdio::null())
2923 .stderr(std::process::Stdio::null())
2924 .spawn()?;
2925 return Ok(runtime::BashCommandOutput {
2926 stdout: String::new(),
2927 stderr: String::new(),
2928 raw_output_path: None,
2929 interrupted: false,
2930 is_image: None,
2931 background_task_id: Some(child.id().to_string()),
2932 backgrounded_by_user: Some(true),
2933 assistant_auto_backgrounded: Some(false),
2934 validation_state: 1,
2935 return_code_interpretation: None,
2936 no_output_expected: Some(true),
2937 structured_content: None,
2938 persisted_output_path: None,
2939 persisted_output_size: None,
2940 sandbox_status: None,
2941 });
2942 }
2943
2944 let mut process = std::process::Command::new(shell);
2945 process
2946 .arg("-NoProfile")
2947 .arg("-NonInteractive")
2948 .arg("-Command")
2949 .arg(command);
2950 process
2951 .stdout(std::process::Stdio::piped())
2952 .stderr(std::process::Stdio::piped());
2953
2954 if let Some(timeout_ms) = timeout {
2955 let mut child = process.spawn()?;
2956 let started = Instant::now();
2957 loop {
2958 if let Some(status) = child.try_wait()? {
2959 let output = child.wait_with_output()?;
2960 return Ok(runtime::BashCommandOutput {
2961 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2962 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2963 raw_output_path: None,
2964 interrupted: false,
2965 is_image: None,
2966 background_task_id: None,
2967 backgrounded_by_user: None,
2968 assistant_auto_backgrounded: None,
2969 validation_state: 1,
2970 return_code_interpretation: status
2971 .code()
2972 .filter(|code| *code != 0)
2973 .map(|code| format!("exit_code:{code}")),
2974 no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
2975 structured_content: None,
2976 persisted_output_path: None,
2977 persisted_output_size: None,
2978 sandbox_status: None,
2979 });
2980 }
2981 if started.elapsed() >= Duration::from_millis(timeout_ms) {
2982 let _ = child.kill();
2983 let output = child.wait_with_output()?;
2984 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2985 let stderr = if stderr.trim().is_empty() {
2986 format!("Command exceeded timeout of {timeout_ms} ms")
2987 } else {
2988 format!(
2989 "{}
2990Command exceeded timeout of {timeout_ms} ms",
2991 stderr.trim_end()
2992 )
2993 };
2994 return Ok(runtime::BashCommandOutput {
2995 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2996 stderr,
2997 raw_output_path: None,
2998 interrupted: true,
2999 is_image: None,
3000 background_task_id: None,
3001 backgrounded_by_user: None,
3002 assistant_auto_backgrounded: None,
3003 validation_state: 1,
3004 return_code_interpretation: Some(String::from("timeout")),
3005 no_output_expected: Some(false),
3006 structured_content: None,
3007 persisted_output_path: None,
3008 persisted_output_size: None,
3009 sandbox_status: None,
3010 });
3011 }
3012 std::thread::sleep(Duration::from_millis(10));
3013 }
3014 }
3015
3016 let output = process.output()?;
3017 Ok(runtime::BashCommandOutput {
3018 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3019 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3020 raw_output_path: None,
3021 interrupted: false,
3022 is_image: None,
3023 background_task_id: None,
3024 backgrounded_by_user: None,
3025 assistant_auto_backgrounded: None,
3026 validation_state: 1,
3027 return_code_interpretation: output
3028 .status
3029 .code()
3030 .filter(|code| *code != 0)
3031 .map(|code| format!("exit_code:{code}")),
3032 no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
3033 structured_content: None,
3034 persisted_output_path: None,
3035 persisted_output_size: None,
3036 sandbox_status: None,
3037 })
3038}
3039
3040fn resolve_cell_index(
3041 cells: &[serde_json::Value],
3042 cell_id: Option<&str>,
3043 edit_mode: NotebookEditMode,
3044) -> Result<usize, String> {
3045 if cells.is_empty()
3046 && matches!(
3047 edit_mode,
3048 NotebookEditMode::Replace | NotebookEditMode::Delete
3049 )
3050 {
3051 return Err(String::from("Notebook has no cells to edit"));
3052 }
3053 if let Some(cell_id) = cell_id {
3054 cells
3055 .iter()
3056 .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
3057 .ok_or_else(|| format!("Cell id not found: {cell_id}"))
3058 } else {
3059 Ok(cells.len().saturating_sub(1))
3060 }
3061}
3062
3063fn source_lines(source: &str) -> Vec<serde_json::Value> {
3064 if source.is_empty() {
3065 return vec![serde_json::Value::String(String::new())];
3066 }
3067 source
3068 .split_inclusive('\n')
3069 .map(|line| serde_json::Value::String(line.to_string()))
3070 .collect()
3071}
3072
3073fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
3074 match mode {
3075 NotebookEditMode::Replace => String::from("replace"),
3076 NotebookEditMode::Insert => String::from("insert"),
3077 NotebookEditMode::Delete => String::from("delete"),
3078 }
3079}
3080
3081fn make_cell_id(index: usize) -> String {
3082 format!("cell-{}", index + 1)
3083}
3084
3085fn parse_skill_description(contents: &str) -> Option<String> {
3086 for line in contents.lines() {
3087 if let Some(value) = line.strip_prefix("description:") {
3088 let trimmed = value.trim();
3089 if !trimmed.is_empty() {
3090 return Some(trimmed.to_string());
3091 }
3092 }
3093 }
3094 None
3095}
3096
3097#[cfg(test)]
3098mod tests {
3099 use std::collections::BTreeSet;
3100 use std::fs;
3101 use std::io::{Read, Write};
3102 use std::net::{SocketAddr, TcpListener};
3103 use std::path::PathBuf;
3104 use std::sync::{Arc, Mutex, OnceLock};
3105 use std::thread;
3106 use std::time::Duration;
3107
3108 use super::{
3109 agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
3110 execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
3111 AgentInput, AgentJob, SubagentToolExecutor,
3112 };
3113 use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
3114 use serde_json::json;
3115
3116 fn env_lock() -> &'static Mutex<()> {
3117 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3118 LOCK.get_or_init(|| Mutex::new(()))
3119 }
3120
3121 fn temp_path(name: &str) -> PathBuf {
3122 let unique = std::time::SystemTime::now()
3123 .duration_since(std::time::UNIX_EPOCH)
3124 .expect("time")
3125 .as_nanos();
3126 std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
3127 }
3128
3129 #[test]
3130 fn exposes_mvp_tools() {
3131 let names = mvp_tool_specs()
3132 .into_iter()
3133 .map(|spec| spec.name)
3134 .collect::<Vec<_>>();
3135 assert!(names.contains(&"bash"));
3136 assert!(names.contains(&"read_file"));
3137 assert!(names.contains(&"WebFetch"));
3138 assert!(names.contains(&"WebSearch"));
3139 assert!(names.contains(&"TodoWrite"));
3140 assert!(names.contains(&"Skill"));
3141 assert!(names.contains(&"Agent"));
3142 assert!(names.contains(&"ToolSearch"));
3143 assert!(names.contains(&"NotebookEdit"));
3144 assert!(names.contains(&"Sleep"));
3145 assert!(names.contains(&"SendUserMessage"));
3146 assert!(names.contains(&"Config"));
3147 assert!(names.contains(&"StructuredOutput"));
3148 assert!(names.contains(&"REPL"));
3149 assert!(names.contains(&"PowerShell"));
3150 }
3151
3152 #[test]
3153 fn rejects_unknown_tool_names() {
3154 let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
3155 assert!(error.contains("unsupported tool"));
3156 }
3157
3158 #[test]
3159 fn web_fetch_returns_prompt_aware_summary() {
3160 let server = TestServer::spawn(Arc::new(|request_line: &str| {
3161 assert!(request_line.starts_with("GET /page "));
3162 HttpResponse::html(
3163 200,
3164 "OK",
3165 "<html><head><title>Ignored</title></head><body><h1>Test Page</h1><p>Hello <b>world</b> from local server.</p></body></html>",
3166 )
3167 }));
3168
3169 let result = execute_tool(
3170 "WebFetch",
3171 &json!({
3172 "url": format!("http://{}/page", server.addr()),
3173 "prompt": "Summarize this page"
3174 }),
3175 )
3176 .expect("WebFetch should succeed");
3177
3178 let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3179 assert_eq!(output["code"], 200);
3180 let summary = output["result"].as_str().expect("result string");
3181 assert!(summary.contains("Fetched"));
3182 assert!(summary.contains("Test Page"));
3183 assert!(summary.contains("Hello world from local server"));
3184
3185 let titled = execute_tool(
3186 "WebFetch",
3187 &json!({
3188 "url": format!("http://{}/page", server.addr()),
3189 "prompt": "What is the page title?"
3190 }),
3191 )
3192 .expect("WebFetch title query should succeed");
3193 let titled_output: serde_json::Value = serde_json::from_str(&titled.output).expect("valid json");
3194 let titled_summary = titled_output["result"].as_str().expect("result string");
3195 assert!(titled_summary.contains("Title: Ignored"));
3196 }
3197
3198 #[test]
3199 fn web_fetch_supports_plain_text_and_rejects_invalid_url() {
3200 let server = TestServer::spawn(Arc::new(|request_line: &str| {
3201 assert!(request_line.starts_with("GET /plain "));
3202 HttpResponse::text(200, "OK", "plain text response")
3203 }));
3204
3205 let result = execute_tool(
3206 "WebFetch",
3207 &json!({
3208 "url": format!("http://{}/plain", server.addr()),
3209 "prompt": "Show me the content"
3210 }),
3211 )
3212 .expect("WebFetch should succeed for text content");
3213
3214 let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3215 assert_eq!(output["url"], format!("http://{}/plain", server.addr()));
3216 assert!(output["result"]
3217 .as_str()
3218 .expect("result")
3219 .contains("plain text response"));
3220
3221 let error = execute_tool(
3222 "WebFetch",
3223 &json!({
3224 "url": "not a url",
3225 "prompt": "Summarize"
3226 }),
3227 )
3228 .expect_err("invalid URL should fail");
3229 assert!(error.contains("relative URL without a base") || error.contains("invalid"));
3230 }
3231
3232 #[test]
3233 fn web_search_extracts_and_filters_results() {
3234 let server = TestServer::spawn(Arc::new(|request_line: &str| {
3235 assert!(request_line.contains("GET /search?q=rust+web+search "));
3236 HttpResponse::html(
3237 200,
3238 "OK",
3239 r#"
3240 <html><body>
3241 <a class="result__a" href="https://docs.rs/reqwest">Reqwest docs</a>
3242 <a class="result__a" href="https://example.com/blocked">Blocked result</a>
3243 </body></html>
3244 "#,
3245 )
3246 }));
3247
3248 std::env::set_var(
3249 "CLAWD_WEB_SEARCH_BASE_URL",
3250 format!("http://{}/search", server.addr()),
3251 );
3252 let result = execute_tool(
3253 "WebSearch",
3254 &json!({
3255 "query": "rust web search",
3256 "allowed_domains": ["https://DOCS.rs/"],
3257 "blocked_domains": ["HTTPS://EXAMPLE.COM"]
3258 }),
3259 )
3260 .expect("WebSearch should succeed");
3261 std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3262
3263 let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3264 assert_eq!(output["query"], "rust web search");
3265 let results = output["results"].as_array().expect("results array");
3266 let search_result = results
3267 .iter()
3268 .find(|item| item.get("content").is_some())
3269 .expect("search result block present");
3270 let content = search_result["content"].as_array().expect("content array");
3271 assert_eq!(content.len(), 1);
3272 assert_eq!(content[0]["title"], "Reqwest docs");
3273 assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
3274 }
3275
3276 #[test]
3277 fn web_search_handles_generic_links_and_invalid_base_url() {
3278 let _guard = env_lock()
3279 .lock()
3280 .unwrap_or_else(std::sync::PoisonError::into_inner);
3281 let server = TestServer::spawn(Arc::new(|request_line: &str| {
3282 assert!(request_line.contains("GET /fallback?q=generic+links "));
3283 HttpResponse::html(
3284 200,
3285 "OK",
3286 r#"
3287 <html><body>
3288 <a href="https://example.com/one">Example One</a>
3289 <a href="https://example.com/one">Duplicate Example One</a>
3290 <a href="https://docs.rs/tokio">Tokio Docs</a>
3291 </body></html>
3292 "#,
3293 )
3294 }));
3295
3296 std::env::set_var(
3297 "CLAWD_WEB_SEARCH_BASE_URL",
3298 format!("http://{}/fallback", server.addr()),
3299 );
3300 let result = execute_tool(
3301 "WebSearch",
3302 &json!({
3303 "query": "generic links"
3304 }),
3305 )
3306 .expect("WebSearch fallback parsing should succeed");
3307 std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3308
3309 let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3310 let results = output["results"].as_array().expect("results array");
3311 let search_result = results
3312 .iter()
3313 .find(|item| item.get("content").is_some())
3314 .expect("search result block present");
3315 let content = search_result["content"].as_array().expect("content array");
3316 assert_eq!(content.len(), 2);
3317 assert_eq!(content[0]["url"], "https://example.com/one");
3318 assert_eq!(content[1]["url"], "https://docs.rs/tokio");
3319
3320 std::env::set_var("CLAWD_WEB_SEARCH_BASE_URL", "://bad-base-url");
3321 let error = execute_tool("WebSearch", &json!({ "query": "generic links" }))
3322 .expect_err("invalid base URL should fail");
3323 std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3324 assert!(error.contains("relative URL without a base") || error.contains("empty host"));
3325 }
3326
3327 #[test]
3328 fn todo_write_persists_and_returns_previous_state() {
3329 let _guard = env_lock()
3330 .lock()
3331 .unwrap_or_else(std::sync::PoisonError::into_inner);
3332 let path = temp_path("todos.json");
3333 std::env::set_var("CLAWD_TODO_STORE", &path);
3334
3335 let first = execute_tool(
3336 "TodoWrite",
3337 &json!({
3338 "todos": [
3339 {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
3340 {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
3341 ]
3342 }),
3343 )
3344 .expect("TodoWrite should succeed");
3345 let first_output: serde_json::Value = serde_json::from_str(&first.output).expect("valid json");
3346 assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
3347
3348 let second = execute_tool(
3349 "TodoWrite",
3350 &json!({
3351 "todos": [
3352 {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
3353 {"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
3354 {"content": "Verify", "activeForm": "Verifying", "status": "completed"}
3355 ]
3356 }),
3357 )
3358 .expect("TodoWrite should succeed");
3359 std::env::remove_var("CLAWD_TODO_STORE");
3360 let _ = std::fs::remove_file(path);
3361
3362 let second_output: serde_json::Value = serde_json::from_str(&second.output).expect("valid json");
3363 assert_eq!(
3364 second_output["oldTodos"].as_array().expect("array").len(),
3365 2
3366 );
3367 assert_eq!(
3368 second_output["newTodos"].as_array().expect("array").len(),
3369 3
3370 );
3371 assert!(second_output["verificationNudgeNeeded"].is_null());
3372 }
3373
3374 #[test]
3375 fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
3376 let _guard = env_lock()
3377 .lock()
3378 .unwrap_or_else(std::sync::PoisonError::into_inner);
3379 let path = temp_path("todos-errors.json");
3380 std::env::set_var("CLAWD_TODO_STORE", &path);
3381
3382 let empty = execute_tool("TodoWrite", &json!({ "todos": [] }))
3383 .expect_err("empty todos should fail");
3384 assert!(empty.contains("todos must not be empty"));
3385
3386 let _multi_active = execute_tool(
3388 "TodoWrite",
3389 &json!({
3390 "todos": [
3391 {"content": "One", "activeForm": "Doing one", "status": "in_progress"},
3392 {"content": "Two", "activeForm": "Doing two", "status": "in_progress"}
3393 ]
3394 }),
3395 )
3396 .expect("multiple in-progress todos should succeed");
3397
3398 let blank_content = execute_tool(
3399 "TodoWrite",
3400 &json!({
3401 "todos": [
3402 {"content": " ", "activeForm": "Doing it", "status": "pending"}
3403 ]
3404 }),
3405 )
3406 .expect_err("blank content should fail");
3407 assert!(blank_content.contains("todo content must not be empty"));
3408
3409 let nudge = execute_tool(
3410 "TodoWrite",
3411 &json!({
3412 "todos": [
3413 {"content": "Write tests", "activeForm": "Writing tests", "status": "completed"},
3414 {"content": "Fix errors", "activeForm": "Fixing errors", "status": "completed"},
3415 {"content": "Ship branch", "activeForm": "Shipping branch", "status": "completed"}
3416 ]
3417 }),
3418 )
3419 .expect("completed todos should succeed");
3420 std::env::remove_var("CLAWD_TODO_STORE");
3421 let _ = fs::remove_file(path);
3422
3423 let output: serde_json::Value = serde_json::from_str(&nudge.output).expect("valid json");
3424 assert_eq!(output["verificationNudgeNeeded"], true);
3425 }
3426
3427 #[test]
3428 fn skill_loads_local_skill_prompt() {
3429 let result = execute_tool(
3430 "Skill",
3431 &json!({
3432 "skill": "help",
3433 "args": "overview"
3434 }),
3435 )
3436 .expect("Skill should succeed");
3437
3438 let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3439 assert_eq!(output["skill"], "help");
3440 assert!(output["path"]
3441 .as_str()
3442 .expect("path")
3443 .ends_with("/help/SKILL.md"));
3444 assert!(output["prompt"]
3445 .as_str()
3446 .expect("prompt")
3447 .contains("Guide on using oh-my-codex plugin"));
3448
3449 let dollar_result = execute_tool(
3450 "Skill",
3451 &json!({
3452 "skill": "$help"
3453 }),
3454 )
3455 .expect("Skill should accept $skill invocation form");
3456 let dollar_output: serde_json::Value =
3457 serde_json::from_str(&dollar_result.output).expect("valid json");
3458 assert_eq!(dollar_output["skill"], "$help");
3459 assert!(dollar_output["path"]
3460 .as_str()
3461 .expect("path")
3462 .ends_with("/help/SKILL.md"));
3463 }
3464
3465 #[test]
3466 fn tool_search_supports_keyword_and_select_queries() {
3467 let keyword = execute_tool(
3468 "ToolSearch",
3469 &json!({"query": "web current", "max_results": 3}),
3470 )
3471 .expect("ToolSearch should succeed");
3472 let keyword_output: serde_json::Value = serde_json::from_str(&keyword.output).expect("valid json");
3473 let matches = keyword_output["matches"].as_array().expect("matches");
3474 assert!(matches.iter().any(|value| value == "WebSearch"));
3475
3476 let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"}))
3477 .expect("ToolSearch should succeed");
3478 let selected_output: serde_json::Value =
3479 serde_json::from_str(&selected.output).expect("valid json");
3480 assert_eq!(selected_output["matches"][0], "Agent");
3481 assert_eq!(selected_output["matches"][1], "Skill");
3482
3483 let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
3484 .expect("ToolSearch should support tool aliases");
3485 let aliased_output: serde_json::Value = serde_json::from_str(&aliased.output).expect("valid json");
3486 assert_eq!(aliased_output["matches"][0], "Agent");
3487 assert_eq!(aliased_output["normalized_query"], "agent");
3488
3489 let selected_with_alias =
3490 execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
3491 .expect("ToolSearch alias select should succeed");
3492 let selected_with_alias_output: serde_json::Value =
3493 serde_json::from_str(&selected_with_alias.output).expect("valid json");
3494 assert_eq!(selected_with_alias_output["matches"][0], "Agent");
3495 assert_eq!(selected_with_alias_output["matches"][1], "Skill");
3496 }
3497
3498 #[test]
3499 fn agent_persists_handoff_metadata() {
3500 let _guard = env_lock()
3501 .lock()
3502 .unwrap_or_else(std::sync::PoisonError::into_inner);
3503 let dir = temp_path("agent-store");
3504 std::env::set_var("CLAWD_AGENT_STORE", &dir);
3505 let captured = Arc::new(Mutex::new(None::<AgentJob>));
3506 let captured_for_spawn = Arc::clone(&captured);
3507
3508 let manifest = execute_agent_with_spawn(
3509 AgentInput {
3510 description: "Audit the branch".to_string(),
3511 prompt: "Check tests and outstanding work.".to_string(),
3512 subagent_type: Some("Explore".to_string()),
3513 name: Some("ship-audit".to_string()),
3514 model: None,
3515 },
3516 move |job| {
3517 *captured_for_spawn
3518 .lock()
3519 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job);
3520 Ok(())
3521 },
3522 )
3523 .expect("Agent should succeed");
3524 std::env::remove_var("CLAWD_AGENT_STORE");
3525
3526 assert_eq!(manifest.name, "ship-audit");
3527 assert_eq!(manifest.subagent_type.as_deref(), Some("Explore"));
3528 assert_eq!(manifest.status, "running");
3529 assert!(!manifest.created_at.is_empty());
3530 assert!(manifest.started_at.is_some());
3531 assert!(manifest.completed_at.is_none());
3532 let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists");
3533 let manifest_contents =
3534 std::fs::read_to_string(&manifest.manifest_file).expect("manifest file exists");
3535 assert!(contents.contains("Audit the branch"));
3536 assert!(contents.contains("Check tests and outstanding work."));
3537 assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
3538 assert!(manifest_contents.contains("\"status\": \"running\""));
3539 let captured_job = captured
3540 .lock()
3541 .unwrap_or_else(std::sync::PoisonError::into_inner)
3542 .clone()
3543 .expect("spawn job should be captured");
3544 assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
3545 assert!(captured_job.allowed_tools.contains("read_file"));
3546 assert!(!captured_job.allowed_tools.contains("Agent"));
3547
3548 let normalized = execute_tool(
3549 "Agent",
3550 &json!({
3551 "description": "Verify the branch",
3552 "prompt": "Check tests.",
3553 "subagent_type": "explorer"
3554 }),
3555 )
3556 .expect("Agent should normalize built-in aliases");
3557 let normalized_output: serde_json::Value =
3558 serde_json::from_str(&normalized.output).expect("valid json");
3559 assert_eq!(normalized_output["subagentType"], "Explore");
3560
3561 let named = execute_tool(
3562 "Agent",
3563 &json!({
3564 "description": "Review the branch",
3565 "prompt": "Inspect diff.",
3566 "name": "Ship Audit!!!"
3567 }),
3568 )
3569 .expect("Agent should normalize explicit names");
3570 let named_output: serde_json::Value = serde_json::from_str(&named.output).expect("valid json");
3571 assert_eq!(named_output["name"], "ship-audit");
3572 let _ = std::fs::remove_dir_all(dir);
3573 }
3574
3575 #[test]
3576 fn agent_fake_runner_can_persist_completion_and_failure() {
3577 let _guard = env_lock()
3578 .lock()
3579 .unwrap_or_else(std::sync::PoisonError::into_inner);
3580 let dir = temp_path("agent-runner");
3581 std::env::set_var("CLAWD_AGENT_STORE", &dir);
3582
3583 let completed = execute_agent_with_spawn(
3584 AgentInput {
3585 description: "Complete the task".to_string(),
3586 prompt: "Do the work".to_string(),
3587 subagent_type: Some("Explore".to_string()),
3588 name: Some("complete-task".to_string()),
3589 model: Some("ternlang-sonnet-4-6".to_string()),
3590 },
3591 |job| {
3592 persist_agent_terminal_state(
3593 &job.manifest,
3594 "completed",
3595 Some("Finished successfully"),
3596 None,
3597 )
3598 },
3599 )
3600 .expect("completed agent should succeed");
3601
3602 let completed_manifest = std::fs::read_to_string(&completed.manifest_file)
3603 .expect("completed manifest should exist");
3604 let completed_output =
3605 std::fs::read_to_string(&completed.output_file).expect("completed output should exist");
3606 assert!(completed_manifest.contains("\"status\": \"completed\""));
3607 assert!(completed_output.contains("Finished successfully"));
3608
3609 let failed = execute_agent_with_spawn(
3610 AgentInput {
3611 description: "Fail the task".to_string(),
3612 prompt: "Do the failing work".to_string(),
3613 subagent_type: Some("Verification".to_string()),
3614 name: Some("fail-task".to_string()),
3615 model: None,
3616 },
3617 |job| {
3618 persist_agent_terminal_state(
3619 &job.manifest,
3620 "failed",
3621 None,
3622 Some(String::from("simulated failure")),
3623 )
3624 },
3625 )
3626 .expect("failed agent should still spawn");
3627
3628 let failed_manifest =
3629 std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist");
3630 let failed_output =
3631 std::fs::read_to_string(&failed.output_file).expect("failed output should exist");
3632 assert!(failed_manifest.contains("\"status\": \"failed\""));
3633 assert!(failed_manifest.contains("simulated failure"));
3634 assert!(failed_output.contains("simulated failure"));
3635
3636 let spawn_error = execute_agent_with_spawn(
3637 AgentInput {
3638 description: "Spawn error task".to_string(),
3639 prompt: "Never starts".to_string(),
3640 subagent_type: None,
3641 name: Some("spawn-error".to_string()),
3642 model: None,
3643 },
3644 |_| Err(String::from("thread creation failed")),
3645 )
3646 .expect_err("spawn errors should surface");
3647 assert!(spawn_error.contains("failed to spawn sub-agent"));
3648 let spawn_error_manifest = std::fs::read_dir(&dir)
3649 .expect("agent dir should exist")
3650 .filter_map(Result::ok)
3651 .map(|entry| entry.path())
3652 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
3653 .find_map(|path| {
3654 let contents = std::fs::read_to_string(&path).ok()?;
3655 contents
3656 .contains("\"name\": \"spawn-error\"")
3657 .then_some(contents)
3658 })
3659 .expect("failed manifest should still be written");
3660 assert!(spawn_error_manifest.contains("\"status\": \"failed\""));
3661 assert!(spawn_error_manifest.contains("thread creation failed"));
3662
3663 std::env::remove_var("CLAWD_AGENT_STORE");
3664 let _ = std::fs::remove_dir_all(dir);
3665 }
3666
3667 #[test]
3668 fn agent_tool_subset_mapping_is_expected() {
3669 let general = allowed_tools_for_subagent("general-purpose");
3670 assert!(general.contains("bash"));
3671 assert!(general.contains("write_file"));
3672 assert!(!general.contains("Agent"));
3673
3674 let explore = allowed_tools_for_subagent("Explore");
3675 assert!(explore.contains("read_file"));
3676 assert!(explore.contains("grep_search"));
3677 assert!(!explore.contains("bash"));
3678
3679 let plan = allowed_tools_for_subagent("Plan");
3680 assert!(plan.contains("TodoWrite"));
3681 assert!(plan.contains("StructuredOutput"));
3682 assert!(!plan.contains("Agent"));
3683
3684 let verification = allowed_tools_for_subagent("Verification");
3685 assert!(verification.contains("bash"));
3686 assert!(verification.contains("PowerShell"));
3687 assert!(!verification.contains("write_file"));
3688 }
3689
3690 #[derive(Debug)]
3691 struct MockSubagentApiClient {
3692 calls: usize,
3693 input_path: String,
3694 }
3695
3696 impl runtime::ApiClient for MockSubagentApiClient {
3697 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
3698 self.calls += 1;
3699 match self.calls {
3700 1 => {
3701 assert_eq!(request.messages.len(), 1);
3702 Ok(vec![
3703 AssistantEvent::ToolUse {
3704 id: "tool-1".to_string(),
3705 name: "read_file".to_string(),
3706 input: json!({ "path": self.input_path }).to_string(),
3707 },
3708 AssistantEvent::MessageStop,
3709 ])
3710 }
3711 2 => {
3712 assert!(request.messages.len() >= 3);
3713 Ok(vec![
3714 AssistantEvent::TextDelta("Scope: completed mock review".to_string()),
3715 AssistantEvent::MessageStop,
3716 ])
3717 }
3718 _ => panic!("unexpected mock stream call"),
3719 }
3720 }
3721 }
3722
3723 #[test]
3724 fn subagent_runtime_executes_tool_loop_with_isolated_session() {
3725 let _guard = env_lock()
3726 .lock()
3727 .unwrap_or_else(std::sync::PoisonError::into_inner);
3728 let path = temp_path("subagent-input.txt");
3729 std::fs::write(&path, "hello from child").expect("write input file");
3730
3731 let mut runtime = ConversationRuntime::new(
3732 Session::new(),
3733 MockSubagentApiClient {
3734 calls: 0,
3735 input_path: path.display().to_string(),
3736 },
3737 SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
3738 agent_permission_policy(),
3739 vec![String::from("system prompt")],
3740 );
3741
3742 let summary = runtime
3743 .run_turn("Inspect the delegated file", None)
3744 .expect("subagent loop should succeed");
3745
3746 assert_eq!(
3747 final_assistant_text(&summary),
3748 "Scope: completed mock review"
3749 );
3750 assert!(runtime
3751 .session()
3752 .messages
3753 .iter()
3754 .flat_map(|message| message.blocks.iter())
3755 .any(|block| matches!(
3756 block,
3757 runtime::ContentBlock::ToolResult { output, .. }
3758 if output.contains("hello from child")
3759 )));
3760
3761 let _ = std::fs::remove_file(path);
3762 }
3763
3764 #[test]
3765 fn agent_rejects_blank_required_fields() {
3766 let missing_description = execute_tool(
3767 "Agent",
3768 &json!({
3769 "description": " ",
3770 "prompt": "Inspect"
3771 }),
3772 )
3773 .expect_err("blank description should fail");
3774 assert!(missing_description.contains("description must not be empty"));
3775
3776 let missing_prompt = execute_tool(
3777 "Agent",
3778 &json!({
3779 "description": "Inspect branch",
3780 "prompt": " "
3781 }),
3782 )
3783 .expect_err("blank prompt should fail");
3784 assert!(missing_prompt.contains("prompt must not be empty"));
3785 }
3786
3787 #[test]
3788 fn notebook_edit_replaces_inserts_and_deletes_cells() {
3789 let path = temp_path("notebook.ipynb");
3790 std::fs::write(
3791 &path,
3792 r#"{
3793 "cells": [
3794 {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null}
3795 ],
3796 "metadata": {"kernelspec": {"language": "python"}},
3797 "nbformat": 4,
3798 "nbformat_minor": 5
3799}"#,
3800 )
3801 .expect("write notebook");
3802
3803 let replaced = execute_tool(
3804 "NotebookEdit",
3805 &json!({
3806 "notebook_path": path.display().to_string(),
3807 "cell_id": "cell-a",
3808 "new_source": "print(2)\n",
3809 "edit_mode": "replace"
3810 }),
3811 )
3812 .expect("NotebookEdit replace should succeed");
3813 let replaced_output: serde_json::Value = serde_json::from_str(&replaced.output).expect("json");
3814 assert_eq!(replaced_output["cell_id"], "cell-a");
3815 assert_eq!(replaced_output["cell_type"], "code");
3816
3817 let inserted = execute_tool(
3818 "NotebookEdit",
3819 &json!({
3820 "notebook_path": path.display().to_string(),
3821 "cell_id": "cell-a",
3822 "new_source": "# heading\n",
3823 "cell_type": "markdown",
3824 "edit_mode": "insert"
3825 }),
3826 )
3827 .expect("NotebookEdit insert should succeed");
3828 let inserted_output: serde_json::Value = serde_json::from_str(&inserted.output).expect("json");
3829 assert_eq!(inserted_output["cell_type"], "markdown");
3830 let appended = execute_tool(
3831 "NotebookEdit",
3832 &json!({
3833 "notebook_path": path.display().to_string(),
3834 "new_source": "print(3)\n",
3835 "edit_mode": "insert"
3836 }),
3837 )
3838 .expect("NotebookEdit append should succeed");
3839 let appended_output: serde_json::Value = serde_json::from_str(&appended.output).expect("json");
3840 assert_eq!(appended_output["cell_type"], "code");
3841
3842 let deleted = execute_tool(
3843 "NotebookEdit",
3844 &json!({
3845 "notebook_path": path.display().to_string(),
3846 "cell_id": "cell-a",
3847 "edit_mode": "delete"
3848 }),
3849 )
3850 .expect("NotebookEdit delete should succeed without new_source");
3851 let deleted_output: serde_json::Value = serde_json::from_str(&deleted.output).expect("json");
3852 assert!(deleted_output["cell_type"].is_null());
3853 assert_eq!(deleted_output["new_source"], "");
3854
3855 let final_notebook: serde_json::Value =
3856 serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
3857 .expect("valid notebook json");
3858 let cells = final_notebook["cells"].as_array().expect("cells array");
3859 assert_eq!(cells.len(), 2);
3860 assert_eq!(cells[0]["cell_type"], "markdown");
3861 assert!(cells[0].get("outputs").is_none());
3862 assert_eq!(cells[1]["cell_type"], "code");
3863 assert_eq!(cells[1]["source"][0], "print(3)\n");
3864 let _ = std::fs::remove_file(path);
3865 }
3866
3867 #[test]
3868 fn notebook_edit_rejects_invalid_inputs() {
3869 let text_path = temp_path("notebook.txt");
3870 fs::write(&text_path, "not a notebook").expect("write text file");
3871 let wrong_extension = execute_tool(
3872 "NotebookEdit",
3873 &json!({
3874 "notebook_path": text_path.display().to_string(),
3875 "new_source": "print(1)\n"
3876 }),
3877 )
3878 .expect_err("non-ipynb file should fail");
3879 assert!(wrong_extension.contains("Jupyter notebook"));
3880 let _ = fs::remove_file(&text_path);
3881
3882 let empty_notebook = temp_path("empty.ipynb");
3883 fs::write(
3884 &empty_notebook,
3885 r#"{"cells":[],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}"#,
3886 )
3887 .expect("write empty notebook");
3888
3889 let missing_source = execute_tool(
3890 "NotebookEdit",
3891 &json!({
3892 "notebook_path": empty_notebook.display().to_string(),
3893 "edit_mode": "insert"
3894 }),
3895 )
3896 .expect_err("insert without source should fail");
3897 assert!(missing_source.contains("new_source is required"));
3898
3899 let missing_cell = execute_tool(
3900 "NotebookEdit",
3901 &json!({
3902 "notebook_path": empty_notebook.display().to_string(),
3903 "edit_mode": "delete"
3904 }),
3905 )
3906 .expect_err("delete on empty notebook should fail");
3907 assert!(missing_cell.contains("Notebook has no cells to edit"));
3908 let _ = fs::remove_file(empty_notebook);
3909 }
3910
3911 #[test]
3912 fn bash_tool_reports_success_exit_failure_timeout_and_background() {
3913 let success = execute_tool("bash", &json!({ "command": "printf 'hello'" }))
3914 .expect("bash should succeed");
3915 let success_output: serde_json::Value = serde_json::from_str(&success.output).expect("json");
3916 assert_eq!(success_output["stdout"], "hello");
3917 assert_eq!(success_output["interrupted"], false);
3918
3919 let failure = execute_tool("bash", &json!({ "command": "printf 'oops' >&2; exit 7" }))
3920 .expect("bash failure should still return structured output");
3921 let failure_output: serde_json::Value = serde_json::from_str(&failure.output).expect("json");
3922 assert_eq!(failure_output["returnCodeInterpretation"], "exit_code:7");
3923 assert!(failure_output["stderr"]
3924 .as_str()
3925 .expect("stderr")
3926 .contains("oops"));
3927
3928 let timeout = execute_tool("bash", &json!({ "command": "sleep 1", "timeout": 10 }))
3929 .expect("bash timeout should return output");
3930 let timeout_output: serde_json::Value = serde_json::from_str(&timeout.output).expect("json");
3931 assert_eq!(timeout_output["interrupted"], true);
3932 assert_eq!(timeout_output["returnCodeInterpretation"], "timeout");
3933 assert!(timeout_output["stderr"]
3934 .as_str()
3935 .expect("stderr")
3936 .contains("Command exceeded timeout"));
3937
3938 let background = execute_tool(
3939 "bash",
3940 &json!({ "command": "sleep 1", "run_in_background": true }),
3941 )
3942 .expect("bash background should succeed");
3943 let background_output: serde_json::Value = serde_json::from_str(&background.output).expect("json");
3944 assert!(background_output["backgroundTaskId"].as_str().is_some());
3945 assert_eq!(background_output["noOutputExpected"], true);
3946 }
3947
3948 #[test]
3949 fn file_tools_cover_read_write_and_edit_behaviors() {
3950 let _guard = env_lock()
3951 .lock()
3952 .unwrap_or_else(std::sync::PoisonError::into_inner);
3953 let root = temp_path("fs-suite");
3954 fs::create_dir_all(&root).expect("create root");
3955 let original_dir = std::env::current_dir().expect("cwd");
3956 std::env::set_current_dir(&root).expect("set cwd");
3957
3958 let write_create = execute_tool(
3959 "write_file",
3960 &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
3961 )
3962 .expect("write create should succeed");
3963 let write_create_output: serde_json::Value =
3964 serde_json::from_str(&write_create.output).expect("json");
3965 assert_eq!(write_create_output["type"], "create");
3966 assert!(root.join("nested/demo.txt").exists());
3967
3968 let write_update = execute_tool(
3969 "write_file",
3970 &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\ngamma\n" }),
3971 )
3972 .expect("write update should succeed");
3973 let write_update_output: serde_json::Value =
3974 serde_json::from_str(&write_update.output).expect("json");
3975 assert_eq!(write_update_output["type"], "update");
3976 assert_eq!(write_update_output["originalFile"], "alpha\nbeta\nalpha\n");
3977
3978 let read_full = execute_tool("read_file", &json!({ "path": "nested/demo.txt" }))
3979 .expect("read full should succeed");
3980 let read_full_output: serde_json::Value = serde_json::from_str(&read_full.output).expect("json");
3981 assert_eq!(read_full_output["file"]["content"], "alpha\nbeta\ngamma");
3982 assert_eq!(read_full_output["file"]["startLine"], 1);
3983
3984 let read_slice = execute_tool(
3985 "read_file",
3986 &json!({ "path": "nested/demo.txt", "offset": 1, "limit": 1 }),
3987 )
3988 .expect("read slice should succeed");
3989 let read_slice_output: serde_json::Value = serde_json::from_str(&read_slice.output).expect("json");
3990 assert_eq!(read_slice_output["file"]["content"], "beta");
3991 assert_eq!(read_slice_output["file"]["startLine"], 2);
3992
3993 let read_past_end = execute_tool(
3994 "read_file",
3995 &json!({ "path": "nested/demo.txt", "offset": 50 }),
3996 )
3997 .expect("read past EOF should succeed");
3998 let read_past_end_output: serde_json::Value =
3999 serde_json::from_str(&read_past_end.output).expect("json");
4000 assert_eq!(read_past_end_output["file"]["content"], "");
4001 assert_eq!(read_past_end_output["file"]["startLine"], 4);
4002
4003 let read_error = execute_tool("read_file", &json!({ "path": "missing.txt" }))
4004 .expect_err("missing file should fail");
4005 assert!(!read_error.is_empty());
4006
4007 let edit_once = execute_tool(
4008 "edit_file",
4009 &json!({ "path": "nested/demo.txt", "old_string": "alpha", "new_string": "omega" }),
4010 )
4011 .expect("single edit should succeed");
4012 let edit_once_output: serde_json::Value = serde_json::from_str(&edit_once.output).expect("json");
4013 assert_eq!(edit_once_output["replaceAll"], false);
4014 assert_eq!(
4015 fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
4016 "omega\nbeta\ngamma\n"
4017 );
4018
4019 execute_tool(
4020 "write_file",
4021 &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
4022 )
4023 .expect("reset file");
4024 let edit_all = execute_tool(
4025 "edit_file",
4026 &json!({
4027 "path": "nested/demo.txt",
4028 "old_string": "alpha",
4029 "new_string": "omega",
4030 "replace_all": true
4031 }),
4032 )
4033 .expect("replace all should succeed");
4034 let edit_all_output: serde_json::Value = serde_json::from_str(&edit_all.output).expect("json");
4035 assert_eq!(edit_all_output["replaceAll"], true);
4036 assert_eq!(
4037 fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
4038 "omega\nbeta\nomega\n"
4039 );
4040
4041 let edit_same = execute_tool(
4042 "edit_file",
4043 &json!({ "path": "nested/demo.txt", "old_string": "omega", "new_string": "omega" }),
4044 )
4045 .expect_err("identical old/new should fail");
4046 assert!(edit_same.contains("must differ"));
4047
4048 let edit_missing = execute_tool(
4049 "edit_file",
4050 &json!({ "path": "nested/demo.txt", "old_string": "missing", "new_string": "omega" }),
4051 )
4052 .expect_err("missing substring should fail");
4053 assert!(edit_missing.contains("old_string not found"));
4054
4055 std::env::set_current_dir(&original_dir).expect("restore cwd");
4056 let _ = fs::remove_dir_all(root);
4057 }
4058
4059 #[test]
4060 fn glob_and_grep_tools_cover_success_and_errors() {
4061 let _guard = env_lock()
4062 .lock()
4063 .unwrap_or_else(std::sync::PoisonError::into_inner);
4064 let root = temp_path("search-suite");
4065 fs::create_dir_all(root.join("nested")).expect("create root");
4066 let original_dir = std::env::current_dir().expect("cwd");
4067 std::env::set_current_dir(&root).expect("set cwd");
4068
4069 fs::write(
4070 root.join("nested/lib.rs"),
4071 "fn main() {}\nlet alpha = 1;\nlet alpha = 2;\n",
4072 )
4073 .expect("write rust file");
4074 fs::write(root.join("nested/notes.txt"), "alpha\nbeta\n").expect("write txt file");
4075
4076 let globbed = execute_tool("glob_search", &json!({ "pattern": "nested/*.rs" }))
4077 .expect("glob should succeed");
4078 let globbed_output: serde_json::Value = serde_json::from_str(&globbed.output).expect("json");
4079 assert_eq!(globbed_output["numFiles"], 1);
4080 assert!(globbed_output["filenames"][0]
4081 .as_str()
4082 .expect("filename")
4083 .ends_with("nested/lib.rs"));
4084
4085 let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" }))
4086 .expect_err("invalid glob should fail");
4087 assert!(!glob_error.is_empty());
4088
4089 let grep_content = execute_tool(
4090 "grep_search",
4091 &json!({
4092 "pattern": "alpha",
4093 "path": "nested",
4094 "glob": "*.rs",
4095 "output_mode": "content",
4096 "-n": true,
4097 "head_limit": 1,
4098 "offset": 1
4099 }),
4100 )
4101 .expect("grep content should succeed");
4102 let grep_content_output: serde_json::Value =
4103 serde_json::from_str(&grep_content.output).expect("json");
4104 assert_eq!(grep_content_output["numFiles"], 0);
4105 assert!(grep_content_output["appliedLimit"].is_null());
4106 assert_eq!(grep_content_output["appliedOffset"], 1);
4107 assert!(grep_content_output["content"]
4108 .as_str()
4109 .expect("content")
4110 .contains("let alpha = 2;"));
4111
4112 let grep_count = execute_tool(
4113 "grep_search",
4114 &json!({ "pattern": "alpha", "path": "nested", "output_mode": "count" }),
4115 )
4116 .expect("grep count should succeed");
4117 let grep_count_output: serde_json::Value = serde_json::from_str(&grep_count.output).expect("json");
4118 assert_eq!(grep_count_output["numMatches"], 3);
4119
4120 let grep_error = execute_tool(
4121 "grep_search",
4122 &json!({ "pattern": "(alpha", "path": "nested" }),
4123 )
4124 .expect_err("invalid regex should fail");
4125 assert!(!grep_error.is_empty());
4126
4127 std::env::set_current_dir(&original_dir).expect("restore cwd");
4128 let _ = fs::remove_dir_all(root);
4129 }
4130
4131 #[test]
4132 fn sleep_waits_and_reports_duration() {
4133 let started = std::time::Instant::now();
4134 let result =
4135 execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed");
4136 let elapsed = started.elapsed();
4137 let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4138 assert_eq!(output["duration_ms"], 20);
4139 assert!(output["message"]
4140 .as_str()
4141 .expect("message")
4142 .contains("Slept for 20ms"));
4143 assert!(elapsed >= Duration::from_millis(15));
4144 }
4145
4146 #[test]
4147 fn brief_returns_sent_message_and_attachment_metadata() {
4148 let attachment = std::env::temp_dir().join(format!(
4149 "clawd-brief-{}.png",
4150 std::time::SystemTime::now()
4151 .duration_since(std::time::UNIX_EPOCH)
4152 .expect("time")
4153 .as_nanos()
4154 ));
4155 std::fs::write(&attachment, b"png-data").expect("write attachment");
4156
4157 let result = execute_tool(
4158 "SendUserMessage",
4159 &json!({
4160 "message": "hello user",
4161 "attachments": [attachment.display().to_string()],
4162 "status": "normal"
4163 }),
4164 )
4165 .expect("SendUserMessage should succeed");
4166
4167 let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4168 assert_eq!(output["message"], "hello user");
4169 assert!(output["sentAt"].as_str().is_some());
4170 assert_eq!(output["attachments"][0]["isImage"], true);
4171 let _ = std::fs::remove_file(attachment);
4172 }
4173
4174 #[test]
4175 fn config_reads_and_writes_supported_values() {
4176 let _guard = env_lock()
4177 .lock()
4178 .unwrap_or_else(std::sync::PoisonError::into_inner);
4179 let root = std::env::temp_dir().join(format!(
4180 "clawd-config-{}",
4181 std::time::SystemTime::now()
4182 .duration_since(std::time::UNIX_EPOCH)
4183 .expect("time")
4184 .as_nanos()
4185 ));
4186 let home = root.join("home");
4187 let cwd = root.join("cwd");
4188 std::fs::create_dir_all(home.join(".ternlang")).expect("home dir");
4189 std::fs::create_dir_all(cwd.join(".ternlang")).expect("cwd dir");
4190 std::fs::write(
4191 home.join(".ternlang").join("settings.json"),
4192 r#"{"verbose":false}"#,
4193 )
4194 .expect("write global settings");
4195
4196 let original_home = std::env::var("HOME").ok();
4197 let original_ternlang_home = std::env::var("TERNLANG_CONFIG_HOME").ok();
4198 let original_dir = std::env::current_dir().expect("cwd");
4199 std::env::set_var("HOME", &home);
4200 std::env::remove_var("TERNLANG_CONFIG_HOME");
4201 std::env::set_current_dir(&cwd).expect("set cwd");
4202
4203 let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
4204 let get_output: serde_json::Value = serde_json::from_str(&get.output).expect("json");
4205 assert_eq!(get_output["value"], false);
4206
4207 let set = execute_tool(
4208 "Config",
4209 &json!({"setting": "permissions.defaultMode", "value": "plan"}),
4210 )
4211 .expect("set config");
4212 let set_output: serde_json::Value = serde_json::from_str(&set.output).expect("json");
4213 assert_eq!(set_output["operation"], "set");
4214 assert_eq!(set_output["newValue"], "plan");
4215
4216 let invalid = execute_tool(
4217 "Config",
4218 &json!({"setting": "permissions.defaultMode", "value": "bogus"}),
4219 )
4220 .expect_err("invalid config value should error");
4221 assert!(invalid.contains("Invalid value"));
4222
4223 let unknown =
4224 execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result");
4225 let unknown_output: serde_json::Value = serde_json::from_str(&unknown.output).expect("json");
4226 assert_eq!(unknown_output["success"], false);
4227
4228 std::env::set_current_dir(&original_dir).expect("restore cwd");
4229 match original_home {
4230 Some(value) => std::env::set_var("HOME", value),
4231 None => std::env::remove_var("HOME"),
4232 }
4233 match original_ternlang_home {
4234 Some(value) => std::env::set_var("TERNLANG_CONFIG_HOME", value),
4235 None => std::env::remove_var("TERNLANG_CONFIG_HOME"),
4236 }
4237 let _ = std::fs::remove_dir_all(root);
4238 }
4239
4240 #[test]
4241 fn structured_output_echoes_input_payload() {
4242 let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
4243 .expect("StructuredOutput should succeed");
4244 let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4245 assert_eq!(output["data"], "Structured output provided successfully");
4246 assert_eq!(output["structured_output"]["ok"], true);
4247 assert_eq!(output["structured_output"]["items"][1], 2);
4248 }
4249
4250 #[test]
4251 fn repl_executes_python_code() {
4252 let result = execute_tool(
4253 "REPL",
4254 &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
4255 )
4256 .expect("REPL should succeed");
4257 let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4258 assert_eq!(output["language"], "python");
4259 assert_eq!(output["exitCode"], 0);
4260 assert!(output["stdout"].as_str().expect("stdout").contains('2'));
4261 }
4262
4263 #[test]
4264 fn powershell_runs_via_stub_shell() {
4265 let _guard = env_lock()
4266 .lock()
4267 .unwrap_or_else(std::sync::PoisonError::into_inner);
4268 let dir = std::env::temp_dir().join(format!(
4269 "clawd-pwsh-bin-{}",
4270 std::time::SystemTime::now()
4271 .duration_since(std::time::UNIX_EPOCH)
4272 .expect("time")
4273 .as_nanos()
4274 ));
4275 std::fs::create_dir_all(&dir).expect("create dir");
4276 let script = dir.join("pwsh");
4277 std::fs::write(
4278 &script,
4279 r#"#!/bin/sh
4280while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done
4281shift
4282printf 'pwsh:%s' "$1"
4283"#,
4284 )
4285 .expect("write script");
4286 std::process::Command::new("/bin/chmod")
4287 .arg("+x")
4288 .arg(&script)
4289 .status()
4290 .expect("chmod");
4291 let original_path = std::env::var("PATH").unwrap_or_default();
4292 std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
4293
4294 let result = execute_tool(
4295 "PowerShell",
4296 &json!({"command": "Write-Output hello", "timeout": 1000}),
4297 )
4298 .expect("PowerShell should succeed");
4299
4300 let background = execute_tool(
4301 "PowerShell",
4302 &json!({"command": "Write-Output hello", "run_in_background": true}),
4303 )
4304 .expect("PowerShell background should succeed");
4305
4306 std::env::set_var("PATH", original_path);
4307 let _ = std::fs::remove_dir_all(dir);
4308
4309 let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4310 assert_eq!(output["stdout"], "pwsh:Write-Output hello");
4311 assert!(output["stderr"].as_str().expect("stderr").is_empty());
4312
4313 let background_output: serde_json::Value = serde_json::from_str(&background.output).expect("json");
4314 assert!(background_output["backgroundTaskId"].as_str().is_some());
4315 assert_eq!(background_output["backgroundedByUser"], true);
4316 assert_eq!(background_output["assistantAutoBackgrounded"], false);
4317 }
4318
4319 #[test]
4320 fn powershell_errors_when_shell_is_missing() {
4321 let _guard = env_lock()
4322 .lock()
4323 .unwrap_or_else(std::sync::PoisonError::into_inner);
4324 let original_path = std::env::var("PATH").unwrap_or_default();
4325 let empty_dir = std::env::temp_dir().join(format!(
4326 "clawd-empty-bin-{}",
4327 std::time::SystemTime::now()
4328 .duration_since(std::time::UNIX_EPOCH)
4329 .expect("time")
4330 .as_nanos()
4331 ));
4332 std::fs::create_dir_all(&empty_dir).expect("create empty dir");
4333 std::env::set_var("PATH", empty_dir.display().to_string());
4334
4335 let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"}))
4336 .expect_err("PowerShell should fail when shell is missing");
4337
4338 std::env::set_var("PATH", original_path);
4339 let _ = std::fs::remove_dir_all(empty_dir);
4340
4341 assert!(err.contains("PowerShell executable not found"));
4342 }
4343
4344 struct TestServer {
4345 addr: SocketAddr,
4346 shutdown: Option<std::sync::mpsc::Sender<()>>,
4347 handle: Option<thread::JoinHandle<()>>,
4348 }
4349
4350 impl TestServer {
4351 fn spawn(handler: Arc<dyn Fn(&str) -> HttpResponse + Send + Sync + 'static>) -> Self {
4352 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4353 listener
4354 .set_nonblocking(true)
4355 .expect("set nonblocking listener");
4356 let addr = listener.local_addr().expect("local addr");
4357 let (tx, rx) = std::sync::mpsc::channel::<()>();
4358
4359 let handle = thread::spawn(move || loop {
4360 if rx.try_recv().is_ok() {
4361 break;
4362 }
4363
4364 match listener.accept() {
4365 Ok((mut stream, _)) => {
4366 let mut buffer = [0_u8; 4096];
4367 let size = stream.read(&mut buffer).expect("read request");
4368 let request = String::from_utf8_lossy(&buffer[..size]).into_owned();
4369 let request_line = request.lines().next().unwrap_or_default().to_string();
4370 let response = handler(&request_line);
4371 stream
4372 .write_all(response.to_bytes().as_slice())
4373 .expect("write response");
4374 }
4375 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
4376 thread::sleep(Duration::from_millis(10));
4377 }
4378 Err(error) => panic!("server accept failed: {error}"),
4379 }
4380 });
4381
4382 Self {
4383 addr,
4384 shutdown: Some(tx),
4385 handle: Some(handle),
4386 }
4387 }
4388
4389 fn addr(&self) -> SocketAddr {
4390 self.addr
4391 }
4392 }
4393
4394 impl Drop for TestServer {
4395 fn drop(&mut self) {
4396 if let Some(tx) = self.shutdown.take() {
4397 let _ = tx.send(());
4398 }
4399 if let Some(handle) = self.handle.take() {
4400 handle.join().expect("join test server");
4401 }
4402 }
4403 }
4404
4405 struct HttpResponse {
4406 status: u16,
4407 reason: &'static str,
4408 content_type: &'static str,
4409 body: String,
4410 }
4411
4412 impl HttpResponse {
4413 fn html(status: u16, reason: &'static str, body: &str) -> Self {
4414 Self {
4415 status,
4416 reason,
4417 content_type: "text/html; charset=utf-8",
4418 body: body.to_string(),
4419 }
4420 }
4421
4422 fn text(status: u16, reason: &'static str, body: &str) -> Self {
4423 Self {
4424 status,
4425 reason,
4426 content_type: "text/plain; charset=utf-8",
4427 body: body.to_string(),
4428 }
4429 }
4430
4431 fn to_bytes(&self) -> Vec<u8> {
4432 format!(
4433 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4434 self.status,
4435 self.reason,
4436 self.content_type,
4437 self.body.len(),
4438 self.body
4439 )
4440 .into_bytes()
4441 }
4442 }
4443}
4444
4445fn run_sequential_thinking(input: SequentialThinkingInput) -> Result<String, String> {
4446 to_pretty_json(input)
4447}
4448
4449fn run_memory(input: MemoryInput) -> Result<String, String> {
4450 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4451 let memory_dir = std::path::PathBuf::from(home).join(".ternlang/memory");
4452 std::fs::create_dir_all(&memory_dir).map_err(|e| e.to_string())?;
4453 let memory_file = memory_dir.join("knowledge_graph.json");
4454
4455 let mut graph: KnowledgeGraph = if memory_file.exists() {
4456 let content = std::fs::read_to_string(&memory_file).map_err(|e| e.to_string())?;
4457 serde_json::from_str(&content).unwrap_or_default()
4458 } else {
4459 KnowledgeGraph::default()
4460 };
4461
4462 let result = match input.action.as_str() {
4463 "create_entities" => {
4464 if let Some(entities) = input.entities {
4465 for entity in entities {
4466 graph.entities.insert(entity.name.clone(), entity);
4467 }
4468 "Entities created successfully.".to_string()
4469 } else {
4470 "No entities provided.".to_string()
4471 }
4472 }
4473 "create_relations" => {
4474 if let Some(relations) = input.relations {
4475 for relation in relations {
4476 graph.relations.push(relation);
4477 }
4478 "Relations created successfully.".to_string()
4479 } else {
4480 "No relations provided.".to_string()
4481 }
4482 }
4483 "add_observations" => {
4484 if let Some(observations) = input.observations {
4485 for obs in observations {
4486 graph.observations.entry(obs.entity_name).or_default().extend(obs.contents);
4487 }
4488 "Observations added successfully.".to_string()
4489 } else {
4490 "No observations provided.".to_string()
4491 }
4492 }
4493 "search_nodes" => {
4494 let query = input.query.unwrap_or_default().to_lowercase();
4495 let matches: Vec<_> = graph.entities.values()
4496 .filter(|e| e.name.to_lowercase().contains(&query) || e.description.to_lowercase().contains(&query))
4497 .cloned()
4498 .collect();
4499 return to_pretty_json(matches);
4500 }
4501 _ => return Err(format!("Unknown memory action: {}", input.action)),
4502 };
4503
4504 let content = serde_json::to_string_pretty(&graph).map_err(|e| e.to_string())?;
4505 std::fs::write(&memory_file, content).map_err(|e| e.to_string())?;
4506 to_pretty_json(result)
4507}
4508
4509fn run_repo_map(input: RepoMapInput) -> Result<String, String> {
4510 use walkdir::WalkDir;
4511 let root = input.path.unwrap_or_else(|| ".".to_string());
4512 let max_depth = input.depth.unwrap_or(2);
4513 let mut tree = String::new();
4514
4515 for entry in WalkDir::new(&root)
4516 .max_depth(max_depth)
4517 .into_iter()
4518 .filter_entry(|e| {
4519 let name = e.file_name().to_string_lossy();
4520 name != ".git" && name != "node_modules" && name != "target" && name != "__pycache__"
4521 })
4522 {
4523 let entry = entry.map_err(|e| e.to_string())?;
4524 let depth = entry.depth();
4525 let name = entry.file_name().to_string_lossy();
4526 let indent = " ".repeat(depth);
4527 if entry.file_type().is_dir() {
4528 tree.push_str(&format!("{indent}[DIR] {name}\n"));
4529 } else {
4530 tree.push_str(&format!("{indent}{name}\n"));
4531 }
4532 }
4533
4534 to_pretty_json(json!({ "tree": tree }))
4535}
4536
4537#[derive(Debug, Deserialize, Serialize)]
4538struct SequentialThinkingInput {
4539 thought: String,
4540 #[serde(rename = "thoughtNumber")]
4541 thought_number: i32,
4542 #[serde(rename = "totalThoughts")]
4543 total_thoughts: i32,
4544 #[serde(rename = "nextThoughtNeeded")]
4545 next_thought_needed: bool,
4546 #[serde(rename = "isRevision")]
4547 is_revision: Option<bool>,
4548 #[serde(rename = "revisesThoughtNumber")]
4549 revises_thought_number: Option<i32>,
4550}
4551
4552#[derive(Debug, Deserialize)]
4553struct MemoryInput {
4554 action: String,
4555 entities: Option<Vec<MemoryEntity>>,
4556 relations: Option<Vec<MemoryRelation>>,
4557 observations: Option<Vec<MemoryObservation>>,
4558 query: Option<String>,
4559}
4560
4561#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4562struct KnowledgeGraph {
4563 entities: BTreeMap<String, MemoryEntity>,
4564 relations: Vec<MemoryRelation>,
4565 observations: BTreeMap<String, Vec<String>>,
4566}
4567
4568#[derive(Debug, Clone, Serialize, Deserialize)]
4569struct MemoryEntity {
4570 name: String,
4571 #[serde(rename = "type")]
4572 entity_type: String,
4573 description: String,
4574}
4575
4576#[derive(Debug, Clone, Serialize, Deserialize)]
4577struct MemoryRelation {
4578 from: String,
4579 to: String,
4580 #[serde(rename = "type")]
4581 relation_type: String,
4582}
4583
4584#[derive(Debug, Deserialize)]
4585struct MemoryObservation {
4586 #[serde(rename = "entityName")]
4587 entity_name: String,
4588 contents: Vec<String>,
4589}
4590
4591#[derive(Debug, Deserialize)]
4592struct RepoMapInput {
4593 path: Option<String>,
4594 depth: Option<usize>,
4595 _include_signatures: Option<bool>,
4596}
4597
4598fn run_bash_wrapped(input: BashCommandInput) -> Result<ToolResult, String> {
4599 let output = execute_bash(input).map_err(|e| e.to_string())?;
4600 Ok(ToolResult {
4601 state: output.validation_state,
4602 output: serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?,
4603 })
4604}
4605
4606fn run_read_file_wrapped(input: ReadFileInput) -> Result<ToolResult, String> {
4607 match run_read_file(input) {
4608 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4609 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4610 }
4611}
4612
4613fn run_write_file_wrapped(input: WriteFileInput) -> Result<ToolResult, String> {
4614 match run_write_file(input) {
4615 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4616 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4617 }
4618}
4619
4620fn run_edit_file_wrapped(input: EditFileInput) -> Result<ToolResult, String> {
4621 match run_edit_file(input) {
4622 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4623 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4624 }
4625}
4626
4627fn run_glob_search_wrapped(input: GlobSearchInputValue) -> Result<ToolResult, String> {
4628 match run_glob_search(input) {
4629 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4630 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4631 }
4632}
4633
4634fn run_grep_search_wrapped(input: GrepSearchInput) -> Result<ToolResult, String> {
4635 match run_grep_search(input) {
4636 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4637 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4638 }
4639}
4640
4641fn run_web_fetch_wrapped(input: WebFetchInput) -> Result<ToolResult, String> {
4642 match run_web_fetch(input) {
4643 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4644 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4645 }
4646}
4647
4648fn run_web_search_wrapped(input: WebSearchInput) -> Result<ToolResult, String> {
4649 match run_web_search(input) {
4650 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4651 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4652 }
4653}
4654
4655fn run_todo_write_wrapped(input: TodoWriteInput) -> Result<ToolResult, String> {
4656 match run_todo_write(input) {
4657 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4658 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4659 }
4660}
4661
4662fn run_skill_wrapped(input: SkillInput) -> Result<ToolResult, String> {
4663 match run_skill(input) {
4664 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4665 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4666 }
4667}
4668
4669fn run_create_skill_wrapped(input: SkillCreateInput) -> Result<ToolResult, String> {
4670 match run_create_skill(input) {
4671 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4672 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4673 }
4674}
4675
4676fn run_agent_wrapped(input: AgentInput) -> Result<ToolResult, String> {
4677 match run_agent(input) {
4678 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4679 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4680 }
4681}
4682
4683fn run_tool_search_wrapped(input: ToolSearchInput) -> Result<ToolResult, String> {
4684 match run_tool_search(input) {
4685 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4686 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4687 }
4688}
4689
4690fn run_notebook_edit_wrapped(input: NotebookEditInput) -> Result<ToolResult, String> {
4691 match run_notebook_edit(input) {
4692 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4693 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4694 }
4695}
4696
4697fn run_sleep_wrapped(input: SleepInput) -> Result<ToolResult, String> {
4698 match run_sleep(input) {
4699 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4700 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4701 }
4702}
4703
4704fn run_brief_wrapped(input: BriefInput) -> Result<ToolResult, String> {
4705 match run_brief(input) {
4706 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4707 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4708 }
4709}
4710
4711fn run_config_wrapped(input: ConfigInput) -> Result<ToolResult, String> {
4712 match run_config(input) {
4713 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4714 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4715 }
4716}
4717
4718fn run_structured_output_wrapped(input: StructuredOutputInput) -> Result<ToolResult, String> {
4719 match run_structured_output(input) {
4720 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4721 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4722 }
4723}
4724
4725fn run_repl_wrapped(input: ReplInput) -> Result<ToolResult, String> {
4726 match run_repl(input) {
4727 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4728 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4729 }
4730}
4731
4732fn run_powershell_wrapped(input: PowerShellInput) -> Result<ToolResult, String> {
4733 match run_powershell(input) {
4734 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4735 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4736 }
4737}
4738
4739fn run_sequential_thinking_wrapped(input: SequentialThinkingInput) -> Result<ToolResult, String> {
4740 match run_sequential_thinking(input) {
4742 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4743 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4744 }
4745}
4746
4747fn run_memory_wrapped(input: MemoryInput) -> Result<ToolResult, String> {
4748 match run_memory(input) {
4749 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4750 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4751 }
4752}
4753
4754fn run_repo_map_wrapped(input: RepoMapInput) -> Result<ToolResult, String> {
4755 match run_repo_map(input) {
4756 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4757 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4758 }
4759}
4760
4761#[derive(Debug, Deserialize)]
4767struct VaultWriteInput {
4768 content: String,
4769 state: Option<String>,
4770}
4771
4772#[derive(Debug, Deserialize)]
4773struct VaultReadInput {
4774 query: String,
4775}
4776
4777fn vault_path() -> Result<std::path::PathBuf, String> {
4778 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4779 let dir = std::path::PathBuf::from(home).join(".albert");
4780 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
4781 Ok(dir.join("vault.jsonl"))
4782}
4783
4784use ternlang_ruvector::RuVectorDB;
4785
4786#[derive(Debug, Serialize, Deserialize)]
4792struct VaultEntry {
4793 id: String,
4794 timestamp: String,
4795 unix_epoch_seconds: i64,
4796 state: String,
4797 tags: Vec<String>,
4798 content: String,
4799}
4800
4801fn vault_ruvector_path() -> Result<std::path::PathBuf, String> {
4802 let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4803 Ok(std::path::PathBuf::from(home).join(".albert").join("vault.ruvector"))
4804}
4805
4806fn vault_extract_tags(text: &str) -> Vec<String> {
4807 let re = regex::Regex::new(r"#\w+").expect("valid regex");
4808 let tags: Vec<String> = re.find_iter(text).map(|m| m.as_str().to_string()).collect();
4809 if tags.is_empty() { vec!["#core_directive".to_string()] } else { tags }
4810}
4811
4812fn run_vault_write(input: VaultWriteInput) -> Result<String, String> {
4813 use std::io::Write as _;
4814 let path = vault_path()?;
4815 let id = uuid::Uuid::new_v4().to_string();
4816
4817 let now = chrono::Utc::now();
4818 let timestamp = now.format("%Y-%m-%dT%H:%M:%S%.3f+0000").to_string();
4819 let unix_epoch_seconds = now.timestamp();
4820
4821 let state = input.state.as_deref().unwrap_or("+1").to_string();
4822 let tags = vault_extract_tags(&input.content);
4823
4824 let content = if input.content.trim().starts_with(×tamp[..10]) {
4825 input.content.trim().to_string()
4826 } else {
4827 format!("{}: {}", timestamp, input.content.trim())
4828 };
4829
4830 let entry = VaultEntry {
4831 id,
4832 timestamp,
4833 unix_epoch_seconds,
4834 state,
4835 tags: tags.clone(),
4836 content,
4837 };
4838
4839 let mut file = std::fs::OpenOptions::new()
4840 .create(true).append(true).open(&path)
4841 .map_err(|e| format!("-1 Entropy. Failed to open vault: {e}"))?;
4842 writeln!(file, "{}", serde_json::to_string(&entry).map_err(|e| e.to_string())?)
4843 .map_err(|e| format!("-1 Entropy. Failed to write to vault: {e}"))?;
4844
4845 Ok(format!("+1 Resonance. Memory secured at T+{}. Tags: {}", unix_epoch_seconds, tags.join(", ")))
4849}
4850
4851fn run_vault_read(input: VaultReadInput) -> Result<String, String> {
4852 let path = vault_path()?;
4853 if !path.exists() {
4854 return Ok("0 Static: Vault is empty — nothing stored yet.".to_string());
4855 }
4856
4857 let query = input.query.to_lowercase();
4858 let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
4859 let mut results: Vec<String> = Vec::new();
4860
4861 for line in content.lines() {
4863 if line.to_lowercase().contains(&query) {
4864 if let Ok(entry) = serde_json::from_str::<VaultEntry>(line) {
4865 results.push(format!("[{}] ({}) {}\n {}",
4866 entry.timestamp, entry.state, entry.tags.join(" "), entry.content));
4867 }
4868 }
4869 }
4870
4871 let rv_path = vault_ruvector_path()?;
4873 if rv_path.exists() {
4874 if let Ok(db_bytes) = std::fs::read(&rv_path) {
4875 if let Ok(_db) = serde_json::from_slice::<RuVectorDB>(&db_bytes) {
4876 results.insert(0, "--- Semantic Index Active (RuVector) ---".to_string());
4879 }
4880 }
4881 }
4882
4883 if results.is_empty() {
4884 Ok(format!("0 Static: No memories found matching '{}'.", input.query))
4885 } else {
4886 Ok(format!("Vault — {} match(es) for '{}':\n\n{}", results.len(), input.query, results.join("\n\n")))
4887 }
4888}
4889
4890fn run_vault_write_wrapped(input: VaultWriteInput) -> Result<ToolResult, String> {
4891 match run_vault_write(input) {
4892 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4893 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4894 }
4895}
4896
4897fn run_vault_read_wrapped(input: VaultReadInput) -> Result<ToolResult, String> {
4898 match run_vault_read(input) {
4899 Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4900 Err(e) => Ok(ToolResult { output: e, state: -1 }),
4901 }
4902}