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        let (content, is_error) = match &r {
930            Ok(v) => (render_tool_result_text(v), false),
931            Err(e) => (format!("{e}"), true),
932        };
933        if let Ok(v) = &r {
934            emit_diff_preview_if_relevant(ctx, &a.name, v);
935        }
936        let msg = crate::message::Message {
937            role: crate::message::MessageRole::Tool,
938            parts: vec![crate::message::MessagePart::ToolResult {
939                tool_use_id: a.id.clone(),
940                content,
941                is_error,
942            }],
943            turn_id: ctx
944                .turn_id
945                .clone()
946                .unwrap_or_else(crate::event::TurnId::now),
947        };
948        emit_tool_result(ctx, &msg);
949        out_slots[a.index] = Some(Value::Message(msg));
950    }
951}
952
953async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
954    for a in batch {
955        let r = a.tool.call(a.call_args, ctx).await;
956        let (content, is_error) = match &r {
957            Ok(v) => (render_tool_result_text(v), false),
958            Err(e) => (format!("{e}"), true),
959        };
960        if let Ok(v) = &r {
961            emit_diff_preview_if_relevant(ctx, &a.name, v);
962        }
963        let msg = crate::message::Message {
964            role: crate::message::MessageRole::Tool,
965            parts: vec![crate::message::MessagePart::ToolResult {
966                tool_use_id: a.id.clone(),
967                content,
968                is_error,
969            }],
970            turn_id: ctx
971                .turn_id
972                .clone()
973                .unwrap_or_else(crate::event::TurnId::now),
974        };
975        emit_tool_result(ctx, &msg);
976        out_slots[a.index] = Some(Value::Message(msg));
977    }
978}
979
980type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
981
982fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
983    let Some(sink) = ctx.events.as_ref() else {
984        return;
985    };
986    let data: Option<DiffPreviewData> = match tool_name {
987        "fs.edit" => {
988            let path = value_struct_string(value, "summary").and_then(|s| {
989                s.strip_prefix("[fs.edit(")
990                    .and_then(|s| s.split(':').next())
991                    .map(|s| s.trim_end_matches(')').to_string())
992            });
993            let diff = value_struct_string(value, "diff");
994            diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
995        }
996        "fs.write" => {
997            let path = value_struct_string(value, "path").unwrap_or_default();
998            let diff = value_struct_string(value, "diff");
999            diff.map(|d| (path, None, None, Some(d)))
1000        }
1001        "git.diff" => {
1002            let Some(diff) = value_struct_string(value, "diff") else {
1003                return;
1004            };
1005            Some(("git diff".into(), None, None, Some(diff)))
1006        }
1007        "git.show" => {
1008            let sha = value_struct_string(value, "sha").unwrap_or_default();
1009            let Some(diff) = value_struct_string(value, "diff") else {
1010                return;
1011            };
1012            Some((format!("git show {sha}"), None, None, Some(diff)))
1013        }
1014        "git.log" => {
1015            let Some(diff) = value_struct_string(value, "diff") else {
1016                return;
1017            };
1018            Some(("git log HEAD".into(), None, None, Some(diff)))
1019        }
1020        _ => None,
1021    };
1022    if let Some((title, old_content, new_content, unified_diff)) = data {
1023        sink.emit(crate::event::Event::DiffPreview {
1024            turn_id: ctx.turn_id.clone(),
1025            flow_run_id: ctx.flow_run_id.clone(),
1026            title,
1027            old_content,
1028            new_content,
1029            unified_diff,
1030        });
1031    }
1032}
1033
1034fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1035    if let Value::Struct(fields) = value {
1036        fields
1037            .iter()
1038            .find(|(k, _)| k == field)
1039            .and_then(|(_, v)| match v {
1040                Value::Str(s) => Some(s.clone()),
1041                _ => None,
1042            })
1043    } else {
1044        None
1045    }
1046}
1047
1048fn emit_tool_node(ctx: &ToolCtx, id: &str, name: &str, input: &Value) {
1049    if let (Some(sink), Some(run_id), Some(parent_node)) = (
1050        ctx.events.as_ref(),
1051        ctx.flow_run_id.clone(),
1052        &ctx.current_node_id,
1053    ) {
1054        let args_preview = format!("{:?}", input)
1055            .chars()
1056            .take(4000)
1057            .collect::<String>();
1058        sink.emit(crate::event::Event::ToolNode {
1059            run_id: run_id.clone(),
1060            parent_node_id: parent_node.clone(),
1061            tool_use_id: id.to_string(),
1062            tool_name: name.to_string(),
1063            args_preview: args_preview.clone(),
1064        });
1065        if let Some(tx) = &ctx.stream_tx {
1066            let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1067                run_id: run_id.0.to_string(),
1068                parent_node_id: parent_node.clone(),
1069                tool_use_id: id.to_string(),
1070                tool: name.to_string(),
1071                args_preview,
1072            });
1073        }
1074    }
1075}
1076
1077fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1078    crate::message::Message {
1079        role: crate::message::MessageRole::Tool,
1080        parts: vec![crate::message::MessagePart::ToolResult {
1081            tool_use_id: tool_use_id.to_string(),
1082            content: content.to_string(),
1083            is_error: true,
1084        }],
1085        turn_id: ctx
1086            .turn_id
1087            .clone()
1088            .unwrap_or_else(crate::event::TurnId::now),
1089    }
1090}
1091
1092fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1093    let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1094        return Vec::new();
1095    };
1096    let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1097    required
1098        .iter()
1099        .filter_map(|v| v.as_str())
1100        .filter(|k| !have.contains(k))
1101        .map(String::from)
1102        .collect()
1103}
1104
1105fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) {
1106    let Some(sink) = &ctx.events else {
1107        return;
1108    };
1109    sink.emit(crate::event::Event::ToolResultMsg {
1110        turn_id: msg.turn_id.clone(),
1111        flow_run_id: ctx.flow_run_id.clone(),
1112        message: msg.clone(),
1113    });
1114    if let Some(tx) = &ctx.stream_tx {
1115        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1116            flow_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1117            message: msg.clone(),
1118        });
1119    }
1120}
1121
1122fn render_tool_result_text(v: &Value) -> String {
1123    match v {
1124        Value::Str(s) => s.clone(),
1125        Value::Message(m) => m.text_concat(),
1126        other => other.to_json().to_string(),
1127    }
1128}
1129
1130fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1131    let value = match args.named(name) {
1132        Some(v) => v,
1133        None => args.positional(pos)?,
1134    };
1135    match value {
1136        Value::List(items) => Ok(items.clone()),
1137        other => Err(RuntimeError::TypeMismatch {
1138            expected: "list".into(),
1139            actual: other.kind_name().into(),
1140        }),
1141    }
1142}
1143
1144async fn call_named_unary(
1145    ctx: &ToolCtx,
1146    fn_name: &str,
1147    element: Value,
1148) -> Result<Value, RuntimeError> {
1149    let Some(registry) = ctx.registry.as_ref() else {
1150        return Err(RuntimeError::ToolFailed(format!(
1151            "list combinator: no tool registry available to resolve `{fn_name}`"
1152        )));
1153    };
1154    let Some(tool) = registry.get(fn_name) else {
1155        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1156    };
1157    let args = ToolArgs {
1158        positional: vec![element],
1159        named: Vec::new(),
1160    };
1161    tool.call(args, ctx).await
1162}
1163
1164async fn call_named_binary(
1165    ctx: &ToolCtx,
1166    fn_name: &str,
1167    a: Value,
1168    b: Value,
1169) -> Result<Value, RuntimeError> {
1170    let Some(registry) = ctx.registry.as_ref() else {
1171        return Err(RuntimeError::ToolFailed(format!(
1172            "list combinator: no tool registry available to resolve `{fn_name}`"
1173        )));
1174    };
1175    let Some(tool) = registry.get(fn_name) else {
1176        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1177    };
1178    let args = ToolArgs {
1179        positional: vec![a, b],
1180        named: Vec::new(),
1181    };
1182    tool.call(args, ctx).await
1183}
1184
1185fn value_as_bool(v: Value, fn_name: &str) -> Result<bool, RuntimeError> {
1186    match v {
1187        Value::Bool(b) => Ok(b),
1188        other => Err(RuntimeError::TypeMismatch {
1189            expected: format!("bool returned by `{fn_name}`"),
1190            actual: other.kind_name().into(),
1191        }),
1192    }
1193}
1194
1195pub struct ListMap;
1196
1197impl Tool for ListMap {
1198    fn name(&self) -> &str {
1199        "list_map"
1200    }
1201    fn tier(&self) -> Tier {
1202        Tier::Zero
1203    }
1204    fn description(&self) -> Option<&str> {
1205        Some("Apply a named tool to each item in a list; returns the transformed list.")
1206    }
1207    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1208        Box::pin(async move {
1209            let items = extract_list(&args, "list", 0)?;
1210            let fn_name = extract_string(&args, "fn_name", 1)?;
1211            let mut out = Vec::with_capacity(items.len());
1212            for it in items {
1213                out.push(call_named_unary(ctx, &fn_name, it).await?);
1214            }
1215            Ok(Value::List(out))
1216        })
1217    }
1218}
1219
1220pub struct ListFilter;
1221
1222impl Tool for ListFilter {
1223    fn name(&self) -> &str {
1224        "list_filter"
1225    }
1226    fn tier(&self) -> Tier {
1227        Tier::Zero
1228    }
1229    fn description(&self) -> Option<&str> {
1230        Some("Keep items where the named predicate tool returns true.")
1231    }
1232    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1233        Box::pin(async move {
1234            let items = extract_list(&args, "list", 0)?;
1235            let fn_name = extract_string(&args, "fn_name", 1)?;
1236            let mut out = Vec::new();
1237            for it in items {
1238                let keep =
1239                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1240                if keep {
1241                    out.push(it);
1242                }
1243            }
1244            Ok(Value::List(out))
1245        })
1246    }
1247}
1248
1249pub struct ListFind;
1250
1251impl Tool for ListFind {
1252    fn name(&self) -> &str {
1253        "list_find"
1254    }
1255    fn tier(&self) -> Tier {
1256        Tier::Zero
1257    }
1258    fn description(&self) -> Option<&str> {
1259        Some("Return the first item where the named predicate tool returns true, else unit.")
1260    }
1261    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1262        Box::pin(async move {
1263            let items = extract_list(&args, "list", 0)?;
1264            let fn_name = extract_string(&args, "fn_name", 1)?;
1265            for it in items {
1266                let hit =
1267                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1268                if hit {
1269                    return Ok(it);
1270                }
1271            }
1272            Ok(Value::Unit)
1273        })
1274    }
1275}
1276
1277pub struct ListAny;
1278
1279impl Tool for ListAny {
1280    fn name(&self) -> &str {
1281        "list_any"
1282    }
1283    fn tier(&self) -> Tier {
1284        Tier::Zero
1285    }
1286    fn description(&self) -> Option<&str> {
1287        Some("True if the named predicate tool returns true for any item.")
1288    }
1289    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1290        Box::pin(async move {
1291            let items = extract_list(&args, "list", 0)?;
1292            let fn_name = extract_string(&args, "fn_name", 1)?;
1293            for it in items {
1294                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1295                if hit {
1296                    return Ok(Value::Bool(true));
1297                }
1298            }
1299            Ok(Value::Bool(false))
1300        })
1301    }
1302}
1303
1304pub struct ListAll;
1305
1306impl Tool for ListAll {
1307    fn name(&self) -> &str {
1308        "list_all"
1309    }
1310    fn tier(&self) -> Tier {
1311        Tier::Zero
1312    }
1313    fn description(&self) -> Option<&str> {
1314        Some("True if the named predicate tool returns true for every item.")
1315    }
1316    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1317        Box::pin(async move {
1318            let items = extract_list(&args, "list", 0)?;
1319            let fn_name = extract_string(&args, "fn_name", 1)?;
1320            for it in items {
1321                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1322                if !hit {
1323                    return Ok(Value::Bool(false));
1324                }
1325            }
1326            Ok(Value::Bool(true))
1327        })
1328    }
1329}
1330
1331pub struct ListReduce;
1332
1333impl Tool for ListReduce {
1334    fn name(&self) -> &str {
1335        "list_reduce"
1336    }
1337    fn tier(&self) -> Tier {
1338        Tier::Zero
1339    }
1340    fn description(&self) -> Option<&str> {
1341        Some(
1342            "Fold a list left-to-right using a named binary tool: fn(acc, elem) -> acc'. \
1343             Takes an initial accumulator value.",
1344        )
1345    }
1346    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1347        Box::pin(async move {
1348            let items = extract_list(&args, "list", 0)?;
1349            let fn_name = extract_string(&args, "fn_name", 1)?;
1350            let init = match args.named("init") {
1351                Some(v) => v.clone(),
1352                None => args.positional(2)?.clone(),
1353            };
1354            let mut acc = init;
1355            for it in items {
1356                acc = call_named_binary(ctx, &fn_name, acc, it).await?;
1357            }
1358            Ok(acc)
1359        })
1360    }
1361}
1362
1363pub struct ComposeEmailPreview;
1364
1365impl Tool for ComposeEmailPreview {
1366    fn name(&self) -> &str {
1367        "compose_email_preview"
1368    }
1369
1370    fn tier(&self) -> Tier {
1371        Tier::Zero
1372    }
1373
1374    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1375        Box::pin(async move {
1376            let subject = extract_string(&args, "subject", 0)?;
1377            let body = extract_string(&args, "body", 1)?;
1378            let to = extract_string_list(&args, "to", 2)?;
1379            Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1380        })
1381    }
1382}
1383
1384pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1385    format!("To: {}\nSubject: {subject}\n---\n{body}", to.join(", "))
1386}
1387
1388fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1389    let value = match args.named(name) {
1390        Some(v) => v,
1391        None => args.positional(pos)?,
1392    };
1393    match value {
1394        Value::Str(s) => Ok(s.clone()),
1395        other => Err(RuntimeError::TypeMismatch {
1396            expected: "string".into(),
1397            actual: other.kind_name().into(),
1398        }),
1399    }
1400}
1401
1402fn extract_string_list(
1403    args: &ToolArgs,
1404    name: &str,
1405    pos: usize,
1406) -> Result<Vec<String>, RuntimeError> {
1407    let value = match args.named(name) {
1408        Some(v) => v,
1409        None => args.positional(pos)?,
1410    };
1411    match value {
1412        Value::List(items) => items
1413            .iter()
1414            .map(|v| match v {
1415                Value::Str(s) => Ok(s.clone()),
1416                other => Err(RuntimeError::TypeMismatch {
1417                    expected: "list of string".into(),
1418                    actual: other.kind_name().into(),
1419                }),
1420            })
1421            .collect(),
1422        other => Err(RuntimeError::TypeMismatch {
1423            expected: "list".into(),
1424            actual: other.kind_name().into(),
1425        }),
1426    }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432
1433    #[test]
1434    fn shell_quote_wraps_and_escapes() {
1435        assert_eq!(shell_quote("hello"), "'hello'");
1436        assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1437        assert_eq!(shell_quote(""), "''");
1438        assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1439    }
1440
1441    #[test]
1442    fn compose_email_preview_formats_headers() {
1443        let preview = compose_email_preview(
1444            "Deploy status",
1445            "See attached",
1446            &["a@x.com".into(), "b@x.com".into()],
1447        );
1448        assert_eq!(
1449            preview,
1450            "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
1451        );
1452    }
1453
1454    use crate::tool::ToolRegistry;
1455    use std::sync::Arc;
1456
1457    struct IsBig;
1458    impl Tool for IsBig {
1459        fn name(&self) -> &str {
1460            "is_big"
1461        }
1462        fn tier(&self) -> Tier {
1463            Tier::Zero
1464        }
1465        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1466            Box::pin(async move {
1467                match args.positional(0)? {
1468                    Value::Int(n) => Ok(Value::Bool(*n > 10)),
1469                    other => Err(RuntimeError::TypeMismatch {
1470                        expected: "int".into(),
1471                        actual: other.kind_name().into(),
1472                    }),
1473                }
1474            })
1475        }
1476    }
1477
1478    struct Double;
1479    impl Tool for Double {
1480        fn name(&self) -> &str {
1481            "double"
1482        }
1483        fn tier(&self) -> Tier {
1484            Tier::Zero
1485        }
1486        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1487            Box::pin(async move {
1488                match args.positional(0)? {
1489                    Value::Int(n) => Ok(Value::Int(n * 2)),
1490                    other => Err(RuntimeError::TypeMismatch {
1491                        expected: "int".into(),
1492                        actual: other.kind_name().into(),
1493                    }),
1494                }
1495            })
1496        }
1497    }
1498
1499    struct AddInts;
1500    impl Tool for AddInts {
1501        fn name(&self) -> &str {
1502            "add_ints"
1503        }
1504        fn tier(&self) -> Tier {
1505            Tier::Zero
1506        }
1507        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1508            Box::pin(async move {
1509                let a = match args.positional(0)? {
1510                    Value::Int(n) => *n,
1511                    other => {
1512                        return Err(RuntimeError::TypeMismatch {
1513                            expected: "int".into(),
1514                            actual: other.kind_name().into(),
1515                        });
1516                    }
1517                };
1518                let b = match args.positional(1)? {
1519                    Value::Int(n) => *n,
1520                    other => {
1521                        return Err(RuntimeError::TypeMismatch {
1522                            expected: "int".into(),
1523                            actual: other.kind_name().into(),
1524                        });
1525                    }
1526                };
1527                Ok(Value::Int(a + b))
1528            })
1529        }
1530    }
1531
1532    fn combinator_ctx() -> ToolCtx {
1533        let reg = ToolRegistry::new();
1534        reg.register(Arc::new(IsBig));
1535        reg.register(Arc::new(Double));
1536        reg.register(Arc::new(AddInts));
1537        ToolCtx::new().with_registry(Arc::new(reg))
1538    }
1539
1540    fn call_args(items: Vec<Value>, fn_name: &str) -> ToolArgs {
1541        ToolArgs {
1542            positional: vec![Value::List(items), Value::Str(fn_name.into())],
1543            named: Vec::new(),
1544        }
1545    }
1546
1547    fn ints(xs: &[i64]) -> Vec<Value> {
1548        xs.iter().copied().map(Value::Int).collect()
1549    }
1550
1551    fn expect_int(v: &Value) -> i64 {
1552        match v {
1553            Value::Int(n) => *n,
1554            other => panic!("want int, got {other:?}"),
1555        }
1556    }
1557
1558    fn expect_bool(v: &Value) -> bool {
1559        match v {
1560            Value::Bool(b) => *b,
1561            other => panic!("want bool, got {other:?}"),
1562        }
1563    }
1564
1565    fn expect_ints(v: &Value) -> Vec<i64> {
1566        match v {
1567            Value::List(xs) => xs.iter().map(expect_int).collect(),
1568            other => panic!("want list, got {other:?}"),
1569        }
1570    }
1571
1572    #[tokio::test]
1573    async fn list_map_applies_named_tool_to_every_item() {
1574        let ctx = combinator_ctx();
1575        let out = ListMap
1576            .call(call_args(ints(&[1, 2, 3]), "double"), &ctx)
1577            .await
1578            .unwrap();
1579        assert_eq!(expect_ints(&out), vec![2, 4, 6]);
1580    }
1581
1582    #[tokio::test]
1583    async fn list_filter_keeps_only_true_predicates() {
1584        let ctx = combinator_ctx();
1585        let out = ListFilter
1586            .call(call_args(ints(&[1, 20, 3, 30]), "is_big"), &ctx)
1587            .await
1588            .unwrap();
1589        assert_eq!(expect_ints(&out), vec![20, 30]);
1590    }
1591
1592    #[tokio::test]
1593    async fn list_find_returns_first_hit_or_unit() {
1594        let ctx = combinator_ctx();
1595        let hit = ListFind
1596            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1597            .await
1598            .unwrap();
1599        assert_eq!(expect_int(&hit), 20);
1600        let miss = ListFind
1601            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1602            .await
1603            .unwrap();
1604        assert!(matches!(miss, Value::Unit));
1605    }
1606
1607    #[tokio::test]
1608    async fn list_any_and_all_short_circuit_correctly() {
1609        let ctx = combinator_ctx();
1610        let any_hit = ListAny
1611            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1612            .await
1613            .unwrap();
1614        assert!(expect_bool(&any_hit));
1615        let any_miss = ListAny
1616            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1617            .await
1618            .unwrap();
1619        assert!(!expect_bool(&any_miss));
1620        let all_hit = ListAll
1621            .call(call_args(ints(&[20, 30]), "is_big"), &ctx)
1622            .await
1623            .unwrap();
1624        assert!(expect_bool(&all_hit));
1625        let all_miss = ListAll
1626            .call(call_args(ints(&[20, 1]), "is_big"), &ctx)
1627            .await
1628            .unwrap();
1629        assert!(!expect_bool(&all_miss));
1630    }
1631
1632    #[tokio::test]
1633    async fn list_reduce_folds_with_init() {
1634        let ctx = combinator_ctx();
1635        let args = ToolArgs {
1636            positional: vec![
1637                Value::List(ints(&[1, 2, 3, 4])),
1638                Value::Str("add_ints".into()),
1639                Value::Int(0),
1640            ],
1641            named: Vec::new(),
1642        };
1643        let out = ListReduce.call(args, &ctx).await.unwrap();
1644        assert_eq!(expect_int(&out), 10);
1645    }
1646
1647    #[tokio::test]
1648    async fn combinator_reports_undefined_tool_by_name() {
1649        let ctx = combinator_ctx();
1650        let err = ListMap
1651            .call(call_args(ints(&[1]), "nope"), &ctx)
1652            .await
1653            .unwrap_err();
1654        match &err {
1655            RuntimeError::UndefinedTool(n) => assert_eq!(n, "nope"),
1656            other => panic!("want UndefinedTool(nope), got {other:?}"),
1657        }
1658    }
1659
1660    #[tokio::test]
1661    async fn combinator_rejects_non_bool_from_predicate() {
1662        let ctx = combinator_ctx();
1663        let err = ListFilter
1664            .call(call_args(ints(&[1, 2]), "double"), &ctx)
1665            .await
1666            .unwrap_err();
1667        match &err {
1668            RuntimeError::TypeMismatch { expected, .. } => {
1669                assert!(
1670                    expected.contains("bool"),
1671                    "want bool-mismatch, got expected={expected:?}"
1672                );
1673            }
1674            other => panic!("want TypeMismatch, got {other:?}"),
1675        }
1676    }
1677}