pub trait ToolRoundExecutor: Send + Sync {
// Required method
fn execute_round<'a, 'async_trait>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> Pin<Box<dyn Future<Output = BoxStream<'a, ToolCallOutcome>> + Send + 'async_trait>>
where Self: 'async_trait,
'a: 'async_trait;
}Expand description
Round-level tool-call policy: decides how one round’s tool calls are
executed — order, concurrency, approval gates. Inject it with
with_tool_round_executor; the
default SerialToolRoundExecutor runs calls one by one in request
order, the classic serial loop with identical behavior.
The executor owns how calls run; the framework owns that they run
correctly: every call goes through ToolRoundCtx::run, which carries
the standard mechanics (tool span, ToolStarted / ToolCompleted events,
registry dispatch, protected-result declaration) — a custom executor
can reorder, parallelize, or gate calls without reimplementing any of
that.
§Contract
- Atomicity: tool rounds are executed atomically and never interrupted — tools are user code with no cancellation interface, and a partial execution risks side effects; cancellation takes effect after the round ends, before the next round’s conversation. An executor that interrupts mid-round accepts that risk itself.
- Outcomes stream back as they complete: the agent records (and, on the streaming path, dispatches) each outcome as the stream yields it. Dropping the stream early aborts the rest of the round — already-produced outcomes stay recorded, and the model adapts to the partial results.
- Decisions feed back as text: to deny a call (approval gate, rate
limit, …), yield a synthetic
ToolCallOutcomecarrying the decision text — it reaches the model through the normal ToolResult channel without the tool ever running.
§Examples
An approval gate: calls to “dangerous” are denied without running, everything else executes as usual.
use molo::agent::{
Agent, ReActAgent, ToolCallOutcome, ToolRoundCtx, ToolRoundExecutor,
};
use molo::message::ToolCall;
use molo::provider::{FakeProvider, FakeReply};
use molo::tool::ToolRegistry;
use futures::stream::BoxStream;
#[derive(Default)]
struct ApprovalGateToolRoundExecutor;
#[molo::async_trait]
impl ToolRoundExecutor for ApprovalGateToolRoundExecutor {
async fn execute_round<'a>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> BoxStream<'a, ToolCallOutcome> {
Box::pin(async_stream::stream! {
for call in calls {
if call.name == "dangerous" {
yield ToolCallOutcome::text(call, "denied by approval gate");
} else {
yield ctx.run(call).await;
}
}
})
}
}
let mut agent = ReActAgent::new(
FakeProvider::new([FakeReply::Text("Hello".into())]),
ToolRegistry::new(),
"",
)
.with_tool_round_executor(ApprovalGateToolRoundExecutor);
assert_eq!(agent.run("Are you there").await?, "Hello");Required Methods§
Sourcefn execute_round<'a, 'async_trait>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> Pin<Box<dyn Future<Output = BoxStream<'a, ToolCallOutcome>> + Send + 'async_trait>>where
Self: 'async_trait,
'a: 'async_trait,
fn execute_round<'a, 'async_trait>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> Pin<Box<dyn Future<Output = BoxStream<'a, ToolCallOutcome>> + Send + 'async_trait>>where
Self: 'async_trait,
'a: 'async_trait,
Execute one tool round: decide the policy over calls — which to
run, in what order, with what concurrency — and yield each
ToolCallOutcome as it completes.
Use ToolRoundCtx::run to execute a call; its futures borrow
ctx, so when the policy runs calls concurrently, the futures must
materialize inside the returned stream (which owns ctx) rather
than across this method’s return — see the parallel example on
with_tool_round_executor.
§Errors
None at the round level: a tool’s failure is not an error here — it rides along in the outcome’s text, which the agent records and feeds back to the model.
Dyn Compatibility§
This trait is dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".