1use crate::approval::{ApprovalOutcome, request_approval};
2use crate::error::RuntimeError;
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5
6pub struct ShellQuote;
7
8impl Tool for ShellQuote {
9 fn name(&self) -> &str {
10 "shell_quote"
11 }
12
13 fn tier(&self) -> Tier {
14 Tier::Zero
15 }
16
17 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
18 Box::pin(async move {
19 let s = extract_string(&args, "s", 0)?;
20 Ok(Value::Str(shell_quote(&s)))
21 })
22 }
23}
24
25pub fn shell_quote(s: &str) -> String {
26 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 seq: 0,
270 session_id: ctx
271 .turn_id
272 .as_ref()
273 .map(|t| t.0.to_string())
274 .unwrap_or_default(),
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 ts: chrono::Utc::now(),
282 });
283 }
284 if let Some(tx) = &ctx.lifecycle_fire_tx {
285 let _ = tx.send(atman_dsl::ast::LifecycleEvent::ContextCompact);
286 }
287 let list: Vec<Value> = out.into_iter().map(Value::Message).collect();
288 Ok(Value::List(list))
289 })
290 }
291}
292
293fn extract_message_list(
294 args: &ToolArgs,
295 name: &str,
296 pos: usize,
297) -> Result<Vec<crate::message::Message>, RuntimeError> {
298 let value = match args.named(name) {
299 Some(v) => v,
300 None => args.positional(pos)?,
301 };
302 match value {
303 Value::List(items) => {
304 let mut out = Vec::with_capacity(items.len());
305 for it in items {
306 match it {
307 Value::Message(m) => out.push(m.clone()),
308 other => {
309 return Err(RuntimeError::TypeMismatch {
310 expected: "list of message".into(),
311 actual: other.kind_name().into(),
312 });
313 }
314 }
315 }
316 Ok(out)
317 }
318 other => Err(RuntimeError::TypeMismatch {
319 expected: "list of message".into(),
320 actual: other.kind_name().into(),
321 }),
322 }
323}
324
325fn extract_int(args: &ToolArgs, name: &str, pos: usize) -> Result<i64, RuntimeError> {
326 let value = match args.named(name) {
327 Some(v) => v,
328 None => args.positional(pos)?,
329 };
330 match value {
331 Value::Int(n) => Ok(*n),
332 other => Err(RuntimeError::TypeMismatch {
333 expected: "int".into(),
334 actual: other.kind_name().into(),
335 }),
336 }
337}
338
339fn extract_string_arg(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
340 let value = match args.named(name) {
341 Some(v) => v,
342 None => args.positional(pos)?,
343 };
344 match value {
345 Value::Str(s) => Ok(s.clone()),
346 other => Err(RuntimeError::TypeMismatch {
347 expected: "string".into(),
348 actual: other.kind_name().into(),
349 }),
350 }
351}
352
353pub struct RenderPromptXml;
354pub struct RenderPromptMarkdown;
355pub struct RenderPromptTerse;
356
357fn extract_prompt_spec(v: &Value) -> Result<PromptSpec<'_>, RuntimeError> {
358 let Value::Struct(fields) = v else {
359 return Err(RuntimeError::TypeMismatch {
360 expected: "struct { role?, context?, task, examples?, schema? }".into(),
361 actual: v.kind_name().into(),
362 });
363 };
364 let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v);
365 let task = match get("task") {
366 Some(Value::Str(s)) => s.clone(),
367 Some(other) => {
368 return Err(RuntimeError::TypeMismatch {
369 expected: "string (task)".into(),
370 actual: other.kind_name().into(),
371 });
372 }
373 None => return Err(RuntimeError::MissingArg("prompt.task".into())),
374 };
375 let role = match get("role") {
376 Some(Value::Str(s)) => Some(s.clone()),
377 Some(Value::Unit) | None => None,
378 Some(other) => {
379 return Err(RuntimeError::TypeMismatch {
380 expected: "string (role)".into(),
381 actual: other.kind_name().into(),
382 });
383 }
384 };
385 let context = get("context");
386 let schema = match get("schema") {
387 Some(Value::Str(s)) => Some(s.clone()),
388 _ => None,
389 };
390 let examples = match get("examples") {
391 Some(Value::List(items)) => items.iter().collect(),
392 _ => Vec::new(),
393 };
394 Ok(PromptSpec {
395 role,
396 context,
397 task,
398 examples,
399 schema,
400 })
401}
402
403struct PromptSpec<'a> {
404 role: Option<String>,
405 context: Option<&'a Value>,
406 task: String,
407 examples: Vec<&'a Value>,
408 schema: Option<String>,
409}
410
411fn json_str(v: &Value) -> String {
412 serde_json::to_string_pretty(&v.to_json()).unwrap_or_default()
413}
414
415fn render_xml(spec: &PromptSpec<'_>) -> String {
416 let mut out = String::new();
417 if let Some(role) = &spec.role {
418 out.push_str(&format!("<role>{}</role>\n", role));
419 }
420 if let Some(ctx) = spec.context {
421 out.push_str(&format!("<context>\n{}\n</context>\n", json_str(ctx)));
422 }
423 if !spec.examples.is_empty() {
424 out.push_str("<examples>\n");
425 for (i, ex) in spec.examples.iter().enumerate() {
426 out.push_str(&format!(
427 " <example n=\"{}\">\n{}\n </example>\n",
428 i + 1,
429 json_str(ex)
430 ));
431 }
432 out.push_str("</examples>\n");
433 }
434 out.push_str(&format!("<task>{}</task>\n", spec.task));
435 if let Some(schema) = &spec.schema {
436 out.push_str(&format!("<schema>{}</schema>\n", schema));
437 }
438 out
439}
440
441fn render_markdown(spec: &PromptSpec<'_>) -> String {
442 let mut out = String::new();
443 if let Some(role) = &spec.role {
444 out.push_str(&format!("# Role\n{}\n\n", role));
445 }
446 if let Some(ctx) = spec.context {
447 out.push_str(&format!("# Context\n```json\n{}\n```\n\n", json_str(ctx)));
448 }
449 if !spec.examples.is_empty() {
450 out.push_str("# Examples\n");
451 for (i, ex) in spec.examples.iter().enumerate() {
452 out.push_str(&format!(
453 "{}. `{}`\n",
454 i + 1,
455 json_str(ex).replace('\n', " ")
456 ));
457 }
458 out.push('\n');
459 }
460 out.push_str(&format!("# Task\n{}\n", spec.task));
461 if let Some(schema) = &spec.schema {
462 out.push_str(&format!("\n# Schema\n{}\n", schema));
463 }
464 out
465}
466
467fn render_terse(spec: &PromptSpec<'_>) -> String {
468 let mut out = String::new();
469 if let Some(role) = &spec.role {
470 out.push_str(&format!("Role: {}\n", role));
471 }
472 if let Some(ctx) = spec.context {
473 out.push_str(&format!("Context: {}\n", json_str(ctx).replace('\n', " ")));
474 }
475 out.push_str(&format!("Task: {}\n", spec.task));
476 if let Some(schema) = &spec.schema {
477 out.push_str(&format!("Schema: {}\n", schema));
478 }
479 for (i, ex) in spec.examples.iter().enumerate() {
480 out.push_str(&format!(
481 "Example {}: {}\n",
482 i + 1,
483 json_str(ex).replace('\n', " ")
484 ));
485 }
486 out
487}
488
489impl Tool for RenderPromptXml {
490 fn name(&self) -> &str {
491 "render_prompt_xml"
492 }
493 fn tier(&self) -> Tier {
494 Tier::Zero
495 }
496 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
497 Box::pin(async move {
498 let v = args.positional(0)?;
499 let spec = extract_prompt_spec(v)?;
500 Ok(Value::Str(render_xml(&spec)))
501 })
502 }
503}
504
505impl Tool for RenderPromptMarkdown {
506 fn name(&self) -> &str {
507 "render_prompt_markdown"
508 }
509 fn tier(&self) -> Tier {
510 Tier::Zero
511 }
512 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
513 Box::pin(async move {
514 let v = args.positional(0)?;
515 let spec = extract_prompt_spec(v)?;
516 Ok(Value::Str(render_markdown(&spec)))
517 })
518 }
519}
520
521impl Tool for RenderPromptTerse {
522 fn name(&self) -> &str {
523 "render_prompt_terse"
524 }
525 fn tier(&self) -> Tier {
526 Tier::Zero
527 }
528 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
529 Box::pin(async move {
530 let v = args.positional(0)?;
531 let spec = extract_prompt_spec(v)?;
532 Ok(Value::Str(render_terse(&spec)))
533 })
534 }
535}
536
537pub struct ToJsonString;
538
539impl Tool for ToJsonString {
540 fn name(&self) -> &str {
541 "to_json_string"
542 }
543
544 fn tier(&self) -> Tier {
545 Tier::Zero
546 }
547
548 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
549 Box::pin(async move {
550 let v = args.positional(0)?.clone();
551 let json = v.to_json();
552 let s = serde_json::to_string_pretty(&json)
553 .map_err(|e| RuntimeError::ToolFailed(format!("to_json_string: {e}")))?;
554 Ok(Value::Str(s))
555 })
556 }
557}
558
559pub struct TextConcat;
560
561impl Tool for TextConcat {
562 fn name(&self) -> &str {
563 "text_concat"
564 }
565
566 fn tier(&self) -> Tier {
567 Tier::Zero
568 }
569
570 fn description(&self) -> Option<&str> {
571 Some("Flatten the text parts of a Message into a single string.")
572 }
573
574 fn input_schema(&self) -> serde_json::Value {
575 serde_json::json!({
576 "type": "object",
577 "properties": {"message": {"description": "A Message value from an llm call."}},
578 "required": ["message"]
579 })
580 }
581
582 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
583 Box::pin(async move {
584 let v = match args.named("message") {
585 Some(v) => v,
586 None => args.positional(0)?,
587 };
588 match v {
589 Value::Message(m) => Ok(Value::Str(m.text_concat())),
590 Value::Str(s) => Ok(Value::Str(s.clone())),
591 other => Err(RuntimeError::TypeMismatch {
592 expected: "message or string".into(),
593 actual: other.kind_name().into(),
594 }),
595 }
596 })
597 }
598}
599
600pub struct Concat;
601
602impl Tool for Concat {
603 fn name(&self) -> &str {
604 "concat"
605 }
606
607 fn tier(&self) -> Tier {
608 Tier::Zero
609 }
610
611 fn description(&self) -> Option<&str> {
612 Some("Concatenate two lists into a single new list.")
613 }
614
615 fn input_schema(&self) -> serde_json::Value {
616 serde_json::json!({
617 "type": "object",
618 "properties": {
619 "left": {"type": "array"},
620 "right": {"type": "array"}
621 },
622 "required": ["left", "right"]
623 })
624 }
625
626 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
627 Box::pin(async move {
628 let left = extract_list(&args, "left", 0)?;
629 let right = extract_list(&args, "right", 1)?;
630 let mut out = Vec::with_capacity(left.len() + right.len());
631 out.extend(left);
632 out.extend(right);
633 Ok(Value::List(out))
634 })
635 }
636}
637
638pub struct ExtractToolUses;
639
640impl Tool for ExtractToolUses {
641 fn name(&self) -> &str {
642 "extract_tool_uses"
643 }
644
645 fn tier(&self) -> Tier {
646 Tier::Zero
647 }
648
649 fn description(&self) -> Option<&str> {
650 Some(
651 "Pull the tool_use parts out of an assistant Message. Returns a list of \
652 {id, name, input} structs suitable for dispatch_all.",
653 )
654 }
655
656 fn input_schema(&self) -> serde_json::Value {
657 serde_json::json!({
658 "type": "object",
659 "properties": {"message": {"description": "Assistant Message value."}},
660 "required": ["message"]
661 })
662 }
663
664 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
665 Box::pin(async move {
666 let v = match args.named("message") {
667 Some(v) => v,
668 None => args.positional(0)?,
669 };
670 let m = match v {
671 Value::Message(m) => m,
672 Value::Str(_) => return Ok(Value::List(Vec::new())),
673 other => {
674 return Err(RuntimeError::TypeMismatch {
675 expected: "message or string".into(),
676 actual: other.kind_name().into(),
677 });
678 }
679 };
680 let mut out = Vec::new();
681 for part in &m.parts {
682 if let crate::message::MessagePart::ToolUse { id, name, input } = part {
683 out.push(Value::Struct(vec![
684 ("id".into(), Value::Str(id.clone())),
685 ("name".into(), Value::Str(name.clone())),
686 ("input".into(), Value::from_json(input.clone())),
687 ]));
688 }
689 }
690 Ok(Value::List(out))
691 })
692 }
693}
694
695pub struct DispatchAll;
696
697impl Tool for DispatchAll {
698 fn name(&self) -> &str {
699 "dispatch_all"
700 }
701
702 fn tier(&self) -> Tier {
703 Tier::Zero
704 }
705
706 fn description(&self) -> Option<&str> {
707 Some(
708 "Dispatch each tool_use in the list against the current tool registry and \
709 return a list of tool_result Message values.",
710 )
711 }
712
713 fn input_schema(&self) -> serde_json::Value {
714 serde_json::json!({
715 "type": "object",
716 "properties": {"tool_uses": {"type": "array"}},
717 "required": ["tool_uses"]
718 })
719 }
720
721 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
722 Box::pin(async move {
723 let uses = extract_list(&args, "tool_uses", 0)?;
724 let Some(registry) = ctx.registry.as_ref() else {
725 return Err(RuntimeError::ToolFailed(
726 "dispatch_all: no tool registry available on ctx".into(),
727 ));
728 };
729 let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
730 let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
731 run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
732 run_serial(serial_batch, ctx, &mut out_slots).await;
733 let out: Vec<Value> = out_slots.into_iter().flatten().collect();
734 Ok(Value::List(out))
735 })
736 }
737}
738
739enum PreparedEntry {
740 Ready {
741 index: usize,
742 id: String,
743 name: String,
744 tool: std::sync::Arc<dyn Tool>,
745 call_args: ToolArgs,
746 },
747 Failed {
748 index: usize,
749 msg: crate::message::Message,
750 },
751}
752
753fn prepare_dispatch(
754 uses: &[Value],
755 registry: &crate::tool::ToolRegistry,
756 ctx: &ToolCtx,
757) -> Result<Vec<PreparedEntry>, RuntimeError> {
758 let mut prepared = Vec::with_capacity(uses.len());
759 for (index, entry) in uses.iter().enumerate() {
760 let Value::Struct(fields) = entry else {
761 return Err(RuntimeError::TypeMismatch {
762 expected: "struct {id, name, input}".into(),
763 actual: entry.kind_name().into(),
764 });
765 };
766 let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
767 let id = match get("id") {
768 Some(Value::Str(s)) => s,
769 _ => {
770 return Err(RuntimeError::ToolFailed(
771 "dispatch_all: tool_use missing `id` string".into(),
772 ));
773 }
774 };
775 let name = match get("name") {
776 Some(Value::Str(s)) => s,
777 _ => {
778 return Err(RuntimeError::ToolFailed(
779 "dispatch_all: tool_use missing `name` string".into(),
780 ));
781 }
782 };
783 let input = get("input").unwrap_or(Value::Unit);
784 let Some(tool) = registry.get(&name) else {
785 prepared.push(PreparedEntry::Failed {
786 index,
787 msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
788 });
789 continue;
790 };
791 let named = match &input {
792 Value::Struct(fields) => fields.clone(),
793 Value::Unit => Vec::new(),
794 other => {
795 return Err(RuntimeError::TypeMismatch {
796 expected: "struct or unit for tool input".into(),
797 actual: other.kind_name().into(),
798 });
799 }
800 };
801 let missing = missing_required_fields(&tool.input_schema(), &named);
802 if !missing.is_empty() {
803 let content = format!(
804 "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
805 missing.join(", "),
806 missing
807 .iter()
808 .map(|f| format!("\"{f}\":\"...\""))
809 .collect::<Vec<_>>()
810 .join(", ")
811 );
812 prepared.push(PreparedEntry::Failed {
813 index,
814 msg: build_error_result(ctx, &id, &content),
815 });
816 continue;
817 }
818 emit_tool_node(ctx, &id, &name, &input);
819 prepared.push(PreparedEntry::Ready {
820 index,
821 id,
822 name,
823 tool,
824 call_args: ToolArgs {
825 positional: Vec::new(),
826 named,
827 },
828 });
829 }
830 Ok(prepared)
831}
832
833struct Approved {
834 index: usize,
835 id: String,
836 name: String,
837 tool: std::sync::Arc<dyn Tool>,
838 call_args: ToolArgs,
839}
840
841async fn partition_and_gate(
842 prepared: Vec<PreparedEntry>,
843 ctx: &ToolCtx,
844) -> (Vec<Approved>, Vec<Approved>, Vec<Option<Value>>) {
845 let total = prepared.len();
846 let mut out_slots: Vec<Option<Value>> = vec![None; total];
847 struct ReadyEntry {
848 index: usize,
849 id: String,
850 name: String,
851 tool: std::sync::Arc<dyn Tool>,
852 call_args: ToolArgs,
853 }
854 let mut ready: Vec<ReadyEntry> = Vec::new();
855 for entry in prepared {
856 match entry {
857 PreparedEntry::Failed { index, msg } => {
858 emit_tool_result(ctx, &msg);
859 out_slots[index] = Some(Value::Message(msg));
860 }
861 PreparedEntry::Ready {
862 index,
863 id,
864 name,
865 tool,
866 call_args,
867 } => {
868 ready.push(ReadyEntry {
869 index,
870 id,
871 name,
872 tool,
873 call_args,
874 });
875 }
876 }
877 }
878 let gates = ready.iter().map(|r| {
880 let level = r.tool.approval_level(&r.call_args, ctx);
881 request_approval(
882 ctx,
883 &r.id,
884 &r.name,
885 &r.call_args,
886 level,
887 Some(r.tool.as_ref()),
888 )
889 });
890 let outcomes = futures::future::join_all(gates).await;
891 let mut auto_batch = Vec::new();
892 let mut serial_batch = Vec::new();
893 for (r, outcome) in ready.into_iter().zip(outcomes) {
894 let level = r.tool.approval_level(&r.call_args, ctx);
895 match outcome {
896 ApprovalOutcome::Approve => {
897 let a = Approved {
898 index: r.index,
899 id: r.id,
900 name: r.name.clone(),
901 tool: r.tool,
902 call_args: r.call_args,
903 };
904 if level == crate::tool::ApprovalLevel::Auto {
905 auto_batch.push(a);
906 } else {
907 serial_batch.push(a);
908 }
909 }
910 ApprovalOutcome::Deny { reason } => {
911 let msg = build_error_result(
912 ctx,
913 &r.id,
914 &format!("tool `{}` denied by user: {reason}", r.name),
915 );
916 emit_tool_result(ctx, &msg);
917 out_slots[r.index] = Some(Value::Message(msg));
918 }
919 }
920 }
921 (auto_batch, serial_batch, out_slots)
922}
923
924async fn run_auto_parallel(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
925 if batch.is_empty() {
926 return;
927 }
928 let futs = batch.iter().map(|a| a.tool.call(a.call_args.clone(), ctx));
929 let results = futures::future::join_all(futs).await;
930 for (a, r) in batch.into_iter().zip(results) {
931 let (content, is_error) = match &r {
932 Ok(v) => (render_tool_result_text(v), false),
933 Err(e) => (format!("{e}"), true),
934 };
935 if let Ok(v) = &r {
936 emit_diff_preview_if_relevant(ctx, &a.name, v);
937 }
938 let msg = crate::message::Message {
939 role: crate::message::MessageRole::Tool,
940 parts: vec![crate::message::MessagePart::ToolResult {
941 tool_use_id: a.id.clone(),
942 content,
943 is_error,
944 }],
945 turn_id: ctx
946 .turn_id
947 .clone()
948 .unwrap_or_else(crate::event::TurnId::now),
949 };
950 emit_tool_result(ctx, &msg);
951 out_slots[a.index] = Some(Value::Message(msg));
952 }
953}
954
955async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
956 for a in batch {
957 let r = a.tool.call(a.call_args, ctx).await;
958 let (content, is_error) = match &r {
959 Ok(v) => (render_tool_result_text(v), false),
960 Err(e) => (format!("{e}"), true),
961 };
962 if let Ok(v) = &r {
963 emit_diff_preview_if_relevant(ctx, &a.name, v);
964 }
965 let msg = crate::message::Message {
966 role: crate::message::MessageRole::Tool,
967 parts: vec![crate::message::MessagePart::ToolResult {
968 tool_use_id: a.id.clone(),
969 content,
970 is_error,
971 }],
972 turn_id: ctx
973 .turn_id
974 .clone()
975 .unwrap_or_else(crate::event::TurnId::now),
976 };
977 emit_tool_result(ctx, &msg);
978 out_slots[a.index] = Some(Value::Message(msg));
979 }
980}
981
982type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
983
984fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
985 let Some(sink) = ctx.events.as_ref() else {
986 return;
987 };
988 let data: Option<DiffPreviewData> = match tool_name {
989 "fs.edit" => {
990 let path = value_struct_string(value, "summary").and_then(|s| {
991 s.strip_prefix("[fs.edit(")
992 .and_then(|s| s.split(':').next())
993 .map(|s| s.trim_end_matches(')').to_string())
994 });
995 let diff = value_struct_string(value, "diff");
996 diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
997 }
998 "fs.write" => {
999 let path = value_struct_string(value, "path").unwrap_or_default();
1000 let diff = value_struct_string(value, "diff");
1001 diff.map(|d| (path, None, None, Some(d)))
1002 }
1003 "git.diff" => {
1004 let Some(diff) = value_struct_string(value, "diff") else {
1005 return;
1006 };
1007 Some(("git diff".into(), None, None, Some(diff)))
1008 }
1009 "git.show" => {
1010 let sha = value_struct_string(value, "sha").unwrap_or_default();
1011 let Some(diff) = value_struct_string(value, "diff") else {
1012 return;
1013 };
1014 Some((format!("git show {sha}"), None, None, Some(diff)))
1015 }
1016 "git.log" => {
1017 let Some(diff) = value_struct_string(value, "diff") else {
1018 return;
1019 };
1020 Some(("git log HEAD".into(), None, None, Some(diff)))
1021 }
1022 _ => None,
1023 };
1024 if let Some((title, old_content, new_content, unified_diff)) = data {
1025 sink.emit(crate::event::Event::DiffPreview {
1026 seq: 0,
1027 turn_id: ctx.turn_id.clone(),
1028 flow_run_id: ctx.flow_run_id.clone(),
1029 title,
1030 old_content,
1031 new_content,
1032 unified_diff,
1033 ts: chrono::Utc::now(),
1034 });
1035 }
1036}
1037
1038fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1039 if let Value::Struct(fields) = value {
1040 fields
1041 .iter()
1042 .find(|(k, _)| k == field)
1043 .and_then(|(_, v)| match v {
1044 Value::Str(s) => Some(s.clone()),
1045 _ => None,
1046 })
1047 } else {
1048 None
1049 }
1050}
1051
1052fn emit_tool_node(ctx: &ToolCtx, id: &str, name: &str, input: &Value) {
1053 if let (Some(sink), Some(run_id), Some(parent_node)) = (
1054 ctx.events.as_ref(),
1055 ctx.flow_run_id.clone(),
1056 &ctx.current_node_id,
1057 ) {
1058 let args_preview = format!("{:?}", input)
1059 .chars()
1060 .take(4000)
1061 .collect::<String>();
1062 sink.emit(crate::event::Event::ToolNode {
1063 seq: 0,
1064 run_id: run_id.clone(),
1065 parent_node_id: parent_node.clone(),
1066 tool_use_id: id.to_string(),
1067 tool_name: name.to_string(),
1068 args_preview: args_preview.clone(),
1069 ts: chrono::Utc::now(),
1070 });
1071 if let Some(tx) = &ctx.stream_tx {
1072 let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1073 run_id: run_id.0.to_string(),
1074 parent_node_id: parent_node.clone(),
1075 tool_use_id: id.to_string(),
1076 tool: name.to_string(),
1077 args_preview,
1078 });
1079 }
1080 }
1081}
1082
1083fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1084 crate::message::Message {
1085 role: crate::message::MessageRole::Tool,
1086 parts: vec![crate::message::MessagePart::ToolResult {
1087 tool_use_id: tool_use_id.to_string(),
1088 content: content.to_string(),
1089 is_error: true,
1090 }],
1091 turn_id: ctx
1092 .turn_id
1093 .clone()
1094 .unwrap_or_else(crate::event::TurnId::now),
1095 }
1096}
1097
1098fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1099 let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1100 return Vec::new();
1101 };
1102 let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1103 required
1104 .iter()
1105 .filter_map(|v| v.as_str())
1106 .filter(|k| !have.contains(k))
1107 .map(String::from)
1108 .collect()
1109}
1110
1111fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) {
1112 let Some(sink) = &ctx.events else {
1113 return;
1114 };
1115 sink.emit(crate::event::Event::ToolResultMsg {
1116 seq: 0,
1117 turn_id: msg.turn_id.clone(),
1118 flow_run_id: ctx.flow_run_id.clone(),
1119 message: msg.clone(),
1120 ts: chrono::Utc::now(),
1121 });
1122 if let Some(tx) = &ctx.stream_tx {
1123 let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1124 flow_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1125 message: msg.clone(),
1126 });
1127 }
1128}
1129
1130fn render_tool_result_text(v: &Value) -> String {
1131 match v {
1132 Value::Str(s) => s.clone(),
1133 Value::Message(m) => m.text_concat(),
1134 other => other.to_json().to_string(),
1135 }
1136}
1137
1138fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1139 let value = match args.named(name) {
1140 Some(v) => v,
1141 None => args.positional(pos)?,
1142 };
1143 match value {
1144 Value::List(items) => Ok(items.clone()),
1145 other => Err(RuntimeError::TypeMismatch {
1146 expected: "list".into(),
1147 actual: other.kind_name().into(),
1148 }),
1149 }
1150}
1151
1152async fn call_named_unary(
1153 ctx: &ToolCtx,
1154 fn_name: &str,
1155 element: Value,
1156) -> Result<Value, RuntimeError> {
1157 let Some(registry) = ctx.registry.as_ref() else {
1158 return Err(RuntimeError::ToolFailed(format!(
1159 "list combinator: no tool registry available to resolve `{fn_name}`"
1160 )));
1161 };
1162 let Some(tool) = registry.get(fn_name) else {
1163 return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1164 };
1165 let args = ToolArgs {
1166 positional: vec![element],
1167 named: Vec::new(),
1168 };
1169 tool.call(args, ctx).await
1170}
1171
1172async fn call_named_binary(
1173 ctx: &ToolCtx,
1174 fn_name: &str,
1175 a: Value,
1176 b: Value,
1177) -> Result<Value, RuntimeError> {
1178 let Some(registry) = ctx.registry.as_ref() else {
1179 return Err(RuntimeError::ToolFailed(format!(
1180 "list combinator: no tool registry available to resolve `{fn_name}`"
1181 )));
1182 };
1183 let Some(tool) = registry.get(fn_name) else {
1184 return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1185 };
1186 let args = ToolArgs {
1187 positional: vec![a, b],
1188 named: Vec::new(),
1189 };
1190 tool.call(args, ctx).await
1191}
1192
1193fn value_as_bool(v: Value, fn_name: &str) -> Result<bool, RuntimeError> {
1194 match v {
1195 Value::Bool(b) => Ok(b),
1196 other => Err(RuntimeError::TypeMismatch {
1197 expected: format!("bool returned by `{fn_name}`"),
1198 actual: other.kind_name().into(),
1199 }),
1200 }
1201}
1202
1203pub struct ListMap;
1204
1205impl Tool for ListMap {
1206 fn name(&self) -> &str {
1207 "list_map"
1208 }
1209 fn tier(&self) -> Tier {
1210 Tier::Zero
1211 }
1212 fn description(&self) -> Option<&str> {
1213 Some("Apply a named tool to each item in a list; returns the transformed list.")
1214 }
1215 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1216 Box::pin(async move {
1217 let items = extract_list(&args, "list", 0)?;
1218 let fn_name = extract_string(&args, "fn_name", 1)?;
1219 let mut out = Vec::with_capacity(items.len());
1220 for it in items {
1221 out.push(call_named_unary(ctx, &fn_name, it).await?);
1222 }
1223 Ok(Value::List(out))
1224 })
1225 }
1226}
1227
1228pub struct ListFilter;
1229
1230impl Tool for ListFilter {
1231 fn name(&self) -> &str {
1232 "list_filter"
1233 }
1234 fn tier(&self) -> Tier {
1235 Tier::Zero
1236 }
1237 fn description(&self) -> Option<&str> {
1238 Some("Keep items where the named predicate tool returns true.")
1239 }
1240 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1241 Box::pin(async move {
1242 let items = extract_list(&args, "list", 0)?;
1243 let fn_name = extract_string(&args, "fn_name", 1)?;
1244 let mut out = Vec::new();
1245 for it in items {
1246 let keep =
1247 value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1248 if keep {
1249 out.push(it);
1250 }
1251 }
1252 Ok(Value::List(out))
1253 })
1254 }
1255}
1256
1257pub struct ListFind;
1258
1259impl Tool for ListFind {
1260 fn name(&self) -> &str {
1261 "list_find"
1262 }
1263 fn tier(&self) -> Tier {
1264 Tier::Zero
1265 }
1266 fn description(&self) -> Option<&str> {
1267 Some("Return the first item where the named predicate tool returns true, else unit.")
1268 }
1269 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1270 Box::pin(async move {
1271 let items = extract_list(&args, "list", 0)?;
1272 let fn_name = extract_string(&args, "fn_name", 1)?;
1273 for it in items {
1274 let hit =
1275 value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1276 if hit {
1277 return Ok(it);
1278 }
1279 }
1280 Ok(Value::Unit)
1281 })
1282 }
1283}
1284
1285pub struct ListAny;
1286
1287impl Tool for ListAny {
1288 fn name(&self) -> &str {
1289 "list_any"
1290 }
1291 fn tier(&self) -> Tier {
1292 Tier::Zero
1293 }
1294 fn description(&self) -> Option<&str> {
1295 Some("True if the named predicate tool returns true for any item.")
1296 }
1297 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1298 Box::pin(async move {
1299 let items = extract_list(&args, "list", 0)?;
1300 let fn_name = extract_string(&args, "fn_name", 1)?;
1301 for it in items {
1302 let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1303 if hit {
1304 return Ok(Value::Bool(true));
1305 }
1306 }
1307 Ok(Value::Bool(false))
1308 })
1309 }
1310}
1311
1312pub struct ListAll;
1313
1314impl Tool for ListAll {
1315 fn name(&self) -> &str {
1316 "list_all"
1317 }
1318 fn tier(&self) -> Tier {
1319 Tier::Zero
1320 }
1321 fn description(&self) -> Option<&str> {
1322 Some("True if the named predicate tool returns true for every item.")
1323 }
1324 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1325 Box::pin(async move {
1326 let items = extract_list(&args, "list", 0)?;
1327 let fn_name = extract_string(&args, "fn_name", 1)?;
1328 for it in items {
1329 let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1330 if !hit {
1331 return Ok(Value::Bool(false));
1332 }
1333 }
1334 Ok(Value::Bool(true))
1335 })
1336 }
1337}
1338
1339pub struct ListReduce;
1340
1341impl Tool for ListReduce {
1342 fn name(&self) -> &str {
1343 "list_reduce"
1344 }
1345 fn tier(&self) -> Tier {
1346 Tier::Zero
1347 }
1348 fn description(&self) -> Option<&str> {
1349 Some(
1350 "Fold a list left-to-right using a named binary tool: fn(acc, elem) -> acc'. \
1351 Takes an initial accumulator value.",
1352 )
1353 }
1354 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1355 Box::pin(async move {
1356 let items = extract_list(&args, "list", 0)?;
1357 let fn_name = extract_string(&args, "fn_name", 1)?;
1358 let init = match args.named("init") {
1359 Some(v) => v.clone(),
1360 None => args.positional(2)?.clone(),
1361 };
1362 let mut acc = init;
1363 for it in items {
1364 acc = call_named_binary(ctx, &fn_name, acc, it).await?;
1365 }
1366 Ok(acc)
1367 })
1368 }
1369}
1370
1371pub struct ComposeEmailPreview;
1372
1373impl Tool for ComposeEmailPreview {
1374 fn name(&self) -> &str {
1375 "compose_email_preview"
1376 }
1377
1378 fn tier(&self) -> Tier {
1379 Tier::Zero
1380 }
1381
1382 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1383 Box::pin(async move {
1384 let subject = extract_string(&args, "subject", 0)?;
1385 let body = extract_string(&args, "body", 1)?;
1386 let to = extract_string_list(&args, "to", 2)?;
1387 Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1388 })
1389 }
1390}
1391
1392pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1393 format!("To: {}\nSubject: {subject}\n---\n{body}", to.join(", "))
1394}
1395
1396fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1397 let value = match args.named(name) {
1398 Some(v) => v,
1399 None => args.positional(pos)?,
1400 };
1401 match value {
1402 Value::Str(s) => Ok(s.clone()),
1403 other => Err(RuntimeError::TypeMismatch {
1404 expected: "string".into(),
1405 actual: other.kind_name().into(),
1406 }),
1407 }
1408}
1409
1410fn extract_string_list(
1411 args: &ToolArgs,
1412 name: &str,
1413 pos: usize,
1414) -> Result<Vec<String>, RuntimeError> {
1415 let value = match args.named(name) {
1416 Some(v) => v,
1417 None => args.positional(pos)?,
1418 };
1419 match value {
1420 Value::List(items) => items
1421 .iter()
1422 .map(|v| match v {
1423 Value::Str(s) => Ok(s.clone()),
1424 other => Err(RuntimeError::TypeMismatch {
1425 expected: "list of string".into(),
1426 actual: other.kind_name().into(),
1427 }),
1428 })
1429 .collect(),
1430 other => Err(RuntimeError::TypeMismatch {
1431 expected: "list".into(),
1432 actual: other.kind_name().into(),
1433 }),
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440
1441 #[test]
1442 fn shell_quote_wraps_and_escapes() {
1443 assert_eq!(shell_quote("hello"), "'hello'");
1444 assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1445 assert_eq!(shell_quote(""), "''");
1446 assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1447 }
1448
1449 #[test]
1450 fn compose_email_preview_formats_headers() {
1451 let preview = compose_email_preview(
1452 "Deploy status",
1453 "See attached",
1454 &["a@x.com".into(), "b@x.com".into()],
1455 );
1456 assert_eq!(
1457 preview,
1458 "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
1459 );
1460 }
1461
1462 use crate::tool::ToolRegistry;
1463 use std::sync::Arc;
1464
1465 struct IsBig;
1466 impl Tool for IsBig {
1467 fn name(&self) -> &str {
1468 "is_big"
1469 }
1470 fn tier(&self) -> Tier {
1471 Tier::Zero
1472 }
1473 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1474 Box::pin(async move {
1475 match args.positional(0)? {
1476 Value::Int(n) => Ok(Value::Bool(*n > 10)),
1477 other => Err(RuntimeError::TypeMismatch {
1478 expected: "int".into(),
1479 actual: other.kind_name().into(),
1480 }),
1481 }
1482 })
1483 }
1484 }
1485
1486 struct Double;
1487 impl Tool for Double {
1488 fn name(&self) -> &str {
1489 "double"
1490 }
1491 fn tier(&self) -> Tier {
1492 Tier::Zero
1493 }
1494 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1495 Box::pin(async move {
1496 match args.positional(0)? {
1497 Value::Int(n) => Ok(Value::Int(n * 2)),
1498 other => Err(RuntimeError::TypeMismatch {
1499 expected: "int".into(),
1500 actual: other.kind_name().into(),
1501 }),
1502 }
1503 })
1504 }
1505 }
1506
1507 struct AddInts;
1508 impl Tool for AddInts {
1509 fn name(&self) -> &str {
1510 "add_ints"
1511 }
1512 fn tier(&self) -> Tier {
1513 Tier::Zero
1514 }
1515 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1516 Box::pin(async move {
1517 let a = match args.positional(0)? {
1518 Value::Int(n) => *n,
1519 other => {
1520 return Err(RuntimeError::TypeMismatch {
1521 expected: "int".into(),
1522 actual: other.kind_name().into(),
1523 });
1524 }
1525 };
1526 let b = match args.positional(1)? {
1527 Value::Int(n) => *n,
1528 other => {
1529 return Err(RuntimeError::TypeMismatch {
1530 expected: "int".into(),
1531 actual: other.kind_name().into(),
1532 });
1533 }
1534 };
1535 Ok(Value::Int(a + b))
1536 })
1537 }
1538 }
1539
1540 fn combinator_ctx() -> ToolCtx {
1541 let mut reg = ToolRegistry::new();
1542 reg.register(Arc::new(IsBig));
1543 reg.register(Arc::new(Double));
1544 reg.register(Arc::new(AddInts));
1545 ToolCtx::new().with_registry(Arc::new(reg))
1546 }
1547
1548 fn call_args(items: Vec<Value>, fn_name: &str) -> ToolArgs {
1549 ToolArgs {
1550 positional: vec![Value::List(items), Value::Str(fn_name.into())],
1551 named: Vec::new(),
1552 }
1553 }
1554
1555 fn ints(xs: &[i64]) -> Vec<Value> {
1556 xs.iter().copied().map(Value::Int).collect()
1557 }
1558
1559 fn expect_int(v: &Value) -> i64 {
1560 match v {
1561 Value::Int(n) => *n,
1562 other => panic!("want int, got {other:?}"),
1563 }
1564 }
1565
1566 fn expect_bool(v: &Value) -> bool {
1567 match v {
1568 Value::Bool(b) => *b,
1569 other => panic!("want bool, got {other:?}"),
1570 }
1571 }
1572
1573 fn expect_ints(v: &Value) -> Vec<i64> {
1574 match v {
1575 Value::List(xs) => xs.iter().map(expect_int).collect(),
1576 other => panic!("want list, got {other:?}"),
1577 }
1578 }
1579
1580 #[tokio::test]
1581 async fn list_map_applies_named_tool_to_every_item() {
1582 let ctx = combinator_ctx();
1583 let out = ListMap
1584 .call(call_args(ints(&[1, 2, 3]), "double"), &ctx)
1585 .await
1586 .unwrap();
1587 assert_eq!(expect_ints(&out), vec![2, 4, 6]);
1588 }
1589
1590 #[tokio::test]
1591 async fn list_filter_keeps_only_true_predicates() {
1592 let ctx = combinator_ctx();
1593 let out = ListFilter
1594 .call(call_args(ints(&[1, 20, 3, 30]), "is_big"), &ctx)
1595 .await
1596 .unwrap();
1597 assert_eq!(expect_ints(&out), vec![20, 30]);
1598 }
1599
1600 #[tokio::test]
1601 async fn list_find_returns_first_hit_or_unit() {
1602 let ctx = combinator_ctx();
1603 let hit = ListFind
1604 .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1605 .await
1606 .unwrap();
1607 assert_eq!(expect_int(&hit), 20);
1608 let miss = ListFind
1609 .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1610 .await
1611 .unwrap();
1612 assert!(matches!(miss, Value::Unit));
1613 }
1614
1615 #[tokio::test]
1616 async fn list_any_and_all_short_circuit_correctly() {
1617 let ctx = combinator_ctx();
1618 let any_hit = ListAny
1619 .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1620 .await
1621 .unwrap();
1622 assert!(expect_bool(&any_hit));
1623 let any_miss = ListAny
1624 .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1625 .await
1626 .unwrap();
1627 assert!(!expect_bool(&any_miss));
1628 let all_hit = ListAll
1629 .call(call_args(ints(&[20, 30]), "is_big"), &ctx)
1630 .await
1631 .unwrap();
1632 assert!(expect_bool(&all_hit));
1633 let all_miss = ListAll
1634 .call(call_args(ints(&[20, 1]), "is_big"), &ctx)
1635 .await
1636 .unwrap();
1637 assert!(!expect_bool(&all_miss));
1638 }
1639
1640 #[tokio::test]
1641 async fn list_reduce_folds_with_init() {
1642 let ctx = combinator_ctx();
1643 let args = ToolArgs {
1644 positional: vec![
1645 Value::List(ints(&[1, 2, 3, 4])),
1646 Value::Str("add_ints".into()),
1647 Value::Int(0),
1648 ],
1649 named: Vec::new(),
1650 };
1651 let out = ListReduce.call(args, &ctx).await.unwrap();
1652 assert_eq!(expect_int(&out), 10);
1653 }
1654
1655 #[tokio::test]
1656 async fn combinator_reports_undefined_tool_by_name() {
1657 let ctx = combinator_ctx();
1658 let err = ListMap
1659 .call(call_args(ints(&[1]), "nope"), &ctx)
1660 .await
1661 .unwrap_err();
1662 match &err {
1663 RuntimeError::UndefinedTool(n) => assert_eq!(n, "nope"),
1664 other => panic!("want UndefinedTool(nope), got {other:?}"),
1665 }
1666 }
1667
1668 #[tokio::test]
1669 async fn combinator_rejects_non_bool_from_predicate() {
1670 let ctx = combinator_ctx();
1671 let err = ListFilter
1672 .call(call_args(ints(&[1, 2]), "double"), &ctx)
1673 .await
1674 .unwrap_err();
1675 match &err {
1676 RuntimeError::TypeMismatch { expected, .. } => {
1677 assert!(
1678 expected.contains("bool"),
1679 "want bool-mismatch, got expected={expected:?}"
1680 );
1681 }
1682 other => panic!("want TypeMismatch, got {other:?}"),
1683 }
1684 }
1685}