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