Files
YiemAgent/src/agent_loop.jl
T
2026-07-28 07:37:57 +07:00

862 lines
28 KiB
Julia

"""
agent_loop.jl - Low-level agent loop implementation
This module implements the core agentLoop functionality that works with AgentMessage
throughout, transforming to Message[] only at the LLM call boundary.
"""
module AgentLoop
using ..Types: *
using ..StreamFn: *
# ============================================================================
# Event sink type
# ============================================================================
const AgentEventSink = Function
# ============================================================================
# Main agent loop function
# ============================================================================
function agentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::EventStream
stream = createAgentStream()
Threads.@spawn begin
messages = runAgentLoop(
prompts,
context,
config,
(event) -> push!(stream, event),
signal,
stream_fn,
)
end(stream, messages)
end
return stream
end
# ============================================================================
# Continue agent loop function
# ============================================================================
function agentLoopContinue(
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::EventStream
if isempty(context.messages)
throw(ErrorException("Cannot continue: no messages in context"))
end
if context.messages[end].role == "assistant"
throw(ErrorException("Cannot continue from message role: assistant"))
end
stream = createAgentStream()
Threads.@spawn begin
messages = runAgentLoopContinue(
context,
config,
(event) -> push!(stream, event),
signal,
stream_fn,
)
end(stream, messages)
end
return stream
end
# ============================================================================
# Run agent loop function
# ============================================================================
function runAgentLoop(
prompts::Vector{AgentMessage},
context::AgentContext,
config::AgentLoopConfig,
emit::AgentEventSink,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::Vector{AgentMessage}
new_messages::Vector{AgentMessage} = copy(prompts)
current_context::AgentContext = 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,
)
return new_messages
end
# ============================================================================
# Run agent loop continue function
# ============================================================================
function runAgentLoopContinue(
context::AgentContext,
config::AgentLoopConfig,
emit::AgentEventSink,
signal::Union{Nothing, AbortSignal},
stream_fn::StreamFn,
)::Vector{AgentMessage}
if isempty(context.messages)
throw(ErrorException("Cannot continue: no messages in context"))
end
if context.messages[end].role == "assistant"
throw(ErrorException("Cannot continue from message role: assistant"))
end
new_messages::Vector{AgentMessage} = []
current_context::AgentContext = context
emit(AgentStartEvent())
emit(TurnStartEvent())
runLoop(
current_context,
new_messages,
config,
signal,
emit,
stream_fn,
)
return new_messages
end
# ============================================================================
# Create agent stream function
# ============================================================================
function createAgentStream()::EventStream
return EventStream(
(event::AgentEvent) -> event isa AgentEndEvent,
(event::AgentEvent) -> event isa AgentEndEvent ? event.messages : AgentMessage[],
)
end
# ============================================================================
# Main loop logic shared by agentLoop and agentLoopContinue
# ============================================================================
function runLoop(
initial_context::AgentContext,
new_messages::Vector{AgentMessage},
initial_config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::Nothing
current_context::AgentContext = initial_context
config::AgentLoopConfig = initial_config
first_turn::Bool = true
pending_messages::Vector{AgentMessage} = getSteeringMessages(config) do
get_steering_messages(config)
end
while true
has_more_tool_calls::Bool = true
while has_more_tool_calls || !isempty(pending_messages)
if !first_turn
emit(TurnStartEvent())
else
first_turn = false
end
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
message = streamAssistantResponse(
current_context,
config,
signal,
emit,
stream_function,
)
push!(new_messages, message)
if message.stop_reason in ("error", "aborted")
emit(TurnEndEvent(message, ToolResultMessage[]))
emit(AgentEndEvent(new_messages))
return
end
tool_calls = filter(
(c) -> c isa ToolCall,
message.content,
)
tool_results::Vector{ToolResultMessage} = []
has_more_tool_calls = false
if !isempty(tool_calls)
executed_tool_batch =
message.stop_reason == "length"
? failToolCallsFromTruncatedMessage(tool_calls, emit)
: executeToolCalls(
current_context,
message,
config,
signal,
emit,
)
append!(tool_results, executed_tool_batch.messages)
has_more_tool_calls = !executed_tool_batch.terminate
for result in tool_results
push!(current_context.messages, result)
push!(new_messages, result)
end
end
emit(TurnEndEvent(message, tool_results))
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,
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
if should_stop_after_turn(config, next_turn_context)
emit(AgentEndEvent(new_messages))
return
end
pending_messages = getSteeringMessages(config) do
get_steering_messages(config)
end
end
follow_up_messages = getFollowUpMessages(config) do
get_follow_up_messages(config)
end
if !isempty(follow_up_messages)
pending_messages = follow_up_messages
continue
end
break
end
emit(AgentEndEvent(new_messages))
end
# ============================================================================
# Helper types
# ============================================================================
struct PrepareNextTurnContext
message::AssistantMessage
tool_results::Vector{ToolResultMessage}
context::AgentContext
new_messages::Vector{AgentMessage}
end
struct AgentLoopTurnUpdate
context::Union{AgentContext, Nothing}
model::Union{Model, Nothing}
thinking_level::Union{ThinkingLevel, Nothing}
end
# ============================================================================
# Helper functions for getting messages from queues
# ============================================================================
macro getSteeringMessages(config)
:(get_steering_messages($(esc(config))))
end
macro getFollowUpMessages(config)
:(get_follow_up_messages($(esc(config))))
end
function get_steering_messages(config::AgentLoopConfig)::Vector{AgentMessage}
return isnothing(config.get_steering_messages) ? AgentMessage[] : config.get_steering_messages()
end
function get_follow_up_messages(config::AgentLoopConfig)::Vector{AgentMessage}
return isnothing(config.get_follow_up_messages) ? AgentMessage[] : config.get_follow_up_messages()
end
function prepare_next_turn(config::AgentLoopConfig, context::PrepareNextTurnContext)::Union{AgentLoopTurnUpdate, Nothing}
return isnothing(config.prepare_next_turn) ? nothing : config.prepare_next_turn(context)
end
function should_stop_after_turn(config::AgentLoopConfig, context::PrepareNextTurnContext)::Bool
return isnothing(config.should_stop_after_turn) ? false : config.should_stop_after_turn(context)
end
# ============================================================================
# Stream assistant response function
# ============================================================================
function streamAssistantResponse(
context::AgentContext,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
stream_function::StreamFn,
)::AssistantMessage
messages::Vector{AgentMessage} = context.messages
if !isnothing(config.transform_context)
messages = config.transform_context(messages, signal)
end
llm_messages::Vector{Message} = config.convert_to_llm(messages)
llm_context::Context = Context(
context.system_prompt,
llm_messages,
context.tools,
)
resolved_api_key::Union{String, Nothing} =
!isnothing(config.get_api_key)
? config.get_api_key(config.model.provider)
: nothing
response = stream_function(
config.model,
llm_context,
merge(
config,
Dict(:apiKey => resolved_api_key, :signal => signal),
),
)
partial_message::Union{AssistantMessage, Nothing} = nothing
added_partial::Bool = false
for event in response
if event.type == "start"
partial_message = event.partial
push!(context.messages, partial_message)
added_partial = true
emit(MessageStartEvent(copy(partial_message)))
elseif event.type in ("text_start", "text_delta", "text_end", "thinking_start", "thinking_delta", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end")
if !isnothing(partial_message)
partial_message = event.partial
context.messages[end] = partial_message
emit(MessageUpdateEvent(copy(partial_message), event))
end
elseif event.type in ("done", "error")
final_message = response.result()
if added_partial
context.messages[end] = final_message
else
push!(context.messages, final_message)
end
if !added_partial
emit(MessageStartEvent(copy(final_message)))
end
emit(MessageEndEvent(final_message))
return final_message
end
end
final_message = response.result()
if added_partial
context.messages[end] = final_message
else
push!(context.messages, final_message)
emit(MessageStartEvent(copy(final_message)))
end
emit(MessageEndEvent(final_message))
return final_message
end
# ============================================================================
# Fail tool calls from truncated message
# ============================================================================
struct ExecutedToolCallBatch
messages::Vector{ToolResultMessage}
terminate::Bool
end
function failToolCallsFromTruncatedMessage(
tool_calls::Vector{ToolCall},
emit::AgentEventSink,
)::ExecutedToolCallBatch
messages::Vector{ToolResultMessage} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
finalized = FinalizedToolCallOutcome(
tool_call,
createErrorToolResult(
"Tool call \"$(tool_call.name)\" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.",
),
true,
)
emitToolExecutionEnd(finalized, emit)
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(messages, tool_result_message)
end
return ExecutedToolCallBatch(messages, false)
end
# ============================================================================
# Execute tool calls
# ============================================================================
function executeToolCalls(
current_context::AgentContext,
assistant_message::AssistantMessage,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
tool_calls = filter(
(c) -> c isa ToolCall,
assistant_message.content,
)
has_sequential_tool_call = any(
(tc) -> begin
tool = findfirst((t) -> t.name == tc.name, current_context.tools)
!isnothing(tool) && tool.execution_mode == EXECUTION_SEQUENTIAL
end,
tool_calls,
)
if config.tool_execution == EXECUTION_SEQUENTIAL || has_sequential_tool_call
return executeToolCallsSequential(
current_context,
assistant_message,
tool_calls,
config,
signal,
emit,
)
end
return executeToolCallsParallel(
current_context,
assistant_message,
tool_calls,
config,
signal,
emit,
)
end
# ============================================================================
# Execute tool calls sequentially
# ============================================================================
function executeToolCallsSequential(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
finalized_calls::Vector{FinalizedToolCallOutcome} = []
messages::Vector{ToolResultMessage} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
preparation = prepareToolCall(current_context, assistant_message, tool_call, config, signal)
finalized = if preparation.kind == "immediate"
FinalizedToolCallOutcome(tool_call, preparation.result, preparation.is_error)
else
executed = executePreparedToolCall(preparation, signal, emit)
finalizeExecutedToolCall(
current_context,
assistant_message,
preparation,
executed,
config,
signal,
)
end
emitToolExecutionEnd(finalized, emit)
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(finalized_calls, finalized)
push!(messages, tool_result_message)
if !isnothing(signal) && signal.aborted
break
end
end
return ExecutedToolCallBatch(messages, shouldTerminateToolBatch(finalized_calls))
end
# ============================================================================
# Execute tool calls in parallel
# ============================================================================
function executeToolCallsParallel(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_calls::Vector{ToolCall},
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallBatch
finalized_calls::Vector{Union{FinalizedToolCallOutcome, Function}} = []
for tool_call in tool_calls
emit(ToolExecutionStartEvent(tool_call.id, tool_call.name, tool_call.arguments))
preparation = prepareToolCall(current_context, assistant_message, tool_call, config, signal)
if preparation.kind == "immediate"
finalized = FinalizedToolCallOutcome(
tool_call,
preparation.result,
preparation.is_error,
)
emitToolExecutionEnd(finalized, emit)
push!(finalized_calls, finalized)
if !isnothing(signal) && signal.aborted
break
end
continue
end
push!(finalized_calls, () -> begin
executed = executePreparedToolCall(preparation, signal, emit)
finalized = finalizeExecutedToolCall(
current_context,
assistant_message,
preparation,
executed,
config,
signal,
)
emitToolExecutionEnd(finalized, emit)
return finalized
end)
if !isnothing(signal) && signal.aborted
break
end
end
ordered_finalized_calls = map(
(entry) -> if entry isa Function
entry()
else
entry
end,
finalized_calls,
)
messages::Vector{ToolResultMessage} = []
for finalized in ordered_finalized_calls
tool_result_message = createToolResultMessage(finalized)
emitToolResultMessage(tool_result_message, emit)
push!(messages, tool_result_message)
end
return ExecutedToolCallBatch(messages, shouldTerminateToolBatch(ordered_finalized_calls))
end
# ============================================================================
# Prepared tool call types
# ============================================================================
struct PreparedToolCall
kind::String
tool_call::ToolCall
tool::AgentTool
args::Any
end
struct ImmediateToolCallOutcome
kind::String
result::AgentToolResultMutable
is_error::Bool
end
struct ExecutedToolCallOutcome
result::AgentToolResultMutable
is_error::Bool
end
struct FinalizedToolCallOutcome
tool_call::ToolCall
result::AgentToolResultMutable
is_error::Bool
end
# ============================================================================
# Helper functions
# ============================================================================
function shouldTerminateToolBatch(finalized_calls::Vector{FinalizedToolCallOutcome})::Bool
return !isempty(finalized_calls) && all(
(finalized) -> finalized.result.terminate === true,
finalized_calls,
)
end
function prepareToolCallArguments(tool::AgentTool, tool_call::ToolCall)::ToolCall
if isnothing(tool.prepare_arguments)
return tool_call
end
prepared_arguments = tool.prepare_arguments(tool_call.arguments)
if prepared_arguments === tool_call.arguments
return tool_call
end
return ToolCall(
tool_call.type,
tool_call.id,
tool_call.name,
prepared_arguments,
tool_call.partial_json,
)
end
function prepareToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
tool_call::ToolCall,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::Union{PreparedToolCall, ImmediateToolCallOutcome}
tool = findfirst((t) -> t.name == tool_call.name, current_context.tools)
if isnothing(tool)
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Tool $(tool_call.name) not found"), true)
end
try
prepared_tool_call = prepareToolCallArguments(tool, tool_call)
validated_args = validateToolArguments(tool, prepared_tool_call)
if !isnothing(config.before_tool_call)
before_result = config.before_tool_call(
BeforeToolCallContext(assistant_message, tool_call, validated_args, current_context),
signal,
)
if !isnothing(signal) && signal.aborted
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Operation aborted"), true)
end
if !isnothing(before_result) && before_result.block
reason = isnothing(before_result.reason) ? "Tool execution was blocked" : before_result.reason
return ImmediateToolCallOutcome("immediate", createErrorToolResult(reason), true)
end
end
if !isnothing(signal) && signal.aborted
return ImmediateToolCallOutcome("immediate", createErrorToolResult("Operation aborted"), true)
end
return PreparedToolCall("prepared", tool_call, tool, validated_args)
catch error
return ImmediateToolCallOutcome("immediate", createErrorToolResult(string(error)), true)
end
end
function executePreparedToolCall(
prepared::PreparedToolCall,
signal::Union{Nothing, AbortSignal},
emit::AgentEventSink,
)::ExecutedToolCallOutcome
update_events::Vector{Future} = []
accepting_updates::Bool = true
try
result = prepared.tool.execute(
prepared.tool_call.id,
prepared.args,
signal,
(partial_result) -> begin
if !accepting_updates
return
end
push!(
update_events,
Threads.@spawn begin
emit(
ToolExecutionUpdateEvent(
prepared.tool_call.id,
prepared.tool_call.name,
prepared.tool_call.arguments,
partial_result,
),
)
end,
)
end,
)
accepting_updates = false
wait.(update_events)
return ExecutedToolCallOutcome(result, false)
catch error
accepting_updates = false
wait.(update_events)
return ExecutedToolCallOutcome(createErrorToolResult(string(error)), true)
finally
accepting_updates = false
end
end
function finalizeExecutedToolCall(
current_context::AgentContext,
assistant_message::AssistantMessage,
prepared::PreparedToolCall,
executed::ExecutedToolCallOutcome,
config::AgentLoopConfig,
signal::Union{Nothing, AbortSignal},
)::FinalizedToolCallOutcome
result = executed.result
is_error = executed.is_error
if !isnothing(config.after_tool_call)
try
after_result = config.after_tool_call(
AfterToolCallContext(
assistant_message,
prepared.tool_call,
prepared.args,
result,
is_error,
current_context,
),
signal,
)
if !isnothing(after_result)
result = AgentToolResultMutable(
isnothing(after_result.content) ? result.content : after_result.content,
isnothing(after_result.details) ? result.details : after_result.details,
isnothing(after_result.usage) ? result.usage : after_result.usage,
result.added_tool_names,
isnothing(after_result.terminate) ? result.terminate : after_result.terminate,
)
is_error = isnothing(after_result.is_error) ? is_error : after_result.is_error
end
catch error
result = createErrorToolResult(string(error))
is_error = true
end
end
return FinalizedToolCallOutcome(prepared.tool_call, result, is_error)
end
function createErrorToolResult(message::String)::AgentToolResultMutable
return AgentToolResultMutable([TextContent(message)], Dict{String, Any}(), nothing, nothing, nothing)
end
function emitToolExecutionEnd(finalized::FinalizedToolCallOutcome, emit::AgentEventSink)::Nothing
emit(ToolExecutionEndEvent(
finalized.tool_call.id,
finalized.tool_call.name,
finalized.result,
finalized.is_error,
))
return nothing
end
function createToolResultMessage(finalized::FinalizedToolCallOutcome)::ToolResultMessage
return ToolResultMessage(
"toolResult",
finalized.tool_call.id,
finalized.tool_call.name,
isnothing(finalized.result.content) ? MessageContent[] : finalized.result.content,
finalized.result.details,
finalized.result.usage,
finalized.result.added_tool_names,
finalized.is_error,
Dates.now(Dates.UTC).datetime,
)
end
function emitToolResultMessage(tool_result_message::ToolResultMessage, emit::AgentEventSink)::Nothing
emit(MessageStartEvent(tool_result_message))
emit(MessageEndEvent(tool_result_message))
return nothing
end
# ============================================================================
# Validation helper
# ============================================================================
function validateToolArguments(tool::AgentTool, tool_call::ToolCall)::Any
# Simplified validation - in a full implementation, this would use TypeBox-like validation
return tool_call.arguments
end
end