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