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