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