47 KiB
47 KiB
AgentCore.jl - AgentLoop Component Deep Dive
AgentLoop Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ AgentLoop Layer │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Public API │
└─────────────────────────────────────────────────────────────────────────────┘
agentLoop()
├─ prompts: Vector{AgentMessage}
├─ context: AgentContext
├─ config: AgentLoopConfig
├─ signal: Union{Nothing, AbortSignal}
└─ stream_fn: StreamFn
└─ Returns: EventStream
agentLoopContinue()
├─ context: AgentContext
├─ config: AgentLoopConfig
├─ signal: Union{Nothing, AbortSignal}
└─ stream_fn: StreamFn
└─ Returns: EventStream
┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Flow with Type Transformations │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. runAgentLoop() ── Entry point for new conversation │
│ Input: prompts::Vector{AgentMessage} │
│ context::AgentContext (system_prompt, messages, tools) │
│ config::AgentLoopConfig │
│ Output: new_messages::Vector{AgentMessage} (appended prompts + turns) │
│ │
│ - Creates copy of prompts │
│ - Appends prompts to context.messages │
│ - Emits AgentStartEvent │
│ - Emits TurnStartEvent │
│ - Emits MessageStart/End for each prompt │
│ - Calls runLoop() │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. runLoop() ── Main event loop │
│ Input: current_context::AgentContext │
│ new_messages::Vector{AgentMessage} │
│ Output: N/A (writes to new_messages and context.messages) │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ while true (outer loop: follow-up messages) │ │
│ │ has_more_tool_calls = true │ │
│ │ while has_more_tool_calls || !isempty(pending_messages) │ │
│ │ 1. Emit TurnStartEvent (on subsequent turns) │ │
│ │ 2. If pending_messages: emit MessageStart/End, drain queue │ │
│ │ 3. streamAssistantResponse() │ │
│ │ 4. If error/aborted: emit TurnEnd, AgentEnd, return │ │
│ │ 5. Execute tool calls (sequential or parallel) │ │
│ │ 6. has_more_tool_calls = !batch.terminate │ │
│ │ 7. Emit TurnEndEvent │ │
│ │ 8. prepare_next_turn (optional config update) │ │
│ │ 9. should_stop_after_turn? (early return) │ │
│ │ 10. pending_messages = get_steering_messages() │ │
│ │ if !isempty(get_follow_up_messages()) → continue outer loop │ │
│ │ break │ │
│ │ emit AgentEndEvent │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. streamAssistantResponse() ── LLM interaction │
│ Input: context::AgentContext │
│ config::AgentLoopConfig │
│ Output: message::AssistantMessage │
│ │
│ Data Transformations: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Step 1: transform_context (optional) │ │
│ │ Input: context.messages::Vector{AgentMessage} │ │
│ │ Output: messages::Vector{AgentMessage} (transformed) │ │
│ │ │ │
│ │ Step 2: convert_to_llm │ │
│ │ Input: messages::Vector{AgentMessage} │ │
│ │ Output: llm_messages::Vector{Message} │ │
│ │ - UserMessage → UserMessage │ │
│ │ - AssistantMessage → AssistantMessage │ │
│ │ - ToolResultMessage → ToolResultMessage │ │
│ │ - BashExecutionMessage → UserMessage │ │
│ │ - CompactionSummaryMessage → UserMessage │ │
│ │ - BranchSummaryMessage → UserMessage │ │
│ │ │ │
│ │ Step 3: Call stream_fn │ │
│ │ Input: model, llm_context::Context, config │ │
│ │ Output: response::Stream (events) │ │
│ │ │ │
│ │ Step 4: Stream events │ │
│ │ Events: start, text_start/delta/end, toolcall_start/delta/end │ │
│ │ Final: AssistantMessage (with ToolCall[] in content) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. executeToolCalls() ── Tool execution │
│ Input: assistant_message::AssistantMessage (contains ToolCall[]) │
│ current_context::AgentContext │
│ Output: ExecutedToolCallBatch (messages::ToolResultMessage[], terminate) │
│ │
│ For each ToolCall: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ prepareToolCall() │ │
│ │ Input: tool_call::ToolCall │ │
│ │ Output: PreparedToolCall or ImmediateToolCallOutcome │ │
│ │ - validates arguments │ │
│ │ - runs before_tool_call hook (optional) │ │
│ │ - runs prepare_arguments hook (optional) │ │
│ │ │ │
│ │ executePreparedToolCall() (if prepared) │ │
│ │ Input: PreparedToolCall │ │
│ │ Output: ExecutedToolCallOutcome │ │
│ │ - calls tool.execute() │ │
│ │ - returns AgentToolResultMutable │ │
│ │ │ │
│ │ finalizeExecutedToolCall() │ │
│ │ Input: ExecutedToolCallOutcome │ │
│ │ Output: FinalizedToolCallOutcome │ │
│ │ - runs after_tool_call hook (optional) │ │
│ │ - returns ToolCall + AgentToolResultMutable + is_error │ │
│ │ │ │
│ │ createToolResultMessage() │ │
│ │ Input: FinalizedToolCallOutcome │ │
│ │ Output: ToolResultMessage │ │
│ │ - role: "toolResult" │ │
│ │ - tool_call_id, tool_name, content, details │ │
│ │ - usage, added_tool_names, is_error, timestamp │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Sequential: executeToolCallsSequential() │ │
│ │ - Executes tools one at a time, waits for each │ │
│ │ - Returns batch of ToolResultMessage[] │ │
│ │ │ │
│ │ Parallel: executeToolCallsParallel() │ │
│ │ - Creates closures for async execution │ │
│ │ - Executes all closures, collects results │ │
│ │ - Returns batch of ToolResultMessage[] │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. AgentEndEvent ── Final event with all messages │
│ Output: messages::Vector{AgentMessage} │
│ Contains: [UserMessage, AssistantMessage, ToolResultMessage, ...] │
└─────────────────────────────────────────────────────────────────────────────┘
AgentLoopConfig
struct AgentLoopConfig
model::Model
reasoning::Union{ThinkingLevel, Nothing}
session_id::Union{String, Nothing}
on_payload::Union{Function, Nothing}
on_response::Union{Function, Nothing}
transport::String
thinking_budgets::Union{Dict{String, Int64}, Nothing}
max_retry_delay_ms::Union{Int64, Nothing}
tool_execution::ToolExecutionMode
before_tool_call::Union{Function, Nothing}
after_tool_call::Union{Function, Nothing}
prepare_next_turn::Union{Function, Nothing}
convert_to_llm::Function
transform_context::Union{Function, Nothing}
get_api_key::Union{Function, Nothing}
get_steering_messages::Union{Function, Nothing}
get_follow_up_messages::Union{Function, Nothing}
should_stop_after_turn::Union{Function, Nothing}
max_tokens::Union{Int64, Nothing}
temperature::Union{Float64, Nothing}
cache_retention::Union{String, Nothing}
headers::Union{Dict{String, String}, Nothing}
metadata::Union{Dict{String, Any}, Nothing}
signal::Union{Any, Nothing}
api_key::Union{String, Nothing}
end
Notes:
should_stop_after_turn(context::PrepareNextTurnContext)::Bool— Default returnsfalse. Use to implement custom termination logic (e.g., max turns, tool-specific termination).max_tokens,temperature,cache_retention— Passed through to the LLM API provider.signal,api_key— Per-request overrides for abort handling and authentication.headers,metadata— Passed through to the LLM API provider.
Main Functions
agentLoop()
function agentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::EventStream
Purpose: Start a new conversation with initial prompts
Flow:
- Create event stream
- Spawn thread to run agent loop
- Return stream for event consumption
stream = agentLoop(
[UserMessage("user", [TextContent("Hello")], timestamp)],
AgentContext(system_prompt, messages, tools),
config,
nothing,
stream_fn,
)
# Consume events
for event in stream
if event isa MessageEndEvent
println("Received: $(event.message)")
end
end
runAgentLoop()
function runAgentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
emit::AgentEventSink,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::Vector{AgentMessage}
Purpose: Execute agent loop with initial prompts
Flow:
- Copy prompts to
new_messages - Create
current_contextwith prompts appended tocontext.messages - Emit
AgentStartEvent - Emit
TurnStartEvent - For each prompt: emit
MessageStartEvent,MessageEndEvent - Call
runLoop()— handles the main loop, tool execution, and termination - Return
new_messages
runLoop() - The Heart of AgentLoop
function runLoop(
initial_context::AgentContext,
new_messages::Vector{AgentMessage},
initial_config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::Nothing
Main Loop (simplified — shows structure; actual code has type annotations):
current_context = initial_context
config = initial_config
first_turn = true
pending_messages = get_steering_messages(config)
while true
has_more_tool_calls = true
# Inner loop: process pending messages AND/OR tool results
while has_more_tool_calls || !isempty(pending_messages)
if !first_turn
emit(TurnStartEvent())
else
first_turn = false
end
# Emit pending messages (steering / follow-up)
if !isempty(pending_messages)
for message in pending_messages
emit(MessageStartEvent(message))
emit(MessageEndEvent(message))
push!(current_context.messages, message)
push!(new_messages, message)
end
pending_messages = AgentMessage[]
end
# Stream assistant response
message = streamAssistantResponse(
current_context, config, signal, emit, stream_function
)
push!(new_messages, message)
# Early exit on error/abort
if message.stop_reason in ("error", "aborted")
emit(TurnEndEvent(message, ToolResultMessage[]))
emit(AgentEndEvent(new_messages))
return
end
# Execute tool calls (if any)
tool_calls = filter(c -> c isa ToolCall, message.content)
tool_results = ToolResultMessage[]
has_more_tool_calls = false
if !isempty(tool_calls)
executed_batch = if message.stop_reason == "length"
failToolCallsFromTruncatedMessage(tool_calls, emit)
else
executeToolCalls(
current_context, message, config, signal, emit
)
end
append!(tool_results, executed_batch.messages)
has_more_tool_calls = !executed_batch.terminate
for result in tool_results
push!(current_context.messages, result)
push!(new_messages, result)
end
end
emit(TurnEndEvent(message, tool_results))
# Optional: prepare next turn (model/thinking/context changes)
next_turn_context = PrepareNextTurnContext(
message, tool_results, current_context, new_messages
)
next_turn_snapshot = prepare_next_turn(config, next_turn_context)
if !isnothing(next_turn_snapshot)
current_context = next_turn_snapshot.context
# Rebuild config with updated model/thinking + preserved fields
config = AgentLoopConfig(
model = next_turn_snapshot.model,
reasoning = next_turn_snapshot.thinking_level,
convert_to_llm = config.convert_to_llm,
transform_context = config.transform_context,
get_api_key = config.get_api_key,
should_stop_after_turn = config.should_stop_after_turn,
prepare_next_turn = config.prepare_next_turn,
get_steering_messages = config.get_steering_messages,
get_follow_up_messages = config.get_follow_up_messages,
tool_execution = config.tool_execution,
before_tool_call = config.before_tool_call,
after_tool_call = config.after_tool_call,
max_tokens = config.max_tokens,
temperature = config.temperature,
reasoning = config.reasoning,
cache_retention = config.cache_retention,
session_id = config.session_id,
headers = config.headers,
metadata = config.metadata,
transport = config.transport,
signal = signal,
api_key = config.api_key,
on_payload = config.on_payload,
on_response = config.on_response,
max_retry_delay_ms = config.max_retry_delay_ms,
)
end
# Check termination
if should_stop_after_turn(config, next_turn_context)
emit(AgentEndEvent(new_messages))
return
end
# Get next steering messages
pending_messages = get_steering_messages(config)
end
# Check follow-up messages (processed only after all tool calls complete)
follow_up_messages = get_follow_up_messages(config)
if !isempty(follow_up_messages)
pending_messages = follow_up_messages
continue
end
break
end
emit(AgentEndEvent(new_messages))
streamAssistantResponse()
function streamAssistantResponse(
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::AssistantMessage
Data Flow:
Input: context.messages::Vector{AgentMessage}
↓
[transform_context] (optional hook)
↓
messages::Vector{AgentMessage}
↓
[convert_to_llm] - Type transformation pipeline
↓
llm_messages::Vector{Message}
│
│ AgentMessage → Message mapping:
│ • UserMessage → UserMessage (pass-through)
│ • AssistantMessage → AssistantMessage (pass-through)
│ • ToolResultMessage → ToolResultMessage (pass-through)
│ • BashExecutionMessage → UserMessage (text conversion)
│ • CompactionSummaryMessage → UserMessage (text wrapped)
│ • BranchSummaryMessage → UserMessage (text wrapped)
│
▼
Context(system_prompt, llm_messages, tools)
↓
stream_fn(model, context, config) - LLM API call
↓
Stream of AssistantMessageEvent:
• StartEvent (partial AssistantMessage)
• TextStartEvent/TextDeltaEvent/TextEndEvent
• ToolCallStartEvent/ToolCallDeltaEvent/ToolCallEndEvent
• DoneEvent (final AssistantMessage with usage, stop_reason)
↓
Return: AssistantMessage
- content::Vector{MessageContent}
- usage::Usage
- stop_reason::String
- Contains ToolCall[] if tool calls requested
executeToolCalls()
function executeToolCalls(
current_context::AgentContext,
assistant_message::AssistantMessage,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
Data Flow:
Input: assistant_message::AssistantMessage
- content::Vector{MessageContent}
└─ Contains ToolCall[] and/or TextContent[]
↓
filter(c -> c isa ToolCall, assistant_message.content)
↓
tool_calls::Vector{ToolCall}
- type: "tool"
- id::String
- name::String
- arguments::Dict{String, Any}
- partial_json::Union{String, Nothing}
↓
Check execution mode:
• config.tool_execution (sequential/parallel)
• Any tool.execution_mode == EXECUTION_SEQUENTIAL?
↓
┌─────────────────────────────────────────────────────────────────┐
│ Sequential Mode (or has_sequential_tool) │
│ For each tool_call in tool_calls: │
│ prepareToolCall() → PreparedToolCall │
│ executePreparedToolCall() → ExecutedToolCallOutcome │
│ finalizeExecutedToolCall() → FinalizedToolCallOutcome │
│ createToolResultMessage() → ToolResultMessage │
│ (wait for completion before next tool) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Parallel Mode │
│ For each tool_call in tool_calls: │
│ prepareToolCall() → (PreparedToolCall | ImmediateOutcome) │
│ If prepared: create closure │
│ If immediate: execute and add to finalized_calls │
│ │
│ For each entry in finalized_calls: │
│ If closure: execute closure │
│ If finalized: use as-is │
└─────────────────────────────────────────────────────────────────┘
↓
ExecutedToolCallBatch
- messages::Vector{ToolResultMessage}
- terminate::Bool (true if all tools have terminate=true)
executeToolCallsSequential()
function executeToolCallsSequential(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
Flow (for each tool call):
- Emit ToolExecutionStartEvent
- prepareToolCall() → PreparedToolCall or ImmediateToolCallOutcome
- If prepared: executePreparedToolCall()
- finalizeExecutedToolCall()
- Emit ToolExecutionEndEvent
- Emit ToolResultMessage
- Check if signal.aborted → break
executeToolCallsParallel()
function executeToolCallsParallel(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
Flow:
- For each tool call:
- If immediate: execute and add to finalized_calls
- If prepared: create closure, add to finalized_calls
- For each entry in finalized_calls:
- If closure: execute closure
- If finalized: use as-is
- Collect all tool results
- Return batch
prepareToolCall()
function prepareToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_call::ToolCall,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::Union{PreparedToolCall, ImmediateToolCallOutcome}
Data Flow:
Input: tool_call::ToolCall
- id::String
- name::String
- arguments::Dict{String, Any}
↓
findfirst(t -> t.name == tool_call.name, current_context.tools)
↓
If tool is nothing:
→ ImmediateToolCallOutcome("immediate", error_result, is_error=true)
↓
If tool exists:
[before_tool_call hook] (optional)
Input: BeforeToolCallContext(assistant_message, tool_call, args, context)
Output: BeforeToolCallResult (block, reason) or nothing
If block=true → ImmediateToolCallOutcome(error)
↓
prepareToolCallArguments(tool, tool_call)
Input: tool_call.arguments::Dict{String, Any}
Output: prepared_arguments::Any
(Optional: transform arguments before validation)
↓
validateToolArguments(tool, prepared_tool_call)
Input: prepared_tool_call.arguments
Output: validated_args::Any
(Optional: JSON schema validation)
↓
Return: PreparedToolCall("prepared", tool_call, tool, validated_args)
- kind: "prepared"
- tool_call: ToolCall (original)
- tool: AgentTool
- args: validated arguments
executePreparedToolCall()
function executePreparedToolCall(
prepared::PreparedToolCall,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallOutcome
Data Flow:
Input: prepared::PreparedToolCall
- tool_call::ToolCall
- tool::AgentTool
- args::Any (validated)
↓
tool.execute(tool_call.id, args, signal, on_update)
Input: tool_call_id::String
args::Any
signal::Union{Any, Nothing}
on_update::Function (partial_result → void)
Output: AgentToolResultMutable (defined in agent_loop.jl)
- content::Vector{MessageContent}
- details::Any
- usage::Union{Usage, Nothing}
- added_tool_names::Union{Vector{String}, Nothing}
- terminate::Union{Bool, Nothing}
↓
Note: tool.execute signature is
(tool_call_id, args, signal, on_update, context)
where context is the tool's captured context closure parameter
↓
Collect update events from on_update callbacks
↓
Return: ExecutedToolCallOutcome(result, is_error=false)
- result::AgentToolResultMutable
finalizeExecutedToolCall()
function finalizeExecutedToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
prepared::PreparedToolCall,
executed::ExecutedToolCallOutcome,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::FinalizedToolCallOutcome
Data Flow:
Input: executed::ExecutedToolCallOutcome
- result::AgentToolResultMutable
- is_error::Bool
↓
[after_tool_call hook] (optional)
Input: AfterToolCallContext(
assistant_message,
tool_call,
args,
result,
is_error,
context
)
Output: AfterToolCallResult (optional patches)
- content::Union{Vector{MessageContent}, Nothing}
- details::Union{Any, Nothing}
- is_error::Union{Bool, Nothing}
- usage::Union{Usage, Nothing}
- terminate::Union{Bool, Nothing}
↓
Apply patches to result (if patches not nothing, replace non-nothing fields)
result = AgentToolResultMutable(
patches.content != nothing ? patches.content : result.content,
patches.details != nothing ? patches.details : result.details,
patches.usage != nothing ? patches.usage : result.usage,
result.added_tool_names, # not patched
patches.terminate != nothing ? patches.terminate : result.terminate,
)
is_error = patches.is_error != nothing ? patches.is_error : is_error
↓
Return: FinalizedToolCallOutcome
- tool_call::ToolCall (original)
- result::AgentToolResultMutable (final)
- is_error::Bool
createToolResultMessage()
function createToolResultMessage(
finalized::FinalizedToolCallOutcome,
)::ToolResultMessage
Data Flow:
Input: finalized::FinalizedToolCallOutcome
- tool_call::ToolCall
- result::AgentToolResultMutable
- content::Vector{MessageContent}
- details::Any
- usage::Union{Usage, Nothing}
- added_tool_names::Union{Vector{String}, Nothing}
- is_error::Bool
↓
Build ToolResultMessage:
• role: "toolResult"
• tool_call_id: tool_call.id
• tool_name: tool_call.name
• content: result.content
• details: result.details
• usage: result.usage
• added_tool_names: result.added_tool_names
• is_error: is_error
• timestamp: Int64(Dates.now(Dates.UTC).datetime)
↓
Output: ToolResultMessage
- role::String ("toolResult")
- tool_call_id::String
- tool_name::String
- content::Vector{MessageContent}
- details::Any
- usage::Union{Usage, Nothing}
- added_tool_names::Union{Vector{String}, Nothing}
- is_error::Bool
- timestamp::Timestamp (Int64)
Execution Modes
Sequential Execution
┌─────────────────────────────────────────────────────────────────────────┐
│ Sequential Execution Flow (Strict Order) │
└─────────────────────────────────────────────────────────────────────────┘
TC1 TC2 TC3
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ prepareToolCall()│───▶│ prepareToolCall()│───▶│ prepareToolCall()│
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ execute() │ │ execute() │ │ execute() │
│ (blocking) │ │ (blocking) │ │ (blocking) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ finalize() │ │ finalize() │ │ finalize() │
│ + emit events │ │ + emit events │ │ + emit events │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
▼ ▼ ▼
Result1 Result2 Result3
│ │ │
└───────────────────────┴───────────────────────┘
│
▼
┌────────────────────────┐
│ ExecutedToolCallBatch │
│ (Result1, Result2, │
│ Result3, terminate) │
└────────────────────────┘
Parallel Execution
┌─────────────────────────────────────────────────────────────────────────┐
│ Parallel Execution Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌──────┐
│ TC1 │ ──► prepareToolCall() ──► create closure ──► ┐
└──────┘ │
│
┌──────┐ │
│ TC2 │ ──► prepareToolCall() ──► create closure ──► ├─► All closures queued
└──────┘ │
│
┌──────┐ │
│ TC3 │ ──► prepareToolCall() ──► create closure ──► ┘
└──────┘
│
▼
┌───────────────────────┐
│ for closure in closures│
│ execute_closure() │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ Collect all results │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ createToolResult() │
└───────────────────────┘
Helper Types
ExecutedToolCallBatch
struct ExecutedToolCallBatch
messages::Vector{ToolResultMessage}
terminate::Bool
end
messages: All tool result messagesterminate: If true, stop agent after this batch
PrepareNextTurnContext
struct PrepareNextTurnContext
message::AssistantMessage
tool_results::Vector{ToolResultMessage}
context::AgentContext
new_messages::Vector{AgentMessage}
end
Used by prepare_next_turn hook to decide next steps.
Before/After Tool Call Contexts
struct BeforeToolCallContext
assistant_message::AssistantMessage
tool_call::ToolCall
args::Any
context::AgentContext
end
struct BeforeToolCallResult
block::Union{Bool, Nothing}
reason::Union{String, Nothing}
end
struct AfterToolCallContext
assistant_message::AssistantMessage
tool_call::ToolCall
args::Any
result::AgentToolResult
is_error::Bool
context::AgentContext
end
struct AfterToolCallResult
content::Union{Vector{MessageContent}, Nothing}
details::Union{Any, Nothing}
is_error::Union{Bool, Nothing}
usage::Union{Usage, Nothing}
terminate::Union{Bool, Nothing}
end
Event Emission Timeline
AgentStartEvent
│
├─ TurnStartEvent (turn 1)
│ │
│ ├─ MessageStartEvent (user prompt)
│ ├─ MessageEndEvent (user prompt)
│ │
│ ├─ MessageStartEvent (assistant)
│ ├─ MessageUpdateEvent (text delta)
│ ├─ MessageUpdateEvent (tool call delta)
│ ├─ MessageEndEvent (assistant)
│ │
│ ├─ ToolExecutionStartEvent (tc1)
│ ├─ ToolExecutionEndEvent (tc1)
│ │
│ ├─ ToolExecutionStartEvent (tc2)
│ ├─ ToolExecutionEndEvent (tc2)
│ │
│ └─ TurnEndEvent (assistant, tool_results)
│
├─ TurnStartEvent (turn 2 - if needed)
│ │
│ ├─ MessageStartEvent (steering/follow-up)
│ ├─ MessageEndEvent (steering/follow-up)
│ │
│ ├─ MessageStartEvent (assistant)
│ ├─ MessageUpdateEvent (text)
│ ├─ MessageEndEvent (assistant)
│ │
│ └─ TurnEndEvent (assistant, [])
│
└─ AgentEndEvent (final messages)
Key Concepts
1. Message Transformation Pipeline
Vector{AgentMessage} (internal conversation history)
│
├─ transform_context() (optional hook)
│ Input: Vector{AgentMessage}
│ Output: Vector{AgentMessage} (transformed)
│
└─ convert_to_llm()
│
│ Type mapping (single dispatch):
│ • UserMessage → UserMessage (pass-through)
│ • AssistantMessage → AssistantMessage (pass-through)
│ • ToolResultMessage → ToolResultMessage (pass-through)
│ • BashExecutionMessage → UserMessage (text conversion)
│ • CompactionSummaryMessage → UserMessage (text wrapped)
│ • BranchSummaryMessage → UserMessage (text wrapped)
│
▼
Vector{Message} (for LLM API)
2. Tool Call Lifecycle (with Data Transformations)
ToolCall (in AssistantMessage.content)
│
├─ before_tool_call hook (optional)
│ Input: BeforeToolCallContext(
│ assistant_message::AssistantMessage,
│ tool_call::ToolCall,
│ args::Dict{String, Any},
│ context::AgentContext
│ )
│ Output: BeforeToolCallResult (block, reason) or nothing
│
├─ prepareToolCall()
│ Input: tool_call::ToolCall
│ Output: Union{PreparedToolCall, ImmediateToolCallOutcome}
│ • PreparedToolCall (kind, tool_call, tool, args)
│ • ImmediateToolCallOutcome (immediate, result, is_error)
│
├─ executePreparedToolCall() (if prepared)
│ Input: PreparedToolCall
│ Output: ExecutedToolCallOutcome
│ tool.execute() returns AgentToolResultMutable
│ • content::Vector{MessageContent}
│ • details::Any
│ • usage::Union{Usage, Nothing}
│ • terminate::Union{Bool, Nothing}
│
├─ finalizeExecutedToolCall()
│ Input: ExecutedToolCallOutcome
│ Output: FinalizedToolCallOutcome
│ • tool_call::ToolCall
│ • result::AgentToolResultMutable
│ • is_error::Bool
│
└─ createToolResultMessage()
Input: FinalizedToolCallOutcome
Output: ToolResultMessage
• role: "toolResult"
• tool_call_id, tool_name
• content::Vector{MessageContent}
• details, usage, added_tool_names
• is_error, timestamp
3. Turn Termination
# The outer while-true loop exits when:
# 1. No pending messages AND no tool results to reprocess (inner loop ends)
# 2. No follow-up messages to queue
# 3. should_stop_after_turn() returns true (checked after each tool-call batch)
# Termination conditions:
# - message.stop_reason in ("error", "aborted") → immediate return
# - should_stop_after_turn() hook returns true → return AgentEndEvent
# - tool result batch has terminate=true → has_more_tool_calls = false, exit inner loop
# - No pending messages, no follow-up messages → break outer loop
Best Practices
- Use sequential execution for tools that modify shared state
- Use parallel execution for independent tool calls (better performance)
- Implement prepare_next_turn for dynamic model/thinking level changes
- Use before_tool_call for logging or blocking sensitive operations
- Use after_tool_call for modifying results or collecting metrics
Complete Example
using AgentCore
# Create config
config = AgentLoopConfig(
model = my_model,
reasoning = THINKING_MEDIUM,
session_id = nothing,
on_payload = nothing,
on_response = nothing,
transport = "auto",
thinking_budgets = nothing,
max_retry_delay_ms = nothing,
tool_execution = EXECUTION_PARALLEL,
before_tool_call = myBeforeToolCallHook,
after_tool_call = myAfterToolCallHook,
prepare_next_turn = myPrepareNextTurnHook,
convert_to_llm = myConvertToLlm,
transform_context = myTransformContext,
get_api_key = myGetApiKey,
get_steering_messages = myGetSteeringMessages,
get_follow_up_messages = myGetFollowUpMessages,
should_stop_after_turn = myShouldStopHook, # Default: always return false
max_tokens = nothing,
temperature = nothing,
cache_retention = nothing,
headers = nothing,
metadata = nothing,
signal = nothing,
api_key = nothing,
)
# Start agent loop
stream = agentLoop(
[UserMessage("user", [TextContent("Hello")], timestamp)],
AgentContext(system_prompt, messages, tools),
config,
nothing,
stream_fn,
)
# Consume events
final_messages = []
for event in stream
if event isa MessageEndEvent
push!(final_messages, event.message)
end
end
# Or use event sink
messages = []
emit(event) = push!(messages, event)
messages = runAgentLoop(
[UserMessage(...)],
context,
config,
emit,
nothing,
stream_fn,
)
This documentation provides a comprehensive understanding of the AgentLoop component, including its architecture, main functions, execution modes, and best practices for building AI agents with AgentCore.jl.