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 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 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 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(®istry);
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, ®istry, &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}