This commit is contained in:
2026-08-05 19:09:56 +07:00
parent afc866a3a5
commit ec28e0ff54
3 changed files with 953 additions and 594 deletions
-594
View File
@@ -1,596 +1,2 @@
# ── executeToolCalls() Julia pseudo code ──────────────────────────
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit
"""
preparedToolCall(tool, toolCall, args)
Intermediate state between tool call validation and execution.
This struct is created after `prepareToolCall` succeeds and serves
as the bridge to the execution phase. Keeping the resolved tool,
original call metadata, and validated args together avoids repeated
lookups and allows the execution phase to access all necessary data
without carrying the full context through the call chain.
"""
struct preparedToolCall
tool::agentTool # The resolved tool definition from the context
toolCall::agentToolCall # The original tool call from the assistant
args::any # Validated (and coerced) argument values
end
"""
immediateOutcome(result, isError)
A tool call that was resolved without actual execution — either
because the tool was not found, validation failed, or a
`beforeToolCall` hook blocked the call. The result is produced
immediately and emitted as a tool result message.
Returning an outcome instead of throwing an exception is intentional:
it lets the agent feed the error back to the LLM as a tool result so
the model can recover — for example, by re-issuing a tool call with
corrected arguments after a validation failure.
"""
struct immediateOutcome
result::agentToolResult # The pre-computed tool result
isError::bool # Whether this outcome represents an error
end
"""
executedOutcome(result, isError)
A tool call that has been executed by `tool.execute()` but has not
yet been through the `afterToolCall` hook. This intermediate state
is necessary because the hook may mutate the result (content, usage,
termination, error status). Keeping execution and finalization separate
allows the hook to inspect the raw result and decide whether to
transform it or replace it entirely.
"""
struct executedOutcome
result::agentToolResult # The tool's execution result
isError::bool # Whether execution raised an error
end
"""
finalizedOutcome(toolCall, result, isError)
The complete outcome of a tool call after both execution and the
`afterToolCall` hook. This is the final form that is used to
construct the `toolResultMessage` emitted to the agent loop.
The three-phase design (prepare → execute → finalize) exists so that
each phase has a single responsibility: preparation handles validation
and gating, execution performs the actual work, and finalization
applies post-processing hooks. This separation allows the agent loop
to emit `tool_execution_end` events with the finalized data while
keeping each phase independently testable and swappable.
"""
struct finalizedOutcome
toolCall::agentToolCall # The original tool call reference
result::agentToolResult # The final tool result (post-afterToolCall)
isError::bool # Whether the call failed or was blocked
end
"""
ToolCallBatch(messages, terminate)
A batch of tool result messages from executing one or more tool calls.
The `terminate` flag indicates whether all tools in the batch requested
termination, which causes the agent loop to stop processing further turns.
This flag is set by the tool implementation (not the end user) to signal
that the agent should not call the LLM again. Typical use cases:
- Task completion: a tool like `deploy` or `submit` finishes its work and
returns `terminate: true` so the agent stops instead of asking the LLM
what to do next.
- Unrecoverable error: a tool hits a fatal condition (e.g. database
connection lost, auth token expired) and returns `terminate: true` so
the agent stops with an error message rather than retrying.
- Async handoff: a tool triggers a long-running external operation and
wants the agent to stop now; the external system will later resume the
agent via `continue()`.
If `terminate` is `false` (default), the agent loop feeds the tool results
back to the LLM for another turn.
"""
struct toolCallBatch
messages::vector{toolResultMessage} # Tool result messages for this batch
terminate::bool # Whether the batch should terminate the loop
end
"""
createErrorToolResult(msg)
Builds an `agentToolResult` containing a single text content item
with the provided error message and an empty details dictionary.
Used when a tool call cannot be executed due to errors.
Returning a result instead of throwing ensures that errors at any
point in the tool call pipeline are fed back to the LLM as a tool
result message. This allows the model to see the error and decide
whether to retry, re-issue the call with different arguments, or
report failure to the user.
"""
function createErrorToolResult(msg::String)::agentToolResult
return agentToolResult([textContent("text", msg)], dict{any,any}())
end
"""
createToolResultMessage(f)
Constructs a `toolResultMessage` from a `finalizedOutcome`.
Normalizes missing content to an empty array and includes
the `addedToolNames` field only when the tool dynamically
registered new tools during execution.
This conversion is necessary because the tool result is an
`agentToolResult` used by tool implementations, while the agent
loop consumes `toolResultMessage` objects that become part of the
conversation history. The message format includes metadata like
timestamp and tool call ID that the raw result does not carry,
and it is the object emitted via `message_start`/`message_end`
events so the LLM receives the result as a proper assistant/user
message in the context window.
"""
function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
return toolResultMessage(
"toolResult", f.toolCall.id, f.toolCall.name,
f.result.content, f.result.details, f.result.usage,
get(f.result, :addedToolNames, string[]), f.isError, nowMillis()
)
end
"""
shouldTerminate(finalizedCalls)
Returns `true` only when every finalized call in the batch has
`result.terminate == true`. All tools must agree — if any tool
did not request termination, the agent continues. This prevents
a single tool that happens to set `terminate: true` (e.g. for
metadata purposes) from accidentally stopping the agent when
other tools in the batch did not intend to terminate.
"""
function shouldTerminate(batches::vector{finalizedOutcome})::bool
return !isempty(batches) && all(b -> b.result.terminate, batches)
end
"""
prepareToolCallArguments(tool, toolCall)
Calls the tool's optional `prepareArguments` hook to transform
the raw argument values from the LLM before schema validation.
If the tool has no hook or the hook returns the same object
reference, the original call is returned unchanged.
This hook allows tools to normalize arguments that the LLM may
have produced in a non-standard format — for example, converting
a date string to a timestamp, expanding a short file path to an
absolute path, or normalizing casing. It runs before schema
validation so the validator sees the normalized form rather than
raw LLM output.
"""
function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall
if tool.prepareArguments === nothing
return toolCall
end
prepared = tool.prepareArguments(toolCall.arguments)
if prepared == toolCall.arguments
return toolCall
end
return merge(toolCall, dict(:arguments => prepared))
end
"""
prepareToolCall(context, assistantMsg, toolCall, config, signal)
Resolves the tool by name, prepares and validates its arguments,
and runs the `beforeToolCall` hook. Returns a `preparedToolCall`
if successful or an `immediateOutcome` if the tool is not found,
validation fails, the hook blocks execution, or the signal is
aborted. Errors during preparation are caught and returned as
immediate error outcomes so the agent loop can feed them back
to the model.
The key design decision here is that preparation never throws.
Every failure path returns an `immediateOutcome` with an error
result. This ensures the agent loop always receives a valid tool
result message for every tool call the assistant requested,
regardless of whether preparation succeeded. The LLM can then
use the error message to decide whether to retry with different
arguments or acknowledge the failure.
"""
function prepareToolCall(
context::agentContext,
assistantMsg::assistantMessage,
toolCall::agentToolCall,
config::agentLoopConfig,
signal::union{nothing,abortSignal},
)::union{preparedToolCall,immediateOutcome}
tool = find(t -> t.name == toolCall.name, context.tools)
if tool === nothing
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
end
try
# 1. prepare arguments (tool-specific transform)
prepared = prepareToolCallArguments(tool, toolCall)
validatedArgs = validateToolArguments(tool, prepared)
# 2. beforeToolCall hook — can block
if config.beforeToolCall !== nothing
before = config.beforeToolCall(
assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal
)
if signal !== nothing && signal.aborted
return immediateOutcome(createErrorToolResult("Operation aborted"), true)
end
if before !== nothing && before.block
return immediateOutcome(
createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true)
end
end
return preparedToolCall(tool, toolCall, validatedArgs)
catch err
return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
end
# ── per-call execution ──────────────────────────────────────────
"""
executePreparedToolCall(prep, signal, emit)
Executes the tool by calling `tool.execute()` with the validated
arguments, the abort signal, and a callback for streaming partial
results. Emits `toolExecutionUpdate` events for each partial
result batch. Waits for all pending update events to settle before
returning. Catches execution errors and returns them as an error
outcome. The `accepting` guard prevents emitting updates after
the call has finished.
Long-running tools (e.g. file uploads, model training, web scraping)
may take seconds or minutes. The streaming update mechanism allows
UI listeners and other consumers to show progress in real time rather
than waiting for the entire call to complete. The `accepting` guard
ensures that if the tool's execute function yields after emitting
updates but before returning, no duplicate or stale updates are
emitted after the result has already been captured.
"""
function executePreparedToolCall(
prep::preparedToolCall,
signal::union{nothing,abortSignal},
emit::agentEventSink,
)::executedOutcome
updateEvents = promise[]
accepting = true
try
result = prep.tool.execute(
prep.toolCall.id, prep.args, signal,
partialResult -> begin
if accepting
push!(updateEvents,
emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name,
prep.toolCall.arguments, partialResult)))
end
end
)
accepting = false
wait.(updateEvents)
return executedOutcome(result, false)
catch err
accepting = false
wait.(updateEvents)
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
end
# ── per-call finalization ───────────────────────────────────────
"""
finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
Runs the `afterToolCall` hook on the executed result, allowing
the consumer to mutate the result content, details, usage,
termination flag, or error status. Catches errors from the
hook and converts them to error outcomes. Returns a
`finalizedOutcome` that is used to construct the tool result
message.
The `afterToolCall` hook exists as a post-processing step that
runs after every tool call regardless of success or failure.
Common use cases include:
- Masking sensitive data from result content before the LLM
sees it (e.g. removing API keys from error messages).
- Normalizing usage tracking data into a consistent format.
- Inspecting the result and deciding to flip `terminate: true`
based on business logic (e.g. "if deployment failed, stop
the agent rather than retrying").
- Wrapping an error result in a friendlier message for the LLM
to understand.
If the hook itself throws, the error is caught and the result
becomes an error outcome. This ensures the tool pipeline never
breaks due to a buggy hook.
"""
function finalizeExecutedToolCall(
context::agentContext,
assistantMsg::assistantMessage,
prep::preparedToolCall,
executed::executedOutcome,
config::agentLoopConfig,
signal::union{nothing,abortSignal},
)::finalizedOutcome
result = executed.result
isError = executed.isError
if config.afterToolCall !== nothing
try
after = config.afterToolCall(
afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
)
if after !== nothing
result = merge(result, dict(:content=>get(after,:content,result.content),
:details=>get(after,:details,result.details),
:usage=>get(after,:usage,result.usage),
:terminate=>get(after,:terminate,result.terminate)))
isError = get(after, :isError, isError)
end
catch err
result = createErrorToolResult(sprint(showerror, err))
isError = true
end
end
return finalizedOutcome(prep.toolCall, result, isError)
end
"""
emitToolExecutionEnd(finalized, emit)
Emits the `toolExecutionEnd` event with the finalized outcome,
signalling to listeners that the tool call has completed.
This event is part of the tool execution lifecycle:
`tool_execution_start` → (zero or more `tool_execution_update` events) →
`tool_execution_end`. Listeners (such as the TUI or logging systems)
use this lifecycle to track individual tool calls. The event carries
the final result so listeners have all the data they need without
requiring external state lookups.
"""
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::agentEventSink)
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
end
# ── sequential execution ────────────────────────────────────────
"""
executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit)
Executes tool calls one at a time in the order they appear. For each
call: emits `toolExecutionStart`, runs `prepareToolCall`,
then either resolves the immediate outcome or executes/finalizes
the prepared call. Emits `toolExecutionEnd` and creates the tool
result message before proceeding to the next call. Respects the
abort signal — if aborted, remaining calls are skipped. Returns
a batch with `terminate` determined by whether all results set
the termination flag.
Sequential execution is required when tool calls have implicit
dependencies — for example, a `create_database` tool must complete
before `create_table` can reference it. It is also the safer
default because it prevents race conditions when multiple tools
share state (e.g. writing to the same file or API rate limits).
Use parallel only when you are confident the tools are independent.
"""
function executeToolCallsSequential(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::agentEventSink,
)::agentToolCallBatch
finalizedCalls = finalizedOutcome[]
messages = toolResultMessage[]
for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
else
executed = executePreparedToolCall(prep, signal, emit)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
end
emitToolExecutionEnd(finalized, emit)
push!(messages, createToolResultMessage(finalized))
push!(finalizedCalls, finalized)
if signal !== nothing && signal.aborted
break
end
end
return toolCallBatch(messages, shouldTerminate(finalizedCalls))
end
# ── parallel execution ──────────────────────────────────────────
"""
executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit)
Prepares all tool calls concurrently and spawns a task for each
prepared call. Immediate outcomes are resolved instantly. Task
entries are collected in order, then `fetch`ed to await all
concurrent executions. Tool result messages are created from
finalized outcomes in order and returned as a batch. Respects
the abort signal — if aborted during preparation, remaining
calls are skipped. Finalization order preserves the original
call order.
Parallel execution is appropriate when the assistant requests
independent tools — for example, reading multiple files, querying
separate databases, or making independent API calls. It reduces
wall-clock time compared to sequential execution. The tradeoff is
that parallel calls can overwhelm external resources (rate limits,
connection pools, disk I/O). Finalization preserves the original
call order so tool result messages appear in the same order the
assistant requested them, regardless of which call finishes first.
"""
function executeToolCallsParallel(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::agentEventSink,
)::agentToolCallBatch
entries = union{finalizedOutcome,task{finalizedOutcome}}[]
for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
emitToolExecutionEnd(finalized, emit)
push!(entries, finalized)
else
task = task() do
executed = executePreparedToolCall(prep, signal, emit)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
emitToolExecutionEnd(finalized, emit)
return finalized
end
schedule(task)
push!(entries, task)
end
if signal !== nothing && signal.aborted
break
end
end
finalizedCalls = finalizedOutcome[]
for entry in entries
outcome = entry isa task ? fetch(entry) : entry
push!(finalizedCalls, outcome)
end
messages = toolResultMessage[]
for f in finalizedCalls
push!(messages, createToolResultMessage(f))
end
return toolCallBatch(messages, shouldTerminate(finalizedCalls))
end
"""
executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit)
Dispatches to sequential or parallel execution. Uses sequential mode
when `config.toolExecution == "sequential"` or when any of the
tool calls reference a tool with `executionMode: "sequential"`.
Otherwise uses parallel execution. This is the entry point called
from `streamAssistantResponse` in the agent loop.
The sequential mode takes priority over parallel because it is the
safe default. If even one tool in a batch is marked sequential, all
tools execute sequentially — this prevents a single dependent tool
from racing with an otherwise independent one. The per-tool
`executionMode` allows fine-grained control (e.g. most tools are
parallel but a specific write tool is sequential), while the config-level
`toolExecution` provides a global override.
"""
function executeToolCalls(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::agentEventSink,
)::agentToolCallBatch
hasSequential = any(tc ->
any(t -> t.name == tc.name && get(t.executionMode, "parallel") == "sequential",
context.tools), toolCalls)
if config.toolExecution == "sequential" || hasSequential
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit)
else
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit)
end
end
+765
View File
@@ -213,6 +213,771 @@ function _process_message(agent::yiemAgent, msg)::assistantMessage
end
"""
executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit)
Dispatches to sequential or parallel execution. Uses sequential mode
when `config.toolExecution == "sequential"` or when any of the
tool calls reference a tool with `executionMode: "sequential"`.
Otherwise uses parallel execution. This is the entry point called
from `streamAssistantResponse` in the agent loop.
The sequential mode takes priority over parallel because it is the
safe default. If even one tool in a batch is marked sequential, all
tools execute sequentially — this prevents a single dependent tool
from racing with an otherwise independent one. The per-tool
`executionMode` allows fine-grained control (e.g. most tools are
parallel but a specific write tool is sequential), while the config-level
`toolExecution` provides a global override.
"""
function executeToolCalls(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::Function,
)::agentToolCallBatch
hasSequential = false
for tc in toolCalls
for t in context.tools
if t.name == tc.name && get(t.executionMode, "parallel") == "sequential"
hasSequential = true
break
end
end
if hasSequential
break
end
end
if config.toolExecution == "sequential" || hasSequential
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit)
else
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit)
end
end
"""
createErrorToolResult(msg::String) -> agentToolResult
Builds an `agentToolResult` containing a single text content item
with the provided error message and an empty details dictionary.
Used when a tool call cannot be executed due to errors.
Returning a result instead of throwing ensures that errors at any
point in the tool call pipeline are fed back to the LLM as a tool
result message. This allows the model to see the error and decide
whether to retry, re-issue the call with different arguments, or
report failure to the user.
# Arguments
- `msg::String`: The error message to embed in the result
# Returns
- `agentToolResult`: A result with `content = [textContent("text", msg)]`
# Examples
```julia
julia> createErrorToolResult("Tool not found")
agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}())
```
"""
function createErrorToolResult(msg::String)::agentToolResult
return agentToolResult([textContent("text", msg)], dict{any,any}())
end
"""
createToolResultMessage(f::finalizedOutcome) -> toolResultMessage
Constructs a `toolResultMessage` from a `finalizedOutcome`.
Normalizes missing content to an empty array and includes the
`addedToolNames` field only when the tool dynamically registered
new tools during execution.
This conversion is necessary because the tool result is an
`agentToolResult` used by tool implementations, while the agent
loop consumes `toolResultMessage` objects that become part of the
conversation history. The message format includes metadata like
timestamp and tool call ID that the raw result does not carry, and
it is the object emitted via `messageStart`/`messageEnd` events
so the LLM receives the result as a proper assistant/user message
in the context window.
# Arguments
- `f::finalizedOutcome`: The finalized tool call outcome
# Returns
- `toolResultMessage`: A message ready for the agent loop context
# Examples
```julia
julia> outcome = finalizedOutcome(tc, agentToolResult(content, details, usage, false), false);
julia> createToolResultMessage(outcome)
toolResultMessage("toolResult", "call_1", "search_wine", content, details, usage, [], false, 1234567890)
```
"""
function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
return toolResultMessage(
"toolResult", f.toolCall.id, f.toolCall.name,
f.result.content, f.result.details, f.result.usage,
get(f.result, :addedToolNames, string[]), f.isError, nowMillis()
)
end
"""
shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool
Returns `true` only when every finalized call in the batch has
`result.terminate == true`. All tools must agree — if any tool
did not request termination, the agent continues. This prevents
a single tool that happens to set `terminate: true` (e.g. for
metadata purposes) from accidentally stopping the agent when
other tools in the batch did not intend to terminate.
# Arguments
- `finalizedCalls`: Vector of finalized tool call outcomes
# Returns
- `Bool`: `true` if all calls requested termination
# Examples
```julia
julia> shouldTerminate(finalizedOutcome[])
false
julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), false), false) for _ in 1:2])
false
julia> shouldTerminate([finalizedOutcome(tc, agentToolResult([], dict{any,any}(), dict{any,any}(), true), false) for _ in 1:2])
true
```
"""
function shouldTerminate(batches::vector{finalizedOutcome})::bool
return !isempty(batches) && all(b -> b.result.terminate, batches)
end
"""
prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall) -> agentToolCall
Calls the tool's optional `prepareArguments` hook to transform the
raw argument values from the LLM before schema validation. If the
tool has no hook or the hook returns the same object reference, the
original call is returned unchanged.
This hook allows tools to normalize arguments that the LLM may have
produced in a non-standard format — for example, converting a date
string to a timestamp, expanding a short file path to an absolute
path, or normalizing casing. It runs before schema validation so
the validator sees the normalized form rather than raw LLM output.
# Arguments
- `tool::agentTool`: The tool definition (may have a `prepareArguments` hook)
- `toolCall::agentToolCall`: The raw tool call from the assistant
# Returns
- `agentToolCall`: The tool call with potentially transformed arguments
# Examples
```julia
# No prepareArguments hook — returns input unchanged
prepareToolCallArguments(noHookTool, tc)
# => tc # same reference
# With hook that normalizes arguments
prepareToolCallArguments(normalizeTool, tc)
# => agentToolCall{..., arguments=Dict("date" => 1700000000)} # "2024-01-15" → timestamp
```
"""
function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall
if tool.prepareArguments === nothing
return toolCall
end
prepared = tool.prepareArguments(toolCall.arguments)
if prepared == toolCall.arguments
return toolCall
end
return merge(toolCall, dict(:arguments => prepared))
end
"""
prepareToolCall(context, assistantMsg, toolCall, config, signal) ->
Union{preparedToolCall,immediateOutcome}
Resolves the tool by name, prepares and validates its arguments,
and runs the `beforeToolCall` hook. Returns a `preparedToolCall`
if successful or an `immediateOutcome` if the tool is not found,
validation fails, the hook blocks execution, or the signal is
aborted. Errors during preparation are caught and returned as
immediate error outcomes so the agent loop can feed them back
to the model.
The key design decision here is that preparation never throws.
Every failure path returns an `immediateOutcome` with an error
result. This ensures the agent loop always receives a valid tool
result message for every tool call the assistant requested,
regardless of whether preparation succeeded. The LLM can then
use the error message to decide whether to retry with different
arguments or acknowledge the failure.
# Arguments
- `context::agentContext`: Current agent context with tools and messages
- `assistantMsg::assistantMessage`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call to prepare
- `config::agentLoopConfig`: Loop configuration (may include `beforeToolCall`)
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
# Returns
- `preparedToolCall`: If preparation succeeded (tool found, arguments valid, not blocked)
- `immediateOutcome`: If preparation failed (tool missing, invalid args, blocked, aborted)
# Notes
- Tool lookup is by name via `context.tools`
- Validation uses `validateToolArguments` which coerces types per the tool schema
- The `beforeToolCall` hook can block execution by returning `{ block: true }`
# Examples
```julia
# Success path
prepareToolCall(context, msg, tc, config, signal)
# => preparedToolCall(tool, tc, validatedArgs)
# Tool not found
prepareToolCall(context, msg, tcNoMatch, config, signal)
# => immediateOutcome(createErrorToolResult("Tool fake_tool not found"), true)
# Validation failure
prepareToolCall(context, msg, tcBadArgs, config, signal)
# => immediateOutcome(createErrorToolResult("Validation failed..."), true)
# Aborted during preparation
prepareToolCall(context, msg, tc, config, abortedSignal)
# => immediateOutcome(createErrorToolResult("Operation aborted"), true)
```
"""
function prepareToolCall(
context::agentContext,
assistantMsg::assistantMessage,
toolCall::agentToolCall,
config::agentLoopConfig,
signal::union{nothing,abortSignal},
)::union{preparedToolCall,immediateOutcome}
tool = find(t -> t.name == toolCall.name, context.tools)
if tool === nothing
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
end
try
# 1. prepare arguments (tool-specific transform)
prepared = prepareToolCallArguments(tool, toolCall)
validatedArgs = validateToolArguments(tool, prepared)
# 2. beforeToolCall hook — can block
if config.beforeToolCall !== nothing
before = config.beforeToolCall(
assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal
)
if signal !== nothing && signal.aborted
return immediateOutcome(createErrorToolResult("Operation aborted"), true)
end
if before !== nothing && before.block
return immediateOutcome(
createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true)
end
end
return preparedToolCall(tool, toolCall, validatedArgs)
catch err
return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
end
# ── per-call execution ──────────────────────────────────────────
"""
executePreparedToolCall(prep, signal, emit) -> executedOutcome
Executes the tool by calling `tool.execute()` with the validated
arguments, the abort signal, and a callback for streaming partial
results. Emits `toolExecutionUpdate` events for each partial
result batch. Waits for all pending update events to settle before
returning. Catches execution errors and returns them as an error
outcome. The `accepting` guard prevents emitting updates after
the call has finished.
Long-running tools (e.g. file uploads, model training, web scraping)
may take seconds or minutes. The streaming update mechanism allows
UI listeners and other consumers to show progress in real time rather
than waiting for the entire call to complete. The `accepting` guard
ensures that if the tool's execute function yields after emitting
updates but before returning, no duplicate or stale updates are
emitted after the result has already been captured.
# Arguments
- `prep::preparedToolCall`: The prepared tool call (resolved tool + validated args)
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
- `emit::Function`: Event emitter for lifecycle events
# Returns
- `executedOutcome`: The execution result and whether it was an error
# Examples
```julia
# Successful execution
executePreparedToolCall(prep, nothing, emit)
# => executedOutcome(agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()), false)
# Execution error
executePreparedToolCall(prep, nothing, emit)
# => executedOutcome(createErrorToolResult("Connection timeout"), true)
```
"""
function executePreparedToolCall(
prep::preparedToolCall,
signal::union{nothing,abortSignal},
emit::Function,
)::executedOutcome
updateEvents = promise[]
accepting = true
try
result = prep.tool.execute(
prep.toolCall.id, prep.args, signal,
partialResult -> begin
if accepting
push!(updateEvents,
emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name,
prep.toolCall.arguments, partialResult)))
end
end
)
accepting = false
wait.(updateEvents)
return executedOutcome(result, false)
catch err
accepting = false
wait.(updateEvents)
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
end
end
# ── per-call finalization ───────────────────────────────────────
"""
finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) ->
finalizedOutcome
Runs the `afterToolCall` hook on the executed result, allowing
the consumer to mutate the result content, details, usage,
termination flag, or error status. Catches errors from the
hook and converts them to error outcomes. Returns a
`finalizedOutcome` that is used to construct the tool result
message.
The `afterToolCall` hook exists as a post-processing step that
runs after every tool call regardless of success or failure.
Common use cases include:
- Masking sensitive data from result content before the LLM
sees it (e.g. removing API keys from error messages).
- Normalizing usage tracking data into a consistent format.
- Inspecting the result and deciding to flip `terminate: true`
based on business logic (e.g. "if deployment failed, stop
the agent rather than retrying").
- Wrapping an error result in a friendlier message for the LLM
to understand.
If the hook itself throws, the error is caught and the result
becomes an error outcome. This ensures the tool pipeline never
breaks due to a buggy hook.
# Arguments
- `context::agentContext`: Current agent context
- `assistantMsg::assistantMessage`: The assistant message that made the tool call
- `prep::preparedToolCall`: The originally prepared tool call
- `executed::executedOutcome`: The raw execution result
- `config::agentLoopConfig`: Loop configuration (may include `afterToolCall`)
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
# Returns
- `finalizedOutcome`: The finalized outcome ready for message construction
# Examples
```julia
# No afterToolCall hook — returns executed result unchanged
finalizeExecutedToolCall(context, msg, prep, execOk, config, nothing)
# => finalizedOutcome(tc, execOk.result, false)
# afterToolCall masks sensitive data
finalizeExecutedToolCall(context, msg, prep, execOk, configWithHook, nothing)
# => finalizedOutcome(tc, maskedResult, false)
# afterToolCall flips terminate based on business logic
finalizeExecutedToolCall(context, msg, prep, execFail, config, nothing)
# => finalizedOutcome(tc, {terminate: true}, true)
```
"""
function finalizeExecutedToolCall(
context::agentContext,
assistantMsg::assistantMessage,
prep::preparedToolCall,
executed::executedOutcome,
config::agentLoopConfig,
signal::union{nothing,abortSignal},
)::finalizedOutcome
result = executed.result
isError = executed.isError
if config.afterToolCall !== nothing
try
after = config.afterToolCall(
afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
)
if after !== nothing
result = merge(result, dict(:content=>get(after,:content,result.content),
:details=>get(after,:details,result.details),
:usage=>get(after,:usage,result.usage),
:terminate=>get(after,:terminate,result.terminate)))
isError = get(after, :isError, isError)
end
catch err
result = createErrorToolResult(sprint(showerror, err))
isError = true
end
end
return finalizedOutcome(prep.toolCall, result, isError)
end
"""
emitToolExecutionEnd(finalized, emit)
Emits the `toolExecutionEnd` event with the finalized outcome,
signalling to listeners that the tool call has completed.
This event is part of the tool execution lifecycle:
`toolExecutionStart` → (zero or more `toolExecutionUpdate` events) →
`toolExecutionEnd`. Listeners (such as the TUI or logging systems)
use this lifecycle to track individual tool calls. The event carries
the final result so listeners have all the data they need without
requiring external state lookups.
# Arguments
- `finalized::finalizedOutcome`: The finalized outcome to report
- `emit::Function`: Event emitter
# Notes
- Part of a three-event lifecycle per tool call
- Carries the complete result so listeners need no external lookups
# Examples
```julia
# Emits a single event; returns nothing
emitToolExecutionEnd(finalized, emit)
# (emit receives toolExecEndEvent("call_1", "search_wine", result, false))
```
"""
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function)
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
end
# ── sequential execution ────────────────────────────────────────
"""
executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) ->
agentToolCallBatch
Executes tool calls one at a time in the order they appear. For each
call: emits `toolExecutionStart`, runs `prepareToolCall`,
then either resolves the immediate outcome or executes/finalizes
the prepared call. Emits `toolExecutionEnd` and creates the tool
result message before proceeding to the next call. Respects the
abort signal — if aborted, remaining calls are skipped. Returns
a batch with `terminate` determined by whether all results set
the termination flag.
Sequential execution is required when tool calls have implicit
dependencies — for example, a `create_database` tool must complete
before `create_table` can reference it. It is also the safer
default because it prevents race conditions when multiple tools
share state (e.g. writing to the same file or API rate limits).
Use parallel only when you are confident the tools are independent.
# Arguments
- `context::agentContext`: Current agent context
- `assistantMsg::assistantMessage`: The assistant message containing tool calls
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute (ordered)
- `config::agentLoopConfig`: Loop configuration
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
- `emit::Function`: Event emitter
# Returns
- `agentToolCallBatch`: Result messages and termination flag
# Notes
- Calls execute strictly in order; each completes fully before the next begins
- Aborting during one call skips all remaining calls
- If any call returns `terminate: true`, it is included in the batch but does
not force termination unless all calls do
# Examples
```julia
# Two independent reads — both succeed
executeToolCallsSequential(ctx, msg, [readTc, readTc2], config, nothing, emit)
# => agentToolCallBatch([result1, result2], false)
# One tool fails, next is skipped due to abort
executeToolCallsSequential(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit)
# => agentToolCallBatch([result1], false) # tc2 failed, tc3 skipped
# All tools request termination
executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit)
# => agentToolCallBatch([deployResult], true)
```
"""
function executeToolCallsSequential(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::Function,
)::agentToolCallBatch
finalizedCalls = finalizedOutcome[]
messages = toolResultMessage[]
for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
else
executed = executePreparedToolCall(prep, signal, emit)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
end
emitToolExecutionEnd(finalized, emit)
push!(messages, createToolResultMessage(finalized))
push!(finalizedCalls, finalized)
if signal !== nothing && signal.aborted
break
end
end
return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
end
# ── parallel execution ──────────────────────────────────────────
"""
executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) ->
agentToolCallBatch
Prepares all tool calls concurrently and spawns a task for each
prepared call. Immediate outcomes are resolved instantly. Task
entries are collected in order, then `fetch`ed to await all
concurrent executions. Tool result messages are created from
finalized outcomes in order and returned as a batch. Respects
the abort signal — if aborted during preparation, remaining
calls are skipped. Finalization order preserves the original
call order.
Parallel execution is appropriate when the assistant requests
independent tools — for example, reading multiple files, querying
separate databases, or making independent API calls. It reduces
wall-clock time compared to sequential execution. The tradeoff is
that parallel calls can overwhelm external resources (rate limits,
connection pools, disk I/O). Finalization preserves the original
call order so tool result messages appear in the same order the
assistant requested them, regardless of which call finishes first.
# Arguments
- `context::agentContext`: Current agent context
- `assistantMsg::assistantMessage`: The assistant message containing tool calls
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute (order preserved in output)
- `config::agentLoopConfig`: Loop configuration
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
- `emit::Function`: Event emitter
# Returns
- `agentToolCallBatch`: Result messages (in original call order) and termination flag
# Notes
- All tool calls are prepared before any execution begins
- Execution tasks run concurrently; `fetch` waits for completion in order
- Immediate outcomes (errors/blocks) resolve instantly without spawning tasks
- Aborting during preparation skips remaining preparations but does not
cancel tasks already running
# Examples
```julia
# Three independent reads — all succeed, results ordered by original call order
executeToolCallsParallel(ctx, msg, [readA, readB, readC], config, nothing, emit)
# => agentToolCallBatch([resultA, resultB, resultC], false)
# Mix of immediate error and concurrent success
executeToolCallsParallel(ctx, msg, [badTc, goodTc], config, nothing, emit)
# => agentToolCallBatch([errorResult, goodResult], false)
# Abort during preparation
executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit)
# => agentToolCallBatch([...], false) # only prepared calls complete
```
"""
function executeToolCallsParallel(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::Function,
)::agentToolCallBatch
entries = union{finalizedOutcome,task{finalizedOutcome}}[]
for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
emitToolExecutionEnd(finalized, emit)
push!(entries, finalized)
else
task = task() do
executed = executePreparedToolCall(prep, signal, emit)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
emitToolExecutionEnd(finalized, emit)
return finalized
end
schedule(task)
push!(entries, task)
end
if signal !== nothing && signal.aborted
break
end
end
finalizedCalls = finalizedOutcome[]
for entry in entries
outcome = entry isa task ? fetch(entry) : entry
push!(finalizedCalls, outcome)
end
messages = toolResultMessage[]
for f in finalizedCalls
push!(messages, createToolResultMessage(f))
end
return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
end
"""
executeToolCalls(context, assistantMsg, toolCalls, config, signal, emit) ->
agentToolCallBatch
Dispatches to sequential or parallel execution. Uses sequential mode
when `config.toolExecution == "sequential"` or when any of the
tool calls reference a tool with `executionMode: "sequential"`.
Otherwise uses parallel execution. This is the entry point called
from `streamAssistantResponse` in the agent loop.
The sequential mode takes priority over parallel because it is the
safe default. If even one tool in a batch is marked sequential, all
tools execute sequentially — this prevents a single dependent tool
from racing with an otherwise independent one. The per-tool
`executionMode` allows fine-grained control (e.g. most tools are
parallel but a specific write tool is sequential), while the config-level
`toolExecution` provides a global override.
# Arguments
- `context::agentContext`: Current agent context (used for per-tool `executionMode` lookup)
- `assistantMsg::assistantMessage`: The assistant message containing tool calls
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute
- `config::agentLoopConfig`: Loop configuration (`toolExecution` mode)
- `signal::Union{Nothing,AbortSignal}`: Optional abort signal
- `emit::Function`: Event emitter
# Returns
- `agentToolCallBatch`: The result batch from the selected execution strategy
# Notes
- Per-tool `executionMode` is checked against `context.tools` for each tool call
- If any tool is sequential, the entire batch runs sequentially
- `config.toolExecution` can override all per-tool settings globally
# Examples
```julia
# Parallel dispatch — no sequential tools in batch
executeToolCalls(ctx, msg, [searchTc, fetchTc], configParallel, nothing, emit)
# => agentToolCallBatch(results, false) # parallel execution
# Sequential fallback — one tool is marked sequential
executeToolCalls(ctx, msg, [searchTc, writeTc], configParallel, nothing, emit)
# => agentToolCallBatch(results, false) # sequential because writeTc is sequential
# Global override — config forces sequential regardless of per-tool settings
executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit)
# => agentToolCallBatch(results, false) # sequential because config says so
```
"""
function executeToolCalls(
context::agentContext,
assistantMsg::assistantMessage,
toolCalls::vector{agentToolCall},
config::agentLoopConfig,
signal::union{nothing,abortSignal},
emit::Function,
)::agentToolCallBatch
hasSequential = false
for tc in toolCalls
for t in context.tools
if t.name == tc.name && get(t.executionMode, "parallel") == "sequential"
hasSequential = true
break
end
end
if hasSequential
break
end
end
if config.toolExecution == "sequential" || hasSequential
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit)
else
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit)
end
end
+188
View File
@@ -396,6 +396,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
end
"""
@@ -419,6 +420,7 @@ on `inputChannel` and `followUpChannel` channels concurrently.
- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`)
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
# Returns
- A new `yiemAgent` instance with an active background task
@@ -444,6 +446,7 @@ function yiemAgent(
sessionId::Union{String, Nothing}=nothing,
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink::Function,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
@@ -467,6 +470,7 @@ function yiemAgent(
sessionId,
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
)
# Spawn the background loop and attach it
@@ -477,6 +481,190 @@ end
"""
preparedToolCall(tool, toolCall, args)
Intermediate state between tool call validation and execution.
Created after `prepareToolCall` succeeds; serves as the bridge to
the execution phase. Keeping the resolved tool, original call
metadata, and validated args together avoids repeated lookups and
allows the execution phase to access all necessary data without
carrying the full context through the call chain.
# Fields
- `tool::agentTool`: The resolved tool definition from the context
- `toolCall::agentToolCall`: The original tool call from the assistant
- `args::any`: Validated (and coerced) argument values
# Examples
```julia
# After prepareToolCall succeeds, the agent holds a preparedToolCall
prep = preparedToolCall(
tool, # agentTool found in context.tools
toolCall, # {id: "call_1", name: "search_wine", arguments: "{\"query\": \"red wine\"}"}
validatedArgs # Dict("query" => "red wine")
)
```
"""
struct preparedToolCall
tool::agentTool # The resolved tool definition from the context
toolCall::agentToolCall # The original tool call from the assistant
args::any # Validated (and coerced) argument values
end
"""
immediateOutcome(result, isError)
A tool call that was resolved without actual execution — either
because the tool was not found, validation failed, or a
`beforeToolCall` hook blocked the call. The result is produced
immediately and emitted as a tool result message.
Returning an outcome instead of throwing an exception is intentional:
it lets the agent feed the error back to the LLM as a tool result so
the model can recover — for example, by re-issuing a tool call with
corrected arguments after a validation failure.
# Fields
- `result::agentToolResult`: The pre-computed tool result
- `isError::bool`: Whether this outcome represents an error
# Examples
```julia
# Tool not found — immediate error
immediateOutcome(
createErrorToolResult("Tool search_wine not found"),
true
)
# beforeToolCall hook blocked execution
immediateOutcome(
createErrorToolResult("Tool execution was blocked"),
true
)
```
"""
struct immediateOutcome
result::agentToolResult # The pre-computed tool result
isError::bool # Whether this outcome represents an error
end
"""
executedOutcome(result, isError)
A tool call that has been executed by `tool.execute()` but has not
yet been through the `afterToolCall` hook. This intermediate state
is necessary because the hook may mutate the result (content, usage,
termination, error status). Keeping execution and finalization
separate allows the hook to inspect the raw result and decide
whether to transform it or replace it entirely.
# Fields
- `result::agentToolResult`: The tool's execution result
- `isError::bool`: Whether execution raised an error
# Examples
```julia
# Successful execution
executedOutcome(
agentToolResult([textContent("text", "Found 3 wines")], dict{any,any}(), dict{any,any}()),
false
)
# Execution error
executedOutcome(
createErrorToolResult("Connection timeout"),
true
)
```
"""
struct executedOutcome
result::agentToolResult # The tool's execution result
isError::bool # Whether execution raised an error
end
"""
finalizedOutcome(toolCall, result, isError)
The complete outcome of a tool call after both execution and the
`afterToolCall` hook. This is the final form used to construct
the `toolResultMessage` emitted to the agent loop.
The three-phase design (prepare → execute → finalize) exists so
that each phase has a single responsibility: preparation handles
validation and gating, execution performs the actual work, and
finalization applies post-processing hooks. This separation allows
the agent loop to emit `toolExecutionEnd` events with the
finalized data while keeping each phase independently testable
and swappable.
# Fields
- `toolCall::agentToolCall`: The original tool call reference
- `result::agentToolResult`: The final tool result (post-afterToolCall)
- `isError::bool`: Whether the call failed or was blocked
# Examples
```julia
# Normal successful finalization
finalizedOutcome(tc, agentToolResult(content, details, usage, false), false)
# afterToolCall mutated result and set terminate
finalizedOutcome(tc, agentToolResult(content, details, usage, true), false)
```
"""
struct finalizedOutcome
toolCall::agentToolCall # The original tool call reference
result::agentToolResult # The final tool result (post-afterToolCall)
isError::bool # Whether the call failed or was blocked
end
"""
agentToolCallBatch(messages, terminate)
A batch of tool result messages from executing one or more tool calls.
The `terminate` flag indicates whether all tools in the batch
requested termination, which causes the agent loop to stop
processing further turns.
This flag is set by the tool implementation (not the end user) to
signal that the agent should not call the LLM again. Typical use
cases:
- Task completion: a tool like `deploy` or `submit` finishes its
work and returns `terminate: true` so the agent stops instead
of asking the LLM what to do next.
- Unrecoverable error: a tool hits a fatal condition (e.g.
database connection lost, auth token expired) and returns
`terminate: true` so the agent stops with an error message
rather than retrying.
- Async handoff: a tool triggers a long-running external
operation and wants the agent to stop now; the external system
will later resume the agent via `continue()`.
If `terminate` is `false` (default), the agent loop feeds the tool
results back to the LLM for another turn.
# Fields
- `messages::Vector{toolResultMessage}`: Tool result messages for this batch
- `terminate::Bool`: Whether the batch should terminate the loop
# Examples
```julia
# Batch of 3 tool results, no termination
agentToolCallBatch(resultMessages, false)
# All tools requested termination
agentToolCallBatch(resultMessages, true)
# Empty batch — terminate is false regardless
agentToolCallBatch(toolResultMessage[], false)
```
"""
struct agentToolCallBatch
messages::vector{toolResultMessage} # Tool result messages for this batch
terminate::bool # Whether the batch should terminate the loop
end