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 MessageUser;
637
638impl Tool for MessageUser {
639 fn name(&self) -> &str {
640 "message.user"
641 }
642 fn tier(&self) -> Tier {
643 Tier::Zero
644 }
645 fn description(&self) -> Option<&str> {
646 Some(
647 "Construct a user-role Message from a text string. Use with session.push to inject user instructions into the session history before an llm { context: session } call.",
648 )
649 }
650 fn input_schema(&self) -> serde_json::Value {
651 serde_json::json!({
652 "type": "object",
653 "properties": {"text": {"type": "string"}},
654 "required": ["text"]
655 })
656 }
657 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
658 Box::pin(async move {
659 let text = extract_string(&args, "text", 0)?;
660 let turn_id = ctx
661 .turn_id
662 .clone()
663 .unwrap_or_else(crate::event::TurnId::now);
664 Ok(Value::Message(crate::message::Message::user_text(
665 turn_id, text,
666 )))
667 })
668 }
669}
670
671pub struct MessageAssistant;
672
673impl Tool for MessageAssistant {
674 fn name(&self) -> &str {
675 "message.assistant"
676 }
677 fn tier(&self) -> Tier {
678 Tier::Zero
679 }
680 fn description(&self) -> Option<&str> {
681 Some("Construct an assistant-role Message from a text string.")
682 }
683 fn input_schema(&self) -> serde_json::Value {
684 serde_json::json!({
685 "type": "object",
686 "properties": {"text": {"type": "string"}},
687 "required": ["text"]
688 })
689 }
690 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
691 Box::pin(async move {
692 let text = extract_string(&args, "text", 0)?;
693 let turn_id = ctx
694 .turn_id
695 .clone()
696 .unwrap_or_else(crate::event::TurnId::now);
697 Ok(Value::Message(crate::message::Message::assistant_text(
698 turn_id, text,
699 )))
700 })
701 }
702}
703
704pub struct MessageSystem;
705
706impl Tool for MessageSystem {
707 fn name(&self) -> &str {
708 "message.system"
709 }
710 fn tier(&self) -> Tier {
711 Tier::Zero
712 }
713 fn description(&self) -> Option<&str> {
714 Some("Construct a system-role Message from a text string.")
715 }
716 fn input_schema(&self) -> serde_json::Value {
717 serde_json::json!({
718 "type": "object",
719 "properties": {"text": {"type": "string"}},
720 "required": ["text"]
721 })
722 }
723 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
724 Box::pin(async move {
725 let text = extract_string(&args, "text", 0)?;
726 let turn_id = ctx
727 .turn_id
728 .clone()
729 .unwrap_or_else(crate::event::TurnId::now);
730 Ok(Value::Message(crate::message::Message::system_text(
731 turn_id, text,
732 )))
733 })
734 }
735}
736
737pub struct MessageTool;
738
739impl Tool for MessageTool {
740 fn name(&self) -> &str {
741 "message.tool"
742 }
743 fn tier(&self) -> Tier {
744 Tier::Zero
745 }
746 fn description(&self) -> Option<&str> {
747 Some(
748 "Construct a tool-role Message from a text string. Rarely needed directly — dispatch_all already returns tool-role Messages.",
749 )
750 }
751 fn input_schema(&self) -> serde_json::Value {
752 serde_json::json!({
753 "type": "object",
754 "properties": {"text": {"type": "string"}},
755 "required": ["text"]
756 })
757 }
758 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
759 Box::pin(async move {
760 let text = extract_string(&args, "text", 0)?;
761 let turn_id = ctx
762 .turn_id
763 .clone()
764 .unwrap_or_else(crate::event::TurnId::now);
765 Ok(Value::Message(crate::message::Message {
766 turn_id,
767 role: crate::message::MessageRole::Tool,
768 parts: vec![crate::message::MessagePart::Text { text }],
769 }))
770 })
771 }
772}
773
774pub struct ExtractToolUses;
775
776impl Tool for ExtractToolUses {
777 fn name(&self) -> &str {
778 "extract_tool_uses"
779 }
780
781 fn tier(&self) -> Tier {
782 Tier::Zero
783 }
784
785 fn description(&self) -> Option<&str> {
786 Some(
787 "Pull the tool_use parts out of an assistant Message. Returns a list of \
788 {id, name, input} structs suitable for dispatch_all.",
789 )
790 }
791
792 fn input_schema(&self) -> serde_json::Value {
793 serde_json::json!({
794 "type": "object",
795 "properties": {"message": {"description": "Assistant Message value."}},
796 "required": ["message"]
797 })
798 }
799
800 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
801 Box::pin(async move {
802 let v = match args.named("message") {
803 Some(v) => v,
804 None => args.positional(0)?,
805 };
806 let m = match v {
807 Value::Message(m) => m,
808 Value::Str(_) => return Ok(Value::List(Vec::new())),
809 other => {
810 return Err(RuntimeError::TypeMismatch {
811 expected: "message or string".into(),
812 actual: other.kind_name().into(),
813 });
814 }
815 };
816 let mut out = Vec::new();
817 for part in &m.parts {
818 if let crate::message::MessagePart::ToolUse { id, name, input } = part {
819 out.push(Value::Struct(vec![
820 ("id".into(), Value::Str(id.clone())),
821 ("name".into(), Value::Str(name.clone())),
822 ("input".into(), Value::from_json(input.clone())),
823 ]));
824 }
825 }
826 Ok(Value::List(out))
827 })
828 }
829}
830
831pub struct DispatchAll;
832
833impl Tool for DispatchAll {
834 fn name(&self) -> &str {
835 "dispatch_all"
836 }
837
838 fn tier(&self) -> Tier {
839 Tier::Zero
840 }
841
842 fn description(&self) -> Option<&str> {
843 Some(
844 "Dispatch each tool_use in the list against the current tool registry and \
845 return a list of tool_result Message values.",
846 )
847 }
848
849 fn input_schema(&self) -> serde_json::Value {
850 serde_json::json!({
851 "type": "object",
852 "properties": {"tool_uses": {"type": "array"}},
853 "required": ["tool_uses"]
854 })
855 }
856
857 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
858 Box::pin(async move {
859 let uses = extract_list(&args, "tool_uses", 0)?;
860 let Some(registry) = ctx.registry.as_ref() else {
861 return Err(RuntimeError::ToolFailed(
862 "dispatch_all: no tool registry available on ctx".into(),
863 ));
864 };
865 let prepared = prepare_dispatch(&uses, registry.as_ref(), ctx)?;
866 let (auto_batch, serial_batch, mut out_slots) = partition_and_gate(prepared, ctx).await;
867 run_auto_parallel(auto_batch, ctx, &mut out_slots).await;
868 run_serial(serial_batch, ctx, &mut out_slots).await;
869 let out: Vec<Value> = out_slots.into_iter().flatten().collect();
870 Ok(Value::List(out))
871 })
872 }
873}
874
875enum PreparedEntry {
876 Ready {
877 index: usize,
878 id: String,
879 name: String,
880 tool: std::sync::Arc<dyn Tool>,
881 call_args: ToolArgs,
882 },
883 Failed {
884 index: usize,
885 msg: crate::message::Message,
886 },
887}
888
889fn prepare_dispatch(
890 uses: &[Value],
891 registry: &crate::tool::ToolRegistry,
892 ctx: &ToolCtx,
893) -> Result<Vec<PreparedEntry>, RuntimeError> {
894 let mut prepared = Vec::with_capacity(uses.len());
895 for (index, entry) in uses.iter().enumerate() {
896 let Value::Struct(fields) = entry else {
897 return Err(RuntimeError::TypeMismatch {
898 expected: "struct {id, name, input}".into(),
899 actual: entry.kind_name().into(),
900 });
901 };
902 let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
903 let id = match get("id") {
904 Some(Value::Str(s)) => s,
905 _ => {
906 return Err(RuntimeError::ToolFailed(
907 "dispatch_all: tool_use missing `id` string".into(),
908 ));
909 }
910 };
911 let name = match get("name") {
912 Some(Value::Str(s)) => s,
913 _ => {
914 return Err(RuntimeError::ToolFailed(
915 "dispatch_all: tool_use missing `name` string".into(),
916 ));
917 }
918 };
919 let input = get("input").unwrap_or(Value::Unit);
920 let Some(tool) = registry.get(&name) else {
921 prepared.push(PreparedEntry::Failed {
922 index,
923 msg: build_error_result(ctx, &id, &format!("dispatch_all: unknown tool `{name}`")),
924 });
925 continue;
926 };
927 let named = match &input {
928 Value::Struct(fields) => fields.clone(),
929 Value::Unit => Vec::new(),
930 other => {
931 return Err(RuntimeError::TypeMismatch {
932 expected: "struct or unit for tool input".into(),
933 actual: other.kind_name().into(),
934 });
935 }
936 };
937 let missing = missing_required_fields(&tool.input_schema(), &named);
938 if !missing.is_empty() {
939 let content = format!(
940 "tool `{name}` received empty/incomplete input. Missing required fields: {}. Retry with a complete argument object like {{{}}} — do NOT reuse an empty {{}} input.",
941 missing.join(", "),
942 missing
943 .iter()
944 .map(|f| format!("\"{f}\":\"...\""))
945 .collect::<Vec<_>>()
946 .join(", ")
947 );
948 prepared.push(PreparedEntry::Failed {
949 index,
950 msg: build_error_result(ctx, &id, &content),
951 });
952 continue;
953 }
954 emit_tool_node(ctx, &id, &name, &input);
955 prepared.push(PreparedEntry::Ready {
956 index,
957 id,
958 name,
959 tool,
960 call_args: ToolArgs {
961 positional: Vec::new(),
962 named,
963 },
964 });
965 }
966 Ok(prepared)
967}
968
969struct Approved {
970 index: usize,
971 id: String,
972 name: String,
973 tool: std::sync::Arc<dyn Tool>,
974 call_args: ToolArgs,
975}
976
977async fn partition_and_gate(
978 prepared: Vec<PreparedEntry>,
979 ctx: &ToolCtx,
980) -> (Vec<Approved>, Vec<Approved>, Vec<Option<Value>>) {
981 let total = prepared.len();
982 let mut out_slots: Vec<Option<Value>> = vec![None; total];
983 struct ReadyEntry {
984 index: usize,
985 id: String,
986 name: String,
987 tool: std::sync::Arc<dyn Tool>,
988 call_args: ToolArgs,
989 }
990 let mut ready: Vec<ReadyEntry> = Vec::new();
991 for entry in prepared {
992 match entry {
993 PreparedEntry::Failed { index, msg } => {
994 emit_tool_result(ctx, &msg);
995 out_slots[index] = Some(Value::Message(msg));
996 }
997 PreparedEntry::Ready {
998 index,
999 id,
1000 name,
1001 tool,
1002 call_args,
1003 } => {
1004 ready.push(ReadyEntry {
1005 index,
1006 id,
1007 name,
1008 tool,
1009 call_args,
1010 });
1011 }
1012 }
1013 }
1014 let gates = ready.iter().map(|r| {
1016 let level = r.tool.approval_level(&r.call_args, ctx);
1017 request_approval(
1018 ctx,
1019 &r.id,
1020 &r.name,
1021 &r.call_args,
1022 level,
1023 Some(r.tool.as_ref()),
1024 )
1025 });
1026 let outcomes = futures::future::join_all(gates).await;
1027 let mut auto_batch = Vec::new();
1028 let mut serial_batch = Vec::new();
1029 for (r, outcome) in ready.into_iter().zip(outcomes) {
1030 let level = r.tool.approval_level(&r.call_args, ctx);
1031 match outcome {
1032 ApprovalOutcome::Approve => {
1033 let a = Approved {
1034 index: r.index,
1035 id: r.id,
1036 name: r.name.clone(),
1037 tool: r.tool,
1038 call_args: r.call_args,
1039 };
1040 if level == crate::tool::ApprovalLevel::Auto {
1041 auto_batch.push(a);
1042 } else {
1043 serial_batch.push(a);
1044 }
1045 }
1046 ApprovalOutcome::Deny { reason } => {
1047 let msg = build_error_result(
1048 ctx,
1049 &r.id,
1050 &format!("tool `{}` denied by user: {reason}", r.name),
1051 );
1052 emit_tool_result(ctx, &msg);
1053 out_slots[r.index] = Some(Value::Message(msg));
1054 }
1055 }
1056 }
1057 (auto_batch, serial_batch, out_slots)
1058}
1059
1060async fn run_auto_parallel(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1061 if batch.is_empty() {
1062 return;
1063 }
1064 let futs = batch.iter().map(|a| a.tool.call(a.call_args.clone(), ctx));
1065 let results = futures::future::join_all(futs).await;
1066 for (a, r) in batch.into_iter().zip(results) {
1067 emit_dispatch_node_start(ctx, &a.id, &a.name);
1068 let (content, is_error) = match &r {
1069 Ok(v) => (render_tool_result_text(v), false),
1070 Err(e) => (format!("{e}"), true),
1071 };
1072 emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
1073 if let Ok(v) = &r {
1074 emit_diff_preview_if_relevant(ctx, &a.name, v);
1075 }
1076 let msg = crate::message::Message {
1077 role: crate::message::MessageRole::Tool,
1078 parts: vec![crate::message::MessagePart::ToolResult {
1079 tool_use_id: a.id.clone(),
1080 content,
1081 is_error,
1082 }],
1083 turn_id: ctx
1084 .turn_id
1085 .clone()
1086 .unwrap_or_else(crate::event::TurnId::now),
1087 };
1088 emit_tool_result(ctx, &msg);
1089 out_slots[a.index] = Some(Value::Message(msg));
1090 }
1091}
1092
1093async fn run_serial(batch: Vec<Approved>, ctx: &ToolCtx, out_slots: &mut [Option<Value>]) {
1094 for a in batch {
1095 emit_dispatch_node_start(ctx, &a.id, &a.name);
1096 let r = a.tool.call(a.call_args, ctx).await;
1097 let (content, is_error) = match &r {
1098 Ok(v) => (render_tool_result_text(v), false),
1099 Err(e) => (format!("{e}"), true),
1100 };
1101 emit_dispatch_node_end(ctx, &a.id, &a.name, is_error);
1102 if let Ok(v) = &r {
1103 emit_diff_preview_if_relevant(ctx, &a.name, v);
1104 }
1105 let msg = crate::message::Message {
1106 role: crate::message::MessageRole::Tool,
1107 parts: vec![crate::message::MessagePart::ToolResult {
1108 tool_use_id: a.id.clone(),
1109 content,
1110 is_error,
1111 }],
1112 turn_id: ctx
1113 .turn_id
1114 .clone()
1115 .unwrap_or_else(crate::event::TurnId::now),
1116 };
1117 emit_tool_result(ctx, &msg);
1118 out_slots[a.index] = Some(Value::Message(msg));
1119 }
1120}
1121
1122fn emit_dispatch_node_start(ctx: &ToolCtx, id: &str, name: &str) {
1123 use crate::nodegraph::NodeKind;
1124 let kind = NodeKind::ToolCall {
1125 path: name.to_string(),
1126 };
1127 let label = format!("⟶ {name}");
1128 let node_id = format!("dispatch:{id}");
1129 if let Some(sink) = ctx.events.as_ref()
1130 && let Some(run_id) = ctx.flow_run_id.as_ref()
1131 {
1132 sink.emit(crate::event::Event::FlowNodeStart {
1133 run_id: run_id.clone(),
1134 node_id: node_id.clone(),
1135 kind: kind.clone(),
1136 label: label.clone(),
1137 parent_node_id: ctx.current_node_id.clone(),
1138 });
1139 }
1140 if let Some(tx) = &ctx.stream_tx
1141 && let Some(run_id) = ctx.flow_run_id.as_ref()
1142 {
1143 let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1144 run_id: run_id.0.to_string(),
1145 node_id,
1146 kind,
1147 label,
1148 parent_node_id: ctx.current_node_id.clone(),
1149 });
1150 }
1151}
1152
1153fn emit_dispatch_node_end(ctx: &ToolCtx, id: &str, name: &str, is_error: bool) {
1154 let node_id = format!("dispatch:{id}");
1155 let status = if is_error {
1156 crate::event::FlowNodeStatus::Err
1157 } else {
1158 crate::event::FlowNodeStatus::Ok
1159 };
1160 let preview = name.to_string();
1161 if let Some(sink) = ctx.events.as_ref()
1162 && let Some(run_id) = ctx.flow_run_id.as_ref()
1163 {
1164 sink.emit(crate::event::Event::FlowNodeEnd {
1165 run_id: run_id.clone(),
1166 node_id: node_id.clone(),
1167 status: status.clone(),
1168 output_preview: Some(preview.clone()),
1169 });
1170 }
1171 if let Some(tx) = &ctx.stream_tx
1172 && let Some(run_id) = ctx.flow_run_id.as_ref()
1173 {
1174 let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1175 run_id: run_id.0.to_string(),
1176 node_id,
1177 status,
1178 output_preview: Some(preview),
1179 parent_node_id: ctx.current_node_id.clone(),
1180 });
1181 }
1182}
1183
1184type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
1185
1186fn emit_diff_preview_if_relevant(ctx: &ToolCtx, tool_name: &str, value: &Value) {
1187 let Some(sink) = ctx.events.as_ref() else {
1188 return;
1189 };
1190 let data: Option<DiffPreviewData> = match tool_name {
1191 "fs.edit" => {
1192 let path = value_struct_string(value, "summary").and_then(|s| {
1193 s.strip_prefix("[fs.edit(")
1194 .and_then(|s| s.split(':').next())
1195 .map(|s| s.trim_end_matches(')').to_string())
1196 });
1197 let diff = value_struct_string(value, "diff");
1198 diff.map(|d| (path.unwrap_or_default(), None, None, Some(d)))
1199 }
1200 "fs.write" => {
1201 let path = value_struct_string(value, "path").unwrap_or_default();
1202 let diff = value_struct_string(value, "diff");
1203 diff.map(|d| (path, None, None, Some(d)))
1204 }
1205 "git.diff" => {
1206 let Some(diff) = value_struct_string(value, "diff") else {
1207 return;
1208 };
1209 Some(("git diff".into(), None, None, Some(diff)))
1210 }
1211 "git.show" => {
1212 let sha = value_struct_string(value, "sha").unwrap_or_default();
1213 let Some(diff) = value_struct_string(value, "diff") else {
1214 return;
1215 };
1216 Some((format!("git show {sha}"), None, None, Some(diff)))
1217 }
1218 "git.log" => {
1219 let Some(diff) = value_struct_string(value, "diff") else {
1220 return;
1221 };
1222 Some(("git log HEAD".into(), None, None, Some(diff)))
1223 }
1224 _ => None,
1225 };
1226 if let Some((title, old_content, new_content, unified_diff)) = data {
1227 sink.emit(crate::event::Event::DiffPreview {
1228 turn_id: ctx.turn_id.clone(),
1229 flow_run_id: ctx.flow_run_id.clone(),
1230 title,
1231 old_content,
1232 new_content,
1233 unified_diff,
1234 });
1235 }
1236}
1237
1238fn value_struct_string(value: &Value, field: &str) -> Option<String> {
1239 if let Value::Struct(fields) = value {
1240 fields
1241 .iter()
1242 .find(|(k, _)| k == field)
1243 .and_then(|(_, v)| match v {
1244 Value::Str(s) => Some(s.clone()),
1245 _ => None,
1246 })
1247 } else {
1248 None
1249 }
1250}
1251
1252fn emit_tool_node(ctx: &ToolCtx, id: &str, name: &str, input: &Value) {
1253 if let (Some(sink), Some(run_id), Some(parent_node)) = (
1254 ctx.events.as_ref(),
1255 ctx.flow_run_id.clone(),
1256 &ctx.current_node_id,
1257 ) {
1258 let args_preview = format!("{:?}", input)
1259 .chars()
1260 .take(4000)
1261 .collect::<String>();
1262 sink.emit(crate::event::Event::ToolNode {
1263 run_id: run_id.clone(),
1264 parent_node_id: parent_node.clone(),
1265 tool_use_id: id.to_string(),
1266 tool_name: name.to_string(),
1267 args_preview: args_preview.clone(),
1268 });
1269 if let Some(tx) = &ctx.stream_tx {
1270 let _ = tx.send(crate::stream::StreamFrame::ToolNode {
1271 run_id: run_id.0.to_string(),
1272 parent_node_id: parent_node.clone(),
1273 tool_use_id: id.to_string(),
1274 tool: name.to_string(),
1275 args_preview,
1276 });
1277 }
1278 }
1279}
1280
1281fn build_error_result(ctx: &ToolCtx, tool_use_id: &str, content: &str) -> crate::message::Message {
1282 crate::message::Message {
1283 role: crate::message::MessageRole::Tool,
1284 parts: vec![crate::message::MessagePart::ToolResult {
1285 tool_use_id: tool_use_id.to_string(),
1286 content: content.to_string(),
1287 is_error: true,
1288 }],
1289 turn_id: ctx
1290 .turn_id
1291 .clone()
1292 .unwrap_or_else(crate::event::TurnId::now),
1293 }
1294}
1295
1296fn missing_required_fields(schema: &serde_json::Value, named: &[(String, Value)]) -> Vec<String> {
1297 let Some(required) = schema.get("required").and_then(|v| v.as_array()) else {
1298 return Vec::new();
1299 };
1300 let have: std::collections::HashSet<&str> = named.iter().map(|(k, _)| k.as_str()).collect();
1301 required
1302 .iter()
1303 .filter_map(|v| v.as_str())
1304 .filter(|k| !have.contains(k))
1305 .map(String::from)
1306 .collect()
1307}
1308
1309fn emit_tool_result(ctx: &ToolCtx, msg: &crate::message::Message) {
1310 let msg =
1311 crate::tools::tool_output::maybe_truncate_tool_message(msg, ctx.session_dir.as_deref());
1312 if let Some(tx) = &ctx.stream_tx {
1313 let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
1314 flow_run_id: if ctx.session_runtime.is_some() {
1315 None
1316 } else {
1317 ctx.flow_run_id.as_ref().map(|r| r.0.to_string())
1318 },
1319 message: msg,
1320 });
1321 }
1322}
1323
1324fn render_tool_result_text(v: &Value) -> String {
1325 match v {
1326 Value::Str(s) => s.clone(),
1327 Value::Message(m) => m.text_concat(),
1328 other => other.to_json().to_string(),
1329 }
1330}
1331
1332fn extract_list(args: &ToolArgs, name: &str, pos: usize) -> Result<Vec<Value>, RuntimeError> {
1333 let value = match args.named(name) {
1334 Some(v) => v,
1335 None => args.positional(pos)?,
1336 };
1337 match value {
1338 Value::List(items) => Ok(items.clone()),
1339 other => Err(RuntimeError::TypeMismatch {
1340 expected: "list".into(),
1341 actual: other.kind_name().into(),
1342 }),
1343 }
1344}
1345
1346async fn call_named_unary(
1347 ctx: &ToolCtx,
1348 fn_name: &str,
1349 element: Value,
1350) -> Result<Value, RuntimeError> {
1351 let Some(registry) = ctx.registry.as_ref() else {
1352 return Err(RuntimeError::ToolFailed(format!(
1353 "list combinator: no tool registry available to resolve `{fn_name}`"
1354 )));
1355 };
1356 let Some(tool) = registry.get(fn_name) else {
1357 return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1358 };
1359 let args = ToolArgs {
1360 positional: vec![element],
1361 named: Vec::new(),
1362 };
1363 tool.call(args, ctx).await
1364}
1365
1366async fn call_named_binary(
1367 ctx: &ToolCtx,
1368 fn_name: &str,
1369 a: Value,
1370 b: Value,
1371) -> Result<Value, RuntimeError> {
1372 let Some(registry) = ctx.registry.as_ref() else {
1373 return Err(RuntimeError::ToolFailed(format!(
1374 "list combinator: no tool registry available to resolve `{fn_name}`"
1375 )));
1376 };
1377 let Some(tool) = registry.get(fn_name) else {
1378 return Err(RuntimeError::UndefinedTool(fn_name.to_string()));
1379 };
1380 let args = ToolArgs {
1381 positional: vec![a, b],
1382 named: Vec::new(),
1383 };
1384 tool.call(args, ctx).await
1385}
1386
1387fn value_as_bool(v: Value, fn_name: &str) -> Result<bool, RuntimeError> {
1388 match v {
1389 Value::Bool(b) => Ok(b),
1390 other => Err(RuntimeError::TypeMismatch {
1391 expected: format!("bool returned by `{fn_name}`"),
1392 actual: other.kind_name().into(),
1393 }),
1394 }
1395}
1396
1397pub struct ListMap;
1398
1399impl Tool for ListMap {
1400 fn name(&self) -> &str {
1401 "list_map"
1402 }
1403 fn tier(&self) -> Tier {
1404 Tier::Zero
1405 }
1406 fn description(&self) -> Option<&str> {
1407 Some("Apply a named tool to each item in a list; returns the transformed list.")
1408 }
1409 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1410 Box::pin(async move {
1411 let items = extract_list(&args, "list", 0)?;
1412 let fn_name = extract_string(&args, "fn_name", 1)?;
1413 let mut out = Vec::with_capacity(items.len());
1414 for it in items {
1415 out.push(call_named_unary(ctx, &fn_name, it).await?);
1416 }
1417 Ok(Value::List(out))
1418 })
1419 }
1420}
1421
1422pub struct ListFilter;
1423
1424impl Tool for ListFilter {
1425 fn name(&self) -> &str {
1426 "list_filter"
1427 }
1428 fn tier(&self) -> Tier {
1429 Tier::Zero
1430 }
1431 fn description(&self) -> Option<&str> {
1432 Some("Keep items where the named predicate tool returns true.")
1433 }
1434 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1435 Box::pin(async move {
1436 let items = extract_list(&args, "list", 0)?;
1437 let fn_name = extract_string(&args, "fn_name", 1)?;
1438 let mut out = Vec::new();
1439 for it in items {
1440 let keep =
1441 value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1442 if keep {
1443 out.push(it);
1444 }
1445 }
1446 Ok(Value::List(out))
1447 })
1448 }
1449}
1450
1451pub struct ListFind;
1452
1453impl Tool for ListFind {
1454 fn name(&self) -> &str {
1455 "list_find"
1456 }
1457 fn tier(&self) -> Tier {
1458 Tier::Zero
1459 }
1460 fn description(&self) -> Option<&str> {
1461 Some("Return the first item where the named predicate tool returns true, else unit.")
1462 }
1463 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1464 Box::pin(async move {
1465 let items = extract_list(&args, "list", 0)?;
1466 let fn_name = extract_string(&args, "fn_name", 1)?;
1467 for it in items {
1468 let hit =
1469 value_as_bool(call_named_unary(ctx, &fn_name, it.clone()).await?, &fn_name)?;
1470 if hit {
1471 return Ok(it);
1472 }
1473 }
1474 Ok(Value::Unit)
1475 })
1476 }
1477}
1478
1479pub struct ListAny;
1480
1481impl Tool for ListAny {
1482 fn name(&self) -> &str {
1483 "list_any"
1484 }
1485 fn tier(&self) -> Tier {
1486 Tier::Zero
1487 }
1488 fn description(&self) -> Option<&str> {
1489 Some("True if the named predicate tool returns true for any item.")
1490 }
1491 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1492 Box::pin(async move {
1493 let items = extract_list(&args, "list", 0)?;
1494 let fn_name = extract_string(&args, "fn_name", 1)?;
1495 for it in items {
1496 let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1497 if hit {
1498 return Ok(Value::Bool(true));
1499 }
1500 }
1501 Ok(Value::Bool(false))
1502 })
1503 }
1504}
1505
1506pub struct ListAll;
1507
1508impl Tool for ListAll {
1509 fn name(&self) -> &str {
1510 "list_all"
1511 }
1512 fn tier(&self) -> Tier {
1513 Tier::Zero
1514 }
1515 fn description(&self) -> Option<&str> {
1516 Some("True if the named predicate tool returns true for every item.")
1517 }
1518 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1519 Box::pin(async move {
1520 let items = extract_list(&args, "list", 0)?;
1521 let fn_name = extract_string(&args, "fn_name", 1)?;
1522 for it in items {
1523 let hit = value_as_bool(call_named_unary(ctx, &fn_name, it).await?, &fn_name)?;
1524 if !hit {
1525 return Ok(Value::Bool(false));
1526 }
1527 }
1528 Ok(Value::Bool(true))
1529 })
1530 }
1531}
1532
1533pub struct ListReduce;
1534
1535impl Tool for ListReduce {
1536 fn name(&self) -> &str {
1537 "list_reduce"
1538 }
1539 fn tier(&self) -> Tier {
1540 Tier::Zero
1541 }
1542 fn description(&self) -> Option<&str> {
1543 Some(
1544 "Fold a list left-to-right using a named binary tool: fn(acc, elem) -> acc'. \
1545 Takes an initial accumulator value.",
1546 )
1547 }
1548 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1549 Box::pin(async move {
1550 let items = extract_list(&args, "list", 0)?;
1551 let fn_name = extract_string(&args, "fn_name", 1)?;
1552 let init = match args.named("init") {
1553 Some(v) => v.clone(),
1554 None => args.positional(2)?.clone(),
1555 };
1556 let mut acc = init;
1557 for it in items {
1558 acc = call_named_binary(ctx, &fn_name, acc, it).await?;
1559 }
1560 Ok(acc)
1561 })
1562 }
1563}
1564
1565pub struct ComposeEmailPreview;
1566
1567impl Tool for ComposeEmailPreview {
1568 fn name(&self) -> &str {
1569 "compose_email_preview"
1570 }
1571
1572 fn tier(&self) -> Tier {
1573 Tier::Zero
1574 }
1575
1576 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1577 Box::pin(async move {
1578 let subject = extract_string(&args, "subject", 0)?;
1579 let body = extract_string(&args, "body", 1)?;
1580 let to = extract_string_list(&args, "to", 2)?;
1581 Ok(Value::Str(compose_email_preview(&subject, &body, &to)))
1582 })
1583 }
1584}
1585
1586pub fn compose_email_preview(subject: &str, body: &str, to: &[String]) -> String {
1587 format!("To: {}\nSubject: {subject}\n---\n{body}", to.join(", "))
1588}
1589
1590fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1591 let value = match args.named(name) {
1592 Some(v) => v,
1593 None => args.positional(pos)?,
1594 };
1595 match value {
1596 Value::Str(s) => Ok(s.clone()),
1597 other => Err(RuntimeError::TypeMismatch {
1598 expected: "string".into(),
1599 actual: other.kind_name().into(),
1600 }),
1601 }
1602}
1603
1604fn extract_string_list(
1605 args: &ToolArgs,
1606 name: &str,
1607 pos: usize,
1608) -> Result<Vec<String>, RuntimeError> {
1609 let value = match args.named(name) {
1610 Some(v) => v,
1611 None => args.positional(pos)?,
1612 };
1613 match value {
1614 Value::List(items) => items
1615 .iter()
1616 .map(|v| match v {
1617 Value::Str(s) => Ok(s.clone()),
1618 other => Err(RuntimeError::TypeMismatch {
1619 expected: "list of string".into(),
1620 actual: other.kind_name().into(),
1621 }),
1622 })
1623 .collect(),
1624 other => Err(RuntimeError::TypeMismatch {
1625 expected: "list".into(),
1626 actual: other.kind_name().into(),
1627 }),
1628 }
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633 use super::*;
1634
1635 #[test]
1636 fn shell_quote_wraps_and_escapes() {
1637 assert_eq!(shell_quote("hello"), "'hello'");
1638 assert_eq!(shell_quote("It's fine"), "'It'\\''s fine'");
1639 assert_eq!(shell_quote(""), "''");
1640 assert_eq!(shell_quote("a'b'c"), "'a'\\''b'\\''c'");
1641 }
1642
1643 #[test]
1644 fn compose_email_preview_formats_headers() {
1645 let preview = compose_email_preview(
1646 "Deploy status",
1647 "See attached",
1648 &["a@x.com".into(), "b@x.com".into()],
1649 );
1650 assert_eq!(
1651 preview,
1652 "To: a@x.com, b@x.com\nSubject: Deploy status\n---\nSee attached"
1653 );
1654 }
1655
1656 use crate::tool::ToolRegistry;
1657 use std::sync::Arc;
1658
1659 struct IsBig;
1660 impl Tool for IsBig {
1661 fn name(&self) -> &str {
1662 "is_big"
1663 }
1664 fn tier(&self) -> Tier {
1665 Tier::Zero
1666 }
1667 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1668 Box::pin(async move {
1669 match args.positional(0)? {
1670 Value::Int(n) => Ok(Value::Bool(*n > 10)),
1671 other => Err(RuntimeError::TypeMismatch {
1672 expected: "int".into(),
1673 actual: other.kind_name().into(),
1674 }),
1675 }
1676 })
1677 }
1678 }
1679
1680 struct Double;
1681 impl Tool for Double {
1682 fn name(&self) -> &str {
1683 "double"
1684 }
1685 fn tier(&self) -> Tier {
1686 Tier::Zero
1687 }
1688 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1689 Box::pin(async move {
1690 match args.positional(0)? {
1691 Value::Int(n) => Ok(Value::Int(n * 2)),
1692 other => Err(RuntimeError::TypeMismatch {
1693 expected: "int".into(),
1694 actual: other.kind_name().into(),
1695 }),
1696 }
1697 })
1698 }
1699 }
1700
1701 struct AddInts;
1702 impl Tool for AddInts {
1703 fn name(&self) -> &str {
1704 "add_ints"
1705 }
1706 fn tier(&self) -> Tier {
1707 Tier::Zero
1708 }
1709 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1710 Box::pin(async move {
1711 let a = match args.positional(0)? {
1712 Value::Int(n) => *n,
1713 other => {
1714 return Err(RuntimeError::TypeMismatch {
1715 expected: "int".into(),
1716 actual: other.kind_name().into(),
1717 });
1718 }
1719 };
1720 let b = match args.positional(1)? {
1721 Value::Int(n) => *n,
1722 other => {
1723 return Err(RuntimeError::TypeMismatch {
1724 expected: "int".into(),
1725 actual: other.kind_name().into(),
1726 });
1727 }
1728 };
1729 Ok(Value::Int(a + b))
1730 })
1731 }
1732 }
1733
1734 fn combinator_ctx() -> ToolCtx {
1735 let reg = ToolRegistry::new();
1736 reg.register(Arc::new(IsBig));
1737 reg.register(Arc::new(Double));
1738 reg.register(Arc::new(AddInts));
1739 ToolCtx::new().with_registry(Arc::new(reg))
1740 }
1741
1742 fn call_args(items: Vec<Value>, fn_name: &str) -> ToolArgs {
1743 ToolArgs {
1744 positional: vec![Value::List(items), Value::Str(fn_name.into())],
1745 named: Vec::new(),
1746 }
1747 }
1748
1749 fn ints(xs: &[i64]) -> Vec<Value> {
1750 xs.iter().copied().map(Value::Int).collect()
1751 }
1752
1753 fn expect_int(v: &Value) -> i64 {
1754 match v {
1755 Value::Int(n) => *n,
1756 other => panic!("want int, got {other:?}"),
1757 }
1758 }
1759
1760 fn expect_bool(v: &Value) -> bool {
1761 match v {
1762 Value::Bool(b) => *b,
1763 other => panic!("want bool, got {other:?}"),
1764 }
1765 }
1766
1767 fn expect_ints(v: &Value) -> Vec<i64> {
1768 match v {
1769 Value::List(xs) => xs.iter().map(expect_int).collect(),
1770 other => panic!("want list, got {other:?}"),
1771 }
1772 }
1773
1774 #[tokio::test]
1775 async fn list_map_applies_named_tool_to_every_item() {
1776 let ctx = combinator_ctx();
1777 let out = ListMap
1778 .call(call_args(ints(&[1, 2, 3]), "double"), &ctx)
1779 .await
1780 .unwrap();
1781 assert_eq!(expect_ints(&out), vec![2, 4, 6]);
1782 }
1783
1784 #[tokio::test]
1785 async fn list_filter_keeps_only_true_predicates() {
1786 let ctx = combinator_ctx();
1787 let out = ListFilter
1788 .call(call_args(ints(&[1, 20, 3, 30]), "is_big"), &ctx)
1789 .await
1790 .unwrap();
1791 assert_eq!(expect_ints(&out), vec![20, 30]);
1792 }
1793
1794 #[tokio::test]
1795 async fn list_find_returns_first_hit_or_unit() {
1796 let ctx = combinator_ctx();
1797 let hit = ListFind
1798 .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1799 .await
1800 .unwrap();
1801 assert_eq!(expect_int(&hit), 20);
1802 let miss = ListFind
1803 .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1804 .await
1805 .unwrap();
1806 assert!(matches!(miss, Value::Unit));
1807 }
1808
1809 #[tokio::test]
1810 async fn list_any_and_all_short_circuit_correctly() {
1811 let ctx = combinator_ctx();
1812 let any_hit = ListAny
1813 .call(call_args(ints(&[1, 20, 3]), "is_big"), &ctx)
1814 .await
1815 .unwrap();
1816 assert!(expect_bool(&any_hit));
1817 let any_miss = ListAny
1818 .call(call_args(ints(&[1, 2, 3]), "is_big"), &ctx)
1819 .await
1820 .unwrap();
1821 assert!(!expect_bool(&any_miss));
1822 let all_hit = ListAll
1823 .call(call_args(ints(&[20, 30]), "is_big"), &ctx)
1824 .await
1825 .unwrap();
1826 assert!(expect_bool(&all_hit));
1827 let all_miss = ListAll
1828 .call(call_args(ints(&[20, 1]), "is_big"), &ctx)
1829 .await
1830 .unwrap();
1831 assert!(!expect_bool(&all_miss));
1832 }
1833
1834 #[tokio::test]
1835 async fn list_reduce_folds_with_init() {
1836 let ctx = combinator_ctx();
1837 let args = ToolArgs {
1838 positional: vec![
1839 Value::List(ints(&[1, 2, 3, 4])),
1840 Value::Str("add_ints".into()),
1841 Value::Int(0),
1842 ],
1843 named: Vec::new(),
1844 };
1845 let out = ListReduce.call(args, &ctx).await.unwrap();
1846 assert_eq!(expect_int(&out), 10);
1847 }
1848
1849 #[tokio::test]
1850 async fn combinator_reports_undefined_tool_by_name() {
1851 let ctx = combinator_ctx();
1852 let err = ListMap
1853 .call(call_args(ints(&[1]), "nope"), &ctx)
1854 .await
1855 .unwrap_err();
1856 match &err {
1857 RuntimeError::UndefinedTool(n) => assert_eq!(n, "nope"),
1858 other => panic!("want UndefinedTool(nope), got {other:?}"),
1859 }
1860 }
1861
1862 #[tokio::test]
1863 async fn combinator_rejects_non_bool_from_predicate() {
1864 let ctx = combinator_ctx();
1865 let err = ListFilter
1866 .call(call_args(ints(&[1, 2]), "double"), &ctx)
1867 .await
1868 .unwrap_err();
1869 match &err {
1870 RuntimeError::TypeMismatch { expected, .. } => {
1871 assert!(
1872 expected.contains("bool"),
1873 "want bool-mismatch, got expected={expected:?}"
1874 );
1875 }
1876 other => panic!("want TypeMismatch, got {other:?}"),
1877 }
1878 }
1879}