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