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            let mut message = crate::message::Message::user_text(turn_id, text);
666            message.origin = crate::message::MessageOrigin::Internal;
667            Ok(Value::Message(message))
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            let final_answer_error = crate::tools::final_answer::validation_error(m);
820            for part in &m.parts {
821                if let crate::message::MessagePart::ToolUse {
822                    id,
823                    name,
824                    input,
825                    intent,
826                } = part
827                {
828                    if name == crate::tools::final_answer::FINAL_ANSWER_TOOL
829                        && final_answer_error.is_none()
830                    {
831                        continue;
832                    }
833                    let mut fields = vec![
834                        ("id".into(), Value::Str(id.clone())),
835                        ("name".into(), Value::Str(name.clone())),
836                        ("input".into(), Value::from_json(input.clone())),
837                    ];
838                    if let Some(intent) = intent {
839                        fields.push(("intent".into(), Value::Str(intent.as_str().into())));
840                    }
841                    if name == crate::tools::final_answer::FINAL_ANSWER_TOOL
842                        && let Some(error) = final_answer_error
843                    {
844                        fields.push(("validation_error".into(), Value::Str(error.into())));
845                    }
846                    out.push(Value::Struct(fields));
847                }
848            }
849            Ok(Value::List(out))
850        })
851    }
852}
853
854pub struct DispatchAll;
855
856impl Tool for DispatchAll {
857    fn name(&self) -> &str {
858        "dispatch_all"
859    }
860
861    fn tier(&self) -> Tier {
862        Tier::Zero
863    }
864
865    fn description(&self) -> Option<&str> {
866        Some(
867            "Dispatch each tool_use in the list against the current tool registry and \
868             return a list of tool_result Message values.",
869        )
870    }
871
872    fn input_schema(&self) -> serde_json::Value {
873        serde_json::json!({
874            "type": "object",
875            "properties": {"tool_uses": {"type": "array"}},
876            "required": ["tool_uses"]
877        })
878    }
879
880    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
881        Box::pin(async move {
882            let uses = extract_list(&args, "tool_uses", 0)?;
883            let Some(registry) = ctx.registry.as_ref() else {
884                return Err(RuntimeError::ToolFailed(
885                    "dispatch_all: no tool registry available on ctx".into(),
886                ));
887            };
888            let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
889            let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
890            run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
891            run_serial(serial_batch, ctx, &mut out_slots).await;
892            let outcomes: Vec<DispatchOutcome> = out_slots.into_iter().flatten().collect();
893            let mut out = Vec::with_capacity(
894                outcomes.len()
895                    + outcomes
896                        .iter()
897                        .map(|outcome| outcome.followups.len())
898                        .sum::<usize>(),
899            );
900            out.extend(outcomes.iter().map(|outcome| outcome.tool_result.clone()));
901            out.extend(outcomes.into_iter().flat_map(|outcome| outcome.followups));
902            Ok(Value::List(out))
903        })
904    }
905}
906
907enum PreparedEntry {
908    Ready {
909        index: usize,
910        id: String,
911        name: String,
912        tool: std::sync::Arc<dyn Tool>,
913        call_args: ToolArgs,
914        call_intent: Option<crate::message::ToolCallIntent>,
915    },
916    Failed {
917        index: usize,
918        msg: crate::message::Message,
919    },
920}
921
922fn prepare_dispatch(
923    uses: &[Value],
924    registry: &crate::tool::ToolRegistry,
925    ctx: &ToolCtx,
926) -> Result<Vec<PreparedEntry>, RuntimeError> {
927    let parsed = uses
928        .iter()
929        .enumerate()
930        .map(|(index, entry)| {
931            let Value::Struct(fields) = entry else {
932                return Err(RuntimeError::TypeMismatch {
933                    expected: "struct {id, name, input}".into(),
934                    actual: entry.kind_name().into(),
935                });
936            };
937            let get = |key: &str| {
938                fields
939                    .iter()
940                    .find(|(name, _)| name == key)
941                    .map(|(_, value)| value.clone())
942            };
943            let id = match get("id") {
944                Some(Value::Str(id)) => id,
945                _ => {
946                    return Err(RuntimeError::ToolFailed(
947                        "dispatch_all: tool_use missing `id` string".into(),
948                    ));
949                }
950            };
951            let name = match get("name") {
952                Some(Value::Str(name)) => name,
953                _ => {
954                    return Err(RuntimeError::ToolFailed(
955                        "dispatch_all: tool_use missing `name` string".into(),
956                    ));
957                }
958            };
959            let call_intent = match get("intent") {
960                Some(Value::Str(value)) => crate::message::ToolCallIntent::new(value),
961                _ => None,
962            };
963            let validation_error = match get("validation_error") {
964                Some(Value::Str(value)) => Some(value),
965                _ => None,
966            };
967            Ok((
968                index,
969                id,
970                name,
971                get("input").unwrap_or(Value::Unit),
972                call_intent,
973                validation_error,
974            ))
975        })
976        .collect::<Result<Vec<_>, RuntimeError>>()?;
977
978    let mut prepared = Vec::with_capacity(parsed.len());
979    for (index, id, name, input, mut call_intent, validation_error) in parsed {
980        if let Some(error) = validation_error {
981            emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
982            prepared.push(PreparedEntry::Failed {
983                index,
984                msg: build_error_result(
985                    ctx,
986                    &id,
987                    &format!("tool `{name}` was not executed: {error}"),
988                ),
989            });
990            continue;
991        }
992        if ctx
993            .model_tool_exposures
994            .as_ref()
995            .is_some_and(|exposures| !exposures.claim(ctx.flow_run_id.as_ref(), &id, &name))
996        {
997            emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
998            prepared.push(PreparedEntry::Failed {
999                index,
1000                msg: build_error_result(
1001                    ctx,
1002                    &id,
1003                    &format!(
1004                        "dispatch_all: tool `{name}` was not exposed by the LLM request that produced call `{id}`"
1005                    ),
1006                ),
1007            });
1008            continue;
1009        }
1010        let Some(tool) = registry.get(&name) else {
1011            emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
1012            prepared.push(PreparedEntry::Failed {
1013                index,
1014                msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
1015            });
1016            continue;
1017        };
1018        let raw_schema = tool.input_schema();
1019        let named = match &input {
1020            Value::Struct(fields) => {
1021                let mut fields = fields.clone();
1022                if !crate::tool::tool_schema_uses_call_intent_field(&raw_schema)
1023                    && let Some(index) = fields
1024                        .iter()
1025                        .position(|(name, _)| name == crate::message::TOOL_CALL_INTENT_FIELD)
1026                {
1027                    let (_, value) = fields.remove(index);
1028                    if call_intent.is_none()
1029                        && let Value::Str(value) = value
1030                    {
1031                        call_intent = crate::message::ToolCallIntent::new(value);
1032                    }
1033                }
1034                fields
1035            }
1036            Value::Unit => Vec::new(),
1037            other => {
1038                emit_tool_node(ctx, &id, &name, &input, call_intent.as_ref());
1039                prepared.push(PreparedEntry::Failed {
1040                    index,
1041                    msg: build_error_result(
1042                        ctx,
1043                        &id,
1044                        &format!(
1045                            "tool `{name}` expected struct or unit input, got {}",
1046                            other.kind_name()
1047                        ),
1048                    ),
1049                });
1050                continue;
1051            }
1052        };
1053        emit_tool_node(
1054            ctx,
1055            &id,
1056            &name,
1057            &Value::Struct(named.clone()),
1058            call_intent.as_ref(),
1059        );
1060        if tool.requires_call_intent() && call_intent.is_none() {
1061            prepared.push(PreparedEntry::Failed {
1062                index,
1063                msg: build_error_result(
1064                    ctx,
1065                    &id,
1066                    &format!(
1067                        "tool `{name}` was not executed because its required `_atman_intent` was missing or empty. Issue a new tool call with a concise `_atman_intent`; do not assume this call succeeded."
1068                    ),
1069                ),
1070            });
1071            continue;
1072        }
1073        let missing = missing_required_fields(&raw_schema, &named);
1074        if !missing.is_empty() {
1075            let content = format!(
1076                "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
1077                missing.join(", "),
1078                missing
1079                    .iter()
1080                    .map(|f| format!("\"{f}\":\"...\""))
1081                    .collect::<Vec<_>>()
1082                    .join(", ")
1083            );
1084            prepared.push(PreparedEntry::Failed {
1085                index,
1086                msg: build_error_result(ctx, &id, &content),
1087            });
1088            continue;
1089        }
1090        prepared.push(PreparedEntry::Ready {
1091            index,
1092            id,
1093            name,
1094            tool,
1095            call_args: ToolArgs {
1096                positional: Vec::new(),
1097                named,
1098            },
1099            call_intent,
1100        });
1101    }
1102    Ok(prepared)
1103}
1104
1105struct Approved {
1106    index: usize,
1107    id: String,
1108    name: String,
1109    tool: std::sync::Arc<dyn Tool>,
1110    call_args: ToolArgs,
1111    call_ctx: ToolCtx,
1112}
1113
1114#[derive(Clone)]
1115struct DispatchOutcome {
1116    tool_result: Value,
1117    followups: Vec<Value>,
1118}
1119
1120impl DispatchOutcome {
1121    fn tool_result(message: crate::message::Message) -> Self {
1122        Self {
1123            tool_result: Value::Message(message),
1124            followups: Vec::new(),
1125        }
1126    }
1127}
1128
1129async fn partition_and_gate(
1130    prepared: Vec<PreparedEntry>,
1131    ctx: &ToolCtx,
1132) -> (Vec<Approved>, Vec<Approved>, Vec<Option<DispatchOutcome>>) {
1133    let total = prepared.len();
1134    let mut out_slots: Vec<Option<DispatchOutcome>> = vec![None; total];
1135    struct ReadyEntry {
1136        index: usize,
1137        id: String,
1138        name: String,
1139        tool: std::sync::Arc<dyn Tool>,
1140        call_args: ToolArgs,
1141        /// Classified once, before the gate. Re-deriving it after approval would
1142        /// let a level that depends on ctx or args drift between the verdict and
1143        /// the auto/serial routing, so a call could be gated as one level and run
1144        /// as another.
1145        level: crate::tool::ApprovalLevel,
1146        invocation_ctx: ToolCtx,
1147    }
1148    let mut ready: Vec<ReadyEntry> = Vec::new();
1149    for entry in prepared {
1150        match entry {
1151            PreparedEntry::Failed { index, msg } => {
1152                out_slots[index] = Some(DispatchOutcome::tool_result(emit_tool_result(ctx, &msg)));
1153            }
1154            PreparedEntry::Ready {
1155                index,
1156                id,
1157                name,
1158                tool,
1159                call_args,
1160                call_intent,
1161            } => {
1162                let invocation_ctx = ctx
1163                    .clone()
1164                    .for_tool_invocation(tool.tier())
1165                    .with_tool_use_id(id.clone())
1166                    .with_call_intent(call_intent);
1167                let level = tool.approval_level(&call_args, &invocation_ctx);
1168                ready.push(ReadyEntry {
1169                    index,
1170                    id,
1171                    name,
1172                    tool,
1173                    call_args,
1174                    level,
1175                    invocation_ctx,
1176                });
1177            }
1178        }
1179    }
1180    // Parallel gating exposes every pending ordinary request to the UI before
1181    // execution begins; permission-control calls authenticate without queuing.
1182    let gates = ready.iter().map(|r| {
1183        authorize_tool_invocation(
1184            &r.invocation_ctx,
1185            &r.id,
1186            &r.name,
1187            &r.call_args,
1188            r.tool.as_ref(),
1189        )
1190    });
1191    let outcomes = futures::future::join_all(gates).await;
1192    let mut auto_batch = Vec::new();
1193    let mut serial_batch = Vec::new();
1194    for (r, outcome) in ready.into_iter().zip(outcomes) {
1195        match outcome {
1196            Ok(call_ctx) => {
1197                let is_control =
1198                    r.tool.invocation_plane() == crate::tool::InvocationPlane::PermissionControl;
1199                let a = Approved {
1200                    index: r.index,
1201                    id: r.id,
1202                    name: r.name.clone(),
1203                    tool: r.tool,
1204                    call_args: r.call_args,
1205                    call_ctx,
1206                };
1207                if r.level == crate::tool::ApprovalLevel::Auto && !is_control {
1208                    auto_batch.push(a);
1209                } else {
1210                    serial_batch.push(a);
1211                }
1212            }
1213            Err(reason) => {
1214                let msg =
1215                    build_error_result(ctx, &r.id, &format!("tool `{}` denied: {reason}", r.name));
1216                out_slots[r.index] =
1217                    Some(DispatchOutcome::tool_result(emit_tool_result(ctx, &msg)));
1218            }
1219        }
1220    }
1221    (auto_batch, serial_batch, out_slots)
1222}
1223
1224async fn run_auto_parallel(
1225    batch: Vec<Approved>,
1226    ctx: &ToolCtx,
1227    out_slots: &mut [Option<DispatchOutcome>],
1228) {
1229    use futures::StreamExt;
1230
1231    let mut pending = futures::stream::FuturesUnordered::new();
1232    for a in batch {
1233        pending.push(async move {
1234            let Approved {
1235                index,
1236                id,
1237                name,
1238                tool,
1239                call_args,
1240                call_ctx,
1241            } = a;
1242            let result = tool.call(call_args, &call_ctx).await;
1243            (index, id, name, tool, result)
1244        });
1245    }
1246    while let Some((index, id, name, tool, result)) = pending.next().await {
1247        out_slots[index] = Some(finish_dispatch_outcome(
1248            ctx,
1249            &id,
1250            &name,
1251            tool.as_ref(),
1252            result,
1253        ));
1254    }
1255}
1256
1257async fn run_serial(
1258    batch: Vec<Approved>,
1259    ctx: &ToolCtx,
1260    out_slots: &mut [Option<DispatchOutcome>],
1261) {
1262    for a in batch {
1263        let Approved {
1264            index,
1265            id,
1266            name,
1267            tool,
1268            call_args,
1269            call_ctx,
1270        } = a;
1271        let result = tool.call(call_args, &call_ctx).await;
1272        out_slots[index] = Some(finish_dispatch_outcome(
1273            ctx,
1274            &id,
1275            &name,
1276            tool.as_ref(),
1277            result,
1278        ));
1279    }
1280}
1281
1282fn finish_dispatch_outcome(
1283    ctx: &ToolCtx,
1284    id: &str,
1285    name: &str,
1286    tool: &dyn Tool,
1287    result: ToolResult,
1288) -> DispatchOutcome {
1289    let followups = result
1290        .as_ref()
1291        .ok()
1292        .map(|value| {
1293            tool.model_followups(value, ctx)
1294                .into_iter()
1295                .map(Value::Message)
1296                .collect()
1297        })
1298        .unwrap_or_default();
1299    DispatchOutcome {
1300        tool_result: finish_dispatch(ctx, id, name, result),
1301        followups,
1302    }
1303}
1304
1305fn finish_dispatch(ctx: &ToolCtx, id: &str, name: &str, result: ToolResult) -> Value {
1306    let (content, is_error) = match &result {
1307        Ok(v) => (render_tool_result_text(v), false),
1308        Err(e) => (format!("{e}"), true),
1309    };
1310    if let Ok(v) = &result {
1311        emit_diff_preview_if_relevant(ctx, id, name, v);
1312    }
1313    let msg = crate::message::Message {
1314        role: crate::message::MessageRole::Tool,
1315        parts: vec![crate::message::MessagePart::ToolResult {
1316            tool_use_id: id.to_string(),
1317            content,
1318            is_error,
1319        }],
1320        turn_id: ctx
1321            .turn_id
1322            .clone()
1323            .unwrap_or_else(crate::event::TurnId::now),
1324        origin: crate::message::MessageOrigin::User,
1325    };
1326    Value::Message(emit_tool_result(ctx, &msg))
1327}
1328
1329type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
1330
1331fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_use_id: &str, tool_name: &str, value: &Value) {
1332    let Some(sink) = ctx.events.as_ref() else {
1333        return;
1334    };
1335    let data: Option<DiffPreviewData> = match tool_name {
1336        "fs.edit" => {
1337            let path = value_struct_string(value, "summary").and_then(|s| {
1338                s.strip_prefix("[fs.edit(")
1339                    .and_then(|s| s.split(':').next())
1340                    .map(|s| s.trim_end_matches(')').to_string())
1341            });
1342            let diff = value_struct_string(value, "diff");
1343            diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
1344        }
1345        "fs.write" => {
1346            let path = value_struct_string(value, "path").unwrap_or_default();
1347            let diff = value_struct_string(value, "diff");
1348            diff.map(|d| (path, None, None, Some(d)))
1349        }
1350        "git.diff" => {
1351            let Some(diff) = value_struct_string(value, "diff") else {
1352                return;
1353            };
1354            Some(("git diff".into(), None, None, Some(diff)))
1355        }
1356        "git.show" => {
1357            let sha = value_struct_string(value, "sha").unwrap_or_default();
1358            let Some(diff) = value_struct_string(value, "diff") else {
1359                return;
1360            };
1361            Some((format!("git show {sha}"), None, None, Some(diff)))
1362        }
1363        "git.log" => {
1364            let Some(diff) = value_struct_string(value, "diff") else {
1365                return;
1366            };
1367            Some(("git log HEAD".into(), None, None, Some(diff)))
1368        }
1369        _ => None,
1370    };
1371    if let Some((title, old_content, new_content, unified_diff)) = data {
1372        sink.emit(crate::event::Event::DiffPreview {
1373            turn_id: ctx.turn_id.clone(),
1374            flow_run_id: ctx.flow_run_id.clone(),
1375            tool_use_id: Some(tool_use_id.to_owned()),
1376            title,
1377            old_content,
1378            new_content,
1379            unified_diff,
1380        });
1381    }
1382}
1383
1384fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1385    if let Value::Struct(fields) = value {
1386        fields
1387            .iter()
1388            .find(|(k, _)| k == field)
1389            .and_then(|(_, v)| match v {
1390                Value::Str(s) => Some(s.clone()),
1391                _ => None,
1392            })
1393    } else {
1394        None
1395    }
1396}
1397
1398fn emit_tool_node(
1399    ctx: &ToolCtx,
1400    id: &str,
1401    name: &str,
1402    input: &Value,
1403    call_intent: Option<&crate::message::ToolCallIntent>,
1404) {
1405    let (Some(run_id), Some(parent_node)) = (&ctx.flow_run_id, &ctx.current_node_id) else {
1406        return;
1407    };
1408    let args_preview = format!("{:?}", input)
1409        .chars()
1410        .take(4000)
1411        .collect::<String>();
1412    if let Some(sink) = &ctx.events {
1413        sink.emit(crate::event::Event::ToolNode {
1414            run_id: run_id.clone(),
1415            parent_node_id: parent_node.clone(),
1416            tool_use_id: id.to_string(),
1417            tool_name: name.to_string(),
1418            args_preview: args_preview.clone(),
1419            call_intent: call_intent.cloned(),
1420        });
1421    }
1422    if let Some(tx) = &ctx.stream_tx {
1423        let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1424            run_id: run_id.0.to_string(),
1425            parent_node_id: parent_node.clone(),
1426            tool_use_id: id.to_string(),
1427            tool: name.to_string(),
1428            args_preview,
1429            call_intent: call_intent.cloned(),
1430        });
1431    }
1432}
1433
1434fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1435    crate::message::Message {
1436        role: crate::message::MessageRole::Tool,
1437        parts: vec![crate::message::MessagePart::ToolResult {
1438            tool_use_id: tool_use_id.to_string(),
1439            content: content.to_string(),
1440            is_error: true,
1441        }],
1442        turn_id: ctx
1443            .turn_id
1444            .clone()
1445            .unwrap_or_else(crate::event::TurnId::now),
1446        origin: crate::message::MessageOrigin::User,
1447    }
1448}
1449
1450fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1451    let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1452        return Vec::new();
1453    };
1454    let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1455    required
1456        .iter()
1457        .filter_map(|v| v.as_str())
1458        .filter(|k| !have.contains(k))
1459        .map(String::from)
1460        .collect()
1461}
1462
1463fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) -> crate::message::Message {
1464    let excerpt = crate::tools::tool_output::maybe_truncate_tool_message_with_budget(
1465        msg,
1466        ctx.output_store.as_deref(),
1467        ctx.tool_output_budget,
1468    );
1469    emit_tool_result_metrics(ctx, msg, &excerpt);
1470    if let Some(tx) = &ctx.stream_tx {
1471        let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1472            flow_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1473            message: excerpt.clone(),
1474        });
1475    } else if let Some(sink) = &ctx.events {
1476        sink.emit(crate::event::Event::ToolResultMsg {
1477            turn_id: excerpt.turn_id.clone(),
1478            flow_run_id: ctx.flow_run_id.clone(),
1479            message: excerpt.clone(),
1480        });
1481    }
1482    excerpt
1483}
1484
1485fn emit_tool_result_metrics(
1486    ctx: &ToolCtx,
1487    raw: &crate::message::Message,
1488    excerpt: &crate::message::Message,
1489) {
1490    let Some(sink) = &ctx.events else {
1491        return;
1492    };
1493    for part in &raw.parts {
1494        let crate::message::MessagePart::ToolResult {
1495            tool_use_id,
1496            content: raw_content,
1497            ..
1498        } = part
1499        else {
1500            continue;
1501        };
1502        let excerpt_content = excerpt.parts.iter().find_map(|part| match part {
1503            crate::message::MessagePart::ToolResult {
1504                tool_use_id: excerpt_id,
1505                content,
1506                ..
1507            } if excerpt_id == tool_use_id => Some(content.as_str()),
1508            _ => None,
1509        });
1510        sink.emit(crate::event::Event::ToolResultMetrics {
1511            turn_id: raw.turn_id.clone(),
1512            flow_run_id: ctx.flow_run_id.clone(),
1513            tool_use_id: tool_use_id.clone(),
1514            raw_bytes: raw_content.len() as u64,
1515            excerpt_bytes: excerpt_content.map_or(0, |content| content.len() as u64),
1516            truncated: excerpt_content != Some(raw_content.as_str()),
1517        });
1518    }
1519}
1520
1521fn render_tool_result_text(v: &Value) -> String {
1522    match v {
1523        Value::Str(s) => s.clone(),
1524        Value::Message(m) => m.text_concat(),
1525        other => other.to_json().to_string(),
1526    }
1527}
1528
1529fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1530    let value = match args.named(name) {
1531        Some(v) => v,
1532        None => args.positional(pos)?,
1533    };
1534    match value {
1535        Value::List(items) => Ok(items.clone()),
1536        other => Err(RuntimeError::TypeMismatch {
1537            expected: "list".into(),
1538            actual: other.kind_name().into(),
1539        }),
1540    }
1541}
1542
1543pub struct ComposeEmailPreview;
1544
1545impl Tool for ComposeEmailPreview {
1546    fn name(&self) -> &str {
1547        "compose_email_preview"
1548    }
1549
1550    fn tier(&self) -> Tier {
1551        Tier::Zero
1552    }
1553
1554    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1555        Box::pin(async move {
1556            let subject = extract_string(&args, "subject", 0)?;
1557            let body = extract_string(&args, "body", 1)?;
1558            let to = extract_string_list(&args, "to", 2)?;
1559            Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1560        })
1561    }
1562}
1563
1564pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1565    format!(
1566        "To: {}
1567Subject: {subject}
1568---
1569{body}",
1570        to.join(", ")
1571    )
1572}
1573
1574fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1575    let value = match args.named(name) {
1576        Some(v) => v,
1577        None => args.positional(pos)?,
1578    };
1579    match value {
1580        Value::Str(s) => Ok(s.clone()),
1581        other => Err(RuntimeError::TypeMismatch {
1582            expected: "string".into(),
1583            actual: other.kind_name().into(),
1584        }),
1585    }
1586}
1587
1588fn extract_string_list(
1589    args: &ToolArgs,
1590    name: &str,
1591    pos: usize,
1592) -> Result<Vec<String>, RuntimeError> {
1593    let value = match args.named(name) {
1594        Some(v) => v,
1595        None => args.positional(pos)?,
1596    };
1597    match value {
1598        Value::List(items) => items
1599            .iter()
1600            .map(|v| match v {
1601                Value::Str(s) => Ok(s.clone()),
1602                other => Err(RuntimeError::TypeMismatch {
1603                    expected: "list of string".into(),
1604                    actual: other.kind_name().into(),
1605                }),
1606            })
1607            .collect(),
1608        other => Err(RuntimeError::TypeMismatch {
1609            expected: "list".into(),
1610            actual: other.kind_name().into(),
1611        }),
1612    }
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617    use super::*;
1618
1619    #[tokio::test]
1620    async fn message_user_marks_injected_prompts_as_internal() {
1621        let turn_id = crate::event::TurnId::now();
1622        let ctx = ToolCtx::new().with_anchors(Some(turn_id.clone()), None, None);
1623        let value = MessageUser
1624            .call(
1625                ToolArgs {
1626                    positional: vec![Value::Str("provide the final answer".into())],
1627                    named: Vec::new(),
1628                },
1629                &ctx,
1630            )
1631            .await
1632            .unwrap();
1633        let Value::Message(message) = value else {
1634            panic!("message.user must return a message");
1635        };
1636
1637        assert_eq!(message.turn_id, turn_id);
1638        assert_eq!(message.origin, crate::message::MessageOrigin::Internal);
1639    }
1640
1641    fn authorized_ctx(registry: std::sync::Arc<crate::tool::ToolRegistry>) -> ToolCtx {
1642        let trust = crate::trust::TrustConfig {
1643            mode: crate::trust::TrustMode::Reckless,
1644            ..crate::trust::TrustConfig::default()
1645        };
1646        let flows = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
1647        let run_id = crate::event::FlowRunId::now();
1648        let identity = flows
1649            .register_root(
1650                "stdlib-test".into(),
1651                run_id.clone(),
1652                crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
1653            )
1654            .unwrap();
1655        let mut ctx = ToolCtx::new()
1656            .with_registry(registry)
1657            .with_flow_registry(std::sync::Arc::clone(&flows))
1658            .with_permission_broker(crate::permission::PermissionBroker::shared(flows))
1659            .with_approval(std::sync::Arc::new(crate::session::ApprovalRegistry::new()))
1660            .with_trust(trust)
1661            .with_anchors(None, Some(run_id), None);
1662        ctx.flow_identity = Some(identity);
1663        ctx
1664    }
1665
1666    #[tokio::test]
1667    async fn final_answer_without_intent_is_withheld_from_tool_dispatch() {
1668        let registry = std::sync::Arc::new(crate::tool::ToolRegistry::new());
1669        registry.register(std::sync::Arc::new(crate::tools::final_answer::FinalAnswer));
1670        let ctx = authorized_ctx(registry);
1671        let reply = Value::Message(crate::message::Message {
1672            role: crate::message::MessageRole::Assistant,
1673            parts: vec![crate::message::MessagePart::ToolUse {
1674                id: "answer-1".into(),
1675                name: crate::tools::final_answer::FINAL_ANSWER_TOOL.into(),
1676                input: serde_json::json!({"message": "Done."}),
1677                intent: None,
1678            }],
1679            turn_id: crate::event::TurnId::now(),
1680            origin: crate::message::MessageOrigin::User,
1681        });
1682        let uses = ExtractToolUses
1683            .call(
1684                ToolArgs {
1685                    positional: vec![reply],
1686                    named: Vec::new(),
1687                },
1688                &ctx,
1689            )
1690            .await
1691            .unwrap();
1692        let Value::List(uses) = uses else {
1693            panic!("tool use list");
1694        };
1695        assert!(uses.is_empty());
1696    }
1697
1698    #[tokio::test]
1699    async fn mixed_final_answer_stays_in_dispatch_as_an_explicit_failure() {
1700        let message = Value::Message(crate::message::Message {
1701            role: crate::message::MessageRole::Assistant,
1702            parts: vec![
1703                crate::message::MessagePart::ToolUse {
1704                    id: "answer-1".into(),
1705                    name: crate::tools::final_answer::FINAL_ANSWER_TOOL.into(),
1706                    input: serde_json::json!({"message": "Done."}),
1707                    intent: crate::message::ToolCallIntent::new("Completed requested work."),
1708                },
1709                crate::message::MessagePart::ToolUse {
1710                    id: "read-1".into(),
1711                    name: "fs.read".into(),
1712                    input: serde_json::json!({"path": "README.md"}),
1713                    intent: crate::message::ToolCallIntent::new("Read documentation."),
1714                },
1715            ],
1716            turn_id: crate::event::TurnId::now(),
1717            origin: crate::message::MessageOrigin::User,
1718        });
1719
1720        let Value::List(uses) = ExtractToolUses
1721            .call(
1722                ToolArgs {
1723                    positional: vec![message],
1724                    named: Vec::new(),
1725                },
1726                &ToolCtx::new(),
1727            )
1728            .await
1729            .unwrap()
1730        else {
1731            panic!("tool use list");
1732        };
1733
1734        assert_eq!(uses.len(), 2);
1735        let Value::Struct(final_fields) = &uses[0] else {
1736            panic!("final tool use");
1737        };
1738        assert!(final_fields.iter().any(|(name, value)| {
1739            name == "validation_error"
1740                && matches!(value, Value::Str(error) if error.contains("without sibling tool calls"))
1741        }));
1742    }
1743
1744    #[test]
1745    fn shell_quote_wraps_and_escapes() {
1746        assert_eq!(shell_quote("hello"), "'hello'");
1747        assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1748        assert_eq!(shell_quote(""), "''");
1749        assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1750    }
1751
1752    struct PermitProbeTool;
1753
1754    impl Tool for PermitProbeTool {
1755        fn name(&self) -> &str {
1756            "permit.probe"
1757        }
1758
1759        fn tier(&self) -> Tier {
1760            Tier::Zero
1761        }
1762
1763        fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1764            Box::pin(async move {
1765                let authorized = ctx
1766                    .invocation_authorization()
1767                    .is_some_and(|permit| permit.is_for_call("probe_id", "permit.probe"));
1768                Ok(Value::Bool(authorized))
1769            })
1770        }
1771    }
1772
1773    struct ControlProbeTool;
1774
1775    impl Tool for ControlProbeTool {
1776        fn name(&self) -> &str {
1777            "permission.probe"
1778        }
1779
1780        fn tier(&self) -> Tier {
1781            Tier::Zero
1782        }
1783
1784        fn invocation_plane(&self) -> crate::tool::InvocationPlane {
1785            crate::tool::InvocationPlane::PermissionControl
1786        }
1787
1788        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1789            Box::pin(async { Ok(Value::Unit) })
1790        }
1791    }
1792
1793    struct UnexposedProbeTool;
1794
1795    impl Tool for UnexposedProbeTool {
1796        fn name(&self) -> &str {
1797            "hidden.probe"
1798        }
1799
1800        fn tier(&self) -> Tier {
1801            Tier::Zero
1802        }
1803
1804        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1805            Box::pin(async { panic!("unexposed tool must not execute") })
1806        }
1807    }
1808
1809    struct FollowupProbeTool(&'static str);
1810
1811    impl Tool for FollowupProbeTool {
1812        fn name(&self) -> &str {
1813            self.0
1814        }
1815
1816        fn tier(&self) -> Tier {
1817            Tier::Zero
1818        }
1819
1820        fn model_followups(&self, result: &Value, _ctx: &ToolCtx) -> Vec<crate::message::Message> {
1821            let Value::Str(label) = result else {
1822                return Vec::new();
1823            };
1824            let mut message = crate::message::Message::user_text(
1825                crate::event::TurnId::now(),
1826                format!("followup:{label}"),
1827            );
1828            message.origin = crate::message::MessageOrigin::Internal;
1829            vec![message]
1830        }
1831
1832        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1833            Box::pin(async move { Ok(Value::Str(self.0.to_string())) })
1834        }
1835    }
1836
1837    #[tokio::test]
1838    async fn dispatch_all_places_followups_after_the_complete_tool_result_batch() {
1839        let registry = crate::tool::ToolRegistry::new();
1840        registry.register(std::sync::Arc::new(FollowupProbeTool("probe.first")));
1841        registry.register(std::sync::Arc::new(FollowupProbeTool("probe.second")));
1842        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1843        let uses = Value::List(
1844            ["probe.first", "probe.second"]
1845                .into_iter()
1846                .enumerate()
1847                .map(|(index, name)| {
1848                    Value::Struct(vec![
1849                        ("id".into(), Value::Str(format!("call-{index}"))),
1850                        ("name".into(), Value::Str(name.into())),
1851                        ("intent".into(), Value::Str(format!("Run {name}"))),
1852                        ("input".into(), Value::Struct(Vec::new())),
1853                    ])
1854                })
1855                .collect(),
1856        );
1857
1858        let Value::List(messages) = DispatchAll
1859            .call(
1860                ToolArgs {
1861                    positional: vec![uses],
1862                    named: Vec::new(),
1863                },
1864                &ctx,
1865            )
1866            .await
1867            .unwrap()
1868        else {
1869            panic!("expected messages")
1870        };
1871
1872        assert_eq!(messages.len(), 4);
1873        assert!(messages[..2].iter().all(
1874            |value| matches!(value, Value::Message(message) if message.role == crate::message::MessageRole::Tool)
1875        ));
1876        assert_eq!(
1877            messages[2..]
1878                .iter()
1879                .map(|value| match value {
1880                    Value::Message(message) => message.text_concat(),
1881                    _ => panic!("expected message"),
1882                })
1883                .collect::<Vec<_>>(),
1884            ["followup:probe.first", "followup:probe.second"]
1885        );
1886    }
1887
1888    #[tokio::test]
1889    async fn dispatch_all_rejects_calls_outside_their_request_exposure() {
1890        let registry = crate::tool::ToolRegistry::new();
1891        registry.register(std::sync::Arc::new(UnexposedProbeTool));
1892        let mut ctx = authorized_ctx(std::sync::Arc::new(registry));
1893        let response = crate::message::Message {
1894            role: crate::message::MessageRole::Assistant,
1895            parts: vec![crate::message::MessagePart::ToolUse {
1896                id: "hidden-call".into(),
1897                name: "hidden.probe".into(),
1898                input: serde_json::json!({}),
1899                intent: None,
1900            }],
1901            turn_id: crate::event::TurnId::now(),
1902            origin: crate::message::MessageOrigin::User,
1903        };
1904        let exposures = crate::tool::ToolExposureRegistry::default();
1905        exposures.register_response(ctx.flow_run_id.as_ref(), &response, ["allowed.probe"]);
1906        ctx.model_tool_exposures = Some(exposures);
1907        let uses = Value::List(vec![Value::Struct(vec![
1908            ("id".into(), Value::Str("hidden-call".into())),
1909            ("name".into(), Value::Str("hidden.probe".into())),
1910            ("input".into(), Value::Struct(Vec::new())),
1911        ])]);
1912
1913        let Value::List(results) = DispatchAll
1914            .call(
1915                ToolArgs {
1916                    positional: vec![uses],
1917                    named: Vec::new(),
1918                },
1919                &ctx,
1920            )
1921            .await
1922            .unwrap()
1923        else {
1924            panic!("dispatch result list")
1925        };
1926        assert!(matches!(
1927            &results[0],
1928            Value::Message(crate::message::Message { parts, .. })
1929                if matches!(
1930                    &parts[..],
1931                    [crate::message::MessagePart::ToolResult { content, is_error: true, .. }]
1932                        if content.contains("was not exposed")
1933                )
1934        ));
1935    }
1936
1937    #[tokio::test]
1938    async fn dispatch_all_routes_permission_control_calls_to_serial_without_recursive_requests() {
1939        let registry = crate::tool::ToolRegistry::new();
1940        registry.register(std::sync::Arc::new(ControlProbeTool));
1941        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1942        let before = ctx.permission_broker.as_ref().unwrap().list().len();
1943        let uses = vec![
1944            Value::Struct(vec![
1945                ("id".into(), Value::Str("control-1".into())),
1946                ("name".into(), Value::Str("permission.probe".into())),
1947                (
1948                    "intent".into(),
1949                    Value::Str("Inspect permission control".into()),
1950                ),
1951                ("input".into(), Value::Struct(Vec::new())),
1952            ]),
1953            Value::Struct(vec![
1954                ("id".into(), Value::Str("control-2".into())),
1955                ("name".into(), Value::Str("permission.probe".into())),
1956                (
1957                    "intent".into(),
1958                    Value::Str("Inspect permission control".into()),
1959                ),
1960                ("input".into(), Value::Struct(Vec::new())),
1961            ]),
1962        ];
1963        let prepared = prepare_dispatch(&uses, ctx.registry.as_ref().unwrap(), &ctx).unwrap();
1964        let (parallel, serial, _) = partition_and_gate(prepared, &ctx).await;
1965
1966        assert!(parallel.is_empty());
1967        assert_eq!(serial.len(), 2);
1968        assert_eq!(ctx.permission_broker.as_ref().unwrap().list().len(), before);
1969    }
1970
1971    #[tokio::test]
1972    async fn auto_parallel_call_receives_its_own_authorization() {
1973        let registry = crate::tool::ToolRegistry::new();
1974        registry.register(std::sync::Arc::new(PermitProbeTool));
1975        let ctx = authorized_ctx(std::sync::Arc::new(registry));
1976        let uses = Value::List(vec![Value::Struct(vec![
1977            ("id".into(), Value::Str("probe_id".into())),
1978            ("name".into(), Value::Str("permit.probe".into())),
1979            ("intent".into(), Value::Str("Inspect authorization".into())),
1980            ("input".into(), Value::Struct(Vec::new())),
1981        ])]);
1982
1983        let Value::List(results) = DispatchAll
1984            .call(
1985                ToolArgs {
1986                    positional: vec![uses],
1987                    named: Vec::new(),
1988                },
1989                &ctx,
1990            )
1991            .await
1992            .unwrap()
1993        else {
1994            panic!("dispatch result list");
1995        };
1996        let Value::Message(message) = &results[0] else {
1997            panic!("tool result message");
1998        };
1999        assert!(message.parts.iter().any(|part| matches!(
2000            part,
2001            crate::message::MessagePart::ToolResult { content, is_error: false, .. }
2002                if content == "true"
2003        )));
2004    }
2005
2006    #[tokio::test]
2007    async fn dispatch_all_strips_wire_intent_and_isolates_parallel_contexts() {
2008        let registry = crate::tool::ToolRegistry::new();
2009        registry.register(std::sync::Arc::new(PermitProbeTool));
2010        let ctx = authorized_ctx(std::sync::Arc::new(registry));
2011        let uses = vec![
2012            Value::Struct(vec![
2013                ("id".into(), Value::Str("first".into())),
2014                ("name".into(), Value::Str("permit.probe".into())),
2015                (
2016                    "input".into(),
2017                    Value::Struct(vec![(
2018                        crate::message::TOOL_CALL_INTENT_FIELD.into(),
2019                        Value::Str("Inspect first target".into()),
2020                    )]),
2021                ),
2022            ]),
2023            Value::Struct(vec![
2024                ("id".into(), Value::Str("second".into())),
2025                ("name".into(), Value::Str("permit.probe".into())),
2026                (
2027                    "input".into(),
2028                    Value::Struct(vec![(
2029                        crate::message::TOOL_CALL_INTENT_FIELD.into(),
2030                        Value::Str("Inspect second target".into()),
2031                    )]),
2032                ),
2033            ]),
2034        ];
2035        let prepared = prepare_dispatch(&uses, ctx.registry.as_ref().unwrap(), &ctx).unwrap();
2036        let (parallel, serial, _) = partition_and_gate(prepared, &ctx).await;
2037
2038        assert!(serial.is_empty());
2039        assert_eq!(parallel.len(), 2);
2040        assert!(parallel.iter().all(|call| {
2041            call.call_args
2042                .named(crate::message::TOOL_CALL_INTENT_FIELD)
2043                .is_none()
2044        }));
2045        assert_eq!(
2046            parallel[0]
2047                .call_ctx
2048                .call_intent
2049                .as_ref()
2050                .map(|intent| intent.as_str()),
2051            Some("Inspect first target")
2052        );
2053        assert_eq!(
2054            parallel[1]
2055                .call_ctx
2056                .call_intent
2057                .as_ref()
2058                .map(|intent| intent.as_str()),
2059            Some("Inspect second target")
2060        );
2061    }
2062
2063    struct ControlledTool {
2064        name: &'static str,
2065        release: std::sync::Arc<tokio::sync::Semaphore>,
2066    }
2067
2068    impl Tool for ControlledTool {
2069        fn name(&self) -> &str {
2070            self.name
2071        }
2072
2073        fn tier(&self) -> Tier {
2074            Tier::Zero
2075        }
2076
2077        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
2078            Box::pin(async move {
2079                let _permit = self.release.acquire().await.unwrap();
2080                Ok(Value::Str(self.name.to_string()))
2081            })
2082        }
2083    }
2084
2085    #[tokio::test]
2086    async fn dispatch_all_emits_each_scoped_result_as_its_tool_finishes() {
2087        let fast_release = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
2088        let slow_release = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
2089        let registry = crate::tool::ToolRegistry::new();
2090        registry.register(std::sync::Arc::new(ControlledTool {
2091            name: "fast",
2092            release: fast_release.clone(),
2093        }));
2094        registry.register(std::sync::Arc::new(ControlledTool {
2095            name: "slow",
2096            release: slow_release.clone(),
2097        }));
2098        let run_id = crate::event::FlowRunId::now();
2099        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(32);
2100        let ctx = authorized_ctx(std::sync::Arc::new(registry))
2101            .with_anchors(None, Some(run_id.clone()), None)
2102            .with_current_node(Some("dispatch_all".into()))
2103            .with_stream_tx(stream_tx);
2104        let uses = Value::List(vec![
2105            Value::Struct(vec![
2106                ("id".into(), Value::Str("slow_id".into())),
2107                ("name".into(), Value::Str("slow".into())),
2108                ("intent".into(), Value::Str("Run slow probe".into())),
2109                ("input".into(), Value::Struct(Vec::new())),
2110            ]),
2111            Value::Struct(vec![
2112                ("id".into(), Value::Str("fast_id".into())),
2113                ("name".into(), Value::Str("fast".into())),
2114                ("intent".into(), Value::Str("Run fast probe".into())),
2115                ("input".into(), Value::Struct(Vec::new())),
2116            ]),
2117        ]);
2118        let task = tokio::spawn(async move {
2119            DispatchAll
2120                .call(
2121                    ToolArgs {
2122                        positional: vec![uses],
2123                        named: Vec::new(),
2124                    },
2125                    &ctx,
2126                )
2127                .await
2128                .unwrap()
2129        });
2130
2131        fast_release.add_permits(1);
2132        let fast_result = tokio::time::timeout(std::time::Duration::from_secs(1), async {
2133            loop {
2134                if let crate::stream::StreamFrame::ToolResultMsg {
2135                    flow_run_id,
2136                    message,
2137                } = stream_rx.recv().await.unwrap()
2138                    && message.parts.iter().any(|part| {
2139                        matches!(
2140                            part,
2141                            crate::message::MessagePart::ToolResult { tool_use_id, .. }
2142                                if tool_use_id == "fast_id"
2143                        )
2144                    })
2145                {
2146                    break flow_run_id;
2147                }
2148            }
2149        })
2150        .await
2151        .expect("fast result before slow release");
2152        assert_eq!(fast_result.as_deref(), Some(run_id.0.to_string().as_str()));
2153        assert!(!task.is_finished());
2154
2155        slow_release.add_permits(1);
2156        let Value::List(results) = task.await.unwrap() else {
2157            panic!("dispatch result list");
2158        };
2159        let ids: Vec<&str> = results
2160            .iter()
2161            .map(|value| match value {
2162                Value::Message(message) => match &message.parts[0] {
2163                    crate::message::MessagePart::ToolResult { tool_use_id, .. } => {
2164                        tool_use_id.as_str()
2165                    }
2166                    _ => panic!("tool result part"),
2167                },
2168                _ => panic!("tool result message"),
2169            })
2170            .collect();
2171        assert_eq!(ids, vec!["slow_id", "fast_id"]);
2172    }
2173
2174    struct TextTool {
2175        name: &'static str,
2176        output: String,
2177    }
2178
2179    impl Tool for TextTool {
2180        fn name(&self) -> &str {
2181            self.name
2182        }
2183
2184        fn tier(&self) -> Tier {
2185            Tier::Zero
2186        }
2187
2188        fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
2189            let output = self.output.clone();
2190            Box::pin(async move { Ok(Value::Str(output)) })
2191        }
2192    }
2193
2194    #[tokio::test]
2195    async fn dispatch_all_preserves_fs_read_pagination_for_full_utf8_reassembly() {
2196        let dir = tempfile::tempdir().unwrap();
2197        let path = dir.path().join("large.txt");
2198        let expected = "界".repeat(349_525) + "a";
2199        assert_eq!(expected.len(), 1_048_576);
2200        tokio::fs::write(&path, &expected).await.unwrap();
2201
2202        let registry = crate::tool::ToolRegistry::new();
2203        crate::tools::register_tier_zero(&registry);
2204        let ctx = authorized_ctx(std::sync::Arc::new(registry))
2205            .with_session_dir(dir.path().to_path_buf())
2206            .with_workspace(crate::git_workspace::WorkspaceBinding {
2207                workspace_id: "stdlib-test".into(),
2208                repository_root: dir.path().to_path_buf(),
2209                path: dir.path().to_path_buf(),
2210                branch: None,
2211            });
2212        let uses = Value::List(vec![Value::Struct(vec![
2213            ("id".into(), Value::Str("read_id".into())),
2214            ("name".into(), Value::Str("fs.read".into())),
2215            (
2216                "intent".into(),
2217                Value::Str("Read the complete fixture".into()),
2218            ),
2219            (
2220                "input".into(),
2221                Value::Struct(vec![("path".into(), Value::Path(path))]),
2222            ),
2223        ])]);
2224        let Value::List(results) = DispatchAll
2225            .call(
2226                ToolArgs {
2227                    positional: vec![uses],
2228                    named: Vec::new(),
2229                },
2230                &ctx,
2231            )
2232            .await
2233            .unwrap()
2234        else {
2235            panic!("dispatch result list");
2236        };
2237        let Value::Message(message) = &results[0] else {
2238            panic!("tool result message");
2239        };
2240        let crate::message::MessagePart::ToolResult { content, .. } = &message.parts[0] else {
2241            panic!("tool result part");
2242        };
2243        let envelope: serde_json::Value = serde_json::from_str(content).unwrap();
2244        let output_id = envelope["output_id"].as_str().unwrap();
2245        let mut reconstructed = envelope["content"].as_str().unwrap().to_string();
2246        let mut offset = envelope["next"]["offset"].as_u64().unwrap() as usize;
2247        while envelope["next"]["has_more"].as_bool().unwrap() || offset < expected.len() {
2248            let page = ctx
2249                .output_store
2250                .as_ref()
2251                .unwrap()
2252                .read_bytes(output_id, offset, usize::MAX, ctx.tool_output_budget)
2253                .unwrap();
2254            reconstructed.push_str(&page.content);
2255            if !page.has_more {
2256                break;
2257            }
2258            assert!(page.next_offset > offset);
2259            offset = page.next_offset;
2260        }
2261        assert_eq!(reconstructed.len(), 1_048_576);
2262        assert_eq!(reconstructed, expected);
2263    }
2264
2265    #[tokio::test]
2266    async fn dispatch_all_returns_the_same_truncated_message_it_emits() {
2267        let registry = crate::tool::ToolRegistry::new();
2268        registry.register(std::sync::Arc::new(TextTool {
2269            name: "text",
2270            output: "0123456789".repeat(20),
2271        }));
2272        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2273        let events = crate::event::EventSink::new();
2274        let mut ctx = ToolCtx::new()
2275            .with_registry(std::sync::Arc::new(registry))
2276            .with_stream_tx(stream_tx)
2277            .with_events(events.clone());
2278        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2279            max_lines: 32,
2280            max_bytes: 24,
2281            max_line_bytes: 24,
2282        };
2283        let uses = Value::List(vec![Value::Struct(vec![
2284            ("id".into(), Value::Str("text_id".into())),
2285            ("name".into(), Value::Str("text".into())),
2286            ("input".into(), Value::Struct(Vec::new())),
2287        ])]);
2288        let Value::List(results) = DispatchAll
2289            .call(
2290                ToolArgs {
2291                    positional: vec![uses],
2292                    named: Vec::new(),
2293                },
2294                &ctx,
2295            )
2296            .await
2297            .unwrap()
2298        else {
2299            panic!("dispatch result list");
2300        };
2301        let Value::Message(returned) = &results[0] else {
2302            panic!("tool result message");
2303        };
2304        let crate::stream::StreamFrame::ToolResultMsg {
2305            message: emitted, ..
2306        } = stream_rx.try_recv().unwrap()
2307        else {
2308            panic!("tool result stream frame");
2309        };
2310        assert_eq!(returned, &emitted);
2311        assert!(matches!(
2312            &returned.parts[0],
2313            crate::message::MessagePart::ToolResult { content, .. }
2314                if content.contains("Output truncated")
2315        ));
2316        let returned_bytes = match &returned.parts[0] {
2317            crate::message::MessagePart::ToolResult { content, .. } => content.len() as u64,
2318            _ => unreachable!(),
2319        };
2320        let metrics = events
2321            .snapshot()
2322            .into_iter()
2323            .find_map(|event| match event {
2324                crate::event::Event::ToolResultMetrics {
2325                    tool_use_id,
2326                    raw_bytes,
2327                    excerpt_bytes,
2328                    truncated,
2329                    ..
2330                } if tool_use_id == "text_id" => Some((raw_bytes, excerpt_bytes, truncated)),
2331                _ => None,
2332            })
2333            .expect("tool result metrics");
2334        assert!(metrics.0 > 0);
2335        assert_eq!((metrics.1, metrics.2), (returned_bytes, true));
2336    }
2337
2338    #[test]
2339    fn finish_dispatch_returns_the_budgeted_message_it_emits() {
2340        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2341        let events = crate::event::EventSink::new();
2342        let mut ctx = ToolCtx::new()
2343            .with_stream_tx(stream_tx)
2344            .with_events(events.clone());
2345        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2346            max_lines: 32,
2347            max_bytes: 24,
2348            max_line_bytes: 24,
2349        };
2350        let Value::Message(returned) = finish_dispatch(
2351            &ctx,
2352            "text_id",
2353            "text",
2354            Ok(Value::Str("0123456789".repeat(20))),
2355        ) else {
2356            panic!("tool result message");
2357        };
2358        let crate::stream::StreamFrame::ToolResultMsg {
2359            message: emitted, ..
2360        } = stream_rx.try_recv().unwrap()
2361        else {
2362            panic!("tool result stream frame");
2363        };
2364
2365        assert_eq!(returned, emitted);
2366        assert!(matches!(
2367            &returned.parts[0],
2368            crate::message::MessagePart::ToolResult { content, .. }
2369                if content.contains("Output truncated")
2370        ));
2371        let excerpt_bytes = match &returned.parts[0] {
2372            crate::message::MessagePart::ToolResult { content, .. } => content.len() as u64,
2373            _ => unreachable!(),
2374        };
2375        assert!(events.snapshot().into_iter().any(|event| matches!(
2376            event,
2377            crate::event::Event::ToolResultMetrics {
2378                tool_use_id,
2379                raw_bytes: 200,
2380                excerpt_bytes: observed,
2381                truncated: true,
2382                ..
2383            } if tool_use_id == "text_id" && observed == excerpt_bytes
2384        )));
2385    }
2386
2387    #[test]
2388    fn finish_dispatch_preserves_error_flag_and_message_consistency() {
2389        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2390        let ctx = ToolCtx::new().with_stream_tx(stream_tx);
2391        let Value::Message(returned) = finish_dispatch(
2392            &ctx,
2393            "error_id",
2394            "failing",
2395            Err(RuntimeError::ToolFailed("expected failure".into())),
2396        ) else {
2397            panic!("tool result message");
2398        };
2399        let crate::stream::StreamFrame::ToolResultMsg {
2400            message: emitted, ..
2401        } = stream_rx.try_recv().unwrap()
2402        else {
2403            panic!("tool result stream frame");
2404        };
2405
2406        assert_eq!(returned, emitted);
2407        assert!(matches!(
2408            &returned.parts[0],
2409            crate::message::MessagePart::ToolResult {
2410                content,
2411                is_error: true,
2412                ..
2413            } if content.contains("expected failure")
2414        ));
2415    }
2416
2417    #[test]
2418    fn finish_dispatch_emits_full_diff_preview_before_result_truncation() {
2419        let events = crate::event::EventSink::new();
2420        let mut ctx = ToolCtx::new().with_events(events.clone());
2421        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2422            max_lines: 1,
2423            max_bytes: 16,
2424            max_line_bytes: 16,
2425        };
2426        let diff = "-old\n+new\n".repeat(20);
2427        let Value::Message(returned) = finish_dispatch(
2428            &ctx,
2429            "edit_id",
2430            "fs.edit",
2431            Ok(Value::Struct(vec![
2432                (
2433                    "summary".into(),
2434                    Value::Str("[fs.edit(example.txt): updated]".into()),
2435                ),
2436                ("diff".into(), Value::Str(diff.clone())),
2437            ])),
2438        ) else {
2439            panic!("tool result message");
2440        };
2441
2442        assert!(matches!(
2443            &returned.parts[0],
2444            crate::message::MessagePart::ToolResult { content, .. }
2445                if content.contains("Output truncated")
2446        ));
2447        assert!(events.snapshot().iter().any(|event| matches!(
2448            event,
2449            crate::event::Event::DiffPreview {
2450                tool_use_id: Some(tool_use_id),
2451                unified_diff: Some(preview),
2452                ..
2453            } if tool_use_id == "edit_id" && preview == &diff
2454        )));
2455    }
2456
2457    #[tokio::test]
2458    async fn dispatch_all_returns_the_same_truncated_preflight_error_it_emits() {
2459        let registry = crate::tool::ToolRegistry::new();
2460        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2461        let mut ctx = ToolCtx::new()
2462            .with_registry(std::sync::Arc::new(registry))
2463            .with_stream_tx(stream_tx);
2464        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2465            max_lines: 32,
2466            max_bytes: 32,
2467            max_line_bytes: 32,
2468        };
2469        let uses = Value::List(vec![Value::Struct(vec![
2470            ("id".into(), Value::Str("unknown_id".into())),
2471            ("name".into(), Value::Str("missing".repeat(40))),
2472            ("input".into(), Value::Struct(Vec::new())),
2473        ])]);
2474
2475        let Value::List(results) = DispatchAll
2476            .call(
2477                ToolArgs {
2478                    positional: vec![uses],
2479                    named: Vec::new(),
2480                },
2481                &ctx,
2482            )
2483            .await
2484            .unwrap()
2485        else {
2486            panic!("dispatch result list");
2487        };
2488        let Value::Message(returned) = &results[0] else {
2489            panic!("tool result message");
2490        };
2491        let crate::stream::StreamFrame::ToolResultMsg {
2492            message: emitted, ..
2493        } = stream_rx.try_recv().unwrap()
2494        else {
2495            panic!("tool result stream frame");
2496        };
2497
2498        assert_eq!(returned, &emitted);
2499        assert!(matches!(
2500            &returned.parts[0],
2501            crate::message::MessagePart::ToolResult {
2502                content,
2503                is_error: true,
2504                ..
2505            } if content.contains("Output truncated")
2506        ));
2507    }
2508
2509    #[tokio::test]
2510    async fn dispatch_all_unknown_tool_finishes_its_workflow_node_with_error() {
2511        use crate::workflow::{NodeStatus, WorkflowGraph};
2512
2513        let registry = crate::tool::ToolRegistry::new();
2514        let run_id = crate::event::FlowRunId::now();
2515        let run = run_id.0.to_string();
2516        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2517        let ctx = ToolCtx::new()
2518            .with_anchors(None, Some(run_id), None)
2519            .with_current_node(Some("dispatch_all".into()))
2520            .with_registry(std::sync::Arc::new(registry))
2521            .with_stream_tx(stream_tx);
2522        let uses = Value::List(vec![Value::Struct(vec![
2523            ("id".into(), Value::Str("unknown_id".into())),
2524            ("name".into(), Value::Str("missing.tool".into())),
2525            ("input".into(), Value::Struct(Vec::new())),
2526        ])]);
2527
2528        DispatchAll
2529            .call(
2530                ToolArgs {
2531                    positional: vec![uses],
2532                    named: Vec::new(),
2533                },
2534                &ctx,
2535            )
2536            .await
2537            .unwrap();
2538
2539        let mut graph = WorkflowGraph::new(crate::event::TurnId::now());
2540        graph.apply_stream_frame(&crate::stream::StreamFrame::FlowStart {
2541            run_id: run.clone(),
2542            flow_name: "agent_loop".into(),
2543            parent_run_id: None,
2544            parent_node_id: None,
2545        });
2546        graph.apply_stream_frame(&crate::stream::StreamFrame::FlowNodeStart {
2547            run_id: run.clone(),
2548            node_id: "dispatch_all".into(),
2549            kind: crate::nodegraph::NodeKind::ToolCall {
2550                path: "dispatch_all".into(),
2551            },
2552            label: "dispatch_all".into(),
2553            parent_node_id: None,
2554        });
2555        while let Ok(frame) = stream_rx.try_recv() {
2556            graph.apply_stream_frame(&frame);
2557        }
2558
2559        let node = graph
2560            .find_node(&format!("tool:{run}:unknown_id"))
2561            .expect("unknown tool node");
2562        assert_eq!(node.status, NodeStatus::Err);
2563        assert!(matches!(
2564            &node.kind,
2565            crate::workflow::WorkflowNodeKind::ToolCall {
2566                result_preview: Some(preview),
2567                ..
2568            } if preview.contains("unknown tool")
2569        ));
2570        let result = node.output_preview.as_deref().unwrap();
2571        assert!(result.contains("unknown tool"));
2572        assert!(
2573            graph
2574                .root
2575                .iter()
2576                .flat_map(|root| &root.children)
2577                .all(|child| {
2578                    !matches!(
2579                        &child.kind,
2580                        crate::workflow::WorkflowNodeKind::ToolCall { tool_use_id, .. }
2581                            if tool_use_id == "unknown_id"
2582                    )
2583                })
2584        );
2585    }
2586
2587    #[test]
2588    fn prepare_dispatch_does_not_emit_partial_nodes_for_malformed_batch() {
2589        let registry = crate::tool::ToolRegistry::new();
2590        let run_id = crate::event::FlowRunId::now();
2591        let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(8);
2592        let ctx = ToolCtx::new()
2593            .with_anchors(None, Some(run_id), None)
2594            .with_current_node(Some("dispatch_all".into()))
2595            .with_stream_tx(stream_tx);
2596        let uses = vec![
2597            Value::Struct(vec![
2598                ("id".into(), Value::Str("valid_id".into())),
2599                ("name".into(), Value::Str("missing.tool".into())),
2600            ]),
2601            Value::Struct(vec![("name".into(), Value::Str("missing.tool".into()))]),
2602        ];
2603
2604        assert!(prepare_dispatch(&uses, &registry, &ctx).is_err());
2605        assert!(matches!(
2606            stream_rx.try_recv(),
2607            Err(tokio::sync::broadcast::error::TryRecvError::Empty)
2608        ));
2609    }
2610
2611    #[test]
2612    fn compose_email_preview_formats_headers() {
2613        let preview = compose_email_preview(
2614            "Deploy status",
2615            "See attached",
2616            &["a@x.com".into(), "b@x.com".into()],
2617        );
2618        assert_eq!(
2619            preview,
2620            "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
2621        );
2622    }
2623}