Files
YiemAgent/README_tools.md
T
2026-08-12 20:10:51 +07:00

1626 lines
59 KiB
Markdown

# 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"=>...))
→ _agentLoop 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 `_agentLoop()` 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 _agentLoop(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/<name>.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
└─> _agentLoop: 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) |