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