#![cfg(feature = "llm")]
use std::{
collections::{BTreeMap, HashMap, HashSet, VecDeque},
future::Future,
sync::{Arc, Mutex, OnceLock, RwLock},
time::Duration,
};
use tokio_util::sync::CancellationToken;
use crate::{
Agent, AgentContext, AgentData, AgentError, AgentOutput, AgentSpec, AgentStatus, AgentValue,
AsAgent, Message, MessageContent, ModularAgent, SharedAgent, ToolCall, async_trait,
modular_agent,
};
#[cfg(feature = "image")]
use crate::{ContentBlock, value::IMAGE_BASE64_PREFIX};
use futures_util::{StreamExt, stream};
use im::{Vector, vector};
use regex::RegexSet;
use tokio::sync::oneshot;
const CATEGORY: &str = "Core/Tool";
const PORT_LIMIT_EXCEEDED: &str = "limit_exceeded";
const PORT_MESSAGE: &str = "message";
const PORT_PATTERNS: &str = "patterns";
const PORT_TOOLS: &str = "tools";
const PORT_TOOL_CALL: &str = "tool_call";
const PORT_TOOL_IN: &str = "tool_in";
const PORT_TOOL_OUT: &str = "tool_out";
const PORT_VALUE: &str = "value";
const CONFIG_TOOLS: &str = "tools";
const CONFIG_TOOL_NAME: &str = "name";
const CONFIG_TOOL_DESCRIPTION: &str = "description";
const CONFIG_TOOL_PARAMETERS: &str = "parameters";
const CONFIG_TIMEOUT_SECS: &str = "timeout_secs";
const CONFIG_MAX_ITERATIONS: &str = "max_iterations";
const CONFIG_MAX_CONCURRENCY: &str = "max_concurrency";
const DEFAULT_TIMEOUT_SECS: i64 = 60;
const DEFAULT_MAX_ITERATIONS: i64 = 25;
const DEFAULT_MAX_CONCURRENCY: i64 = 8;
const ABORTED_TOOL_RESULT: &str = "Operation aborted";
async fn run_unless_cancelled<T>(
cancel: Option<&CancellationToken>,
fut: impl Future<Output = T>,
) -> Option<T> {
match cancel {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => None,
r = fut => Some(r),
}
}
None => Some(fut.await),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExecutionMode {
#[default]
Sequential,
Parallel,
}
#[derive(Clone, Debug)]
pub struct ToolInfo {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
pub execution_mode: ExecutionMode,
}
impl ToolInfo {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: Option<serde_json::Value>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters: parameters
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})),
execution_mode: ExecutionMode::default(),
}
}
pub fn with_execution_mode(mut self, mode: ExecutionMode) -> Self {
self.execution_mode = mode;
self
}
}
#[async_trait]
pub trait Tool {
fn info(&self) -> &ToolInfo;
async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError>;
}
impl From<ToolInfo> for AgentValue {
fn from(info: ToolInfo) -> Self {
let mut obj: BTreeMap<String, AgentValue> = BTreeMap::new();
obj.insert("name".to_string(), AgentValue::from(info.name));
obj.insert(
"description".to_string(),
AgentValue::from(info.description),
);
if let Ok(params_value) = AgentValue::from_serialize(&info.parameters) {
obj.insert("parameters".to_string(), params_value);
}
AgentValue::object(obj.into())
}
}
#[derive(Clone)]
struct ToolEntry {
info: ToolInfo,
tool: Arc<Box<dyn Tool + Send + Sync>>,
}
impl ToolEntry {
fn new<T: Tool + Send + Sync + 'static>(tool: T) -> Self {
Self {
info: tool.info().clone(),
tool: Arc::new(Box::new(tool)),
}
}
}
struct ToolRegistry {
tools: HashMap<String, ToolEntry>,
}
impl ToolRegistry {
fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
fn register_tool<T: Tool + Send + Sync + 'static>(&mut self, tool: T) {
let info = tool.info();
if let Some(warning) = description_warning(&info.name, &info.description) {
log::warn!("{}", warning);
}
let name = info.name.to_string();
let entry = ToolEntry::new(tool);
self.tools.insert(name, entry);
}
fn unregister_tool(&mut self, name: &str) {
self.tools.remove(name);
}
fn get_tool(&self, name: &str) -> Option<Arc<Box<dyn Tool + Send + Sync>>> {
self.tools.get(name).map(|entry| entry.tool.clone())
}
}
static TOOL_REGISTRY: OnceLock<RwLock<ToolRegistry>> = OnceLock::new();
fn registry() -> &'static RwLock<ToolRegistry> {
TOOL_REGISTRY.get_or_init(|| RwLock::new(ToolRegistry::new()))
}
pub fn register_tool<T: Tool + Send + Sync + 'static>(tool: T) {
registry().write().unwrap().register_tool(tool);
}
const MIN_DESCRIPTION_CHARS: usize = 10;
fn description_warning(name: &str, description: &str) -> Option<String> {
let trimmed = description.trim();
if trimmed.is_empty() {
Some(format!(
"Tool '{}' is registered without a description; the description is \
the most important factor for tool-calling performance and should \
explain what the tool does, when to use it, and what its \
parameters mean",
name
))
} else if trimmed.chars().count() < MIN_DESCRIPTION_CHARS {
Some(format!(
"Tool '{}' has a very short description {:?}; detailed descriptions \
(3-4+ sentences) significantly improve tool-calling performance",
name, trimmed
))
} else {
None
}
}
fn is_valid_tool_name(name: &str) -> bool {
let len = name.len();
(1..=64).contains(&len)
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
pub fn unregister_tool(name: &str) {
registry().write().unwrap().unregister_tool(name);
}
pub fn list_tool_infos() -> Vec<ToolInfo> {
registry()
.read()
.unwrap()
.tools
.values()
.map(|entry| entry.info.clone())
.collect()
}
pub fn list_tool_infos_patterns(patterns: &str) -> Result<Vec<ToolInfo>, regex::Error> {
let patterns = patterns
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<&str>>();
let reg_set = RegexSet::new(&patterns)?;
let tool_names = registry()
.read()
.unwrap()
.tools
.values()
.filter_map(|entry| {
if reg_set.is_match(&entry.info.name) {
Some(entry.info.clone())
} else {
None
}
})
.collect();
Ok(tool_names)
}
pub fn get_tool(name: &str) -> Option<Arc<Box<dyn Tool + Send + Sync>>> {
registry().read().unwrap().get_tool(name)
}
pub async fn call_tool(
ctx: AgentContext,
name: &str,
args: AgentValue,
) -> Result<AgentValue, AgentError> {
if ctx.is_cancelled() {
return Err(AgentError::Cancelled);
}
let tool = {
let guard = registry().read().unwrap();
guard.get_tool(name)
};
let Some(tool) = tool else {
return Err(AgentError::Other(format!("Tool '{}' not found", name)));
};
tool.call(ctx, args).await
}
pub fn error_tool_result(call: &ToolCall, e: impl ToString) -> Message {
let mut msg = Message::tool(call.function.name.clone(), e.to_string());
msg.id = call.function.id.clone();
msg.is_error = Some(true);
msg
}
fn schema_accepts(schema: &serde_json::Value, value: &serde_json::Value) -> bool {
jsonschema::validator_for(schema)
.map(|v| v.is_valid(value))
.unwrap_or(false)
}
fn coerce_by_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
let Some(schema_obj) = schema.as_object() else {
return;
};
for key in ["anyOf", "oneOf"] {
let Some(variants) = schema_obj.get(key).and_then(|v| v.as_array()) else {
continue;
};
if !variants.iter().any(|v| schema_accepts(v, value)) {
for variant in variants {
let mut candidate = value.clone();
coerce_by_schema(&mut candidate, variant);
if schema_accepts(variant, &candidate) {
*value = candidate;
break;
}
}
}
break;
}
match schema_obj.get("type").and_then(|t| t.as_str()) {
Some("integer") => {
if let Some(s) = value.as_str()
&& let Ok(i) = s.parse::<i64>()
{
*value = serde_json::Value::from(i);
}
}
Some("number") => {
if let Some(s) = value.as_str()
&& let Ok(f) = s.parse::<f64>()
&& let Some(n) = serde_json::Number::from_f64(f)
{
*value = serde_json::Value::Number(n);
}
}
Some("boolean") => match value.as_str() {
Some("true") => *value = serde_json::Value::Bool(true),
Some("false") => *value = serde_json::Value::Bool(false),
_ => {}
},
_ => {}
}
if let Some(props) = schema_obj.get("properties").and_then(|p| p.as_object())
&& let Some(map) = value.as_object_mut()
{
for (name, prop_schema) in props {
if let Some(v) = map.get_mut(name) {
coerce_by_schema(v, prop_schema);
}
}
}
if let Some(items) = schema_obj.get("items")
&& let Some(arr) = value.as_array_mut()
{
for v in arr {
coerce_by_schema(v, items);
}
}
}
fn validate_tool_args(
schema: &serde_json::Value,
args: &mut serde_json::Value,
) -> Result<(), String> {
let validator = match jsonschema::validator_for(schema) {
Ok(v) => v,
Err(e) => {
log::warn!(
"Tool parameter schema failed to compile; skipping argument validation: {}",
e
);
return Ok(());
}
};
coerce_by_schema(args, schema);
let errors = validator
.iter_errors(args)
.map(|e| format!("at '{}': {}", e.instance_path(), e))
.collect::<Vec<_>>();
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
async fn execute_tool_call(ctx: &AgentContext, call: &ToolCall) -> Message {
if let Some(err) = &call.function.parse_error {
return error_tool_result(
call,
format!(
"Tool call arguments could not be parsed as JSON; the call was \
not executed. Re-issue the call with valid JSON arguments. {}",
err
),
);
}
let mut parameters = call.function.parameters.clone();
if let Some(tool) = get_tool(call.function.name.as_str())
&& let Err(msg) = validate_tool_args(&tool.info().parameters, &mut parameters)
{
return error_tool_result(
call,
format!(
"Tool call arguments failed schema validation; the call was \
not executed. Re-issue the call with corrected arguments. \
Errors: {}",
msg
),
);
}
let args = match AgentValue::from_json(parameters) {
Ok(args) => args,
Err(e) => {
return error_tool_result(call, format!("Failed to parse tool call parameters: {}", e));
}
};
match call_tool(ctx.clone(), call.function.name.as_str(), args).await {
Ok(tool_resp) => {
let mut msg = Message::tool_with_content(
call.function.name.clone(),
tool_result_content(&tool_resp),
);
msg.id = call.function.id.clone();
msg
}
Err(e) => error_tool_result(call, e),
}
}
fn tool_result_content(resp: &AgentValue) -> MessageContent {
#[cfg(feature = "image")]
match resp {
AgentValue::Image(img) => {
return MessageContent::Blocks(vec![image_block(img)]);
}
AgentValue::Array(arr) if arr.iter().any(|v| matches!(v, AgentValue::Image(_))) => {
let blocks = arr
.iter()
.map(|v| match v {
AgentValue::Image(img) => image_block(img),
other => ContentBlock::Text {
text: other.to_json().to_string(),
},
})
.collect();
return MessageContent::Blocks(blocks);
}
_ => {}
}
MessageContent::Text(resp.to_json().to_string())
}
#[cfg(feature = "image")]
fn image_block(img: &photon_rs::PhotonImage) -> ContentBlock {
ContentBlock::Image {
data: img
.get_base64()
.trim_start_matches(IMAGE_BASE64_PREFIX)
.to_string(),
mime_type: "image/png".to_string(),
}
}
async fn flush_parallel_batch(
ctx: &AgentContext,
batch: &mut Vec<&ToolCall>,
max_concurrency: usize,
out: &mut Vec<Message>,
cancel: Option<&CancellationToken>,
) -> bool {
if batch.is_empty() {
return true;
}
let mut futures = Vec::with_capacity(batch.len());
for (index, &call) in batch.iter().enumerate() {
futures.push(async move { (index, execute_tool_call(ctx, call).await) });
}
let mut results = stream::iter(futures).buffer_unordered(max_concurrency);
let mut slots: Vec<Option<Message>> = Vec::new();
slots.resize_with(batch.len(), || None);
let mut aborted = false;
loop {
match run_unless_cancelled(cancel, results.next()).await {
Some(Some((index, msg))) => slots[index] = Some(msg),
Some(None) => break,
None => {
aborted = true;
break;
}
}
}
if aborted {
use futures_util::FutureExt;
while let Some(Some((index, msg))) = results.next().now_or_never() {
slots[index] = Some(msg);
}
}
drop(results);
for (slot, call) in slots.iter_mut().zip(batch.drain(..)) {
out.push(match slot.take() {
Some(msg) => msg,
None => error_tool_result(call, ABORTED_TOOL_RESULT),
});
}
!aborted
}
pub async fn call_tools(
ctx: &AgentContext,
tool_calls: &Vector<ToolCall>,
max_concurrency: usize,
) -> Result<Vector<Message>, AgentError> {
if tool_calls.is_empty() {
return Ok(vector![]);
};
let max_concurrency = max_concurrency.max(1);
let cancel = ctx.cancel_token();
let mut aborted = false;
let mut resp_messages = Vec::with_capacity(tool_calls.len());
let mut parallel_batch: Vec<&ToolCall> = Vec::new();
for call in tool_calls {
let mode = get_tool(call.function.name.as_str())
.map(|tool| tool.info().execution_mode)
.unwrap_or_default();
if mode == ExecutionMode::Parallel {
parallel_batch.push(call);
continue;
}
if !flush_parallel_batch(
ctx,
&mut parallel_batch,
max_concurrency,
&mut resp_messages,
cancel,
)
.await
{
aborted = true;
break;
}
match run_unless_cancelled(cancel, execute_tool_call(ctx, call)).await {
Some(msg) => resp_messages.push(msg),
None => {
aborted = true;
break;
}
}
}
if !aborted
&& !flush_parallel_batch(
ctx,
&mut parallel_batch,
max_concurrency,
&mut resp_messages,
cancel,
)
.await
{
aborted = true;
}
if aborted {
for call in tool_calls.iter().skip(resp_messages.len()) {
resp_messages.push(error_tool_result(call, ABORTED_TOOL_RESULT));
}
}
Ok(resp_messages.into())
}
#[modular_agent(
title="List Tools",
category=CATEGORY,
inputs=[PORT_PATTERNS],
outputs=[PORT_TOOLS],
)]
pub struct ListToolsAgent {
data: AgentData,
}
#[async_trait]
impl AsAgent for ListToolsAgent {
fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
Ok(Self {
data: AgentData::new(ma, id, spec),
})
}
async fn process(
&mut self,
ctx: AgentContext,
_port: String,
value: AgentValue,
) -> Result<(), AgentError> {
let Some(patterns) = value.as_str() else {
return Err(AgentError::InvalidValue(
"patterns input must be a string".to_string(),
));
};
let tools = if !patterns.is_empty() {
list_tool_infos_patterns(patterns)
.map_err(|e| AgentError::InvalidValue(format!("Invalid regex patterns: {}", e)))?
} else {
list_tool_infos()
};
let tools = tools
.into_iter()
.map(|tool| tool.into())
.collect::<Vector<AgentValue>>();
let tools_array = AgentValue::array(tools);
self.output(ctx, PORT_TOOLS, tools_array).await?;
Ok(())
}
}
#[modular_agent(
title="Preset Tool",
category=CATEGORY,
inputs=[PORT_TOOL_OUT],
outputs=[PORT_TOOL_IN],
string_config(name=CONFIG_TOOL_NAME),
text_config(name=CONFIG_TOOL_DESCRIPTION),
object_config(name=CONFIG_TOOL_PARAMETERS),
integer_config(name=CONFIG_TIMEOUT_SECS, default=60),
)]
pub struct PresetToolAgent {
data: AgentData,
name: String,
description: String,
parameters: Option<serde_json::Value>,
pending: Arc<Mutex<HashMap<usize, oneshot::Sender<AgentValue>>>>,
}
impl PresetToolAgent {
fn start_tool_call(
&mut self,
ctx: AgentContext,
args: AgentValue,
) -> Result<oneshot::Receiver<AgentValue>, AgentError> {
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(ctx.id(), tx);
if let Err(e) = self.try_output(ctx.clone(), PORT_TOOL_IN, args) {
self.pending.lock().unwrap().remove(&ctx.id());
return Err(e);
}
Ok(rx)
}
}
#[async_trait]
impl AsAgent for PresetToolAgent {
fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
let def_name = spec.def_name.clone();
let configs = spec.configs.clone();
let name = configs
.as_ref()
.and_then(|c| c.get_string(CONFIG_TOOL_NAME).ok())
.unwrap_or_else(|| def_name.clone());
let description = configs
.as_ref()
.and_then(|c| c.get_string(CONFIG_TOOL_DESCRIPTION).ok())
.unwrap_or_default();
let parameters = configs
.as_ref()
.and_then(|c| c.get(CONFIG_TOOL_PARAMETERS).ok())
.and_then(|v| serde_json::to_value(v).ok());
Ok(Self {
data: AgentData::new(ma, id, spec),
name,
description,
parameters,
pending: Arc::new(Mutex::new(HashMap::new())),
})
}
fn configs_changed(&mut self) -> Result<(), AgentError> {
let old_name = self.name.clone();
self.name = self.configs()?.get_string_or_default(CONFIG_TOOL_NAME);
self.description = self
.configs()?
.get_string_or_default(CONFIG_TOOL_DESCRIPTION);
self.parameters = self
.configs()?
.get(CONFIG_TOOL_PARAMETERS)
.ok()
.and_then(|v| serde_json::to_value(v).ok());
if self.data.status == AgentStatus::Start {
if !is_valid_tool_name(&self.name) {
log::warn!(
"PresetToolAgent {} has invalid tool name {:?}; \
tool names must match ^[a-zA-Z0-9_-]{{1,64}}$",
self.id(),
self.name
);
}
let agent_handle = self
.ma()
.get_agent(self.id())
.ok_or_else(|| AgentError::AgentNotFound(self.id().to_string()))?;
let tool = PresetTool::new(
self.name.clone(),
self.description.clone(),
self.parameters.clone(),
agent_handle,
);
register_tool(tool);
if old_name != self.name {
unregister_tool(&old_name);
}
}
Ok(())
}
async fn start(&mut self) -> Result<(), AgentError> {
if !is_valid_tool_name(&self.name) {
log::warn!(
"PresetToolAgent {} has invalid tool name {:?}; \
tool names must match ^[a-zA-Z0-9_-]{{1,64}}$",
self.id(),
self.name
);
}
let agent_handle = self
.ma()
.get_agent(self.id())
.ok_or_else(|| AgentError::AgentNotFound(self.id().to_string()))?;
let tool = PresetTool::new(
self.name.clone(),
self.description.clone(),
self.parameters.clone(),
agent_handle,
);
register_tool(tool);
Ok(())
}
async fn stop(&mut self) -> Result<(), AgentError> {
unregister_tool(&self.name);
self.pending.lock().unwrap().clear();
Ok(())
}
async fn process(
&mut self,
ctx: AgentContext,
_port: String,
value: AgentValue,
) -> Result<(), AgentError> {
if let Some(tx) = self.pending.lock().unwrap().remove(&ctx.id()) {
let _ = tx.send(value);
}
Ok(())
}
}
struct PresetTool {
info: ToolInfo,
agent: SharedAgent,
}
impl PresetTool {
fn new(
name: String,
description: String,
parameters: Option<serde_json::Value>,
agent: SharedAgent,
) -> Self {
Self {
info: ToolInfo::new(name, description, parameters),
agent,
}
}
async fn tool_call(
&self,
ctx: AgentContext,
args: AgentValue,
) -> Result<AgentValue, AgentError> {
if ctx.is_cancelled() {
return Err(AgentError::Cancelled);
}
let ctx_id = ctx.id();
let cancel = ctx.cancel_token().cloned();
let (rx, timeout_secs, pending) = {
let mut guard = self.agent.lock().await;
let Some(preset_tool_agent) = guard.as_agent_mut::<PresetToolAgent>() else {
return Err(AgentError::Other(
"Agent is not PresetToolAgent".to_string(),
));
};
if ctx.is_cancelled() {
return Err(AgentError::Cancelled);
}
let timeout_secs = preset_tool_agent
.configs()
.map(|c| c.get_integer_or(CONFIG_TIMEOUT_SECS, DEFAULT_TIMEOUT_SECS))
.unwrap_or(DEFAULT_TIMEOUT_SECS);
let pending = preset_tool_agent.pending.clone();
let rx = preset_tool_agent.start_tool_call(ctx, args)?;
(rx, timeout_secs, pending)
};
struct PendingGuard {
pending: Arc<Mutex<HashMap<usize, oneshot::Sender<AgentValue>>>>,
ctx_id: usize,
}
impl Drop for PendingGuard {
fn drop(&mut self) {
self.pending.lock().unwrap().remove(&self.ctx_id);
}
}
let _guard = PendingGuard { pending, ctx_id };
let wait = async {
let rx = async {
rx.await
.map_err(|_| AgentError::Other("tool_out dropped".to_string()))
};
if timeout_secs <= 0 {
rx.await
} else {
match tokio::time::timeout(Duration::from_secs(timeout_secs as u64), rx).await {
Ok(result) => result,
Err(_) => Err(AgentError::Timeout(format!(
"Tool call timed out after {} seconds",
timeout_secs
))),
}
}
};
run_unless_cancelled(cancel.as_ref(), wait)
.await
.unwrap_or(Err(AgentError::Cancelled))
}
}
#[async_trait]
impl Tool for PresetTool {
fn info(&self) -> &ToolInfo {
&self.info
}
async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError> {
self.tool_call(ctx, args).await
}
}
#[modular_agent(
title="Call Tool Message",
category=CATEGORY,
inputs=[PORT_MESSAGE],
outputs=[PORT_MESSAGE],
string_config(name=CONFIG_TOOLS),
integer_config(name=CONFIG_MAX_CONCURRENCY, default=8),
)]
pub struct CallToolMessageAgent {
data: AgentData,
executed: BTreeMap<String, HashSet<String>>,
ctx_key_order: VecDeque<String>,
}
const MAX_TRACKED_CTX_KEYS: usize = 1024;
impl CallToolMessageAgent {
fn is_executed(&self, ctx_key: &str, id: &str) -> bool {
self.executed
.get(ctx_key)
.is_some_and(|ids| ids.contains(id))
}
fn mark_executed(&mut self, ctx_key: &str, id: String) {
if !self.executed.contains_key(ctx_key) {
if self.ctx_key_order.len() >= MAX_TRACKED_CTX_KEYS
&& let Some(oldest) = self.ctx_key_order.pop_front()
{
self.executed.remove(&oldest);
}
self.ctx_key_order.push_back(ctx_key.to_string());
}
self.executed
.entry(ctx_key.to_string())
.or_default()
.insert(id);
}
}
#[async_trait]
impl AsAgent for CallToolMessageAgent {
fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
Ok(Self {
data: AgentData::new(ma, id, spec),
executed: BTreeMap::new(),
ctx_key_order: VecDeque::new(),
})
}
async fn stop(&mut self) -> Result<(), AgentError> {
self.executed.clear();
self.ctx_key_order.clear();
Ok(())
}
async fn process(
&mut self,
ctx: AgentContext,
_port: String,
value: AgentValue,
) -> Result<(), AgentError> {
let Some(message) = value.as_message() else {
return Ok(());
};
if message.streaming {
return Ok(());
}
let Some(mut tool_calls) = message.tool_calls.clone() else {
return Ok(());
};
let config_tools = self.configs()?.get_string_or_default(CONFIG_TOOLS);
if !config_tools.is_empty() {
let tools = list_tool_infos_patterns(&config_tools)
.map_err(|e| AgentError::InvalidValue(format!("Invalid regex patterns: {}", e)))?;
let allowed_tool_names: HashSet<String> = tools.into_iter().map(|t| t.name).collect();
tool_calls = tool_calls
.iter()
.filter(|call| allowed_tool_names.contains(&call.function.name))
.cloned()
.collect();
}
let ctx_key = ctx.ctx_key()?;
tool_calls = tool_calls
.iter()
.filter(|call| match &call.function.id {
Some(id) => !self.is_executed(&ctx_key, id),
None => true,
})
.cloned()
.collect();
for call in &tool_calls {
if let Some(id) = &call.function.id {
self.mark_executed(&ctx_key, id.clone());
}
}
if message.stop_reason.as_deref() == Some("length") {
for call in &tool_calls {
let resp_msg = error_tool_result(
call,
format!(
"Tool call \"{}\" was not executed: output hit the token \
limit; arguments may be truncated. Re-issue with complete \
arguments.",
call.function.name
),
);
self.output(ctx.clone(), PORT_MESSAGE, AgentValue::message(resp_msg))
.await?;
}
return Ok(());
}
let max_concurrency = self
.configs()?
.get_integer_or(CONFIG_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY)
.max(1) as usize;
let resp_messages = call_tools(&ctx, &tool_calls, max_concurrency).await?;
for resp_msg in resp_messages {
self.output(ctx.clone(), PORT_MESSAGE, AgentValue::message(resp_msg))
.await?;
}
Ok(())
}
}
#[modular_agent(
title="Loop Control",
category=CATEGORY,
inputs=[PORT_MESSAGE],
outputs=[PORT_MESSAGE, PORT_LIMIT_EXCEEDED],
integer_config(name=CONFIG_MAX_ITERATIONS, default=25),
)]
pub struct LoopControlAgent {
data: AgentData,
counts: BTreeMap<String, (u32, Option<String>)>,
ctx_key_order: VecDeque<String>,
}
impl LoopControlAgent {
fn record_count(&mut self, ctx_key: &str, count: u32, id: Option<String>) {
if !self.counts.contains_key(ctx_key) {
if self.ctx_key_order.len() >= MAX_TRACKED_CTX_KEYS
&& let Some(oldest) = self.ctx_key_order.pop_front()
{
self.counts.remove(&oldest);
}
self.ctx_key_order.push_back(ctx_key.to_string());
}
self.counts.insert(ctx_key.to_string(), (count, id));
}
}
#[async_trait]
impl AsAgent for LoopControlAgent {
fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
Ok(Self {
data: AgentData::new(ma, id, spec),
counts: BTreeMap::new(),
ctx_key_order: VecDeque::new(),
})
}
async fn stop(&mut self) -> Result<(), AgentError> {
self.counts.clear();
self.ctx_key_order.clear();
Ok(())
}
async fn process(
&mut self,
ctx: AgentContext,
_port: String,
value: AgentValue,
) -> Result<(), AgentError> {
let countable = value.as_message().is_some_and(|m| {
m.role == "assistant"
&& !m.streaming
&& m.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
});
if !countable {
return self.output(ctx, PORT_MESSAGE, value).await;
}
let message_id = value.as_message().and_then(|m| m.id.clone());
let max_iterations = self
.configs()?
.get_integer_or(CONFIG_MAX_ITERATIONS, DEFAULT_MAX_ITERATIONS);
let ctx_key = ctx.ctx_key()?;
let (count, last_counted_id) = self.counts.get(&ctx_key).cloned().unwrap_or((0, None));
if let Some(id) = &message_id
&& last_counted_id.as_deref() == Some(id)
{
if max_iterations > 0 && i64::from(count) > max_iterations {
return Ok(());
}
return self.output(ctx, PORT_MESSAGE, value).await;
}
let count = count.saturating_add(1);
self.record_count(&ctx_key, count, message_id);
if max_iterations > 0 && i64::from(count) > max_iterations {
let notice = Message::assistant(format!(
"Loop limit reached: the tool-call cycle exceeded the configured max_iterations of {} and has been stopped.",
max_iterations
));
return self
.output(ctx, PORT_LIMIT_EXCEEDED, AgentValue::message(notice))
.await;
}
self.output(ctx, PORT_MESSAGE, value).await
}
}
#[modular_agent(
title="Call Tool",
category=CATEGORY,
inputs=[PORT_TOOL_CALL],
outputs=[PORT_VALUE],
)]
pub struct CallToolAgent {
data: AgentData,
}
#[async_trait]
impl AsAgent for CallToolAgent {
fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
Ok(Self {
data: AgentData::new(ma, id, spec),
})
}
async fn process(
&mut self,
ctx: AgentContext,
_port: String,
value: AgentValue,
) -> Result<(), AgentError> {
let obj = value.as_object().ok_or_else(|| {
AgentError::InvalidValue("tool_call input must be an object".to_string())
})?;
let tool_name = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
AgentError::InvalidValue("tool_call.name must be a string".to_string())
})?;
let tool_parameters = obj.get("parameters").cloned().unwrap_or(AgentValue::unit());
let resp = call_tool(ctx.clone(), tool_name, tool_parameters).await?;
self.output(ctx, PORT_VALUE, resp).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ToolCallFunction;
#[test]
fn test_tool_info_new_parameters_default() {
let info = ToolInfo::new("t", "d", None);
assert_eq!(
info.parameters,
serde_json::json!({"type": "object", "properties": {}})
);
let schema = serde_json::json!({
"type": "object",
"properties": {"x": {"type": "string"}},
"required": ["x"]
});
let info = ToolInfo::new("t", "d", Some(schema.clone()));
assert_eq!(info.parameters, schema);
}
#[test]
fn test_error_tool_result_shape() {
let call = ToolCall {
function: ToolCallFunction {
name: "my_tool".to_string(),
parameters: serde_json::json!({}),
id: Some("call42".to_string()),
parse_error: None,
},
};
let msg = error_tool_result(&call, "something went wrong");
assert_eq!(msg.role, "tool");
assert_eq!(msg.tool_name.as_deref(), Some("my_tool"));
assert_eq!(msg.id.as_deref(), Some("call42"));
assert_eq!(msg.is_error, Some(true));
assert_eq!(msg.text(), "something went wrong");
}
#[test]
fn test_is_valid_tool_name_accepts_valid() {
assert!(is_valid_tool_name("a"));
assert!(is_valid_tool_name("my_tool"));
assert!(is_valid_tool_name("my-tool"));
assert!(is_valid_tool_name("Tool_123-ABC"));
assert!(is_valid_tool_name(&"x".repeat(64)));
}
#[test]
fn test_preset_tool_timeout_config_default() {
let def = PresetToolAgent::agent_definition();
let specs = def
.configs
.as_ref()
.expect("PresetToolAgent should have config specs");
let spec = specs
.get(CONFIG_TIMEOUT_SECS)
.expect("timeout_secs config should be present");
assert_eq!(spec.value, AgentValue::integer(DEFAULT_TIMEOUT_SECS));
}
#[test]
fn test_description_warning_flags_weak_descriptions() {
let warning = description_warning("my_tool", "").expect("empty should warn");
assert!(warning.contains("my_tool"));
let warning = description_warning("my_tool", " \t\n ").expect("whitespace should warn");
assert!(warning.contains("my_tool"));
let warning = description_warning("my_tool", "too short").expect("short should warn");
assert!(warning.contains("my_tool"));
assert!(warning.contains("too short"));
}
#[test]
fn test_description_warning_accepts_adequate_descriptions() {
assert!(description_warning("t", "0123456789").is_none());
assert!(
description_warning("t", "Fetches the current weather for a given city.").is_none()
);
assert!(description_warning("t", "ツールの詳細な説明です。").is_none());
}
#[test]
fn test_is_valid_tool_name_rejects_invalid() {
assert!(!is_valid_tool_name(""));
assert!(!is_valid_tool_name(&"x".repeat(65)));
assert!(!is_valid_tool_name("my tool"));
assert!(!is_valid_tool_name("my.tool"));
assert!(!is_valid_tool_name("ツール"));
assert!(!is_valid_tool_name("tool@1"));
}
mod arg_validation {
use super::*;
use serde_json::json;
struct EchoTool {
info: ToolInfo,
}
#[async_trait]
impl Tool for EchoTool {
fn info(&self) -> &ToolInfo {
&self.info
}
async fn call(
&self,
_ctx: AgentContext,
args: AgentValue,
) -> Result<AgentValue, AgentError> {
Ok(args)
}
}
fn register_echo(name: &str, schema: Option<serde_json::Value>) {
register_tool(EchoTool {
info: ToolInfo::new(name, "echoes args", schema),
});
}
fn call(name: &str, id: &str, params: serde_json::Value) -> ToolCall {
ToolCall {
function: ToolCallFunction {
name: name.to_string(),
parameters: params,
id: Some(id.to_string()),
parse_error: None,
},
}
}
fn content_json(msg: &Message) -> serde_json::Value {
serde_json::from_str(&msg.text()).unwrap()
}
#[test]
fn coerce_does_not_truncate_float_string_to_integer() {
let schema = json!({"type": "integer"});
let mut v = json!("42.5");
coerce_by_schema(&mut v, &schema);
assert_eq!(v, json!("42.5"));
}
#[test]
fn coerce_number_accepts_float_string_and_keeps_integer() {
let schema = json!({"type": "number"});
let mut v = json!("42.5");
coerce_by_schema(&mut v, &schema);
assert_eq!(v, json!(42.5));
let mut v = json!(3);
coerce_by_schema(&mut v, &schema);
assert_eq!(v, json!(3));
}
#[test]
fn coerce_array_items() {
let schema = json!({"type": "array", "items": {"type": "integer"}});
let mut v = json!(["1", 2, "3"]);
coerce_by_schema(&mut v, &schema);
assert_eq!(v, json!([1, 2, 3]));
}
#[tokio::test]
async fn string_primitives_coerced_before_tool_call() {
let name = "p14_coerce_prims";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {
"count": {"type": "integer"},
"flag": {"type": "boolean"}
},
"required": ["count", "flag"]
})),
);
let calls = vector![call(name, "c1", json!({"count": "42", "flag": "true"}))];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, None);
assert_eq!(msgs[0].id.as_deref(), Some("c1"));
assert_eq!(content_json(&msgs[0]), json!({"count": 42, "flag": true}));
}
#[tokio::test]
async fn nested_object_property_coerced() {
let name = "p14_coerce_nested";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {
"outer": {
"type": "object",
"properties": {"n": {"type": "integer"}}
}
}
})),
);
let calls = vector![call(name, "c1", json!({"outer": {"n": "7"}}))];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, None);
assert_eq!(content_json(&msgs[0]), json!({"outer": {"n": 7}}));
}
#[tokio::test]
async fn validation_failure_reports_error_and_batch_continues() {
let name = "p14_validation_failure";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {"count": {"type": "integer"}},
"required": ["count"]
})),
);
let calls = vector![
call(name, "bad", json!({"count": "abc"})),
call(name, "good", json!({"count": "5"})),
];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].is_error, Some(true));
assert_eq!(msgs[0].id.as_deref(), Some("bad"));
assert!(msgs[0].text().contains("not executed"));
assert!(msgs[0].text().contains("/count"));
assert_eq!(msgs[1].is_error, None);
assert_eq!(msgs[1].id.as_deref(), Some("good"));
assert_eq!(content_json(&msgs[1]), json!({"count": 5}));
}
#[tokio::test]
async fn validation_failure_collects_all_errors() {
let name = "p14_all_errors";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {
"count": {"type": "integer"},
"flag": {"type": "boolean"}
},
"required": ["count", "flag"]
})),
);
let calls = vector![call(name, "bad", json!({"count": "abc", "flag": "xyz"}))];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, Some(true));
assert!(msgs[0].text().contains("/count"));
assert!(msgs[0].text().contains("/flag"));
}
#[tokio::test]
async fn default_empty_object_schema_accepts_arbitrary_args() {
let name = "p14_default_schema";
register_echo(name, None);
let args = json!({"anything": [1, 2, 3], "nested": {"x": "y"}});
let calls = vector![call(name, "c1", args.clone())];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, None);
assert_eq!(content_json(&msgs[0]), args);
}
#[tokio::test]
async fn uncompilable_schema_skips_validation_and_executes() {
let name = "p14_broken_schema";
register_echo(name, Some(json!({"type": 123})));
let args = json!({"x": "1"});
let calls = vector![call(name, "c1", args.clone())];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, None);
assert_eq!(content_json(&msgs[0]), args);
}
#[tokio::test]
async fn any_of_coercion_picks_validating_variant() {
let name = "p14_any_of";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {
"v": {"anyOf": [{"type": "integer"}, {"type": "boolean"}]}
}
})),
);
let calls = vector![
call(name, "c1", json!({"v": "true"})),
call(name, "c2", json!({"v": "7"})),
call(name, "c3", json!({"v": 5})),
];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 3);
assert_eq!(content_json(&msgs[0]), json!({"v": true}));
assert_eq!(content_json(&msgs[1]), json!({"v": 7}));
assert_eq!(content_json(&msgs[2]), json!({"v": 5}));
}
#[tokio::test]
async fn any_of_with_sibling_properties_still_coerces() {
let name = "p14_any_of_siblings";
register_echo(
name,
Some(json!({
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"anyOf": [{"required": ["a"]}, {"required": ["b"]}]
})),
);
let calls = vector![call(name, "c1", json!({"a": "5"}))];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, None);
assert_eq!(content_json(&msgs[0]), json!({"a": 5}));
}
}
mod result_content {
use super::*;
struct FixedTool {
info: ToolInfo,
value: AgentValue,
}
#[async_trait]
impl Tool for FixedTool {
fn info(&self) -> &ToolInfo {
&self.info
}
async fn call(
&self,
_ctx: AgentContext,
_args: AgentValue,
) -> Result<AgentValue, AgentError> {
Ok(self.value.clone())
}
}
fn register_fixed(name: &str, value: AgentValue) {
register_tool(FixedTool {
info: ToolInfo::new(name, "returns a fixed value for tests", None),
value,
});
}
fn call(name: &str, id: &str) -> ToolCall {
ToolCall {
function: ToolCallFunction {
name: name.to_string(),
parameters: serde_json::json!({}),
id: Some(id.to_string()),
parse_error: None,
},
}
}
async fn run_single(name: &str) -> Message {
let calls = vector![call(name, "c1")];
let mut msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
unregister_tool(name);
assert_eq!(msgs.len(), 1);
msgs.remove(0)
}
#[cfg(feature = "image")]
#[tokio::test]
async fn image_result_becomes_single_image_block() {
let name = "result_content_image";
register_fixed(name, AgentValue::image_default());
let msg = run_single(name).await;
assert_eq!(msg.role, "tool");
assert_eq!(msg.tool_name.as_deref(), Some(name));
assert_eq!(msg.id.as_deref(), Some("c1"));
assert_eq!(msg.is_error, None);
let MessageContent::Blocks(blocks) = &msg.content else {
panic!("expected block content, got {:?}", msg.content);
};
assert_eq!(blocks.len(), 1);
let ContentBlock::Image { data, mime_type } = &blocks[0] else {
panic!("expected image block, got {:?}", blocks[0]);
};
assert_eq!(mime_type, "image/png");
assert!(!data.starts_with("data:"));
assert!(!data.is_empty());
}
#[cfg(feature = "image")]
#[tokio::test]
async fn mixed_array_result_becomes_ordered_blocks() {
let name = "result_content_mixed_array";
register_fixed(
name,
AgentValue::array(vector![
AgentValue::image_default(),
AgentValue::string("caption"),
]),
);
let msg = run_single(name).await;
let MessageContent::Blocks(blocks) = &msg.content else {
panic!("expected block content, got {:?}", msg.content);
};
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], ContentBlock::Image { .. }));
let ContentBlock::Text { text } = &blocks[1] else {
panic!("expected text block, got {:?}", blocks[1]);
};
assert_eq!(text, "\"caption\"");
}
#[tokio::test]
async fn non_image_results_keep_legacy_text_form() {
let object = AgentValue::from_json(serde_json::json!({"a": 1, "b": "x"})).unwrap();
let imageless_array = AgentValue::from_json(serde_json::json!([1, "two", null])).unwrap();
let cases = [
("result_content_object", object),
("result_content_string", AgentValue::string("plain")),
("result_content_array", imageless_array),
];
for (name, value) in cases {
let expected = value.to_json().to_string();
register_fixed(name, value);
let msg = run_single(name).await;
assert_eq!(msg.content, MessageContent::Text(expected));
}
}
#[tokio::test]
async fn error_result_stays_text() {
let calls = vector![call("result_content_no_such_tool", "c1")];
let msgs = call_tools(&AgentContext::new(), &calls, 8).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].is_error, Some(true));
assert!(matches!(msgs[0].content, MessageContent::Text(_)));
assert!(msgs[0].text().contains("not found"));
}
}
#[cfg(feature = "test-utils")]
mod loop_control {
use super::*;
use crate::test_utils::{ProbeReceiver, probe_receiver};
use crate::{AgentContext, ConnectionSpec, SharedAgent};
const LOOP_DEF: &str = "modular_agent_core::tool::LoopControlAgent";
const PROBE_DEF: &str = "modular_agent_core::test_utils::TestProbeAgent";
const PROBE_PORT: &str = "value";
struct Fixture {
ma: ModularAgent,
loop_agent: SharedAgent,
forwarded: ProbeReceiver,
limit: ProbeReceiver,
}
async fn setup(max_iterations: i64) -> Fixture {
let ma = ModularAgent::init().unwrap();
ma.ready().await.unwrap();
let preset_id = ma.new_preset().unwrap();
let loop_def = ma.get_agent_definition(LOOP_DEF).unwrap();
let loop_id = ma
.add_agent(preset_id.clone(), loop_def.to_spec())
.await
.unwrap();
let probe_def = ma.get_agent_definition(PROBE_DEF).unwrap();
let fwd_id = ma
.add_agent(preset_id.clone(), probe_def.to_spec())
.await
.unwrap();
let lim_id = ma
.add_agent(preset_id.clone(), probe_def.to_spec())
.await
.unwrap();
for (source_handle, target) in [
(PORT_MESSAGE, fwd_id.clone()),
(PORT_LIMIT_EXCEEDED, lim_id.clone()),
] {
ma.add_connection(
&preset_id,
ConnectionSpec {
source: loop_id.clone(),
source_handle: source_handle.to_string(),
target,
target_handle: PROBE_PORT.to_string(),
},
)
.await
.unwrap();
}
let loop_agent = ma.get_agent(&loop_id).unwrap();
loop_agent
.lock()
.await
.set_config(
CONFIG_MAX_ITERATIONS.into(),
AgentValue::integer(max_iterations),
)
.unwrap();
ma.start_preset(&preset_id).await.unwrap();
let forwarded = probe_receiver(&ma, &fwd_id).await.unwrap();
let limit = probe_receiver(&ma, &lim_id).await.unwrap();
Fixture {
ma,
loop_agent,
forwarded,
limit,
}
}
fn assistant_tool_call_msg(id: Option<&str>, streaming: bool) -> AgentValue {
let mut msg = Message::assistant("use tools".to_string());
msg.id = id.map(str::to_string);
msg.streaming = streaming;
msg.tool_calls = Some(vector![ToolCall {
function: ToolCallFunction {
name: "my_tool".to_string(),
parameters: serde_json::json!({}),
id: Some("call1".to_string()),
parse_error: None,
},
}]);
AgentValue::message(msg)
}
async fn send(fixture: &Fixture, ctx: &AgentContext, value: AgentValue) {
fixture
.loop_agent
.lock()
.await
.process(ctx.clone(), PORT_MESSAGE.to_string(), value)
.await
.unwrap();
}
async fn recv(rx: &ProbeReceiver) -> AgentValue {
let (_ctx, value) = rx.recv().await.unwrap();
value
}
async fn expect_no_event(rx: &ProbeReceiver) {
assert!(
rx.recv_with_timeout(Duration::from_millis(200))
.await
.is_err()
);
}
async fn count_for(fixture: &Fixture, ctx_key: &str) -> Option<(u32, Option<String>)> {
let guard = fixture.loop_agent.lock().await;
let agent = guard.as_agent::<LoopControlAgent>().unwrap();
agent.counts.get(ctx_key).cloned()
}
#[tokio::test]
async fn streaming_partials_are_not_double_counted() {
let fixture = setup(25).await;
let ctx = AgentContext::new();
let ctx_key = ctx.ctx_key().unwrap();
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), true)).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), true)).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
for _ in 0..3 {
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m1"));
}
assert_eq!(
count_for(&fixture, &ctx_key).await,
Some((1, Some("m1".to_string())))
);
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
#[tokio::test]
async fn same_id_final_redelivered_counts_once() {
let fixture = setup(25).await;
let ctx = AgentContext::new();
let ctx_key = ctx.ctx_key().unwrap();
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
for _ in 0..2 {
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m1"));
}
assert_eq!(
count_for(&fixture, &ctx_key).await,
Some((1, Some("m1".to_string())))
);
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
#[tokio::test]
async fn blocks_and_emits_limit_exceeded_after_max_iterations() {
let fixture = setup(2).await;
let ctx = AgentContext::new();
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
for expected in ["m1", "m2"] {
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some(expected));
}
send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
let notice = recv(&fixture.limit).await;
let notice = notice.as_message().unwrap();
assert_eq!(notice.role, "assistant");
assert!(!notice.streaming);
assert!(notice.tool_calls.is_none());
assert!(notice.text().contains("max_iterations of 2"));
expect_no_event(&fixture.forwarded).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
expect_no_event(&fixture.forwarded).await;
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
#[tokio::test]
async fn non_countable_values_pass_through_even_over_limit() {
let fixture = setup(1).await;
let ctx = AgentContext::new();
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
let _ = recv(&fixture.forwarded).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
let _ = recv(&fixture.limit).await;
let passthrough = [
AgentValue::message(Message::user("hi".to_string())),
AgentValue::message(Message::tool("my_tool".to_string(), "ok".to_string())),
AgentValue::message(Message::assistant("no tools".to_string())),
assistant_tool_call_msg(Some("m4"), true),
AgentValue::string("not a message"),
];
for value in passthrough {
send(&fixture, &ctx, value.clone()).await;
let received = recv(&fixture.forwarded).await;
assert_eq!(received, value);
}
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
#[tokio::test]
async fn separate_ctx_keys_count_independently() {
let fixture = setup(1).await;
let ctx_a = AgentContext::new();
let ctx_b = AgentContext::new();
send(&fixture, &ctx_a, assistant_tool_call_msg(Some("a1"), false)).await;
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some("a1"));
send(&fixture, &ctx_a, assistant_tool_call_msg(Some("a2"), false)).await;
let _ = recv(&fixture.limit).await;
expect_no_event(&fixture.forwarded).await;
send(&fixture, &ctx_b, assistant_tool_call_msg(Some("b1"), false)).await;
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some("b1"));
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
#[tokio::test]
async fn stop_clears_counters() {
let fixture = setup(1).await;
let ctx = AgentContext::new();
send(&fixture, &ctx, assistant_tool_call_msg(Some("m1"), false)).await;
let _ = recv(&fixture.forwarded).await;
send(&fixture, &ctx, assistant_tool_call_msg(Some("m2"), false)).await;
let _ = recv(&fixture.limit).await;
{
let mut guard = fixture.loop_agent.lock().await;
guard.stop().await.unwrap();
guard.start().await.unwrap();
}
send(&fixture, &ctx, assistant_tool_call_msg(Some("m3"), false)).await;
let value = recv(&fixture.forwarded).await;
assert_eq!(value.as_message().unwrap().id.as_deref(), Some("m3"));
expect_no_event(&fixture.limit).await;
fixture.ma.quit();
}
}
mod cancellation {
use super::*;
use std::time::Duration;
struct SlowTool {
info: ToolInfo,
}
#[async_trait]
impl Tool for SlowTool {
fn info(&self) -> &ToolInfo {
&self.info
}
async fn call(
&self,
_ctx: AgentContext,
_args: AgentValue,
) -> Result<AgentValue, AgentError> {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok(AgentValue::string("done"))
}
}
struct FastTool {
info: ToolInfo,
}
#[async_trait]
impl Tool for FastTool {
fn info(&self) -> &ToolInfo {
&self.info
}
async fn call(
&self,
_ctx: AgentContext,
_args: AgentValue,
) -> Result<AgentValue, AgentError> {
Ok(AgentValue::string("fast done"))
}
}
fn slow_call(name: &str, id: &str) -> ToolCall {
ToolCall {
function: ToolCallFunction {
name: name.to_string(),
parameters: serde_json::json!({}),
id: Some(id.to_string()),
parse_error: None,
},
}
}
#[tokio::test]
async fn cancelled_call_tools_synthesizes_aborted_results() {
let tool_name = "cancel_test_slow_tool";
register_tool(SlowTool {
info: ToolInfo::new(tool_name, "sleeps forever for cancellation tests", None),
});
let token = CancellationToken::new();
let ctx = AgentContext::new().with_cancel_token(token.clone());
let calls: Vector<ToolCall> =
vector![slow_call(tool_name, "c1"), slow_call(tool_name, "c2")];
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
});
let msgs = tokio::time::timeout(Duration::from_secs(5), call_tools(&ctx, &calls, 8))
.await
.expect("cancelled call_tools must return promptly")
.unwrap();
assert_eq!(msgs.len(), 2);
for (msg, id) in msgs.iter().zip(["c1", "c2"]) {
assert_eq!(msg.role, "tool");
assert_eq!(msg.id.as_deref(), Some(id));
assert_eq!(msg.is_error, Some(true));
assert_eq!(msg.text(), ABORTED_TOOL_RESULT);
}
unregister_tool(tool_name);
}
#[tokio::test]
async fn cancellation_keeps_results_of_completed_parallel_calls() {
let slow_name = "cancel_test_slow_parallel_tool";
let fast_name = "cancel_test_fast_parallel_tool";
register_tool(SlowTool {
info: ToolInfo::new(slow_name, "sleeps forever for cancellation tests", None)
.with_execution_mode(ExecutionMode::Parallel),
});
register_tool(FastTool {
info: ToolInfo::new(
fast_name,
"completes immediately for cancellation tests",
None,
)
.with_execution_mode(ExecutionMode::Parallel),
});
let token = CancellationToken::new();
let ctx = AgentContext::new().with_cancel_token(token.clone());
let calls: Vector<ToolCall> =
vector![slow_call(slow_name, "c1"), slow_call(fast_name, "c2")];
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
});
let msgs = tokio::time::timeout(Duration::from_secs(5), call_tools(&ctx, &calls, 8))
.await
.expect("cancelled call_tools must return promptly")
.unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].id.as_deref(), Some("c1"));
assert_eq!(msgs[0].is_error, Some(true));
assert_eq!(msgs[0].text(), ABORTED_TOOL_RESULT);
assert_eq!(msgs[1].id.as_deref(), Some("c2"));
assert_eq!(msgs[1].is_error, None);
assert!(msgs[1].text().contains("fast done"));
unregister_tool(slow_name);
unregister_tool(fast_name);
}
}
}