diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 765807e..5659d95 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -13,7 +13,7 @@ module YiemAgent include("utils.jl") using .utils - include("tools/registry.jl") + include("toolRegistry.jl") using .toolRegistry # include("llmfunction.jl") diff --git a/src/tools/registry.jl b/src/toolRegistry.jl similarity index 53% rename from src/tools/registry.jl rename to src/toolRegistry.jl index 3954e8b..8cf9a45 100644 --- a/src/tools/registry.jl +++ b/src/toolRegistry.jl @@ -1,27 +1,55 @@ module toolRegistry -export loadTools, registerTool, getTools, clearTools +export ToolStore, loadTools, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures using ..type -# Global registry — populated at runtime by loadTools() or registerTool() -const _registry = Vector{agentTool}() +""" +Per-agent isolated tool storage. -# Module references — kept alive to prevent GC of tool code that closures depend on -const _tool_modules = Vector{Module}() +Each agent gets its own `ToolStore` so tool registration is independent — +`registerTool(store, tool)` only affects that agent's tool set. -# Auto-register the built-in listTools tool -function __init__() - registerTool(_listTool()) +# Fields +- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration +- `name::String` — identifier for debugging/logs +""" +struct ToolStore + tools::OrderedDict{String, agentTool} + name::String +end + +""" +Create a new isolated tool store. + +# Keyword Arguments +- `name::String`: Identifier for this store (default: "default") + +# Examples +```julia +store = ToolStore(name="agent1") +tools = loadTools(store, "src/tools") +registerTool(store, my_tool) +agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) +``` +""" +function ToolStore(; name::String="default")::ToolStore + ToolStore(OrderedDict{String, agentTool}(), name) end """ List tool definition — lets the agent query available tools for collision detection when creating new tools via writeTool. + +# Arguments +- `store::ToolStore`: The tool store to list from + +Each `ToolStore` gets its own `listTool` instance bound to that store, +so each agent sees only its own tools. """ -function _listTool()::agentTool +function listTool(store::ToolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -32,11 +60,11 @@ function _listTool()::agentTool "required" => Any[] ), execute = (toolCallId, args, signal, onPartialResult) -> begin - tools = getTools() + tools = getTools(store) if isempty(tools) result_text = "No tools registered." else - lines = String["- $(t.name): $(t.label) — $(t.description)" for t in tools] + lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools] result_text = "Available tools:\n" * join(lines, "\n") end return agentToolResult( @@ -52,52 +80,41 @@ function _listTool()::agentTool end """ -Load all tool modules from a directory. +Load all tool modules from a directory into a specific ToolStore. Scans `dir` for `.jl` files. Each file must define a function named -`getTool()::agentTool`. Files are sorted alphabetically so tool +`getTool()::agentTool`. Files are sorted alphabetically so tool registration order is deterministic. Each `.jl` file is loaded into its own **submodule** so that all functions defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, and any helper functions) are namespaced and never collide with other tools. -# Tool file format -Each `.jl` file defines one function `getTool()` that returns an `agentTool`. -Inside the file you can freely define as many helper functions as you need — -they will all be scoped under the tool's submodule. - -```julia -# src/tools/getWeather.jl - -# These are namespaced — no collision with getTime.validateRequiredArgs, etc. -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - ... -end - -function getTool()::agentTool - return agentTool( - name = "getWeather", - ... - ) -end -``` - # Arguments +- `store::ToolStore`: The tool store to register tools into - `dir::String`: Directory path to scan for `.jl` tool files # Returns -- `Vector{agentTool}`: All loaded tools +- `OrderedDict{String, agentTool}`: All loaded tools keyed by name # Errors - Throws `ArgumentError` if a tool file does not define a `getTool` function + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +OrderedDict{String, agentTool} with 3 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "listTools" => agentTool(...) +``` """ -function loadTools(dir::String)::OrderedDict{String, agentTool} +function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end - tools = OrderedDict{String, agentTool}() jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) sort!(jl_files) @@ -113,13 +130,13 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} # and constructing the module AST by hand is fragile. # Instead, we generate the full module source as a string, # parse it, and eval the resulting expression. - # Also import Dates, UUIDs, DataStructures, JSON — common dependencies - # that tool files use (and that the ..type module transitively uses). + # Each tool file declares its own dependencies via `using` statements + # at the top of the file — the registry only injects `using ..type` + # to make core types (agentTool, textContent, etc.) available. file_content = read(filepath, String) module_code = """ module $(mod_name) using ..type - using Dates, UUIDs, DataStructures, JSON $(file_content) end """ @@ -138,13 +155,8 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" )) end - # Keep module reference alive — closures in the agentTool (execute, - # validateRequiredArgs, prepareArguments) may reference module-scoped - # functions. Without this, GC could collect the module. - push!(_tool_modules, mod) - push!(_registry, tool) - tools[tool.name] = tool - println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)") + store.tools[tool.name] = tool + println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") catch e if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) throw(ArgumentError( @@ -156,40 +168,75 @@ function loadTools(dir::String)::OrderedDict{String, agentTool} end end - return tools + return store.tools end """ -Register a single agentTool into the global registry. +Register a single agentTool into a specific ToolStore. # Arguments +- `store::ToolStore`: The tool store to register into - `tool::agentTool`: The tool to register # Returns -- `Vector{agentTool}`: Updated registry +- `OrderedDict{String, agentTool}`: Updated tool dict for this store + +# Examples +```julia +julia> store = ToolStore(name="agent1") +julia> registerTool(store, my_tool) +[toolRegistry:agent1] Registered tool: my_tool +``` """ -function registerTool(tool::agentTool)::Vector{agentTool} - push!(_registry, tool) - println("[toolRegistry] Registered tool: $(tool.name)") - return _registry +function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool} + store.tools[tool.name] = tool + println("[$(store.name)] Registered tool: $(tool.name)") + return store.tools end """ -Get all registered tools. +Get the registered tools from a specific ToolStore. + +Returns the internal `OrderedDict` directly — O(1) lookup by name, +ordered iteration preserving registration order. + +# Arguments +- `store::ToolStore`: The tool store to query # Returns -- `Vector{agentTool}`: Copy of the registry +- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order + +# Examples +```julia +julia> getTools(store) +OrderedDict{String, agentTool} with 3 entries: + "listTools" => agentTool(...) + "getWeather" => agentTool(...) + "getTime" => agentTool(...) +``` """ -function getTools()::Vector{agentTool} - return deepcopy(_registry) +function getTools(store::ToolStore)::OrderedDict{String, agentTool} + return store.tools end """ -Clear all registered tools from the global registry. +Clear all registered tools from a specific ToolStore. + +# Arguments +- `store::ToolStore`: The tool store to clear + +# Returns +- `nothing` + +# Examples +```julia +julia> clearTools(store) +[toolRegistry:agent1] Registry cleared +``` """ -function clearTools()::Nothing - empty!(_registry) - println("[toolRegistry] Registry cleared") +function clearTools(store::ToolStore)::Nothing + empty!(store.tools) + println("[$(store.name)] Registry cleared") return nothing end diff --git a/src/tools/README.md b/src/tools/README.md index 9e0c73c..de6986f 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -1,211 +1,581 @@ -# Tools +# Tools — Complete End-to-End Lifecycle -Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does). +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. -## Tool Anatomy +--- -Each tool has 3 main parts: +## Table of Contents -### 1. Schema (`inputSchema`) +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) -JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields: +--- + +## 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 -inputSchema = Dict{String,Any}( - "type" => "object", - "properties" => Dict( - "city" => Dict("type" => "string", "description" => "City name"), - "units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius") - ), - "required" => ["city"] +# 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`. + +```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 +``` + +**Result extraction:** +```julia +all_tools = getTools(store) # OrderedDict{String, agentTool} +# Keys: "getTime", "getWeather", "writeTool" +getTime_tool = all_tools["getTime"] + +# Manual registration (alternative to loadTools) +registerTool(store, my_tool) +clearTools(store) # Clear all tools from store +``` + +**Source:** `toolRegistry.jl:113-172` + +--- + +### 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 +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 ) ``` -### 2. Execution Function (`execute`) - -A function with the signature: +**Manual registration** (without `loadTools`): ```julia -execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult -``` +store = ToolStore(name="myAgent") +registerTool(store, getTime_tool) +registerTool(store, getWeather_tool) -- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call) -- **`args`** — validated arguments provided by the LLM -- **`signal`** — abort signal for cancellable operations -- **`onPartialResult`** — callback for streaming progress updates -- **Returns** — `agentToolResult` with content, details, usage, and termination flag - -```julia -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - # Optional: stream progress updates - onPartialResult(Dict("status" => "Fetching data...")) - - # Do work - result = "Weather in $(args["city"]): Sunny, 22°C" - - # Return result - return agentToolResult( - [textContent(result)], - Dict{Any,Any}(), # details - nothing, # usage - false # terminate (true to stop agent loop) - ) -end -``` - -### 3. Tool Definition (`getTool()`) - -Returns an `agentTool` struct: - -| Field | Type | Description | -|---|---|---| -| `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) | -| `inputSchema` | `Any` | JSON Schema (MCP format) | -| `execute` | `Function` | The execution function | -| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation | -| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation | -| `parallelToolExecute` | `Bool` | Run this tool in parallel with others | - -## Argument Validation - -Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`. - -### Default: JSON Schema Required Fields - -Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present: - -```julia -# src/tools/getWeather.jl — uses default validation -function getTool()::agentTool - return agentTool( - name = "getWeather", - # ... - validateRequiredArgs = nothing, # uses default - ) -end -``` - -### Custom Validation Hook - -Override `validateRequiredArgs` when you need: -- **Cross-field constraints** (e.g. "at least one of X or Y") -- **Format validation** (e.g. regex patterns, date parsing) -- **Domain rules** (e.g. value ranges, business logic) - -The hook signature takes only `args`: - -```julia -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} - tz = get(args, "timezone", nothing) - city = get(args, "city", "") - - if !haskey(args, "timezone") && isempty(city) - return "Missing required argument: provide at least one of 'timezone' or 'city'" - end - - if tz !== nothing - tz_str = string(tz) - if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str) - return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'" - end - end - - return nothing -end -``` - -Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments. - -## Tool Discovery and Lifecycle - -The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective: - -### The Agent Loop - -```julia -# agentCore.jl:175 - _process_message() -while true - # 1. Drain messages from inputChannel - while isready(agent.inputChannel) - raw_msg = take!(agent.inputChannel) - user_msg = OpenAiToUserMessage(raw_msg) - push!(agent._state.messages, user_msg) - end - - # 2. Format messages for LLM - ctx = agent.prepareContext(agent._state) - formatted = agent.formatMsgForLLM(ctx) - - # 3. Call LLM - response = agent.llmCall(formatted) - - # 4. Check if LLM used tool calls - if has_tool_calls(response.content) - # 5. Execute tools, feed results back to LLM, loop - else - # 6. No tool calls — return final response - break - end -end -``` - -### Step 1: Tool Discovery - -Tools are discovered from `agent._state.tools`, which is a `Vector{agentTool}` populated during agent creation: - -```julia -# Loading tools -tools = loadTools("src/tools") # returns Vector{agentTool} - -# Passing to agent agent = yiemAgent( - systemPrompt = "...", - tools = tools, # ← tools stored in agent._state.tools + tools = getTools(store), llmCall = my_llm_call, agentEventSink = my_event_sink, + _tool_store = store, ) ``` -When the LLM response contains tool calls, the agent builds an `agentContext` with those tools: +**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 → run_agent(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 -context = agentContext( - agent._state.systemPrompt, - agent._state.messages, - agent._state.tools, # ← tools available for discovery +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:113-172` + +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` and returns an `OrderedDict{String, agentTool}` + +### 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 +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 run_agent() + - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() + - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() + - _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")), + ] ) ``` -### Step 2: Extract Tool Calls from LLM Response - -The agent inspects the `response.content` blocks for `tool_calls`: +### Format 2: Single `tool_call` block + +```julia +Dict( + :type => "tool_call", + :id => "call_1", + :name => "getWeather", + :arguments => Dict("city" => "Tokyo") +) +``` + +### Extraction Logic ```julia -# agentCore.jl:217-245 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" - # OpenAI format: {"type": "tool_calls", "tool_calls": [...]} for tc_data in get(content_block, :tool_calls, []) tc = agentToolCall( type = "function", - id = get(tc_data, :id, string(uuid4())), + 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" - # Alternative format: single tool_call block + tc_data = content_block tc = agentToolCall( type = "function", - id = get(content_block, :id, string(uuid4())), - name = get(content_block, :name, ""), - arguments = get(content_block, :arguments, Dict()), + 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 @@ -213,313 +583,1040 @@ for content_block in response.content end ``` -### Step 3: Execute Each Tool Call - -For each tool call, the agent runs through the **prepare → execute → finalize** pipeline: +The `agentToolCall` struct is defined at `type.jl:362-367`: ```julia -# agentCore.jl:247-302 -if has_tool_calls && length(tool_call_list) > 0 - context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) - config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode) - signal = nothing - emit = agent.agentEventSink - - # Execute all 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 - - # If any tool requested termination, break the loop - if batch.terminate - final_response = build_final_response(batch) - break - end - # Otherwise, loop back to step 2 (format + call LLM again) +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 ``` -### Step 4: The Per-Call Pipeline +--- -Each tool call goes through three phases: +## 8. The Per-Call Pipeline — Prepare, Execute, Finalize -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PREPARE → prepareToolCall() │ -│ │ -│ 1. Find tool by name in context.tools │ -│ 2. Transform args via tool.prepareArguments (if defined) │ -│ 3. Validate via tool.validateRequiredArgs (or default) │ -│ 4. Run beforeToolCall hook (if defined) │ -│ └── on any failure → return immediateOutcome (skip execution) │ -│ └── success → return preparedToolCall │ -├─────────────────────────────────────────────────────────────────┤ -│ EXECUTE → executePreparedToolCall() │ -│ │ -│ 1. emit toolExecutionStart event │ -│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│ -│ 3. wait for all pending update events │ -│ └── on error → return executedOutcome(isError=true) │ -│ └── success → return executedOutcome(isError=false) │ -├─────────────────────────────────────────────────────────────────┤ -│ FINALIZE → finalizeExecutedToolCall() │ -│ │ -│ 1. Run afterToolCall hook (if defined) │ -│ - can mutate content, details, usage, terminate, isError │ -│ 2. emit toolExecutionEnd event │ -│ 3. createToolResultMessage → adds to conversation history │ -│ └── return finalizedOutcome │ -└─────────────────────────────────────────────────────────────────┘ -``` +This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases. -### Step 5: Feed Results Back to LLM +### 8.1 Phase 1: Prepare — `prepareToolCall()` -Tool results are added to `agent._state.messages` as `toolResultMessage` objects. On the next loop iteration, `formatMsgForLLM()` converts them to OpenAI format and the LLM receives the results: - -``` -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" -``` - -The LLM then decides: call another tool, or return a final text answer. - -## Execution Modes - -### Sequential - -Tools execute one at a time in order. Required when: -- Tools have implicit dependencies -- Tools share state (e.g. writing to the same file) -- Tools have `parallelToolExecute = false` - -Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`. - -### Parallel - -Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`. - -## Streaming Partial Results - -For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress: +**Source:** `agentCore.jl:511-547` ```julia -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult - onPartialResult(Dict("status" => "Step 1: Fetching data...")) - sleep(1) - - onPartialResult(Dict("status" => "Step 2: Processing...")) - sleep(1) - - return agentToolResult( - [textContent("Done!")], - Dict{Any,Any}(), nothing, false +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 `assistantMsgCtx(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 `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Hook can mutate the result: + ```julia + after = config.afterToolCall(afterCtx(...)) + 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 ``` -UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`. +### `toolResultMessage` Struct -## Loading Tools - -### Auto-load from Directory +**Source:** `type.jl:152-191` ```julia -using .toolRegistry - -tools = loadTools("src/tools") # scans for *.jl files with getTool() +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 ``` -Files are loaded alphabetically for deterministic registration order. - -### Manual Registration - -```julia -tool = getTool() # from your tool module -registerTool(tool) -``` - -## Complete Lifecycle Example - -```julia -# ─── USER SENDS MESSAGE ─────────────────────────────────────────── -run_agent(agent, "What's the weather in Tokyo?") - -# ─── LOOP ITERATION 1 ───────────────────────────────────────────── -# Agent formats messages and calls LLM -formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) -response = agent.llmCall(formatted) -# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]} - -# Agent extracts tool call, builds context -context = agentContext(systemPrompt, messages, agent._state.tools) -tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))] - -# PREPARE: find tool, validate args -tool = find(t -> t.name == "getWeather", context.tools) # found! -validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes -beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped - -# EXECUTE: call tool.execute() -result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult) -# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false) - -# FINALIZE: afterToolCall hook, emit events -finalized = finalizedOutcome(tc, result, false) -emit(toolExecEndEvent("call_1", "getWeather", result, false)) -msg = createToolResultMessage(finalized) # toolResultMessage for conversation history - -# Add result to conversation -push!(agent._state.messages, msg) -# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"] - -# ─── LOOP ITERATION 2 ───────────────────────────────────────────── -# LLM called again with tool result included -formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state)) -response = agent.llmCall(formatted) -# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]} - -# No tool calls detected → break loop, return final response -return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...) - -# ─── USER RECEIVES RESPONSE ─────────────────────────────────────── -response = take_response(agent) -println(response.content) -# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" -``` - -## Self-Modifying Tools - -The framework includes tools that allow the agent to create new tools at runtime. - -### `writeTool` — Create New Tool Files - -`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (the actual Julia code), and `writeTool` wraps it in the required boilerplate. - -**How it works:** - -The LLM constructs `writeTool` with: -- **`executeCode`** — the actual tool logic (Julia code body, NOT wrapped in a function) -- **`name`, `label`, `description`** — tool metadata -- **`inputSchema`** — parameter schema in MCP format -- **`validateCode`, `prepareCode`** (optional) — custom validation/preparation logic - -`writeTool` produces `src/tools/.jl` by: -1. Converting the `inputSchema` Dict into a Julia `Dict{String,Any}(...)` string literal -2. Indenting `executeCode` with 4 spaces -3. Wrapping it inside a `function executeTool(...)::agentToolResult ... end` template -4. Appending the `getTool()` definition that returns an `agentTool` struct -5. Writing the combined string to disk - -**Workflow:** +### Conversation History After Tool Execution ``` -LLM decides: "Need a searchWine tool. I'll provide the logic." - -LLM calls writeTool: - name: "searchWine" - executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..." - -writeTool wraps it → src/tools/searchWine.jl: - function executeTool(...)::agentToolResult - query = args["query"] ← LLM code (indented 4 spaces) - result = search(query) - return agentToolResult(...) - end - - function getTool()::agentTool - return agentTool(name="searchWine", ...) - end - -Restart → loadTools("src/tools") loads searchWine.jl +[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" ``` -**Example specification:** +On the next loop iteration, `formatMsgForLLM()` converts `toolResultMessage` to OpenAI format: ```julia Dict( - "name" => "searchWine", - "label" => "Wine Search", - "description" => "Search a wine database by name, region, or variety", - "inputSchema" => Dict( - "type" => "object", - "properties" => Dict( - "query" => Dict("type" => "string", "description" => "Search query"), - "maxResults" => Dict("type" => "integer", "default" => 10) - ), - "required" => ["query"] - ), - "executeCode" => """ - query = args["query"] - max_results = get(args, "maxResults", 10) - # Perform search logic here - result = "Found 3 wines matching: $query" - return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false) - """, - "parallel" => false + "role" => "tool", + "tool_call_id" => "call_1", + "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")] ) ``` -**Optional hooks:** +--- -| Field | Description | -|---|---| -| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. | -| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. | +## 12. Error Handling & Recovery Pattern -### `listTools` — Discover Available Tools +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. -Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool` — the LLM checks existing names before picking a unique one. +### 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 -# Result from listTools: -# Available tools: -# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city. -# - getTime: Time Lookup — Get current local time for a timezone or city. -# - writeTool: Create Tool — Generate new tool files... -# - listTools: List Tools — List all available tools with their names and labels... +function createErrorToolResult(msg::String)::agentToolResult + return agentToolResult([textContent("text", msg)], Dict{Any,Any}()) +end ``` -### Complete Self-Tooling Example +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: ``` -User: "I need to search for wines. Do you have a tool for that?" - -# ─── LOOP: Agent realizes no wine search tool exists ───────────────── - -# LLM generates the tool logic and calls writeTool to write it to disk - -[Tool Call] writeTool(name="searchWine", label="Wine Search", - description="Search a wine database by name, region, or variety", - inputSchema={...}, - executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)") - -# writeTool generates src/tools/searchWine.jl - -# ─── SYSTEM RESTARTS ───────────────────────────────────────────────── - -# loadTools("src/tools") loads searchWine.jl alongside all other tools - -# ─── Agent calls the new tool ───────────────────────────────────────── - -[Tool Call] searchWine(query="cabernet", maxResults=5) -# Result: "Found 5 cabernet wines..." - -# ─── Final response ────────────────────────────────────────────────── - -"The search found 5 cabernet wines: ..." +toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent ``` -## Available Tools +### Event Types -| Tool | Description | Validation | -|---|---|---| -| `getWeather` | Fetch weather for a city | Default (JSON Schema required) | -| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) | -| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) | -| `listTools` | List all available tools with descriptions | None (no arguments) | +```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::assistantMsgCtx, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | +| `afterToolCall` | `(afterCtx::afterCtx, 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( + assistantMsgCtx(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( + afterCtx(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:54-82` + +Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily 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 + └─> run_agent(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) + └─> take_response(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`) | +| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | +| `afterCtx` | `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) | diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index b58a752..94b7752 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -1,3 +1,5 @@ +using Dates + """ Validate required arguments for the getTime tool. diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 40c4d8c..c682e1d 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,3 +1,5 @@ +using JSON + """ Tool that writes new Julia tool module files to disk. @@ -5,7 +7,7 @@ The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so `loadTools("src/tools")` picks +After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks up the new file. The new tool is immediately available. # Example @@ -248,13 +250,13 @@ function getTool()::agentTool tool_code = join(parts) - # Write the file — tool is loaded on next agent restart via loadTools() + # Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools") write(filepath, tool_code) onPartialResult(Dict("status" => "Done")) return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")], + [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")], Dict{Any,Any}( "file" => filepath, "name" => tool_name, diff --git a/src/type.jl b/src/type.jl index ee373cf..37b614f 100644 --- a/src/type.jl +++ b/src/type.jl @@ -567,9 +567,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context sessionId::Union{String, Nothing} # Optional session identifier maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function -end + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the ToolStore for runtime registration + end """ Create a new yiemAgent instance with a background loop task. @@ -593,15 +594,17 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) - `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) - `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`) # Returns - A new `yiemAgent` instance with an active background task # Examples ```julia -julia> tools = loadTools("src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=...) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...) +julia> store = ToolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) """ function yiemAgent( ; systemPrompt::String="You are helpful assistant.", @@ -619,6 +622,7 @@ function yiemAgent( maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -643,6 +647,7 @@ function yiemAgent( maxRetryDelayMs, parallelToolExecute, agentEventSink, + tool_store, ) # Spawn the background loop and attach it diff --git a/test/loadToolTest.jl b/test/toolTest.jl similarity index 68% rename from test/loadToolTest.jl rename to test/toolTest.jl index ad6613a..8057cf1 100644 --- a/test/loadToolTest.jl +++ b/test/toolTest.jl @@ -6,12 +6,13 @@ using YiemAgent.type # Path to the real tools directory TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") -@testset "loadTools" begin +@testset "loadTools with ToolStore" begin # ------------------------------------------------------------------ # # 1. loadTools throws on non-existent directory # # ------------------------------------------------------------------ # - @test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist") + store = ToolStore(name="test1") + @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") # ------------------------------------------------------------------ # # 2. loadTools throws if a .jl file does not define getTool() # @@ -20,12 +21,13 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # bad_dir = mktempdir() write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") - @test_throws ArgumentError loadTools(bad_dir) + @test_throws ArgumentError loadTools(store, bad_dir) # ------------------------------------------------------------------ # # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - loaded = loadTools(TOOLS_DIR) + store2 = ToolStore(name="test2") + loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) @test length(loaded) == 3 @@ -98,15 +100,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test occursin("72°F", result_w2.content[1].text) # ------------------------------------------------------------------ # - # 7. getTools / registerTool / clearTools # + # 7. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # - registry_tools = getTools() - @test !isempty(registry_tools) - @test any(t -> t.name == "getTime", registry_tools) - @test any(t -> t.name == "getWeather", registry_tools) + store3 = ToolStore(name="test3") + registry_tools = getTools(store3) + @test isempty(registry_tools) - clearTools() - @test isempty(getTools()) + # listTool is not auto-registered anymore — each store starts empty + # Register tools manually + registerTool(store3, loaded["getTime"]) + registerTool(store3, loaded["getWeather"]) + registerTool(store3, loaded["writeTool"]) + + reg = getTools(store3) + @test !isempty(reg) + @test "getTime" in keys(reg) + @test "getWeather" in keys(reg) + @test "writeTool" in keys(reg) + @test collect(keys(reg))[1] == "getTime" + @test collect(keys(reg))[2] == "getWeather" + @test collect(keys(reg))[3] == "writeTool" + + clearTools(store3) + @test isempty(getTools(store3)) test_tool = agentTool( name = "manualTool", @@ -119,18 +135,39 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") validateRequiredArgs = nothing, parallelToolExecute = true ) - registerTool(test_tool) - reg = getTools() - @test any(t -> t.name == "manualTool", reg) - @test count(t -> t.name == "manualTool", reg) == 1 - @test reg[1].parallelToolExecute == true + registerTool(store3, test_tool) + reg = getTools(store3) + @test haskey(reg, "manualTool") + @test length(reg) == 1 + @test reg["manualTool"].parallelToolExecute == true # ------------------------------------------------------------------ # - # 8. getTools returns deep copy (mutations don't affect registry) # + # 8. getTools returns direct reference (mutations affect registry) # # ------------------------------------------------------------------ # - copy1 = getTools() - copy2 = getTools() - @test copy1 !== copy2 + copy1 = getTools(store3) + copy2 = getTools(store3) + @test copy1 === copy2 # same reference, not a deep copy empty!(copy1) - @test !isempty(getTools()) + @test isempty(getTools(store3)) # mutation propagates + + # ------------------------------------------------------------------ # + # 9. Per-store isolation — two stores don't share tools # + # ------------------------------------------------------------------ # + storeA = ToolStore(name="isolationA") + storeB = ToolStore(name="isolationB") + + registerTool(storeA, loaded["getTime"]) + registerTool(storeB, loaded["getWeather"]) + + regA = getTools(storeA) + regB = getTools(storeB) + + @test "getTime" in keys(regA) + @test "getWeather" ∉ keys(regA) + @test "getWeather" in keys(regB) + @test "getTime" ∉ keys(regB) + + clearTools(storeA) + @test isempty(getTools(storeA)) + @test !isempty(getTools(storeB)) # storeB unaffected end