20 KiB
Agent Loop Tracing
This document traces the agent loop through two example interactions.
Architecture Overview
Agent (src/agent.jl)
|
v
AgentLoop (src/agent_loop.jl) -- runLoop() is the core while(true) loop
|
v
StreamFn (src/stream_fn.jl) -- LLM streaming function (user-provided)
|
v
Tools (src/tools/*.jl) -- bash, read, write, edit
Key types:
Agent(agent.jl:85) -- high-level wrapper with state, queues, listenersagentLoop()(agent_loop.jl:23) -- entry point, spawns thread, returnsEventStreamrunLoop()(agent_loop.jl:169) -- the corewhile(true)loopstreamAssistantResponse()(agent_loop.jl:361) -- calls LLM, streams events, returnsAssistantMessageexecuteToolCalls()(agent_loop.jl:476) -- runs tool calls (sequential or parallel)AgentContext(types.jl:186) -- system_prompt + messages + toolsAgentLoopConfig-- model, thinking_level, callbacks for steering/follow-up/tool execution
Scenario 1: User asks "what is the content of text.txt file", agent responds
Step 1: User invokes prompt(agent, "what is the content of text.txt file")
File: agent.jl:284-292
prompt(agent, "what is the content of text.txt file")
-> normalizePromptInput(agent, "what is the content of text.txt file", [])
-> [UserMessage("user", [TextContent("what is the content of text.txt file")], timestamp)]
-> runPromptMessages(agent, messages)
The string is normalized into a single UserMessage.
Step 2: runPromptMessages calls agentLoop()
File: agent.jl:310-313 (TODO stub, but conceptually):
runPromptMessages(agent, messages)
-> AgentLoop.agentLoop(
prompts = [UserMessage(...)],
context = createContextSnapshot(agent), # AgentContext with system_prompt, messages, tools
config = createLoopConfig(agent),
signal = nothing,
stream_fn = agent.stream_function,
)
Step 3: agentLoop() spawns thread and calls runAgentLoop()
File: agent_loop.jl:23-45
agentLoop(prompts, context, config, signal, stream_fn)
-> createAgentStream() # creates EventStream
-> Threads.@spawn begin
runAgentLoop(prompts, context, config, emit, signal, stream_fn)
end(stream, messages)
end
-> return stream
Step 4: runAgentLoop() initializes and enters runLoop()
File: agent_loop.jl:85-116
runAgentLoop(prompts, context, config, emit, signal, stream_fn)
-> new_messages = copy(prompts) # [UserMessage(...)]
-> current_context = AgentContext(context.system_prompt, vcat(context.messages, copy(prompts)), context.tools)
-> emit(AgentStartEvent())
-> emit(TurnStartEvent())
-> for prompt in prompts: emit(MessageStartEvent(prompt)); emit(MessageEndEvent(prompt)) end
-> runLoop(current_context, new_messages, config, signal, emit, stream_fn)
Events emitted so far:
AgentStartEventTurnStartEventMessageStartEvent(UserMessage)MessageEndEvent(UserMessage)
Step 5: runLoop() -- first iteration
File: agent_loop.jl:169-310
runLoop(initial_context, new_messages, initial_config, signal, emit, stream_function)
-> current_context = initial_context
-> first_turn = true
-> pending_messages = getSteeringMessages(config) # may be empty [] by default (agent_loop.jl:180-182)
-> while true:
has_more_tool_calls = true # reset each outer iteration
# Inner loop: has_more_tool_calls || !isempty(pending_messages)
while has_more_tool_calls || !isempty(pending_messages)
first_turn = false # TurnStartEvent NOT emitted (already done)
# no pending_messages
# === STEP 5a: Call LLM ===
message = streamAssistantResponse(current_context, config, signal, emit, stream_function)
Step 5a: streamAssistantResponse() -- LLM call
File: agent_loop.jl:361-435
streamAssistantResponse(context, config, signal, emit, stream_function)
-> messages = context.messages # [UserMessage(...)]
-> llm_messages = config.convert_to_llm(messages) # filter to user/assistant/toolResult roles
-> llm_context = Context(context.system_prompt, llm_messages, context.tools)
-> response = stream_function(config.model, llm_context, merged_config)
The stream_function (user-provided via StreamFn) calls the LLM API. It yields events:
StartEvent(partial=AssistantMessage(role="assistant", content=[]))
-> push!(context.messages, partial_message)
-> emit(MessageStartEvent(partial_message))
TextDeltaEvent(partial=AssistantMessage with ToolCall for "read")
-> context.messages[end] = partial_message
-> emit(MessageUpdateEvent(partial_message, event))
TextDeltaEvent(...) -- streaming continues
toolcall_start/toolcall_delta/toolcall_end -- tool call detected: read(file="text.txt") (agent_loop.jl:405)
DoneEvent(reason="tool_calls", ...)
-> final_message = AssistantMessage(role="assistant", content=[ToolCall(...)])
-> context.messages[end] = final_message
-> emit(MessageEndEvent(final_message))
-> return final_message
Back in runLoop:
message=AssistantMessagewithstop_reason = "tool_calls"push!(new_messages, message)
Step 5b: Tool call detection
File: agent_loop.jl:219-244
tool_calls = filter(c -> c isa ToolCall, message.content)
# tool_calls = [ToolCall(type="tool_call", id="call_1", name="read", arguments={file="text.txt"}, ...)]
tool_results = []
has_more_tool_calls = false # set to true only if tool calls execute and don't terminate (agent_loop.jl:225)
if !isempty(tool_calls)
executed_tool_batch = executeToolCalls(
current_context, message, config, signal, emit,
)
append!(tool_results, executed_tool_batch.messages)
has_more_tool_calls = !executed_tool_batch.terminate
Step 5c: executeToolCalls() -- sequential or parallel
File: agent_loop.jl:476-514
Since there's only one tool call and no sequential mode forced, it uses executeToolCallsParallel() (or sequential -- both paths converge for a single tool call).
executeToolCalls(context, assistant_message, config, signal, emit)
-> tool_calls extracted from assistant_message.content (agent_loop.jl:483-486)
-> tool = findfirst(t -> t.name == "read", context.tools)
-> preparation = prepareToolCall(...)
-> validated_args = {file="text.txt"}
-> return PreparedToolCall("prepared", tool_call, tool, validated_args)
executed = executePreparedToolCall(preparation, signal, emit)
-> result = prepared.tool.execute("call_1", {file="text.txt"}, signal, on_update, context)
# This invokes the read tool's execute function (src/tools/read.jl:26)
# TODO: in the current code, it returns a placeholder
-> return ExecutedToolCallOutcome(result, false)
finalized = finalizeExecutedToolCall(...)
# Creates FinalizedToolCallOutcome
emitToolExecutionEnd(finalized, emit)
# emits ToolExecutionEndEvent
tool_result_message = createToolResultMessage(finalized)
# creates ToolResultMessage(role="toolResult", tool_call_id="call_1", tool_name="read", content=[TextContent(...)])
emitToolResultMessage(tool_result_message, emit)
# emits MessageStartEvent(tool_result_message), MessageEndEvent(tool_result_message)
Events emitted during tool execution:
5. MessageStartEvent(assistant_message) (from LLM)
6. MessageEndEvent(assistant_message) (from LLM done)
7. ToolExecutionStartEvent
8. ToolExecutionEndEvent
9. MessageStartEvent(tool_result_message)
10. MessageEndEvent(tool_result_message)
Step 5d: Back in inner loop
File: agent_loop.jl:240-294
push!(current_context.messages, tool_result_message)
push!(new_messages, tool_result_message)
emit(TurnEndEvent(message, tool_results))
next_turn_snapshot = prepare_next_turn(config, PrepareNextTurnContext(...))
# Returns nothing by default (no custom prepare_next_turn)
if !isnothing(next_turn_snapshot) ... end # skipped
if should_stop_after_turn(config, ...) ... end # returns false by default
pending_messages = get_steering_messages(config) # returns []
# inner while continues: has_more_tool_calls = true, pending_messages = []
# === SECOND LLM CALL ===
message = streamAssistantResponse(current_context, config, signal, emit, stream_function)
# context.messages now = [UserMessage(...), AssistantMessage(read tool call), ToolResultMessage(file contents)]
Step 5e: Second LLM call -- agent responds
File: agent_loop.jl:361-435
streamAssistantResponse(context, config, signal, emit, stream_function)
-> llm_messages = [UserMessage(...), AssistantMessage(...), ToolResultMessage(...)]
-> response = stream_function(model, Context(system_prompt, llm_messages, tools), config)
The LLM receives the user's question + its own tool call + the file contents as a tool result. It generates a text response.
Events:
StartEvent -> MessageStartEvent
TextDeltaEvent -> MessageUpdateEvent (text streaming)
...
DoneEvent(reason="end_turn") -> MessageEndEvent
Step 5f: No more tool calls -- loop exits
File: agent_loop.jl:219-244
tool_calls = filter(c -> c isa ToolCall, message.content)
# tool_calls = [] (no tool calls in the final response)
has_more_tool_calls = false # stays false
emit(TurnEndEvent(message, ToolResultMessage[]))
next_turn_snapshot = prepare_next_turn(...) # nothing
should_stop_after_turn(...) # false
pending_messages = get_steering_messages(...) # []
# inner while: has_more_tool_calls=false, pending_messages=[] -> exits inner loop
follow_up_messages = get_follow_up_messages(...) # []
# exits outer while
emit(AgentEndEvent(new_messages))
Events emitted at end:
11. MessageStartEvent(assistant_response)
12. MessageUpdateEvent(...) (text deltas)
13. MessageEndEvent(assistant_response)
14. TurnEndEvent(response, [])
15. AgentEndEvent([UserMessage, AssistantMessage, ToolResultMessage, AssistantResponse])
Summary of Scenario 1 event sequence:
| # | Event | Source |
|---|---|---|
| 1 | AgentStartEvent |
runAgentLoop() |
| 2 | TurnStartEvent |
runAgentLoop() |
| 3 | MessageStartEvent(UserMessage) |
runAgentLoop() |
| 4 | MessageEndEvent(UserMessage) |
runAgentLoop() |
| 5 | MessageStartEvent(AssistantMessage) |
streamAssistantResponse() |
| 6 | MessageUpdateEvent(AssistantMessage) |
streamAssistantResponse() (streaming) |
| 7 | MessageEndEvent(AssistantMessage) |
streamAssistantResponse() |
| 8 | ToolExecutionStartEvent |
executeToolCalls() |
| 9 | ToolExecutionEndEvent |
executeToolCalls() |
| 10 | MessageStartEvent(ToolResultMessage) |
emitToolResultMessage() |
| 11 | MessageEndEvent(ToolResultMessage) |
emitToolResultMessage() |
| 12 | TurnEndEvent(AssistantMessage, [tool_results]) |
runLoop() |
| 13 | MessageStartEvent(AssistantMessage) |
streamAssistantResponse() (2nd call) |
| 14 | MessageUpdateEvent(AssistantMessage) |
streamAssistantResponse() (text streaming) |
| 15 | MessageEndEvent(AssistantMessage) |
streamAssistantResponse() |
| 16 | TurnEndEvent(AssistantResponse, []) |
runLoop() |
| 17 | AgentEndEvent([all messages]) |
runLoop() |
Scenario 2: User asks "copy text.txt to text.md", agent responds
Step 1-4: Same as Scenario 1
User invokes prompt(agent, "copy text.txt to text.md"), which flows through agentLoop() -> runAgentLoop() -> runLoop().
Events 1-4 are identical (AgentStart, TurnStart, UserMessage start/end).
Step 5: First LLM call -- agent decides to use tools
The LLM receives:
System: <system_prompt>
User: "copy text.txt to text.md"
The LLM decides it needs to:
- Read text.txt (to get its contents), then
- Write those contents to text.md
The LLM may emit a single AssistantMessage with two ToolCall objects:
AssistantMessage(content=[
ToolCall(id="call_1", name="read", arguments={file="text.txt"}),
ToolCall(id="call_2", name="write", arguments={file="text.md", content="...contents of text.txt..."}),
])
Or it may emit one tool call at a time (sequential), which is also supported.
Step 5b: Tool execution
File: agent_loop.jl:219-244
tool_calls = filter(c -> c isa ToolCall, message.content)
# tool_calls = [ToolCall(read), ToolCall(write)]
executed_tool_batch = executeToolCalls(context, message, config, signal, emit)
If tool_execution == EXECUTION_PARALLEL (default) and no tool forces sequential mode:
File: agent_loop.jl:568-633 (executeToolCallsParallel)
executeToolCallsParallel(...)
-> for tool_call in tool_calls:
# call_1: read
emit(ToolExecutionStartEvent("call_1", "read", {file="text.txt"}))
preparation = prepareToolCall(...) # validated
push!(finalized_calls, () -> executed_read()) # closure for deferred execution
# call_2: write
emit(ToolExecutionStartEvent("call_2", "write", {file="text.md", content="..."}))
preparation = prepareToolCall(...)
push!(finalized_calls, () -> executed_write()) # closure
# Execute in order
ordered_finalized_calls = map(entry -> entry(), finalized_calls)
for finalized in ordered_finalized_calls:
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
Events for parallel execution:
ToolExecutionStartEvent(call_1, "read", ...)
ToolExecutionEndEvent(call_1, "read", ...)
ToolExecutionStartEvent(call_2, "write", ...)
ToolExecutionEndEvent(call_2, "write", ...)
MessageStartEvent(ToolResultMessage[read result])
MessageEndEvent(ToolResultMessage[read result])
MessageStartEvent(ToolResultMessage[write result])
MessageEndEvent(ToolResultMessage[write result])
If tool_execution == EXECUTION_SEQUENTIAL or any tool is marked sequential:
File: agent_loop.jl:520-562 (executeToolCallsSequential)
for tool_call in tool_calls:
emit(ToolExecutionStartEvent(...))
# execute, finalize, emit result
# THEN proceed to next
Events for sequential execution:
ToolExecutionStartEvent(call_1, "read", ...)
ToolExecutionEndEvent(call_1, "read", ...)
MessageStartEvent(ToolResultMessage[read result])
MessageEndEvent(ToolResultMessage[read result])
ToolExecutionStartEvent(call_2, "write", ...)
ToolExecutionEndEvent(call_2, "write", ...)
MessageStartEvent(ToolResultMessage[write result])
MessageEndEvent(ToolResultMessage[write result])
Step 5d: Second LLM call
has_more_tool_calls = !executed_tool_batch.terminate # false (unless terminate=true)
# inner loop continues since pending_messages is still empty
# Actually: has_more_tool_calls = false, pending_messages = []
# -> exits inner loop
# follow_up_messages = []
# -> exits outer loop
emit(TurnEndEvent(message, tool_results))
Wait -- this depends on whether the LLM's first response included only tool calls (no text answer). If the LLM only returned tool calls and the tool results were processed, the agent may need a third LLM call to generate the final user-facing response.
Revised flow for two tool calls:
After tool results are added to context:
context.messages = [
UserMessage("copy text.txt to text.md"),
AssistantMessage([ToolCall(read), ToolCall(write)]),
ToolResultMessage(read result),
ToolResultMessage(write result),
]
The agent needs another LLM call to generate a response. Let's trace it:
Step 5e: Second LLM call -- final response
message = streamAssistantResponse(current_context, ...)
LLM receives:
System: <system_prompt>
User: "copy text.txt to text.md"
Assistant: [ToolCall(read), ToolCall(write)]
ToolResult: (contents of text.txt)
ToolResult: (write confirmation)
LLM generates: "I've copied text.txt to text.md."
Events:
MessageStartEvent(AssistantMessage)
MessageUpdateEvent(... text deltas ...)
MessageEndEvent(AssistantMessage)
Step 5f: No tool calls, loop exits
tool_calls = [] # no ToolCalls in response
has_more_tool_calls = false
emit(TurnEndEvent(message, []))
pending_messages = []
follow_up_messages = []
emit(AgentEndEvent(new_messages))
Summary of Scenario 2 event sequence (parallel tool execution):
| # | Event | Source |
|---|---|---|
| 1 | AgentStartEvent |
runAgentLoop() |
| 2 | TurnStartEvent |
runAgentLoop() |
| 3 | MessageStartEvent(UserMessage) |
runAgentLoop() |
| 4 | MessageEndEvent(UserMessage) |
runAgentLoop() |
| 5 | MessageStartEvent(AssistantMessage) |
streamAssistantResponse() (1st LLM call) |
| 6 | MessageEndEvent(AssistantMessage) |
streamAssistantResponse() |
| 7 | ToolExecutionStartEvent(call_1, "read") |
executeToolCallsParallel() |
| 8 | ToolExecutionEndEvent(call_1, "read") |
executeToolCallsParallel() |
| 9 | ToolExecutionStartEvent(call_2, "write") |
executeToolCallsParallel() |
| 10 | ToolExecutionEndEvent(call_2, "write") |
executeToolCallsParallel() |
| 11 | MessageStartEvent(ToolResultMessage[read]) |
emitToolResultMessage() |
| 12 | MessageEndEvent(ToolResultMessage[read]) |
emitToolResultMessage() |
| 13 | MessageStartEvent(ToolResultMessage[write]) |
emitToolResultMessage() |
| 14 | MessageEndEvent(ToolResultMessage[write]) |
emitToolResultMessage() |
| 15 | TurnEndEvent(AssistantToolCalls, [read_result, write_result]) |
runLoop() |
| 16 | MessageStartEvent(AssistantMessage) |
streamAssistantResponse() (2nd LLM call) |
| 17 | MessageUpdateEvent(AssistantMessage) |
streamAssistantResponse() (text streaming) |
| 18 | MessageEndEvent(AssistantMessage) |
streamAssistantResponse() |
| 19 | TurnEndEvent(AssistantResponse, []) |
runLoop() |
| 20 | AgentEndEvent([all messages]) |
runLoop() |
Key Design Patterns
1. Event Stream Architecture
Events flow through emit::AgentEventSink (a function) into an EventStream. Consumers subscribe to the stream and receive events as they occur. The stream terminates when AgentEndEvent is emitted.
2. Context Accumulation
AgentContext.messages grows across turns:
[UserMessage, AssistantMessage, ToolResultMessage, AssistantMessage, ToolResultMessage, ...]
3. LLM Conversion
Before each LLM call, config.convert_to_llm() filters the agent messages to only include user/assistant/toolResult roles (src/agent.jl:18-23):
filter(m -> m.role in ("user", "assistant", "toolResult"), messages)
4. Tool Execution Modes
EXECUTION_PARALLEL(default): tool calls are prepared as closures and executed in sequence after all are preparedEXECUTION_SEQUENTIAL: each tool is prepared, executed, and finalized before the next begins
5. Turn Continuation
The inner while has_more_tool_calls loop handles:
- Multiple tool calls from a single assistant response
- Pending steering/follow-up messages injected between turns
The outer while true loop handles:
- Full turns (LLM call + tool execution)
- Switching between tool-result turns and response turns
6. Message Types
| Type | Role | Created By |
|---|---|---|
UserMessage |
"user" | User via prompt() |
AssistantMessage |
"assistant" | LLM via streamAssistantResponse() |
ToolResultMessage |
"toolResult" | createToolResultMessage() after tool execution |
BashExecutionMessage |
"user" | Bash tool (excluded from context by default) |
CompactionSummaryMessage |
"user" | Compaction process |
BranchSummaryMessage |
"user" | Branch summarization |
7. Tool Call Lifecycle
ToolCall (from LLM)
-> prepareToolCall() (validate args, before_tool_call hook)
-> executePreparedToolCall() (invoke tool.execute)
-> finalizeExecutedToolCall() (after_tool_call hook)
-> createToolResultMessage() (wrap result in ToolResultMessage)
-> emitToolResultMessage() (emit MessageStart/MessageEnd)