update
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user