diff --git a/src/tools/README.md b/src/tools/README.md index d7c6380..7262450 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -155,33 +155,218 @@ end Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. -## Tool Call Lifecycle +## Tool Lifecycle — Framework Internals + +This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`. + +### Phase 1: Detect Tool Calls in LLM Response + +After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block: + +```julia +# agentCore.jl:217-244 +has_tool_calls = false +tool_call_list = agentToolCall[] + +for content_block in response.content + if content_block isa Dict + # OpenAI-style: type == "tool_calls" with array of tool calls + if get(content_block, :type, "") == "tool_calls" + for tc_data in get(content_block, :tool_calls, []) + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :function, Dict{String,Any}())[:name], + arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], + ) + push!(tool_call_list, tc) + end + # Alternative style: type == "tool_call" single dict per block + elseif get(content_block, :type, "") == "tool_call" + tc = agentToolCall( + type="function", + id=get(tc_data, :id, string(uuid4())), + name=get(tc_data, :name, ""), + arguments=get(tc_data, :arguments, Dict{String,Any}()), + ) + push!(tool_call_list, tc) + end + end +end +``` + +Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`. + +### Phase 2: Dispatch to Sequential or Parallel Execution + +At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode: + +```julia +# agentCore.jl:247-265 +context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) +config = agentLoopConfig( + agent._state.tools, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute ? "parallel" : "sequential", +) +batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) +``` + +`executeToolCalls` (`agentCore.jl:988-1015`) checks: +- `config.toolExecution == "sequential"` → sequential mode +- Any tool has `parallelToolExecute == false` → sequential mode +- Otherwise → parallel mode + +### Phase 3: Per-Call Preparation (`prepareToolCall`) + +Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`): ``` -LLM requests tool call - └── prepareToolCall (agentCore.jl:511) - ├── Tool lookup by name - ├── prepareArguments (tool-specific transform, if defined) - ├── validateToolArguments (validateRequiredArgs hook or default) - │ └── on failure → immediateOutcome (no execution) - ├── beforeToolCall hook (if defined) - │ └── on block → immediateOutcome (no execution) - └── returns preparedToolCall +1. Look up tool by name: find(t -> t.name == tc.name, context.tools) +2. If not found → immediateOutcome("Tool X not found", true) +3. Run tool.prepareArguments (if defined) → transforms raw LLM args +4. Run validateToolArguments → validateRequiredArgs (hook or default) + → if fails → throws ArgumentError → caught below +5. Run beforeToolCall hook (if defined) → can block execution + → if blocked → immediateOutcome("Tool execution was blocked", true) +6. Return preparedToolCall(tool, tc, validatedArgs) +``` -executed by executeToolCallsSequential or executeToolCallsParallel - └── executePreparedToolCall (agentCore.jl:589) - ├── emit toolExecutionStart - ├── call tool.execute() - │ └── on error → executedOutcome(isError=true) - └── returns executedOutcome +If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`: -finalizeExecutedToolCall (agentCore.jl:675) - ├── afterToolCall hook (if defined) - │ └── can mutate result content, usage, terminate, isError - └── returns finalizedOutcome +```julia +catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) +end +``` -emit toolExecutionEnd - └── createToolResultMessage → added to conversation history +### Phase 4: Execution (`executePreparedToolCall`) + +For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs: + +```julia +function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome + updateEvents = promise[] + accepting = true + + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, emit(toolExecUpdateEvent(..., 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 +``` + +Key behaviors: +- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function +- `signal` can be checked inside `executeTool` for cancellation +- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events +- `accepting` guard prevents emitting updates after the result is already captured +- `wait.(updateEvents)` ensures all streaming updates are delivered before returning +- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown + +### Phase 5: Finalization (`finalizeExecutedToolCall`) + +After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook: + +```julia +function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::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 + # Hook can mutate: content, details, usage, terminate, isError + result = merge(result, dict(...)) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true + end + end + + return finalizedOutcome(prep.toolCall, result, isError) +end +``` + +The hook can: +- Mask sensitive data from result content +- Normalize usage tracking +- Flip `terminate: true` based on business logic +- Wrap errors in friendlier messages for the LLM + +If the hook itself throws, the error is caught and converted to an error outcome. + +### Phase 6: Emit Events and Create Result Message + +Each call emits `toolExecutionEnd`: + +```julia +function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) +end +``` + +Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`): + +```julia +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 +``` + +### Phase 7: Batch Assembly and Loop Control + +In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected: + +```julia +messages = toolResultMessage[] +for finalized in finalizedCalls + push!(messages, createToolResultMessage(finalized)) +end +return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) +``` + +`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn. + +### Data Flow Summary + +``` +response.content (Vector{Any}) + └── phase 1: parse content blocks + └── tool_call_list :: Vector{agentToolCall} + └── phase 2: dispatch to sequential/parallel + └── phase 3: prepareToolCall + └── preparedToolCall or immediateOutcome + └── phase 4: executePreparedToolCall + └── executedOutcome + └── phase 5: finalizeExecutedToolCall + └── finalizedOutcome + └── phase 6: createToolResultMessage + └── toolResultMessage + └── phase 7: agentToolCallBatch + └── pushed to agent._state.messages + └── loop back to LLM ``` ## Execution Modes