27 KiB
27 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
┌─────────────────────────────────────────────────────────────────────────────┐
│ Internal Flow │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. runAgentLoop() ── Entry point for new conversation │
│ - 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 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ while true: │ │
│ │ 1. Get steering/follow-up messages (if any) │ │
│ │ 2. Emit messages as UserMessage │ │
│ │ 3. streamAssistantResponse() │ │
│ │ 4. Execute tool calls (sequential or parallel) │ │
│ │ 5. Emit TurnEndEvent │ │
│ │ 6. prepare_next_turn (optional) │ │
│ │ 7. should_stop_after_turn? (check termination) │ │
│ │ 8. Loop continues if not terminated │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. streamAssistantResponse() ── LLM interaction │
│ - transform_context (optional) │
│ - convert_to_llm (transform to Message[]) │
│ - Call stream_fn (LLM API) │
│ - Stream response deltas │
│ - Emit MessageStart/Update/End events │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. executeToolCalls() ── Tool execution │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ if EXECUTION_SEQUENTIAL || has_sequential_tool: │ │
│ │ executeToolCallsSequential() │ │
│ │ else: │ │
│ │ executeToolCallsParallel() │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. AgentEndEvent ── Final event with all messages │
└─────────────────────────────────────────────────────────────────────────────┘
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}
end
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
- Append prompts to context.messages
- Emit AgentStartEvent
- For each prompt: emit MessageStartEvent, MessageEndEvent
- Call runLoop()
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:
current_context = initial_context
config = initial_config
first_turn = true
pending_messages = get_steering_messages()
while true
# Process steering/follow-up messages
while !isempty(pending_messages)
if !first_turn
emit(TurnStartEvent())
else
first_turn = false
end
# Emit 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 = []
end
# Stream assistant response
message = streamAssistantResponse(
current_context,
config,
signal,
emit,
stream_function,
)
push!(new_messages, message)
# Check for errors
if message.stop_reason in ("error", "aborted")
emit(TurnEndEvent(message, []))
emit(AgentEndEvent(new_messages))
return
end
# Execute tool calls
tool_calls = filter(c -> c isa ToolCall, message.content)
tool_results = []
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))
# Prepare next turn (optional)
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
config = AgentLoopConfig(
model = next_turn_snapshot.model,
reasoning = next_turn_snapshot.thinking_level,
# ... other config fields
)
end
# Check if should stop
if should_stop_after_turn(config, next_turn_context)
emit(AgentEndEvent(new_messages))
return
end
# Get next pending messages
pending_messages = get_steering_messages()
# Check follow-up messages
follow_up_messages = get_follow_up_messages()
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
Flow:
- Get messages from context
- Apply transform_context (optional)
- Convert to LLM messages with convert_to_llm
- Create Context object
- Resolve API key
- Call stream_fn with model, context, and config
- Stream events:
- "start" → MessageStartEvent
- "text_start", "text_delta", "text_end" → MessageUpdateEvent
- "done", "error" → MessageEndEvent
executeToolCalls()
function executeToolCalls(
current_context::AgentContext,
assistant_message::AssistantMessage,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
Logic:
tool_calls = filter(c -> c isa ToolCall, assistant_message.content)
# Check if any tool requires sequential execution
has_sequential = any(tc -> begin
tool = findfirst(t -> t.name == tc.name, current_context.tools)
!isnothing(tool) && tool.execution_mode == EXECUTION_SEQUENTIAL
end, tool_calls)
# Determine execution mode
if config.tool_execution == EXECUTION_SEQUENTIAL || has_sequential
executeToolCallsSequential(...)
else
executeToolCallsParallel(...)
end
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}
Flow:
- Find tool by name
- If not found → ImmediateToolCallOutcome (error)
- before_tool_call hook (optional)
- prepareToolCallArguments() (optional)
- validateToolArguments()
- Return PreparedToolCall
executePreparedToolCall()
function executePreparedToolCall(
prepared::PreparedToolCall,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallOutcome
Flow:
- Call tool.execute(id, args, signal, on_update)
- Collect update events (if any)
- Wait for all update events
- Return ExecutedToolCallOutcome(result)
finalizeExecutedToolCall()
function finalizeExecutedToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
prepared::PreparedToolCall,
executed::ExecutedToolCallOutcome,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::FinalizedToolCallOutcome
Flow:
- after_tool_call hook (optional)
- Return FinalizedToolCallOutcome
createToolResultMessage()
function createToolResultMessage(
finalized::FinalizedToolCallOutcome,
)::ToolResultMessage
Creates:
ToolResultMessage(
"toolResult",
finalized.tool_call.id,
finalized.tool_call.name,
finalized.result.content,
finalized.result.details,
finalized.result.usage,
finalized.result.added_tool_names,
finalized.is_error,
timestamp,
)
Execution Modes
Sequential Execution
┌─────────────────────────────────────────────────────────────────────────┐
│ Sequential Execution Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌──────┐
│ TC1 │ ──► prepareToolCall()
└──────┘ │
▼
┌──────────────┐
│ execute() │ ──► Wait for completion
└──────────────┘ │
│ ▼
├───────────── createToolResultMessage()
│ │
▼ ▼
┌──────────────┐ ┌──────────┐
│ TC2 │ ──► │ │ Result1 │
└──────┘ └──────────┘
│
▼
┌──────────────┐
│ execute() │
└──────────────┘
│
▼
┌──────────────┐
│ TC3 │ ──► │
└──────┘ │
│ ▼
├───── createToolResultMessage()
│ │
▼ ▼
┌──────────────┐ ┌──────────┐
│ execute() │ │ │ Result2 │
└──────────────┘ └──────────┘
│
▼
┌──────────┐
│ Result3 │
└──────────┘
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
AgentMessage[] (internal)
│
│ transform_context()
▼
AgentMessage[] (transformed)
│
│ convert_to_llm()
▼
Message[] (LLM API)
2. Tool Call Lifecycle
ToolCall (in assistant message)
│
├─ before_tool_call (hook)
│
├─ prepareToolCall()
│ ├─ validate arguments
│ └─ prepare arguments (optional)
│
├─ execute()
│ ├─ Immediate: return result
│ └─ Prepared: async execution
│
├─ after_tool_call (hook)
│
└─ createToolResultMessage()
3. Turn Termination
# Turn ends when:
# 1. No more pending messages
# 2. No more tool calls to execute
# 3. should_stop_after_turn() returns true
# Reasons to stop:
# - Max turns reached
# - Tool returned terminate=true
# - Error or abort
# - Steering/follow-up queues empty
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,
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,
)
# 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.