Skip to main content

atman_runtime/tools/
stdlib.rs

1use crate::approval::authorize_tool_invocation;
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                    flow_run_id: ctx.message_flow_run_id(),
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                });
282            }
283            if let Some(tx) = &ctx.lifecycle_fire_tx {
284                let _ = tx.send(atman_dsl::ast::LifecycleEvent::ContextCompact);
285            }
286            let list: Vec<Value> = out.into_iter().map(Value::Message).collect();
287            Ok(Value::List(list))
288        })
289    }
290}
291
292fn extract_message_list(
293    args: &ToolArgs,
294    name: &str,
295    pos: usize,
296) -> Result<Vec<crate::message::Message>, RuntimeError> {
297    let value = match args.named(name) {
298        Some(v) => v,
299        None => args.positional(pos)?,
300    };
301    match value {
302        Value::List(items) => {
303            let mut out = Vec::with_capacity(items.len());
304            for it in items {
305                match it {
306                    Value::Message(m) => out.push(m.clone()),
307                    other => {
308                        return Err(RuntimeError::TypeMismatch {
309                            expected: "list of message".into(),
310                            actual: other.kind_name().into(),
311                        });
312                    }
313                }
314            }
315            Ok(out)
316        }
317        other => Err(RuntimeError::TypeMismatch {
318            expected: "list of message".into(),
319            actual: other.kind_name().into(),
320        }),
321    }
322}
323
324fn extract_int(args: &ToolArgs, name: &str, pos: usize) -> Result<i64, RuntimeError> {
325    let value = match args.named(name) {
326        Some(v) => v,
327        None => args.positional(pos)?,
328    };
329    match value {
330        Value::Int(n) => Ok(*n),
331        other => Err(RuntimeError::TypeMismatch {
332            expected: "int".into(),
333            actual: other.kind_name().into(),
334        }),
335    }
336}
337
338fn extract_string_arg(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
339    let value = match args.named(name) {
340        Some(v) => v,
341        None => args.positional(pos)?,
342    };
343    match value {
344        Value::Str(s) => Ok(s.clone()),
345        other => Err(RuntimeError::TypeMismatch {
346            expected: "string".into(),
347            actual: other.kind_name().into(),
348        }),
349    }
350}
351
352pub struct RenderPromptXml;
353pub struct RenderPromptMarkdown;
354pub struct RenderPromptTerse;
355
356fn extract_prompt_spec(v: &Value) -> Result<PromptSpec<'_>, RuntimeError> {
357    let Value::Struct(fields) = v else {
358        return Err(RuntimeError::TypeMismatch {
359            expected: "struct { role?, context?, task, examples?, schema? }".into(),
360            actual: v.kind_name().into(),
361        });
362    };
363    let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v);
364    let task = match get("task") {
365        Some(Value::Str(s)) => s.clone(),
366        Some(other) => {
367            return Err(RuntimeError::TypeMismatch {
368                expected: "string (task)".into(),
369                actual: other.kind_name().into(),
370            });
371        }
372        None => return Err(RuntimeError::MissingArg("prompt.task".into())),
373    };
374    let role = match get("role") {
375        Some(Value::Str(s)) => Some(s.clone()),
376        Some(Value::Unit) | None => None,
377        Some(other) => {
378            return Err(RuntimeError::TypeMismatch {
379                expected: "string (role)".into(),
380                actual: other.kind_name().into(),
381            });
382        }
383    };
384    let context = get("context");
385    let schema = match get("schema") {
386        Some(Value::Str(s)) => Some(s.clone()),
387        _ => None,
388    };
389    let examples = match get("examples") {
390        Some(Value::List(items)) => items.iter().collect(),
391        _ => Vec::new(),
392    };
393    Ok(PromptSpec {
394        role,
395        context,
396        task,
397        examples,
398        schema,
399    })
400}
401
402struct PromptSpec<'a> {
403    role: Option<String>,
404    context: Option<&'a Value>,
405    task: String,
406    examples: Vec<&'a Value>,
407    schema: Option<String>,
408}
409
410fn json_str(v: &Value) -> String {
411    serde_json::to_string_pretty(&v.to_json()).unwrap_or_default()
412}
413
414fn render_xml(spec: &PromptSpec<'_>) -> String {
415    let mut out = String::new();
416    if let Some(role) = &spec.role {
417        out.push_str(&format!("<role>{}</role>\n", role));
418    }
419    if let Some(ctx) = spec.context {
420        out.push_str(&format!("<context>\n{}\n</context>\n", json_str(ctx)));
421    }
422    if !spec.examples.is_empty() {
423        out.push_str("<examples>\n");
424        for (i, ex) in spec.examples.iter().enumerate() {
425            out.push_str(&format!(
426                "  <example n=\"{}\">\n{}\n  </example>\n",
427                i + 1,
428                json_str(ex)
429            ));
430        }
431        out.push_str("</examples>\n");
432    }
433    out.push_str(&format!("<task>{}</task>\n", spec.task));
434    if let Some(schema) = &spec.schema {
435        out.push_str(&format!("<schema>{}</schema>\n", schema));
436    }
437    out
438}
439
440fn render_markdown(spec: &PromptSpec<'_>) -> String {
441    let mut out = String::new();
442    if let Some(role) = &spec.role {
443        out.push_str(&format!("# Role\n{}\n\n", role));
444    }
445    if let Some(ctx) = spec.context {
446        out.push_str(&format!("# Context\n```json\n{}\n```\n\n", json_str(ctx)));
447    }
448    if !spec.examples.is_empty() {
449        out.push_str("# Examples\n");
450        for (i, ex) in spec.examples.iter().enumerate() {
451            out.push_str(&format!(
452                "{}. `{}`\n",
453                i + 1,
454                json_str(ex).replace('\n', " ")
455            ));
456        }
457        out.push('\n');
458    }
459    out.push_str(&format!("# Task\n{}\n", spec.task));
460    if let Some(schema) = &spec.schema {
461        out.push_str(&format!("\n# Schema\n{}\n", schema));
462    }
463    out
464}
465
466fn render_terse(spec: &PromptSpec<'_>) -> String {
467    let mut out = String::new();
468    if let Some(role) = &spec.role {
469        out.push_str(&format!("Role: {}\n", role));
470    }
471    if let Some(ctx) = spec.context {
472        out.push_str(&format!("Context: {}\n", json_str(ctx).replace('\n', " ")));
473    }
474    out.push_str(&format!("Task: {}\n", spec.task));
475    if let Some(schema) = &spec.schema {
476        out.push_str(&format!("Schema: {}\n", schema));
477    }
478    for (i, ex) in spec.examples.iter().enumerate() {
479        out.push_str(&format!(
480            "Example {}: {}\n",
481            i + 1,
482            json_str(ex).replace('\n', " ")
483        ));
484    }
485    out
486}
487
488impl Tool for RenderPromptXml {
489    fn name(&self) -> &str {
490        "render_prompt_xml"
491    }
492    fn tier(&self) -> Tier {
493        Tier::Zero
494    }
495    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
496        Box::pin(async move {
497            let v = args.positional(0)?;
498            let spec = extract_prompt_spec(v)?;
499            Ok(Value::Str(render_xml(&spec)))
500        })
501    }
502}
503
504impl Tool for RenderPromptMarkdown {
505    fn name(&self) -> &str {
506        "render_prompt_markdown"
507    }
508    fn tier(&self) -> Tier {
509        Tier::Zero
510    }
511    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
512        Box::pin(async move {
513            let v = args.positional(0)?;
514            let spec = extract_prompt_spec(v)?;
515            Ok(Value::Str(render_markdown(&spec)))
516        })
517    }
518}
519
520impl Tool for RenderPromptTerse {
521    fn name(&self) -> &str {
522        "render_prompt_terse"
523    }
524    fn tier(&self) -> Tier {
525        Tier::Zero
526    }
527    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
528        Box::pin(async move {
529            let v = args.positional(0)?;
530            let spec = extract_prompt_spec(v)?;
531            Ok(Value::Str(render_terse(&spec)))
532        })
533    }
534}
535
536pub struct ToJsonString;
537
538impl Tool for ToJsonString {
539    fn name(&self) -> &str {
540        "to_json_string"
541    }
542
543    fn tier(&self) -> Tier {
544        Tier::Zero
545    }
546
547    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
548        Box::pin(async move {
549            let v = args.positional(0)?.clone();
550            let json = v.to_json();
551            let s = serde_json::to_string_pretty(&json)
552                .map_err(|e| RuntimeError::ToolFailed(format!("to_json_string: {e}")))?;
553            Ok(Value::Str(s))
554        })
555    }
556}
557
558pub struct TextConcat;
559
560impl Tool for TextConcat {
561    fn name(&self) -> &str {
562        "text_concat"
563    }
564
565    fn tier(&self) -> Tier {
566        Tier::Zero
567    }
568
569    fn description(&self) -> Option<&str> {
570        Some("Flatten the text parts of a Message into a single string.")
571    }
572
573    fn input_schema(&self) -> serde_json::Value {
574        serde_json::json!({
575            "type": "object",
576            "properties": {"message": {"description": "A Message value from an llm call."}},
577            "required": ["message"]
578        })
579    }
580
581    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
582        Box::pin(async move {
583            let v = match args.named("message") {
584                Some(v) => v,
585                None => args.positional(0)?,
586            };
587            match v {
588                Value::Message(m) => Ok(Value::Str(m.text_concat())),
589                Value::Str(s) => Ok(Value::Str(s.clone())),
590                other => Err(RuntimeError::TypeMismatch {
591                    expected: "message or string".into(),
592                    actual: other.kind_name().into(),
593                }),
594            }
595        })
596    }
597}
598
599pub struct Concat;
600
601impl Tool for Concat {
602    fn name(&self) -> &str {
603        "concat"
604    }
605
606    fn tier(&self) -> Tier {
607        Tier::Zero
608    }
609
610    fn description(&self) -> Option<&str> {
611        Some("Concatenate two lists into a single new list.")
612    }
613
614    fn input_schema(&self) -> serde_json::Value {
615        serde_json::json!({
616            "type": "object",
617            "properties": {
618                "left": {"type": "array"},
619                "right": {"type": "array"}
620            },
621            "required": ["left", "right"]
622        })
623    }
624
625    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
626        Box::pin(async move {
627            let left = extract_list(&args, "left", 0)?;
628            let right = extract_list(&args, "right", 1)?;
629            let mut out = Vec::with_capacity(left.len() + right.len());
630            out.extend(left);
631            out.extend(right);
632            Ok(Value::List(out))
633        })
634    }
635}
636
637pub struct MessageUser;
638
639impl Tool for MessageUser {
640    fn name(&self) -> &str {
641        "message.user"
642    }
643    fn tier(&self) -> Tier {
644        Tier::Zero
645    }
646    fn description(&self) -> Option<&str> {
647        Some(
648            "Construct a user-role Message from a text string. Use with session.push to inject user instructions into the session history before an llm.call(context: session) call.",
649        )
650    }
651    fn input_schema(&self) -> serde_json::Value {
652        serde_json::json!({
653            "type": "object",
654            "properties": {"text": {"type": "string"}},
655            "required": ["text"]
656        })
657    }
658    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
659        Box::pin(async move {
660            let text = extract_string(&args, "text", 0)?;
661            let turn_id = ctx
662                .turn_id
663                .clone()
664                .unwrap_or_else(crate::event::TurnId::now);
665            Ok(Value::Message(crate::message::Message::user_text(
666                turn_id, text,
667            )))
668        })
669    }
670}
671
672pub struct MessageAssistant;
673
674impl Tool for MessageAssistant {
675    fn name(&self) -> &str {
676        "message.assistant"
677    }
678    fn tier(&self) -> Tier {
679        Tier::Zero
680    }
681    fn description(&self) -> Option<&str> {
682        Some("Construct an assistant-role Message from a text string.")
683    }
684    fn input_schema(&self) -> serde_json::Value {
685        serde_json::json!({
686            "type": "object",
687            "properties": {"text": {"type": "string"}},
688            "required": ["text"]
689        })
690    }
691    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
692        Box::pin(async move {
693            let text = extract_string(&args, "text", 0)?;
694            let turn_id = ctx
695                .turn_id
696                .clone()
697                .unwrap_or_else(crate::event::TurnId::now);
698            Ok(Value::Message(crate::message::Message::assistant_text(
699                turn_id, text,
700            )))
701        })
702    }
703}
704
705pub struct MessageSystem;
706
707impl Tool for MessageSystem {
708    fn name(&self) -> &str {
709        "message.system"
710    }
711    fn tier(&self) -> Tier {
712        Tier::Zero
713    }
714    fn description(&self) -> Option<&str> {
715        Some("Construct a system-role Message from a text string.")
716    }
717    fn input_schema(&self) -> serde_json::Value {
718        serde_json::json!({
719            "type": "object",
720            "properties": {"text": {"type": "string"}},
721            "required": ["text"]
722        })
723    }
724    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
725        Box::pin(async move {
726            let text = extract_string(&args, "text", 0)?;
727            let turn_id = ctx
728                .turn_id
729                .clone()
730                .unwrap_or_else(crate::event::TurnId::now);
731            Ok(Value::Message(crate::message::Message::system_text(
732                turn_id, text,
733            )))
734        })
735    }
736}
737
738pub struct MessageTool;
739
740impl Tool for MessageTool {
741    fn name(&self) -> &str {
742        "message.tool"
743    }
744    fn tier(&self) -> Tier {
745        Tier::Zero
746    }
747    fn description(&self) -> Option<&str> {
748        Some(
749            "Construct a tool-role Message from a text string. Rarely needed directly — dispatch_all already returns tool-role Messages.",
750        )
751    }
752    fn input_schema(&self) -> serde_json::Value {
753        serde_json::json!({
754            "type": "object",
755            "properties": {"text": {"type": "string"}},
756            "required": ["text"]
757        })
758    }
759    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
760        Box::pin(async move {
761            let text = extract_string(&args, "text", 0)?;
762            let turn_id = ctx
763                .turn_id
764                .clone()
765                .unwrap_or_else(crate::event::TurnId::now);
766            Ok(Value::Message(crate::message::Message {
767                turn_id,
768                role: crate::message::MessageRole::Tool,
769                parts: vec![crate::message::MessagePart::Text { text }],
770                origin: crate::message::MessageOrigin::User,
771            }))
772        })
773    }
774}
775
776pub struct ExtractToolUses;
777
778impl Tool for ExtractToolUses {
779    fn name(&self) -> &str {
780        "extract_tool_uses"
781    }
782
783    fn tier(&self) -> Tier {
784        Tier::Zero
785    }
786
787    fn description(&self) -> Option<&str> {
788        Some(
789            "Pull the tool_use parts out of an assistant Message. Returns a list of \
790             {id, name, input, intent?} structs suitable for dispatch_all.",
791        )
792    }
793
794    fn input_schema(&self) -> serde_json::Value {
795        serde_json::json!({
796            "type": "object",
797            "properties": {"message": {"description": "Assistant Message value."}},
798            "required": ["message"]
799        })
800    }
801
802    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
803        Box::pin(async move {
804            let v = match args.named("message") {
805                Some(v) => v,
806                None => args.positional(0)?,
807            };
808            let m = match v {
809                Value::Message(m) => m,
810                Value::Str(_) => return Ok(Value::List(Vec::new())),
811                other => {
812                    return Err(RuntimeError::TypeMismatch {
813                        expected: "message or string".into(),
814                        actual: other.kind_name().into(),
815                    });
816                }
817            };
818            let mut out = Vec::new();
819            for part in &m.parts {
820                if let crate::message::MessagePart::ToolUse {
821                    id,
822                    name,
823                    input,
824                    intent,
825                } = part
826                {
827                    let mut fields = vec![
828                        ("id".into(), Value::Str(id.clone())),
829                        ("name".into(), Value::Str(name.clone())),
830                        ("input".into(), Value::from_json(input.clone())),
831                    ];
832                    if let Some(intent) = intent {
833                        fields.push(("intent".into(), Value::Str(intent.as_str().into())));
834                    }
835                    out.push(Value::Struct(fields));
836                }
837            }
838            Ok(Value::List(out))
839        })
840    }
841}
842
843pub struct DispatchAll;
844
845impl Tool for DispatchAll {
846    fn name(&self) -> &str {
847        "dispatch_all"
848    }
849
850    fn tier(&self) -> Tier {
851        Tier::Zero
852    }
853
854    fn description(&self) -> Option<&str> {
855        Some(
856            "Dispatch each tool_use in the list against the current tool registry and \
857             return a list of tool_result Message values.",
858        )
859    }
860
861    fn input_schema(&self) -> serde_json::Value {
862        serde_json::json!({
863            "type": "object",
864            "properties": {"tool_uses": {"type": "array"}},
865            "required": ["tool_uses"]
866        })
867    }
868
869    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
870        Box::pin(async move {
871            let uses = extract_list(&args, "tool_uses", 0)?;
872            let Some(registry) = ctx.registry.as_ref() else {
873                return Err(RuntimeError::ToolFailed(
874                    "dispatch_all: no tool registry available on ctx".into(),
875                ));
876            };
877            let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
878            let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
879            run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
880            run_serial(serial_batch, ctx, &mut out_slots).await;
881            let out: Vec<Value> = out_slots.into_iter().flatten().collect();
882            Ok(Value::List(out))
883        })
884    }
885}
886
887enum PreparedEntry {
888    Ready {
889        index: usize,
890        id: String,
891        name: String,
892        tool: std::sync::Arc<dyn Tool>,
893        call_args: ToolArgs,
894        call_intent: Option<crate::message::ToolCallIntent>,
895    },
896    Failed {
897        index: usize,
898        msg: crate::message::Message,
899    },
900}
901
902fn prepare_dispatch(
903    uses: &[Value],
904    registry: &crate::tool::ToolRegistry,
905    ctx: &ToolCtx,
906) -> Result<Vec<PreparedEntry>, RuntimeError> {
907    let parsed = uses
908        .iter()
909        .enumerate()
910        .map(|(index, entry)| {
911            let Value::Struct(fields) = entry else {
912                return Err(RuntimeError::TypeMismatch {
913                    expected: "struct {id, name, input}".into(),
914                    actual: entry.kind_name().into(),
915                });
916            };
917            let get = |key: &str| {
918                fields
919                    .iter()
920                    .find(|(name, _)| name == key)
921                    .map(|(_, value)| value.clone())
922            };
923            let id = match get("id") {
924                Some(Value::Str(id)) => id,
925                _ => {
926                    return Err(RuntimeError::ToolFailed(
927                        "dispatch_all: tool_use missing `id` string".into(),
928                    ));
929                }
930            };
931            let name = match get("name") {
932                Some(Value::Str(name)) => name,
933                _ => {
934                    return Err(RuntimeError::ToolFailed(
935                        "dispatch_all: tool_use missing `name` string".into(),
936                    ));
937                }
938            };
939            let call_intent = match get("intent") {
940                Some(Value::Str(value)) => crate::message::ToolCallIntent::new(value),
941                _ => None,
942            };
943            Ok((
944                index,
945                id,
946                name,
947                get("input").unwrap_or(Value::Unit),
948                call_intent,
949            ))
950        })
951        .collect::<Result<Vec<_>, RuntimeError>>()?;
952
953    let mut prepared = Vec::with_capacity(parsed.len());
954    for (index, id, name, input, mut call_intent) in parsed {
955        if ctx
956            .model_tool_exposures
957            .as_ref()
958            .is_some_and(|exposures| !exposures.claim(ctx.flow_run_id.as_ref(), &id, &name))
959        {
960            emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
961            prepared.push(PreparedEntry::Failed {
962                index,
963                msg: build_error_result(
964                    ctx,
965                    &id,
966                    &format!(
967                        "dispatch_all: tool `{name}` was not exposed by the LLM request that produced call `{id}`"
968                    ),
969                ),
970            });
971            continue;
972        }
973        let Some(tool) = registry.get(&name) else {
974            emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
975            prepared.push(PreparedEntry::Failed {
976                index,
977                msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
978            });
979            continue;
980        };
981        let raw_schema = tool.input_schema();
982        let named = match &input {
983            Value::Struct(fields) => {
984                let mut fields = fields.clone();
985                if !crate::tool::tool_schema_uses_call_intent_field(&raw_schema)
986                    && let Some(index) = fields
987                        .iter()
988                        .position(|(name, _)| name == crate::message::TOOL_CALL_INTENT_FIELD)
989                {
990                    let (_, value) = fields.remove(index);
991                    if call_intent.is_none()
992                        && let Value::Str(value) = value
993                    {
994                        call_intent = crate::message::ToolCallIntent::new(value);
995                    }
996                }
997                fields
998            }
999            Value::Unit => Vec::new(),
1000            other => {
1001                emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
1002                prepared.push(PreparedEntry::Failed {
1003                    index,
1004                    msg: build_error_result(
1005                        ctx,
1006                        &id,
1007                        &format!(
1008                            "tool `{name}` expected struct or unit input, got {}",
1009                            other.kind_name()
1010                        ),
1011                    ),
1012                });
1013                continue;
1014            }
1015        };
1016        emit_tool_node(
1017            ctx,
1018            &id,
1019            &name,
1020            &Value::Struct(named.clone()),
1021            call_intent.as_ref(),
1022        );
1023        let missing = missing_required_fields(&raw_schema, &named);
1024        if !missing.is_empty() {
1025            let content = format!(
1026                "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
1027                missing.join(", "),
1028                missing
1029                    .iter()
1030                    .map(|f| format!("\"{f}\":\"...\""))
1031                    .collect::<Vec<_>>()
1032                    .join(", ")
1033            );
1034            prepared.push(PreparedEntry::Failed {
1035                index,
1036                msg: build_error_result(ctx, &id, &content),
1037            });
1038            continue;
1039        }
1040        prepared.push(PreparedEntry::Ready {
1041            index,
1042            id,
1043            name,
1044            tool,
1045            call_args: ToolArgs {
1046                positional: Vec::new(),
1047                named,
1048            },
1049            call_intent,
1050        });
1051    }
1052    Ok(prepared)
1053}
1054
1055struct Approved {
1056    index: usize,
1057    id: String,
1058    name: String,
1059    tool: std::sync::Arc<dyn Tool>,
1060    call_args: ToolArgs,
1061    call_ctx: ToolCtx,
1062}
1063
1064async fn partition_and_gate(
1065    prepared: Vec<PreparedEntry>,
1066    ctx: &ToolCtx,
1067) -> (Vec<Approved>, Vec<Approved>, Vec<Option<Value>>) {
1068    let total = prepared.len();
1069    let mut out_slots: Vec<Option<Value>> = vec![None; total];
1070    struct ReadyEntry {
1071        index: usize,
1072        id: String,
1073        name: String,
1074        tool: std::sync::Arc<dyn Tool>,
1075        call_args: ToolArgs,
1076        /// Classified once, before the gate. Re-deriving it after approval would
1077        /// let a level that depends on ctx or args drift between the verdict and
1078        /// the auto/serial routing, so a call could be gated as one level and run
1079        /// as another.
1080        level: crate::tool::ApprovalLevel,
1081        invocation_ctx: ToolCtx,
1082    }
1083    let mut ready: Vec<ReadyEntry> = Vec::new();
1084    for entry in prepared {
1085        match entry {
1086            PreparedEntry::Failed { index, msg } => {
1087                out_slots[index] = Some(Value::Message(emit_tool_result(ctx, &msg)));
1088            }
1089            PreparedEntry::Ready {
1090                index,
1091                id,
1092                name,
1093                tool,
1094                call_args,
1095                call_intent,
1096            } => {
1097                let invocation_ctx = ctx
1098                    .clone()
1099                    .for_tool_invocation(tool.tier())
1100                    .with_call_intent(call_intent);
1101                let level = tool.approval_level(&call_args, &invocation_ctx);
1102                ready.push(ReadyEntry {
1103                    index,
1104                    id,
1105                    name,
1106                    tool,
1107                    call_args,
1108                    level,
1109                    invocation_ctx,
1110                });
1111            }
1112        }
1113    }
1114    // Parallel gating exposes every pending ordinary request to the UI before
1115    // execution begins; permission-control calls authenticate without queuing.
1116    let gates = ready.iter().map(|r| {
1117        authorize_tool_invocation(
1118            &r.invocation_ctx,
1119            &r.id,
1120            &r.name,
1121            &r.call_args,
1122            r.tool.as_ref(),
1123        )
1124    });
1125    let outcomes = futures::future::join_all(gates).await;
1126    let mut auto_batch = Vec::new();
1127    let mut serial_batch = Vec::new();
1128    for (r, outcome) in ready.into_iter().zip(outcomes) {
1129        match outcome {
1130            Ok(call_ctx) => {
1131                let is_control =
1132                    r.tool.invocation_plane() == crate::tool::InvocationPlane::PermissionControl;
1133                let a = Approved {
1134                    index: r.index,
1135                    id: r.id,
1136                    name: r.name.clone(),
1137                    tool: r.tool,
1138                    call_args: r.call_args,
1139                    call_ctx,
1140                };
1141                if r.level == crate::tool::ApprovalLevel::Auto && !is_control {
1142                    auto_batch.push(a);
1143                } else {
1144                    serial_batch.push(a);
1145                }
1146            }
1147            Err(reason) => {
1148                let msg =
1149                    build_error_result(ctx, &r.id, &format!("tool `{}` denied: {reason}", r.name));
1150                out_slots[r.index] = Some(Value::Message(emit_tool_result(ctx, &msg)));
1151            }
1152        }
1153    }
1154    (auto_batch, serial_batch, out_slots)
1155}
1156
1157async fn run_auto_parallel(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1158    use futures::StreamExt;
1159
1160    let mut pending = futures::stream::FuturesUnordered::new();
1161    for a in batch {
1162        pending.push(async move {
1163            let result = a.tool.call(a.call_args, &a.call_ctx).await;
1164            (a.index, a.id, a.name, result)
1165        });
1166    }
1167    while let Some((index, id, name, result)) = pending.next().await {
1168        out_slots[index] = Some(finish_dispatch(ctx, &id, &name, result));
1169    }
1170}
1171
1172async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1173    for a in batch {
1174        let result = a.tool.call(a.call_args, &a.call_ctx).await;
1175        out_slots[a.index] = Some(finish_dispatch(ctx, &a.id, &a.name, result));
1176    }
1177}
1178
1179fn finish_dispatch(ctx: &ToolCtx, id: &str, name: &str, result: ToolResult) -> Value {
1180    let (content, is_error) = match &result {
1181        Ok(v) => (render_tool_result_text(v), false),
1182        Err(e) => (format!("{e}"), true),
1183    };
1184    if let Ok(v) = &result {
1185        emit_diff_preview_if_relevant(ctx, name, v);
1186    }
1187    let msg = crate::message::Message {
1188        role: crate::message::MessageRole::Tool,
1189        parts: vec![crate::message::MessagePart::ToolResult {
1190            tool_use_id: id.to_string(),
1191            content,
1192            is_error,
1193        }],
1194        turn_id: ctx
1195            .turn_id
1196            .clone()
1197            .unwrap_or_else(crate::event::TurnId::now),
1198        origin: crate::message::MessageOrigin::User,
1199    };
1200    Value::Message(emit_tool_result(ctx, &msg))
1201}
1202
1203type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
1204
1205fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
1206    let Some(sink) = ctx.events.as_ref() else {
1207        return;
1208    };
1209    let data: Option<DiffPreviewData> = match tool_name {
1210        "fs.edit" => {
1211            let path = value_struct_string(value, "summary").and_then(|s| {
1212                s.strip_prefix("[fs.edit(")
1213                    .and_then(|s| s.split(':').next())
1214                    .map(|s| s.trim_end_matches(')').to_string())
1215            });
1216            let diff = value_struct_string(value, "diff");
1217            diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
1218        }
1219        "fs.write" => {
1220            let path = value_struct_string(value, "path").unwrap_or_default();
1221            let diff = value_struct_string(value, "diff");
1222            diff.map(|d| (path, None, None, Some(d)))
1223        }
1224        "git.diff" => {
1225            let Some(diff) = value_struct_string(value, "diff") else {
1226                return;
1227            };
1228            Some(("git diff".into(), None, None, Some(diff)))
1229        }
1230        "git.show" => {
1231            let sha = value_struct_string(value, "sha").unwrap_or_default();
1232            let Some(diff) = value_struct_string(value, "diff") else {
1233                return;
1234            };
1235            Some((format!("git show {sha}"), None, None, Some(diff)))
1236        }
1237        "git.log" => {
1238            let Some(diff) = value_struct_string(value, "diff") else {
1239                return;
1240            };
1241            Some(("git log HEAD".into(), None, None, Some(diff)))
1242        }
1243        _ => None,
1244    };
1245    if let Some((title, old_content, new_content, unified_diff)) = data {
1246        sink.emit(crate::event::Event::DiffPreview {
1247            turn_id: ctx.turn_id.clone(),
1248            flow_run_id: ctx.flow_run_id.clone(),
1249            title,
1250            old_content,
1251            new_content,
1252            unified_diff,
1253        });
1254    }
1255}
1256
1257fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1258    if let Value::Struct(fields) = value {
1259        fields
1260            .iter()
1261            .find(|(k, _)| k == field)
1262            .and_then(|(_, v)| match v {
1263                Value::Str(s) => Some(s.clone()),
1264                _ => None,
1265            })
1266    } else {
1267        None
1268    }
1269}
1270
1271fn emit_tool_node(
1272    ctx: &ToolCtx,
1273    id: &str,
1274    name: &str,
1275    input: &Value,
1276    call_intent: Option<&crate::message::ToolCallIntent>,
1277) {
1278    let (Some(run_id), Some(parent_node)) = (&ctx.flow_run_id, &ctx.current_node_id) else {
1279        return;
1280    };
1281    let args_preview = format!("{:?}", input)
1282        .chars()
1283        .take(4000)
1284        .collect::<String>();
1285    if let Some(sink) = &ctx.events {
1286        sink.emit(crate::event::Event::ToolNode {
1287            run_id: run_id.clone(),
1288            parent_node_id: parent_node.clone(),
1289            tool_use_id: id.to_string(),
1290            tool_name: name.to_string(),
1291            args_preview: args_preview.clone(),
1292            call_intent: call_intent.cloned(),
1293        });
1294    }
1295    if let Some(tx) = &ctx.stream_tx {
1296        let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1297            run_id: run_id.0.to_string(),
1298            parent_node_id: parent_node.clone(),
1299            tool_use_id: id.to_string(),
1300            tool: name.to_string(),
1301            args_preview,
1302            call_intent: call_intent.cloned(),
1303        });
1304    }
1305}
1306
1307fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1308    crate::message::Message {
1309        role: crate::message::MessageRole::Tool,
1310        parts: vec![crate::message::MessagePart::ToolResult {
1311            tool_use_id: tool_use_id.to_string(),
1312            content: content.to_string(),
1313            is_error: true,
1314        }],
1315        turn_id: ctx
1316            .turn_id
1317            .clone()
1318            .unwrap_or_else(crate::event::TurnId::now),
1319        origin: crate::message::MessageOrigin::User,
1320    }
1321}
1322
1323fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1324    let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1325        return Vec::new();
1326    };
1327    let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1328    required
1329        .iter()
1330        .filter_map(|v| v.as_str())
1331        .filter(|k| !have.contains(k))
1332        .map(String::from)
1333        .collect()
1334}
1335
1336fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) -> crate::message::Message {
1337    let excerpt = crate::tools::tool_output::maybe_truncate_tool_message_with_budget(
1338        msg,
1339        ctx.output_store.as_deref(),
1340        ctx.tool_output_budget,
1341    );
1342    emit_tool_result_metrics(ctx, msg, &excerpt);
1343    if let Some(tx) = &ctx.stream_tx {
1344        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1345            flow_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1346            message: excerpt.clone(),
1347        });
1348    } else if let Some(sink) = &ctx.events {
1349        sink.emit(crate::event::Event::ToolResultMsg {
1350            turn_id: excerpt.turn_id.clone(),
1351            flow_run_id: ctx.flow_run_id.clone(),
1352            message: excerpt.clone(),
1353        });
1354    }
1355    excerpt
1356}
1357
1358fn emit_tool_result_metrics(
1359    ctx: &ToolCtx,
1360    raw: &crate::message::Message,
1361    excerpt: &crate::message::Message,
1362) {
1363    let Some(sink) = &ctx.events else {
1364        return;
1365    };
1366    for part in &raw.parts {
1367        let crate::message::MessagePart::ToolResult {
1368            tool_use_id,
1369            content: raw_content,
1370            ..
1371        } = part
1372        else {
1373            continue;
1374        };
1375        let excerpt_content = excerpt.parts.iter().find_map(|part| match part {
1376            crate::message::MessagePart::ToolResult {
1377                tool_use_id: excerpt_id,
1378                content,
1379                ..
1380            } if excerpt_id == tool_use_id => Some(content.as_str()),
1381            _ => None,
1382        });
1383        sink.emit(crate::event::Event::ToolResultMetrics {
1384            turn_id: raw.turn_id.clone(),
1385            flow_run_id: ctx.flow_run_id.clone(),
1386            tool_use_id: tool_use_id.clone(),
1387            raw_bytes: raw_content.len() as u64,
1388            excerpt_bytes: excerpt_content.map_or(0, |content| content.len() as u64),
1389            truncated: excerpt_content != Some(raw_content.as_str()),
1390        });
1391    }
1392}
1393
1394fn render_tool_result_text(v: &Value) -> String {
1395    match v {
1396        Value::Str(s) => s.clone(),
1397        Value::Message(m) => m.text_concat(),
1398        other => other.to_json().to_string(),
1399    }
1400}
1401
1402fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1403    let value = match args.named(name) {
1404        Some(v) => v,
1405        None => args.positional(pos)?,
1406    };
1407    match value {
1408        Value::List(items) => Ok(items.clone()),
1409        other => Err(RuntimeError::TypeMismatch {
1410            expected: "list".into(),
1411            actual: other.kind_name().into(),
1412        }),
1413    }
1414}
1415
1416pub struct ComposeEmailPreview;
1417
1418impl Tool for ComposeEmailPreview {
1419    fn name(&self) -> &str {
1420        "compose_email_preview"
1421    }
1422
1423    fn tier(&self) -> Tier {
1424        Tier::Zero
1425    }
1426
1427    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1428        Box::pin(async move {
1429            let subject = extract_string(&args, "subject", 0)?;
1430            let body = extract_string(&args, "body", 1)?;
1431            let to = extract_string_list(&args, "to", 2)?;
1432            Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1433        })
1434    }
1435}
1436
1437pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1438    format!(
1439        "To: {}
1440Subject: {subject}
1441---
1442{body}",
1443        to.join(", ")
1444    )
1445}
1446
1447fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1448    let value = match args.named(name) {
1449        Some(v) => v,
1450        None => args.positional(pos)?,
1451    };
1452    match value {
1453        Value::Str(s) => Ok(s.clone()),
1454        other => Err(RuntimeError::TypeMismatch {
1455            expected: "string".into(),
1456            actual: other.kind_name().into(),
1457        }),
1458    }
1459}
1460
1461fn extract_string_list(
1462    args: &ToolArgs,
1463    name: &str,
1464    pos: usize,
1465) -> Result<Vec<String>, RuntimeError> {
1466    let value = match args.named(name) {
1467        Some(v) => v,
1468        None => args.positional(pos)?,
1469    };
1470    match value {
1471        Value::List(items) => items
1472            .iter()
1473            .map(|v| match v {
1474                Value::Str(s) => Ok(s.clone()),
1475                other => Err(RuntimeError::TypeMismatch {
1476                    expected: "list of string".into(),
1477                    actual: other.kind_name().into(),
1478                }),
1479            })
1480            .collect(),
1481        other => Err(RuntimeError::TypeMismatch {
1482            expected: "list".into(),
1483            actual: other.kind_name().into(),
1484        }),
1485    }
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490    use super::*;
1491
1492    fn authorized_ctx(registry: std::sync::Arc<crate::tool::ToolRegistry>) -> ToolCtx {
1493        let trust = crate::trust::TrustConfig {
1494            mode: crate::trust::TrustMode::Reckless,
1495            ..crate::trust::TrustConfig::default()
1496        };
1497        let flows = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
1498        let run_id = crate::event::FlowRunId::now();
1499        let identity = flows
1500            .register_root(
1501                "stdlib-test".into(),
1502                run_id.clone(),
1503                crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
1504            )
1505            .unwrap();
1506        let mut ctx = ToolCtx::new()
1507            .with_registry(registry)
1508            .with_flow_registry(std::sync::Arc::clone(&flows))
1509            .with_permission_broker(crate::permission::PermissionBroker::shared(flows))
1510            .with_approval(std::sync::Arc::new(crate::session::ApprovalRegistry::new()))
1511            .with_trust(trust)
1512            .with_anchors(None, Some(run_id), None);
1513        ctx.flow_identity = Some(identity);
1514        ctx
1515    }
1516
1517    #[test]
1518    fn shell_quote_wraps_and_escapes() {
1519        assert_eq!(shell_quote("hello"), "'hello'");
1520        assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1521        assert_eq!(shell_quote(""), "''");
1522        assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1523    }
1524
1525    struct PermitProbeTool;
1526
1527    impl Tool for PermitProbeTool {
1528        fn name(&self) -> &str {
1529            "permit.probe"
1530        }
1531
1532        fn tier(&self) -> Tier {
1533            Tier::Zero
1534        }
1535
1536        fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1537            Box::pin(async move {
1538                let authorized = ctx
1539                    .invocation_authorization()
1540                    .is_some_and(|permit| permit.is_for_call("probe_id", "permit.probe"));
1541                Ok(Value::Bool(authorized))
1542            })
1543        }
1544    }
1545
1546    struct ControlProbeTool;
1547
1548    impl Tool for ControlProbeTool {
1549        fn name(&self) -> &str {
1550            "permission.probe"
1551        }
1552
1553        fn tier(&self) -> Tier {
1554            Tier::Zero
1555        }
1556
1557        fn invocation_plane(&self) -> crate::tool::InvocationPlane {
1558            crate::tool::InvocationPlane::PermissionControl
1559        }
1560
1561        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1562            Box::pin(async { Ok(Value::Unit) })
1563        }
1564    }
1565
1566    struct UnexposedProbeTool;
1567
1568    impl Tool for UnexposedProbeTool {
1569        fn name(&self) -> &str {
1570            "hidden.probe"
1571        }
1572
1573        fn tier(&self) -> Tier {
1574            Tier::Zero
1575        }
1576
1577        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1578            Box::pin(async { panic!("unexposed tool must not execute") })
1579        }
1580    }
1581
1582    #[tokio::test]
1583    async fn dispatch_all_rejects_calls_outside_their_request_exposure() {
1584        let registry = crate::tool::ToolRegistry::new();
1585        registry.register(std::sync::Arc::new(UnexposedProbeTool));
1586        let mut ctx = authorized_ctx(std::sync::Arc::new(registry));
1587        let response = crate::message::Message {
1588            role: crate::message::MessageRole::Assistant,
1589            parts: vec![crate::message::MessagePart::ToolUse {
1590                id: "hidden-call".into(),
1591                name: "hidden.probe".into(),
1592                input: serde_json::json!({}),
1593                intent: None,
1594            }],
1595            turn_id: crate::event::TurnId::now(),
1596            origin: crate::message::MessageOrigin::User,
1597        };
1598        let exposures = crate::tool::ToolExposureRegistry::default();
1599        exposures.register_response(ctx.flow_run_id.as_ref(), &response, ["allowed.probe"]);
1600        ctx.model_tool_exposures = Some(exposures);
1601        let uses = Value::List(vec![Value::Struct(vec![
1602            ("id".into(), Value::Str("hidden-call".into())),
1603            ("name".into(), Value::Str("hidden.probe".into())),
1604            ("input".into(), Value::Struct(Vec::new())),
1605        ])]);
1606
1607        let Value::List(results) = DispatchAll
1608            .call(
1609                ToolArgs {
1610                    positional: vec![uses],
1611                    named: Vec::new(),
1612                },
1613                &ctx,
1614            )
1615            .await
1616            .unwrap()
1617        else {
1618            panic!("dispatch result list")
1619        };
1620        assert!(matches!(
1621            &results[0],
1622            Value::Message(crate::message::Message { parts, .. })
1623                if matches!(
1624                    &parts[..],
1625                    [crate::message::MessagePart::ToolResult { content, is_error: true, .. }]
1626                        if content.contains("was not exposed")
1627                )
1628        ));
1629    }
1630
1631    #[tokio::test]
1632    async fn dispatch_all_routes_permission_control_calls_to_serial_without_recursive_requests() {
1633        let registry = crate::tool::ToolRegistry::new();
1634        registry.register(std::sync::Arc::new(ControlProbeTool));
1635        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1636        let before = ctx.permission_broker.as_ref().unwrap().list().len();
1637        let uses = vec![
1638            Value::Struct(vec![
1639                ("id".into(), Value::Str("control-1".into())),
1640                ("name".into(), Value::Str("permission.probe".into())),
1641                ("input".into(), Value::Struct(Vec::new())),
1642            ]),
1643            Value::Struct(vec![
1644                ("id".into(), Value::Str("control-2".into())),
1645                ("name".into(), Value::Str("permission.probe".into())),
1646                ("input".into(), Value::Struct(Vec::new())),
1647            ]),
1648        ];
1649        let prepared = prepare_dispatch(&uses, ctx.registry.as_ref().unwrap(), &ctx).unwrap();
1650        let (parallel, serial, _) = partition_and_gate(prepared, &ctx).await;
1651
1652        assert!(parallel.is_empty());
1653        assert_eq!(serial.len(), 2);
1654        assert_eq!(ctx.permission_broker.as_ref().unwrap().list().len(), before);
1655    }
1656
1657    #[tokio::test]
1658    async fn auto_parallel_call_receives_its_own_authorization() {
1659        let registry = crate::tool::ToolRegistry::new();
1660        registry.register(std::sync::Arc::new(PermitProbeTool));
1661        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1662        let uses = Value::List(vec![Value::Struct(vec![
1663            ("id".into(), Value::Str("probe_id".into())),
1664            ("name".into(), Value::Str("permit.probe".into())),
1665            ("input".into(), Value::Struct(Vec::new())),
1666        ])]);
1667
1668        let Value::List(results) = DispatchAll
1669            .call(
1670                ToolArgs {
1671                    positional: vec![uses],
1672                    named: Vec::new(),
1673                },
1674                &ctx,
1675            )
1676            .await
1677            .unwrap()
1678        else {
1679            panic!("dispatch result list");
1680        };
1681        let Value::Message(message) = &results[0] else {
1682            panic!("tool result message");
1683        };
1684        assert!(message.parts.iter().any(|part| matches!(
1685            part,
1686            crate::message::MessagePart::ToolResult { content, is_error: false, .. }
1687                if content == "true"
1688        )));
1689    }
1690
1691    #[tokio::test]
1692    async fn dispatch_all_strips_wire_intent_and_isolates_parallel_contexts() {
1693        let registry = crate::tool::ToolRegistry::new();
1694        registry.register(std::sync::Arc::new(PermitProbeTool));
1695        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1696        let uses = vec![
1697            Value::Struct(vec![
1698                ("id".into(), Value::Str("first".into())),
1699                ("name".into(), Value::Str("permit.probe".into())),
1700                (
1701                    "input".into(),
1702                    Value::Struct(vec![(
1703                        crate::message::TOOL_CALL_INTENT_FIELD.into(),
1704                        Value::Str("Inspect first target".into()),
1705                    )]),
1706                ),
1707            ]),
1708            Value::Struct(vec![
1709                ("id".into(), Value::Str("second".into())),
1710                ("name".into(), Value::Str("permit.probe".into())),
1711                (
1712                    "input".into(),
1713                    Value::Struct(vec![(
1714                        crate::message::TOOL_CALL_INTENT_FIELD.into(),
1715                        Value::Str("Inspect second target".into()),
1716                    )]),
1717                ),
1718            ]),
1719        ];
1720        let prepared = prepare_dispatch(&uses, ctx.registry.as_ref().unwrap(), &ctx).unwrap();
1721        let (parallel, serial, _) = partition_and_gate(prepared, &ctx).await;
1722
1723        assert!(serial.is_empty());
1724        assert_eq!(parallel.len(), 2);
1725        assert!(parallel.iter().all(|call| {
1726            call.call_args
1727                .named(crate::message::TOOL_CALL_INTENT_FIELD)
1728                .is_none()
1729        }));
1730        assert_eq!(
1731            parallel[0]
1732                .call_ctx
1733                .call_intent
1734                .as_ref()
1735                .map(|intent| intent.as_str()),
1736            Some("Inspect first target")
1737        );
1738        assert_eq!(
1739            parallel[1]
1740                .call_ctx
1741                .call_intent
1742                .as_ref()
1743                .map(|intent| intent.as_str()),
1744            Some("Inspect second target")
1745        );
1746    }
1747
1748    struct ControlledTool {
1749        name: &'static str,
1750        release: std::sync::Arc<tokio::sync::Semaphore>,
1751    }
1752
1753    impl Tool for ControlledTool {
1754        fn name(&self) -> &str {
1755            self.name
1756        }
1757
1758        fn tier(&self) -> Tier {
1759            Tier::Zero
1760        }
1761
1762        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1763            Box::pin(async move {
1764                let _permit = self.release.acquire().await.unwrap();
1765                Ok(Value::Str(self.name.to_string()))
1766            })
1767        }
1768    }
1769
1770    #[tokio::test]
1771    async fn dispatch_all_emits_each_scoped_result_as_its_tool_finishes() {
1772        let fast_release = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
1773        let slow_release = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
1774        let registry = crate::tool::ToolRegistry::new();
1775        registry.register(std::sync::Arc::new(ControlledTool {
1776            name: "fast",
1777            release: fast_release.clone(),
1778        }));
1779        registry.register(std::sync::Arc::new(ControlledTool {
1780            name: "slow",
1781            release: slow_release.clone(),
1782        }));
1783        let run_id = crate::event::FlowRunId::now();
1784        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(32);
1785        let ctx = authorized_ctx(std::sync::Arc::new(registry))
1786            .with_anchors(None, Some(run_id.clone()), None)
1787            .with_current_node(Some("dispatch_all".into()))
1788            .with_stream_tx(stream_tx);
1789        let uses = Value::List(vec![
1790            Value::Struct(vec![
1791                ("id".into(), Value::Str("slow_id".into())),
1792                ("name".into(), Value::Str("slow".into())),
1793                ("input".into(), Value::Struct(Vec::new())),
1794            ]),
1795            Value::Struct(vec![
1796                ("id".into(), Value::Str("fast_id".into())),
1797                ("name".into(), Value::Str("fast".into())),
1798                ("input".into(), Value::Struct(Vec::new())),
1799            ]),
1800        ]);
1801        let task = tokio::spawn(async move {
1802            DispatchAll
1803                .call(
1804                    ToolArgs {
1805                        positional: vec![uses],
1806                        named: Vec::new(),
1807                    },
1808                    &ctx,
1809                )
1810                .await
1811                .unwrap()
1812        });
1813
1814        fast_release.add_permits(1);
1815        let fast_result = tokio::time::timeout(std::time::Duration::from_secs(1), async {
1816            loop {
1817                if let crate::stream::StreamFrame::ToolResultMsg {
1818                    flow_run_id,
1819                    message,
1820                } = stream_rx.recv().await.unwrap()
1821                    && message.parts.iter().any(|part| {
1822                        matches!(
1823                            part,
1824                            crate::message::MessagePart::ToolResult { tool_use_id, .. }
1825                                if tool_use_id == "fast_id"
1826                        )
1827                    })
1828                {
1829                    break flow_run_id;
1830                }
1831            }
1832        })
1833        .await
1834        .expect("fast result before slow release");
1835        assert_eq!(fast_result.as_deref(), Some(run_id.0.to_string().as_str()));
1836        assert!(!task.is_finished());
1837
1838        slow_release.add_permits(1);
1839        let Value::List(results) = task.await.unwrap() else {
1840            panic!("dispatch result list");
1841        };
1842        let ids: Vec<&str> = results
1843            .iter()
1844            .map(|value| match value {
1845                Value::Message(message) => match &message.parts[0] {
1846                    crate::message::MessagePart::ToolResult { tool_use_id, .. } => {
1847                        tool_use_id.as_str()
1848                    }
1849                    _ => panic!("tool result part"),
1850                },
1851                _ => panic!("tool result message"),
1852            })
1853            .collect();
1854        assert_eq!(ids, vec!["slow_id", "fast_id"]);
1855    }
1856
1857    struct TextTool {
1858        name: &'static str,
1859        output: String,
1860    }
1861
1862    impl Tool for TextTool {
1863        fn name(&self) -> &str {
1864            self.name
1865        }
1866
1867        fn tier(&self) -> Tier {
1868            Tier::Zero
1869        }
1870
1871        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1872            let output = self.output.clone();
1873            Box::pin(async move { Ok(Value::Str(output)) })
1874        }
1875    }
1876
1877    #[tokio::test]
1878    async fn dispatch_all_preserves_fs_read_pagination_for_full_utf8_reassembly() {
1879        let dir = tempfile::tempdir().unwrap();
1880        let path = dir.path().join("large.txt");
1881        let expected = "界".repeat(349_525) + "a";
1882        assert_eq!(expected.len(), 1_048_576);
1883        tokio::fs::write(&path, &expected).await.unwrap();
1884
1885        let registry = crate::tool::ToolRegistry::new();
1886        crate::tools::register_tier_zero(&registry);
1887        let ctx = authorized_ctx(std::sync::Arc::new(registry))
1888            .with_session_dir(dir.path().to_path_buf())
1889            .with_workspace(crate::git_workspace::WorkspaceBinding {
1890                workspace_id: "stdlib-test".into(),
1891                repository_root: dir.path().to_path_buf(),
1892                path: dir.path().to_path_buf(),
1893                branch: None,
1894            });
1895        let uses = Value::List(vec![Value::Struct(vec![
1896            ("id".into(), Value::Str("read_id".into())),
1897            ("name".into(), Value::Str("fs.read".into())),
1898            (
1899                "input".into(),
1900                Value::Struct(vec![("path".into(), Value::Path(path))]),
1901            ),
1902        ])]);
1903        let Value::List(results) = DispatchAll
1904            .call(
1905                ToolArgs {
1906                    positional: vec![uses],
1907                    named: Vec::new(),
1908                },
1909                &ctx,
1910            )
1911            .await
1912            .unwrap()
1913        else {
1914            panic!("dispatch result list");
1915        };
1916        let Value::Message(message) = &results[0] else {
1917            panic!("tool result message");
1918        };
1919        let crate::message::MessagePart::ToolResult { content, .. } = &message.parts[0] else {
1920            panic!("tool result part");
1921        };
1922        let envelope: serde_json::Value = serde_json::from_str(content).unwrap();
1923        let output_id = envelope["output_id"].as_str().unwrap();
1924        let mut reconstructed = envelope["content"].as_str().unwrap().to_string();
1925        let mut offset = envelope["next"]["offset"].as_u64().unwrap() as usize;
1926        while envelope["next"]["has_more"].as_bool().unwrap() || offset < expected.len() {
1927            let page = ctx
1928                .output_store
1929                .as_ref()
1930                .unwrap()
1931                .read_bytes(output_id, offset, usize::MAX, ctx.tool_output_budget)
1932                .unwrap();
1933            reconstructed.push_str(&page.content);
1934            if !page.has_more {
1935                break;
1936            }
1937            assert!(page.next_offset > offset);
1938            offset = page.next_offset;
1939        }
1940        assert_eq!(reconstructed.len(), 1_048_576);
1941        assert_eq!(reconstructed, expected);
1942    }
1943
1944    #[tokio::test]
1945    async fn dispatch_all_returns_the_same_truncated_message_it_emits() {
1946        let registry = crate::tool::ToolRegistry::new();
1947        registry.register(std::sync::Arc::new(TextTool {
1948            name: "text",
1949            output: "0123456789".repeat(20),
1950        }));
1951        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
1952        let events = crate::event::EventSink::new();
1953        let mut ctx = ToolCtx::new()
1954            .with_registry(std::sync::Arc::new(registry))
1955            .with_stream_tx(stream_tx)
1956            .with_events(events.clone());
1957        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
1958            max_lines: 32,
1959            max_bytes: 24,
1960            max_line_bytes: 24,
1961        };
1962        let uses = Value::List(vec![Value::Struct(vec![
1963            ("id".into(), Value::Str("text_id".into())),
1964            ("name".into(), Value::Str("text".into())),
1965            ("input".into(), Value::Struct(Vec::new())),
1966        ])]);
1967        let Value::List(results) = DispatchAll
1968            .call(
1969                ToolArgs {
1970                    positional: vec![uses],
1971                    named: Vec::new(),
1972                },
1973                &ctx,
1974            )
1975            .await
1976            .unwrap()
1977        else {
1978            panic!("dispatch result list");
1979        };
1980        let Value::Message(returned) = &results[0] else {
1981            panic!("tool result message");
1982        };
1983        let crate::stream::StreamFrame::ToolResultMsg {
1984            message: emitted, ..
1985        } = stream_rx.try_recv().unwrap()
1986        else {
1987            panic!("tool result stream frame");
1988        };
1989        assert_eq!(returned, &emitted);
1990        assert!(matches!(
1991            &returned.parts[0],
1992            crate::message::MessagePart::ToolResult { content, .. }
1993                if content.contains("Output truncated")
1994        ));
1995        let returned_bytes = match &returned.parts[0] {
1996            crate::message::MessagePart::ToolResult { content, .. } => content.len() as u64,
1997            _ => unreachable!(),
1998        };
1999        let metrics = events
2000            .snapshot()
2001            .into_iter()
2002            .find_map(|event| match event {
2003                crate::event::Event::ToolResultMetrics {
2004                    tool_use_id,
2005                    raw_bytes,
2006                    excerpt_bytes,
2007                    truncated,
2008                    ..
2009                } if tool_use_id == "text_id" => Some((raw_bytes, excerpt_bytes, truncated)),
2010                _ => None,
2011            })
2012            .expect("tool result metrics");
2013        assert!(metrics.0 > 0);
2014        assert_eq!((metrics.1, metrics.2), (returned_bytes, true));
2015    }
2016
2017    #[test]
2018    fn finish_dispatch_returns_the_budgeted_message_it_emits() {
2019        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2020        let events = crate::event::EventSink::new();
2021        let mut ctx = ToolCtx::new()
2022            .with_stream_tx(stream_tx)
2023            .with_events(events.clone());
2024        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2025            max_lines: 32,
2026            max_bytes: 24,
2027            max_line_bytes: 24,
2028        };
2029        let Value::Message(returned) = finish_dispatch(
2030            &ctx,
2031            "text_id",
2032            "text",
2033            Ok(Value::Str("0123456789".repeat(20))),
2034        ) else {
2035            panic!("tool result message");
2036        };
2037        let crate::stream::StreamFrame::ToolResultMsg {
2038            message: emitted, ..
2039        } = stream_rx.try_recv().unwrap()
2040        else {
2041            panic!("tool result stream frame");
2042        };
2043
2044        assert_eq!(returned, emitted);
2045        assert!(matches!(
2046            &returned.parts[0],
2047            crate::message::MessagePart::ToolResult { content, .. }
2048                if content.contains("Output truncated")
2049        ));
2050        let excerpt_bytes = match &returned.parts[0] {
2051            crate::message::MessagePart::ToolResult { content, .. } => content.len() as u64,
2052            _ => unreachable!(),
2053        };
2054        assert!(events.snapshot().into_iter().any(|event| matches!(
2055            event,
2056            crate::event::Event::ToolResultMetrics {
2057                tool_use_id,
2058                raw_bytes: 200,
2059                excerpt_bytes: observed,
2060                truncated: true,
2061                ..
2062            } if tool_use_id == "text_id" && observed == excerpt_bytes
2063        )));
2064    }
2065
2066    #[test]
2067    fn finish_dispatch_preserves_error_flag_and_message_consistency() {
2068        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2069        let ctx = ToolCtx::new().with_stream_tx(stream_tx);
2070        let Value::Message(returned) = finish_dispatch(
2071            &ctx,
2072            "error_id",
2073            "failing",
2074            Err(RuntimeError::ToolFailed("expected failure".into())),
2075        ) else {
2076            panic!("tool result message");
2077        };
2078        let crate::stream::StreamFrame::ToolResultMsg {
2079            message: emitted, ..
2080        } = stream_rx.try_recv().unwrap()
2081        else {
2082            panic!("tool result stream frame");
2083        };
2084
2085        assert_eq!(returned, emitted);
2086        assert!(matches!(
2087            &returned.parts[0],
2088            crate::message::MessagePart::ToolResult {
2089                content,
2090                is_error: true,
2091                ..
2092            } if content.contains("expected failure")
2093        ));
2094    }
2095
2096    #[test]
2097    fn finish_dispatch_emits_full_diff_preview_before_result_truncation() {
2098        let events = crate::event::EventSink::new();
2099        let mut ctx = ToolCtx::new().with_events(events.clone());
2100        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2101            max_lines: 1,
2102            max_bytes: 16,
2103            max_line_bytes: 16,
2104        };
2105        let diff = "-old\n+new\n".repeat(20);
2106        let Value::Message(returned) = finish_dispatch(
2107            &ctx,
2108            "edit_id",
2109            "fs.edit",
2110            Ok(Value::Struct(vec![
2111                (
2112                    "summary".into(),
2113                    Value::Str("[fs.edit(example.txt): updated]".into()),
2114                ),
2115                ("diff".into(), Value::Str(diff.clone())),
2116            ])),
2117        ) else {
2118            panic!("tool result message");
2119        };
2120
2121        assert!(matches!(
2122            &returned.parts[0],
2123            crate::message::MessagePart::ToolResult { content, .. }
2124                if content.contains("Output truncated")
2125        ));
2126        assert!(events.snapshot().iter().any(|event| matches!(
2127            event,
2128            crate::event::Event::DiffPreview {
2129                unified_diff: Some(preview),
2130                ..
2131            } if preview == &diff
2132        )));
2133    }
2134
2135    #[tokio::test]
2136    async fn dispatch_all_returns_the_same_truncated_preflight_error_it_emits() {
2137        let registry = crate::tool::ToolRegistry::new();
2138        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2139        let mut ctx = ToolCtx::new()
2140            .with_registry(std::sync::Arc::new(registry))
2141            .with_stream_tx(stream_tx);
2142        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2143            max_lines: 32,
2144            max_bytes: 32,
2145            max_line_bytes: 32,
2146        };
2147        let uses = Value::List(vec![Value::Struct(vec![
2148            ("id".into(), Value::Str("unknown_id".into())),
2149            ("name".into(), Value::Str("missing".repeat(40))),
2150            ("input".into(), Value::Struct(Vec::new())),
2151        ])]);
2152
2153        let Value::List(results) = DispatchAll
2154            .call(
2155                ToolArgs {
2156                    positional: vec![uses],
2157                    named: Vec::new(),
2158                },
2159                &ctx,
2160            )
2161            .await
2162            .unwrap()
2163        else {
2164            panic!("dispatch result list");
2165        };
2166        let Value::Message(returned) = &results[0] else {
2167            panic!("tool result message");
2168        };
2169        let crate::stream::StreamFrame::ToolResultMsg {
2170            message: emitted, ..
2171        } = stream_rx.try_recv().unwrap()
2172        else {
2173            panic!("tool result stream frame");
2174        };
2175
2176        assert_eq!(returned, &emitted);
2177        assert!(matches!(
2178            &returned.parts[0],
2179            crate::message::MessagePart::ToolResult {
2180                content,
2181                is_error: true,
2182                ..
2183            } if content.contains("Output truncated")
2184        ));
2185    }
2186
2187    #[tokio::test]
2188    async fn dispatch_all_unknown_tool_finishes_its_workflow_node_with_error() {
2189        use crate::workflow::{NodeStatus, WorkflowGraph};
2190
2191        let registry = crate::tool::ToolRegistry::new();
2192        let run_id = crate::event::FlowRunId::now();
2193        let run = run_id.0.to_string();
2194        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2195        let ctx = ToolCtx::new()
2196            .with_anchors(None, Some(run_id), None)
2197            .with_current_node(Some("dispatch_all".into()))
2198            .with_registry(std::sync::Arc::new(registry))
2199            .with_stream_tx(stream_tx);
2200        let uses = Value::List(vec![Value::Struct(vec![
2201            ("id".into(), Value::Str("unknown_id".into())),
2202            ("name".into(), Value::Str("missing.tool".into())),
2203            ("input".into(), Value::Struct(Vec::new())),
2204        ])]);
2205
2206        DispatchAll
2207            .call(
2208                ToolArgs {
2209                    positional: vec![uses],
2210                    named: Vec::new(),
2211                },
2212                &ctx,
2213            )
2214            .await
2215            .unwrap();
2216
2217        let mut graph = WorkflowGraph::new(crate::event::TurnId::now());
2218        graph.apply_stream_frame(&crate::stream::StreamFrame::FlowStart {
2219            run_id: run.clone(),
2220            flow_name: "agent_loop".into(),
2221            parent_run_id: None,
2222            parent_node_id: None,
2223        });
2224        graph.apply_stream_frame(&crate::stream::StreamFrame::FlowNodeStart {
2225            run_id: run.clone(),
2226            node_id: "dispatch_all".into(),
2227            kind: crate::nodegraph::NodeKind::ToolCall {
2228                path: "dispatch_all".into(),
2229            },
2230            label: "dispatch_all".into(),
2231            parent_node_id: None,
2232        });
2233        while let Ok(frame) = stream_rx.try_recv() {
2234            graph.apply_stream_frame(&frame);
2235        }
2236
2237        let node = graph
2238            .find_node(&format!("tool:{run}:unknown_id"))
2239            .expect("unknown tool node");
2240        assert_eq!(node.status, NodeStatus::Err);
2241        assert!(matches!(
2242            &node.kind,
2243            crate::workflow::WorkflowNodeKind::ToolCall {
2244                result_preview: Some(preview),
2245                ..
2246            } if preview.contains("unknown tool")
2247        ));
2248        let result = node.output_preview.as_deref().unwrap();
2249        assert!(result.contains("unknown tool"));
2250        assert!(
2251            graph
2252                .root
2253                .iter()
2254                .flat_map(|root| &root.children)
2255                .all(|child| {
2256                    !matches!(
2257                        &child.kind,
2258                        crate::workflow::WorkflowNodeKind::ToolCall { tool_use_id, .. }
2259                            if tool_use_id == "unknown_id"
2260                    )
2261                })
2262        );
2263    }
2264
2265    #[test]
2266    fn prepare_dispatch_does_not_emit_partial_nodes_for_malformed_batch() {
2267        let registry = crate::tool::ToolRegistry::new();
2268        let run_id = crate::event::FlowRunId::now();
2269        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2270        let ctx = ToolCtx::new()
2271            .with_anchors(None, Some(run_id), None)
2272            .with_current_node(Some("dispatch_all".into()))
2273            .with_stream_tx(stream_tx);
2274        let uses = vec![
2275            Value::Struct(vec![
2276                ("id".into(), Value::Str("valid_id".into())),
2277                ("name".into(), Value::Str("missing.tool".into())),
2278            ]),
2279            Value::Struct(vec![("name".into(), Value::Str("missing.tool".into()))]),
2280        ];
2281
2282        assert!(prepare_dispatch(&uses, &registry, &ctx).is_err());
2283        assert!(matches!(
2284            stream_rx.try_recv(),
2285            Err(tokio::sync::broadcast::error::TryRecvError::Empty)
2286        ));
2287    }
2288
2289    #[test]
2290    fn compose_email_preview_formats_headers() {
2291        let preview = compose_email_preview(
2292            "Deploy status",
2293            "See attached",
2294            &["a@x.com".into(), "b@x.com".into()],
2295        );
2296        assert_eq!(
2297            preview,
2298            "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
2299        );
2300    }
2301}