Skip to main content

albert_tools/
lib.rs

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