Skip to main content

atman_runtime/tools/
stdlib.rs

1use crate::approval::{ApprovalOutcome, request_approval};
2use crate::error::RuntimeError;
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5
6pub struct ShellQuote;
7
8impl Tool for ShellQuote {
9    fn name(&self) -> &str {
10        "shell_quote"
11    }
12
13    fn tier(&self) -> Tier {
14        Tier::Zero
15    }
16
17    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
18        Box::pin(async move {
19            let s = extract_string(&args, "s", 0)?;
20            Ok(Value::Str(shell_quote(&s)))
21        })
22    }
23}
24
25pub fn shell_quote(s: &str) -> String {
26    // POSIX-safe: wrap in single quotes, escape any internal ' as '\''.
27    let mut out = String::with_capacity(s.len() + 2);
28    out.push('\'');
29    for c in s.chars() {
30        if c == '\'' {
31            out.push_str("'\\''");
32        } else {
33            out.push(c);
34        }
35    }
36    out.push('\'');
37    out
38}
39
40pub struct Len;
41
42impl Tool for Len {
43    fn name(&self) -> &str {
44        "len"
45    }
46
47    fn tier(&self) -> Tier {
48        Tier::Zero
49    }
50
51    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
52        Box::pin(async move {
53            let v = args.positional(0)?;
54            match v {
55                Value::List(items) => Ok(Value::Int(items.len() as i64)),
56                Value::Str(s) => Ok(Value::Int(s.chars().count() as i64)),
57                other => Err(RuntimeError::TypeMismatch {
58                    expected: "list or string".into(),
59                    actual: other.kind_name().into(),
60                }),
61            }
62        })
63    }
64}
65
66pub struct Head;
67
68impl Tool for Head {
69    fn name(&self) -> &str {
70        "head"
71    }
72
73    fn tier(&self) -> Tier {
74        Tier::Zero
75    }
76
77    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
78        Box::pin(async move {
79            match args.positional(0)? {
80                Value::List(items) => items
81                    .first()
82                    .cloned()
83                    .ok_or_else(|| RuntimeError::ToolFailed("head: empty list".into())),
84                other => Err(RuntimeError::TypeMismatch {
85                    expected: "list".into(),
86                    actual: other.kind_name().into(),
87                }),
88            }
89        })
90    }
91}
92
93pub struct Tail;
94
95impl Tool for Tail {
96    fn name(&self) -> &str {
97        "tail"
98    }
99
100    fn tier(&self) -> Tier {
101        Tier::Zero
102    }
103
104    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
105        Box::pin(async move {
106            match args.positional(0)? {
107                Value::List(items) if !items.is_empty() => Ok(Value::List(items[1..].to_vec())),
108                Value::List(_) => Err(RuntimeError::ToolFailed("tail: empty list".into())),
109                other => Err(RuntimeError::TypeMismatch {
110                    expected: "list".into(),
111                    actual: other.kind_name().into(),
112                }),
113            }
114        })
115    }
116}
117
118pub struct IsEmpty;
119
120impl Tool for IsEmpty {
121    fn name(&self) -> &str {
122        "is_empty"
123    }
124
125    fn tier(&self) -> Tier {
126        Tier::Zero
127    }
128
129    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
130        Box::pin(async move {
131            let v = args.positional(0)?;
132            match v {
133                Value::List(items) => Ok(Value::Bool(items.is_empty())),
134                Value::Str(s) => Ok(Value::Bool(s.is_empty())),
135                other => Err(RuntimeError::TypeMismatch {
136                    expected: "list or string".into(),
137                    actual: other.kind_name().into(),
138                }),
139            }
140        })
141    }
142}
143
144pub struct EstimateTokens;
145
146impl Tool for EstimateTokens {
147    fn name(&self) -> &str {
148        "estimate_tokens"
149    }
150    fn tier(&self) -> Tier {
151        Tier::Zero
152    }
153    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
154        Box::pin(async move {
155            let v = args.positional(0)?;
156            match v {
157                Value::List(items) => {
158                    let mut msgs = Vec::with_capacity(items.len());
159                    for it in items {
160                        match it {
161                            Value::Message(m) => msgs.push(m.clone()),
162                            other => {
163                                return Err(RuntimeError::TypeMismatch {
164                                    expected: "list of message".into(),
165                                    actual: other.kind_name().into(),
166                                });
167                            }
168                        }
169                    }
170                    let n = crate::compaction::estimate_tokens_for_messages(&msgs);
171                    Ok(Value::Int(n as i64))
172                }
173                Value::Message(m) => Ok(Value::Int(
174                    crate::compaction::estimate_tokens_for_message(m) as i64,
175                )),
176                Value::Str(s) => {
177                    let approx = ((s.len() as f64) / 3.5).ceil() as i64;
178                    Ok(Value::Int(approx))
179                }
180                other => Err(RuntimeError::TypeMismatch {
181                    expected: "message | list of message | string".into(),
182                    actual: other.kind_name().into(),
183                }),
184            }
185        })
186    }
187}
188
189pub struct FindCompactRange;
190
191impl Tool for FindCompactRange {
192    fn name(&self) -> &str {
193        "find_compact_range"
194    }
195    fn tier(&self) -> Tier {
196        Tier::Zero
197    }
198    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
199        Box::pin(async move {
200            let messages = extract_message_list(&args, "messages", 0)?;
201            let budget = extract_int(&args, "budget", 1)? as u64;
202            match crate::compaction::find_compact_range(&messages, budget) {
203                Some(range) => Ok(Value::Struct(vec![
204                    ("start".into(), Value::Int(range.start as i64)),
205                    ("end".into(), Value::Int(range.end as i64)),
206                    (
207                        "tokens_saved".into(),
208                        Value::Int(range.tokens_saved_estimate as i64),
209                    ),
210                    ("found".into(), Value::Bool(true)),
211                ])),
212                None => Ok(Value::Struct(vec![
213                    ("start".into(), Value::Int(0)),
214                    ("end".into(), Value::Int(0)),
215                    ("tokens_saved".into(), Value::Int(0)),
216                    ("found".into(), Value::Bool(false)),
217                ])),
218            }
219        })
220    }
221}
222
223pub struct ReplaceMessagesRange;
224
225impl Tool for ReplaceMessagesRange {
226    fn name(&self) -> &str {
227        "replace_messages_range"
228    }
229    fn tier(&self) -> Tier {
230        Tier::Zero
231    }
232    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
233        Box::pin(async move {
234            let messages = extract_message_list(&args, "messages", 0)?;
235            let start = extract_int(&args, "start", 1)? as usize;
236            let end = extract_int(&args, "end", 2)? as usize;
237            let summary = extract_string_arg(&args, "summary", 3)?;
238            if start > end || end > messages.len() {
239                return Err(RuntimeError::ToolFailed(format!(
240                    "replace_messages_range: invalid range start={start} end={end} len={}",
241                    messages.len()
242                )));
243            }
244            let before_tokens = crate::compaction::estimate_tokens_for_messages(&messages);
245            let seq_span = messages
246                .get(start..end.min(messages.len()))
247                .and_then(|slice| {
248                    Some((
249                        slice.first().map(|_| start as u64)?,
250                        slice.last().map(|_| end.saturating_sub(1) as u64)?,
251                    ))
252                })
253                .unwrap_or((start as u64, end.saturating_sub(1) as u64));
254            let range = crate::compaction::CompactRange {
255                start,
256                end,
257                tokens_saved_estimate: 0,
258            };
259            let turn_id = messages
260                .first()
261                .map(|m| m.turn_id.clone())
262                .unwrap_or_else(crate::event::TurnId::now);
263            let out =
264                crate::compaction::replace_range_with_summary(&messages, &range, summary, turn_id);
265            let after_tokens = crate::compaction::estimate_tokens_for_messages(&out);
266            if let Some(sink) = &ctx.events {
267                sink.mark_compacted();
268                sink.emit(crate::event::Event::ContextCompact {
269                    session_id: ctx
270                        .turn_id
271                        .as_ref()
272                        .map(|t| t.0.to_string())
273                        .unwrap_or_default(),
274                    before_tokens,
275                    after_tokens,
276                    compacted_range_start: seq_span.0,
277                    compacted_range_end: seq_span.1,
278                    summary_text: None,
279                    replacement_msg_seq: None,
280                });
281            }
282            if let Some(tx) = &ctx.lifecycle_fire_tx {
283                let _ = tx.send(atman_dsl::ast::LifecycleEvent::ContextCompact);
284            }
285            let list: Vec<Value> = out.into_iter().map(Value::Message).collect();
286            Ok(Value::List(list))
287        })
288    }
289}
290
291fn extract_message_list(
292    args: &ToolArgs,
293    name: &str,
294    pos: usize,
295) -> Result<Vec<crate::message::Message>, RuntimeError> {
296    let value = match args.named(name) {
297        Some(v) => v,
298        None => args.positional(pos)?,
299    };
300    match value {
301        Value::List(items) => {
302            let mut out = Vec::with_capacity(items.len());
303            for it in items {
304                match it {
305                    Value::Message(m) => out.push(m.clone()),
306                    other => {
307                        return Err(RuntimeError::TypeMismatch {
308                            expected: "list of message".into(),
309                            actual: other.kind_name().into(),
310                        });
311                    }
312                }
313            }
314            Ok(out)
315        }
316        other => Err(RuntimeError::TypeMismatch {
317            expected: "list of message".into(),
318            actual: other.kind_name().into(),
319        }),
320    }
321}
322
323fn extract_int(args: &ToolArgs, name: &str, pos: usize) -> Result<i64, RuntimeError> {
324    let value = match args.named(name) {
325        Some(v) => v,
326        None => args.positional(pos)?,
327    };
328    match value {
329        Value::Int(n) => Ok(*n),
330        other => Err(RuntimeError::TypeMismatch {
331            expected: "int".into(),
332            actual: other.kind_name().into(),
333        }),
334    }
335}
336
337fn extract_string_arg(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
338    let value = match args.named(name) {
339        Some(v) => v,
340        None => args.positional(pos)?,
341    };
342    match value {
343        Value::Str(s) => Ok(s.clone()),
344        other => Err(RuntimeError::TypeMismatch {
345            expected: "string".into(),
346            actual: other.kind_name().into(),
347        }),
348    }
349}
350
351pub struct RenderPromptXml;
352pub struct RenderPromptMarkdown;
353pub struct RenderPromptTerse;
354
355fn extract_prompt_spec(v: &Value) -> Result<PromptSpec<'_>, RuntimeError> {
356    let Value::Struct(fields) = v else {
357        return Err(RuntimeError::TypeMismatch {
358            expected: "struct { role?, context?, task, examples?, schema? }".into(),
359            actual: v.kind_name().into(),
360        });
361    };
362    let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v);
363    let task = match get("task") {
364        Some(Value::Str(s)) => s.clone(),
365        Some(other) => {
366            return Err(RuntimeError::TypeMismatch {
367                expected: "string (task)".into(),
368                actual: other.kind_name().into(),
369            });
370        }
371        None => return Err(RuntimeError::MissingArg("prompt.task".into())),
372    };
373    let role = match get("role") {
374        Some(Value::Str(s)) => Some(s.clone()),
375        Some(Value::Unit) | None => None,
376        Some(other) => {
377            return Err(RuntimeError::TypeMismatch {
378                expected: "string (role)".into(),
379                actual: other.kind_name().into(),
380            });
381        }
382    };
383    let context = get("context");
384    let schema = match get("schema") {
385        Some(Value::Str(s)) => Some(s.clone()),
386        _ => None,
387    };
388    let examples = match get("examples") {
389        Some(Value::List(items)) => items.iter().collect(),
390        _ => Vec::new(),
391    };
392    Ok(PromptSpec {
393        role,
394        context,
395        task,
396        examples,
397        schema,
398    })
399}
400
401struct PromptSpec<'a> {
402    role: Option<String>,
403    context: Option<&'a Value>,
404    task: String,
405    examples: Vec<&'a Value>,
406    schema: Option<String>,
407}
408
409fn json_str(v: &Value) -> String {
410    serde_json::to_string_pretty(&v.to_json()).unwrap_or_default()
411}
412
413fn render_xml(spec: &PromptSpec<'_>) -> String {
414    let mut out = String::new();
415    if let Some(role) = &spec.role {
416        out.push_str(&format!("<role>{}</role>\n", role));
417    }
418    if let Some(ctx) = spec.context {
419        out.push_str(&format!("<context>\n{}\n</context>\n", json_str(ctx)));
420    }
421    if !spec.examples.is_empty() {
422        out.push_str("<examples>\n");
423        for (i, ex) in spec.examples.iter().enumerate() {
424            out.push_str(&format!(
425                "  <example n=\"{}\">\n{}\n  </example>\n",
426                i + 1,
427                json_str(ex)
428            ));
429        }
430        out.push_str("</examples>\n");
431    }
432    out.push_str(&format!("<task>{}</task>\n", spec.task));
433    if let Some(schema) = &spec.schema {
434        out.push_str(&format!("<schema>{}</schema>\n", schema));
435    }
436    out
437}
438
439fn render_markdown(spec: &PromptSpec<'_>) -> String {
440    let mut out = String::new();
441    if let Some(role) = &spec.role {
442        out.push_str(&format!("# Role\n{}\n\n", role));
443    }
444    if let Some(ctx) = spec.context {
445        out.push_str(&format!("# Context\n```json\n{}\n```\n\n", json_str(ctx)));
446    }
447    if !spec.examples.is_empty() {
448        out.push_str("# Examples\n");
449        for (i, ex) in spec.examples.iter().enumerate() {
450            out.push_str(&format!(
451                "{}. `{}`\n",
452                i + 1,
453                json_str(ex).replace('\n', " ")
454            ));
455        }
456        out.push('\n');
457    }
458    out.push_str(&format!("# Task\n{}\n", spec.task));
459    if let Some(schema) = &spec.schema {
460        out.push_str(&format!("\n# Schema\n{}\n", schema));
461    }
462    out
463}
464
465fn render_terse(spec: &PromptSpec<'_>) -> String {
466    let mut out = String::new();
467    if let Some(role) = &spec.role {
468        out.push_str(&format!("Role: {}\n", role));
469    }
470    if let Some(ctx) = spec.context {
471        out.push_str(&format!("Context: {}\n", json_str(ctx).replace('\n', " ")));
472    }
473    out.push_str(&format!("Task: {}\n", spec.task));
474    if let Some(schema) = &spec.schema {
475        out.push_str(&format!("Schema: {}\n", schema));
476    }
477    for (i, ex) in spec.examples.iter().enumerate() {
478        out.push_str(&format!(
479            "Example {}: {}\n",
480            i + 1,
481            json_str(ex).replace('\n', " ")
482        ));
483    }
484    out
485}
486
487impl Tool for RenderPromptXml {
488    fn name(&self) -> &str {
489        "render_prompt_xml"
490    }
491    fn tier(&self) -> Tier {
492        Tier::Zero
493    }
494    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
495        Box::pin(async move {
496            let v = args.positional(0)?;
497            let spec = extract_prompt_spec(v)?;
498            Ok(Value::Str(render_xml(&spec)))
499        })
500    }
501}
502
503impl Tool for RenderPromptMarkdown {
504    fn name(&self) -> &str {
505        "render_prompt_markdown"
506    }
507    fn tier(&self) -> Tier {
508        Tier::Zero
509    }
510    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
511        Box::pin(async move {
512            let v = args.positional(0)?;
513            let spec = extract_prompt_spec(v)?;
514            Ok(Value::Str(render_markdown(&spec)))
515        })
516    }
517}
518
519impl Tool for RenderPromptTerse {
520    fn name(&self) -> &str {
521        "render_prompt_terse"
522    }
523    fn tier(&self) -> Tier {
524        Tier::Zero
525    }
526    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
527        Box::pin(async move {
528            let v = args.positional(0)?;
529            let spec = extract_prompt_spec(v)?;
530            Ok(Value::Str(render_terse(&spec)))
531        })
532    }
533}
534
535pub struct ToJsonString;
536
537impl Tool for ToJsonString {
538    fn name(&self) -> &str {
539        "to_json_string"
540    }
541
542    fn tier(&self) -> Tier {
543        Tier::Zero
544    }
545
546    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
547        Box::pin(async move {
548            let v = args.positional(0)?.clone();
549            let json = v.to_json();
550            let s = serde_json::to_string_pretty(&json)
551                .map_err(|e| RuntimeError::ToolFailed(format!("to_json_string: {e}")))?;
552            Ok(Value::Str(s))
553        })
554    }
555}
556
557pub struct TextConcat;
558
559impl Tool for TextConcat {
560    fn name(&self) -> &str {
561        "text_concat"
562    }
563
564    fn tier(&self) -> Tier {
565        Tier::Zero
566    }
567
568    fn description(&self) -> Option<&str> {
569        Some("Flatten the text parts of a Message into a single string.")
570    }
571
572    fn input_schema(&self) -> serde_json::Value {
573        serde_json::json!({
574            "type": "object",
575            "properties": {"message": {"description": "A Message value from an llm call."}},
576            "required": ["message"]
577        })
578    }
579
580    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
581        Box::pin(async move {
582            let v = match args.named("message") {
583                Some(v) => v,
584                None => args.positional(0)?,
585            };
586            match v {
587                Value::Message(m) => Ok(Value::Str(m.text_concat())),
588                Value::Str(s) => Ok(Value::Str(s.clone())),
589                other => Err(RuntimeError::TypeMismatch {
590                    expected: "message or string".into(),
591                    actual: other.kind_name().into(),
592                }),
593            }
594        })
595    }
596}
597
598pub struct Concat;
599
600impl Tool for Concat {
601    fn name(&self) -> &str {
602        "concat"
603    }
604
605    fn tier(&self) -> Tier {
606        Tier::Zero
607    }
608
609    fn description(&self) -> Option<&str> {
610        Some("Concatenate two lists into a single new list.")
611    }
612
613    fn input_schema(&self) -> serde_json::Value {
614        serde_json::json!({
615            "type": "object",
616            "properties": {
617                "left": {"type": "array"},
618                "right": {"type": "array"}
619            },
620            "required": ["left", "right"]
621        })
622    }
623
624    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
625        Box::pin(async move {
626            let left = extract_list(&args, "left", 0)?;
627            let right = extract_list(&args, "right", 1)?;
628            let mut out = Vec::with_capacity(left.len() + right.len());
629            out.extend(left);
630            out.extend(right);
631            Ok(Value::List(out))
632        })
633    }
634}
635
636pub struct ExtractToolUses;
637
638impl Tool for ExtractToolUses {
639    fn name(&self) -> &str {
640        "extract_tool_uses"
641    }
642
643    fn tier(&self) -> Tier {
644        Tier::Zero
645    }
646
647    fn description(&self) -> Option<&str> {
648        Some(
649            "Pull the tool_use parts out of an assistant Message. Returns a list of \
650             {id, name, input} structs suitable for dispatch_all.",
651        )
652    }
653
654    fn input_schema(&self) -> serde_json::Value {
655        serde_json::json!({
656            "type": "object",
657            "properties": {"message": {"description": "Assistant Message value."}},
658            "required": ["message"]
659        })
660    }
661
662    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
663        Box::pin(async move {
664            let v = match args.named("message") {
665                Some(v) => v,
666                None => args.positional(0)?,
667            };
668            let m = match v {
669                Value::Message(m) => m,
670                Value::Str(_) => return Ok(Value::List(Vec::new())),
671                other => {
672                    return Err(RuntimeError::TypeMismatch {
673                        expected: "message or string".into(),
674                        actual: other.kind_name().into(),
675                    });
676                }
677            };
678            let mut out = Vec::new();
679            for part in &m.parts {
680                if let crate::message::MessagePart::ToolUse { id, name, input } = part {
681                    out.push(Value::Struct(vec![
682                        ("id".into(), Value::Str(id.clone())),
683                        ("name".into(), Value::Str(name.clone())),
684                        ("input".into(), Value::from_json(input.clone())),
685                    ]));
686                }
687            }
688            Ok(Value::List(out))
689        })
690    }
691}
692
693pub struct DispatchAll;
694
695impl Tool for DispatchAll {
696    fn name(&self) -> &str {
697        "dispatch_all"
698    }
699
700    fn tier(&self) -> Tier {
701        Tier::Zero
702    }
703
704    fn description(&self) -> Option<&str> {
705        Some(
706            "Dispatch each tool_use in the list against the current tool registry and \
707             return a list of tool_result Message values.",
708        )
709    }
710
711    fn input_schema(&self) -> serde_json::Value {
712        serde_json::json!({
713            "type": "object",
714            "properties": {"tool_uses": {"type": "array"}},
715            "required": ["tool_uses"]
716        })
717    }
718
719    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
720        Box::pin(async move {
721            let uses = extract_list(&args, "tool_uses", 0)?;
722            let Some(registry) = ctx.registry.as_ref() else {
723                return Err(RuntimeError::ToolFailed(
724                    "dispatch_all: no tool registry available on ctx".into(),
725                ));
726            };
727            let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
728            let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
729            run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
730            run_serial(serial_batch, ctx, &mut out_slots).await;
731            let out: Vec<Value> = out_slots.into_iter().flatten().collect();
732            Ok(Value::List(out))
733        })
734    }
735}
736
737enum PreparedEntry {
738    Ready {
739        index: usize,
740        id: String,
741        name: String,
742        tool: std::sync::Arc<dyn Tool>,
743        call_args: ToolArgs,
744    },
745    Failed {
746        index: usize,
747        msg: crate::message::Message,
748    },
749}
750
751fn prepare_dispatch(
752    uses: &[Value],
753    registry: &crate::tool::ToolRegistry,
754    ctx: &ToolCtx,
755) -> Result<Vec<PreparedEntry>, RuntimeError> {
756    let mut prepared = Vec::with_capacity(uses.len());
757    for (index, entry) in uses.iter().enumerate() {
758        let Value::Struct(fields) = entry else {
759            return Err(RuntimeError::TypeMismatch {
760                expected: "struct {id, name, input}".into(),
761                actual: entry.kind_name().into(),
762            });
763        };
764        let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
765        let id = match get("id") {
766            Some(Value::Str(s)) => s,
767            _ => {
768                return Err(RuntimeError::ToolFailed(
769                    "dispatch_all: tool_use missing `id` string".into(),
770                ));
771            }
772        };
773        let name = match get("name") {
774            Some(Value::Str(s)) => s,
775            _ => {
776                return Err(RuntimeError::ToolFailed(
777                    "dispatch_all: tool_use missing `name` string".into(),
778                ));
779            }
780        };
781        let input = get("input").unwrap_or(Value::Unit);
782        let Some(tool) = registry.get(&name) else {
783            prepared.push(PreparedEntry::Failed {
784                index,
785                msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
786            });
787            continue;
788        };
789        let named = match &input {
790            Value::Struct(fields) => fields.clone(),
791            Value::Unit => Vec::new(),
792            other => {
793                return Err(RuntimeError::TypeMismatch {
794                    expected: "struct or unit for tool input".into(),
795                    actual: other.kind_name().into(),
796                });
797            }
798        };
799        let missing = missing_required_fields(&tool.input_schema(), &named);
800        if !missing.is_empty() {
801            let content = format!(
802                "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
803                missing.join(", "),
804                missing
805                    .iter()
806                    .map(|f| format!("\"{f}\":\"...\""))
807                    .collect::<Vec<_>>()
808                    .join(", ")
809            );
810            prepared.push(PreparedEntry::Failed {
811                index,
812                msg: build_error_result(ctx, &id, &content),
813            });
814            continue;
815        }
816        emit_tool_node(ctx, &id, &name, &input);
817        prepared.push(PreparedEntry::Ready {
818            index,
819            id,
820            name,
821            tool,
822            call_args: ToolArgs {
823                positional: Vec::new(),
824                named,
825            },
826        });
827    }
828    Ok(prepared)
829}
830
831struct Approved {
832    index: usize,
833    id: String,
834    name: String,
835    tool: std::sync::Arc<dyn Tool>,
836    call_args: ToolArgs,
837}
838
839async fn partition_and_gate(
840    prepared: Vec<PreparedEntry>,
841    ctx: &ToolCtx,
842) -> (Vec<Approved>, Vec<Approved>, Vec<Option<Value>>) {
843    let total = prepared.len();
844    let mut out_slots: Vec<Option<Value>> = vec![None; total];
845    struct ReadyEntry {
846        index: usize,
847        id: String,
848        name: String,
849        tool: std::sync::Arc<dyn Tool>,
850        call_args: ToolArgs,
851    }
852    let mut ready: Vec<ReadyEntry> = Vec::new();
853    for entry in prepared {
854        match entry {
855            PreparedEntry::Failed { index, msg } => {
856                emit_tool_result(ctx, &msg);
857                out_slots[index] = Some(Value::Message(msg));
858            }
859            PreparedEntry::Ready {
860                index,
861                id,
862                name,
863                tool,
864                call_args,
865            } => {
866                ready.push(ReadyEntry {
867                    index,
868                    id,
869                    name,
870                    tool,
871                    call_args,
872                });
873            }
874        }
875    }
876    // Parallel: serial awaits hid all but the first pending node from the UI.
877    let gates = ready.iter().map(|r| {
878        let level = r.tool.approval_level(&r.call_args, ctx);
879        request_approval(
880            ctx,
881            &r.id,
882            &r.name,
883            &r.call_args,
884            level,
885            Some(r.tool.as_ref()),
886        )
887    });
888    let outcomes = futures::future::join_all(gates).await;
889    let mut auto_batch = Vec::new();
890    let mut serial_batch = Vec::new();
891    for (r, outcome) in ready.into_iter().zip(outcomes) {
892        let level = r.tool.approval_level(&r.call_args, ctx);
893        match outcome {
894            ApprovalOutcome::Approve => {
895                let a = Approved {
896                    index: r.index,
897                    id: r.id,
898                    name: r.name.clone(),
899                    tool: r.tool,
900                    call_args: r.call_args,
901                };
902                if level == crate::tool::ApprovalLevel::Auto {
903                    auto_batch.push(a);
904                } else {
905                    serial_batch.push(a);
906                }
907            }
908            ApprovalOutcome::Deny { reason } => {
909                let msg = build_error_result(
910                    ctx,
911                    &r.id,
912                    &format!("tool `{}` denied by user: {reason}", r.name),
913                );
914                emit_tool_result(ctx, &msg);
915                out_slots[r.index] = Some(Value::Message(msg));
916            }
917        }
918    }
919    (auto_batch, serial_batch, out_slots)
920}
921
922async fn run_auto_parallel(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
923    if batch.is_empty() {
924        return;
925    }
926    let futs = batch.iter().map(|a| a.tool.call(a.call_args.clone(), ctx));
927    let results = futures::future::join_all(futs).await;
928    for (a, r) in batch.into_iter().zip(results) {
929        emit_dispatch_node_start(ctx, &a.id, &a.name);
930        let (content, is_error) = match &r {
931            Ok(v) => (render_tool_result_text(v), false),
932            Err(e) => (format!("{e}"), true),
933        };
934        emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
935        if let Ok(v) = &r {
936            emit_diff_preview_if_relevant(ctx, &a.name, v);
937        }
938        let msg = crate::message::Message {
939            role: crate::message::MessageRole::Tool,
940            parts: vec![crate::message::MessagePart::ToolResult {
941                tool_use_id: a.id.clone(),
942                content,
943                is_error,
944            }],
945            turn_id: ctx
946                .turn_id
947                .clone()
948                .unwrap_or_else(crate::event::TurnId::now),
949        };
950        emit_tool_result(ctx, &msg);
951        out_slots[a.index] = Some(Value::Message(msg));
952    }
953}
954
955async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
956    for a in batch {
957        emit_dispatch_node_start(ctx, &a.id, &a.name);
958        let r = a.tool.call(a.call_args, ctx).await;
959        let (content, is_error) = match &r {
960            Ok(v) => (render_tool_result_text(v), false),
961            Err(e) => (format!("{e}"), true),
962        };
963        emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
964        if let Ok(v) = &r {
965            emit_diff_preview_if_relevant(ctx, &a.name, v);
966        }
967        let msg = crate::message::Message {
968            role: crate::message::MessageRole::Tool,
969            parts: vec![crate::message::MessagePart::ToolResult {
970                tool_use_id: a.id.clone(),
971                content,
972                is_error,
973            }],
974            turn_id: ctx
975                .turn_id
976                .clone()
977                .unwrap_or_else(crate::event::TurnId::now),
978        };
979        emit_tool_result(ctx, &msg);
980        out_slots[a.index] = Some(Value::Message(msg));
981    }
982}
983
984fn emit_dispatch_node_start(ctx: &ToolCtx, id: &str, name: &str) {
985    use crate::nodegraph::NodeKind;
986    let kind = NodeKind::ToolCall {
987        path: name.to_string(),
988    };
989    let label = format!("⟶ {name}");
990    let node_id = format!("dispatch:{id}");
991    if let Some(sink) = ctx.events.as_ref()
992        && let Some(run_id) = ctx.flow_run_id.as_ref()
993    {
994        sink.emit(crate::event::Event::FlowNodeStart {
995            run_id: run_id.clone(),
996            node_id: node_id.clone(),
997            kind: kind.clone(),
998            label: label.clone(),
999            parent_node_id: ctx.current_node_id.clone(),
1000        });
1001    }
1002    if let Some(tx) = &ctx.stream_tx
1003        && let Some(run_id) = ctx.flow_run_id.as_ref()
1004    {
1005        let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1006            run_id: run_id.0.to_string(),
1007            node_id,
1008            kind,
1009            label,
1010            parent_node_id: ctx.current_node_id.clone(),
1011        });
1012    }
1013}
1014
1015fn emit_dispatch_node_end(ctx: &ToolCtx, id: &str, name: &str, is_error: bool) {
1016    let node_id = format!("dispatch:{id}");
1017    let status = if is_error {
1018        crate::event::FlowNodeStatus::Err
1019    } else {
1020        crate::event::FlowNodeStatus::Ok
1021    };
1022    let preview = name.to_string();
1023    if let Some(sink) = ctx.events.as_ref()
1024        && let Some(run_id) = ctx.flow_run_id.as_ref()
1025    {
1026        sink.emit(crate::event::Event::FlowNodeEnd {
1027            run_id: run_id.clone(),
1028            node_id: node_id.clone(),
1029            status: status.clone(),
1030            output_preview: Some(preview.clone()),
1031        });
1032    }
1033    if let Some(tx) = &ctx.stream_tx
1034        && let Some(run_id) = ctx.flow_run_id.as_ref()
1035    {
1036        let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1037            run_id: run_id.0.to_string(),
1038            node_id,
1039            status,
1040            output_preview: Some(preview),
1041            parent_node_id: ctx.current_node_id.clone(),
1042        });
1043    }
1044}
1045
1046type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
1047
1048fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
1049    let Some(sink) = ctx.events.as_ref() else {
1050        return;
1051    };
1052    let data: Option<DiffPreviewData> = match tool_name {
1053        "fs.edit" => {
1054            let path = value_struct_string(value, "summary").and_then(|s| {
1055                s.strip_prefix("[fs.edit(")
1056                    .and_then(|s| s.split(':').next())
1057                    .map(|s| s.trim_end_matches(')').to_string())
1058            });
1059            let diff = value_struct_string(value, "diff");
1060            diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
1061        }
1062        "fs.write" => {
1063            let path = value_struct_string(value, "path").unwrap_or_default();
1064            let diff = value_struct_string(value, "diff");
1065            diff.map(|d| (path, None, None, Some(d)))
1066        }
1067        "git.diff" => {
1068            let Some(diff) = value_struct_string(value, "diff") else {
1069                return;
1070            };
1071            Some(("git diff".into(), None, None, Some(diff)))
1072        }
1073        "git.show" => {
1074            let sha = value_struct_string(value, "sha").unwrap_or_default();
1075            let Some(diff) = value_struct_string(value, "diff") else {
1076                return;
1077            };
1078            Some((format!("git show {sha}"), None, None, Some(diff)))
1079        }
1080        "git.log" => {
1081            let Some(diff) = value_struct_string(value, "diff") else {
1082                return;
1083            };
1084            Some(("git log HEAD".into(), None, None, Some(diff)))
1085        }
1086        _ => None,
1087    };
1088    if let Some((title, old_content, new_content, unified_diff)) = data {
1089        sink.emit(crate::event::Event::DiffPreview {
1090            turn_id: ctx.turn_id.clone(),
1091            flow_run_id: ctx.flow_run_id.clone(),
1092            title,
1093            old_content,
1094            new_content,
1095            unified_diff,
1096        });
1097    }
1098}
1099
1100fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1101    if let Value::Struct(fields) = value {
1102        fields
1103            .iter()
1104            .find(|(k, _)| k == field)
1105            .and_then(|(_, v)| match v {
1106                Value::Str(s) => Some(s.clone()),
1107                _ => None,
1108            })
1109    } else {
1110        None
1111    }
1112}
1113
1114fn emit_tool_node(ctx: &ToolCtx, id: &str, name: &str, input: &Value) {
1115    if let (Some(sink), Some(run_id), Some(parent_node)) = (
1116        ctx.events.as_ref(),
1117        ctx.flow_run_id.clone(),
1118        &ctx.current_node_id,
1119    ) {
1120        let args_preview = format!("{:?}", input)
1121            .chars()
1122            .take(4000)
1123            .collect::<String>();
1124        sink.emit(crate::event::Event::ToolNode {
1125            run_id: run_id.clone(),
1126            parent_node_id: parent_node.clone(),
1127            tool_use_id: id.to_string(),
1128            tool_name: name.to_string(),
1129            args_preview: args_preview.clone(),
1130        });
1131        if let Some(tx) = &ctx.stream_tx {
1132            let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1133                run_id: run_id.0.to_string(),
1134                parent_node_id: parent_node.clone(),
1135                tool_use_id: id.to_string(),
1136                tool: name.to_string(),
1137                args_preview,
1138            });
1139        }
1140    }
1141}
1142
1143fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1144    crate::message::Message {
1145        role: crate::message::MessageRole::Tool,
1146        parts: vec![crate::message::MessagePart::ToolResult {
1147            tool_use_id: tool_use_id.to_string(),
1148            content: content.to_string(),
1149            is_error: true,
1150        }],
1151        turn_id: ctx
1152            .turn_id
1153            .clone()
1154            .unwrap_or_else(crate::event::TurnId::now),
1155    }
1156}
1157
1158fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1159    let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1160        return Vec::new();
1161    };
1162    let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1163    required
1164        .iter()
1165        .filter_map(|v| v.as_str())
1166        .filter(|k| !have.contains(k))
1167        .map(String::from)
1168        .collect()
1169}
1170
1171fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) {
1172    let Some(sink) = &ctx.events else {
1173        return;
1174    };
1175    let msg =
1176        crate::tools::tool_output::maybe_truncate_tool_message(msg, ctx.session_dir.as_deref());
1177    sink.emit(crate::event::Event::ToolResultMsg {
1178        turn_id: msg.turn_id.clone(),
1179        flow_run_id: ctx.flow_run_id.clone(),
1180        message: msg.clone(),
1181    });
1182    if let Some(tx) = &ctx.stream_tx {
1183        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1184            flow_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1185            message: msg.clone(),
1186        });
1187    }
1188}
1189
1190fn render_tool_result_text(v: &Value) -> String {
1191    match v {
1192        Value::Str(s) => s.clone(),
1193        Value::Message(m) => m.text_concat(),
1194        other => other.to_json().to_string(),
1195    }
1196}
1197
1198fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1199    let value = match args.named(name) {
1200        Some(v) => v,
1201        None => args.positional(pos)?,
1202    };
1203    match value {
1204        Value::List(items) => Ok(items.clone()),
1205        other => Err(RuntimeError::TypeMismatch {
1206            expected: "list".into(),
1207            actual: other.kind_name().into(),
1208        }),
1209    }
1210}
1211
1212async fn call_named_unary(
1213    ctx: &ToolCtx,
1214    fn_name: &str,
1215    element: Value,
1216) -> Result<Value, RuntimeError> {
1217    let Some(registry) = ctx.registry.as_ref() else {
1218        return Err(RuntimeError::ToolFailed(format!(
1219            "list combinator: no tool registry available to resolve `{fn_name}`"
1220        )));
1221    };
1222    let Some(tool) = registry.get(fn_name) else {
1223        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1224    };
1225    let args = ToolArgs {
1226        positional: vec![element],
1227        named: Vec::new(),
1228    };
1229    tool.call(args, ctx).await
1230}
1231
1232async fn call_named_binary(
1233    ctx: &ToolCtx,
1234    fn_name: &str,
1235    a: Value,
1236    b: Value,
1237) -> Result<Value, RuntimeError> {
1238    let Some(registry) = ctx.registry.as_ref() else {
1239        return Err(RuntimeError::ToolFailed(format!(
1240            "list combinator: no tool registry available to resolve `{fn_name}`"
1241        )));
1242    };
1243    let Some(tool) = registry.get(fn_name) else {
1244        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1245    };
1246    let args = ToolArgs {
1247        positional: vec![a, b],
1248        named: Vec::new(),
1249    };
1250    tool.call(args, ctx).await
1251}
1252
1253fn value_as_bool(v: Value, fn_name: &str) -> Result<bool, RuntimeError> {
1254    match v {
1255        Value::Bool(b) => Ok(b),
1256        other => Err(RuntimeError::TypeMismatch {
1257            expected: format!("bool returned by `{fn_name}`"),
1258            actual: other.kind_name().into(),
1259        }),
1260    }
1261}
1262
1263pub struct ListMap;
1264
1265impl Tool for ListMap {
1266    fn name(&self) -> &str {
1267        "list_map"
1268    }
1269    fn tier(&self) -> Tier {
1270        Tier::Zero
1271    }
1272    fn description(&self) -> Option<&str> {
1273        Some("Apply a named tool to each item in a list; returns the transformed list.")
1274    }
1275    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1276        Box::pin(async move {
1277            let items = extract_list(&args, "list", 0)?;
1278            let fn_name = extract_string(&args, "fn_name", 1)?;
1279            let mut out = Vec::with_capacity(items.len());
1280            for it in items {
1281                out.push(call_named_unary(ctx, &fn_name, it).await?);
1282            }
1283            Ok(Value::List(out))
1284        })
1285    }
1286}
1287
1288pub struct ListFilter;
1289
1290impl Tool for ListFilter {
1291    fn name(&self) -> &str {
1292        "list_filter"
1293    }
1294    fn tier(&self) -> Tier {
1295        Tier::Zero
1296    }
1297    fn description(&self) -> Option<&str> {
1298        Some("Keep items where the named predicate tool returns true.")
1299    }
1300    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1301        Box::pin(async move {
1302            let items = extract_list(&args, "list", 0)?;
1303            let fn_name = extract_string(&args, "fn_name", 1)?;
1304            let mut out = Vec::new();
1305            for it in items {
1306                let keep =
1307                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1308                if keep {
1309                    out.push(it);
1310                }
1311            }
1312            Ok(Value::List(out))
1313        })
1314    }
1315}
1316
1317pub struct ListFind;
1318
1319impl Tool for ListFind {
1320    fn name(&self) -> &str {
1321        "list_find"
1322    }
1323    fn tier(&self) -> Tier {
1324        Tier::Zero
1325    }
1326    fn description(&self) -> Option<&str> {
1327        Some("Return the first item where the named predicate tool returns true, else unit.")
1328    }
1329    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1330        Box::pin(async move {
1331            let items = extract_list(&args, "list", 0)?;
1332            let fn_name = extract_string(&args, "fn_name", 1)?;
1333            for it in items {
1334                let hit =
1335                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1336                if hit {
1337                    return Ok(it);
1338                }
1339            }
1340            Ok(Value::Unit)
1341        })
1342    }
1343}
1344
1345pub struct ListAny;
1346
1347impl Tool for ListAny {
1348    fn name(&self) -> &str {
1349        "list_any"
1350    }
1351    fn tier(&self) -> Tier {
1352        Tier::Zero
1353    }
1354    fn description(&self) -> Option<&str> {
1355        Some("True if the named predicate tool returns true for any item.")
1356    }
1357    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1358        Box::pin(async move {
1359            let items = extract_list(&args, "list", 0)?;
1360            let fn_name = extract_string(&args, "fn_name", 1)?;
1361            for it in items {
1362                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1363                if hit {
1364                    return Ok(Value::Bool(true));
1365                }
1366            }
1367            Ok(Value::Bool(false))
1368        })
1369    }
1370}
1371
1372pub struct ListAll;
1373
1374impl Tool for ListAll {
1375    fn name(&self) -> &str {
1376        "list_all"
1377    }
1378    fn tier(&self) -> Tier {
1379        Tier::Zero
1380    }
1381    fn description(&self) -> Option<&str> {
1382        Some("True if the named predicate tool returns true for every item.")
1383    }
1384    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1385        Box::pin(async move {
1386            let items = extract_list(&args, "list", 0)?;
1387            let fn_name = extract_string(&args, "fn_name", 1)?;
1388            for it in items {
1389                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1390                if !hit {
1391                    return Ok(Value::Bool(false));
1392                }
1393            }
1394            Ok(Value::Bool(true))
1395        })
1396    }
1397}
1398
1399pub struct ListReduce;
1400
1401impl Tool for ListReduce {
1402    fn name(&self) -> &str {
1403        "list_reduce"
1404    }
1405    fn tier(&self) -> Tier {
1406        Tier::Zero
1407    }
1408    fn description(&self) -> Option<&str> {
1409        Some(
1410            "Fold a list left-to-right using a named binary tool: fn(acc, elem) -> acc'. \
1411             Takes an initial accumulator value.",
1412        )
1413    }
1414    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1415        Box::pin(async move {
1416            let items = extract_list(&args, "list", 0)?;
1417            let fn_name = extract_string(&args, "fn_name", 1)?;
1418            let init = match args.named("init") {
1419                Some(v) => v.clone(),
1420                None => args.positional(2)?.clone(),
1421            };
1422            let mut acc = init;
1423            for it in items {
1424                acc = call_named_binary(ctx, &fn_name, acc, it).await?;
1425            }
1426            Ok(acc)
1427        })
1428    }
1429}
1430
1431pub struct ComposeEmailPreview;
1432
1433impl Tool for ComposeEmailPreview {
1434    fn name(&self) -> &str {
1435        "compose_email_preview"
1436    }
1437
1438    fn tier(&self) -> Tier {
1439        Tier::Zero
1440    }
1441
1442    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1443        Box::pin(async move {
1444            let subject = extract_string(&args, "subject", 0)?;
1445            let body = extract_string(&args, "body", 1)?;
1446            let to = extract_string_list(&args, "to", 2)?;
1447            Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1448        })
1449    }
1450}
1451
1452pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1453    format!("To: {}\nSubject: {subject}\n---\n{body}", to.join(", "))
1454}
1455
1456fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1457    let value = match args.named(name) {
1458        Some(v) => v,
1459        None => args.positional(pos)?,
1460    };
1461    match value {
1462        Value::Str(s) => Ok(s.clone()),
1463        other => Err(RuntimeError::TypeMismatch {
1464            expected: "string".into(),
1465            actual: other.kind_name().into(),
1466        }),
1467    }
1468}
1469
1470fn extract_string_list(
1471    args: &ToolArgs,
1472    name: &str,
1473    pos: usize,
1474) -> Result<Vec<String>, RuntimeError> {
1475    let value = match args.named(name) {
1476        Some(v) => v,
1477        None => args.positional(pos)?,
1478    };
1479    match value {
1480        Value::List(items) => items
1481            .iter()
1482            .map(|v| match v {
1483                Value::Str(s) => Ok(s.clone()),
1484                other => Err(RuntimeError::TypeMismatch {
1485                    expected: "list of string".into(),
1486                    actual: other.kind_name().into(),
1487                }),
1488            })
1489            .collect(),
1490        other => Err(RuntimeError::TypeMismatch {
1491            expected: "list".into(),
1492            actual: other.kind_name().into(),
1493        }),
1494    }
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500
1501    #[test]
1502    fn shell_quote_wraps_and_escapes() {
1503        assert_eq!(shell_quote("hello"), "'hello'");
1504        assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1505        assert_eq!(shell_quote(""), "''");
1506        assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1507    }
1508
1509    #[test]
1510    fn compose_email_preview_formats_headers() {
1511        let preview = compose_email_preview(
1512            "Deploy status",
1513            "See attached",
1514            &["a@x.com".into(), "b@x.com".into()],
1515        );
1516        assert_eq!(
1517            preview,
1518            "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
1519        );
1520    }
1521
1522    use crate::tool::ToolRegistry;
1523    use std::sync::Arc;
1524
1525    struct IsBig;
1526    impl Tool for IsBig {
1527        fn name(&self) -> &str {
1528            "is_big"
1529        }
1530        fn tier(&self) -> Tier {
1531            Tier::Zero
1532        }
1533        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1534            Box::pin(async move {
1535                match args.positional(0)? {
1536                    Value::Int(n) => Ok(Value::Bool(*n > 10)),
1537                    other => Err(RuntimeError::TypeMismatch {
1538                        expected: "int".into(),
1539                        actual: other.kind_name().into(),
1540                    }),
1541                }
1542            })
1543        }
1544    }
1545
1546    struct Double;
1547    impl Tool for Double {
1548        fn name(&self) -> &str {
1549            "double"
1550        }
1551        fn tier(&self) -> Tier {
1552            Tier::Zero
1553        }
1554        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1555            Box::pin(async move {
1556                match args.positional(0)? {
1557                    Value::Int(n) => Ok(Value::Int(n * 2)),
1558                    other => Err(RuntimeError::TypeMismatch {
1559                        expected: "int".into(),
1560                        actual: other.kind_name().into(),
1561                    }),
1562                }
1563            })
1564        }
1565    }
1566
1567    struct AddInts;
1568    impl Tool for AddInts {
1569        fn name(&self) -> &str {
1570            "add_ints"
1571        }
1572        fn tier(&self) -> Tier {
1573            Tier::Zero
1574        }
1575        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1576            Box::pin(async move {
1577                let a = match args.positional(0)? {
1578                    Value::Int(n) => *n,
1579                    other => {
1580                        return Err(RuntimeError::TypeMismatch {
1581                            expected: "int".into(),
1582                            actual: other.kind_name().into(),
1583                        });
1584                    }
1585                };
1586                let b = match args.positional(1)? {
1587                    Value::Int(n) => *n,
1588                    other => {
1589                        return Err(RuntimeError::TypeMismatch {
1590                            expected: "int".into(),
1591                            actual: other.kind_name().into(),
1592                        });
1593                    }
1594                };
1595                Ok(Value::Int(a + b))
1596            })
1597        }
1598    }
1599
1600    fn combinator_ctx() -> ToolCtx {
1601        let reg = ToolRegistry::new();
1602        reg.register(Arc::new(IsBig));
1603        reg.register(Arc::new(Double));
1604        reg.register(Arc::new(AddInts));
1605        ToolCtx::new().with_registry(Arc::new(reg))
1606    }
1607
1608    fn call_args(items: Vec<Value>, fn_name: &str) -> ToolArgs {
1609        ToolArgs {
1610            positional: vec![Value::List(items), Value::Str(fn_name.into())],
1611            named: Vec::new(),
1612        }
1613    }
1614
1615    fn ints(xs: &[i64]) -> Vec<Value> {
1616        xs.iter().copied().map(Value::Int).collect()
1617    }
1618
1619    fn expect_int(v: &Value) -> i64 {
1620        match v {
1621            Value::Int(n) => *n,
1622            other => panic!("want int, got {other:?}"),
1623        }
1624    }
1625
1626    fn expect_bool(v: &Value) -> bool {
1627        match v {
1628            Value::Bool(b) => *b,
1629            other => panic!("want bool, got {other:?}"),
1630        }
1631    }
1632
1633    fn expect_ints(v: &Value) -> Vec<i64> {
1634        match v {
1635            Value::List(xs) => xs.iter().map(expect_int).collect(),
1636            other => panic!("want list, got {other:?}"),
1637        }
1638    }
1639
1640    #[tokio::test]
1641    async fn list_map_applies_named_tool_to_every_item() {
1642        let ctx = combinator_ctx();
1643        let out = ListMap
1644            .call(call_args(ints(&[1, 2, 3]), "double"), &ctx)
1645            .await
1646            .unwrap();
1647        assert_eq!(expect_ints(&out), vec![2, 4, 6]);
1648    }
1649
1650    #[tokio::test]
1651    async fn list_filter_keeps_only_true_predicates() {
1652        let ctx = combinator_ctx();
1653        let out = ListFilter
1654            .call(call_args(ints(&[1, 20, 3, 30]), "is_big"), &ctx)
1655            .await
1656            .unwrap();
1657        assert_eq!(expect_ints(&out), vec![20, 30]);
1658    }
1659
1660    #[tokio::test]
1661    async fn list_find_returns_first_hit_or_unit() {
1662        let ctx = combinator_ctx();
1663        let hit = ListFind
1664            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1665            .await
1666            .unwrap();
1667        assert_eq!(expect_int(&hit), 20);
1668        let miss = ListFind
1669            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1670            .await
1671            .unwrap();
1672        assert!(matches!(miss, Value::Unit));
1673    }
1674
1675    #[tokio::test]
1676    async fn list_any_and_all_short_circuit_correctly() {
1677        let ctx = combinator_ctx();
1678        let any_hit = ListAny
1679            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1680            .await
1681            .unwrap();
1682        assert!(expect_bool(&any_hit));
1683        let any_miss = ListAny
1684            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1685            .await
1686            .unwrap();
1687        assert!(!expect_bool(&any_miss));
1688        let all_hit = ListAll
1689            .call(call_args(ints(&[20, 30]), "is_big"), &ctx)
1690            .await
1691            .unwrap();
1692        assert!(expect_bool(&all_hit));
1693        let all_miss = ListAll
1694            .call(call_args(ints(&[20, 1]), "is_big"), &ctx)
1695            .await
1696            .unwrap();
1697        assert!(!expect_bool(&all_miss));
1698    }
1699
1700    #[tokio::test]
1701    async fn list_reduce_folds_with_init() {
1702        let ctx = combinator_ctx();
1703        let args = ToolArgs {
1704            positional: vec![
1705                Value::List(ints(&[1, 2, 3, 4])),
1706                Value::Str("add_ints".into()),
1707                Value::Int(0),
1708            ],
1709            named: Vec::new(),
1710        };
1711        let out = ListReduce.call(args, &ctx).await.unwrap();
1712        assert_eq!(expect_int(&out), 10);
1713    }
1714
1715    #[tokio::test]
1716    async fn combinator_reports_undefined_tool_by_name() {
1717        let ctx = combinator_ctx();
1718        let err = ListMap
1719            .call(call_args(ints(&[1]), "nope"), &ctx)
1720            .await
1721            .unwrap_err();
1722        match &err {
1723            RuntimeError::UndefinedTool(n) => assert_eq!(n, "nope"),
1724            other => panic!("want UndefinedTool(nope), got {other:?}"),
1725        }
1726    }
1727
1728    #[tokio::test]
1729    async fn combinator_rejects_non_bool_from_predicate() {
1730        let ctx = combinator_ctx();
1731        let err = ListFilter
1732            .call(call_args(ints(&[1, 2]), "double"), &ctx)
1733            .await
1734            .unwrap_err();
1735        match &err {
1736            RuntimeError::TypeMismatch { expected, .. } => {
1737                assert!(
1738                    expected.contains("bool"),
1739                    "want bool-mismatch, got expected={expected:?}"
1740                );
1741            }
1742            other => panic!("want TypeMismatch, got {other:?}"),
1743        }
1744    }
1745}