597 lines
21 KiB
Julia
597 lines
21 KiB
Julia
# ── 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|