From 7876ff21ebe227709601f4a6749b6a6f85e637e4 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 31 Jul 2026 11:41:53 +0700 Subject: [PATCH] update --- learning/02-AGENT_COMPONENT.md | 27 +- learning/03-AGENTLOOP_COMPONENT.md | 391 +++++----- learning/04-TYPES_MESSAGES.md | 54 +- learning/05-SESSION_MANAGEMENT.md | 493 ++++++++----- learning/06-TOOLS.md | 860 +++++----------------- learning/07-AGENTHARNESS.md | 1103 +++++++++++++--------------- learning/08-EXAMPLES.md | 857 +++++++++------------ learning/README.md | 54 +- 8 files changed, 1671 insertions(+), 2168 deletions(-) diff --git a/learning/02-AGENT_COMPONENT.md b/learning/02-AGENT_COMPONENT.md index 18cb3f3..9f3ede3 100644 --- a/learning/02-AGENT_COMPONENT.md +++ b/learning/02-AGENT_COMPONENT.md @@ -36,8 +36,8 @@ end # Create agent with options agent = Agent(Dict{Symbol, Any}( :systemPrompt => "You are a helpful assistant", - :model => Model(...), - :thinkingLevel => THINKING_MEDIUM, + :model => Model("", "", "unknown", "unknown", "", false, String[], ModelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + :thinkingLevel => THINKING_OFF, :tools => [bash_tool, read_tool], :steeringMode => QUEUE_ONE_AT_A_TIME, :followUpMode => QUEUE_ONE_AT_A_TIME, @@ -290,7 +290,7 @@ agent = Agent(Dict(:transformContext => myTransformContext)) # Hook before tool execution function myBeforeToolCall(context, signal) println("About to execute: $(context.tool_call.name)") - return nothing # Return block=true to prevent execution + return BeforeToolCallResult(nothing, nothing) # Return BeforeToolCallResult(true, "reason") to block end agent = Agent(Dict(:beforeToolCall => myBeforeToolCall)) @@ -303,8 +303,11 @@ agent = Agent(Dict(:beforeToolCall => myBeforeToolCall)) function myAfterToolCall(context, signal) # Can modify tool result return AfterToolCallResult( - content = context.result.content, - terminate = context.result.terminate + context.result.content, + context.result.details, + nothing, + nothing, + context.result.terminate ) end @@ -319,9 +322,9 @@ function myPrepareNextTurn(context, signal) # context: PrepareNextTurnContext # Returns AgentLoopTurnUpdate or nothing return AgentLoopTurnUpdate( - context = context.context, - model = context.context.model, # Can change model - thinking_level = THINKING_HIGH # Can change thinking level + context.context, # context + context.context.model, # model - can change + THINKING_HIGH # thinking_level - can change ) end @@ -334,11 +337,11 @@ agent = Agent(Dict(:prepareNextTurn => myPrepareNextTurn)) # Check if agent is busy if !isnothing(agent.active_run) # Agent is processing - abort(agent) # Abort current run + abort(agent) # Abort current run (NOTE: implementation is a TODO stub) end # Wait for completion -wait_for_idle(agent) # Returns Promise +waitForIdle(agent) # Returns Promise ``` ## Complete Example @@ -367,7 +370,7 @@ end prompt(agent, "What's in the current directory?") # 4. Wait for completion -wait_for_idle(agent) +waitForIdle(agent) # 5. Check final state state = get_state(agent) @@ -375,7 +378,7 @@ println("Total messages: $(length(state.messages))") # 6. Continue with steering steer(agent, UserMessage(...)) -wait_for_idle(agent) +waitForIdle(agent) # 7. Clean up unsubscribe() # Stop listening diff --git a/learning/03-AGENTLOOP_COMPONENT.md b/learning/03-AGENTLOOP_COMPONENT.md index c4098e3..7f8dce5 100644 --- a/learning/03-AGENTLOOP_COMPONENT.md +++ b/learning/03-AGENTLOOP_COMPONENT.md @@ -53,19 +53,22 @@ agentLoopContinue() │ Output: N/A (writes to new_messages and context.messages) │ │ │ │ ┌────────────────────────────────────────────────────────────────────┐ │ -│ │ while true: │ │ -│ │ 1. Get steering/follow-up messages (if any) │ │ -│ │ 2. Emit messages as UserMessage │ │ -│ │ 3. streamAssistantResponse() │ │ -│ │ - Input: context.messages::Vector{AgentMessage} │ │ -│ │ - Output: message::AssistantMessage │ │ -│ │ 4. Execute tool calls (sequential or parallel) │ │ -│ │ - Input: AssistantMessage with ToolCall[] │ │ -│ │ - Output: tool_results::Vector{ToolResultMessage} │ │ -│ │ 5. Emit TurnEndEvent │ │ -│ │ 6. prepare_next_turn (optional) │ │ -│ │ 7. should_stop_after_turn? (check termination) │ │ -│ │ 8. Loop continues if not terminated │ │ +│ │ 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 │ │ │ └────────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────┘ │ @@ -179,9 +182,23 @@ struct AgentLoopConfig 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 returns `false`. 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() @@ -236,11 +253,13 @@ function runAgentLoop( **Purpose**: Execute agent loop with initial prompts **Flow**: -1. Copy prompts to new_messages -2. Append prompts to context.messages -3. Emit AgentStartEvent -4. For each prompt: emit MessageStartEvent, MessageEndEvent -5. Call runLoop() +1. Copy prompts to `new_messages` +2. Create `current_context` with prompts appended to `context.messages` +3. Emit `AgentStartEvent` +4. Emit `TurnStartEvent` +5. For each prompt: emit `MessageStartEvent`, `MessageEndEvent` +6. Call `runLoop()` — handles the main loop, tool execution, and termination +7. Return `new_messages` ### runLoop() - The Heart of AgentLoop @@ -255,109 +274,124 @@ function runLoop( )::Nothing ``` -**Main Loop**: +**Main Loop** (simplified — shows structure; actual code has type annotations): ```julia current_context = initial_context config = initial_config first_turn = true -pending_messages = get_steering_messages() +pending_messages = get_steering_messages(config) while true - # Process steering/follow-up messages - while !isempty(pending_messages) + 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 - for message in pending_messages - emit(MessageStartEvent(message)) - emit(MessageEndEvent(message)) - push!(current_context.messages, message) - push!(new_messages, message) + + # 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 - - 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, + + # 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 - 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) + + # 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 - - 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() + + # 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 @@ -422,7 +456,7 @@ function executeToolCalls( current_context::AgentContext, assistant_message::AssistantMessage, config::AgentLoopConfig, - signal::Union{Nothing, AbortSignal>, + signal::Union{Nothing, AbortSignal}, emit::AgentEventSink, )::ExecutedToolCallBatch ``` @@ -572,7 +606,7 @@ Input: tool_call::ToolCall ```julia function executePreparedToolCall( prepared::PreparedToolCall, - signal::Union{Nothing, AbortSignal>, + signal::Union{Nothing, AbortSignal}, emit::AgentEventSink, )::ExecutedToolCallOutcome ``` @@ -590,14 +624,18 @@ Input: prepared::PreparedToolCall args::Any signal::Union{Any, Nothing} on_update::Function (partial_result → void) - Output: AgentToolResultMutable - - content::Vector{MessageContent} - - details::Any - - usage::Union{Usage, Nothing} - - added_tool_names::Union{Vector{String}, Nothing} - - terminate::Union{Bool, Nothing} - ↓ - Collect update events from on_update callbacks + 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 @@ -612,7 +650,7 @@ function finalizeExecutedToolCall( prepared::PreparedToolCall, executed::ExecutedToolCallOutcome, config::AgentLoopConfig, - signal::Union{Nothing, AbortSignal>, + signal::Union{Nothing, AbortSignal}, )::FinalizedToolCallOutcome ``` @@ -632,18 +670,23 @@ Input: executed::ExecutedToolCallOutcome 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 any) - result.content = result.content ∪ patches.content - result.details = result.details ∪ patches.details - is_error = is_error ∪ patches.is_error - ↓ + 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) @@ -699,45 +742,39 @@ Input: finalized::FinalizedToolCallOutcome ``` ┌─────────────────────────────────────────────────────────────────────────┐ -│ Sequential Execution Flow │ +│ Sequential Execution Flow (Strict Order) │ └─────────────────────────────────────────────────────────────────────────┘ -┌──────┐ -│ TC1 │ ──► prepareToolCall() -└──────┘ │ - ▼ - ┌──────────────┐ - │ execute() │ ──► Wait for completion - └──────────────┘ │ - │ ▼ - ├───────────── createToolResultMessage() - │ │ - ▼ ▼ - ┌──────────────┐ ┌──────────┐ - │ TC2 │ ──► │ │ Result1 │ - └──────┘ └──────────┘ - │ - ▼ - ┌──────────────┐ - │ execute() │ - └──────────────┘ - │ - ▼ - ┌──────────────┐ - │ TC3 │ ──► │ - └──────┘ │ - │ ▼ - ├───── createToolResultMessage() - │ │ - ▼ ▼ - ┌──────────────┐ ┌──────────┐ - │ execute() │ │ │ Result2 │ - └──────────────┘ └──────────┘ - │ - ▼ - ┌──────────┐ - │ Result3 │ - └──────────┘ + 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 @@ -948,16 +985,16 @@ ToolCall (in AssistantMessage.content) ### 3. Turn Termination ```julia -# Turn ends when: -# 1. No more pending messages -# 2. No more tool calls to execute -# 3. should_stop_after_turn() returns true +# 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) -# Reasons to stop: -# - Max turns reached -# - Tool returned terminate=true -# - Error or abort -# - Steering/follow-up queues empty +# 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 @@ -977,6 +1014,12 @@ using AgentCore 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, @@ -986,6 +1029,14 @@ config = AgentLoopConfig( 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 diff --git a/learning/04-TYPES_MESSAGES.md b/learning/04-TYPES_MESSAGES.md index 3fcdfd5..9e0d7af 100644 --- a/learning/04-TYPES_MESSAGES.md +++ b/learning/04-TYPES_MESSAGES.md @@ -248,6 +248,9 @@ struct ToolResultMessage <: Message is_error::Bool # True if tool execution failed timestamp::Timestamp end + +# Note: AgentToolResult{T} (types.jl) - generic result type with type param T +# AgentToolResultMutable (agent_loop.jl) - mutable variant used internally ``` **Usage**: @@ -288,6 +291,8 @@ end - `prepare_arguments`: Optional preprocessing - `execution_mode`: Sequential or parallel +**Note:** `AgentHarnessTool` (`harness_types.jl:91`) is a harness-specific variant with the same structure but uses camelCase field names (`prepareArguments`, `executionMode`) and includes additional type parameters `{TContext, TParameters, TDetails}`. + ### Tool Execution Function Signature ```julia @@ -297,12 +302,12 @@ execute::Function( signal::Union{Any, Nothing}, # Abort signal on_update::Function, # Callback for streaming updates context::Any, # Tool context -)::AgentToolResult +)::AgentToolResult{T} ``` -**Returns**: +**Returns** (`AgentToolResult{T}` from `types.jl`): ```julia -AgentToolResult( +AgentToolResult{T}( content::Vector{MessageContent}, # Result content details::T, # Tool-specific details usage::Union{Usage, Nothing}, # Usage statistics @@ -311,6 +316,10 @@ AgentToolResult( ) ``` +**Note:** `AgentToolResultMutable` (in `agent_loop.jl`) is a mutable variant used internally for intermediate results. + +**Note:** External types used throughout the codebase: `Context`, `AbortSignal`, `EventStream`, `Promise` are defined in external modules (not in the source files covered by this document). + ## AgentContext ```julia @@ -532,6 +541,32 @@ mutable struct BranchSummaryMessage end ``` +### CustomMessage + +**Note:** There are two `CustomMessage` types in the codebase: + +1. **Types.CustomMessage** (`types.jl:155`) - A simple wrapper that holds another `AgentMessage` with a custom type label: +```julia +struct CustomMessage <: AgentMessage + message::AgentMessage + custom_type::String +end +``` + +2. **Messages.CustomMessage{T}** (`messages.jl:42`) - A standalone mutable message with content, display flag, and details: +```julia +mutable struct CustomMessage{T} + role::String + custom_type::String + content::Union{String, Vector{MessageContent}} + display::Bool + details::Union{T, Nothing} + timestamp::Timestamp +end +``` + +Only `Messages.CustomMessage{T}` is converted by `convertToLlmMessage()` to a `UserMessage`. + ## AgentState ```julia @@ -589,6 +624,8 @@ Vector{AgentMessage} (internal conversation history) │ (wrapped with COMPACTION_SUMMARY_PREFIX/SUFFIX) │ • BranchSummaryMessage → UserMessage │ (wrapped with BRANCH_SUMMARY_PREFIX/SUFFIX) + │ • CustomMessage → UserMessage + │ (content field used directly, string→TextContent) │ ▼ Vector{Message} (for LLM API) @@ -607,6 +644,7 @@ Vector{Message} (for LLM API) ], "openai", "openai", "gpt-4", Usage(...), "done", nothing, 1234567891), BashExecutionMessage("custom", "ls -la", "file1.md\nfile2.md\n", 0, false, false, nothing, 1234567892, false), CompactionSummaryMessage("compactionSummary", "Previous conversation compacted", 1000, 1234567893), + CustomMessage("custom", "someCustomType", "Some custom content", true, nothing, 1234567894), ] # Output: Vector{Message} @@ -618,6 +656,7 @@ Vector{Message} (for LLM API) ], "openai", "openai", "gpt-4", Usage(...), "done", nothing, 1234567891), UserMessage("user", [TextContent("Ran `ls -la`\n```\nfile1.md\nfile2.md\n```\n")], 1234567892), UserMessage("user", [TextContent("Previous conversation compacted")], 1234567893), + UserMessage("user", [TextContent("Some custom content")], 1234567894), ] ``` @@ -636,6 +675,15 @@ function convertToLlmMessage(m::CompactionSummaryMessage) return UserMessage("user", [TextContent(text)], m.timestamp) end +function convertToLlmMessage(m::CustomMessage)::Union{UserMessage, Nothing} + content = if m.content isa String + [TextContent(m.content)] + else + m.content + end + return UserMessage("user", content, m.timestamp) +end + function convertToLlmMessage(m::BranchSummaryMessage) text = BRANCH_SUMMARY_PREFIX * m.summary * BRANCH_SUMMARY_SUFFIX return UserMessage("user", [TextContent(text)], m.timestamp) diff --git a/learning/05-SESSION_MANAGEMENT.md b/learning/05-SESSION_MANAGEMENT.md index f0a95f1..87421b7 100644 --- a/learning/05-SESSION_MANAGEMENT.md +++ b/learning/05-SESSION_MANAGEMENT.md @@ -27,15 +27,16 @@ │ └─► storage.appendEntry() → JSONL file │ │ │ │ To navigate to E2 (fork point): │ -│ Session.moveTo(E2) │ +│ session.moveTo(E2) │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ E1 │────▶│ E2 │────▶│ E3' │────▶│ E4' │────▶│ E5' │ (new branch) │ │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ │ │ │ │ │ │ ▼ create BranchSummary │ │ │ ┌─────┐ │ -│ └──────│ E6 │ (branch summary) │ -│ └─────┘ │ +│ │ │ E6 │ (branch summary) │ +│ │ └─────┘ │ +│ └───────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -49,43 +50,45 @@ AgentState.messages::Vector{AgentMessage} │ ▼ │ ┌──────────────────────────────────────────────────────────────┐ │ │ appendMessage(session, AgentMessage) │ - │ │ Input: message::AgentMessage │ + │ │ Input: session::Session, message::AgentMessage │ │ │ Output: entry_id::String │ │ │ │ │ │ Steps: │ │ │ 1. Create MessageEntry: │ - │ │ - type: "message" │ - │ │ - id: createEntryId(storage) │ - │ │ - parent_id: getLeafId(storage) │ - │ │ - timestamp: create_timestamp() │ - │ │ - message: copy(message) │ + │ │ - base: SessionTreeEntryBase(type, id, leaf_id, time) │ + │ │ - message: the AgentMessage │ │ │ 2. storage.appendEntry(entry) │ - │ │ - Write JSONL line to file │ - │ │ - Update leaf_id │ + │ │ - In-memory: push to entries vector, update by_id dict │ + │ │ - JSONL: would append to file (TODO) │ │ │ 3. Return entry.id │ │ └──────────────────────────────────────────────────────────────┘ │ - └─► Entry stored in JSONL: + └─► Entry stored in JSONL (conceptual): {"type":"message","id":"msg_1","parent_id":null,"timestamp":"...","message":{...}} ``` ## Entry Types -## Entry Types +All entry types extend `abstract type SessionTreeEntry end` and embed a +`base::SessionTreeEntryBase` struct containing `type`, `id`, `parent_id`, and `timestamp`. ```julia abstract type SessionTreeEntry end + +struct SessionTreeEntryBase + type::String + id::String + parent_id::Union{String, Nothing} + timestamp::String +end ``` ### 1. MessageEntry ```julia struct MessageEntry <: SessionTreeEntry - type::String # "message" - id::String # Unique entry ID - parent_id::Union{String, Nothing} - timestamp::String # ISO 8601 timestamp - message::AgentMessage # The actual message + base::SessionTreeEntryBase + message::AgentMessage end ``` @@ -95,11 +98,8 @@ end ```julia struct ThinkingLevelChangeEntry <: SessionTreeEntry - type::String # "thinking_level_change" - id::String - parent_id::Union{String, Nothing} - timestamp::String - thinking_level::String # "off", "minimal", "low", "medium", etc. + base::SessionTreeEntryBase + thinking_level::String end ``` @@ -109,12 +109,9 @@ end ```julia struct ModelChangeEntry <: SessionTreeEntry - type::String # "model_change" - id::String - parent_id::Union{String, Nothing} - timestamp::String - provider::String # "openai", "anthropic", etc. - model_id::String # Model identifier + base::SessionTreeEntryBase + provider::String + model_id::String end ``` @@ -124,10 +121,7 @@ end ```julia struct ActiveToolsChangeEntry <: SessionTreeEntry - type::String # "active_tools_change" - id::String - parent_id::Union{String, Nothing} - timestamp::String + base::SessionTreeEntryBase active_tool_names::Vector{String} end ``` @@ -137,18 +131,15 @@ end ### 5. CompactionEntry ```julia -struct CompactionEntry <: SessionTreeEntry - type::String # "compaction" - id::String - parent_id::Union{String, Nothing} - timestamp::String - summary::String # Summary of compacted history +struct CompactionEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + summary::String first_kept_entry_id::Union{String, Nothing} - tokens_before::Int64 # Context size before compaction + tokens_before::Int64 retained_tail::Union{Vector{AgentMessage}, Nothing} - details::Union{Any, Nothing} + details::Union{T, Nothing} usage::Union{Usage, Nothing} - from_hook::Bool # Whether triggered by hook + from_hook::Bool end ``` @@ -163,14 +154,11 @@ end ### 6. BranchSummaryEntry ```julia -struct BranchSummaryEntry <: SessionTreeEntry - type::String # "branch_summary" - id::String - parent_id::Union{String, Nothing} - timestamp::String - from_id::String # Branch point entry ID - summary::String # Summary of branch history - details::Union{Any, Nothing} +struct BranchSummaryEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + from_id::String + summary::String + details::Union{T, Nothing} usage::Union{Usage, Nothing} from_hook::Bool end @@ -181,13 +169,10 @@ end ### 7. CustomEntry ```julia -struct CustomEntry <: SessionTreeEntry - type::String # Custom type - id::String - parent_id::Union{String, Nothing} - timestamp::String +struct CustomEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase custom_type::String - data::Union{Any, Nothing} + data::Union{T, Nothing} end ``` @@ -196,14 +181,11 @@ end ### 8. CustomMessageEntry ```julia -struct CustomMessageEntry <: SessionTreeEntry - type::String - id::String - parent_id::Union{String, Nothing} - timestamp::String +struct CustomMessageEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase custom_type::String content::String - details::Union{Any, Nothing} + details::Union{T, Nothing} display::Bool end ``` @@ -214,11 +196,8 @@ end ```julia struct LabelEntry <: SessionTreeEntry - type::String - id::String - parent_id::Union{String, Nothing} - timestamp::String - target_id::String # Entry being labeled + base::SessionTreeEntryBase + target_id::String label::Union{String, Nothing} end ``` @@ -229,10 +208,7 @@ end ```julia struct SessionInfoEntry <: SessionTreeEntry - type::String - id::String - parent_id::Union{String, Nothing} - timestamp::String + base::SessionTreeEntryBase name::Union{String, Nothing} end ``` @@ -243,10 +219,7 @@ end ```julia struct LeafEntry <: SessionTreeEntry - type::String - id::String - parent_id::Union{String, Nothing} - timestamp::String + base::SessionTreeEntryBase target_id::Union{String, Nothing} end ``` @@ -259,86 +232,95 @@ end abstract type SessionStorage{T<:SessionMetadata} end ``` -### Storage Methods +### Storage Methods (actual implementation signatures) ```julia # Metadata -getMetadata(storage::SessionStorage)::Promise{T} +getMetadata(storage::SessionStorage)::T # Leaf management -getLeafId(storage::SessionStorage)::Promise{Union{String, Nothing}} -setLeafId(storage::SessionStorage, leaf_id::String)::Promise{Nothing} +getLeafId(storage::SessionStorage)::Union{String, Nothing} +setLeafId(storage::SessionStorage, leaf_id::Union{String, Nothing})::Nothing # Entry management -createEntryId(storage::SessionStorage)::Promise{String} -appendEntry(storage::SessionStorage, entry::SessionTreeEntry)::Promise{Nothing} -getEntry(storage::SessionStorage, id::String)::Promise{Union{SessionTreeEntry, Nothing}} +createEntryId(storage::SessionStorage)::String +appendEntry(storage::SessionStorage, entry::SessionTreeEntry)::Nothing +getEntry(storage::SessionStorage, id::String)::Union{SessionTreeEntry, Nothing} # Query -findEntries(storage::SessionStorage, type::String)::Promise{Vector{SessionTreeEntry}} -getLabel(storage::SessionStorage, id::String)::Promise{Union{String, Nothing}} -getSessionName(storage::SessionStorage)::Promise{Union{String, Nothing}} +findEntries(storage::SessionStorage, type::String)::Vector{SessionTreeEntry} +getLabel(storage::SessionStorage, id::String)::Union{String, Nothing} +getSessionName(storage::SessionStorage)::Union{String, Nothing} # Branch navigation -getPathToRootOrCompaction( - storage::SessionStorage, - leaf_id::String, -)::Promise{Vector{SessionTreeEntry}} - -getEntries(storage::SessionStorage, options::Dict{String, Any})::Promise{Vector{SessionTreeEntry}} +getPathToRootOrCompaction(storage::SessionStorage, leaf_id::Union{String, Nothing})::Vector{SessionTreeEntry} +getEntries(storage::SessionStorage, options::Dict{String, Any})::Vector{SessionTreeEntry} # Stats -getSessionStats(storage::SessionStorage)::Promise{SessionStats} +getSessionStats(storage::SessionStorage)::SessionStats ``` ## JsonlSessionStorage +``` +mutable struct JsonlSessionStorage{T<:SessionMetadata} <: SessionStorage{T} + file_path::String + metadata::T + entries::Vector{SessionTreeEntry} # ordered list + by_id::Dict{String, SessionTreeEntry} # fast lookup by id + labels_by_id::Dict{String, String} # label cache + current_leaf_id::Union{String, Nothing} # current branch tip +end +``` + ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ JSONL Storage Format │ └─────────────────────────────────────────────────────────────────────────────┘ -File: session.jsonl +File: session.jsonl (conceptual - not yet implemented) -Entry 1 (Metadata): -{"type":"session","id":"meta_1","created_at":"2024-01-01T00:00:00Z","cwd":"/path","path":"/path/session.jsonl"} +Entry 1 (Metadata via SessionHeader): +{"type":"session","version":3,"id":"meta_1","timestamp":"...","cwd":"/path","parent_session":null,"metadata":{}} Entry 2 (Message): -{"type":"message","id":"msg_1","parent_id":null,"timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}} +{"type":"message","id":"msg_1","parent_id":null,"timestamp":"...","message":{"role":"user",...}} Entry 3 (Thinking Level): -{"type":"thinking_level_change","id":"tl_1","parent_id":"msg_1","timestamp":"2024-01-01T00:00:02Z","thinking_level":"medium"} +{"type":"thinking_level_change","id":"tl_1","parent_id":"msg_1","timestamp":"...","thinking_level":"medium"} Entry 4 (Model Change): -{"type":"model_change","id":"mc_1","parent_id":"tl_1","timestamp":"2024-01-01T00:00:03Z","provider":"openai","model_id":"gpt-4"} +{"type":"model_change","id":"mc_1","parent_id":"tl_1","timestamp":"...","provider":"openai","model_id":"gpt-4"} Entry 5 (Compaction): -{"type":"compaction","id":"comp_1","parent_id":"mc_1","timestamp":"2024-01-01T00:00:04Z","summary":"Previous messages summarized...","first_kept_entry_id":"msg_3","tokens_before":100000,"tokens_after":50000} +{"type":"compaction","id":"comp_1","parent_id":"mc_1","timestamp":"...","summary":"...","first_kept_entry_id":"msg_3","tokens_before":100000} Entry 6 (Branch Summary): -{"type":"branch_summary","id":"branch_1","parent_id":"comp_1","timestamp":"2024-01-01T00:00:05Z","from_id":"msg_3","summary":"Branch from message 3"} +{"type":"branch_summary","id":"branch_1","parent_id":"comp_1","timestamp":"...","from_id":"msg_3","summary":"..."} Entry 7 (Active Tools): -{"type":"active_tools_change","id":"tools_1","parent_id":"branch_1","timestamp":"2024-01-01T00:00:06Z","active_tool_names":["bash","read"]} +{"type":"active_tools_change","id":"tools_1","parent_id":"branch_1","timestamp":"...","active_tool_names":["bash","read"]} Entry 8 (Leaf): -{"type":"leaf","id":"leaf_1","parent_id":"tools_1","timestamp":"2024-01-01T00:00:07Z","target_id":"msg_5"} +{"type":"leaf","id":"leaf_1","parent_id":"tools_1","timestamp":"...","target_id":"msg_5"} Notes: -- Each line is a JSON object (JSONL format) +- Each line is a JSON object (JSONL format) - TODO: file I/O not yet implemented - parent_id references previous entry (linked list structure) - Leaf entry points to current position in tree - To fork, create new branch from any entry +- In-memory mode uses Vector + Dict by_id for fast access ``` ## InMemorySessionStorage ```julia -mutable struct InMemorySessionStorage - metadata::SessionMetadata +mutable struct InMemorySessionStorage{T<:SessionMetadata} <: SessionStorage{T} + metadata::T + entries::Vector{SessionTreeEntry} + by_id::Dict{String, SessionTreeEntry} + labels_by_id::Dict{String, String} leaf_id::Union{String, Nothing} - entries::Dict{String, SessionTreeEntry} - labels::Dict{String, String} end ``` @@ -355,6 +337,19 @@ end mutable struct Session{T<:SessionMetadata} storage::SessionStorage{T} context_build_options::SessionContextBuildOptions + + function Session(storage::SessionStorage, context_build_options=SessionContextBuildOptions(nothing, nothing)) + new{typeof(storage.metadata)}(storage, context_build_options) + end +end +``` + +### SessionContextBuildOptions + +```julia +mutable struct SessionContextBuildOptions + entry_transforms::Union{Vector{Function}, Nothing} + entry_projectors::Union{Dict{String, Function}, Nothing} end ``` @@ -364,14 +359,10 @@ end ```julia function appendMessage(session::Session, message::AgentMessage)::String - entry = MessageEntry( - "message", - createEntryId(session.storage), - getLeafId(session.storage), - create_timestamp(), + return appendTypedEntry(session, MessageEntry( + SessionTreeEntryBase("message", createEntryId(session.storage), getLeafId(session.storage), create_timestamp()), message, - ) - return appendTypedEntry(session, entry) + )) end ``` @@ -392,18 +383,34 @@ tool_id = appendMessage(session, ToolResultMessage(...)) #### appendThinkingLevelChange() ```julia -function appendThinkingLevelChange( - session::Session, - thinking_level::String, -)::String - entry = ThinkingLevelChangeEntry( - "thinking_level_change", - createEntryId(session.storage), - getLeafId(session.storage), - create_timestamp(), +function appendThinkingLevelChange(session::Session, thinking_level::String)::String + return appendTypedEntry(session, ThinkingLevelChangeEntry( + SessionTreeEntryBase("thinking_level_change", createEntryId(session.storage), getLeafId(session.storage), create_timestamp()), thinking_level, - ) - return appendTypedEntry(session, entry) + )) +end +``` + +#### appendModelChange() + +```julia +function appendModelChange(session::Session, provider::String, model_id::String)::String + return appendTypedEntry(session, ModelChangeEntry( + SessionTreeEntryBase("model_change", createEntryId(session.storage), getLeafId(session.storage), create_timestamp()), + provider, + model_id, + )) +end +``` + +#### appendActiveToolsChange() + +```julia +function appendActiveToolsChange(session::Session, active_tool_names::Vector{String})::String + return appendTypedEntry(session, ActiveToolsChangeEntry( + SessionTreeEntryBase("active_tools_change", createEntryId(session.storage), getLeafId(session.storage), create_timestamp()), + active_tool_names, + )) end ``` @@ -420,11 +427,8 @@ function appendCompaction( usage::Union{Usage, Nothing}=nothing, retained_tail::Union{Vector{AgentMessage}, Nothing}=nothing, )::String - entry = CompactionEntry( - "compaction", - createEntryId(session.storage), - getLeafId(session.storage), - create_timestamp(), + return appendTypedEntry(session, CompactionEntry( + SessionTreeEntryBase("compaction", createEntryId(session.storage), getLeafId(session.storage), create_timestamp()), summary, first_kept_entry_id, tokens_before, @@ -432,8 +436,7 @@ function appendCompaction( details, usage, from_hook, - ) - return appendTypedEntry(session, entry) + )) end ``` @@ -445,25 +448,24 @@ function moveTo( entry_id::Union{String, Nothing}, summary::Union{Dict{String, Any}, Nothing}=nothing, )::Union{String, Nothing} - # Set new leaf - setLeafId(session.storage, entry_id) - - # Optionally create branch summary - if !isnothing(summary) - return appendTypedEntry(session, BranchSummaryEntry( - "branch_summary", - createEntryId(session.storage), - entry_id, - create_timestamp(), - entry_id, - summary["summary"], - get(summary, "details", nothing), - get(summary, "usage", nothing), - get(summary, "from_hook", false), - )) + # Validate entry exists + if !isnothing(entry_id) && isnothing(getEntry(session, entry_id)) + throw(SessionError("not_found", "Entry $(entry_id) not found")) end - - return nothing + # Set new leaf (creates a LeafEntry) + setLeafId(session.storage, entry_id) + # Optionally create branch summary + if isnothing(summary) + return nothing + end + return appendTypedEntry(session, BranchSummaryEntry( + SessionTreeEntryBase("branch_summary", createEntryId(session.storage), entry_id, create_timestamp()), + entry_id, + summary["summary"], + get(summary, "details", nothing), + get(summary, "usage", nothing), + get(summary, "from_hook", false), + )) end ``` @@ -482,12 +484,18 @@ session.moveTo( ) ``` +**How it works**: +1. Validates the target entry exists +2. Calls `setLeafId()` which creates a `LeafEntry` with `target_id = entry_id` +3. If `summary` is provided, creates a `BranchSummaryEntry` as a child of the target entry +4. The new leaf now points to `entry_id`, making it the root of a new branch + ## Build Session Context ```julia function buildSessionContext( path_entries::Vector{SessionTreeEntry}, - options::SessionContextBuildOptions=SessionContextBuildOptions(), + options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing), )::SessionContext state = deriveSessionContextState(path_entries) context_entries = buildContextEntries(path_entries, options) @@ -497,14 +505,36 @@ function buildSessionContext( end return SessionContext(messages, state.thinking_level, state.model, state.active_tool_names) end + +function deriveSessionContextState(path_entries::Vector{SessionTreeEntry})::Dict{String, Any} + thinking_level = "off" + model = nothing + active_tool_names = nothing + + for entry in path_entries + if entry isa ThinkingLevelChangeEntry + thinking_level = entry.thinking_level + elseif entry isa ModelChangeEntry + model = Dict("provider" => entry.provider, "modelId" => entry.model_id) + elseif entry isa MessageEntry && entry.message.role == "assistant" + model = Dict("provider" => entry.message.provider, "modelId" => entry.message.model) + elseif entry isa ActiveToolsChangeEntry + active_tool_names = copy(entry.active_tool_names) + end + end + + return Dict( + "thinking_level" => thinking_level, + "model" => model, + "active_tool_names" => active_tool_names, + ) +end ``` ### Context Entry Transform ```julia -function defaultContextEntryTransform( - path_entries::Vector{SessionTreeEntry}, -)::Vector{SessionTreeEntry} +function defaultContextEntryTransform(path_entries::Vector{SessionTreeEntry})::Vector{SessionTreeEntry} compaction = nothing for entry in path_entries if entry isa CompactionEntry @@ -512,25 +542,26 @@ function defaultContextEntryTransform( break end end - + if isnothing(compaction) return copy(path_entries) end - - # Include compaction entry - entries = [compaction] - - # Include retained tail if present + + entries::Vector{SessionTreeEntry} = [compaction] + compaction_idx = findfirst( + (entry) -> entry isa CompactionEntry && entry.id == compaction.id, + path_entries, + ) + if !isnothing(compaction.retained_tail) - compaction_idx = findfirst(e -> e.id == compaction.id, path_entries) - append!(entries, path_entries[compaction_idx+1:end]) + for i in compaction_idx+1:length(path_entries) + push!(entries, path_entries[i]) + end return entries end - - # Otherwise include entries after first_kept_entry_id + if !isnothing(compaction.first_kept_entry_id) found_first_kept = false - compaction_idx = findfirst(e -> e.id == compaction.id, path_entries) for i in 1:compaction_idx-1 entry = path_entries[i] if entry.id == compaction.first_kept_entry_id @@ -541,11 +572,26 @@ function defaultContextEntryTransform( end end end - - # Include entries after compaction - compaction_idx = findfirst(e -> e.id == compaction.id, path_entries) - append!(entries, path_entries[compaction_idx+1:end]) - + + for i in compaction_idx+1:length(path_entries) + push!(entries, path_entries[i]) + end + + return entries +end + +function buildContextEntries( + path_entries::Vector{SessionTreeEntry}, + options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing), +)::Vector{SessionTreeEntry} + entries = defaultContextEntryTransform(path_entries) + + if !isnothing(options.entry_transforms) + for transform in options.entry_transforms + entries = transform(entries) + end + end + return entries end ``` @@ -557,12 +603,12 @@ function sessionEntryToContextMessages( entry::SessionTreeEntry, index::Int64, entries::Vector{SessionTreeEntry}, - options::SessionContextBuildOptions=SessionContextBuildOptions(), + options::SessionContextBuildOptions=SessionContextBuildOptions(nothing, nothing), )::Vector{AgentMessage} if entry isa MessageEntry return [entry.message] end - + if entry isa CustomMessageEntry return [createCustomMessage( entry.custom_type, @@ -572,7 +618,7 @@ function sessionEntryToContextMessages( entry.timestamp, )] end - + if entry isa CompactionEntry messages = [createCompactionSummaryMessage( entry.summary, @@ -584,7 +630,7 @@ function sessionEntryToContextMessages( end return messages end - + if entry isa BranchSummaryEntry return [createBranchSummaryMessage( entry.summary, @@ -592,16 +638,15 @@ function sessionEntryToContextMessages( entry.timestamp, )] end - + if entry isa CustomEntry - # Custom projectors can transform custom entries if !isnothing(options.entry_projectors) && haskey(options.entry_projectors, entry.custom_type) projector = options.entry_projectors[entry.custom_type] return projector(entry, index, entries) end return AgentMessage[] end - + return AgentMessage[] end ``` @@ -648,6 +693,16 @@ Key Points: - Each branch has independent tail ``` +### getPathToRootOrCompaction + +Walks from a leaf back to the root, handling compaction entries: + +```julia +# When encountering a CompactionEntry: +# - If retained_tail is set: stop (compaction covers the tail) +# - Otherwise: skip to first_kept_entry_id and continue walking +``` + ## Compaction Strategy ### Why Compaction? @@ -679,7 +734,7 @@ LLM context windows have limits: # 4. Update storage # - Append CompactionEntry -# - Update leaf to CompactionEntry +# - Leaf automatically points to CompactionEntry (leafIdAfterEntry) ``` ### Compaction Example @@ -735,8 +790,10 @@ using AgentCore # 1. Create storage storage = JsonlSessionStorage( - SessionMetadata("session_1", "2024-01-01T00:00:00Z"), "/path/to/session.jsonl", + SessionHeader("session", 3, "session_1", created_at, "/path", nothing, nothing), + SessionTreeEntry[], + nothing, ) # 2. Create session @@ -756,7 +813,7 @@ mc_id = appendModelChange(session, "openai", "gpt-4") msg3_id = appendMessage(session, UserMessage("user", [TextContent("What can you do?")], timestamp)) msg4_id = appendMessage(session, AssistantMessage("assistant", [TextContent("I can...")], ...)) -# 7. Compact context (100K tokens → 20K) +# 7. Compact context (100K tokens -> 20K) compact_id = appendCompaction( session, "User asked about capabilities and assistant explained", @@ -771,31 +828,91 @@ compact_id = appendCompaction( # 8. Fork and branch session.moveTo(msg2_id) # Go back to msg2 -# 9. Create new branch -branch_id = appendBranchSummary( +# 9. Continue on new branch (moveTo creates branch summary when summary is provided) +branch_id = moveTo( session, - "User changed direction to focus on file operations", msg2_id, - Dict("focus" => "files"), + Dict("summary" => "User changed direction", "details" => Dict("focus" => "files")), ) # 10. Continue on new branch msg5_id = appendMessage(session, UserMessage("user", [TextContent("Let's work with files")], timestamp)) # 11. Query session context -context = buildSessionContext(session) +context = buildContext(session) # 12. Get stats stats = getSessionStats(session) println("Messages: $(stats.message_count)") println("Total tokens: $(stats.total_tokens)") -println("Cost: $$(stats.cost_total)") +println("Cost: \$(stats.cost_total)") +``` + +## Session Repo Interface + +### Session Repository Methods + +```julia +# Create a new session +create(repo::SessionRepo, options::TCreateOptions)::Session + +# Open an existing session +open(repo::SessionRepo, metadata::TMetadata)::Session + +# List sessions +list(repo::SessionRepo, options::TListOptions)::Vector{TMetadata} + +# Delete a session +delete(repo::SessionRepo, metadata::TMetadata)::Nothing + +# Fork a session (copy branch from entry) +fork(repo::SessionRepo, source::TMetadata, options::Dict{String, Any})::Session +``` + +### JSONL vs In-Memory Repos + +| Feature | JsonlSessionRepo | InMemorySessionRepo | +|---------|------------------|---------------------| +| Persistence | File-based (TODO) | In-memory only | +| Use case | Production | Testing | +| Fork | Not implemented | Uses getEntriesToFork | +| Metadata | JsonlSessionMetadata | SessionMetadata | + +### Fork Behavior (`getEntriesToFork`) + +```julia +function getEntriesToFork(storage, options)::Vector{SessionTreeEntry} + # If no entryId specified, fork from current leaf (full copy) + if !haskey(options, :entryId) || isnothing(options[:entryId]) + return getEntries(storage, Dict{String, Any}()) + end + + target = getEntry(storage, options[:entryId]) + position = get(options, "position", "before") + + if position == "at" + # Fork includes the target entry + effective_leaf_id = target.id + else + # Fork before the target (parent) + # Target must be a user message + if target isa MessageEntry && target.message.role != "user" + throw(SessionError("invalid_fork_target", "Not a user message")) + end + effective_leaf_id = target.parent_id + end + + return getPathToRootOrCompaction(storage, effective_leaf_id) +end ``` ## Best Practices 1. **Use compaction** for long conversations to stay within context limits -2. **Create branch summaries** when forking to document divergent paths -3. **Retain tail messages** after compaction for context +2. **Create branch summaries** when forking to document divergent paths (via `moveTo()` with summary) +3. **Retain tail messages** after compaction for context (`retained_tail` field) 4. **Track token usage** to optimize compaction timing 5. **Use InMemorySessionStorage** for testing +6. **Use `getBranch(session)`** to get the current path from leaf to root/compaction +7. **Use `buildContext(session)`** as the convenient Session method for building context +8. **Use `mergeContextBuildOptions(session, options)`** to combine session-level and call-level transforms/projectors diff --git a/learning/06-TOOLS.md b/learning/06-TOOLS.md index 1e1f8bc..f8682aa 100644 --- a/learning/06-TOOLS.md +++ b/learning/06-TOOLS.md @@ -1,465 +1,187 @@ # AgentCore.jl - Tools Deep Dive -## Tool Architecture with Data Flow +## Tool Types (from types.jl) -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Tool Layer │ -└─────────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────────┐ -│ AgentTool │ -│ - name: String (identifier) │ -│ - label: String (display name) │ -│ - description: String (what it does) │ -│ - parameters::Any (JSON schema or type) │ -│ - execute::Function (main logic) │ -│ - prepare_arguments::Union{Function, Nothing} │ -│ - execution_mode::Union{ToolExecutionMode, Nothing} │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ BashTool │ │ ReadTool │ │ WriteTool │ - │ - bash() │ │ - read() │ │ - write() │ - └─────────────┘ └─────────────┘ └─────────────┘ - ┌─────────────┐ - │ EditTool │ - │ - edit() │ - └─────────────┘ - -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Tool Execution Data Flow │ -└─────────────────────────────────────────────────────────────────────────────┘ - - Input: AssistantMessage (from LLM) - content::Vector{MessageContent} - └─ Contains: TextContent[] and ToolCall[] - - ▼ - ┌─────────────────────────────────────────────────────────────────────┐ - │ extract ToolCalls │ - │ filter(c -> c isa ToolCall, assistant_message.content) │ - └─────────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌─────────────────────────────────────────────────────────────────────┐ - │ ToolCall Type │ - │ • type::String ("tool") │ - │ • id::String (unique identifier) │ - │ • name::String (tool name to execute) │ - │ • arguments::Dict{String, Any} (JSON-like arguments) │ - │ • partial_json::Union{String, Nothing} │ - └─────────────────────────────────────────────────────────────────────┘ - │ - ├─► prepareToolCall() - │ Input: tool_call::ToolCall - │ Output: Union{PreparedToolCall, ImmediateToolCallOutcome} - │ - │ Steps: - │ 1. Find tool by name in context.tools - │ 2. before_tool_call hook (optional) - │ Input: BeforeToolCallContext - │ Output: BeforeToolCallResult (block, reason) - │ 3. prepareToolCallArguments() (optional) - │ Input: tool_call.arguments::Dict{String, Any} - │ Output: prepared_arguments::Any - │ 4. validateToolArguments() - │ Input: prepared_tool_call.arguments - │ Output: validated_args::Any - │ 5. Return: PreparedToolCall(kind, tool_call, tool, args) - │ - ├─► executePreparedToolCall() (if prepared) - │ Input: PreparedToolCall - │ Output: ExecutedToolCallOutcome - │ - │ tool.execute(tool_call.id, args, signal, on_update) - │ Input: tool_call_id::String - │ args::Any - │ signal::Union{Any, Nothing} - │ on_update::Function (streaming updates) - │ Output: AgentToolResultMutable - │ • content::Vector{MessageContent} - │ • details::Any - │ • usage::Union{Usage, Nothing} - │ • terminate::Union{Bool, Nothing} - │ - ├─► finalizeExecutedToolCall() - │ Input: ExecutedToolCallOutcome - │ Output: FinalizedToolCallOutcome - │ - │ Steps: - │ 1. after_tool_call hook (optional) - │ Input: AfterToolCallContext - │ Output: AfterToolCallResult (patches) - │ 2. Apply patches to result - │ 3. Return: FinalizedToolCallOutcome(tool_call, result, is_error) - │ - └─► createToolResultMessage() - Input: FinalizedToolCallOutcome - Output: ToolResultMessage - • role: "toolResult" - • tool_call_id::String (matches ToolCall.id) - • tool_name::String (matches ToolCall.name) - • content::Vector{MessageContent} - • details::Any - • usage::Union{Usage, Nothing} - • added_tool_names::Union{Vector{String}, Nothing} - • is_error::Bool - • timestamp::Timestamp (Int64) - │ - ▼ - ┌─────────────────────────────────────────────────────────────────────┐ - │ ToolResultMessage[] (one per ToolCall) │ - └─────────────────────────────────────────────────────────────────────┘ - │ - ├─► Append to context.messages (AgentState.messages) - └─► Next turn: LLM sees tool results as input -``` - -## Built-in Tools - -## Built-in Tools - -### 1. BashTool +### AgentTool (struct) ```julia -struct BashToolOptions{TContext} - command_prefix::Union{String, Nothing} - prepare::Union{BashPrepare{TContext}, Nothing} +struct AgentTool{TParameters, TDetails} + name::String # tool identifier + label::String # display name + description::String # what it does + parameters::TParameters # JSON schema or type + execute::Function # (tool_call_id, params, signal, on_update, context) -> AgentToolResult + prepare_arguments::Union{Function, Nothing} + execution_mode::Union{ToolExecutionMode, Nothing} +end +``` + +### AgentToolResult (struct) + +```julia +struct AgentToolResult{T} + content::Vector{MessageContent} + details::T + usage::Union{Usage, Nothing} + added_tool_names::Union{Vector{String}, Nothing} + terminate::Union{Bool, Nothing} +end +``` + +### ToolCall (struct) + +```julia +struct ToolCall + type::String # always "tool" + id::String # unique identifier + name::String # tool name to execute + arguments::Dict{String, Any} # JSON-like arguments + partial_json::Union{String, Nothing} +end +``` + +### ToolExecutionMode (enum) + +```julia +@enum ToolExecutionMode begin + EXECUTION_SEQUENTIAL = "sequential" + EXECUTION_PARALLEL = "parallel" +end +``` + +## Tool Execution Flow + +``` +AssistantMessage (from LLM) + content::Vector{MessageContent} + └─ Contains: TextContent[] and ToolCall[] + ▼ + Agent.execute() (in agent.jl) + └─ before_tool_call hook (Agent.before_tool_call, optional) + Input: BeforeToolCallContext + Output: BeforeToolCallResult (block, reason) + ▼ + For each ToolCall: + tool = find_tool(name) + tool.execute(tool_call_id, args, signal, on_update, context) + ▼ + AgentToolResult{T}(content, details, usage, added_tool_names, terminate) + ▼ + └─ after_tool_call hook (Agent.after_tool_call, optional) + Input: AfterToolCallContext + Output: AfterToolCallResult (patches: content, details, is_error, usage, terminate) + ▼ + ToolResultMessage (one per ToolCall) + role: "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 + ▼ + Append to AgentState.messages + └─ Next turn: LLM sees tool results as input +``` + +## Built-in Tools + +### 1. BashTool (`tools/bash.jl`) + +```julia +struct BashExecution + command::String + cwd::String + env::Dict{String, String} + inherit_env::Bool end -struct BashPrepare{TContext} +mutable struct BashPrepare{TContext} function::Function context::TContext signal::Union{Any, Nothing} end -struct BashToolDetails +mutable struct BashToolOptions{TContext} + command_prefix::Union{String, Nothing} + prepare::Union{BashPrepare{TContext}, Nothing} +end + +mutable struct BashToolDetails truncation::Union{Any, Nothing} full_output_path::Union{String, Nothing} end + +function createBashTool{TContext}(options::Union{BashToolOptions{TContext}, Nothing}=nothing) where TContext ``` -#### createBashTool() +**Execute signature**: `(tool_call_id, params, signal, on_update, context) -> AgentToolResult` + +**Note**: The actual bash execution is a TODO stub in the current source. + +### 2. ReadTool (`tools/read.jl`) ```julia -function createBashTool{TContext}(options::Union{BashToolOptions{TContext}, Nothing}=nothing) - return AgentTool( - "bash", - "bash", - "Execute a bash command in the current working directory.", - Dict{String, Any}(), - (tool_call_id, params, signal, on_update, context) -> begin - # Execute command - result = executeBashCommand(params, signal, on_update) - - # Return result - return AgentToolResult( - [TextContent(result.output)], - BashToolDetails(result.truncation, result.full_path), - nothing, - nothing, - result.terminate, - ) - end, - nothing, # prepare_arguments - nothing, # execution_mode (default: use config) - ) +mutable struct ReadToolDetails + truncation::Union{Any, Nothing} +end + +mutable struct ReadToolOptions + auto_resize_images::Bool + image_processor::Union{Any, Nothing} +end + +function createReadTool{TContext}(options::Union{ReadToolOptions, Nothing}=nothing) where TContext +``` + +**Execute signature**: `(tool_call_id, params, signal, on_update, context) -> AgentToolResult` + +### 3. WriteTool (`tools/write.jl`) + +```julia +function createWriteTool{TContext}() where TContext +``` + +**Execute signature**: `(tool_call_id, params, signal, on_update, context) -> AgentToolResult` + +### 4. EditTool (`tools/edit.jl`) + +```julia +mutable struct EditToolDetails + diff::String + patch::String + first_changed_line::Union{Int64, Nothing} +end + +function createEditTool{TContext}() where TContext +``` + +**Execute signature**: `(tool_call_id, params, signal, on_update, context) -> AgentToolResult` + +## Tool Hooks (on Agent struct) + +The `Agent` struct in `agent.jl` has these hook fields: + +```julia +mutable struct Agent + ... + before_tool_call::Union{Function, Nothing} + after_tool_call::Union{Function, Nothing} + prepare_next_turn::Union{Function, Nothing} + prepare_next_turn_with_context::Union{Function, Nothing} + ... end ``` -**Parameters Schema**: -```json -{ - "command": "string", - "timeout": "number (optional)", - "cwd": "string (optional)", - "env": "object (optional)" -} -``` +Configured via `Agent(Dict(...))` options: +- `:beforeToolCall` → `Agent.before_tool_call` +- `:afterToolCall` → `Agent.after_tool_call` +- `:prepareNextTurn` → `Agent.prepare_next_turn` +- `:prepareNextTurnWithContext` → `Agent.prepare_next_turn_with_context` -**Example**: -```julia -# Create tool -bash_tool = createBashTool() - -# Agent receives command -tool_call = ToolCall("tool", "tc1", "bash", Dict( - "command" => "ls -la", - "timeout" => 30 -), nothing) - -# Execute -result = bash_tool.execute( - "tc1", - Dict("command" => "ls -la", "timeout" => 30), - nothing, - on_update, # Callback for streaming output - nothing, -) - -# Result -AgentToolResult( - [TextContent("total 12\n-rw-r--r-- 1 user user 100 Jan 1 file1.md\n-rw-r--r-- 1 user user 200 Jan 2 file2.md\n")], - BashToolDetails(truncation_info, nothing), - nothing, - nothing, - nothing, -) -``` - -### 2. ReadTool - -```julia -struct ReadToolOptions{TContext} - max_size::Union{Int64, Nothing} - max_lines::Union{Int64, Nothing} - image_processor::Union{ReadImageProcessor, Nothing} - prepare::Union{ReadPrepare{TContext}, Nothing} -end - -struct ReadImageProcessor - function::Function - context::Any -end - -struct ReadImageProcessorResult - content::Vector{MessageContent} - usage::Union{Usage, Nothing} -end -``` - -#### createReadTool() - -```julia -function createReadTool{TContext}(options::Union{ReadToolOptions{TContext}, Nothing}=nothing) - return AgentTool( - "read", - "read", - "Read a file from the file system.", - Dict{String, Any}(), - (tool_call_id, params, signal, on_update, context) -> begin - # Read file - result = readFileSystem(params, signal, options) - - # Process content - content = if isImage(params.path) - # Image processing - image_result = options.image_processor.function(result.path, context) - image_result.content - else - # Text content - [TextContent(result.content)] - end - - return AgentToolResult( - content, - ReadToolDetails(result.size, result.truncated, result.full_path), - nothing, - nothing, - nothing, - ) - end, - nothing, - nothing, - ) -end -``` - -**Parameters Schema**: -```json -{ - "path": "string" -} -``` - -**Example**: -```julia -# Create tool -read_tool = createReadTool() - -# Agent requests to read file -tool_call = ToolCall("tool", "tc2", "read", Dict( - "path" => "src/main.jl" -), nothing) - -# Execute -result = read_tool.execute("tc2", Dict("path" => "src/main.jl"), nothing, nothing, nothing) - -# Result -AgentToolResult( - [TextContent("module Main\nfunction main()\n println(\"Hello\")\nend\nend\n")], - ReadToolDetails(1234, false, "/path/to/src/main.jl"), - nothing, - nothing, - nothing, -) -``` - -### 3. WriteTool - -```julia -struct WriteToolInput - path::String - content::String -end -``` - -#### createWriteTool() - -```julia -function createWriteTool{TContext}(options::Union{WriteToolOptions{TContext}, Nothing}=nothing) - return AgentTool( - "write", - "write", - "Write content to a file.", - Dict{String, Any}(), - (tool_call_id, params, signal, on_update, context) -> begin - # Write file - result = writeToFile(params, signal) - - return AgentToolResult( - [TextContent(result.message)], - nothing, - nothing, - nothing, - nothing, - ) - end, - nothing, - nothing, - ) -end -``` - -**Parameters Schema**: -```json -{ - "path": "string", - "content": "string" -} -``` - -**Example**: -```julia -# Create tool -write_tool = createWriteTool() - -# Agent wants to write file -tool_call = ToolCall("tool", "tc3", "write", Dict( - "path" => "output.txt", - "content" => "Hello World" -), nothing) - -# Execute -result = write_tool.execute("tc3", Dict( - "path" => "output.txt", - "content" => "Hello World" -), nothing, nothing, nothing) - -# Result -AgentToolResult( - [TextContent("File written: output.txt (11 bytes)")], - nothing, - nothing, - nothing, - nothing, -) -``` - -### 4. EditTool - -```julia -struct EditToolInput - path::String - find::String - replacement::String -end - -struct EditToolDetails - edits::Vector{Edit} - before_content::String - after_content::String -end -``` - -#### createEditTool() - -```julia -function createEditTool{TContext}(options::Union{EditToolOptions{TContext}, Nothing}=nothing) - return AgentTool( - "edit", - "edit", - "Edit a file by finding and replacing text.", - Dict{String, Any}(), - (tool_call_id, params, signal, on_update, context) -> begin - # Read file - before_content = read(params.path) - - # Apply edit - after_content = replace(before_content, params.find => params.replacement) - - # Write file - write(params.path, after_content) - - return AgentToolResult( - [TextContent("Edit applied successfully")], - EditToolDetails([Edit(params.find, params.replacement)], before_content, after_content), - nothing, - nothing, - nothing, - ) - end, - nothing, - nothing, - ) -end -``` - -**Parameters Schema**: -```json -{ - "path": "string", - "find": "string", - "replacement": "string" -} -``` - -**Example**: -```julia -# Create tool -edit_tool = createEditTool() - -# Agent wants to replace text -tool_call = ToolCall("tool", "tc4", "edit", Dict( - "path" => "README.md", - "find" => "v1.0.0", - "replacement" => "v2.0.0" -), nothing) - -# Execute -result = edit_tool.execute("tc4", Dict( - "path" => "README.md", - "find" => "v1.0.0", - "replacement" => "v2.0.0" -), nothing, nothing, nothing) - -# Result -AgentToolResult( - [TextContent("Edit applied: README.md")], - EditToolDetails([Edit("v1.0.0", "v2.0.0")], "Version 1.0.0", "Version 2.0.0"), - nothing, - nothing, - nothing, -) -``` - -## Tool Execution Hooks - -### before_tool_call +### BeforeToolCallContext / BeforeToolCallResult (from types.jl) ```julia struct BeforeToolCallContext @@ -475,32 +197,7 @@ struct BeforeToolCallResult end ``` -**Usage**: -```julia -function myBeforeToolCall(context, signal) - tool_name = context.tool_call.name - - # Block dangerous commands - if tool_name == "bash" && contains(context.args["command"], "rm -rf /") - return BeforeToolCallResult( - true, - "Blocking dangerous command: rm -rf /" - ) - end - - # Log tool execution - println("Executing tool: $tool_name") - - return nothing # Allow execution -end - -# Configure agent -agent = Agent(Dict( - :beforeToolCall => myBeforeToolCall, -)) -``` - -### after_tool_call +### AfterToolCallContext / AfterToolCallResult (from types.jl) ```julia struct AfterToolCallContext @@ -521,37 +218,7 @@ struct AfterToolCallResult end ``` -**Usage**: -```julia -function myAfterToolCall(context, signal) - tool_name = context.tool_call.name - - # Modify bash output - if tool_name == "bash" - # Add timestamp to output - new_content = [ - TextContent("[Executed at $(Dates.now())]\n"), - context.result.content[1], - ] - return AfterToolCallResult( - content = new_content, - details = context.result.details, - is_error = context.is_error, - usage = context.result.usage, - terminate = context.result.terminate, - ) - end - - return nothing # Use original result -end - -# Configure agent -agent = Agent(Dict( - :afterToolCall => myAfterToolCall, -)) -``` - -### prepare_next_turn +### PrepareNextTurnContext / AgentLoopTurnUpdate (from types.jl) ```julia struct PrepareNextTurnContext @@ -568,193 +235,73 @@ struct AgentLoopTurnUpdate end ``` -**Usage**: -```julia -function myPrepareNextTurn(context, signal) - # Check if we should use a different model - last_message = context.message - tool_results = context.tool_results - - # If tool execution had errors, use more capable model - has_errors = any(r -> r.is_error, tool_results) - if has_errors - return AgentLoopTurnUpdate( - context = context.context, - model = Model("gpt-4", "GPT-4", "openai", "openai", "", ...), - thinking_level = THINKING_HIGH, - ) - end - - return nothing # Keep current settings -end - -# Configure agent -agent = Agent(Dict( - :prepareNextTurn => myPrepareNextTurn, -)) -``` - ## Tool Execution Modes ### Sequential Execution ```julia -# Tools run one at a time, in order -# Use case: Tools that modify shared state - -# Configure tool -bash_tool = AgentTool( - "bash", - "bash", - "Execute bash command", - ..., - execute, - nothing, - EXECUTION_SEQUENTIAL, # Force sequential -) - -# Or configure globally +# Configure on Agent agent = Agent(Dict( :toolExecution => EXECUTION_SEQUENTIAL, )) ``` -**Example Scenario**: -```julia -# Sequential execution (correct order) - -1. Tool 1: create_directory("build/") - └─ Creates build/ directory - -2. Tool 2: write("build/app.js", "...") - └─ Writes file to build/ - -(If parallel: might fail because build/ doesn't exist yet) -``` - -### Parallel Execution +### Parallel Execution (default) ```julia -# Tools run concurrently -# Use case: Independent operations - -# Default behavior agent = Agent(Dict( - :toolExecution => EXECUTION_PARALLEL, # Default + :toolExecution => EXECUTION_PARALLEL, )) ``` -**Example Scenario**: -```julia -# Parallel execution (independent operations) - -1. Tool 1: read("README.md") ─────┐ -2. Tool 2: read("CHANGELOG.md") ─┼─► Run simultaneously -3. Tool 3: read("LICENSE") ──────┘ - -(Parallel: All three read operations can happen at once) -(Sequential: Would wait for each read to complete) -``` - -## Custom Tools - -### Example: Database Tool +Tools can also specify their own mode: ```julia -function createDatabaseTool() - return AgentTool( - "database", - "database", - "Execute SQL queries against the database.", - Dict{String, Any}( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string"), - "params" => Dict("type" => "array", "items" => Dict("type" => "string")), - ), - "required" => ["query"], - ), - (tool_call_id, params, signal, on_update, context) -> begin - # Execute query - query = params["query"] - result = executeQuery(query) - - # Format output - output = formatQueryResult(result) - - return AgentToolResult( - [TextContent(output)], - Dict("rows_affected" => result.rows_affected), - nothing, - nothing, - nothing, - ) - end, - nothing, - EXECUTION_SEQUENTIAL, - ) -end - -# Usage -db_tool = createDatabaseTool() -agent = Agent(Dict(:tools => [db_tool])) +agent_tool = AgentTool( + "name", + "label", + "description", + params_schema, + execute_fn, + nothing, + EXECUTION_SEQUENTIAL, # or EXECUTION_PARALLEL +) ``` -### Example: HTTP Request Tool +## Tool Exports (from tools/index.jl) ```julia -function createHTTPTool() - return AgentTool( - "http", - "http", - "Make HTTP requests.", - Dict{String, Any}( - "type" => "object", - "properties" => Dict( - "url" => Dict("type" => "string"), - "method" => Dict("type" => "string", "enum" => ["GET", "POST", "PUT", "DELETE"]), - "body" => Dict("type" => "string"), - "headers" => Dict("type" => "object"), - ), - "required" => ["url", "method"], - ), - (tool_call_id, params, signal, on_update, context) -> begin - # Make request - url = params["url"] - method = params["method"] - body = get(params, "body", nothing) - headers = get(params, "headers", Dict()) - - response = makeHTTPRequest(method, url, body, headers) - - return AgentToolResult( - [TextContent(response.body)], - Dict( - "status_code" => response.status_code, - "headers" => response.headers, - ), - nothing, - nothing, - nothing, - ) - end, - nothing, - EXECUTION_PARALLEL, - ) -end +export + createBashTool, + createReadTool, + createWriteTool, + createEditTool, + BashExecution, + BashPrepare, + BashToolDetails, + BashToolInput, + BashToolOptions, + EditToolDetails, + EditToolInput, + ReadToolDetails, + ReadToolInput, + ReadToolOptions, + ReadImageProcessor, + ReadImageProcessorResult, + WriteToolInput ``` -## Complete Example +## Example: Creating and Using Tools ```julia using AgentCore -# 1. Create tools +# Create tools bash_tool = createBashTool() read_tool = createReadTool() write_tool = createWriteTool() -# 2. Configure hooks +# Configure hooks before_hook = (context, signal) -> begin println("About to execute: $(context.tool_call.name)") return nothing @@ -769,22 +316,17 @@ after_hook = (context, signal) -> begin return nothing end -# 3. Create agent +# Create agent with tools and hooks agent = Agent(Dict( :systemPrompt => "You are a helpful assistant with file system access.", :tools => [bash_tool, read_tool, write_tool], :beforeToolCall => before_hook, :afterToolCall => after_hook, + :toolExecution => EXECUTION_PARALLEL, )) -# 4. Run conversation +# Run prompt prompt(agent, "List files in current directory and read the first one") - -# 5. Agent will: -# - Execute bash("ls -la") tool -# - Parse output to find first file -# - Execute read("path/to/file") tool -# - Return content to user ``` ## Best Practices diff --git a/learning/07-AGENTHARNESS.md b/learning/07-AGENTHARNESS.md index fdd7932..837da78 100644 --- a/learning/07-AGENTHARNESS.md +++ b/learning/07-AGENTHARNESS.md @@ -1,79 +1,98 @@ -# AgentCore.jl - AgentHarness Deep Dive +# AgentCore.jl - AgentHarness Design Reference -## AgentHarness Architecture +## Status + +> **Note**: The AgentHarness module (`src/agent_harness.jl`) is **not yet implemented**. This document +> describes the intended design based on types defined in `src/harness_types.jl`. The types, events, +> and interfaces below are defined but the harness that connects them is a planned feature. +> +> Several modules referenced in `src/AgentCore.jl` are also not yet implemented: +> `compaction/compaction.jl`, `compaction/utils.jl`, `compaction/branch_summarization.jl`, +> `utils/truncate.jl`, `utils/shell_output.jl`, `proxy.jl`. +> +> Type placeholders not yet defined: `AgentLoopConfig`, `Promise`, `AbortSignal`, `EventStream`, +> `Context`. The `SessionRepo` methods in `harness_types.jl` return `Promise()` stubs. + +## AgentHarness Architecture (Planned) ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ AgentHarness Layer │ -└─────────────────────────────────────────────────────────────────────────────┐ +AgentHarness = Agent + Session + Resources + Hooks -┌─────────────────────────────────────────────────────────────────────────────┐ -│ AgentHarness = Agent + Session + Resources │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ AgentHarness │ │ -│ │ - Manages Agent instances │ │ -│ │ - Provides Session persistence │ │ -│ │ - Manages resources (skills, prompt templates) │ │ -│ │ - Handles extension hooks │ │ -│ │ - Coordinates tool execution with context │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌─────────────────────┼─────────────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Agent │ │ SessionRepo │ │ Resources │ │ -│ │ (state, │ │ (create, │ │ (skills, │ │ -│ │ events) │ │ open, │ │ templates) │ │ -│ └──────────────┘ │ list) │ └──────────────┘ │ -│ └──────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────┐ │ -│ │ Session │ │ -│ │ (history, │ │ -│ │ branching) │ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ + AgentHarness (to be implemented in src/agent_harness.jl) + ├── Manages Agent instances + ├── Provides Session persistence via SessionRepo + ├── Manages resources (skills, prompt templates) + ├── Handles extension hooks (BeforeAgentStart, BeforeProviderPayload, etc.) + └── Coordinates tool execution with AgentHarnessToolContextSource -┌─────────────────────────────────────────────────────────────────────────────┐ -│ AgentHarnessEvent System │ -└─────────────────────────────────────────────────────────────────────────────┘ - -AgentEvent (from Agent) -├─ AgentHarnessOwnEvent -│ ├─ BeforeAgentStartEvent -│ ├─ ContextEvent -│ ├─ BeforeProviderRequestEvent -│ ├─ BeforeProviderPayloadEvent -│ ├─ AfterProviderResponseEvent -│ ├─ ToolCallEvent -│ ├─ ToolResultEvent -│ ├─ SessionBeforeCompactEvent -│ ├─ SessionCompactEvent -│ ├─ SessionBeforeTreeEvent -│ ├─ SessionTreeEvent -│ ├─ ModelUpdateEvent -│ ├─ ThinkingLevelUpdateEvent -│ ├─ ToolsUpdateEvent -│ ├─ ResourcesUpdateEvent -│ └─ ... (other session events) - -└─ AgentEvent (from AgentLoop) - ├─ AgentStartEvent / AgentEndEvent - ├─ TurnStartEvent / TurnEndEvent - ├─ MessageStartEvent / MessageEndEvent - └─ ToolExecutionStartEvent / ToolExecutionEndEvent + AgentHarnessOptions (src/harness_types.jl:1067) + ├── session::Session + ├── models::Any + ├── tools::Union{Vector{TTool}, Nothing} + ├── resources::Union{AgentHarnessResources, Nothing} + ├── system_prompt::Union{AgentHarnessSystemPrompt, Nothing} + ├── stream_options::Union{AgentHarnessStreamOptions, Nothing} + ├── retry::Union{Any, Nothing} + ├── model::Model + ├── thinking_level::Union{ThinkingLevel, Nothing} + ├── active_tool_names::Union{Vector{String}, Nothing} + ├── steering_mode::Union{QueueMode, Nothing} + ├── follow_up_mode::Union{QueueMode, Nothing} + └── tool_context::Union{AgentHarnessToolContextSource, Nothing} ``` -## AgentHarness Components +## Event Type Hierarchy (Actual) -### 1. AgentHarnessOptions +The harness event types are defined as `mutable struct` in `harness_types.jl`. +They are NOT subtypes of `AgentHarnessEvent` or `AgentHarnessOwnEvent` - those +abstract types exist but nothing inherits from them. + +``` +AgentEvent (abstract, types.jl:196) +├── AgentStartEvent (types.jl:198) +├── AgentEndEvent (types.jl:199) +├── TurnStartEvent (types.jl:202) +├── TurnEndEvent (types.jl:203) +├── MessageStartEvent (types.jl:207) +├── MessageUpdateEvent (types.jl:210) +├── MessageEndEvent (types.jl:214) +├── ToolExecutionStartEvent (types.jl:217) +├── ToolExecutionUpdateEvent (types.jl:222) +└── ToolExecutionEndEvent (types.jl:228) + +AgentHarnessOwnEvent (abstract, harness_types.jl:850) + └── (nothing inherits from this) + +AgentHarnessEvent (abstract, harness_types.jl:856) + └── (nothing inherits from this) + +Harness event structs (harness_types.jl) - mutable structs, not subtypes: +├── BeforeAgentStartEvent (line 653) +├── ContextEvent (line 665) +├── BeforeProviderRequestEvent (line 674) +├── BeforeProviderPayloadEvent (line 685) +├── AfterProviderResponseEvent (line 695) +├── ToolCallEvent (line 705) +├── ToolResultEvent (line 716) +├── SessionBeforeCompactEvent (line 731) +├── SessionCompactEvent (line 743) +├── SessionBeforeTreeEvent (line 753) +├── SessionTreeEvent (line 763) +├── RetryScheduledEvent (line 775) +├── RetryAttemptStartEvent (line 788) +├── RetryFinishedEvent (line 797) +├── ModelUpdateEvent (line 806) +├── ThinkingLevelUpdateEvent (line 817) +├── ToolsUpdateEvent (line 827) +└── ResourcesUpdateEvent (line 840) +``` + +## Types (from harness_types.jl) + +### AgentHarnessOptions (line 1067) ```julia -mutable struct AgentHarnessOptions{ - TC, TSkill<:Skill, TPromptTemplate<:PromptTemplate, TTool<:AgentHarnessTool -} +mutable struct AgentHarnessOptions{TC<:Any, TSkill<:Skill, TPromptTemplate<:PromptTemplate, TTool<:AgentHarnessTool} session::Session models::Any tools::Union{Vector{TTool}, Nothing} @@ -90,21 +109,9 @@ mutable struct AgentHarnessOptions{ end ``` -**Purpose**: Configure AgentHarness with all necessary options +**Purpose**: Configure AgentHarness with all necessary options (defined but harness not implemented). -**Key fields**: -- `session`: Session instance for persistence -- `models`: Available models -- `tools`: Agent tools -- `resources`: Skills and prompt templates -- `system_prompt`: System prompt (string or function) -- `stream_options`: LLM streaming options -- `model`: Default model -- `thinking_level`: Default thinking level -- `active_tool_names`: Active tools -- `tool_context`: Context source for tools - -### 2. AgentHarnessResources +### AgentHarnessResources (line 82) ```julia mutable struct AgentHarnessResources{TSkill<:Skill, TPromptTemplate<:PromptTemplate} @@ -113,9 +120,7 @@ mutable struct AgentHarnessResources{TSkill<:Skill, TPromptTemplate<:PromptTempl end ``` -**Purpose**: Load and manage skills and prompt templates - -### 3. Skill +### Skill (line 68) ```julia mutable struct Skill @@ -127,9 +132,9 @@ mutable struct Skill end ``` -**Purpose**: Define specialized instructions for specific tasks +**Loading**: `loadSkills(env, dir)` is defined in `skills.jl` but **parsing is stubbed** - currently returns `nothing, diagnostics`. The frontmatter parsing code (lines 266-300 of skills.jl) is commented out as TODO. -**Format**: +**Skill format**: ```markdown { @@ -144,7 +149,7 @@ end This skill provides instructions for working with files... ``` -### 4. PromptTemplate +### PromptTemplate (line 76) ```julia mutable struct PromptTemplate @@ -154,7 +159,7 @@ mutable struct PromptTemplate end ``` -**Purpose**: Reusable prompt snippets with arguments +**Loading**: `loadPromptTemplates(env, paths)` is defined in `prompt_templates.jl` but **parsing is stubbed** - currently returns `nothing, diagnostics`. Frontmatter parsing is commented out as TODO (lines 188-215). **Format**: ```markdown @@ -169,7 +174,7 @@ $1 $ARGUMENTS ``` -### 5. AgentHarnessStreamOptions +### AgentHarnessStreamOptions (line 109) ```julia mutable struct AgentHarnessStreamOptions @@ -183,234 +188,35 @@ mutable struct AgentHarnessStreamOptions end ``` -**Purpose**: Configure LLM API call options - -## SessionRepo Interface +### AgentHarnessStreamOptionsPatch (line 119) ```julia -abstract type SessionRepo< - TMetadata<:SessionMetadata, - TCreateOptions, - TListOptions -> end -``` - -### Repo Methods - -```julia -# Create new session -create(repo::SessionRepo, options::TCreateOptions)::Promise{Session} - -# Open existing session -open(repo::SessionRepo, metadata::TMetadata)::Promise{Session} - -# List sessions -list(repo::SessionRepo, options::TListOptions)::Promise{Vector{TMetadata}} - -# Delete session -delete(repo::SessionRepo, metadata::TMetadata)::Promise{Nothing} - -# Fork session (create branch) -fork(repo::SessionRepo, source::TMetadata, options::Dict{String, Any})::Promise{Session} -``` - -### JsonlSessionRepo - -```julia -# JSONL-based session repository -# - Sessions stored as JSONL files -# - Supports create, open, list, delete, fork -# - Branch navigation via session tree -``` - -## Extension Hooks - -### Hook Types - -```julia -# Before agent starts -BeforeAgentStartEvent -├─ prompt: String -├─ images: Union{Vector{ImageContent}, Nothing} -├─ system_prompt: String -└─ resources: AgentHarnessResources - -BeforeAgentStartResult -├─ messages: Union{Vector{AgentMessage}, Nothing} -└─ system_prompt: Union{String, Nothing} - -# Context event -ContextEvent -└─ messages: Vector{AgentMessage} - -ContextResult -└─ messages: Vector{AgentMessage} - -# Before LLM request -BeforeProviderRequestEvent -├─ model: Model -├─ session_id: String -└─ stream_options: AgentHarnessStreamOptions - -BeforeProviderRequestResult -└─ stream_options: Union{AgentHarnessStreamOptionsPatch, Nothing} - -# Before LLM payload -BeforeProviderPayloadEvent -├─ model: Model -└─ payload: Any - -BeforeProviderPayloadResult -└─ payload: Any - -# After LLM response -AfterProviderResponseEvent -├─ status: Int64 -└─ headers: Dict{String, String} - -# Tool call -ToolCallEvent -├─ tool_call_id: String -├─ tool_name: String -└─ input: Dict{String, Any} - -ToolCallResult -├─ block: Union{Bool, Nothing} -└─ reason: Union{String, Nothing} - -# Tool result -ToolResultEvent -├─ tool_call_id: String -├─ tool_name: String -├─ input: Dict{String, Any} -├─ content: Vector{MessageContent} -├─ details: Any -├─ is_error: Bool -└─ usage: Union{Usage, Nothing} - -ToolResultPatch -├─ content: Union{Vector{MessageContent}, Nothing} -├─ details: Union{Any, Nothing} -├─ is_error: Union{Bool, Nothing} -├─ usage: Union{Usage, Nothing} -└─ terminate: Union{Bool, Nothing} - -# Session compaction -SessionBeforeCompactEvent -├─ preparation: Any -├─ branch_entries: Vector{SessionTreeEntry} -├─ custom_instructions: Union{String, Nothing} -└─ signal: Any - -SessionBeforeCompactResult -├─ cancel: Union{Bool, Nothing} -└─ compaction: Union{CompactResult, Nothing} - -SessionCompactEvent -├─ compaction_entry: CompactionEntry -└─ from_hook: Bool - -# Session tree (branching) -SessionBeforeTreeEvent -├─ preparation: Any -└─ signal: Any - -SessionBeforeTreeResult -├─ cancel: Union{Bool, Nothing} -├─ summary: Union{Dict{String, Any}, Nothing} -├─ custom_instructions: Union{String, Nothing} -├─ replace_instructions: Union{Bool, Nothing} -└─ label: Union{String, Nothing} - -SessionTreeEvent -├─ new_leaf_id: Union{String, Nothing} -├─ old_leaf_id: Union{String, Nothing} -├─ summary_entry: Union{BranchSummaryEntry, Nothing} -└─ from_hook: Union{Bool, Nothing} -``` - -### Hook Usage Examples - -#### BeforeAgentStartHook - -```julia -function beforeAgentStart(event, signal) - # Modify system prompt based on context - new_system_prompt = "$(event.system_prompt)\n\nUser prefers concise responses." - - # Prepend initial messages - initial_messages = [ - UserMessage("user", [TextContent("Context: $(event.prompt)")], timestamp), - ] - - return BeforeAgentStartResult( - initial_messages, - new_system_prompt, - ) -end - -# Configure harness -harness = AgentHarness(Dict( - :beforeAgentStart => beforeAgentStart, -)) -``` - -#### BeforeProviderPayloadHook - -```julia -function beforeProviderPayload(event, signal) - # Modify LLM payload before sending - payload = event.payload - - # Add custom metadata - payload.metadata = merge(payload.metadata, Dict( - "session_id" => event.session_id, - "timestamp" => Dates.now(), - )) - - return BeforeProviderPayloadResult(payload) +mutable struct AgentHarnessStreamOptionsPatch + transport::Union{String, Nothing} + timeout_ms::Union{Int64, Nothing} + max_retries::Union{Int64, Nothing} + max_retry_delay_ms::Union{Int64, Nothing} + cache_retention::Union{String, Nothing} + headers::Union{Dict{String, String}, Nothing} + metadata::Union{Dict{String, Any}, Nothing} end ``` -#### ToolCallHook +### AgentHarnessTool (line 91) ```julia -function toolCall(event, signal) - # Block dangerous tool calls - if event.tool_name == "bash" && contains(event.input["command"], "rm -rf /") - return ToolCallResult(true, "Blocking dangerous command") - end - - # Log tool execution - println("Tool call: $(event.tool_name)") - - return nothing # Allow execution +mutable struct AgentHarnessTool{TContext, TParameters, TDetails} + name::String + label::String + description::String + parameters::TParameters + execute::Function + prepareArguments::Union{Function, Nothing} + executionMode::Union{ToolExecutionMode, Nothing} end ``` -#### BeforeCompactHook - -```julia -function beforeCompact(event, signal) - # Add custom instructions for compaction - custom_instructions = """ - Focus on retaining user preferences and key decisions. - Omit verbose tool outputs that don't add value. - """ - - return SessionBeforeCompactResult( - false, # Don't cancel - Dict( - "summary" => "Custom compaction with focus on user intent", - "custom_instructions" => custom_instructions, - ), - ) -end -``` - -## Tool Context - -### AgentHarnessToolContextSource +### AgentHarnessToolContextSource (line 101) ```julia mutable struct AgentHarnessToolContextSource{TContext} @@ -418,337 +224,428 @@ mutable struct AgentHarnessToolContextSource{TContext} end ``` -**Purpose**: Provide context to tools during execution - -### Tool Execution Context +### AgentHarnessSystemPrompt (line 1059) ```julia -# Tools receive context from AgentHarness -tool.execute( - tool_call_id, - params, - signal, - on_update, - context, # From AgentHarnessToolContextSource -) - -# Context can be: -# - Static value -# - Function that returns value +mutable struct AgentHarnessSystemPrompt{TC<:Any, TSkill<:Skill, TPromptTemplate<:PromptTemplate, TTool<:AgentHarnessTool} + value::Union{String, Function} +end ``` -## Complete Example +## SessionRepo Interface (stubs in harness_types.jl:564-588) ```julia -using AgentCore +abstract type SessionRepo< + TMetadata<:SessionMetadata, + TCreateOptions, + TListOptions +> end -# 1. Create skills -skills, skill_diagnostics = loadSkills( - execution_env, - "/path/to/skills", -) +function create(repo::SessionRepo, options::TCreateOptions)::Promise{Session} + return Promise() # STUB - Promise type not defined +end -# 2. Create prompt templates -templates, template_diagnostics = loadPromptTemplates( - execution_env, - "/path/to/templates", -) +function open(repo::SessionRepo, metadata::TMetadata)::Promise{Session} + return Promise() # STUB +end -# 3. Create resources -resources = AgentHarnessResources( - templates, - skills, -) +function list(repo::SessionRepo, options::TListOptions)::Promise{Vector{TMetadata}} + return Promise() # STUB +end -# 4. Create session repo -repo = JsonlSessionRepo( - "/path/to/sessions", -) +function delete(repo::SessionRepo, metadata::TMetadata)::Promise{Nothing} + return Promise() # STUB +end -# 5. Create session -session = create(repo, Dict( - "cwd" => "/path/to/project", - "metadata" => Dict("project" => "my-project"), -)) +function fork(repo::SessionRepo, source::TMetadata, options::Dict{String, Any})::Promise{Session} + return Promise() # STUB +end +``` -# 6. Configure tools -bash_tool = createBashTool() -read_tool = createReadTool() +> **Note**: These methods are stubs in `harness_types.jl`. `Promise` is not defined anywhere. -tools = [bash_tool, read_tool] +### JsonlSessionRepo (src/session/jsonl_repo.jl) -# 7. Configure hooks -hooks = Dict( - :beforeAgentStart => beforeAgentStartHook, - :beforeProviderPayload => beforePayloadHook, - :toolCall => toolCallHook, -) +```julia +mutable struct JsonlSessionRepo <: SessionRepo{ + JsonlSessionMetadata, + JsonlSessionCreateOptions, + JsonlSessionListOptions +} + fs::Any + sessions_root_input::String + sessions_root::Union{String, Nothing} -# 8. Create harness -harness = AgentHarness(Dict( - :session => session, - :models => models, - :tools => tools, - :resources => resources, - :system_prompt => "You are a helpful assistant.", - :model => Model(...), - :thinking_level => THINKING_MEDIUM, - :active_tool_names => ["bash", "read"], - :steering_mode => QUEUE_ONE_AT_A_TIME, - :follow_up_mode => QUEUE_ONE_AT_A_TIME, - :tool_context => AgentHarnessToolContextSource(context), - :stream_options => AgentHarnessStreamOptions( - transport = "auto", - timeout_ms = 30000, - max_retries = 3, - ), -)) - -# 9. Subscribe to events -subscribe(harness) do event, signal - if event isa BeforeAgentStartEvent - println("Agent starting...") - elseif event isa MessageEndEvent - println("Message: $(event.message)") + function JsonlSessionRepo(; sessions_root::String, fs::Any) + new(fs, sessions_root, nothing) end end - -# 10. Run conversation -harness.prompt("What files are in the current directory?") - -# 11. Wait for completion -wait_for_idle(harness) - -# 12. Manage branches -session.moveTo(some_entry_id) # Fork from entry ``` -## Hook Execution Flow +> **Note**: Constructor uses **keyword arguments** (`sessions_root=`, `fs=`), NOT positional. -``` -User Code - │ - ├─► AgentHarness.prompt() - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ BeforeAgentStartEvent │ -│ ├─ User prompt │ -│ ├─ System prompt │ -│ └─ Resources │ -│ │ │ -│ └─► beforeAgentStart hook (optional) │ -│ └─► BeforeAgentStartResult (optional modifications) │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ Agent.createLoopConfig() │ -│ └─► Merge options with hooks │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ Agent.prompt() │ -│ └─► Start AgentLoop │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ AgentLoop.agentLoop() │ -│ │ │ -│ ├─► transform_context hook (optional) │ -│ └─► convert_to_llm() │ -│ └─► Message[] for LLM API │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ BeforeProviderRequestEvent │ -│ ├─ Model │ -│ ├─ Session ID │ -│ └─ Stream Options │ -│ │ │ -│ └─► beforeProviderRequest hook (optional) │ -│ └─► BeforeProviderRequestResult (optional modifications) │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ StreamFn (LLM API call) │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ AfterProviderResponseEvent │ -│ ├─ Status code │ -│ └─ Response headers │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ BeforeProviderPayloadEvent │ -│ ├─ Model │ -│ └─ Payload (before sending) │ -│ │ │ -│ └─► beforeProviderPayload hook (optional) │ -│ └─► BeforeProviderPayloadResult (optional modifications) │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ LLM API Request │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ Assistant Message (streaming) │ -│ │ │ -│ ├─► Text deltas │ -│ └─► Tool calls │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ Tool Execution (for each tool call) │ -│ │ │ -│ ├─► before_tool_call hook (Agent) │ -│ ├─► toolCall hook (Harness - optional) │ -│ │ └─► ToolCallResult (can block execution) │ -│ ├─► prepareToolCall() │ -│ ├─► execute() │ -│ │ └─► Tool execution with context │ -│ ├─► after_tool_call hook (Agent) │ -│ └─► toolResult hook (Harness - optional) │ -│ └─► ToolResultPatch (can modify result) │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ AgentLoop continues with tool results │ -│ │ │ -│ ├─► Next LLM call with tool results │ -│ └─► Or end of conversation │ -└────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────────────────────┐ -│ AgentEndEvent │ -│ └─► Final messages in session │ -└────────────────────────────────────────────────────────────────────────┘ -``` +### JsonlSessionStorage (src/session/jsonl_storage.jl) -## Session Management with Harness +- `file_path::String` +- `metadata::T` (SessionMetadata) +- `entries::Vector{SessionTreeEntry}` +- `by_id::Dict{String, SessionTreeEntry}` +- `labels_by_id::Dict{String, String}` +- `current_leaf_id::Union{String, Nothing}` + +Methods: `getMetadata`, `getLeafId`, `setLeafId`, `createEntryId`, `appendEntry`, `getEntry`, `findEntries`, `getLabel`, `getSessionName`, `getSessionStats`, `getPathToRootOrCompaction`, `getEntries`. + +## Agent (src/agent.jl) ```julia -# Create harness with session repo -repo = JsonlSessionRepo("/path/to/sessions") +mutable struct Agent + _state::AgentState + listeners::Set{Tuple{Function, Ref{Bool}}} + steering_queue::PendingMessageQueue + follow_up_queue::PendingMessageQueue + convert_to_llm::Function + transform_context::Union{Function, Nothing} + stream_function::StreamFn + get_api_key::Union{Function, Nothing} + on_payload::Union{Function, Nothing} + on_response::Union{Function, Nothing} + before_tool_call::Union{Function, Nothing} + after_tool_call::Union{Function, Nothing} + prepare_next_turn::Union{Function, Nothing} + prepare_next_turn_with_context::Union{Function, Nothing} + active_run::Union{ActiveRun, Nothing} + session_id::Union{String, Nothing} + thinking_budgets::Union{Dict{String, Int64}, Nothing} + transport::String + max_retry_delay_ms::Union{Int64, Nothing} + tool_execution::ToolExecutionMode +end +``` -# Create session -session = create(repo, Dict( - "cwd" => "/path/to/project", - "metadata" => Dict("name" => "my-session"), -)) +Key methods: +- `subscribe(agent, listener)` - subscribe to events, returns unsubscribe function +- `get_state(agent)` - get current AgentState +- `steer(agent, message)` - queue message for injection after current turn +- `followUp(agent, message)` - queue message to run after agent would stop +- `prompt(agent, input, images)` - start a new prompt (input can be String, AgentMessage, or Vector{AgentMessage}) +- `continue!(agent)` - continue from current transcript +- `waitForIdle(agent)` - resolve when current run finishes +- `abort(agent)` - abort current run (partially implemented) +- `reset!(agent)` - clear all state +- `clearSteeringQueue(agent)` / `clearFollowUpQueue(agent)` / `clearAllQueues(agent)` +- `hasQueuedMessages(agent)` - check for pending messages +- `createContextSnapshot(agent)` - create AgentContext snapshot +- `createLoopConfig(agent, options)` - create AgentLoopConfig -# Or open existing session -metadata = JsonlSessionMetadata(...) -session = open(repo, metadata) +> **Note**: `AgentLoopConfig` type is **not defined** in any visible file. It is referenced in `agent.jl:368` and `agent_loop.jl`. -# List sessions -sessions = list(repo, Dict()) -for meta in sessions - println("Session: $(meta.id)") +## AgentLoop (src/agent_loop.jl) + +Key functions: +- `agentLoop(prompts, context, config, signal, stream_fn)` - main loop, returns EventStream +- `agentLoopContinue(context, config, signal, stream_fn)` - continue from existing context +- `runAgentLoop(...)` - internal run, emits events via `emit::AgentEventSink` +- `runAgentLoopContinue(...)` - internal continue run +- `runLoop(...)` - shared main loop logic +- `streamAssistantResponse(...)` - stream LLM response with event emission +- `executeToolCalls(...)` - execute tool calls (sequential or parallel) +- `executeToolCallsSequential(...)` - sequential execution +- `executeToolCallsParallel(...)` - parallel execution via Threads.@spawn + +The loop flow: +1. `AgentStartEvent` emitted +2. `TurnStartEvent` emitted (first turn only from agentLoop, not from runLoop) +3. Steering messages drained and emitted as `MessageStartEvent`/`MessageEndEvent` +4. `streamAssistantResponse` called - transforms context, converts to LLM messages, calls stream_fn +5. For each tool call in response: execute sequentially or in parallel +6. `TurnEndEvent` emitted with message and tool results +7. `prepare_next_turn` hook (if configured) called +8. If `should_stop_after_turn` returns true or no pending messages, `AgentEndEvent` emitted +9. Follow-up messages drained and loop repeats + +### AgentLoopConfig fields (referenced, not defined) + +Created in `agent.jl:368-401`: +``` +model, reasoning (thinking_level), session_id, on_payload, on_response, +transport, thinking_budgets, max_retry_delay_ms, tool_execution, +before_tool_call, after_tool_call, prepare_next_turn, convert_to_llm, +transform_context, get_api_key, get_steering_messages, get_follow_up_messages +``` + +## Hook System (Planned - Harness Not Implemented) + +The following hook types are defined as event/result structs in `harness_types.jl` +but **no harness implementation exists to trigger or handle them**. These are +intended to be used by the future AgentHarness module. + +### BeforeAgentStartEvent (line 653) +```julia +mutable struct BeforeAgentStartEvent{TSkill, TPromptTemplate} + type::String + prompt::String + images::Union{Vector{ImageContent}, Nothing} + system_prompt::String + resources::AgentHarnessResources{TSkill, TPromptTemplate} +end +``` +**Result**: `BeforeAgentStartResult` (line 862) - `messages::Union{Vector{AgentMessage}, Nothing}`, `system_prompt::Union{String, Nothing}` + +### ContextEvent (line 665) +```julia +mutable struct ContextEvent + type::String + messages::Vector{AgentMessage} +end +``` +**Result**: `ContextResult` (line 871) - `messages::Vector{AgentMessage}` + +### BeforeProviderRequestEvent (line 674) +```julia +mutable struct BeforeProviderRequestEvent + type::String + model::Model + session_id::String + stream_options::AgentHarnessStreamOptions +end +``` +**Result**: `BeforeProviderRequestResult` (line 879) - `stream_options::Union{AgentHarnessStreamOptionsPatch, Nothing}` + +### BeforeProviderPayloadEvent (line 685) +```julia +mutable struct BeforeProviderPayloadEvent + type::String + model::Model + payload::Any +end +``` +**Result**: `BeforeProviderPayloadResult` (line 887) - `payload::Any` + +### AfterProviderResponseEvent (line 695) +```julia +mutable struct AfterProviderResponseEvent + type::String + status::Int64 + headers::Dict{String, String} +end +``` + +### ToolCallEvent (line 705) +```julia +mutable struct ToolCallEvent + type::String + tool_call_id::String + tool_name::String + input::Dict{String, Any} +end +``` +**Result**: `ToolCallResult` (line 895) - `block::Union{Bool, Nothing}`, `reason::Union{String, Nothing}` + +### ToolResultEvent (line 716) +```julia +mutable struct ToolResultEvent + type::String + tool_call_id::String + tool_name::String + input::Dict{String, Any} + content::Vector{MessageContent} + details::Any + is_error::Bool + usage::Union{Usage, Nothing} +end +``` +**Result**: `ToolResultPatch` (line 904) - `content`, `details`, `is_error`, `usage`, `terminate` (all Union{...}) + +### SessionBeforeCompactEvent (line 731) +```julia +mutable struct SessionBeforeCompactEvent + type::String + preparation::Any + branch_entries::Vector{SessionTreeEntry} + custom_instructions::Union{String, Nothing} + signal::Any +end +``` +**Result**: `SessionBeforeCompactResult` (line 916) - `cancel::Union{Bool, Nothing}`, `compaction::Union{CompactResult, Nothing}` + +### SessionBeforeTreeEvent (line 753) +```julia +mutable struct SessionBeforeTreeEvent + type::String + preparation::Any + signal::Any +end +``` +**Result**: `SessionBeforeTreeResult` (line 925) - `cancel`, `summary`, `custom_instructions`, `replace_instructions`, `label` + +### SessionCompactEvent (line 743) +```julia +mutable struct SessionCompactEvent + type::String + compaction_entry::CompactionEntry + from_hook::Bool +end +``` + +### SessionTreeEvent (line 763) +```julia +mutable struct SessionTreeEvent + type::String + new_leaf_id::Union{String, Nothing} + old_leaf_id::Union{String, Nothing} + summary_entry::Union{BranchSummaryEntry, Nothing} + from_hook::Union{Bool, Nothing} +end +``` + +## Session (src/session/session.jl) + +```julia +mutable struct Session{T<:SessionMetadata} + storage::SessionStorage{T} + context_build_options::SessionContextBuildOptions +end +``` + +Key methods: +- `getMetadata(session)` / `getStorage(session)` / `getLeafId(session)` / `getEntry(session, id)` +- `getEntries(session, options)` / `getBranch(session, from_id)` +- `buildContextEntries(session, options)` / `buildContext(session, options)` +- `getLabel(session, id)` / `getSessionStats(session)` / `getSessionName(session)` +- `appendMessage(session, message)` → entry_id +- `appendThinkingLevelChange(session, level)` → entry_id +- `appendModelChange(session, provider, model_id)` → entry_id +- `appendActiveToolsChange(session, active_tool_names)` → entry_id +- `appendCompaction(session, summary, first_kept_entry_id, tokens_before, ...)` → entry_id +- `appendCustomEntry(session, custom_type, data)` → entry_id +- `appendCustomMessageEntry(session, custom_type, content, display, details)` → entry_id +- `appendLabel(session, target_id, label)` → entry_id +- `appendSessionName(session, name)` → entry_id +- `moveTo(session, entry_id, summary)` → new_leaf_id or nothing (line 392) + +## Session Tree Entries (types.jl and harness_types.jl) + +```julia +abstract type SessionTreeEntry end + +struct MessageEntry <: SessionTreeEntry + base::SessionTreeEntryBase # or direct fields in harness_types.jl + message::AgentMessage end -# Delete session -delete(repo, metadata) +struct ThinkingLevelChangeEntry <: SessionTreeEntry + base::SessionTreeEntryBase + thinking_level::String +end -# Fork session (branch) -forked_session = fork(repo, source_metadata, Dict( - "summary" => "Branch for feature X", -)) +struct ModelChangeEntry <: SessionTreeEntry + base::SessionTreeEntryBase + provider::String + model_id::String +end + +struct ActiveToolsChangeEntry <: SessionTreeEntry + base::SessionTreeEntryBase + active_tool_names::Vector{String} +end + +struct CompactionEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + summary::String + first_kept_entry_id::Union{String, Nothing} + tokens_before::Int64 + retained_tail::Union{Vector{AgentMessage}, Nothing} + details::Union{T, Nothing} + usage::Union{Usage, Nothing} + from_hook::Bool +end + +struct BranchSummaryEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + from_id::String + summary::String + details::Union{T, Nothing} + usage::Union{Usage, Nothing} + from_hook::Bool +end + +struct CustomEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + custom_type::String + data::Union{T, Nothing} +end + +struct CustomMessageEntry{T} <: SessionTreeEntry + base::SessionTreeEntryBase + custom_type::String + content::String + details::Union{T, Nothing} + display::Bool +end + +struct LabelEntry <: SessionTreeEntry + base::SessionTreeEntryBase + target_id::String + label::Union{String, Nothing} +end + +struct SessionInfoEntry <: SessionTreeEntry + base::SessionTreeEntryBase + name::Union{String, Nothing} +end + +struct LeafEntry <: SessionTreeEntry + base::SessionTreeEntryBase + target_id::Union{String, Nothing} +end ``` -## Resources Management +## Resource Loading (stubs) + +### loadSkills (skills.jl:61) ```julia -# Load skills from directory -skills, diagnostics = loadSkills( - execution_env, - "/path/to/skills", -) - -# Load prompt templates from directory -templates, diagnostics = loadPromptTemplates( - execution_env, - "/path/to/templates", -) - -# Create resources -resources = AgentHarnessResources( - templates, - skills, -) - -# Use in harness -harness = AgentHarness(Dict( - :resources => resources, -)) +skills, diagnostics = loadSkills(env, "/path/to/skills") ``` +> **Note**: Parsing is **stubbed** (line 302 returns `nothing, diagnostics`). The frontmatter parsing code is commented out (lines 266-300). `formatSkillInvocation(skill, additional_instructions)` is implemented. + +### loadPromptTemplates (prompt_templates.jl:43) + +```julia +templates, diagnostics = loadPromptTemplates(env, "/path/to/templates") +``` + +> **Note**: Parsing is **stubbed** (line 217 returns `nothing, diagnostics`). The frontmatter parsing code is commented out (lines 188-215). `formatPromptTemplateInvocation(template, args)` and `parseCommandArgs(args_string)` and `substituteArgs(content, args)` are implemented. + +## Missing Types / Modules + +The following types are referenced in the code but **not defined**: +- `AgentLoopConfig` - referenced in `agent.jl:368`, `agent_loop.jl` +- `Promise` - referenced in `harness_types.jl` +- `AbortSignal` - referenced in `agent_loop.jl` +- `EventStream` - referenced in `agent_loop.jl:158` +- `Context` - referenced in `agent_loop.jl:376` +- `AgentToolResultMutable` - referenced in `agent_loop.jl` +- `FinalizedToolCallOutcome`, `PreparedToolCall`, `ImmediateToolCallOutcome`, `ExecutedToolCallOutcome` - defined in `agent_loop.jl:639-661` (these exist) + +The following modules are referenced in `AgentCore.jl` but **files don't exist**: +- `compaction/compaction.jl` +- `compaction/utils.jl` +- `compaction/branch_summarization.jl` +- `utils/truncate.jl` +- `utils/shell_output.jl` +- `proxy.jl` + +## AgentCore Exports (from AgentCore.jl:61-147) + +The module exports: AgentMessage, AgentTool, AgentContext, AgentEvent, ThinkingLevel, ToolExecutionMode, QueueMode, AgentState, Agent, AgentOptions, AgentLoopConfig, agentLoop, agentLoopContinue, runAgentLoop, runAgentLoopContinue, AgentHarness, AgentHarnessOptions, AgentHarnessEvent, AgentHarnessResources, AgentHarnessSystemPrompt, Session, SessionStorage, SessionRepo, JsonlSessionStorage, JsonlSessionRepo, InMemorySessionStorage, InMemorySessionRepo, createBashTool, createReadTool, createWriteTool, createEditTool, ExecutionEnv, compact, prepareCompaction, DEFAULT_COMPACTION_SETTINGS, generateSummary, generateBranchSummary, truncateHead, truncateTail, formatSize, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES, convertToLlm, bashExecutionToText, formatSkillsForSystemPrompt, loadSkills, formatSkillInvocation, loadPromptTemplates, formatPromptTemplateInvocation, parseCommandArgs, substituteArgs, streamProxy, ProxyStreamOptions, setDefaultStreamFn, getDefaultStreamFn, uuidv7, create_timestamp. + ## Best Practices -1. **Use hooks for logging and validation** - - `beforeAgentStart` for initialization - - `beforeProviderPayload` for custom metadata - - `toolCall` for blocking dangerous operations - -2. **Organize skills by domain** - - File operations - - Database queries - - HTTP requests - - Git operations - -3. **Use templates for common patterns** - - Commit message generation - - Code review instructions - - Testing prompts - -4. **Manage sessions carefully** - - Compact periodically - - Use branches for exploration - - Clean up old sessions - -5. **Monitor resource usage** - - Track token counts - - Watch API costs - - Optimize tool execution - -## Troubleshooting - -### Hook not being called - -```julia -# Check hook is registered -if isnothing(harness.beforeAgentStart) - println("Hook not registered") -end -``` - -### Session not persisting - -```julia -# Check repo is configured -if isnothing(harness.repo) - println("No repo configured") -end -``` - -### Resources not loading - -```julia -# Check diagnostics -for diag in skill_diagnostics - println("Skill warning: $(diag.message)") -end -``` +1. **Agent hooks** (planned): Use `beforeAgentStart` for initialization, `beforeProviderPayload` for custom metadata, `toolCall` for blocking dangerous operations +2. **Skills**: Organize by domain (file operations, database queries, HTTP requests, git operations) +3. **Templates**: Use for common patterns (commit messages, code review, testing prompts) +4. **Sessions**: Compact periodically, use branches for exploration, clean up old sessions +5. **Monitoring**: Track token counts, watch API costs, optimize tool execution +6. **Tool execution**: Choose between `EXECUTION_SEQUENTIAL` and `EXECUTION_PARALLEL` based on tool dependencies diff --git a/learning/08-EXAMPLES.md b/learning/08-EXAMPLES.md index 0e5a63c..21599c4 100644 --- a/learning/08-EXAMPLES.md +++ b/learning/08-EXAMPLES.md @@ -44,7 +44,7 @@ end prompt(agent, "What's in the current directory?") # Wait for completion -wait_for_idle(agent) +waitForIdle(agent) # Get final state state = get_state(agent) @@ -55,44 +55,34 @@ println("Total messages: $(length(state.messages))") ```julia # Create session storage -storage = JsonlSessionStorage( - JsonlSessionMetadata( - "session_1", - "2024-01-01T00:00:00Z", - "/path/to/project", - "/path/to/session.jsonl", - nothing, - Dict("project" => "my-project"), - ), +metadata = JsonlSessionMetadata( + "session_1", + "2024-01-01T00:00:00Z", + "/path/to/project", "/path/to/session.jsonl", + nothing, + Dict("project" => "my-project"), ) +storage = JsonlSessionStorage(metadata, "/path/to/session.jsonl") # Create session session = Session(storage) +# Add messages to session +appendMessage(session, UserMessage("user", [TextContent("Hello, my name is Alice.")], Int64(Dates.now(Dates.UTC).datetime))) + +# Check session stats +stats = getSessionStats(session) +println("Messages: $(stats.message_count)") +println("Total tokens: $(stats.total_tokens)") + # Create agent with session agent = Agent(Dict( :systemPrompt => "You are a helpful assistant.", :model => model, :tools => [bash_tool], - :sessionId => session.getMetadata().id, + :sessionId => getMetadata(session).id, )) - -# Add messages to session -function addToSession(session, message) - appendMessage(session, message) -end - -# Start conversation -prompt(agent, "Hello, my name is Alice.") - -# Continue conversation (messages persist in session) -prompt(agent, "What's the weather like today?") - -# Check session stats -stats = getSessionStats(session) -println("Messages: $(stats.message_count)") -println("Total tokens: $(stats.total_tokens)") ``` ### Example 3: Steering and Follow-Up @@ -101,19 +91,19 @@ println("Total tokens: $(stats.total_tokens)") # Start conversation prompt(agent, "Create a Python project.") -# User wants to redirect +# Queue a steering message (injected after current assistant turn) +timestamp = Int64(Dates.now(Dates.UTC).datetime) steer(agent, UserMessage("user", [TextContent("Actually, let's use Node.js instead")], timestamp)) # Wait for redirection -wait_for_idle(agent) +waitForIdle(agent) -# Agent would normally stop, but user has more -prompt(agent, "Wait, there's one more thing...") +# Queue a follow-up message (runs only after agent would otherwise stop) followUp(agent, UserMessage("user", [TextContent("Can you add tests?")], timestamp)) # Continue until completion while hasQueuedMessages(agent) - wait_for_idle(agent) + waitForIdle(agent) end ``` @@ -123,99 +113,29 @@ end # Initial conversation prompt(agent, "I want to build a web app.") -# User decides to explore a different path -session.moveTo(msg_3_id) # Go back to message 3 +# Get the branch at a specific point +entry_id = "msg_3_id" +branch = getBranch(session, entry_id) +println("Branch has $(length(branch)) entries") -# Create branch -appendBranchSummary( - session, - "User decided to explore mobile app instead", - msg_3_id, - Dict("focus" => "mobile"), -) +# Move to a specific entry (creates a branch summary if summary is provided) +moveTo(session, entry_id, Dict("summary" => "User decided to explore mobile app instead")) # Continue on new branch prompt(agent, "Let's build a mobile app instead.") -# Check branches +# Check session branch branch = getBranch(session) println("Current branch has $(length(branch)) entries") ``` ## Advanced Patterns -### Pattern 1: Long-Running Agent with Compaction +### Pattern 1: Token Usage Monitoring ```julia -# Configure compaction settings -MAX_TOKENS = 120000 # Stay under 128K limit -COMPACTION_THRESHOLD = 100000 - -# Agent loop with compaction -function runAgentWithCompaction(agent, session) - while true - # Get current token count - stats = getSessionStats(session) - - if stats.total_tokens > COMPACTION_THRESHOLD - # Compact session - compactSession(session) - end - - # Check if agent is idle - if !hasQueuedMessages(agent) && !isnothing(agent.active_run) - break - end - end -end - -function compactSession(session) - # Get current branch - branch = getBranch(session) - - # Calculate tokens to compact - total_tokens = 0 - for entry in branch - if entry isa MessageEntry - total_tokens += estimateTokens(entry.message) - end - end - - if total_tokens < COMPACTION_THRESHOLD - return - end - - # Identify messages to compact - messages_to_compact = [] - tokens_to_keep = 50000 # Keep recent 50K tokens - - for entry in branch - if entry isa MessageEntry - msg_tokens = estimateTokens(entry.message) - if tokens_to_keep > 0 - tokens_to_keep -= msg_tokens - else - push!(messages_to_compact, entry) - end - end - end - - # Generate summary - summary = generateSummary(messages_to_compact) - - # Create compaction entry - appendCompaction( - session, - summary, - messages_to_compact[end].id, - total_tokens, - ) - - println("Compacted $(length(messages_to_compact)) messages") -end - +# Simple token estimation from messages function estimateTokens(message::AgentMessage)::Int64 - # Simple estimation: ~4 chars per token content = if message isa UserMessage join([c.text for c in message.content if c isa TextContent]) elseif message isa AssistantMessage @@ -225,57 +145,64 @@ function estimateTokens(message::AgentMessage)::Int64 else "" end - return ceil(Int, length(content) / 4) end -function generateSummary(messages::Vector{MessageEntry})::String - # Use LLM to generate summary - summary = "Conversation summary:" - for msg in messages - summary *= "\n- $(msg.message)" +# Monitor session token usage +function checkTokenUsage(agent, session) + state = get_state(agent) + stats = getSessionStats(session) + + println("Session tokens: $(stats.total_tokens)") + println("Messages in state: $(length(state.messages))") + + total_estimated = sum(estimateTokens, state.messages) + println("Estimated total tokens: $(total_estimated)") + + return stats.total_tokens +end + +# Agent loop with token monitoring +function runAgentWithMonitoring(agent, session, max_tokens=120000) + while true + total = checkTokenUsage(agent, session) + if total > max_tokens + println("Approaching token limit: $(total)") + break + end + + if !hasQueuedMessages(agent) && isnothing(agent.active_run) + break + end end - return summary end ``` -### Pattern 2: Custom Tool with Context +### Pattern 2: Custom Tool ```julia -# Define context type -struct DatabaseContext - connection::Any - user::String -end - -# Create tool with context -function createDatabaseTool() +# Create a custom tool +function createCustomTool() return AgentTool( - "database", - "database", - "Execute SQL queries", + "custom_tool", + "custom_tool", + "A custom tool description.", Dict{String, Any}(), (tool_call_id, params, signal, on_update, context) -> begin - if !isa(context, DatabaseContext) - return AgentToolResult( - [TextContent("Error: Database context not provided")], - nothing, - nothing, - nothing, - true, # terminate - ) - end + # Execute tool logic + value = params["value"] - # Execute query - query = params["query"] - result = executeQuery(context.connection, query) + # Send progress updates + on_update("Processing $value...") + + result = processValue(value) return AgentToolResult( - [TextContent(formatResult(result))], - Dict("user" => context.user), + [TextContent(result)], nothing, nothing, nothing, + nothing, # terminate ) end, nothing, @@ -283,99 +210,84 @@ function createDatabaseTool() ) end -# Use tool with context -db_context = DatabaseContext(connection, "alice") +# Use custom tool +custom_tool = createCustomTool() -harness = AgentHarness(Dict( - :tools => [createDatabaseTool()], - :tool_context => AgentHarnessToolContextSource(db_context), +agent = Agent(Dict( + :systemPrompt => "You are a helpful assistant.", + :model => model, + :tools => [bash_tool, custom_tool], )) ``` -### Pattern 3: Dynamic Model Selection +### Pattern 3: Dynamic Model Selection via Hook ```julia -# Hook to change model based on task -function dynamicModelSelection(context, signal) - # Check message content - last_message = context.message - - # If complex task, use more capable model - if contains(join(last_message.content), "analyze") - return AgentLoopTurnUpdate( - context = context.context, - model = Model("gpt-4", "GPT-4", "openai", ...), - thinking_level = THINKING_HIGH, - ) - end - - # Otherwise use cheaper model - return AgentLoopTurnUpdate( - context = context.context, - model = Model("gpt-3.5", "GPT-3.5", "openai", ...), - thinking_level = THINKING_MEDIUM, - ) +# Hook to change model based on conversation context +function dynamicModelSelection(signal) + # This hook is called between turns to potentially change the model + # Return AgentLoopTurnUpdate to change model/thinking_level, or nothing to keep current + return nothing end -# Configure agent +# Configure agent with the hook agent = Agent(Dict( :prepareNextTurn => dynamicModelSelection, )) + +# The hook receives an AgentEvent and AbortSignal. +# Access conversation context via: +# context.message - the last assistant message +# context.tool_results - tool results from the last turn +# context.context - the full AgentContext ``` -### Pattern 4: Rate Limiting +### Pattern 4: Tool Call Interception ```julia -# Rate limiter -struct RateLimiter - calls_per_minute::Int - last_calls::Vector{DateTime} -end - -function RateLimiter(calls_per_minute::Int) - return RateLimiter(calls_per_minute, DateTime[]) -end - -function rateLimit(limiter::RateLimiter) - now = Dates.now() - - # Remove old calls - limiter.last_calls = filter( - c -> Dates.value(now - c) / 1000 < 60, - limiter.last_calls, - ) - - # Check limit - if length(limiter.last_calls) >= limiter.calls_per_minute - return false +# Hook to validate or block tool calls before they execute +function toolCallValidator(event, signal) + if event isa ToolExecutionStartEvent + # Log or validate tool calls + println("Tool call: $(event.tool_name) with args: $(event.args)") + + # Block dangerous commands + if event.tool_name == "bash" + args = event.args + if args isa Dict && haskey(args, :command) + cmd = args[:command] + if contains(cmd, "rm -rf /") + println("Blocked dangerous command!") + end + end + end end - - # Record call - push!(limiter.last_calls, now) - return true + return nothing end -# Use in hook -limiter = RateLimiter(60) # 60 calls per minute - -function rateLimitHook(event, signal) - if !rateLimit(limiter) - return BeforeProviderPayloadResult(event.payload) # Still send, but track - end - - return BeforeProviderPayloadResult(event.payload) -end - -# Configure +# Configure with beforeToolCall hook agent = Agent(Dict( - :beforeProviderPayload => rateLimitHook, + :beforeToolCall => toolCallValidator, +)) + +# After tool call hook +function toolCallLogger(event, signal) + if event isa ToolExecutionEndEvent + status = event.is_error ? "ERROR" : "OK" + println("[$status] $(event.tool_name): $(event.tool_call_id)") + end + return nothing +end + +agent = Agent(Dict( + :afterToolCall => toolCallLogger, )) ``` ### Pattern 5: Multi-Step Tool Execution ```julia -# Tool that requires multiple steps +# Tool that requires multiple steps with progress updates function createMultiStepTool() return AgentTool( "multistep", @@ -409,57 +321,70 @@ function createMultiStepTool() end ``` -### Pattern 6: Image Processing +### Pattern 6: Image Processing with Read Tool ```julia # Create read tool with image support -image_processor = ReadImageProcessor( - (path, context) -> begin - # Load image - image_data = readImage(path) - - # Process with vision model - result = processImageWithVision(image_data) - - return ReadImageProcessorResult( - [TextContent(result.description)], - result.usage, - ) - end, - context, -) - -read_tool = createReadTool(Dict( - "image_processor" => image_processor, +read_tool = createReadTool(ReadToolOptions( + auto_resize_images=true, + image_processor=nothing, )) + +# Use with agent that supports image input +agent = Agent(Dict( + :systemPrompt => "You are a helpful assistant.", + :model => model, + :tools => [read_tool], +)) + +# Send prompt with image content +timestamp = Int64(Dates.now(Dates.UTC).datetime) +image_msg = UserMessage( + "user", + [ + TextContent("Analyze this image:"), + ImageContent(base64_data, "image/png"), + ], + timestamp, +) +prompt(agent, image_msg) ``` ### Pattern 7: Session Navigation ```julia -# Navigate to specific point -session.moveTo(entry_id) +# Navigate to specific entry +moveTo(session, entry_id) # Get branch from specific point branch = getBranch(session, entry_id) -# Create label for easy navigation +# Create label for an entry (links to another entry) appendLabel(session, entry_id, "important-decision") -# Find labeled entry -label = getLabel(session, "important-decision") +# Get the label for a specific entry +label = getLabel(session, entry_id) +if !isnothing(label) + println("Label: $label") +end -# Build context from branch +# Build session context from current branch context = buildSessionContext(session) -# Get specific messages -messages = sessionEntryToContextMessages(entry, index, entries) +# Get specific messages from branch entries +entries = getBranch(session) +for (i, entry) in enumerate(entries) + messages = sessionEntryToContextMessages(entry, i, entries) + for msg in messages + println("$(msg.role): $(msg)") + end +end ``` ### Pattern 8: Batch Processing ```julia -# Process multiple prompts in batch +# Process multiple prompts sequentially prompts = [ "What is Julia?", "What is JavaScript?", @@ -478,7 +403,7 @@ for prompt_text in prompts # Run prompt prompt(agent, prompt_text) - wait_for_idle(agent) + waitForIdle(agent) # Get result state = get_state(agent) @@ -489,62 +414,69 @@ for prompt_text in prompts # Clean up reset!(agent) end - -# Process results -for result in results - println("Result: $(result)") -end ``` -### Pattern 9: Custom Event Handling +### Pattern 9: Event Subscription ```julia -# Custom event types -struct CustomEvent <: AgentEvent - data::Any -end - -# Custom event handler -function customEventHandler(event, signal) - if event isa CustomEvent - println("Custom event: $(event.data)") +# Subscribe to various agent events +subscribe(agent) do event, signal + if event isa AgentStartEvent + println("Agent started") + elseif event isa TurnStartEvent + println("Turn started") + elseif event isa MessageStartEvent + println("Message started") + elseif event isa MessageUpdateEvent + # Partial message update during streaming + partial = event.assistant_message_event + # Access partial message content + elseif event isa MessageEndEvent + println("Message ended: $(event.message)") + elseif event isa ToolExecutionStartEvent + println("Tool exec start: $(event.tool_name)") + elseif event isa ToolExecutionUpdateEvent + # Tool progress update + println("Tool update: $(event.partial_result)") + elseif event isa ToolExecutionEndEvent + status = event.is_error ? "error" : "success" + println("Tool exec end: $(event.tool_name) [$status]") + elseif event isa TurnEndEvent + println("Turn ended") + elseif event isa AgentEndEvent + println("Agent ended with $(length(event.messages)) messages") end end - -# Subscribe to custom events -subscribe(agent) do event, signal - customEventHandler(event, signal) -end - -# Emit custom event -emit(CustomEvent("custom data")) ``` ### Pattern 10: Error Handling ```julia -# Hook for error handling -function errorHook(context, signal) - if context isa PrepareNextTurnContext - last_message = context.message - - if last_message.stop_reason == "error" - println("Error in conversation: $(last_message.error_message)") - - return AgentLoopTurnUpdate( - context = context.context, - model = context.context.model, - thinking_level = THINKING_HIGH, # Use more capable model - ) +# Monitor for errors in conversation +subscribe(agent) do event, signal + if event isa MessageEndEvent + msg = event.message + if msg isa AssistantMessage + if msg.stop_reason == "error" + println("Error: $(msg.error_message)") + elseif msg.stop_reason == "length" + println("Response truncated (token limit reached)") + elseif msg.stop_reason == "aborted" + println("Request aborted") + end end end - +end + +# Error handling hook +function errorHandlingHook(signal) + # This is called between turns + # Return AgentLoopTurnUpdate to modify behavior, or nothing return nothing end -# Use in agent agent = Agent(Dict( - :prepareNextTurn => errorHook, + :prepareNextTurn => errorHandlingHook, )) ``` @@ -553,286 +485,183 @@ agent = Agent(Dict( ### Unit Testing ```julia -# Test tool execution -@testset "Bash tool" begin - tool = createBashTool() - - # Test successful execution - result = tool.execute("tc1", Dict("command" => "echo hello"), nothing, nothing, nothing) - @test result.content[1].text == "hello\n" - @test result.details === nothing - - # Test error handling - result = tool.execute("tc2", Dict("command" => "exit 1"), nothing, nothing, nothing) - @test result.terminate === true -end +using Test +using AgentCore -# Test agent with mock LLM -@testset "Agent with mock" begin - # Mock stream function - function mockStreamFn(model, context, options) - # Return mock response - return MockResponse([TextContent("Hello!")]) - end - - agent = Agent(Dict( - :stream_fn => mockStreamFn, - :systemPrompt => "You are a helpful assistant.", - :model => model, - )) - - # Test prompt - prompt(agent, "Hello") - wait_for_idle(agent) - - # Verify result - state = get_state(agent) - @test length(state.messages) == 2 # User + Assistant -end +# Test tool creation +@test createBashTool() isa AgentTool +@test createReadTool() isa AgentTool +@test createWriteTool() isa AgentTool +@test createEditTool() isa AgentTool + +# Test basic agent creation +@test_throws ErrorException Agent(Dict(:model => nothing)) + +# Test agent state +agent = Agent(Dict( + :systemPrompt => "Test", + :model => Model("", "", "test", "test", "", false, String[], ModelCost(0,0,0,0), 0, 0), +)) +state = get_state(agent) +@test state.system_prompt == "Test" +@test length(state.messages) == 0 ``` -### Integration Testing +### Integration Testing with In-Memory Storage ```julia -# Test full conversation flow -@testset "Full conversation" begin - # Create session storage - storage = InMemorySessionStorage(...) - session = Session(storage) - - # Create agent - agent = Agent(Dict( - :systemPrompt => "You are a helpful assistant.", - :model => model, - :tools => [bash_tool], - :sessionId => session.getMetadata().id, - )) - - # Run conversation - prompt(agent, "What's in the directory?") - wait_for_idle(agent) - - # Verify session - context = buildSessionContext(session) - @test length(context.messages) == 2 - - # Continue conversation - prompt(agent, "What's the weather?") - wait_for_idle(agent) - - # Verify growth - context = buildSessionContext(session) - @test length(context.messages) == 4 -end +using AgentCore + +# Create in-memory session +repo = InMemorySessionRepo() +session = create(repo) + +# Add messages +appendMessage(session, UserMessage("user", [TextContent("Hello")], Int64(Dates.now(Dates.UTC).datetime))) + +# Verify session +stats = getSessionStats(session) +@test stats.message_count == 1 + +# Navigate with moveTo +entry_id = getLeafId(session) +moveTo(session, entry_id) + +# Fork from entry +forked = fork(repo, getMetadata(session), Dict("entryId" => entry_id)) ``` ## Performance Patterns -### Pattern 1: Caching +### Pattern 1: Queue Mode Configuration ```julia -# Simple caching for LLM calls -struct LLMCache - cache::Dict{String, AssistantMessage} -end +# Configure steering mode (how steering messages are queued) +agent = Agent(Dict( + :systemPrompt => "You are a helpful assistant.", + :model => model, + :steeringMode => QUEUE_ONE_AT_A_TIME, # Only one steering message processed at a time + :followUpMode => QUEUE_ALL, # All follow-ups processed in batch +)) -function LLMCache() - return LLMCache(Dict{String, AssistantMessage}()) -end - -function getCached(cache::LLMCache, key::String) - return get(cache.cache, key, nothing) -end - -function setCached(cache::LLMCache, key::String, value::AssistantMessage) - cache.cache[key] = value -end - -# Use in stream function -function cachedStreamFn(model, context, options) - key = generateCacheKey(context) - - cached = getCached(cache, key) - if !isnothing(cached) - return MockResponse(cached) - end - - result = actualStreamFn(model, context, options) - setCached(cache, key, result) - return result -end +# Clear queues as needed +clearSteeringQueue(agent) +clearFollowUpQueue(agent) +clearAllQueues(agent) ``` -### Pattern 2: Batch LLM Calls +### Pattern 2: Message Normalization ```julia -# Batch multiple LLM calls -function batchLLMCalls(calls::Vector{Dict}) - results = [] - - for call in calls - result = streamFunction( - call[:model], - call[:context], - call[:options], - ) - push!(results, result) - end - - return results -end - -# Use with parallel execution -tool.execute = (id, params, signal, on_update, context) -> begin - # Batch multiple LLM calls - llm_calls = [ - Dict(:model => model, :context => context1, :options => options1), - Dict(:model => model, :context => context2, :options => options2), - ] - - results = batchLLMCalls(llm_calls) - - return AgentToolResult( - [TextContent(join([r.text for r in results], "\n"))], - nothing, - nothing, - nothing, - nothing, +# Custom message normalization function +function customNormalize(messages::Vector{AgentMessage})::Vector{Message} + return filter( + (m) -> m.role == "user" || m.role == "assistant" || m.role == "toolResult", + messages, ) end + +agent = Agent(Dict( + :systemPrompt => "You are a helpful assistant.", + :model => model, + :convertToLlm => customNormalize, +)) ``` -### Pattern 3: Lazy Loading +### Pattern 3: Context Transformation ```julia -# Lazy load skills -struct LazySkills - dir::String - skills::Union{Vector{Skill}, Nothing} +# Transform context before LLM call +function transformContextFn(messages::Vector{AgentMessage}, signal) + # Filter or modify messages before sending to LLM + filtered = filter(m -> m.role != "toolResult", messages) + return filtered end -function LazySkills(dir) - return LazySkills(dir, nothing) -end - -function getSkills(lazy::LazySkills) - if isnothing(lazy.skills) - lazy.skills, _ = loadSkills(lazy.dir) - end - return lazy.skills -end - -# Use in harness -harness = AgentHarness(Dict( - :resources => AgentHarnessResources( - templates, - LazySkills("/path/to/skills"), - ), +agent = Agent(Dict( + :systemPrompt => "You are a helpful assistant.", + :model => model, + :transformContext => transformContextFn, )) ``` ## Production Patterns -### Pattern 1: Observability +### Pattern 1: Observability via Events ```julia -# Logging hook -function loggingHook(event, signal) - if event isa BeforeProviderRequestEvent - println("[Request] $(event.model.id)") - elseif event isa AfterProviderResponseEvent - println("[Response] Status: $(event.status)") - elseif event isa ToolExecutionEndEvent - println("[Tool] $(event.tool_name): $(event.is_error ? "error" : "success")") - end - return nothing -end - -# Metrics hook -function metricsHook(event, signal) - if event isa AgentStartEvent - metrics.start_time = Dates.now() - elseif event isa AgentEndEvent - duration = Dates.value(Dates.now() - metrics.start_time) / 1000 - println("[Metrics] Duration: $(duration)s") - end - return nothing -end -``` - -### Pattern 2: Retry Logic - -```julia -# Retry hook -function retryHook(event, signal) - if event isa AfterProviderResponseEvent && event.status >= 500 - # Server error, retry - return BeforeProviderRequestResult(Dict( - "retry" => true, - "max_retries" => 3, - )) - end - return nothing -end - -# Use in stream options -harness = AgentHarness(Dict( - :stream_options => AgentHarnessStreamOptions( - max_retries = 3, - max_retry_delay_ms = 5000, - ), - :retry => retryHook, -)) -``` - -### Pattern 3: Security - -```julia -# Security hook -function securityHook(event, signal) - if event isa ToolCallEvent - # Validate tool call - if event.tool_name == "bash" - command = event.input["command"] - - # Block dangerous commands - dangerous_patterns = ["rm -rf /", "sudo", "curl | sh"] - for pattern in dangerous_patterns - if contains(command, pattern) - return ToolCallResult(true, "Blocked dangerous command") - end - end - end - end +# Log all agent events for debugging and monitoring +subscribe(agent) do event, signal + timestamp = Dates.now(Dates.UTC) - return nothing + if event isa AgentStartEvent + println("[$timestamp] AgentStart") + elseif event isa AgentEndEvent + println("[$timestamp] AgentEnd ($(length(event.messages)) messages)") + elseif event isa TurnStartEvent + println("[$timestamp] TurnStart") + elseif event isa TurnEndEvent + tool_count = length(event.tool_results) + println("[$timestamp] TurnEnd ($tool_count tools)") + elseif event isa ToolExecutionStartEvent + println("[$timestamp] ToolStart: $(event.tool_name)") + elseif event isa ToolExecutionEndEvent + status = event.is_error ? "ERROR" : "OK" + println("[$timestamp] ToolEnd: $(event.tool_name) [$status]") + end end ``` +### Pattern 2: Abort Handling + +```julia +# Abort a running agent +if !isnothing(agent.active_run) + abort(agent) +end + +# Check if agent is idle +if isnothing(agent.active_run) + println("Agent is idle") +end +``` + +### Pattern 3: Continue from Transcript + +```julia +# Continue from the last message in the transcript +continue!(agent) + +# The last message must be user or tool-result role. +# If the last message is assistant, pending steering/follow-up messages +# are processed first, then an error is thrown if none exist. +``` + ## Debugging Patterns ### Pattern 1: Conversation Trace ```julia -# Trace conversation +# Trace all messages in the conversation trace = [] subscribe(agent) do event, signal if event isa MessageEndEvent + msg = event.message push!(trace, Dict( - "role" => event.message.role, - "content" => event.message.content, + "role" => msg.role, + "type" => typeof(msg).name.name, )) end end # Run conversation prompt(agent, "Hello") -wait_for_idle(agent) +waitForIdle(agent) # Print trace for entry in trace - println("$(entry["role"]): $(entry["content"])") + println("$(entry["type"]): $(entry["role"])") end ``` @@ -846,12 +675,14 @@ subscribe(agent) do event, signal push!(tool_trace, Dict( "type" => "start", "tool" => event.tool_name, + "id" => event.tool_call_id, "args" => event.args, )) elseif event isa ToolExecutionEndEvent push!(tool_trace, Dict( "type" => "end", "tool" => event.tool_name, + "id" => event.tool_call_id, "error" => event.is_error, )) end @@ -875,7 +706,7 @@ end # Use after conversation prompt(agent, "Hello") -wait_for_idle(agent) +waitForIdle(agent) dumpState(agent) ``` @@ -883,11 +714,11 @@ dumpState(agent) 1. **Start simple**, add complexity gradually 2. **Use hooks for customization**, not core logic -3. **Test with mock LLM** first +3. **Test with basic agent** first before adding hooks 4. **Monitor token usage** for long conversations 5. **Use branches** for exploration -6. **Compact periodically** to stay within limits -7. **Handle errors gracefully** -8. **Log important events** -9. **Test edge cases** -10. **Profile performance** +6. **Handle errors gracefully** via event subscriptions +7. **Log important events** +8. **Clear queues** when not needed +9. **Use correct Julia naming conventions** (camelCase for functions) +10. **Pass session as first argument** for session functions diff --git a/learning/README.md b/learning/README.md index 252edb9..d163f82 100644 --- a/learning/README.md +++ b/learning/README.md @@ -37,7 +37,7 @@ agent = Agent(Dict( prompt(agent, "Hello!") # Wait for completion -wait_for_idle(agent) +waitForIdle(agent) ``` ### Understanding the Flow @@ -83,6 +83,12 @@ User Code - `steer()` - Queue message for next turn - `followUp()` - Queue message after stop - `subscribe()` - Listen to events +- `waitForIdle()` - Wait for agent to finish processing +- `reset!()` - Clear transcript state and queued messages +- `clearAllQueues()` - Remove all queued steering and follow-up messages +- `hasQueuedMessages()` - Check if queues have pending messages +- `abort()` - Abort the current run +- `get_state()` - Get the current agent state ### AgentLoop @@ -113,9 +119,14 @@ User Code **Key methods**: - `appendMessage()` - Add message -- `appendCompaction()` - Compress history +- `appendCompaction()` - Compress history with summary - `moveTo()` - Navigate branches -- `buildSessionContext()` - Build context for LLM +- `buildContext()` - Build context for LLM +- `getBranch()` - Get branch entries +- `getSessionStats()` - Get session statistics +- `appendThinkingLevelChange()` - Record thinking level change +- `appendModelChange()` - Record model change +- `appendActiveToolsChange()` - Record active tools change ### Tools @@ -193,20 +204,23 @@ Message (for LLM API) ├── is_error::Bool └── timestamp::Timestamp -AgentMessage (internal, extends Message) +AgentMessage (internal, abstract type) ├── UserMessage (same as above) ├── AssistantMessage (same as above) -├── ToolResultMessage (same as above) +├── ToolResultMessage (same as above, plus: role, added_tool_names) ├── BashExecutionMessage (custom) │ ├── role, command, output, exit_code -│ ├── cancelled, truncated, exclude_from_context -│ └── timestamp +│ ├── cancelled, truncated, full_output_path, timestamp +│ └── exclude_from_context ├── CompactionSummaryMessage (custom) -│ ├── summary, tokens_before, timestamp +│ ├── role, summary, tokens_before, timestamp │ └── converted to UserMessage for LLM -└── BranchSummaryMessage (custom) - ├── summary, from_id, timestamp - └── converted to UserMessage for LLM +├── BranchSummaryMessage (custom) +│ ├── role, summary, from_id, timestamp +│ └── converted to UserMessage for LLM +└── CustomMessage (custom, extends AgentMessage) + ├── message::AgentMessage + └── custom_type::String ``` ### Complete Conversation Flow @@ -427,7 +441,7 @@ appendMessage(session, user_message) appendMessage(session, assistant_message) # Build context from session -context = buildSessionContext(session) +context = buildContext(session) ``` ### Pattern 2: Long Conversations @@ -451,7 +465,7 @@ end session.moveTo(branch_point_id) # Create new branch -appendBranchSummary(session, "Exploring alternative approach") +moveTo(session, branch_point_id, summary=["summary" => "Exploring alternative approach"]) appendMessage(session, new_user_message) ``` @@ -460,13 +474,13 @@ appendMessage(session, new_user_message) ```julia # Create custom tool custom_tool = AgentTool( - "custom", - "custom", - "Does custom thing", - ..., - execute_function, - nothing, - EXECUTION_PARALLEL, + "custom", # name + "Custom", # label + "Does custom thing", # description + parameters, # parameter schema + execute_function, # execute + nothing, # prepare_arguments (optional) + EXECUTION_PARALLEL, # execution_mode ) # Add to agent