Skip to main content

modular_agent_core/
tool.rs

1//! Tool registry and agents for LLM function calling.
2//!
3//! This module provides infrastructure for registering, managing, and invoking tools
4//! that can be called by LLMs. It includes:
5//!
6//! - A global tool registry for registering and looking up tools by name
7//! - The `Tool` trait for implementing custom tools
8//! - Agents for working with tools in workflows:
9//!   - `ListToolsAgent` - Lists available tools matching a pattern
10//!   - `PresetToolAgent` - Exposes a workflow as a callable tool
11//!   - `CallToolMessageAgent` - Processes tool calls from LLM messages
12//!   - `LoopControlAgent` - Guards tool-call cycles with an iteration limit
13//!   - `CallToolAgent` - Directly invokes a tool by name
14//! ```
15
16#![cfg(feature = "llm")]
17
18use std::{
19    collections::{BTreeMap, HashMap, HashSet, VecDeque},
20    future::Future,
21    sync::{Arc, Mutex, OnceLock, RwLock},
22    time::Duration,
23};
24
25use tokio_util::sync::CancellationToken;
26
27use crate::{
28    Agent, AgentContext, AgentData, AgentError, AgentOutput, AgentSpec, AgentStatus, AgentValue,
29    AsAgent, Message, MessageContent, ModularAgent, SharedAgent, ToolCall, async_trait,
30    modular_agent,
31};
32#[cfg(feature = "image")]
33use crate::{ContentBlock, value::IMAGE_BASE64_PREFIX};
34use futures_util::{StreamExt, stream};
35use im::{Vector, vector};
36use regex::RegexSet;
37use tokio::sync::oneshot;
38
39const CATEGORY: &str = "Core/Tool";
40
41const PORT_LIMIT_EXCEEDED: &str = "limit_exceeded";
42const PORT_MESSAGE: &str = "message";
43const PORT_PATTERNS: &str = "patterns";
44const PORT_TOOLS: &str = "tools";
45const PORT_TOOL_CALL: &str = "tool_call";
46const PORT_TOOL_IN: &str = "tool_in";
47const PORT_TOOL_OUT: &str = "tool_out";
48const PORT_VALUE: &str = "value";
49
50const CONFIG_TOOLS: &str = "tools";
51const CONFIG_TOOL_NAME: &str = "name";
52const CONFIG_TOOL_DESCRIPTION: &str = "description";
53const CONFIG_TOOL_PARAMETERS: &str = "parameters";
54const CONFIG_TIMEOUT_SECS: &str = "timeout_secs";
55const CONFIG_MAX_ITERATIONS: &str = "max_iterations";
56const CONFIG_MAX_CONCURRENCY: &str = "max_concurrency";
57
58/// Fallback timeout (seconds) when `timeout_secs` config is unset or unreadable.
59const DEFAULT_TIMEOUT_SECS: i64 = 60;
60
61/// Fallback loop limit when `max_iterations` config is unset or unreadable.
62const DEFAULT_MAX_ITERATIONS: i64 = 25;
63
64/// Fallback parallel-tool concurrency cap when `max_concurrency` config is
65/// unset or unreadable.
66const DEFAULT_MAX_CONCURRENCY: i64 = 8;
67
68/// Content of the synthetic tool result emitted for a tool call whose
69/// execution was cancelled before a real result was produced. Carrying the
70/// original tool_call id keeps the message history consistent for the model.
71const ABORTED_TOOL_RESULT: &str = "Operation aborted";
72
73/// Runs `fut` to completion unless `cancel` fires first.
74///
75/// Returns `None` when cancelled (dropping `fut` mid-flight). A token that is
76/// already cancelled short-circuits before `fut` is first polled.
77async fn run_unless_cancelled<T>(
78    cancel: Option<&CancellationToken>,
79    fut: impl Future<Output = T>,
80) -> Option<T> {
81    match cancel {
82        Some(token) => {
83            tokio::select! {
84                biased;
85                _ = token.cancelled() => None,
86                r = fut => Some(r),
87            }
88        }
89        None => Some(fut.await),
90    }
91}
92
93/// How a tool may be scheduled relative to other calls in the same batch.
94///
95/// Tools with side effects (database writes, message posting, workflow
96/// invocations) must stay [`Sequential`](ExecutionMode::Sequential) so their
97/// effects happen one at a time and in input order.
98/// [`Parallel`](ExecutionMode::Parallel) is opt-in for independent,
99/// read-only tools where concurrent execution is safe.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub enum ExecutionMode {
102    /// Never runs concurrently with any other tool call (the default).
103    #[default]
104    Sequential,
105    /// May run concurrently with adjacent `Parallel` calls.
106    Parallel,
107}
108
109/// Metadata describing a tool available for LLM function calling.
110///
111/// This information is typically sent to the LLM to describe what tools
112/// are available and how to call them.
113#[derive(Clone, Debug)]
114pub struct ToolInfo {
115    /// Unique name identifying the tool.
116    pub name: String,
117
118    /// Human-readable description of what the tool does.
119    ///
120    /// Per Claude's tool-use guidance, the description is the single most
121    /// important factor for tool-calling performance. Write a detailed
122    /// description (3-4+ sentences) covering what the tool does, when it
123    /// should (and should not) be used, what each parameter means, and any
124    /// caveats or limitations. Registering a tool with a missing or very
125    /// short description logs a warning (see [`register_tool`]).
126    pub description: String,
127
128    /// JSON Schema describing the tool's parameters.
129    ///
130    /// Defaults to `{"type": "object", "properties": {}}` when no schema
131    /// is provided (see [`ToolInfo::new`]).
132    pub parameters: serde_json::Value,
133
134    /// How this tool may be scheduled relative to other calls in the same
135    /// batch (see [`ExecutionMode`]). Internal execution metadata — never
136    /// sent to the LLM.
137    pub execution_mode: ExecutionMode,
138}
139
140impl ToolInfo {
141    /// Creates a new `ToolInfo` with the default `Sequential` execution mode.
142    ///
143    /// When `parameters` is `None`, the empty-object JSON Schema
144    /// `{"type": "object", "properties": {}}` is used so providers always
145    /// receive a valid schema.
146    pub fn new(
147        name: impl Into<String>,
148        description: impl Into<String>,
149        parameters: Option<serde_json::Value>,
150    ) -> Self {
151        Self {
152            name: name.into(),
153            description: description.into(),
154            parameters: parameters
155                .unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})),
156            execution_mode: ExecutionMode::default(),
157        }
158    }
159
160    /// Sets the execution mode, typically to opt a read-only tool into
161    /// parallel scheduling (see [`ExecutionMode`]).
162    pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
163        self.execution_mode = mode;
164        self
165    }
166}
167
168/// Trait for implementing callable tools.
169///
170/// Tools are functions that can be invoked by LLMs during conversations.
171/// Implement this trait to create custom tools that can be registered
172/// with the global tool registry.
173///
174/// # Example
175///
176/// ```ignore
177/// use modular_agent_core::{Tool, ToolInfo, AgentContext, AgentValue, AgentError, async_trait};
178///
179/// struct MyTool {
180///     info: ToolInfo,
181/// }
182///
183/// #[async_trait]
184/// impl Tool for MyTool {
185///     fn info(&self) -> &ToolInfo {
186///         &self.info
187///     }
188///
189///     async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError> {
190///         // Tool implementation
191///         Ok(AgentValue::string("result"))
192///     }
193/// }
194/// ```
195#[async_trait]
196pub trait Tool {
197    /// Returns metadata about this tool.
198    fn info(&self) -> &ToolInfo;
199
200    /// Invokes the tool with the given context and arguments.
201    ///
202    /// # Arguments
203    ///
204    /// * `ctx` - The agent context for this invocation
205    /// * `args` - Arguments passed to the tool (typically from LLM)
206    ///
207    /// # Returns
208    ///
209    /// The tool's result as an `AgentValue`, or an error if the call fails.
210    async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError>;
211}
212
213impl From<ToolInfo> for AgentValue {
214    fn from(info: ToolInfo) -> Self {
215        let mut obj: BTreeMap<String, AgentValue> = BTreeMap::new();
216        obj.insert("name".to_string(), AgentValue::from(info.name));
217        obj.insert(
218            "description".to_string(),
219            AgentValue::from(info.description),
220        );
221        if let Ok(params_value) = AgentValue::from_serialize(&info.parameters) {
222            obj.insert("parameters".to_string(), params_value);
223        }
224        AgentValue::object(obj.into())
225    }
226}
227
228/// Internal entry for a registered tool.
229#[derive(Clone)]
230struct ToolEntry {
231    info: ToolInfo,
232    tool: Arc<Box<dyn Tool + Send + Sync>>,
233}
234
235impl ToolEntry {
236    /// Creates a new tool entry from a tool implementation.
237    fn new<T: Tool + Send + Sync + 'static>(tool: T) -> Self {
238        Self {
239            info: tool.info().clone(),
240            tool: Arc::new(Box::new(tool)),
241        }
242    }
243}
244
245/// Thread-safe registry for managing tools.
246struct ToolRegistry {
247    tools: HashMap<String, ToolEntry>,
248}
249
250impl ToolRegistry {
251    /// Creates a new empty tool registry.
252    fn new() -> Self {
253        Self {
254            tools: HashMap::new(),
255        }
256    }
257
258    /// Registers a tool with the registry.
259    fn register_tool<T: Tool + Send + Sync + 'static>(&mut self, tool: T) {
260        let info = tool.info();
261        if let Some(warning) = description_warning(&info.name, &info.description) {
262            log::warn!("{}", warning);
263        }
264        let name = info.name.to_string();
265        let entry = ToolEntry::new(tool);
266        self.tools.insert(name, entry);
267    }
268
269    /// Removes a tool from the registry by name.
270    fn unregister_tool(&mut self, name: &str) {
271        self.tools.remove(name);
272    }
273
274    /// Retrieves a tool by name, if it exists.
275    fn get_tool(&self, name: &str) -> Option<Arc<Box<dyn Tool + Send + Sync>>> {
276        self.tools.get(name).map(|entry| entry.tool.clone())
277    }
278}
279
280/// Global tool registry instance.
281static TOOL_REGISTRY: OnceLock<RwLock<ToolRegistry>> = OnceLock::new();
282
283/// Returns the global tool registry, initializing it if necessary.
284fn registry() -> &'static RwLock<ToolRegistry> {
285    TOOL_REGISTRY.get_or_init(|| RwLock::new(ToolRegistry::new()))
286}
287
288/// Registers a tool with the global registry.
289///
290/// The tool will be available for lookup and invocation by its name.
291/// If a tool with the same name already exists, it will be replaced.
292///
293/// A missing or very short description logs a warning at registration time,
294/// but never rejects the registration. See [`ToolInfo::description`] for
295/// description best practices.
296///
297/// # Arguments
298///
299/// * `tool` - The tool implementation to register
300pub fn register_tool<T: Tool + Send + Sync + 'static>(tool: T) {
301    registry().write().unwrap().register_tool(tool);
302}
303
304/// Minimum description length (in characters) below which registration warns.
305const MIN_DESCRIPTION_CHARS: usize = 10;
306
307/// Returns a warning message when a tool description is too weak to guide
308/// tool selection — missing, whitespace-only, or shorter than
309/// [`MIN_DESCRIPTION_CHARS`] characters — or `None` when it is adequate.
310fn description_warning(name: &str, description: &str) -> Option<String> {
311    let trimmed = description.trim();
312    if trimmed.is_empty() {
313        Some(format!(
314            "Tool '{}' is registered without a description; the description is \
315             the most important factor for tool-calling performance and should \
316             explain what the tool does, when to use it, and what its \
317             parameters mean",
318            name
319        ))
320    } else if trimmed.chars().count() < MIN_DESCRIPTION_CHARS {
321        Some(format!(
322            "Tool '{}' has a very short description {:?}; detailed descriptions \
323             (3-4+ sentences) significantly improve tool-calling performance",
324            name, trimmed
325        ))
326    } else {
327        None
328    }
329}
330
331/// Returns whether a tool name satisfies the `^[a-zA-Z0-9_-]{1,64}$` pattern
332/// required by the Claude and OpenAI APIs.
333fn is_valid_tool_name(name: &str) -> bool {
334    let len = name.len();
335    (1..=64).contains(&len)
336        && name
337            .bytes()
338            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
339}
340
341/// Removes a tool from the global registry by name.
342///
343/// # Arguments
344///
345/// * `name` - The name of the tool to unregister
346pub fn unregister_tool(name: &str) {
347    registry().write().unwrap().unregister_tool(name);
348}
349
350/// Returns information about all registered tools.
351///
352/// # Returns
353///
354/// A vector of `ToolInfo` for all currently registered tools.
355pub fn list_tool_infos() -> Vec<ToolInfo> {
356    registry()
357        .read()
358        .unwrap()
359        .tools
360        .values()
361        .map(|entry| entry.info.clone())
362        .collect()
363}
364
365/// Returns tool information for tools matching the given regex patterns.
366///
367/// Patterns are newline-separated regular expressions. A tool is included
368/// if its name matches any of the patterns.
369///
370/// # Arguments
371///
372/// * `patterns` - Newline-separated regex patterns to match tool names
373///
374/// # Returns
375///
376/// A vector of `ToolInfo` for tools whose names match the patterns.
377///
378/// # Errors
379///
380/// Returns an error if any of the patterns are invalid regular expressions.
381pub fn list_tool_infos_patterns(patterns: &str) -> Result<Vec<ToolInfo>, regex::Error> {
382    // Split patterns by newline and trim whitespace
383    let patterns = patterns
384        .lines()
385        .map(|line| line.trim())
386        .filter(|line| !line.is_empty())
387        .collect::<Vec<&str>>();
388    let reg_set = RegexSet::new(&patterns)?;
389    let tool_names = registry()
390        .read()
391        .unwrap()
392        .tools
393        .values()
394        .filter_map(|entry| {
395            if reg_set.is_match(&entry.info.name) {
396                Some(entry.info.clone())
397            } else {
398                None
399            }
400        })
401        .collect();
402    Ok(tool_names)
403}
404
405/// Retrieves a tool by name from the global registry.
406///
407/// # Arguments
408///
409/// * `name` - The name of the tool to retrieve
410///
411/// # Returns
412///
413/// The tool if found, or `None` if no tool with that name is registered.
414pub fn get_tool(name: &str) -> Option<Arc<Box<dyn Tool + Send + Sync>>> {
415    registry().read().unwrap().get_tool(name)
416}
417
418/// Invokes a tool by name with the given arguments.
419///
420/// # Arguments
421///
422/// * `ctx` - The agent context for the invocation
423/// * `name` - The name of the tool to call
424/// * `args` - Arguments to pass to the tool
425///
426/// # Returns
427///
428/// The tool's result, or an error if the tool is not found or fails.
429pub async fn call_tool(
430    ctx: AgentContext,
431    name: &str,
432    args: AgentValue,
433) -> Result<AgentValue, AgentError> {
434    if ctx.is_cancelled() {
435        return Err(AgentError::Cancelled);
436    }
437
438    let tool = {
439        let guard = registry().read().unwrap();
440        guard.get_tool(name)
441    };
442
443    let Some(tool) = tool else {
444        return Err(AgentError::Other(format!("Tool '{}' not found", name)));
445    };
446
447    tool.call(ctx, args).await
448}
449
450/// Builds an error tool-result message for a failed tool call.
451///
452/// The message carries `is_error: Some(true)` so LLM clients can report the
453/// failure back to the model (Claude's `tool_result` `is_error`) instead of
454/// aborting the whole flow. This is the designated return path for parse,
455/// validation, and execution failures of tool calls.
456pub fn error_tool_result(call: &ToolCall, e: impl ToString) -> Message {
457    let mut msg = Message::tool(call.function.name.clone(), e.to_string());
458    msg.id = call.function.id.clone();
459    msg.is_error = Some(true);
460    msg
461}
462
463/// Returns whether `value` validates against `schema`, treating an
464/// uncompilable schema as non-matching.
465fn schema_accepts(schema: &serde_json::Value, value: &serde_json::Value) -> bool {
466    jsonschema::validator_for(schema)
467        .map(|v| v.is_valid(value))
468        .unwrap_or(false)
469}
470
471/// Coerces primitive values in-place toward `schema` where an unambiguous
472/// string-to-primitive conversion exists.
473///
474/// LLMs frequently emit numbers and booleans as JSON strings (`"42"`,
475/// `"true"`); this repairs such calls instead of bouncing them back to the
476/// model. Only lossless conversions are applied — `"42.5"` is never coerced
477/// to an integer — and values already matching the schema type are left
478/// untouched. Recurses into object `properties` and array `items`. For
479/// `anyOf`/`oneOf`, an already-valid value is kept as-is; otherwise coercion
480/// is tried against each variant and the first result that validates wins.
481/// Sibling keywords (`type`/`properties`/`items`) are still applied after
482/// `anyOf`/`oneOf` handling, since schemas like
483/// `{"properties": ..., "anyOf": [{"required": ...}]}` rely on property
484/// coercion even when a variant already accepts the value. When nothing
485/// applies the value is left unchanged so validation can report the error.
486fn coerce_by_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
487    let Some(schema_obj) = schema.as_object() else {
488        return;
489    };
490
491    for key in ["anyOf", "oneOf"] {
492        let Some(variants) = schema_obj.get(key).and_then(|v| v.as_array()) else {
493            continue;
494        };
495        if !variants.iter().any(|v| schema_accepts(v, value)) {
496            for variant in variants {
497                let mut candidate = value.clone();
498                coerce_by_schema(&mut candidate, variant);
499                if schema_accepts(variant, &candidate) {
500                    *value = candidate;
501                    break;
502                }
503            }
504        }
505        break;
506    }
507
508    match schema_obj.get("type").and_then(|t| t.as_str()) {
509        Some("integer") => {
510            // i64 parsing rejects fractional strings, so "42.5" is never
511            // silently truncated.
512            if let Some(s) = value.as_str()
513                && let Ok(i) = s.parse::<i64>()
514            {
515                *value = serde_json::Value::from(i);
516            }
517        }
518        Some("number") => {
519            // from_f64 rejects NaN/infinity, which have no JSON representation.
520            if let Some(s) = value.as_str()
521                && let Ok(f) = s.parse::<f64>()
522                && let Some(n) = serde_json::Number::from_f64(f)
523            {
524                *value = serde_json::Value::Number(n);
525            }
526        }
527        Some("boolean") => match value.as_str() {
528            Some("true") => *value = serde_json::Value::Bool(true),
529            Some("false") => *value = serde_json::Value::Bool(false),
530            _ => {}
531        },
532        _ => {}
533    }
534
535    if let Some(props) = schema_obj.get("properties").and_then(|p| p.as_object())
536        && let Some(map) = value.as_object_mut()
537    {
538        for (name, prop_schema) in props {
539            if let Some(v) = map.get_mut(name) {
540                coerce_by_schema(v, prop_schema);
541            }
542        }
543    }
544
545    if let Some(items) = schema_obj.get("items")
546        && let Some(arr) = value.as_array_mut()
547    {
548        for v in arr {
549            coerce_by_schema(v, items);
550        }
551    }
552}
553
554/// Validates (and lightly coerces) tool-call arguments against a tool's
555/// JSON Schema.
556///
557/// [`coerce_by_schema`] is applied first, so string-encoded primitives from
558/// the model are repaired before validation. All validation errors are
559/// collected with their instance paths into one readable string. If the
560/// schema itself fails to compile (e.g. supplied by a misbehaving MCP
561/// server), validation and coercion are skipped with a warning — a broken
562/// schema must not block tool execution.
563fn validate_tool_args(
564    schema: &serde_json::Value,
565    args: &mut serde_json::Value,
566) -> Result<(), String> {
567    let validator = match jsonschema::validator_for(schema) {
568        Ok(v) => v,
569        Err(e) => {
570            log::warn!(
571                "Tool parameter schema failed to compile; skipping argument validation: {}",
572                e
573            );
574            return Ok(());
575        }
576    };
577
578    coerce_by_schema(args, schema);
579
580    let errors = validator
581        .iter_errors(args)
582        .map(|e| format!("at '{}': {}", e.instance_path(), e))
583        .collect::<Vec<_>>();
584    if errors.is_empty() {
585        Ok(())
586    } else {
587        Err(errors.join("; "))
588    }
589}
590
591/// Executes a single tool call, funneling every failure (unparseable
592/// parameters, schema validation failure, tool error) through
593/// [`error_tool_result`] so a failing call never aborts its siblings.
594async fn execute_tool_call(ctx: &AgentContext, call: &ToolCall) -> Message {
595    // A provider argument string that failed to parse even after repair is
596    // reported back to the model rather than executed with bogus arguments.
597    if let Some(err) = &call.function.parse_error {
598        return error_tool_result(
599            call,
600            format!(
601                "Tool call arguments could not be parsed as JSON; the call was \
602                 not executed. Re-issue the call with valid JSON arguments. {}",
603                err
604            ),
605        );
606    }
607    // Validate and coerce arguments against the tool's declared schema
608    // before execution. An unknown tool falls through unchanged so
609    // call_tool() reports the not-found error as usual.
610    let mut parameters = call.function.parameters.clone();
611    if let Some(tool) = get_tool(call.function.name.as_str())
612        && let Err(msg) = validate_tool_args(&tool.info().parameters, &mut parameters)
613    {
614        return error_tool_result(
615            call,
616            format!(
617                "Tool call arguments failed schema validation; the call was \
618                 not executed. Re-issue the call with corrected arguments. \
619                 Errors: {}",
620                msg
621            ),
622        );
623    }
624    let args = match AgentValue::from_json(parameters) {
625        Ok(args) => args,
626        Err(e) => {
627            return error_tool_result(call, format!("Failed to parse tool call parameters: {}", e));
628        }
629    };
630    match call_tool(ctx.clone(), call.function.name.as_str(), args).await {
631        Ok(tool_resp) => {
632            let mut msg = Message::tool_with_content(
633                call.function.name.clone(),
634                tool_result_content(&tool_resp),
635            );
636            msg.id = call.function.id.clone();
637            msg
638        }
639        Err(e) => error_tool_result(call, e),
640    }
641}
642
643/// Builds the message content for a successful tool response.
644///
645/// An image result — or an array containing at least one image — becomes
646/// structured content blocks so multimodal LLM clients can send the image
647/// itself instead of an opaque base64 string. Every other value keeps the
648/// legacy stringified-JSON form byte-for-byte: persisted sessions and
649/// downstream consumers compare against that exact string, so it must not
650/// change shape.
651fn tool_result_content(resp: &AgentValue) -> MessageContent {
652    #[cfg(feature = "image")]
653    match resp {
654        AgentValue::Image(img) => {
655            return MessageContent::Blocks(vec![image_block(img)]);
656        }
657        AgentValue::Array(arr) if arr.iter().any(|v| matches!(v, AgentValue::Image(_))) => {
658            let blocks = arr
659                .iter()
660                .map(|v| match v {
661                    AgentValue::Image(img) => image_block(img),
662                    other => ContentBlock::Text {
663                        text: other.to_json().to_string(),
664                    },
665                })
666                .collect();
667            return MessageContent::Blocks(blocks);
668        }
669        _ => {}
670    }
671    MessageContent::Text(resp.to_json().to_string())
672}
673
674/// Converts a tool-result image into an image content block.
675///
676/// `get_base64()` returns a PNG data URL, while [`ContentBlock::Image`]
677/// carries the raw base64 payload and the MIME type separately.
678#[cfg(feature = "image")]
679fn image_block(img: &photon_rs::PhotonImage) -> ContentBlock {
680    ContentBlock::Image {
681        data: img
682            .get_base64()
683            .trim_start_matches(IMAGE_BASE64_PREFIX)
684            .to_string(),
685        mime_type: "image/png".to_string(),
686    }
687}
688
689/// Runs the accumulated batch of `Parallel` calls concurrently, bounded by
690/// `max_concurrency`, and appends the results to `out` in the batch's order.
691///
692/// Returns `false` when `cancel` fired mid-batch. Even then `out` gains one
693/// message per batch entry: in-flight calls are dropped and reported with a
694/// synthetic aborted result, but calls that already completed keep their
695/// real result — their side effects happened, so reporting them aborted
696/// would mislead the model into re-issuing them.
697async fn flush_parallel_batch(
698    ctx: &AgentContext,
699    batch: &mut Vec<&ToolCall>,
700    max_concurrency: usize,
701    out: &mut Vec<Message>,
702    cancel: Option<&CancellationToken>,
703) -> bool {
704    if batch.is_empty() {
705        return true;
706    }
707    // Materialize the futures before streaming: mapping lazily inside
708    // stream::iter trips a higher-ranked lifetime inference error when the
709    // stream is held across an await in the caller.
710    let mut futures = Vec::with_capacity(batch.len());
711    for (index, &call) in batch.iter().enumerate() {
712        futures.push(async move { (index, execute_tool_call(ctx, call).await) });
713    }
714    // `buffer_unordered` with explicit indices instead of the in-order
715    // `buffered`: a completed result must be observable even while an
716    // earlier call is still running, or cancellation would discard it.
717    let mut results = stream::iter(futures).buffer_unordered(max_concurrency);
718    let mut slots: Vec<Option<Message>> = Vec::new();
719    slots.resize_with(batch.len(), || None);
720    let mut aborted = false;
721    loop {
722        match run_unless_cancelled(cancel, results.next()).await {
723            Some(Some((index, msg))) => slots[index] = Some(msg),
724            Some(None) => break,
725            None => {
726                aborted = true;
727                break;
728            }
729        }
730    }
731    if aborted {
732        // Salvage results that completed but were not yet yielded by the
733        // stream; only still-pending calls are left to the aborted fill.
734        use futures_util::FutureExt;
735        while let Some(Some((index, msg))) = results.next().now_or_never() {
736            slots[index] = Some(msg);
737        }
738    }
739    drop(results);
740    for (slot, call) in slots.iter_mut().zip(batch.drain(..)) {
741        out.push(match slot.take() {
742            Some(msg) => msg,
743            None => error_tool_result(call, ABORTED_TOOL_RESULT),
744        });
745    }
746    !aborted
747}
748
749/// Executes multiple tool calls and returns the results as messages.
750///
751/// Returns tool response messages suitable for continuing an LLM
752/// conversation, always in the same order as `tool_calls` regardless of
753/// completion order. Before execution, each call's arguments are validated
754/// against the tool's JSON Schema with lightweight type coercion (see
755/// [`coerce_by_schema`]); the coerced arguments are what the tool receives.
756/// A failure of one call (unparseable parameters, schema validation failure,
757/// or a tool error) does not abort the others: it is returned as a tool
758/// message with `is_error: Some(true)` so the LLM can recover.
759///
760/// Scheduling follows each tool's [`ExecutionMode`]:
761///
762/// * Consecutive calls to `Parallel` tools are executed concurrently, with
763///   at most `max_concurrency` calls in flight at once.
764/// * A call to a `Sequential` tool (the default, including tools not found
765///   in the registry) acts as a barrier: every earlier call must finish
766///   first, and the sequential call runs alone.
767///
768/// # Cancellation
769///
770/// When `ctx` carries a cancellation token (see
771/// [`AgentContext::cancel_token`]) and it fires mid-execution, in-flight
772/// calls are dropped and every call without a real result receives a
773/// synthetic `is_error` tool message with content `"Operation aborted"`,
774/// carrying the original tool_call id. Calls that already completed keep
775/// their real result. This keeps the message history consistent: the model
776/// sees a result for every call it issued, and finished side effects are
777/// not misreported as aborted.
778///
779/// # Arguments
780///
781/// * `ctx` - The agent context for the invocations
782/// * `tool_calls` - The tool calls to execute
783/// * `max_concurrency` - Upper bound on concurrently running `Parallel`
784///   calls; values below 1 are treated as 1
785///
786/// # Returns
787///
788/// A vector of tool response messages, one for each tool call, in input order.
789pub async fn call_tools(
790    ctx: &AgentContext,
791    tool_calls: &Vector<ToolCall>,
792    max_concurrency: usize,
793) -> Result<Vector<Message>, AgentError> {
794    if tool_calls.is_empty() {
795        return Ok(vector![]);
796    };
797    let max_concurrency = max_concurrency.max(1);
798    let cancel = ctx.cancel_token();
799
800    // Results land in input order throughout (flush_parallel_batch appends
801    // its whole batch in order and sequential calls are barriers), so at any
802    // point the calls still lacking a result are exactly
803    // tool_calls[resp_messages.len()..].
804    let mut aborted = false;
805    let mut resp_messages = Vec::with_capacity(tool_calls.len());
806    let mut parallel_batch: Vec<&ToolCall> = Vec::new();
807    for call in tool_calls {
808        // An unknown tool counts as Sequential so it flows through
809        // execute_tool_call alone and call_tool() reports the not-found error.
810        let mode = get_tool(call.function.name.as_str())
811            .map(|tool| tool.info().execution_mode)
812            .unwrap_or_default();
813        if mode == ExecutionMode::Parallel {
814            parallel_batch.push(call);
815            continue;
816        }
817        // A Sequential call is a barrier: flush the pending parallel batch
818        // first, then run the sequential call with nothing else in flight.
819        if !flush_parallel_batch(
820            ctx,
821            &mut parallel_batch,
822            max_concurrency,
823            &mut resp_messages,
824            cancel,
825        )
826        .await
827        {
828            aborted = true;
829            break;
830        }
831        match run_unless_cancelled(cancel, execute_tool_call(ctx, call)).await {
832            Some(msg) => resp_messages.push(msg),
833            None => {
834                aborted = true;
835                break;
836            }
837        }
838    }
839    if !aborted
840        && !flush_parallel_batch(
841            ctx,
842            &mut parallel_batch,
843            max_concurrency,
844            &mut resp_messages,
845            cancel,
846        )
847        .await
848    {
849        aborted = true;
850    }
851
852    // History consistency on cancellation: every tool_call the model issued
853    // must receive a result, so calls interrupted or never started get a
854    // synthetic aborted result carrying the original tool_call id (mirrors
855    // the stop_reason == "length" guard in CallToolMessageAgent).
856    if aborted {
857        for call in tool_calls.iter().skip(resp_messages.len()) {
858            resp_messages.push(error_tool_result(call, ABORTED_TOOL_RESULT));
859        }
860    }
861
862    Ok(resp_messages.into())
863}
864
865// ============================================================================
866// Tool Agents
867// ============================================================================
868
869/// Agent that lists available tools.
870///
871/// Outputs tool information for all registered tools, optionally filtered
872/// by regex patterns provided on the input port.
873///
874/// # Inputs
875///
876/// * `patterns` - Optional regex patterns (newline-separated) to filter tools
877///
878/// # Outputs
879///
880/// * `tools` - Array of tool information objects
881#[modular_agent(
882    title="List Tools",
883    category=CATEGORY,
884    inputs=[PORT_PATTERNS],
885    outputs=[PORT_TOOLS],
886)]
887pub struct ListToolsAgent {
888    data: AgentData,
889}
890
891#[async_trait]
892impl AsAgent for ListToolsAgent {
893    fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
894        Ok(Self {
895            data: AgentData::new(ma, id, spec),
896        })
897    }
898
899    async fn process(
900        &mut self,
901        ctx: AgentContext,
902        _port: String,
903        value: AgentValue,
904    ) -> Result<(), AgentError> {
905        let Some(patterns) = value.as_str() else {
906            return Err(AgentError::InvalidValue(
907                "patterns input must be a string".to_string(),
908            ));
909        };
910
911        let tools = if !patterns.is_empty() {
912            list_tool_infos_patterns(patterns)
913                .map_err(|e| AgentError::InvalidValue(format!("Invalid regex patterns: {}", e)))?
914        } else {
915            list_tool_infos()
916        };
917        let tools = tools
918            .into_iter()
919            .map(|tool| tool.into())
920            .collect::<Vector<AgentValue>>();
921        let tools_array = AgentValue::array(tools);
922
923        self.output(ctx, PORT_TOOLS, tools_array).await?;
924
925        Ok(())
926    }
927}
928
929/// Agent that exposes a workflow as a callable tool.
930///
931/// This agent registers itself as a tool that can be invoked by LLMs.
932/// When called, it forwards the arguments to the `tool_in` output port
933/// and waits for a response on the `tool_out` input port.
934///
935/// # Configuration
936///
937/// * `name` - The tool name (defaults to agent definition name)
938/// * `description` - Human-readable description of the tool
939/// * `parameters` - JSON Schema describing the tool's parameters
940/// * `timeout_secs` - Seconds to wait for the workflow's result before timing
941///   out (default: 60). `0` waits indefinitely. On timeout the caller receives
942///   a tool result with `is_error: true` so the LLM can recover.
943///
944/// # Ports
945///
946/// * Input `tool_out` - Receives the tool's result from the workflow
947/// * Output `tool_in` - Emits the tool call arguments to the workflow
948#[modular_agent(
949    title="Preset Tool",
950    category=CATEGORY,
951    inputs=[PORT_TOOL_OUT],
952    outputs=[PORT_TOOL_IN],
953    string_config(name=CONFIG_TOOL_NAME),
954    text_config(name=CONFIG_TOOL_DESCRIPTION),
955    object_config(name=CONFIG_TOOL_PARAMETERS),
956    integer_config(name=CONFIG_TIMEOUT_SECS, default=60),
957)]
958pub struct PresetToolAgent {
959    data: AgentData,
960    name: String,
961    description: String,
962    parameters: Option<serde_json::Value>,
963    /// Pending tool calls awaiting results, keyed by context ID.
964    pending: Arc<Mutex<HashMap<usize, oneshot::Sender<AgentValue>>>>,
965}
966
967impl PresetToolAgent {
968    /// Initiates a tool call and returns a receiver for the result.
969    ///
970    /// Emits the arguments to the workflow and registers a pending receiver
971    /// that will be fulfilled when the result arrives on the input port.
972    fn start_tool_call(
973        &mut self,
974        ctx: AgentContext,
975        args: AgentValue,
976    ) -> Result<oneshot::Receiver<AgentValue>, AgentError> {
977        let (tx, rx) = oneshot::channel();
978
979        self.pending.lock().unwrap().insert(ctx.id(), tx);
980        if let Err(e) = self.try_output(ctx.clone(), PORT_TOOL_IN, args) {
981            // Nothing was emitted, so no result can ever arrive; drop the entry
982            // now or it would linger until stop() and could swallow the result
983            // of a later call reusing this context id.
984            self.pending.lock().unwrap().remove(&ctx.id());
985            return Err(e);
986        }
987
988        Ok(rx)
989    }
990}
991
992#[async_trait]
993impl AsAgent for PresetToolAgent {
994    fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
995        let def_name = spec.def_name.clone();
996        let configs = spec.configs.clone();
997        let name = configs
998            .as_ref()
999            .and_then(|c| c.get_string(CONFIG_TOOL_NAME).ok())
1000            .unwrap_or_else(|| def_name.clone());
1001        let description = configs
1002            .as_ref()
1003            .and_then(|c| c.get_string(CONFIG_TOOL_DESCRIPTION).ok())
1004            .unwrap_or_default();
1005        let parameters = configs
1006            .as_ref()
1007            .and_then(|c| c.get(CONFIG_TOOL_PARAMETERS).ok())
1008            .and_then(|v| serde_json::to_value(v).ok());
1009        Ok(Self {
1010            data: AgentData::new(ma, id, spec),
1011            name,
1012            description,
1013            parameters,
1014            pending: Arc::new(Mutex::new(HashMap::new())),
1015        })
1016    }
1017
1018    fn configs_changed(&mut self) -> Result<(), AgentError> {
1019        let old_name = self.name.clone();
1020        self.name = self.configs()?.get_string_or_default(CONFIG_TOOL_NAME);
1021        self.description = self
1022            .configs()?
1023            .get_string_or_default(CONFIG_TOOL_DESCRIPTION);
1024        self.parameters = self
1025            .configs()?
1026            .get(CONFIG_TOOL_PARAMETERS)
1027            .ok()
1028            .and_then(|v| serde_json::to_value(v).ok());
1029
1030        // Refresh the registration only while running; otherwise start() will
1031        // register the tool with the new values later.
1032        if self.data.status == AgentStatus::Start {
1033            if !is_valid_tool_name(&self.name) {
1034                log::warn!(
1035                    "PresetToolAgent {} has invalid tool name {:?}; \
1036                     tool names must match ^[a-zA-Z0-9_-]{{1,64}}$",
1037                    self.id(),
1038                    self.name
1039                );
1040            }
1041            let agent_handle = self
1042                .ma()
1043                .get_agent(self.id())
1044                .ok_or_else(|| AgentError::AgentNotFound(self.id().to_string()))?;
1045            let tool = PresetTool::new(
1046                self.name.clone(),
1047                self.description.clone(),
1048                self.parameters.clone(),
1049                agent_handle,
1050            );
1051            // Register first: for an in-place refresh this overwrites the entry
1052            // atomically, so concurrent lookups never hit a missing tool. The
1053            // registry is name-keyed and process-global, so on rename the old
1054            // name must still be removed explicitly or it would leak a stale
1055            // entry that stop() (which unregisters the new name) never cleans up.
1056            register_tool(tool);
1057            if old_name != self.name {
1058                unregister_tool(&old_name);
1059            }
1060        }
1061
1062        Ok(())
1063    }
1064
1065    async fn start(&mut self) -> Result<(), AgentError> {
1066        // Claude and OpenAI both require tool names to match ^[a-zA-Z0-9_-]{1,64}$;
1067        // an invalid name only fails later at API-call time, so surface it early.
1068        if !is_valid_tool_name(&self.name) {
1069            log::warn!(
1070                "PresetToolAgent {} has invalid tool name {:?}; \
1071                 tool names must match ^[a-zA-Z0-9_-]{{1,64}}$",
1072                self.id(),
1073                self.name
1074            );
1075        }
1076        let agent_handle = self
1077            .ma()
1078            .get_agent(self.id())
1079            .ok_or_else(|| AgentError::AgentNotFound(self.id().to_string()))?;
1080        let tool = PresetTool::new(
1081            self.name.clone(),
1082            self.description.clone(),
1083            self.parameters.clone(),
1084            agent_handle,
1085        );
1086        register_tool(tool);
1087        Ok(())
1088    }
1089
1090    async fn stop(&mut self) -> Result<(), AgentError> {
1091        unregister_tool(&self.name);
1092        self.pending.lock().unwrap().clear();
1093        Ok(())
1094    }
1095
1096    async fn process(
1097        &mut self,
1098        ctx: AgentContext,
1099        _port: String,
1100        value: AgentValue,
1101    ) -> Result<(), AgentError> {
1102        if let Some(tx) = self.pending.lock().unwrap().remove(&ctx.id()) {
1103            let _ = tx.send(value);
1104        }
1105        Ok(())
1106    }
1107}
1108
1109/// Internal Tool implementation that delegates to a PresetToolAgent.
1110struct PresetTool {
1111    info: ToolInfo,
1112    agent: SharedAgent,
1113}
1114
1115impl PresetTool {
1116    /// Creates a new PresetTool wrapping a PresetToolAgent.
1117    fn new(
1118        name: String,
1119        description: String,
1120        parameters: Option<serde_json::Value>,
1121        agent: SharedAgent,
1122    ) -> Self {
1123        Self {
1124            info: ToolInfo::new(name, description, parameters),
1125            agent,
1126        }
1127    }
1128
1129    /// Executes a tool call through the wrapped agent.
1130    ///
1131    /// Waits up to the agent's `timeout_secs` config (default 60) for a result;
1132    /// `0` waits indefinitely. The timeout is read at call time so runtime config
1133    /// changes take effect. On timeout an `AgentError::Timeout` is returned, which
1134    /// the LLM tool-call path (`call_tools`) turns into an `is_error` tool result.
1135    ///
1136    /// When `ctx` carries a cancellation token, the wait also aborts as soon
1137    /// as the token fires, returning [`AgentError::Cancelled`].
1138    async fn tool_call(
1139        &self,
1140        ctx: AgentContext,
1141        args: AgentValue,
1142    ) -> Result<AgentValue, AgentError> {
1143        if ctx.is_cancelled() {
1144            return Err(AgentError::Cancelled);
1145        }
1146
1147        // Kick off the tool call while holding the lock, then drop it before awaiting the result
1148        let ctx_id = ctx.id();
1149        let cancel = ctx.cancel_token().cloned();
1150        let (rx, timeout_secs, pending) = {
1151            let mut guard = self.agent.lock().await;
1152            let Some(preset_tool_agent) = guard.as_agent_mut::<PresetToolAgent>() else {
1153                return Err(AgentError::Other(
1154                    "Agent is not PresetToolAgent".to_string(),
1155                ));
1156            };
1157            // Cancellation may have fired while waiting for the agent lock.
1158            // Check again immediately before pending state is registered and
1159            // `tool_in` is emitted.
1160            if ctx.is_cancelled() {
1161                return Err(AgentError::Cancelled);
1162            }
1163            let timeout_secs = preset_tool_agent
1164                .configs()
1165                .map(|c| c.get_integer_or(CONFIG_TIMEOUT_SECS, DEFAULT_TIMEOUT_SECS))
1166                .unwrap_or(DEFAULT_TIMEOUT_SECS);
1167            let pending = preset_tool_agent.pending.clone();
1168            let rx = preset_tool_agent.start_tool_call(ctx, args)?;
1169            (rx, timeout_secs, pending)
1170        };
1171
1172        // Deregister the pending sender however this future ends — including
1173        // being dropped mid-await: call_tools races this whole future against
1174        // the flow token and drops it on cancellation without polling it
1175        // again, so cleanup after the await would never run. The pending map
1176        // is keyed by context id, which is shared by every tool call in the
1177        // same flow (including an LLM retry after an error), so a leftover
1178        // sender would deliver a late tool_out to whichever call registers
1179        // under the same id next. PresetTool registers with
1180        // ExecutionMode::Sequential and call_tools never runs a Sequential
1181        // call concurrently with anything, so within a flow no newer call can
1182        // have registered a sender while this one is alive — the guard cannot
1183        // remove a newer call's sender.
1184        struct PendingGuard {
1185            pending: Arc<Mutex<HashMap<usize, oneshot::Sender<AgentValue>>>>,
1186            ctx_id: usize,
1187        }
1188        impl Drop for PendingGuard {
1189            fn drop(&mut self) {
1190                self.pending.lock().unwrap().remove(&self.ctx_id);
1191            }
1192        }
1193        let _guard = PendingGuard { pending, ctx_id };
1194
1195        let wait = async {
1196            let rx = async {
1197                rx.await
1198                    .map_err(|_| AgentError::Other("tool_out dropped".to_string()))
1199            };
1200            if timeout_secs <= 0 {
1201                rx.await
1202            } else {
1203                match tokio::time::timeout(Duration::from_secs(timeout_secs as u64), rx).await {
1204                    Ok(result) => result,
1205                    Err(_) => Err(AgentError::Timeout(format!(
1206                        "Tool call timed out after {} seconds",
1207                        timeout_secs
1208                    ))),
1209                }
1210            }
1211        };
1212        run_unless_cancelled(cancel.as_ref(), wait)
1213            .await
1214            .unwrap_or(Err(AgentError::Cancelled))
1215    }
1216}
1217
1218#[async_trait]
1219impl Tool for PresetTool {
1220    fn info(&self) -> &ToolInfo {
1221        &self.info
1222    }
1223
1224    async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError> {
1225        self.tool_call(ctx, args).await
1226    }
1227}
1228
1229/// Agent that processes tool calls from LLM messages.
1230///
1231/// When an LLM response contains tool calls, this agent executes them
1232/// and outputs the results as tool response messages. Consecutive calls to
1233/// tools registered with `ExecutionMode::Parallel` run concurrently (bounded
1234/// by `max_concurrency`); calls to `Sequential` tools — the default — run one
1235/// at a time and act as barriers. Results are emitted in input order.
1236///
1237/// # Configuration
1238///
1239/// * `tools` - Optional regex patterns to filter which tools can be called
1240/// * `max_concurrency` - Maximum number of `Parallel`-mode tool calls in
1241///   flight at once; values below 1 are treated as 1 (default: 8)
1242///
1243/// # Ports
1244///
1245/// * Input `message` - LLM message that may contain tool calls
1246/// * Output `message` - Tool response messages (one per tool call)
1247#[modular_agent(
1248    title="Call Tool Message",
1249    category=CATEGORY,
1250    inputs=[PORT_MESSAGE],
1251    outputs=[PORT_MESSAGE],
1252    string_config(name=CONFIG_TOOLS),
1253    integer_config(name=CONFIG_MAX_CONCURRENCY, default=8),
1254)]
1255pub struct CallToolMessageAgent {
1256    data: AgentData,
1257    /// Tool-call ids already executed, keyed by ctx_key, guarding against a
1258    /// streaming turn re-delivering the same final message (e.g. Claude emits
1259    /// identical tool_calls on both ContentBlockStop and MessageStop).
1260    executed: BTreeMap<String, HashSet<String>>,
1261    /// Insertion order of ctx_keys, enabling oldest-first eviction once capped.
1262    ctx_key_order: VecDeque<String>,
1263}
1264
1265/// Upper bound on tracked ctx_keys; the oldest entry is evicted when exceeded.
1266const MAX_TRACKED_CTX_KEYS: usize = 1024;
1267
1268impl CallToolMessageAgent {
1269    fn is_executed(&self, ctx_key: &str, id: &str) -> bool {
1270        self.executed
1271            .get(ctx_key)
1272            .is_some_and(|ids| ids.contains(id))
1273    }
1274
1275    fn mark_executed(&mut self, ctx_key: &str, id: String) {
1276        if !self.executed.contains_key(ctx_key) {
1277            if self.ctx_key_order.len() >= MAX_TRACKED_CTX_KEYS
1278                && let Some(oldest) = self.ctx_key_order.pop_front()
1279            {
1280                self.executed.remove(&oldest);
1281            }
1282            self.ctx_key_order.push_back(ctx_key.to_string());
1283        }
1284        self.executed
1285            .entry(ctx_key.to_string())
1286            .or_default()
1287            .insert(id);
1288    }
1289}
1290
1291#[async_trait]
1292impl AsAgent for CallToolMessageAgent {
1293    fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
1294        Ok(Self {
1295            data: AgentData::new(ma, id, spec),
1296            executed: BTreeMap::new(),
1297            ctx_key_order: VecDeque::new(),
1298        })
1299    }
1300
1301    async fn stop(&mut self) -> Result<(), AgentError> {
1302        self.executed.clear();
1303        self.ctx_key_order.clear();
1304        Ok(())
1305    }
1306
1307    async fn process(
1308        &mut self,
1309        ctx: AgentContext,
1310        _port: String,
1311        value: AgentValue,
1312    ) -> Result<(), AgentError> {
1313        let Some(message) = value.as_message() else {
1314            return Ok(());
1315        };
1316        // Partial streaming messages carry accumulated tool_calls but are not final;
1317        // only the streaming=false message for the turn may trigger execution.
1318        if message.streaming {
1319            return Ok(());
1320        }
1321        let Some(mut tool_calls) = message.tool_calls.clone() else {
1322            return Ok(());
1323        };
1324
1325        // Filter tools
1326        let config_tools = self.configs()?.get_string_or_default(CONFIG_TOOLS);
1327        if !config_tools.is_empty() {
1328            let tools = list_tool_infos_patterns(&config_tools)
1329                .map_err(|e| AgentError::InvalidValue(format!("Invalid regex patterns: {}", e)))?;
1330            // FIXME: cache allowed tool names
1331            let allowed_tool_names: HashSet<String> = tools.into_iter().map(|t| t.name).collect();
1332            tool_calls = tool_calls
1333                .iter()
1334                .filter(|call| allowed_tool_names.contains(&call.function.name))
1335                .cloned()
1336                .collect();
1337        }
1338
1339        // Defensive dedup: skip calls whose id was already executed for this flow.
1340        // Calls without an id keep legacy behavior and always execute.
1341        let ctx_key = ctx.ctx_key()?;
1342        tool_calls = tool_calls
1343            .iter()
1344            .filter(|call| match &call.function.id {
1345                Some(id) => !self.is_executed(&ctx_key, id),
1346                None => true,
1347            })
1348            .cloned()
1349            .collect();
1350
1351        // Record ids before executing: for side-effecting tools (Slack posts, DB
1352        // writes) skipping a retry is safer than double execution.
1353        for call in &tool_calls {
1354            if let Some(id) = &call.function.id {
1355                self.mark_executed(&ctx_key, id.clone());
1356            }
1357        }
1358
1359        // A response truncated by the token limit may carry incomplete tool-call
1360        // arguments; report that back to the model instead of executing them.
1361        // Placed after the dedup bookkeeping so a re-delivered final message does
1362        // not emit duplicate synthetic results.
1363        if message.stop_reason.as_deref() == Some("length") {
1364            for call in &tool_calls {
1365                let resp_msg = error_tool_result(
1366                    call,
1367                    format!(
1368                        "Tool call \"{}\" was not executed: output hit the token \
1369                         limit; arguments may be truncated. Re-issue with complete \
1370                         arguments.",
1371                        call.function.name
1372                    ),
1373                );
1374                self.output(ctx.clone(), PORT_MESSAGE, AgentValue::message(resp_msg))
1375                    .await?;
1376            }
1377            return Ok(());
1378        }
1379
1380        // Read at call time (like timeout_secs) so runtime config changes
1381        // take effect without a restart.
1382        let max_concurrency = self
1383            .configs()?
1384            .get_integer_or(CONFIG_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY)
1385            .max(1) as usize;
1386
1387        let resp_messages = call_tools(&ctx, &tool_calls, max_concurrency).await?;
1388        for resp_msg in resp_messages {
1389            self.output(ctx.clone(), PORT_MESSAGE, AgentValue::message(resp_msg))
1390                .await?;
1391        }
1392        Ok(())
1393    }
1394}
1395
1396/// Agent that guards LLM tool-call cycles against runaway iteration.
1397///
1398/// Insert this agent between a chat agent's message output and the
1399/// tool-execution node. It forwards traffic transparently while counting,
1400/// per flow, the final assistant messages that request tool calls. Once the
1401/// count would exceed `max_iterations`, the triggering message is not
1402/// forwarded — severing the cycle — and a synthesized assistant message
1403/// explaining the stop is emitted on `limit_exceeded` instead.
1404///
1405/// A message is counted only when all of the following hold: role is
1406/// "assistant", `tool_calls` is present and non-empty, `streaming` is false,
1407/// and its id differs from the last counted id for the flow. Streaming turns
1408/// re-deliver the same tool_calls-bearing message under one id (partials plus
1409/// a possibly duplicated final), so counting every delivery would exhaust the
1410/// limit within a few turns. Messages without an id cannot be deduplicated
1411/// and are always counted. Everything else — non-message values, user / tool /
1412/// system messages, streaming partials, and assistant messages without tool
1413/// calls — passes through unchanged.
1414///
1415/// Setting `max_iterations` to zero or a negative value disables the limit.
1416/// Counters are kept per flow (`ctx_key`), capped at 1024 flows with
1417/// oldest-first eviction, and cleared when the agent stops.
1418///
1419/// # Configuration
1420///
1421/// * `max_iterations` - Maximum tool-call iterations per flow; `<= 0` disables the limit (default: 25)
1422///
1423/// # Ports
1424///
1425/// * Input `message` - Messages flowing through the tool-call cycle
1426/// * Output `message` - The forwarded input while within the limit
1427/// * Output `limit_exceeded` - Synthesized assistant message emitted when the limit is exceeded
1428#[modular_agent(
1429    title="Loop Control",
1430    category=CATEGORY,
1431    inputs=[PORT_MESSAGE],
1432    outputs=[PORT_MESSAGE, PORT_LIMIT_EXCEEDED],
1433    integer_config(name=CONFIG_MAX_ITERATIONS, default=25),
1434)]
1435pub struct LoopControlAgent {
1436    data: AgentData,
1437    /// Iteration count and last counted message id, keyed by ctx_key. The id
1438    /// guards against a streaming turn re-delivering the same final message.
1439    counts: BTreeMap<String, (u32, Option<String>)>,
1440    /// Insertion order of ctx_keys, enabling oldest-first eviction once capped.
1441    ctx_key_order: VecDeque<String>,
1442}
1443
1444impl LoopControlAgent {
1445    fn record_count(&mut self, ctx_key: &str, count: u32, id: Option<String>) {
1446        if !self.counts.contains_key(ctx_key) {
1447            if self.ctx_key_order.len() >= MAX_TRACKED_CTX_KEYS
1448                && let Some(oldest) = self.ctx_key_order.pop_front()
1449            {
1450                self.counts.remove(&oldest);
1451            }
1452            self.ctx_key_order.push_back(ctx_key.to_string());
1453        }
1454        self.counts.insert(ctx_key.to_string(), (count, id));
1455    }
1456}
1457
1458#[async_trait]
1459impl AsAgent for LoopControlAgent {
1460    fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
1461        Ok(Self {
1462            data: AgentData::new(ma, id, spec),
1463            counts: BTreeMap::new(),
1464            ctx_key_order: VecDeque::new(),
1465        })
1466    }
1467
1468    async fn stop(&mut self) -> Result<(), AgentError> {
1469        self.counts.clear();
1470        self.ctx_key_order.clear();
1471        Ok(())
1472    }
1473
1474    async fn process(
1475        &mut self,
1476        ctx: AgentContext,
1477        _port: String,
1478        value: AgentValue,
1479    ) -> Result<(), AgentError> {
1480        let countable = value.as_message().is_some_and(|m| {
1481            m.role == "assistant"
1482                && !m.streaming
1483                && m.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
1484        });
1485        if !countable {
1486            // The node must stay transparent for everything it does not count:
1487            // non-message values, user / tool / system messages, streaming
1488            // partials, and assistant messages without tool calls.
1489            return self.output(ctx, PORT_MESSAGE, value).await;
1490        }
1491        let message_id = value.as_message().and_then(|m| m.id.clone());
1492
1493        let max_iterations = self
1494            .configs()?
1495            .get_integer_or(CONFIG_MAX_ITERATIONS, DEFAULT_MAX_ITERATIONS);
1496        let ctx_key = ctx.ctx_key()?;
1497        let (count, last_counted_id) = self.counts.get(&ctx_key).cloned().unwrap_or((0, None));
1498
1499        // Re-delivery of the last counted message (e.g. Claude emits the same
1500        // final message on both ContentBlockStop and MessageStop). Never
1501        // re-count it, and forward it only if it was forwarded the first time,
1502        // so a duplicate of the blocked message neither re-opens the cycle nor
1503        // emits limit_exceeded twice.
1504        if let Some(id) = &message_id
1505            && last_counted_id.as_deref() == Some(id)
1506        {
1507            if max_iterations > 0 && i64::from(count) > max_iterations {
1508                return Ok(());
1509            }
1510            return self.output(ctx, PORT_MESSAGE, value).await;
1511        }
1512
1513        let count = count.saturating_add(1);
1514        self.record_count(&ctx_key, count, message_id);
1515
1516        if max_iterations > 0 && i64::from(count) > max_iterations {
1517            let notice = Message::assistant(format!(
1518                "Loop limit reached: the tool-call cycle exceeded the configured max_iterations of {} and has been stopped.",
1519                max_iterations
1520            ));
1521            return self
1522                .output(ctx, PORT_LIMIT_EXCEEDED, AgentValue::message(notice))
1523                .await;
1524        }
1525
1526        self.output(ctx, PORT_MESSAGE, value).await
1527    }
1528}
1529
1530/// Agent that directly invokes a tool by name.
1531///
1532/// Takes a tool call specification (name and parameters) and invokes
1533/// the corresponding registered tool, outputting the result.
1534///
1535/// # Ports
1536///
1537/// * Input `tool_call` - Object with `name` (string) and optional `parameters`
1538/// * Output `value` - The tool's return value
1539#[modular_agent(
1540    title="Call Tool",
1541    category=CATEGORY,
1542    inputs=[PORT_TOOL_CALL],
1543    outputs=[PORT_VALUE],
1544)]
1545pub struct CallToolAgent {
1546    data: AgentData,
1547}
1548
1549#[async_trait]
1550impl AsAgent for CallToolAgent {
1551    fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
1552        Ok(Self {
1553            data: AgentData::new(ma, id, spec),
1554        })
1555    }
1556
1557    async fn process(
1558        &mut self,
1559        ctx: AgentContext,
1560        _port: String,
1561        value: AgentValue,
1562    ) -> Result<(), AgentError> {
1563        let obj = value.as_object().ok_or_else(|| {
1564            AgentError::InvalidValue("tool_call input must be an object".to_string())
1565        })?;
1566        let tool_name = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
1567            AgentError::InvalidValue("tool_call.name must be a string".to_string())
1568        })?;
1569        let tool_parameters = obj.get("parameters").cloned().unwrap_or(AgentValue::unit());
1570
1571        let resp = call_tool(ctx.clone(), tool_name, tool_parameters).await?;
1572        self.output(ctx, PORT_VALUE, resp).await?;
1573
1574        Ok(())
1575    }
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580    use super::*;
1581    use crate::ToolCallFunction;
1582
1583    #[test]
1584    fn test_tool_info_new_parameters_default() {
1585        let info = ToolInfo::new("t", "d", None);
1586        assert_eq!(
1587            info.parameters,
1588            serde_json::json!({"type": "object", "properties": {}})
1589        );
1590
1591        let schema = serde_json::json!({
1592            "type": "object",
1593            "properties": {"x": {"type": "string"}},
1594            "required": ["x"]
1595        });
1596        let info = ToolInfo::new("t", "d", Some(schema.clone()));
1597        assert_eq!(info.parameters, schema);
1598    }
1599
1600    #[test]
1601    fn test_error_tool_result_shape() {
1602        let call = ToolCall {
1603            function: ToolCallFunction {
1604                name: "my_tool".to_string(),
1605                parameters: serde_json::json!({}),
1606                id: Some("call42".to_string()),
1607                parse_error: None,
1608            },
1609        };
1610        let msg = error_tool_result(&call, "something went wrong");
1611
1612        assert_eq!(msg.role, "tool");
1613        assert_eq!(msg.tool_name.as_deref(), Some("my_tool"));
1614        assert_eq!(msg.id.as_deref(), Some("call42"));
1615        assert_eq!(msg.is_error, Some(true));
1616        assert_eq!(msg.text(), "something went wrong");
1617    }
1618
1619    #[test]
1620    fn test_is_valid_tool_name_accepts_valid() {
1621        assert!(is_valid_tool_name("a"));
1622        assert!(is_valid_tool_name("my_tool"));
1623        assert!(is_valid_tool_name("my-tool"));
1624        assert!(is_valid_tool_name("Tool_123-ABC"));
1625        assert!(is_valid_tool_name(&"x".repeat(64)));
1626    }
1627
1628    #[test]
1629    fn test_preset_tool_timeout_config_default() {
1630        let def = PresetToolAgent::agent_definition();
1631        let specs = def
1632            .configs
1633            .as_ref()
1634            .expect("PresetToolAgent should have config specs");
1635        let spec = specs
1636            .get(CONFIG_TIMEOUT_SECS)
1637            .expect("timeout_secs config should be present");
1638        assert_eq!(spec.value, AgentValue::integer(DEFAULT_TIMEOUT_SECS));
1639    }
1640
1641    #[test]
1642    fn test_description_warning_flags_weak_descriptions() {
1643        let warning = description_warning("my_tool", "").expect("empty should warn");
1644        assert!(warning.contains("my_tool"));
1645
1646        let warning = description_warning("my_tool", "  \t\n  ").expect("whitespace should warn");
1647        assert!(warning.contains("my_tool"));
1648
1649        // 9 characters (including the space) is below the threshold.
1650        let warning = description_warning("my_tool", "too short").expect("short should warn");
1651        assert!(warning.contains("my_tool"));
1652        assert!(warning.contains("too short"));
1653    }
1654
1655    #[test]
1656    fn test_description_warning_accepts_adequate_descriptions() {
1657        assert!(description_warning("t", "0123456789").is_none());
1658        assert!(
1659            description_warning("t", "Fetches the current weather for a given city.").is_none()
1660        );
1661        // Threshold counts characters, not bytes: 12 chars, 36 bytes.
1662        assert!(description_warning("t", "ツールの詳細な説明です。").is_none());
1663    }
1664
1665    #[test]
1666    fn test_is_valid_tool_name_rejects_invalid() {
1667        assert!(!is_valid_tool_name(""));
1668        assert!(!is_valid_tool_name(&"x".repeat(65)));
1669        assert!(!is_valid_tool_name("my tool"));
1670        assert!(!is_valid_tool_name("my.tool"));
1671        assert!(!is_valid_tool_name("ツール"));
1672        assert!(!is_valid_tool_name("tool@1"));
1673    }
1674
1675    mod arg_validation {
1676        use super::*;
1677        use serde_json::json;
1678
1679        /// Tool that echoes its arguments back, exposing what reached it
1680        /// after validation and coercion.
1681        struct EchoTool {
1682            info: ToolInfo,
1683        }
1684
1685        #[async_trait]
1686        impl Tool for EchoTool {
1687            fn info(&self) -> &ToolInfo {
1688                &self.info
1689            }
1690
1691            async fn call(
1692                &self,
1693                _ctx: AgentContext,
1694                args: AgentValue,
1695            ) -> Result<AgentValue, AgentError> {
1696                Ok(args)
1697            }
1698        }
1699
1700        fn register_echo(name: &str, schema: Option<serde_json::Value>) {
1701            register_tool(EchoTool {
1702                info: ToolInfo::new(name, "echoes args", schema),
1703            });
1704        }
1705
1706        fn call(name: &str, id: &str, params: serde_json::Value) -> ToolCall {
1707            ToolCall {
1708                function: ToolCallFunction {
1709                    name: name.to_string(),
1710                    parameters: params,
1711                    id: Some(id.to_string()),
1712                    parse_error: None,
1713                },
1714            }
1715        }
1716
1717        fn content_json(msg: &Message) -> serde_json::Value {
1718            serde_json::from_str(&msg.text()).unwrap()
1719        }
1720
1721        #[test]
1722        fn coerce_does_not_truncate_float_string_to_integer() {
1723            let schema = json!({"type": "integer"});
1724            let mut v = json!("42.5");
1725            coerce_by_schema(&mut v, &schema);
1726            assert_eq!(v, json!("42.5"));
1727        }
1728
1729        #[test]
1730        fn coerce_number_accepts_float_string_and_keeps_integer() {
1731            let schema = json!({"type": "number"});
1732            let mut v = json!("42.5");
1733            coerce_by_schema(&mut v, &schema);
1734            assert_eq!(v, json!(42.5));
1735
1736            // An integer is already a valid number and must stay untouched.
1737            let mut v = json!(3);
1738            coerce_by_schema(&mut v, &schema);
1739            assert_eq!(v, json!(3));
1740        }
1741
1742        #[test]
1743        fn coerce_array_items() {
1744            let schema = json!({"type": "array", "items": {"type": "integer"}});
1745            let mut v = json!(["1", 2, "3"]);
1746            coerce_by_schema(&mut v, &schema);
1747            assert_eq!(v, json!([1, 2, 3]));
1748        }
1749
1750        #[tokio::test]
1751        async fn string_primitives_coerced_before_tool_call() {
1752            let name = "p14_coerce_prims";
1753            register_echo(
1754                name,
1755                Some(json!({
1756                    "type": "object",
1757                    "properties": {
1758                        "count": {"type": "integer"},
1759                        "flag": {"type": "boolean"}
1760                    },
1761                    "required": ["count", "flag"]
1762                })),
1763            );
1764
1765            let calls = vector![call(name, "c1", json!({"count": "42", "flag": "true"}))];
1766            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1767            unregister_tool(name);
1768
1769            assert_eq!(msgs.len(), 1);
1770            assert_eq!(msgs[0].is_error, None);
1771            assert_eq!(msgs[0].id.as_deref(), Some("c1"));
1772            assert_eq!(content_json(&msgs[0]), json!({"count": 42, "flag": true}));
1773        }
1774
1775        #[tokio::test]
1776        async fn nested_object_property_coerced() {
1777            let name = "p14_coerce_nested";
1778            register_echo(
1779                name,
1780                Some(json!({
1781                    "type": "object",
1782                    "properties": {
1783                        "outer": {
1784                            "type": "object",
1785                            "properties": {"n": {"type": "integer"}}
1786                        }
1787                    }
1788                })),
1789            );
1790
1791            let calls = vector![call(name, "c1", json!({"outer": {"n": "7"}}))];
1792            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1793            unregister_tool(name);
1794
1795            assert_eq!(msgs.len(), 1);
1796            assert_eq!(msgs[0].is_error, None);
1797            assert_eq!(content_json(&msgs[0]), json!({"outer": {"n": 7}}));
1798        }
1799
1800        #[tokio::test]
1801        async fn validation_failure_reports_error_and_batch_continues() {
1802            let name = "p14_validation_failure";
1803            register_echo(
1804                name,
1805                Some(json!({
1806                    "type": "object",
1807                    "properties": {"count": {"type": "integer"}},
1808                    "required": ["count"]
1809                })),
1810            );
1811
1812            let calls = vector![
1813                call(name, "bad", json!({"count": "abc"})),
1814                call(name, "good", json!({"count": "5"})),
1815            ];
1816            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1817            unregister_tool(name);
1818
1819            assert_eq!(msgs.len(), 2);
1820            assert_eq!(msgs[0].is_error, Some(true));
1821            assert_eq!(msgs[0].id.as_deref(), Some("bad"));
1822            assert!(msgs[0].text().contains("not executed"));
1823            assert!(msgs[0].text().contains("/count"));
1824
1825            // The failure of the first call must not abort the second.
1826            assert_eq!(msgs[1].is_error, None);
1827            assert_eq!(msgs[1].id.as_deref(), Some("good"));
1828            assert_eq!(content_json(&msgs[1]), json!({"count": 5}));
1829        }
1830
1831        #[tokio::test]
1832        async fn validation_failure_collects_all_errors() {
1833            let name = "p14_all_errors";
1834            register_echo(
1835                name,
1836                Some(json!({
1837                    "type": "object",
1838                    "properties": {
1839                        "count": {"type": "integer"},
1840                        "flag": {"type": "boolean"}
1841                    },
1842                    "required": ["count", "flag"]
1843                })),
1844            );
1845
1846            let calls = vector![call(name, "bad", json!({"count": "abc", "flag": "xyz"}))];
1847            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1848            unregister_tool(name);
1849
1850            assert_eq!(msgs.len(), 1);
1851            assert_eq!(msgs[0].is_error, Some(true));
1852            // Every invalid path must be reported, not just the first error.
1853            assert!(msgs[0].text().contains("/count"));
1854            assert!(msgs[0].text().contains("/flag"));
1855        }
1856
1857        #[tokio::test]
1858        async fn default_empty_object_schema_accepts_arbitrary_args() {
1859            let name = "p14_default_schema";
1860            register_echo(name, None);
1861
1862            let args = json!({"anything": [1, 2, 3], "nested": {"x": "y"}});
1863            let calls = vector![call(name, "c1", args.clone())];
1864            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1865            unregister_tool(name);
1866
1867            assert_eq!(msgs.len(), 1);
1868            assert_eq!(msgs[0].is_error, None);
1869            assert_eq!(content_json(&msgs[0]), args);
1870        }
1871
1872        #[tokio::test]
1873        async fn uncompilable_schema_skips_validation_and_executes() {
1874            let name = "p14_broken_schema";
1875            register_echo(name, Some(json!({"type": 123})));
1876
1877            let args = json!({"x": "1"});
1878            let calls = vector![call(name, "c1", args.clone())];
1879            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1880            unregister_tool(name);
1881
1882            assert_eq!(msgs.len(), 1);
1883            assert_eq!(msgs[0].is_error, None);
1884            // Coercion is also skipped: the original string arrives untouched.
1885            assert_eq!(content_json(&msgs[0]), args);
1886        }
1887
1888        #[tokio::test]
1889        async fn any_of_coercion_picks_validating_variant() {
1890            let name = "p14_any_of";
1891            register_echo(
1892                name,
1893                Some(json!({
1894                    "type": "object",
1895                    "properties": {
1896                        "v": {"anyOf": [{"type": "integer"}, {"type": "boolean"}]}
1897                    }
1898                })),
1899            );
1900
1901            let calls = vector![
1902                call(name, "c1", json!({"v": "true"})),
1903                call(name, "c2", json!({"v": "7"})),
1904                call(name, "c3", json!({"v": 5})),
1905            ];
1906            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1907            unregister_tool(name);
1908
1909            assert_eq!(msgs.len(), 3);
1910            // "true" fails the integer variant but coerces to the boolean one.
1911            assert_eq!(content_json(&msgs[0]), json!({"v": true}));
1912            assert_eq!(content_json(&msgs[1]), json!({"v": 7}));
1913            // An already-valid value is left untouched.
1914            assert_eq!(content_json(&msgs[2]), json!({"v": 5}));
1915        }
1916
1917        #[tokio::test]
1918        async fn any_of_with_sibling_properties_still_coerces() {
1919            let name = "p14_any_of_siblings";
1920            // Common "at least one of" pattern: anyOf lists required variants
1921            // while sibling properties carry the type information. A variant
1922            // accepting the raw object must not skip property coercion.
1923            register_echo(
1924                name,
1925                Some(json!({
1926                    "type": "object",
1927                    "properties": {
1928                        "a": {"type": "integer"},
1929                        "b": {"type": "integer"}
1930                    },
1931                    "anyOf": [{"required": ["a"]}, {"required": ["b"]}]
1932                })),
1933            );
1934
1935            let calls = vector![call(name, "c1", json!({"a": "5"}))];
1936            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1937            unregister_tool(name);
1938
1939            assert_eq!(msgs.len(), 1);
1940            assert_eq!(msgs[0].is_error, None);
1941            assert_eq!(content_json(&msgs[0]), json!({"a": 5}));
1942        }
1943    }
1944
1945    mod result_content {
1946        use super::*;
1947
1948        /// Tool that returns a fixed value regardless of its arguments.
1949        struct FixedTool {
1950            info: ToolInfo,
1951            value: AgentValue,
1952        }
1953
1954        #[async_trait]
1955        impl Tool for FixedTool {
1956            fn info(&self) -> &ToolInfo {
1957                &self.info
1958            }
1959
1960            async fn call(
1961                &self,
1962                _ctx: AgentContext,
1963                _args: AgentValue,
1964            ) -> Result<AgentValue, AgentError> {
1965                Ok(self.value.clone())
1966            }
1967        }
1968
1969        fn register_fixed(name: &str, value: AgentValue) {
1970            register_tool(FixedTool {
1971                info: ToolInfo::new(name, "returns a fixed value for tests", None),
1972                value,
1973            });
1974        }
1975
1976        fn call(name: &str, id: &str) -> ToolCall {
1977            ToolCall {
1978                function: ToolCallFunction {
1979                    name: name.to_string(),
1980                    parameters: serde_json::json!({}),
1981                    id: Some(id.to_string()),
1982                    parse_error: None,
1983                },
1984            }
1985        }
1986
1987        async fn run_single(name: &str) -> Message {
1988            let calls = vector![call(name, "c1")];
1989            let mut msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
1990            unregister_tool(name);
1991            assert_eq!(msgs.len(), 1);
1992            msgs.remove(0)
1993        }
1994
1995        #[cfg(feature = "image")]
1996        #[tokio::test]
1997        async fn image_result_becomes_single_image_block() {
1998            let name = "result_content_image";
1999            register_fixed(name, AgentValue::image_default());
2000
2001            let msg = run_single(name).await;
2002            assert_eq!(msg.role, "tool");
2003            assert_eq!(msg.tool_name.as_deref(), Some(name));
2004            assert_eq!(msg.id.as_deref(), Some("c1"));
2005            assert_eq!(msg.is_error, None);
2006
2007            let MessageContent::Blocks(blocks) = &msg.content else {
2008                panic!("expected block content, got {:?}", msg.content);
2009            };
2010            assert_eq!(blocks.len(), 1);
2011            let ContentBlock::Image { data, mime_type } = &blocks[0] else {
2012                panic!("expected image block, got {:?}", blocks[0]);
2013            };
2014            assert_eq!(mime_type, "image/png");
2015            assert!(!data.starts_with("data:"));
2016            assert!(!data.is_empty());
2017        }
2018
2019        #[cfg(feature = "image")]
2020        #[tokio::test]
2021        async fn mixed_array_result_becomes_ordered_blocks() {
2022            let name = "result_content_mixed_array";
2023            register_fixed(
2024                name,
2025                AgentValue::array(vector![
2026                    AgentValue::image_default(),
2027                    AgentValue::string("caption"),
2028                ]),
2029            );
2030
2031            let msg = run_single(name).await;
2032            let MessageContent::Blocks(blocks) = &msg.content else {
2033                panic!("expected block content, got {:?}", msg.content);
2034            };
2035            assert_eq!(blocks.len(), 2);
2036            assert!(matches!(&blocks[0], ContentBlock::Image { .. }));
2037            let ContentBlock::Text { text } = &blocks[1] else {
2038                panic!("expected text block, got {:?}", blocks[1]);
2039            };
2040            // Non-image array elements keep their stringified-JSON form.
2041            assert_eq!(text, "\"caption\"");
2042        }
2043
2044        #[tokio::test]
2045        async fn non_image_results_keep_legacy_text_form() {
2046            let object = AgentValue::from_json(serde_json::json!({"a": 1, "b": "x"})).unwrap();
2047            let imageless_array =
2048                AgentValue::from_json(serde_json::json!([1, "two", null])).unwrap();
2049            let cases = [
2050                ("result_content_object", object),
2051                ("result_content_string", AgentValue::string("plain")),
2052                ("result_content_array", imageless_array),
2053            ];
2054            for (name, value) in cases {
2055                let expected = value.to_json().to_string();
2056                register_fixed(name, value);
2057                let msg = run_single(name).await;
2058                assert_eq!(msg.content, MessageContent::Text(expected));
2059            }
2060        }
2061
2062        #[tokio::test]
2063        async fn error_result_stays_text() {
2064            // An unregistered tool routes through error_tool_result.
2065            let calls = vector![call("result_content_no_such_tool", "c1")];
2066            let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
2067            assert_eq!(msgs.len(), 1);
2068            assert_eq!(msgs[0].is_error, Some(true));
2069            assert!(matches!(msgs[0].content, MessageContent::Text(_)));
2070            assert!(msgs[0].text().contains("not found"));
2071        }
2072    }
2073
2074    #[cfg(feature = "test-utils")]
2075    mod loop_control {
2076        use super::*;
2077        use crate::test_utils::{ProbeReceiver, probe_receiver};
2078        use crate::{AgentContext, ConnectionSpec, SharedAgent};
2079
2080        const LOOP_DEF: &str = "modular_agent_core::tool::LoopControlAgent";
2081        const PROBE_DEF: &str = "modular_agent_core::test_utils::TestProbeAgent";
2082        const PROBE_PORT: &str = "value";
2083
2084        struct Fixture {
2085            ma: ModularAgent,
2086            loop_agent: SharedAgent,
2087            forwarded: ProbeReceiver,
2088            limit: ProbeReceiver,
2089        }
2090
2091        /// Builds a running preset: LoopControlAgent with its `message` and
2092        /// `limit_exceeded` outputs each wired to a TestProbeAgent.
2093        async fn setup(max_iterations: i64) -> Fixture {
2094            let ma = ModularAgent::init().unwrap();
2095            ma.ready().await.unwrap();
2096            let preset_id = ma.new_preset().unwrap();
2097
2098            let loop_def = ma.get_agent_definition(LOOP_DEF).unwrap();
2099            let loop_id = ma
2100                .add_agent(preset_id.clone(), loop_def.to_spec())
2101                .await
2102                .unwrap();
2103
2104            let probe_def = ma.get_agent_definition(PROBE_DEF).unwrap();
2105            let fwd_id = ma
2106                .add_agent(preset_id.clone(), probe_def.to_spec())
2107                .await
2108                .unwrap();
2109            let lim_id = ma
2110                .add_agent(preset_id.clone(), probe_def.to_spec())
2111                .await
2112                .unwrap();
2113
2114            for (source_handle, target) in [
2115                (PORT_MESSAGE, fwd_id.clone()),
2116                (PORT_LIMIT_EXCEEDED, lim_id.clone()),
2117            ] {
2118                ma.add_connection(
2119                    &preset_id,
2120                    ConnectionSpec {
2121                        source: loop_id.clone(),
2122                        source_handle: source_handle.to_string(),
2123                        target,
2124                        target_handle: PROBE_PORT.to_string(),
2125                    },
2126                )
2127                .await
2128                .unwrap();
2129            }
2130
2131            let loop_agent = ma.get_agent(&loop_id).unwrap();
2132            loop_agent
2133                .lock()
2134                .await
2135                .set_config(
2136                    CONFIG_MAX_ITERATIONS.into(),
2137                    AgentValue::integer(max_iterations),
2138                )
2139                .unwrap();
2140
2141            ma.start_preset(&preset_id).await.unwrap();
2142
2143            let forwarded = probe_receiver(&ma, &fwd_id).await.unwrap();
2144            let limit = probe_receiver(&ma, &lim_id).await.unwrap();
2145
2146            Fixture {
2147                ma,
2148                loop_agent,
2149                forwarded,
2150                limit,
2151            }
2152        }
2153
2154        fn assistant_tool_call_msg(id: Option<&str>, streaming: bool) -> AgentValue {
2155            let mut msg = Message::assistant("use tools".to_string());
2156            msg.id = id.map(str::to_string);
2157            msg.streaming = streaming;
2158            msg.tool_calls = Some(vector![ToolCall {
2159                function: ToolCallFunction {
2160                    name: "my_tool".to_string(),
2161                    parameters: serde_json::json!({}),
2162                    id: Some("call1".to_string()),
2163                    parse_error: None,
2164                },
2165            }]);
2166            AgentValue::message(msg)
2167        }
2168
2169        async fn send(fixture: &Fixture, ctx: &AgentContext, value: AgentValue) {
2170            fixture
2171                .loop_agent
2172                .lock()
2173                .await
2174                .process(ctx.clone(), PORT_MESSAGE.to_string(), value)
2175                .await
2176                .unwrap();
2177        }
2178
2179        async fn recv(rx: &ProbeReceiver) -> AgentValue {
2180            let (_ctx, value) = rx.recv().await.unwrap();
2181            value
2182        }
2183
2184        async fn expect_no_event(rx: &ProbeReceiver) {
2185            assert!(
2186                rx.recv_with_timeout(Duration::from_millis(200))
2187                    .await
2188                    .is_err()
2189            );
2190        }
2191
2192        async fn count_for(fixture: &Fixture, ctx_key: &str) -> Option<(u32, Option<String>)> {
2193            let guard = fixture.loop_agent.lock().await;
2194            let agent = guard.as_agent::<LoopControlAgent>().unwrap();
2195            agent.counts.get(ctx_key).cloned()
2196        }
2197
2198        #[tokio::test]
2199        async fn streaming_partials_are_not_double_counted() {
2200            let fixture = setup(25).await;
2201            let ctx = AgentContext::new();
2202            let ctx_key = ctx.ctx_key().unwrap();
2203
2204            // Streaming partials re-deliver accumulated tool_calls under the
2205            // same id, followed by the streaming=false final message.
2206            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), true)).await;
2207            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), true)).await;
2208            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2209
2210            // All three deliveries pass through transparently.
2211            for _ in 0..3 {
2212                let value = recv(&fixture.forwarded).await;
2213                assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m1"));
2214            }
2215            // Only the final message is counted.
2216            assert_eq!(
2217                count_for(&fixture, &ctx_key).await,
2218                Some((1, Some("m1".to_string())))
2219            );
2220            expect_no_event(&fixture.limit).await;
2221
2222            fixture.ma.quit();
2223        }
2224
2225        #[tokio::test]
2226        async fn same_id_final_redelivered_counts_once() {
2227            let fixture = setup(25).await;
2228            let ctx = AgentContext::new();
2229            let ctx_key = ctx.ctx_key().unwrap();
2230
2231            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2232            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2233
2234            // Both deliveries are forwarded, but counted only once.
2235            for _ in 0..2 {
2236                let value = recv(&fixture.forwarded).await;
2237                assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m1"));
2238            }
2239            assert_eq!(
2240                count_for(&fixture, &ctx_key).await,
2241                Some((1, Some("m1".to_string())))
2242            );
2243            expect_no_event(&fixture.limit).await;
2244
2245            fixture.ma.quit();
2246        }
2247
2248        #[tokio::test]
2249        async fn blocks_and_emits_limit_exceeded_after_max_iterations() {
2250            let fixture = setup(2).await;
2251            let ctx = AgentContext::new();
2252
2253            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2254            send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
2255            for expected in ["m1", "m2"] {
2256                let value = recv(&fixture.forwarded).await;
2257                assert_eq!(value.as_message().unwrap().id.as_deref(), Some(expected));
2258            }
2259
2260            // The third distinct countable message exceeds max_iterations=2:
2261            // it must not be forwarded, and limit_exceeded fires instead.
2262            send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
2263            let notice = recv(&fixture.limit).await;
2264            let notice = notice.as_message().unwrap();
2265            assert_eq!(notice.role, "assistant");
2266            assert!(!notice.streaming);
2267            assert!(notice.tool_calls.is_none());
2268            assert!(notice.text().contains("max_iterations of 2"));
2269            expect_no_event(&fixture.forwarded).await;
2270
2271            // A re-delivered duplicate of the blocked message (same id) is
2272            // neither forwarded nor reported again.
2273            send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
2274            expect_no_event(&fixture.forwarded).await;
2275            expect_no_event(&fixture.limit).await;
2276
2277            fixture.ma.quit();
2278        }
2279
2280        #[tokio::test]
2281        async fn non_countable_values_pass_through_even_over_limit() {
2282            let fixture = setup(1).await;
2283            let ctx = AgentContext::new();
2284
2285            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2286            let _ = recv(&fixture.forwarded).await;
2287            // Trip the limit for this flow.
2288            send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
2289            let _ = recv(&fixture.limit).await;
2290
2291            // Non-countable traffic must keep flowing untouched.
2292            let passthrough = [
2293                AgentValue::message(Message::user("hi".to_string())),
2294                AgentValue::message(Message::tool("my_tool".to_string(), "ok".to_string())),
2295                AgentValue::message(Message::assistant("no tools".to_string())),
2296                assistant_tool_call_msg(Some("m4"), true),
2297                AgentValue::string("not a message"),
2298            ];
2299            for value in passthrough {
2300                send(&fixture, &ctx, value.clone()).await;
2301                let received = recv(&fixture.forwarded).await;
2302                assert_eq!(received, value);
2303            }
2304            expect_no_event(&fixture.limit).await;
2305
2306            fixture.ma.quit();
2307        }
2308
2309        #[tokio::test]
2310        async fn separate_ctx_keys_count_independently() {
2311            let fixture = setup(1).await;
2312            let ctx_a = AgentContext::new();
2313            let ctx_b = AgentContext::new();
2314
2315            send(&fixture, &ctx_a, assistant_tool_call_msg(Some("a1"), false)).await;
2316            let value = recv(&fixture.forwarded).await;
2317            assert_eq!(value.as_message().unwrap().id.as_deref(), Some("a1"));
2318
2319            // ctx_a hits its limit...
2320            send(&fixture, &ctx_a, assistant_tool_call_msg(Some("a2"), false)).await;
2321            let _ = recv(&fixture.limit).await;
2322            expect_no_event(&fixture.forwarded).await;
2323
2324            // ...but ctx_b still has its own budget.
2325            send(&fixture, &ctx_b, assistant_tool_call_msg(Some("b1"), false)).await;
2326            let value = recv(&fixture.forwarded).await;
2327            assert_eq!(value.as_message().unwrap().id.as_deref(), Some("b1"));
2328            expect_no_event(&fixture.limit).await;
2329
2330            fixture.ma.quit();
2331        }
2332
2333        #[tokio::test]
2334        async fn stop_clears_counters() {
2335            let fixture = setup(1).await;
2336            let ctx = AgentContext::new();
2337
2338            send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
2339            let _ = recv(&fixture.forwarded).await;
2340            send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
2341            let _ = recv(&fixture.limit).await;
2342
2343            {
2344                let mut guard = fixture.loop_agent.lock().await;
2345                guard.stop().await.unwrap();
2346                guard.start().await.unwrap();
2347            }
2348
2349            // After a restart the flow gets a fresh budget.
2350            send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
2351            let value = recv(&fixture.forwarded).await;
2352            assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m3"));
2353            expect_no_event(&fixture.limit).await;
2354
2355            fixture.ma.quit();
2356        }
2357    }
2358
2359    mod cancellation {
2360        use super::*;
2361        use std::time::Duration;
2362
2363        /// Tool that never finishes on its own.
2364        struct SlowTool {
2365            info: ToolInfo,
2366        }
2367
2368        #[async_trait]
2369        impl Tool for SlowTool {
2370            fn info(&self) -> &ToolInfo {
2371                &self.info
2372            }
2373
2374            async fn call(
2375                &self,
2376                _ctx: AgentContext,
2377                _args: AgentValue,
2378            ) -> Result<AgentValue, AgentError> {
2379                tokio::time::sleep(Duration::from_secs(30)).await;
2380                Ok(AgentValue::string("done"))
2381            }
2382        }
2383
2384        /// Tool that completes immediately.
2385        struct FastTool {
2386            info: ToolInfo,
2387        }
2388
2389        #[async_trait]
2390        impl Tool for FastTool {
2391            fn info(&self) -> &ToolInfo {
2392                &self.info
2393            }
2394
2395            async fn call(
2396                &self,
2397                _ctx: AgentContext,
2398                _args: AgentValue,
2399            ) -> Result<AgentValue, AgentError> {
2400                Ok(AgentValue::string("fast done"))
2401            }
2402        }
2403
2404        fn slow_call(name: &str, id: &str) -> ToolCall {
2405            ToolCall {
2406                function: ToolCallFunction {
2407                    name: name.to_string(),
2408                    parameters: serde_json::json!({}),
2409                    id: Some(id.to_string()),
2410                    parse_error: None,
2411                },
2412            }
2413        }
2414
2415        #[tokio::test]
2416        async fn cancelled_call_tools_synthesizes_aborted_results() {
2417            // Unique name: the registry is process-global and shared with
2418            // other tests running in parallel.
2419            let tool_name = "cancel_test_slow_tool";
2420            register_tool(SlowTool {
2421                info: ToolInfo::new(tool_name, "sleeps forever for cancellation tests", None),
2422            });
2423
2424            let token = CancellationToken::new();
2425            let ctx = AgentContext::new().with_cancel_token(token.clone());
2426            let calls: Vector<ToolCall> =
2427                vector![slow_call(tool_name, "c1"), slow_call(tool_name, "c2")];
2428
2429            tokio::spawn(async move {
2430                tokio::time::sleep(Duration::from_millis(50)).await;
2431                token.cancel();
2432            });
2433
2434            let msgs = tokio::time::timeout(Duration::from_secs(5), call_tools(&ctx, &calls, 8))
2435                .await
2436                .expect("cancelled call_tools must return promptly")
2437                .unwrap();
2438
2439            // Every issued tool_call receives a result carrying its id.
2440            assert_eq!(msgs.len(), 2);
2441            for (msg, id) in msgs.iter().zip(["c1", "c2"]) {
2442                assert_eq!(msg.role, "tool");
2443                assert_eq!(msg.id.as_deref(), Some(id));
2444                assert_eq!(msg.is_error, Some(true));
2445                assert_eq!(msg.text(), ABORTED_TOOL_RESULT);
2446            }
2447
2448            unregister_tool(tool_name);
2449        }
2450
2451        #[tokio::test]
2452        async fn cancellation_keeps_results_of_completed_parallel_calls() {
2453            // Unique names: the registry is process-global and shared with
2454            // other tests running in parallel.
2455            let slow_name = "cancel_test_slow_parallel_tool";
2456            let fast_name = "cancel_test_fast_parallel_tool";
2457            register_tool(SlowTool {
2458                info: ToolInfo::new(slow_name, "sleeps forever for cancellation tests", None)
2459                    .with_execution_mode(ExecutionMode::Parallel),
2460            });
2461            register_tool(FastTool {
2462                info: ToolInfo::new(
2463                    fast_name,
2464                    "completes immediately for cancellation tests",
2465                    None,
2466                )
2467                .with_execution_mode(ExecutionMode::Parallel),
2468            });
2469
2470            let token = CancellationToken::new();
2471            let ctx = AgentContext::new().with_cancel_token(token.clone());
2472            // The fast call sits behind the still-running slow call in input
2473            // order — its completed result must survive the abort.
2474            let calls: Vector<ToolCall> =
2475                vector![slow_call(slow_name, "c1"), slow_call(fast_name, "c2")];
2476
2477            tokio::spawn(async move {
2478                tokio::time::sleep(Duration::from_millis(50)).await;
2479                token.cancel();
2480            });
2481
2482            let msgs = tokio::time::timeout(Duration::from_secs(5), call_tools(&ctx, &calls, 8))
2483                .await
2484                .expect("cancelled call_tools must return promptly")
2485                .unwrap();
2486
2487            assert_eq!(msgs.len(), 2);
2488            assert_eq!(msgs[0].id.as_deref(), Some("c1"));
2489            assert_eq!(msgs[0].is_error, Some(true));
2490            assert_eq!(msgs[0].text(), ABORTED_TOOL_RESULT);
2491            // The completed call keeps its real result instead of being
2492            // misreported as aborted.
2493            assert_eq!(msgs[1].id.as_deref(), Some("c2"));
2494            assert_eq!(msgs[1].is_error, None);
2495            assert!(msgs[1].text().contains("fast done"));
2496
2497            unregister_tool(slow_name);
2498            unregister_tool(fast_name);
2499        }
2500    }
2501}