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 MessageUser;
637
638impl Tool for MessageUser {
639    fn name(&self) -> &str {
640        "message.user"
641    }
642    fn tier(&self) -> Tier {
643        Tier::Zero
644    }
645    fn description(&self) -> Option<&str> {
646        Some(
647            "Construct a user-role Message from a text string. Use with session.push to inject user instructions into the session history before an llm { context: session } call.",
648        )
649    }
650    fn input_schema(&self) -> serde_json::Value {
651        serde_json::json!({
652            "type": "object",
653            "properties": {"text": {"type": "string"}},
654            "required": ["text"]
655        })
656    }
657    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
658        Box::pin(async move {
659            let text = extract_string(&args, "text", 0)?;
660            let turn_id = ctx
661                .turn_id
662                .clone()
663                .unwrap_or_else(crate::event::TurnId::now);
664            Ok(Value::Message(crate::message::Message::user_text(
665                turn_id, text,
666            )))
667        })
668    }
669}
670
671pub struct MessageAssistant;
672
673impl Tool for MessageAssistant {
674    fn name(&self) -> &str {
675        "message.assistant"
676    }
677    fn tier(&self) -> Tier {
678        Tier::Zero
679    }
680    fn description(&self) -> Option<&str> {
681        Some("Construct an assistant-role Message from a text string.")
682    }
683    fn input_schema(&self) -> serde_json::Value {
684        serde_json::json!({
685            "type": "object",
686            "properties": {"text": {"type": "string"}},
687            "required": ["text"]
688        })
689    }
690    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
691        Box::pin(async move {
692            let text = extract_string(&args, "text", 0)?;
693            let turn_id = ctx
694                .turn_id
695                .clone()
696                .unwrap_or_else(crate::event::TurnId::now);
697            Ok(Value::Message(crate::message::Message::assistant_text(
698                turn_id, text,
699            )))
700        })
701    }
702}
703
704pub struct MessageSystem;
705
706impl Tool for MessageSystem {
707    fn name(&self) -> &str {
708        "message.system"
709    }
710    fn tier(&self) -> Tier {
711        Tier::Zero
712    }
713    fn description(&self) -> Option<&str> {
714        Some("Construct a system-role Message from a text string.")
715    }
716    fn input_schema(&self) -> serde_json::Value {
717        serde_json::json!({
718            "type": "object",
719            "properties": {"text": {"type": "string"}},
720            "required": ["text"]
721        })
722    }
723    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
724        Box::pin(async move {
725            let text = extract_string(&args, "text", 0)?;
726            let turn_id = ctx
727                .turn_id
728                .clone()
729                .unwrap_or_else(crate::event::TurnId::now);
730            Ok(Value::Message(crate::message::Message::system_text(
731                turn_id, text,
732            )))
733        })
734    }
735}
736
737pub struct MessageTool;
738
739impl Tool for MessageTool {
740    fn name(&self) -> &str {
741        "message.tool"
742    }
743    fn tier(&self) -> Tier {
744        Tier::Zero
745    }
746    fn description(&self) -> Option<&str> {
747        Some(
748            "Construct a tool-role Message from a text string. Rarely needed directly — dispatch_all already returns tool-role Messages.",
749        )
750    }
751    fn input_schema(&self) -> serde_json::Value {
752        serde_json::json!({
753            "type": "object",
754            "properties": {"text": {"type": "string"}},
755            "required": ["text"]
756        })
757    }
758    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
759        Box::pin(async move {
760            let text = extract_string(&args, "text", 0)?;
761            let turn_id = ctx
762                .turn_id
763                .clone()
764                .unwrap_or_else(crate::event::TurnId::now);
765            Ok(Value::Message(crate::message::Message {
766                turn_id,
767                role: crate::message::MessageRole::Tool,
768                parts: vec![crate::message::MessagePart::Text { text }],
769            }))
770        })
771    }
772}
773
774pub struct ExtractToolUses;
775
776impl Tool for ExtractToolUses {
777    fn name(&self) -> &str {
778        "extract_tool_uses"
779    }
780
781    fn tier(&self) -> Tier {
782        Tier::Zero
783    }
784
785    fn description(&self) -> Option<&str> {
786        Some(
787            "Pull the tool_use parts out of an assistant Message. Returns a list of \
788             {id, name, input} structs suitable for dispatch_all.",
789        )
790    }
791
792    fn input_schema(&self) -> serde_json::Value {
793        serde_json::json!({
794            "type": "object",
795            "properties": {"message": {"description": "Assistant Message value."}},
796            "required": ["message"]
797        })
798    }
799
800    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
801        Box::pin(async move {
802            let v = match args.named("message") {
803                Some(v) => v,
804                None => args.positional(0)?,
805            };
806            let m = match v {
807                Value::Message(m) => m,
808                Value::Str(_) => return Ok(Value::List(Vec::new())),
809                other => {
810                    return Err(RuntimeError::TypeMismatch {
811                        expected: "message or string".into(),
812                        actual: other.kind_name().into(),
813                    });
814                }
815            };
816            let mut out = Vec::new();
817            for part in &m.parts {
818                if let crate::message::MessagePart::ToolUse { id, name, input } = part {
819                    out.push(Value::Struct(vec![
820                        ("id".into(), Value::Str(id.clone())),
821                        ("name".into(), Value::Str(name.clone())),
822                        ("input".into(), Value::from_json(input.clone())),
823                    ]));
824                }
825            }
826            Ok(Value::List(out))
827        })
828    }
829}
830
831pub struct DispatchAll;
832
833impl Tool for DispatchAll {
834    fn name(&self) -> &str {
835        "dispatch_all"
836    }
837
838    fn tier(&self) -> Tier {
839        Tier::Zero
840    }
841
842    fn description(&self) -> Option<&str> {
843        Some(
844            "Dispatch each tool_use in the list against the current tool registry and \
845             return a list of tool_result Message values.",
846        )
847    }
848
849    fn input_schema(&self) -> serde_json::Value {
850        serde_json::json!({
851            "type": "object",
852            "properties": {"tool_uses": {"type": "array"}},
853            "required": ["tool_uses"]
854        })
855    }
856
857    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
858        Box::pin(async move {
859            let uses = extract_list(&args, "tool_uses", 0)?;
860            let Some(registry) = ctx.registry.as_ref() else {
861                return Err(RuntimeError::ToolFailed(
862                    "dispatch_all: no tool registry available on ctx".into(),
863                ));
864            };
865            let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
866            let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
867            run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
868            run_serial(serial_batch, ctx, &mut out_slots).await;
869            let out: Vec<Value> = out_slots.into_iter().flatten().collect();
870            Ok(Value::List(out))
871        })
872    }
873}
874
875enum PreparedEntry {
876    Ready {
877        index: usize,
878        id: String,
879        name: String,
880        tool: std::sync::Arc<dyn Tool>,
881        call_args: ToolArgs,
882    },
883    Failed {
884        index: usize,
885        msg: crate::message::Message,
886    },
887}
888
889fn prepare_dispatch(
890    uses: &[Value],
891    registry: &crate::tool::ToolRegistry,
892    ctx: &ToolCtx,
893) -> Result<Vec<PreparedEntry>, RuntimeError> {
894    let mut prepared = Vec::with_capacity(uses.len());
895    for (index, entry) in uses.iter().enumerate() {
896        let Value::Struct(fields) = entry else {
897            return Err(RuntimeError::TypeMismatch {
898                expected: "struct {id, name, input}".into(),
899                actual: entry.kind_name().into(),
900            });
901        };
902        let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
903        let id = match get("id") {
904            Some(Value::Str(s)) => s,
905            _ => {
906                return Err(RuntimeError::ToolFailed(
907                    "dispatch_all: tool_use missing `id` string".into(),
908                ));
909            }
910        };
911        let name = match get("name") {
912            Some(Value::Str(s)) => s,
913            _ => {
914                return Err(RuntimeError::ToolFailed(
915                    "dispatch_all: tool_use missing `name` string".into(),
916                ));
917            }
918        };
919        let input = get("input").unwrap_or(Value::Unit);
920        let Some(tool) = registry.get(&name) else {
921            prepared.push(PreparedEntry::Failed {
922                index,
923                msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
924            });
925            continue;
926        };
927        let named = match &input {
928            Value::Struct(fields) => fields.clone(),
929            Value::Unit => Vec::new(),
930            other => {
931                return Err(RuntimeError::TypeMismatch {
932                    expected: "struct or unit for tool input".into(),
933                    actual: other.kind_name().into(),
934                });
935            }
936        };
937        let missing = missing_required_fields(&tool.input_schema(), &named);
938        if !missing.is_empty() {
939            let content = format!(
940                "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
941                missing.join(", "),
942                missing
943                    .iter()
944                    .map(|f| format!("\"{f}\":\"...\""))
945                    .collect::<Vec<_>>()
946                    .join(", ")
947            );
948            prepared.push(PreparedEntry::Failed {
949                index,
950                msg: build_error_result(ctx, &id, &content),
951            });
952            continue;
953        }
954        emit_tool_node(ctx, &id, &name, &input);
955        prepared.push(PreparedEntry::Ready {
956            index,
957            id,
958            name,
959            tool,
960            call_args: ToolArgs {
961                positional: Vec::new(),
962                named,
963            },
964        });
965    }
966    Ok(prepared)
967}
968
969struct Approved {
970    index: usize,
971    id: String,
972    name: String,
973    tool: std::sync::Arc<dyn Tool>,
974    call_args: ToolArgs,
975}
976
977async fn partition_and_gate(
978    prepared: Vec<PreparedEntry>,
979    ctx: &ToolCtx,
980) -> (Vec<Approved>, Vec<Approved>, Vec<Option<Value>>) {
981    let total = prepared.len();
982    let mut out_slots: Vec<Option<Value>> = vec![None; total];
983    struct ReadyEntry {
984        index: usize,
985        id: String,
986        name: String,
987        tool: std::sync::Arc<dyn Tool>,
988        call_args: ToolArgs,
989    }
990    let mut ready: Vec<ReadyEntry> = Vec::new();
991    for entry in prepared {
992        match entry {
993            PreparedEntry::Failed { index, msg } => {
994                emit_tool_result(ctx, &msg);
995                out_slots[index] = Some(Value::Message(msg));
996            }
997            PreparedEntry::Ready {
998                index,
999                id,
1000                name,
1001                tool,
1002                call_args,
1003            } => {
1004                ready.push(ReadyEntry {
1005                    index,
1006                    id,
1007                    name,
1008                    tool,
1009                    call_args,
1010                });
1011            }
1012        }
1013    }
1014    // Parallel: serial awaits hid all but the first pending node from the UI.
1015    let gates = ready.iter().map(|r| {
1016        let level = r.tool.approval_level(&r.call_args, ctx);
1017        request_approval(
1018            ctx,
1019            &r.id,
1020            &r.name,
1021            &r.call_args,
1022            level,
1023            Some(r.tool.as_ref()),
1024        )
1025    });
1026    let outcomes = futures::future::join_all(gates).await;
1027    let mut auto_batch = Vec::new();
1028    let mut serial_batch = Vec::new();
1029    for (r, outcome) in ready.into_iter().zip(outcomes) {
1030        let level = r.tool.approval_level(&r.call_args, ctx);
1031        match outcome {
1032            ApprovalOutcome::Approve => {
1033                let a = Approved {
1034                    index: r.index,
1035                    id: r.id,
1036                    name: r.name.clone(),
1037                    tool: r.tool,
1038                    call_args: r.call_args,
1039                };
1040                if level == crate::tool::ApprovalLevel::Auto {
1041                    auto_batch.push(a);
1042                } else {
1043                    serial_batch.push(a);
1044                }
1045            }
1046            ApprovalOutcome::Deny { reason } => {
1047                let msg = build_error_result(
1048                    ctx,
1049                    &r.id,
1050                    &format!("tool `{}` denied by user: {reason}", r.name),
1051                );
1052                emit_tool_result(ctx, &msg);
1053                out_slots[r.index] = Some(Value::Message(msg));
1054            }
1055        }
1056    }
1057    (auto_batch, serial_batch, out_slots)
1058}
1059
1060async fn run_auto_parallel(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1061    if batch.is_empty() {
1062        return;
1063    }
1064    let futs = batch.iter().map(|a| a.tool.call(a.call_args.clone(), ctx));
1065    let results = futures::future::join_all(futs).await;
1066    for (a, r) in batch.into_iter().zip(results) {
1067        emit_dispatch_node_start(ctx, &a.id, &a.name);
1068        let (content, is_error) = match &r {
1069            Ok(v) => (render_tool_result_text(v), false),
1070            Err(e) => (format!("{e}"), true),
1071        };
1072        emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
1073        if let Ok(v) = &r {
1074            emit_diff_preview_if_relevant(ctx, &a.name, v);
1075        }
1076        let msg = crate::message::Message {
1077            role: crate::message::MessageRole::Tool,
1078            parts: vec![crate::message::MessagePart::ToolResult {
1079                tool_use_id: a.id.clone(),
1080                content,
1081                is_error,
1082            }],
1083            turn_id: ctx
1084                .turn_id
1085                .clone()
1086                .unwrap_or_else(crate::event::TurnId::now),
1087        };
1088        emit_tool_result(ctx, &msg);
1089        out_slots[a.index] = Some(Value::Message(msg));
1090    }
1091}
1092
1093async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1094    for a in batch {
1095        emit_dispatch_node_start(ctx, &a.id, &a.name);
1096        let r = a.tool.call(a.call_args, ctx).await;
1097        let (content, is_error) = match &r {
1098            Ok(v) => (render_tool_result_text(v), false),
1099            Err(e) => (format!("{e}"), true),
1100        };
1101        emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
1102        if let Ok(v) = &r {
1103            emit_diff_preview_if_relevant(ctx, &a.name, v);
1104        }
1105        let msg = crate::message::Message {
1106            role: crate::message::MessageRole::Tool,
1107            parts: vec![crate::message::MessagePart::ToolResult {
1108                tool_use_id: a.id.clone(),
1109                content,
1110                is_error,
1111            }],
1112            turn_id: ctx
1113                .turn_id
1114                .clone()
1115                .unwrap_or_else(crate::event::TurnId::now),
1116        };
1117        emit_tool_result(ctx, &msg);
1118        out_slots[a.index] = Some(Value::Message(msg));
1119    }
1120}
1121
1122fn emit_dispatch_node_start(ctx: &ToolCtx, id: &str, name: &str) {
1123    use crate::nodegraph::NodeKind;
1124    let kind = NodeKind::ToolCall {
1125        path: name.to_string(),
1126    };
1127    let label = format!("⟶ {name}");
1128    let node_id = format!("dispatch:{id}");
1129    if let Some(sink) = ctx.events.as_ref()
1130        && let Some(run_id) = ctx.flow_run_id.as_ref()
1131    {
1132        sink.emit(crate::event::Event::FlowNodeStart {
1133            run_id: run_id.clone(),
1134            node_id: node_id.clone(),
1135            kind: kind.clone(),
1136            label: label.clone(),
1137            parent_node_id: ctx.current_node_id.clone(),
1138        });
1139    }
1140    if let Some(tx) = &ctx.stream_tx
1141        && let Some(run_id) = ctx.flow_run_id.as_ref()
1142    {
1143        let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1144            run_id: run_id.0.to_string(),
1145            node_id,
1146            kind,
1147            label,
1148            parent_node_id: ctx.current_node_id.clone(),
1149        });
1150    }
1151}
1152
1153fn emit_dispatch_node_end(ctx: &ToolCtx, id: &str, name: &str, is_error: bool) {
1154    let node_id = format!("dispatch:{id}");
1155    let status = if is_error {
1156        crate::event::FlowNodeStatus::Err
1157    } else {
1158        crate::event::FlowNodeStatus::Ok
1159    };
1160    let preview = name.to_string();
1161    if let Some(sink) = ctx.events.as_ref()
1162        && let Some(run_id) = ctx.flow_run_id.as_ref()
1163    {
1164        sink.emit(crate::event::Event::FlowNodeEnd {
1165            run_id: run_id.clone(),
1166            node_id: node_id.clone(),
1167            status: status.clone(),
1168            output_preview: Some(preview.clone()),
1169        });
1170    }
1171    if let Some(tx) = &ctx.stream_tx
1172        && let Some(run_id) = ctx.flow_run_id.as_ref()
1173    {
1174        let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1175            run_id: run_id.0.to_string(),
1176            node_id,
1177            status,
1178            output_preview: Some(preview),
1179            parent_node_id: ctx.current_node_id.clone(),
1180        });
1181    }
1182}
1183
1184type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
1185
1186fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
1187    let Some(sink) = ctx.events.as_ref() else {
1188        return;
1189    };
1190    let data: Option<DiffPreviewData> = match tool_name {
1191        "fs.edit" => {
1192            let path = value_struct_string(value, "summary").and_then(|s| {
1193                s.strip_prefix("[fs.edit(")
1194                    .and_then(|s| s.split(':').next())
1195                    .map(|s| s.trim_end_matches(')').to_string())
1196            });
1197            let diff = value_struct_string(value, "diff");
1198            diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
1199        }
1200        "fs.write" => {
1201            let path = value_struct_string(value, "path").unwrap_or_default();
1202            let diff = value_struct_string(value, "diff");
1203            diff.map(|d| (path, None, None, Some(d)))
1204        }
1205        "git.diff" => {
1206            let Some(diff) = value_struct_string(value, "diff") else {
1207                return;
1208            };
1209            Some(("git diff".into(), None, None, Some(diff)))
1210        }
1211        "git.show" => {
1212            let sha = value_struct_string(value, "sha").unwrap_or_default();
1213            let Some(diff) = value_struct_string(value, "diff") else {
1214                return;
1215            };
1216            Some((format!("git show {sha}"), None, None, Some(diff)))
1217        }
1218        "git.log" => {
1219            let Some(diff) = value_struct_string(value, "diff") else {
1220                return;
1221            };
1222            Some(("git log HEAD".into(), None, None, Some(diff)))
1223        }
1224        _ => None,
1225    };
1226    if let Some((title, old_content, new_content, unified_diff)) = data {
1227        sink.emit(crate::event::Event::DiffPreview {
1228            turn_id: ctx.turn_id.clone(),
1229            flow_run_id: ctx.flow_run_id.clone(),
1230            title,
1231            old_content,
1232            new_content,
1233            unified_diff,
1234        });
1235    }
1236}
1237
1238fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1239    if let Value::Struct(fields) = value {
1240        fields
1241            .iter()
1242            .find(|(k, _)| k == field)
1243            .and_then(|(_, v)| match v {
1244                Value::Str(s) => Some(s.clone()),
1245                _ => None,
1246            })
1247    } else {
1248        None
1249    }
1250}
1251
1252fn emit_tool_node(ctx: &ToolCtx, id: &str, name: &str, input: &Value) {
1253    if let (Some(sink), Some(run_id), Some(parent_node)) = (
1254        ctx.events.as_ref(),
1255        ctx.flow_run_id.clone(),
1256        &ctx.current_node_id,
1257    ) {
1258        let args_preview = format!("{:?}", input)
1259            .chars()
1260            .take(4000)
1261            .collect::<String>();
1262        sink.emit(crate::event::Event::ToolNode {
1263            run_id: run_id.clone(),
1264            parent_node_id: parent_node.clone(),
1265            tool_use_id: id.to_string(),
1266            tool_name: name.to_string(),
1267            args_preview: args_preview.clone(),
1268        });
1269        if let Some(tx) = &ctx.stream_tx {
1270            let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1271                run_id: run_id.0.to_string(),
1272                parent_node_id: parent_node.clone(),
1273                tool_use_id: id.to_string(),
1274                tool: name.to_string(),
1275                args_preview,
1276            });
1277        }
1278    }
1279}
1280
1281fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1282    crate::message::Message {
1283        role: crate::message::MessageRole::Tool,
1284        parts: vec![crate::message::MessagePart::ToolResult {
1285            tool_use_id: tool_use_id.to_string(),
1286            content: content.to_string(),
1287            is_error: true,
1288        }],
1289        turn_id: ctx
1290            .turn_id
1291            .clone()
1292            .unwrap_or_else(crate::event::TurnId::now),
1293    }
1294}
1295
1296fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1297    let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1298        return Vec::new();
1299    };
1300    let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1301    required
1302        .iter()
1303        .filter_map(|v| v.as_str())
1304        .filter(|k| !have.contains(k))
1305        .map(String::from)
1306        .collect()
1307}
1308
1309fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) {
1310    let msg =
1311        crate::tools::tool_output::maybe_truncate_tool_message(msg, ctx.session_dir.as_deref());
1312    if let Some(tx) = &ctx.stream_tx {
1313        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1314            flow_run_id: if ctx.session_runtime.is_some() {
1315                None
1316            } else {
1317                ctx.flow_run_id.as_ref().map(|r| r.0.to_string())
1318            },
1319            message: msg,
1320        });
1321    }
1322}
1323
1324fn render_tool_result_text(v: &Value) -> String {
1325    match v {
1326        Value::Str(s) => s.clone(),
1327        Value::Message(m) => m.text_concat(),
1328        other => other.to_json().to_string(),
1329    }
1330}
1331
1332fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1333    let value = match args.named(name) {
1334        Some(v) => v,
1335        None => args.positional(pos)?,
1336    };
1337    match value {
1338        Value::List(items) => Ok(items.clone()),
1339        other => Err(RuntimeError::TypeMismatch {
1340            expected: "list".into(),
1341            actual: other.kind_name().into(),
1342        }),
1343    }
1344}
1345
1346async fn call_named_unary(
1347    ctx: &ToolCtx,
1348    fn_name: &str,
1349    element: Value,
1350) -> Result<Value, RuntimeError> {
1351    let Some(registry) = ctx.registry.as_ref() else {
1352        return Err(RuntimeError::ToolFailed(format!(
1353            "list combinator: no tool registry available to resolve `{fn_name}`"
1354        )));
1355    };
1356    let Some(tool) = registry.get(fn_name) else {
1357        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1358    };
1359    let args = ToolArgs {
1360        positional: vec![element],
1361        named: Vec::new(),
1362    };
1363    tool.call(args, ctx).await
1364}
1365
1366async fn call_named_binary(
1367    ctx: &ToolCtx,
1368    fn_name: &str,
1369    a: Value,
1370    b: Value,
1371) -> Result<Value, RuntimeError> {
1372    let Some(registry) = ctx.registry.as_ref() else {
1373        return Err(RuntimeError::ToolFailed(format!(
1374            "list combinator: no tool registry available to resolve `{fn_name}`"
1375        )));
1376    };
1377    let Some(tool) = registry.get(fn_name) else {
1378        return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1379    };
1380    let args = ToolArgs {
1381        positional: vec![a, b],
1382        named: Vec::new(),
1383    };
1384    tool.call(args, ctx).await
1385}
1386
1387fn value_as_bool(v: Value, fn_name: &str) -> Result<bool, RuntimeError> {
1388    match v {
1389        Value::Bool(b) => Ok(b),
1390        other => Err(RuntimeError::TypeMismatch {
1391            expected: format!("bool returned by `{fn_name}`"),
1392            actual: other.kind_name().into(),
1393        }),
1394    }
1395}
1396
1397pub struct ListMap;
1398
1399impl Tool for ListMap {
1400    fn name(&self) -> &str {
1401        "list_map"
1402    }
1403    fn tier(&self) -> Tier {
1404        Tier::Zero
1405    }
1406    fn description(&self) -> Option<&str> {
1407        Some("Apply a named tool to each item in a list; returns the transformed list.")
1408    }
1409    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1410        Box::pin(async move {
1411            let items = extract_list(&args, "list", 0)?;
1412            let fn_name = extract_string(&args, "fn_name", 1)?;
1413            let mut out = Vec::with_capacity(items.len());
1414            for it in items {
1415                out.push(call_named_unary(ctx, &fn_name, it).await?);
1416            }
1417            Ok(Value::List(out))
1418        })
1419    }
1420}
1421
1422pub struct ListFilter;
1423
1424impl Tool for ListFilter {
1425    fn name(&self) -> &str {
1426        "list_filter"
1427    }
1428    fn tier(&self) -> Tier {
1429        Tier::Zero
1430    }
1431    fn description(&self) -> Option<&str> {
1432        Some("Keep items where the named predicate tool returns true.")
1433    }
1434    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1435        Box::pin(async move {
1436            let items = extract_list(&args, "list", 0)?;
1437            let fn_name = extract_string(&args, "fn_name", 1)?;
1438            let mut out = Vec::new();
1439            for it in items {
1440                let keep =
1441                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1442                if keep {
1443                    out.push(it);
1444                }
1445            }
1446            Ok(Value::List(out))
1447        })
1448    }
1449}
1450
1451pub struct ListFind;
1452
1453impl Tool for ListFind {
1454    fn name(&self) -> &str {
1455        "list_find"
1456    }
1457    fn tier(&self) -> Tier {
1458        Tier::Zero
1459    }
1460    fn description(&self) -> Option<&str> {
1461        Some("Return the first item where the named predicate tool returns true, else unit.")
1462    }
1463    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1464        Box::pin(async move {
1465            let items = extract_list(&args, "list", 0)?;
1466            let fn_name = extract_string(&args, "fn_name", 1)?;
1467            for it in items {
1468                let hit =
1469                    value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1470                if hit {
1471                    return Ok(it);
1472                }
1473            }
1474            Ok(Value::Unit)
1475        })
1476    }
1477}
1478
1479pub struct ListAny;
1480
1481impl Tool for ListAny {
1482    fn name(&self) -> &str {
1483        "list_any"
1484    }
1485    fn tier(&self) -> Tier {
1486        Tier::Zero
1487    }
1488    fn description(&self) -> Option<&str> {
1489        Some("True if the named predicate tool returns true for any item.")
1490    }
1491    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1492        Box::pin(async move {
1493            let items = extract_list(&args, "list", 0)?;
1494            let fn_name = extract_string(&args, "fn_name", 1)?;
1495            for it in items {
1496                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1497                if hit {
1498                    return Ok(Value::Bool(true));
1499                }
1500            }
1501            Ok(Value::Bool(false))
1502        })
1503    }
1504}
1505
1506pub struct ListAll;
1507
1508impl Tool for ListAll {
1509    fn name(&self) -> &str {
1510        "list_all"
1511    }
1512    fn tier(&self) -> Tier {
1513        Tier::Zero
1514    }
1515    fn description(&self) -> Option<&str> {
1516        Some("True if the named predicate tool returns true for every item.")
1517    }
1518    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1519        Box::pin(async move {
1520            let items = extract_list(&args, "list", 0)?;
1521            let fn_name = extract_string(&args, "fn_name", 1)?;
1522            for it in items {
1523                let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1524                if !hit {
1525                    return Ok(Value::Bool(false));
1526                }
1527            }
1528            Ok(Value::Bool(true))
1529        })
1530    }
1531}
1532
1533pub struct ListReduce;
1534
1535impl Tool for ListReduce {
1536    fn name(&self) -> &str {
1537        "list_reduce"
1538    }
1539    fn tier(&self) -> Tier {
1540        Tier::Zero
1541    }
1542    fn description(&self) -> Option<&str> {
1543        Some(
1544            "Fold a list left-to-right using a named binary tool: fn(acc, elem) -> acc'. \
1545             Takes an initial accumulator value.",
1546        )
1547    }
1548    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1549        Box::pin(async move {
1550            let items = extract_list(&args, "list", 0)?;
1551            let fn_name = extract_string(&args, "fn_name", 1)?;
1552            let init = match args.named("init") {
1553                Some(v) => v.clone(),
1554                None => args.positional(2)?.clone(),
1555            };
1556            let mut acc = init;
1557            for it in items {
1558                acc = call_named_binary(ctx, &fn_name, acc, it).await?;
1559            }
1560            Ok(acc)
1561        })
1562    }
1563}
1564
1565pub struct ComposeEmailPreview;
1566
1567impl Tool for ComposeEmailPreview {
1568    fn name(&self) -> &str {
1569        "compose_email_preview"
1570    }
1571
1572    fn tier(&self) -> Tier {
1573        Tier::Zero
1574    }
1575
1576    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1577        Box::pin(async move {
1578            let subject = extract_string(&args, "subject", 0)?;
1579            let body = extract_string(&args, "body", 1)?;
1580            let to = extract_string_list(&args, "to", 2)?;
1581            Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1582        })
1583    }
1584}
1585
1586pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1587    format!("To: {}\nSubject: {subject}\n---\n{body}", to.join(", "))
1588}
1589
1590fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1591    let value = match args.named(name) {
1592        Some(v) => v,
1593        None => args.positional(pos)?,
1594    };
1595    match value {
1596        Value::Str(s) => Ok(s.clone()),
1597        other => Err(RuntimeError::TypeMismatch {
1598            expected: "string".into(),
1599            actual: other.kind_name().into(),
1600        }),
1601    }
1602}
1603
1604fn extract_string_list(
1605    args: &ToolArgs,
1606    name: &str,
1607    pos: usize,
1608) -> Result<Vec<String>, RuntimeError> {
1609    let value = match args.named(name) {
1610        Some(v) => v,
1611        None => args.positional(pos)?,
1612    };
1613    match value {
1614        Value::List(items) => items
1615            .iter()
1616            .map(|v| match v {
1617                Value::Str(s) => Ok(s.clone()),
1618                other => Err(RuntimeError::TypeMismatch {
1619                    expected: "list of string".into(),
1620                    actual: other.kind_name().into(),
1621                }),
1622            })
1623            .collect(),
1624        other => Err(RuntimeError::TypeMismatch {
1625            expected: "list".into(),
1626            actual: other.kind_name().into(),
1627        }),
1628    }
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633    use super::*;
1634
1635    #[test]
1636    fn shell_quote_wraps_and_escapes() {
1637        assert_eq!(shell_quote("hello"), "'hello'");
1638        assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1639        assert_eq!(shell_quote(""), "''");
1640        assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1641    }
1642
1643    #[test]
1644    fn compose_email_preview_formats_headers() {
1645        let preview = compose_email_preview(
1646            "Deploy status",
1647            "See attached",
1648            &["a@x.com".into(), "b@x.com".into()],
1649        );
1650        assert_eq!(
1651            preview,
1652            "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
1653        );
1654    }
1655
1656    use crate::tool::ToolRegistry;
1657    use std::sync::Arc;
1658
1659    struct IsBig;
1660    impl Tool for IsBig {
1661        fn name(&self) -> &str {
1662            "is_big"
1663        }
1664        fn tier(&self) -> Tier {
1665            Tier::Zero
1666        }
1667        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1668            Box::pin(async move {
1669                match args.positional(0)? {
1670                    Value::Int(n) => Ok(Value::Bool(*n > 10)),
1671                    other => Err(RuntimeError::TypeMismatch {
1672                        expected: "int".into(),
1673                        actual: other.kind_name().into(),
1674                    }),
1675                }
1676            })
1677        }
1678    }
1679
1680    struct Double;
1681    impl Tool for Double {
1682        fn name(&self) -> &str {
1683            "double"
1684        }
1685        fn tier(&self) -> Tier {
1686            Tier::Zero
1687        }
1688        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1689            Box::pin(async move {
1690                match args.positional(0)? {
1691                    Value::Int(n) => Ok(Value::Int(n * 2)),
1692                    other => Err(RuntimeError::TypeMismatch {
1693                        expected: "int".into(),
1694                        actual: other.kind_name().into(),
1695                    }),
1696                }
1697            })
1698        }
1699    }
1700
1701    struct AddInts;
1702    impl Tool for AddInts {
1703        fn name(&self) -> &str {
1704            "add_ints"
1705        }
1706        fn tier(&self) -> Tier {
1707            Tier::Zero
1708        }
1709        fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1710            Box::pin(async move {
1711                let a = match args.positional(0)? {
1712                    Value::Int(n) => *n,
1713                    other => {
1714                        return Err(RuntimeError::TypeMismatch {
1715                            expected: "int".into(),
1716                            actual: other.kind_name().into(),
1717                        });
1718                    }
1719                };
1720                let b = match args.positional(1)? {
1721                    Value::Int(n) => *n,
1722                    other => {
1723                        return Err(RuntimeError::TypeMismatch {
1724                            expected: "int".into(),
1725                            actual: other.kind_name().into(),
1726                        });
1727                    }
1728                };
1729                Ok(Value::Int(a + b))
1730            })
1731        }
1732    }
1733
1734    fn combinator_ctx() -> ToolCtx {
1735        let reg = ToolRegistry::new();
1736        reg.register(Arc::new(IsBig));
1737        reg.register(Arc::new(Double));
1738        reg.register(Arc::new(AddInts));
1739        ToolCtx::new().with_registry(Arc::new(reg))
1740    }
1741
1742    fn call_args(items: Vec<Value>, fn_name: &str) -> ToolArgs {
1743        ToolArgs {
1744            positional: vec![Value::List(items), Value::Str(fn_name.into())],
1745            named: Vec::new(),
1746        }
1747    }
1748
1749    fn ints(xs: &[i64]) -> Vec<Value> {
1750        xs.iter().copied().map(Value::Int).collect()
1751    }
1752
1753    fn expect_int(v: &Value) -> i64 {
1754        match v {
1755            Value::Int(n) => *n,
1756            other => panic!("want int, got {other:?}"),
1757        }
1758    }
1759
1760    fn expect_bool(v: &Value) -> bool {
1761        match v {
1762            Value::Bool(b) => *b,
1763            other => panic!("want bool, got {other:?}"),
1764        }
1765    }
1766
1767    fn expect_ints(v: &Value) -> Vec<i64> {
1768        match v {
1769            Value::List(xs) => xs.iter().map(expect_int).collect(),
1770            other => panic!("want list, got {other:?}"),
1771        }
1772    }
1773
1774    #[tokio::test]
1775    async fn list_map_applies_named_tool_to_every_item() {
1776        let ctx = combinator_ctx();
1777        let out = ListMap
1778            .call(call_args(ints(&[1, 2, 3]), "double"), &ctx)
1779            .await
1780            .unwrap();
1781        assert_eq!(expect_ints(&out), vec![2, 4, 6]);
1782    }
1783
1784    #[tokio::test]
1785    async fn list_filter_keeps_only_true_predicates() {
1786        let ctx = combinator_ctx();
1787        let out = ListFilter
1788            .call(call_args(ints(&[1, 20, 3, 30]), "is_big"), &ctx)
1789            .await
1790            .unwrap();
1791        assert_eq!(expect_ints(&out), vec![20, 30]);
1792    }
1793
1794    #[tokio::test]
1795    async fn list_find_returns_first_hit_or_unit() {
1796        let ctx = combinator_ctx();
1797        let hit = ListFind
1798            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1799            .await
1800            .unwrap();
1801        assert_eq!(expect_int(&hit), 20);
1802        let miss = ListFind
1803            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1804            .await
1805            .unwrap();
1806        assert!(matches!(miss, Value::Unit));
1807    }
1808
1809    #[tokio::test]
1810    async fn list_any_and_all_short_circuit_correctly() {
1811        let ctx = combinator_ctx();
1812        let any_hit = ListAny
1813            .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1814            .await
1815            .unwrap();
1816        assert!(expect_bool(&any_hit));
1817        let any_miss = ListAny
1818            .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1819            .await
1820            .unwrap();
1821        assert!(!expect_bool(&any_miss));
1822        let all_hit = ListAll
1823            .call(call_args(ints(&[20, 30]), "is_big"), &ctx)
1824            .await
1825            .unwrap();
1826        assert!(expect_bool(&all_hit));
1827        let all_miss = ListAll
1828            .call(call_args(ints(&[20, 1]), "is_big"), &ctx)
1829            .await
1830            .unwrap();
1831        assert!(!expect_bool(&all_miss));
1832    }
1833
1834    #[tokio::test]
1835    async fn list_reduce_folds_with_init() {
1836        let ctx = combinator_ctx();
1837        let args = ToolArgs {
1838            positional: vec![
1839                Value::List(ints(&[1, 2, 3, 4])),
1840                Value::Str("add_ints".into()),
1841                Value::Int(0),
1842            ],
1843            named: Vec::new(),
1844        };
1845        let out = ListReduce.call(args, &ctx).await.unwrap();
1846        assert_eq!(expect_int(&out), 10);
1847    }
1848
1849    #[tokio::test]
1850    async fn combinator_reports_undefined_tool_by_name() {
1851        let ctx = combinator_ctx();
1852        let err = ListMap
1853            .call(call_args(ints(&[1]), "nope"), &ctx)
1854            .await
1855            .unwrap_err();
1856        match &err {
1857            RuntimeError::UndefinedTool(n) => assert_eq!(n, "nope"),
1858            other => panic!("want UndefinedTool(nope), got {other:?}"),
1859        }
1860    }
1861
1862    #[tokio::test]
1863    async fn combinator_rejects_non_bool_from_predicate() {
1864        let ctx = combinator_ctx();
1865        let err = ListFilter
1866            .call(call_args(ints(&[1, 2]), "double"), &ctx)
1867            .await
1868            .unwrap_err();
1869        match &err {
1870            RuntimeError::TypeMismatch { expected, .. } => {
1871                assert!(
1872                    expected.contains("bool"),
1873                    "want bool-mismatch, got expected={expected:?}"
1874                );
1875            }
1876            other => panic!("want TypeMismatch, got {other:?}"),
1877        }
1878    }
1879}