diff --git a/etc.jl b/etc.jl index beb9929..4ce0950 100644 --- a/etc.jl +++ b/etc.jl @@ -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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/agentCore.jl b/src/agentCore.jl index 2860165..2c2cca7 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -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 + + + + + + + + + + + + + + + diff --git a/src/type.jl b/src/type.jl index 897e72d..99cff35 100644 --- a/src/type.jl +++ b/src/type.jl @@ -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 +