# Tools — Complete End-to-End Lifecycle This document describes the complete tool lifecycle in the YiemAgent framework, from definition through execution, for framework authors and maintainers who need a thorough understanding of the architecture. --- ## Table of Contents 1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) 2. [Overview](#2-overview) 3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) 4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) 5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) 6. [Message Processing Pipeline](#6-message-processing-pipeline) 7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) 8. [The Per-Call Pipeline — Prepare, Execute, Finalize](#8-the-per-call-pipeline--prepare-execute-finalize) 9. [Execution Modes — Sequential vs Parallel](#9-execution-modes--sequential-vs-parallel) 10. [Tool Call Batches & Termination Logic](#10-tool-call-batches--termination-logic) 11. [Tool Result Message Creation](#11-tool-result-message-creation) 12. [Error Handling & Recovery Pattern](#12-error-handling--recovery-pattern) 13. [Event System — Tool Lifecycle Events](#13-event-system--tool-lifecycle-events) 14. [Agent Lifecycle Hooks](#14-agent-lifecycle-hooks) 15. [Self-Modifying Tools](#15-self-modifying-tools) 16. [Complete End-to-End Example](#16-complete-end-to-end-example) 17. [Tool File Contract](#17-tool-file-contract) 18. [Appendix: Type Reference](#18-appendix-type-reference) --- ## 1. Quick Start: Tool Lifecycle This section shows the complete lifecycle from tool registration through execution and result extraction. Each step maps to the detailed sections below. ### Step 1: Discover — `listTools` The agent calls the `listTools` tool to see available tools and detect name collisions before creating new ones. ```julia # The listTools tool is auto-injected via listTool(store) — no manual registration needed tool = listTool(store) # Returns an agentTool that, when executed, lists all tools in the store ``` **Result extraction:** ```julia result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) # result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..." ``` **Source:** `toolRegistry.jl:54-82` --- ### Step 2: Load — `loadTools()` Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. ```julia using YiemAgent, YiemAgent.toolRegistry store = toolStore(name="myAgent") tools = loadTools(store, "src/tools") # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools # Also auto-registers listTools for runtime discovery ``` **Result extraction:** ```julia all_tools = getTools(store) # OrderedDict{String, agentTool} # Keys: "getTime", "getWeather", "writeTool", "listTools" getTime_tool = all_tools["getTime"] # Manual registration (alternative to loadTools) registerTool(store, my_tool) clearTools(store) # Clear all tools from store ``` **Source:** `toolRegistry.jl:126-178` --- ### Step 2.5: Create Agent with Tools Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration. ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry # 1. Set up toolStore and load tools (auto-registers listTools) store = toolStore(name="myAgent") loadTools(store, "src/tools") # 2. Create agent — pass tools + _tool_store agent = yiemAgent( systemPrompt = "You are a helpful assistant.", model = my_model, tools = getTools(store), # OrderedDict{String, agentTool} llmCall = my_llm_call, # Function that calls the LLM API agentEventSink = my_event_sink, # Function for TUI/logging _tool_store = store, # For runtime registerTool() calls ) ``` **Manual registration** (without `loadTools`): ```julia store = toolStore(name="myAgent") registerTool(store, getTime_tool) registerTool(store, getWeather_tool) registerTool(store, listTool(store)) # needed for manual registration agent = yiemAgent( tools = getTools(store), llmCall = my_llm_call, agentEventSink = my_event_sink, _tool_store = store, ) ``` **Key constructor parameters:** | Parameter | Type | Required | Purpose | |-----------|------|----------|---------| | `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | | `model` | `llmModel` | No | LLM model config | | `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) | | `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | | `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` | Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. **Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195` --- ### Step 3: Use — Tool Execution Tools can be used in two ways: **Direct execution (testing / standalone):** ```julia using YiemAgent.type sig = nothing op = x -> x # no-op partial result callback # Execute a loaded tool directly result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) ``` **Via agent loop (production):** ``` user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) → _agent_loop detects message → @spawn _process_message(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) → prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall → createToolResultMessage → batch.messages (toolResultMessage[]) ``` **Source:** Direct: `test/toolTest.jl:81-99` | Agent: `agentCore.jl:35-311` --- ### Step 4: Extract Result **`agentToolResult`** (raw tool output, `type.jl:429-434`): ```julia result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) result.content[1] # textContent("Current time in Tokyo: ...") result.content[1].text # "Current time in Tokyo: 2026-08-10T..." result.details # Dict{Any,Any}() — tool-specific metadata result.usage # nothing — llmUsage tracking (optional) result.terminate # false — signals loop termination ``` **`toolResultMessage`** (wrapped for conversation history, `type.jl:152-191`): ```julia msg = batch.messages[1] # toolResultMessage msg.toolCallId # "call-1" msg.toolName # "getTime" msg.content # Vector{messageContent} msg.isError # false msg.details # tool-specific metadata msg.timestamp # DateTime ``` --- ## 2. Overview The tool system follows a **three-phase pipeline** per tool call: ``` PREPARE → EXECUTE → FINALIZE ``` Each phase has a single responsibility and produces an intermediate result: | Phase | Function | Input | Output | Purpose | |-------|----------|-------|--------|---------| | Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | | Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results | | Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. --- ## 3. Tool Definition — The `agentTool` Struct **Source:** `type.jl:261-281` Every tool is an `agentTool` struct with the following fields: ```julia struct agentTool name::String # Unique identifier (e.g. "getWeather") label::String # Human-readable name (e.g. "Weather Lookup") description::String # What the tool does (shown to the LLM for selection) inputSchema::Any # JSON Schema (MCP format) describing parameters execute::Function # Core execution: (toolCallId, args, signal, onPartialResult) -> agentToolResult prepareArguments::Union{Function, Nothing} # Optional: (args) -> modified_args (before validation) validateRequiredArgs::Union{Function, Nothing} # Optional: (args) -> Union{Nothing, String} error parallelToolExecute::Bool # Override: run in parallel with other tools end ``` ### Field Details | Field | Required | Signature | Purpose | |-------|----------|-----------|---------| | `name` | Yes | `String` | Unique key for tool lookup in `context.tools[name]` | | `label` | Yes | `String` | Human-readable name for display | | `description` | Yes | `String` | Shown to LLM for tool selection decisions | | `inputSchema` | Yes | `Dict{String,Any}` | JSON Schema (MCP format) with `type`, `properties`, `required` | | `execute` | Yes | `Function` | The actual tool logic (see signature below) | | `prepareArguments` | No | `Function` | Transforms args before validation; `(args::Dict) -> Dict` | | `validateRequiredArgs` | No | `Function` | Custom validation; `(args::Dict) -> Union{Nothing, String}` | | `parallelToolExecute` | No | `Bool` | Default `false`. When `true`, allows parallel execution | ### Execute Function Signature ```julia execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult ``` | Parameter | Description | |-----------|-------------| | `toolCallId` | Unique ID from the LLM's tool call (e.g., `"call_abc123"`) | | `args` | Validated arguments from the LLM, already passed through `prepareArguments` and `validateRequiredArgs` | | `signal` | Optional `abortSignal` for cancellable operations. Check `signal.aborted` to abort early. | | `onPartialResult` | Callback for streaming progress: `onPartialResult(partial_data)` emits `toolExecUpdateEvent` | ### Returns — `agentToolResult` **Source:** `type.jl:429-434` ```julia struct agentToolResult content::Vector{messageContent} # Output content (textContent, etc.) details::Dict{Any,Any} # Tool-specific metadata (e.g., counts, IDs) usage::Union{llmUsage, Nothing} # Token usage tracking (optional) terminate::Bool # If true, signals the tool requested loop termination end ``` The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-call-batches--termination-logic) for details. --- ## 4. Tool Registration — Per-Agent Tool Stores **Source:** `toolRegistry.jl` ### How `toolStore` Works The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. ```julia struct toolStore tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration name::String # identifier for debugging/logs end ``` `store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. ### How `loadTools(store, dir)` Works ```julia function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} ``` **Source:** `toolRegistry.jl:126-178` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order 3. **Wraps** each file in a dynamically created submodule: ```julia # For "getWeather.jl" → module _tool_getWeather module _tool_getWeather using ..type using Dates, UUIDs, DataStructures, JSON # (file contents here) end ``` 4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 5. **Validates** the return value is an `agentTool` instance 6. **Registers** the tool in `store.tools` 7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime ### Why Submodules? Each tool file is loaded into its own **namespaced submodule**. This means: - `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` - No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` - The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected ### Registration API ```julia # Create per-agent stores store1 = toolStore(name="agent1") store2 = toolStore(name="agent2") # Load tools into specific stores (auto-registers listTools) tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only # Manual registration (per-store) registerTool(store1, my_tool) # Query (returns OrderedDict keyed by tool name, in registration order) all_tools = getTools(store1) # OrderedDict{String, agentTool} — O(1) lookup + deterministic order # Clear (per-store) clearTools(store1) # only clears store1 ``` `getTools(store)` returns the internal `OrderedDict` directly, giving callers: - O(1) lookup by tool name - Deterministic iteration order (registration order) - Consistency with `agentState.tools` (also `OrderedDict{String, agentTool}`) - No copy overhead — mutations on the returned value affect the store ### Per-Agent Isolation Each `toolStore` is completely independent — tools registered in one store do not appear in another: ```julia storeA = toolStore(name="A") storeB = toolStore(name="B") registerTool(storeA, getTime_tool) registerTool(storeB, getWeather_tool) getTools(storeA) # only contains getTime getTools(storeB) # only contains getWeather clearTools(storeA) # storeB is unaffected ``` This ensures that `yiemAgent` instances with different `tool_store` references operate with completely isolated tool sets. --- ## 5. The Agent Loop — High-Level Flow **Source:** `agentCore.jl:35-145` The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. ### Channel Architecture ``` yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - _tool_store (toolStore) ← per-agent isolated tool registry ``` ### Loop States The loop tracks 6 states (documented at `agentCore.jl:39-75`): | State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | |-------|-----------------|-------------|----------------|-------------------|----------| | 1 | `nothing` | `false` | empty | empty | Idle, waiting | | 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` | | 3 | running | `true` | empty | empty | Processing, no new input | | 4 | running | `true` | has msg | empty | New message while busy → queued | | 5 | running | `true` | empty | has msg | Follow-up while busy → queued | | 6 | done | `false` | empty | empty | Task completed → send result, reset | ### Loop Logic (simplified) ```julia function _agent_loop(agent::yiemAgent) while true # 1. Wait for message from inputChannel (blocking poll) msg = fetch!(agent.inputChannel) # agentCore.jl:84 # 2. Handle shutdown signal if msg === :shutdown drain both channels, break loop end # 3. If agent is idle, spawn _process_message if agent._state.activeRun == false processingTask = Threads.@spawn _process_message(agent) agent._state.activeRun = true end # 4. While processing: check for followUp messages if istaskdone(processingTask) == false && isready(agent.followUpChannel) drain followUpChannel → push to inputChannel continue # wait for current processing to finish end # 5. When processing completes if istaskdone(processingTask) == true result = fetch(processingTask) put!(agent.outputChannel, result) agent._state.activeRun = false processingTask = nothing end end end ``` **Key design:** The loop always checks `inputChannel` before `followUpChannel`. Follow-up messages are merged into `inputChannel` only when the current processing task is active, ensuring they are processed after the primary message completes but before new input arrives. --- ## 6. Message Processing Pipeline **Source:** `agentCore.jl:175-311` `_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline. ### Pipeline Steps ```julia function _process_message(agent::yiemAgent)::assistantMessage final_response = nothing while true # Loop until LLM returns response without tool calls # ── Step 1: Drain inputChannel ────────────────────────────── while isready(agent.inputChannel) raw_msg = take!(agent.inputChannel) if raw_msg === :shutdown put!(agent.inputChannel, :shutdown) break end user_msg = OpenAiToUserMessage(raw_msg) # Convert Dict → userMessage push!(agent._state.messages, user_msg) end # ── Step 2: Prepare context ───────────────────────────────── preparedContext = agent.prepareContext(agent._state) # Default: deep copies systemPrompt, messages, tools from agentState → agentContext # Override point: filter tools, inject context, modify system prompt # ── Step 3: Format for LLM ────────────────────────────────── formatted_messages = agent.formatMsgForLLM(preparedContext) # Converts agentContext → Dict("messages" => [...]) in OpenAI format # Wraps systemPrompt as system role, converts each messageContent block # ── Step 4: Call LLM ──────────────────────────────────────── response = agent.llmCall(formatted_messages) # Returns assistantMessage with content::Vector{messageContent} # Each content block has a type: "text", "thinking", or "tool_call" # ── Step 5: Extract tool calls ────────────────────────────── has_tool_calls, tool_call_list = extract_tool_calls(response.content) # Inspects content blocks for "tool_calls" or "tool_call" Dict entries # ── Step 6: Execute tool calls or return ──────────────────── if has_tool_calls && !isempty(tool_call_list) # Build context and config context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...) signal = nothing emit = agent.agentEventSink # Execute tool calls (sequential or parallel) batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) # Save results to conversation history for tool_result in batch.messages push!(agent._state.messages, tool_result) end # Check termination if batch.terminate final_response = build_final_response(batch) break end # Otherwise, loop back to Step 1 (drain any new input) and call LLM again else # No tool calls — this is the final response final_response = response break end end return final_response end ``` ### Debug Note There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use. --- ## 7. Tool Call Extraction from LLM Response **Source:** `agentCore.jl:217-245` After the LLM call, the agent inspects `response.content` (a `Vector{messageContent}`) for tool call blocks. Two formats are supported: ### Format 1: OpenAI `tool_calls` array ```julia # Response content block: Dict( :type => "tool_calls", :tool_calls => [ Dict(:id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo")), Dict(:id => "call_2", :name => "getTime", :arguments => Dict("timezone" => "Asia/Tokyo")), ] ) ``` ### Format 2: Single `tool_call` block ```julia Dict( :type => "tool_call", :id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo") ) ``` ### Extraction Logic ```julia tool_call_list = agentToolCall[] for content_block in response.content if content_block isa Dict # OpenAI format: array of tool calls if get(content_block, :type, "") == "tool_calls" for tc_data in get(content_block, :tool_calls, []) tc = agentToolCall( type = "function", id = get(tc_data, :id, string(uuid4())), # fallback UUID name = get(tc_data, :function, Dict())[:name], arguments = get(tc_data, :function, Dict())[:arguments], ) push!(tool_call_list, tc) end # Alternative format: single tool call elseif get(content_block, :type, "") == "tool_call" tc_data = content_block tc = agentToolCall( type = "function", id = get(tc_data, :id, string(uuid4())), name = get(tc_data, :name, ""), arguments = get(tc_data, :arguments, Dict{String,Any}()), ) push!(tool_call_list, tc) end end end ``` The `agentToolCall` struct is defined at `type.jl:362-367`: ```julia struct agentToolCall type::String # Always "function" id::String # Unique tool call identifier name::String # Tool name (matches context.tools keys) arguments::Dict{String, Any} # Parsed tool arguments end ``` --- ## 8. The Per-Call Pipeline — Prepare, Execute, Finalize This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. ### 8.1 Phase 1: Prepare — `prepareToolCall()` **Source:** `agentCore.jl:511-547` ```julia function prepareToolCall( context::agentContext, assistantMsg::assistantMessage, toolCall::agentToolCall, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall, immediateOutcome} ``` **Steps:** 1. **Resolve tool by name** — `get(context.tools, toolCall.name, nothing)` - If `nothing` → `immediateOutcome(createErrorToolResult("Tool X not found"), true)` 2. **Prepare arguments** — `prepareToolCallArguments(tool, toolCall)` - Calls `tool.prepareArguments(toolCall.arguments)` if defined - Returns the toolCall with transformed arguments - If no hook or no change, returns original `toolCall` 3. **Validate arguments** — `validateToolArguments(tool, prepared)` - Calls `tool.validateRequiredArgs(prepared.arguments)` if defined, otherwise uses default `validateRequiredArgs(prepared.arguments, tool.inputSchema)` - Default validator checks `inputSchema["required"]` array - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below 4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal` - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` 5. **Return success** → `preparedToolCall(tool, toolCall, validatedArgs)` **Source:** `type.jl:681-685` — `preparedToolCall` holds the resolved tool, original call metadata, and validated arguments together. **Key design principle:** Preparation **never throws**. Every failure path returns an `immediateOutcome` with `isError=true`, ensuring the agent loop always has a valid result to feed back to the LLM. ### 8.2 Phase 2: Execute — `executePreparedToolCall()` **Source:** `agentCore.jl:589-617` ```julia function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing, abortSignal}, emit::Function, )::executedOutcome ``` **Steps:** 1. **Initialize streaming state:** ```julia updateEvents = promise[] # vector to collect update event handles accepting = true # guard to prevent duplicate emissions ``` 2. **Call `tool.execute()`:** ```julia result = prep.tool.execute( prep.toolCall.id, prep.args, signal, partialResult -> begin if accepting push!(updateEvents, emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, prep.toolCall.arguments, partialResult))) end end ) ``` 3. **Wait for streaming to settle:** ```julia accepting = false wait.(updateEvents) # wait for all pending update event handlers return executedOutcome(result, false) ``` 4. **On error:** ```julia catch err accepting = false wait.(updateEvents) return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) end ``` **Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted. ### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` **Source:** `agentCore.jl:675-706` ```julia function finalizeExecutedToolCall( context::agentContext, assistantMsg::assistantMessage, prep::preparedToolCall, executed::executedOutcome, config::agentLoopConfig, signal::Union{Nothing,abortSignal}, )::finalizedOutcome ``` **Steps:** 1. **Extract execution result:** ```julia result = executed.result isError = executed.isError ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - Hook can mutate the result: ```julia after = config.afterToolCall(afterToolCallContext(...)) if after !== nothing result = merge(result, dict( :content => get(after, :content, result.content), :details => get(after, :details, result.details), :usage => get(after, :usage, result.usage), :terminate => get(after, :terminate, result.terminate) )) isError = get(after, :isError, isError) end ``` - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` 3. **Return:** ```julia return finalizedOutcome(prep.toolCall, result, isError) ``` **Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. ### 8.4 Emission — `emitToolExecutionEnd()` **Source:** `agentCore.jl:736-739` ```julia function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) end ``` This is called immediately after finalization, before building the `toolResultMessage`. --- ## 9. Execution Modes — Sequential vs Parallel **Source:** `agentCore.jl:795-936, 988-1011` ### Dispatcher — `executeToolCalls()` ```julia function executeToolCalls( context::agentContext, assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, emit::Function, )::agentToolCallBatch ``` **Decision logic** (`agentCore.jl:997-1010`): ```julia hasSequential = false for tc in toolCalls t = get(context.tools, tc.name, nothing) if t !== nothing && !t.parallelToolExecute hasSequential = true break end end if config.toolExecution == "sequential" || hasSequential return executeToolCallsSequential(...) else return executeToolCallsParallel(...) end ``` **Rule:** If the global config is `"sequential"` **OR** any tool in the batch has `parallelToolExecute = false`, the entire batch runs sequentially. Sequential is the safe default. ### Sequential Execution — `executeToolCallsSequential()` **Source:** `agentCore.jl:795-829` ```julia function executeToolCallsSequential(...)::agentToolCallBatch finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] for tc in toolCalls emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) else executed = executePreparedToolCall(prep, signal, emit) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end emitToolExecutionEnd(finalized, emit) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) if signal !== nothing && signal.aborted break # abort: skip remaining calls end end return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end ``` ### Parallel Execution — `executeToolCallsParallel()` **Source:** `agentCore.jl:888-936` ```julia function executeToolCallsParallel(...)::agentToolCallBatch entries = union{finalizedOutcome, task{finalizedOutcome}}[] for tc in toolCalls emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) emitToolExecutionEnd(finalized, emit) push!(entries, finalized) # immediate outcome — no task else task = task() do executed = executePreparedToolCall(prep, signal, emit) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) emitToolExecutionEnd(finalized, emit) return finalized end schedule(task) push!(entries, task) # pending task end if signal !== nothing && signal.aborted break # abort during preparation — skip remaining end end # Collect results in original order finalizedCalls = finalizedOutcome[] for entry in entries outcome = entry isa task ? fetch(entry) : entry push!(finalizedCalls, outcome) end messages = toolResultMessage[] for f in finalizedCalls push!(messages, createToolResultMessage(f)) end return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end ``` **Key design:** All tool calls are **prepared** sequentially (validation, hooks), then prepared calls are **executed** concurrently as separate tasks. Results are collected in the original call order via `fetch()`. **Trade-off:** Parallel execution reduces wall-clock time for independent tools but can overwhelm external resources (rate limits, connection pools, disk I/O). --- ## 10. Tool Call Batches & Termination Logic **Source:** `type.jl:793-838` ### `agentToolCallBatch` ```julia struct agentToolCallBatch messages::Vector{toolResultMessage} # Tool result messages for this batch terminate::Bool # Whether the batch should terminate the loop end ``` ### Termination Logic — `shouldTerminate()` **Source:** `agentCore.jl:409-411` ```julia function shouldTerminate(batches::Vector{finalizedOutcome})::Bool return !isempty(batches) && all(b -> b.result.terminate, batches) end ``` **Rule:** `terminate` is `true` **only when every tool in the batch** has `result.terminate == true`. This prevents a single tool that sets `terminate: true` (e.g., for metadata purposes) from accidentally stopping the agent when other tools did not intend to terminate. ### When to Set `terminate = true` From the type documentation (`type.jl:803-815`): | Use Case | Description | |----------|-------------| | Task completion | A tool like `deploy` or `submit` finishes its work and signals the agent to stop | | Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | | Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | ### Batch Processing in `_process_message()` **Source:** `agentCore.jl:266-307` ```julia batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) # Save results to conversation history for tool_result in batch.messages push!(agent._state.messages, tool_result) end if batch.terminate # Build final response from tool results final_content = [textContent("Tool execution completed.")] for tool_result in batch.messages for content_block in tool_result.content if content_block isa textContent append!(final_content, [content_block]) elseif content_block isa Dict if haskey(content_block, :text) push!(final_content, textContent(content_block[:text])) end end end end final_response = assistantMessage( role = "assistant", content = final_content, api = response.api, model = response.model, usage = response.usage, stopReason = "tool_use_terminated", errorMessage = if any(x -> x.isError, batch.messages) "One or more tool calls failed" else nothing end, timestamp = now(), ) break # exit the while loop end # If batch.terminate == false, loop back to call LLM again with tool results ``` --- ## 11. Tool Result Message Creation **Source:** `agentCore.jl:373-379` ### `createToolResultMessage()` ```julia function createToolResultMessage(f::finalizedOutcome)::toolResultMessage return toolResultMessage( "toolResult", # role f.toolCall.id, # toolCallId f.toolCall.name, # toolName f.result.content, # content (Vector{messageContent}) f.result.details, # details f.result.usage, # usage get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools) f.isError, # isError nowMillis(), # timestamp ) end ``` ### `toolResultMessage` Struct **Source:** `type.jl:152-191` ```julia struct toolResultMessage <: agentMessage role::String # Always "tool" toolCallId::String # ID matching the original tool call toolName::String # Name of the executed tool content::Vector{messageContent} # Tool output content details::Any # Additional tool-specific details usage::Union{llmUsage, Nothing} # Token usage if applicable addedToolNames::Union{Vector{String}, Nothing} # Tools added during execution isError::Bool # Whether the tool call resulted in an error timestamp::Timestamp # When the result was recorded end ``` ### Conversation History After Tool Execution ``` [system] "You are a helpful assistant." [user] "What's the weather in Tokyo?" [assistant] {tool_calls: [getWeather(city="Tokyo")]} [tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C" ``` On the next loop iteration, `formatMsgForLLM()` converts `toolResultMessage` to OpenAI format: ```julia Dict( "role" => "tool", "tool_call_id" => "call_1", "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")] ) ``` --- ## 12. Error Handling & Recovery Pattern The framework uses a **result-based error handling** pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover. ### Error Flow ``` Tool call fails at any phase │ ▼ ┌─────────────────────────┐ │ Phase: Prepare │ → immediateOutcome(error_result, true) │ Phase: Execute │ → executedOutcome(error_result, true) │ Phase: Finalize (hook) │ → finalizedOutcome(tc, error_result, true) └─────────────────────────┘ │ ▼ emitToolExecutionEnd(finalized, emit) │ ▼ createToolResultMessage(finalized) │ ▼ push!(agent._state.messages, toolResultMessage) │ ▼ formatMsgForLLM() → LLM receives error as tool result │ ▼ LLM can: retry with corrected args, report failure, or ask user for clarification ``` ### `createErrorToolResult()` **Source:** `agentCore.jl:339-341` ```julia function createErrorToolResult(msg::String)::agentToolResult return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) end ``` Returns an `agentToolResult` with a single `textContent` block containing the error message. This is wrapped in an `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` depending on where the error occurred, then converted to `toolResultMessage` for the LLM. **Key design:** Returning a result instead of throwing allows the LLM to see the error and decide whether to retry, re-issue the call with different arguments, or report failure to the user. --- ## 13. Event System — Tool Lifecycle Events **Source:** `type.jl:480-516` Each tool call emits a three-event lifecycle: ``` toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent ``` ### Event Types ```julia struct toolExecStartEvent toolCallId::String # ID of the tool call toolName::String # Name of the tool arguments::Dict{String, Any} # Tool arguments end struct toolExecUpdateEvent toolCallId::String # ID of the tool call toolName::String # Name of the tool arguments::Dict{String, Any} # Tool arguments partialResult::Any # The partial result data end struct toolExecEndEvent toolCallId::String # ID of the tool call toolName::String # Name of the tool result::agentToolResult # The final tool result isError::Bool # Whether execution resulted in an error end ``` ### Event Emission Points | Event | Emitted From | When | |-------|-------------|------| | `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | | `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | | `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call | ### Event Sink The `emit` function is passed through the entire call chain: ```julia emit = agent.agentEventSink # set during yiemAgent construction ``` The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: - **TUI (Terminal UI):** Display real-time progress, tool names, results - **Logging systems:** Record tool execution history - **Monitoring:** Track tool usage, execution times, error rates - **Audit trails:** Log all tool calls with arguments and results --- ## 14. Agent Lifecycle Hooks ### Hook Types | Hook | Signature | Called | Purpose | |------|-----------|--------|---------| | `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | ### `beforeToolCall` Hook **Source:** `agentCore.jl:530-541` ```julia if config.beforeToolCall !== nothing before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) if signal !== nothing && signal.aborted return immediateOutcome(createErrorToolResult("Operation aborted"), true) end if before !== nothing && before.block return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end ``` **Return values:** - `nothing` — proceed with execution - `Dict(:block => true, :reason => "...")` — block execution, error fed back to LLM - `Dict(:block => false)` — proceed (explicit allow) ### `afterToolCall` Hook **Source:** `agentCore.jl:687-703` ```julia if config.afterToolCall !== nothing try after = config.afterToolCall( afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing result = merge(result, dict( :content => get(after, :content, result.content), :details => get(after, :details, result.details), :usage => get(after, :usage, result.usage), :terminate => get(after, :terminate, result.terminate) )) isError = get(after, :isError, isError) end catch err result = createErrorToolResult(sprint(showerror, err)) isError = true end end ``` **Common use cases:** - Mask sensitive data from result content before the LLM sees it (e.g., removing API keys from error messages) - Normalize usage tracking data into a consistent format - Inspect the result and decide to flip `terminate: true` based on business logic - Wrap an error result in a friendlier message for the LLM to understand ### `prepareContext` Hook **Source:** `utils.jl:111-125` ```julia function prepareContext(state::agentState)::agentContext # TODO: filter tools from state.tools based on user intent filteredTools = state.tools # TODO: add filtered tools to the current system prompt / modify systemPrompt preparedSystemPrompt = state.systemPrompt # TODO: add system prompt, adjust/modify and inject additional context into messages preparedMessages = deepcopy(state.messages) return agentContext(preparedSystemPrompt, preparedMessages, filteredTools) end ``` **Override points for customization:** - Filter tools based on user intent (e.g., only show "wine" tools when user asks about wine) - Modify the system prompt dynamically (e.g., inject current time, user preferences) - Inject additional context (e.g., retrieved documents, current user state) - Prune or reorder messages before formatting for the LLM ### `formatMsgForLLM` Hook **Source:** `utils.jl:158-219` Default implementation converts `agentContext` to OpenAI-compatible format: ```julia function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} messages = Vector{Dict{String, Any}}() # System prompt as system message if !isempty(ctx.systemPrompt) push!(messages, Dict( "role" => "system", "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] )) end # Conversation messages for msg in ctx.messages if msg isa userMessage push!(messages, _userMessageToOpenAI(msg)) elseif msg isa assistantMessage push!(messages, _assistantMessageToOpenAI(msg)) elseif msg isa toolResultMessage push!(messages, _toolResultMessageToOpenAI(msg)) end end return Dict("messages" => messages) end ``` Override this to produce custom LLM message formats for different APIs/providers (e.g., Anthropic, Google, Ollama). --- ## 15. Self-Modifying Tools The framework supports tools that modify the tool system itself at runtime. ### `writeTool` — Create New Tool Files **Source:** `tools/writeTool.jl` `writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (Julia code body), and `writeTool` wraps it in Julia boilerplate: 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 2. Indents `executeCode` with 4 spaces 3. Wraps it inside `function executeTool(...)::agentToolResult ... end` 4. Appends `getTool()` returning an `agentTool` struct 5. Writes the combined string to `src/tools/.jl` ### `listTool` — Discover Available Tools **Source:** `toolRegistry.jl:55-82` Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow ``` 1. Agent detects no existing tool handles the user's request 2. Agent calls writeTool with: - name: "searchWine" - label: "Wine Search" - description: "Search a wine database..." - inputSchema: { ... } - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (optional) validateCode, prepareCode 3. writeTool generates src/tools/searchWine.jl 4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file 5. Agent calls searchWine(query="cabernet") 6. Result: "Found 5 cabernet wines..." ``` ### `writeTool` Input Schema | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `String` | Yes | Valid Julia identifier (letters, digits, underscores) | | `label` | `String` | Yes | Human-readable tool name | | `description` | `String` | Yes | What the tool does | | `inputSchema` | `Dict` | Yes | JSON Schema in MCP format | | `executeCode` | `String` | Yes | Julia code for `executeTool` body (NOT wrapped in function) | | `validateCode` | `String` | No | Custom validation Julia code | | `prepareCode` | `String` | No | Argument preparation code | | `parallel` | `Bool` | No | Whether the tool can run in parallel (default: `false`) | --- ## 16. Complete End-to-End Example ### Full Lifecycle: User Message to Tool Result ``` USER SENDS MESSAGE └─> runAgent(agent, "What's the weather in Tokyo?") └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL └─> _agent_loop: detects msg in inputChannel └─> Threads.@spawn _process_message(agent) ── _process_message ────────────────────────────────────────────── │ │ Step 1: Drain inputChannel │ raw_msg = Dict("role" => "user", "content" => [...]) │ user_msg = OpenAiToUserMessage(raw_msg) │ push!(agent._state.messages, user_msg) │ │ Step 2: prepareContext │ ctx = agent.prepareContext(agent._state) │ → agentContext(systemPrompt, messages, tools) │ │ Step 3: formatMsgForLLM │ formatted = agent.formatMsgForLLM(ctx) │ → Dict("messages" => [ │ Dict("role" => "system", "content" => [...]), │ Dict("role" => "user", "content" => [...]), │ ]) │ │ Step 4: llmCall │ response = agent.llmCall(formatted) │ → assistantMessage(content = [ │ Dict(:type => "tool_calls", :tool_calls => [ │ Dict(:id => "call_1", :name => "getWeather", │ :arguments => Dict("city" => "Tokyo")) │ ]) │ ]) │ │ Step 5: Extract tool calls │ tool_call_list = [agentToolCall("function", "call_1", "getWeather", ...)] │ │ Step 6: Execute tool calls │ context = agentContext(systemPrompt, messages, tools) │ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential") │ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit) │ │ ── executeToolCallsSequential ────────────────────────────── │ │ │ │ For tc = agentToolCall("call_1", "getWeather", ...): │ │ │ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"})) │ │ │ │ PREPARE: │ │ tool = context.tools["getWeather"] → found! │ │ validatedArgs = validateToolArguments(tool, tc) │ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes │ │ beforeToolCall_hook(...) → nothing (skipped) │ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) │ │ │ │ EXECUTE: │ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult) │ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) │ │ → executedOutcome(result, false) │ │ │ │ FINALIZE: │ │ afterToolCall_hook(...) → nothing (skipped) │ │ → finalizedOutcome(tc, result, false) │ │ │ │ emit(toolExecEndEvent("call_1", "getWeather", result, false)) │ │ msg = createToolResultMessage(finalized) │ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) │ │ │ └─> agentToolCallBatch([msg], false) │ │ Save results: │ for tool_result in batch.messages │ push!(agent._state.messages, tool_result) │ end │ │ batch.terminate == false → loop back to Step 1 │ LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE ── _process_message (second iteration) ─────────────────────────── │ │ Step 1: Drain inputChannel → empty │ │ Step 2-3: prepareContext → formatMsgForLLM │ → messages now include: │ [system] "You are a helpful assistant." │ [user] "What's the weather in Tokyo?" │ [tool] tool_call_id="call_1", content="Weather in Tokyo: Sunny, 22°C" │ │ Step 4: llmCall │ → assistantMessage(content = [Dict(:type => "text", :text => "The weather in Tokyo is sunny, 22°C.")]) │ │ Step 5: Extract tool calls → none │ │ Step 6: has_tool_calls == false → break, return final_response │ └─> return final_response AGENT LOOP: SEND RESPONSE TO USER └─> put!(agent.outputChannel, final_response) └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") ``` --- ## 17. Tool File Contract Each `.jl` file in `src/tools/` must conform to the following contract: ### Required Function ```julia function getTool()::agentTool # Must return an agentTool instance end ``` ### Optional Functions ```julia # Argument preparation (before validation) function prepareArguments(args::Dict{String,Any})::Dict{String,Any} # Return modified args, or args unchanged return args end # Custom validation (before execution) function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} # Return nothing to pass, or error string to fail return nothing end # Core execution function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult # Return agentToolResult with content, details, usage, terminate return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) end ``` ### File Structure ```julia # src/tools/myTool.jl using Dates # ← tool declares its own dependencies (registry injects only `using ..type`) # Optional: helper functions function helper_function(...) ... end # Optional: prepareArguments function prepareArguments(args::Dict{String,Any})::Dict{String,Any} return args end # Optional: validateRequiredArgs function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end # Required: executeTool function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult ... end # Required: getTool function getTool()::agentTool return agentTool( name = "myTool", label = "My Tool", description = "What this tool does", inputSchema = Dict{String,Any}( "type" => "object", "properties" => Dict(...), "required" => [...] ), execute = executeTool, prepareArguments = prepareArguments, validateRequiredArgs = validateRequiredArgs, parallelToolExecute = false ) end ``` ### Dependencies Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements. ```julia # src/tools/getTime.jl using Dates function executeTool(...) now() # Dates.now requires `using Dates` end ``` ```julia # src/tools/myApiTool.jl using HTTP, JSON function executeTool(...) response = HTTP.get("https://api.example.com") data = JSON.parse(String(response.body)) ... end ``` ### Module Isolation When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available: ```julia # User writes in src/tools/myTool.jl: using Dates, HTTP, JSON # ← tool's own dependencies function getTool()::agentTool ... end # loadTools() creates: module _tool_myTool using ..type # ← injected by registry (core types only) using Dates, HTTP, JSON # ← from tool file # (user's code here) end ``` All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures. --- ## 18. Appendix: Type Reference ### Message Types | Type | Source | Description | |------|--------|-------------| | `messageContent` | `type.jl:67` | Abstract base for message content | | `textContent` | `type.jl:69` | Plain text content (`text::String`) | | `imageContent` | `type.jl:73` | Image content (`data::String`, `mimeType::String`) | | `agentMessage` | `type.jl:82` | Abstract base for all messages | | `userMessage` | `type.jl:84` | User message (`role`, `content`, `timestamp`) | | `assistantMessage` | `type.jl:111` | LLM response (`role`, `content`, `api`, `provider`, `model`, `usage`, `stopReason`, `errorMessage`, `timestamp`) | | `toolResultMessage` | `type.jl:152` | Tool result (`role`, `toolCallId`, `toolName`, `content`, `details`, `usage`, `addedToolNames`, `isError`, `timestamp`) | ### Tool Types | Type | Source | Description | |------|--------|-------------| | `agentTool` | `type.jl:261` | Tool definition (name, label, description, schema, execute, hooks) | | `agentToolCall` | `type.jl:362` | Tool call from LLM (type, id, name, arguments) | | `agentToolResult` | `type.jl:429` | Tool execution result (content, details, usage, terminate) | | `agentToolCallBatch` | `type.jl:835` | Batch of tool results (messages, terminate) | ### Lifecycle Outcome Types | Type | Source | Description | |------|--------|-------------| | `preparedToolCall` | `type.jl:681` | After prepare: (tool, toolCall, args) | | `immediateOutcome` | `type.jl:719` | Failed before execution: (result, isError) | | `executedOutcome` | `type.jl:753` | After execute, before finalize: (result, isError) | | `finalizedOutcome` | `type.jl:787` | After all phases: (toolCall, result, isError) | ### Context & Config Types | Type | Source | Description | |------|--------|-------------| | `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | ### Event Types | Type | Source | Description | |------|--------|-------------| | `toolExecStartEvent` | `type.jl:480` | (toolCallId, toolName, arguments) | | `toolExecUpdateEvent` | `type.jl:495` | (toolCallId, toolName, arguments, partialResult) | | `toolExecEndEvent` | `type.jl:511` | (toolCallId, toolName, result, isError) | ### Agent Types | Type | Source | Description | |------|--------|-------------| | `agent` | `type.jl:522` | Abstract base type | | `yiemAgent` | `type.jl:527` | High-level agent wrapper (state, channels, callbacks, task) |