1626 lines
59 KiB
Markdown
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) |
|