This commit is contained in:
2026-08-08 09:38:05 +07:00
parent 04e75e61b8
commit 0ddbcf9ca1
5 changed files with 548 additions and 27 deletions
+383
View File
@@ -0,0 +1,383 @@
# Tools
Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does).
## Quick Start
Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`:
```julia
# src/tools/my_tool.jl
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
city = args["city"]
return agentToolResult(
[textContent("Hello from $(city)!")],
Dict{Any,Any}(), nothing, false
)
end
function getTool()::agentTool
return agentTool(
name = "my_tool",
label = "My Tool",
description = "Says hello to a city.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City name")
),
"required" => ["city"]
),
execute = executeTool,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
```
When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent.
## Tool Anatomy
Each tool has 3 main parts:
### 1. Schema (`inputSchema`)
JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields:
```julia
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City name"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
),
"required" => ["city"]
)
```
### 2. Execution Function (`execute`)
A function with the signature:
```julia
execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
```
- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call)
- **`args`** — validated arguments provided by the LLM
- **`signal`** — abort signal for cancellable operations
- **`onPartialResult`** — callback for streaming progress updates
- **Returns** — `agentToolResult` with content, details, usage, and termination flag
```julia
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
# Optional: stream progress updates
onPartialResult(Dict("status" => "Fetching data..."))
# Do work
result = "Weather in $(args["city"]): Sunny, 22°C"
# Return result
return agentToolResult(
[textContent(result)],
Dict{Any,Any}(), # details
nothing, # usage
false # terminate (true to stop agent loop)
)
end
```
### 3. Tool Definition (`getTool()`)
Returns an `agentTool` struct:
| Field | Type | Description |
|---|---|---|
| `name` | `String` | Unique identifier (e.g. `"getWeather"`) |
| `label` | `String` | Human-readable name (e.g. `"Weather Lookup"`) |
| `description` | `String` | What the tool does (shown to the LLM) |
| `inputSchema` | `Any` | JSON Schema (MCP format) |
| `execute` | `Function` | The execution function |
| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation |
| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation |
| `parallelToolExecute` | `Bool` | Run this tool in parallel with others |
## Argument Validation
Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`.
### Default: JSON Schema Required Fields
Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present:
```julia
# src/tools/getWeather.jl — uses default validation
function getTool()::agentTool
return agentTool(
name = "getWeather",
# ...
validateRequiredArgs = nothing, # uses default
)
end
```
### Custom Validation Hook
Override `validateRequiredArgs` when you need:
- **Cross-field constraints** (e.g. "at least one of X or Y")
- **Format validation** (e.g. regex patterns, date parsing)
- **Domain rules** (e.g. value ranges, business logic)
The hook signature takes only `args`:
```julia
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if !haskey(args, "timezone") && isempty(city)
return "Missing required argument: provide at least one of 'timezone' or 'city'"
end
if tz !== nothing
tz_str = string(tz)
if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str)
return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'"
end
end
return nothing
end
```
Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments.
## Tool Call Lifecycle
```
LLM requests tool call
└── prepareToolCall (agentCore.jl:511)
├── Tool lookup by name
├── prepareArguments (tool-specific transform, if defined)
├── validateToolArguments (validateRequiredArgs hook or default)
│ └── on failure → immediateOutcome (no execution)
├── beforeToolCall hook (if defined)
│ └── on block → immediateOutcome (no execution)
└── returns preparedToolCall
executed by executeToolCallsSequential or executeToolCallsParallel
└── executePreparedToolCall (agentCore.jl:589)
├── emit toolExecutionStart
├── call tool.execute()
│ └── on error → executedOutcome(isError=true)
└── returns executedOutcome
finalizeExecutedToolCall (agentCore.jl:675)
├── afterToolCall hook (if defined)
│ └── can mutate result content, usage, terminate, isError
└── returns finalizedOutcome
emit toolExecutionEnd
└── createToolResultMessage → added to conversation history
```
## Execution Modes
### Sequential
Tools execute one at a time in order. Required when:
- Tools have implicit dependencies
- Tools share state (e.g. writing to the same file)
- Tools have `parallelToolExecute = false`
Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`.
### Parallel
Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`.
## Streaming Partial Results
For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress:
```julia
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
onPartialResult(Dict("status" => "Step 1: Fetching data..."))
sleep(1)
onPartialResult(Dict("status" => "Step 2: Processing..."))
sleep(1)
return agentToolResult(
[textContent("Done!")],
Dict{Any,Any}(), nothing, false
)
end
```
UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`.
## Loading Tools
### Auto-load from Directory
```julia
using .toolRegistry
tools = loadTools("src/tools") # scans for *.jl files with getTool()
```
Files are loaded alphabetically for deterministic registration order.
### Manual Registration
```julia
tool = getTool() # from your tool module
registerTool(tool)
```
## Using Tools with an Agent
Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow:
```julia
using .YiemAgent
using .toolRegistry
# 1. Load tools from the tools directory
tools = loadTools("src/tools")
# [toolRegistry] Loading tool from: src/tools/getTime.jl
# [toolRegistry] Loaded tool: getTime — Time Lookup
# [toolRegistry] Loading tool from: src/tools/getWeather.jl
# [toolRegistry] Loaded tool: getWeather — Weather Lookup
# 2. Define your LLM call function
function my_llm_call(messages::Dict)::assistantMessage
# Call your LLM API here (OpenAI, Anthropic, local model, etc.)
# Return an assistantMessage with the response content
# If the LLM wants to call a tool, include tool_call content blocks
...
end
# 3. Define your event sink (optional, for logging/debugging)
function my_event_sink(event)
if event isa toolExecStartEvent
println("[EVENT] Tool start: $(event.toolName)")
elseif event isa toolExecEndEvent
status = event.isError ? "ERROR" : "OK"
println("[EVENT] Tool end: $(event.toolName)$status")
end
end
# 4. Create the agent with tools
agent = yiemAgent(
systemPrompt = "You are a helpful assistant that can check weather and time.",
model = my_model,
tools = tools, # pass loaded tools
llmCall = my_llm_call, # your LLM function
agentEventSink = my_event_sink, # event handler
)
# 5. Send a message and get a response
run_agent(agent, "What's the weather in Tokyo?")
response = take_response(agent)
# response.content contains the LLM's reply (with tool results if applicable)
println(response.content)
# 6. When done, stop the agent
stop_agent(agent)
```
### How It Works
1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`.
2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`.
3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`).
4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent:
- Extracts each tool call (name, arguments)
- Runs validation (`validateRequiredArgs` or default)
- Executes the tool (or returns an error if validation fails)
- Feeds the result back as a `toolResultMessage` in the conversation
5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls.
6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`.
### Minimal Working Example
```julia
using .YiemAgent
using .toolRegistry
# Load tools
tools = loadTools("src/tools")
# Mock LLM that echoes back a tool call, then a text response
call_count = 0
function mock_llm_call(messages::Dict)::assistantMessage
global call_count += 1
if call_count == 1
# First call: LLM decides to use getWeather
return assistantMessage(
content=[
Dict("type" => "tool_calls",
"tool_calls" => [Dict("id" => "call_1", "name" => "getWeather",
"arguments" => Dict("city" => "Tokyo"))])
],
model = "mock",
usage = llmUsage(0, 0)
)
else
# Second call: LLM returns text (after tool result)
return assistantMessage(
content = [textContent("The weather in Tokyo is sunny, 22°C.")],
model = "mock",
usage = llmUsage(0, 0)
)
end
end
# Create agent
agent = yiemAgent(
systemPrompt = "You are a helpful assistant.",
tools = tools,
llmCall = mock_llm_call,
agentEventSink = e -> nothing, # no events
)
# Run
run_agent(agent, "What's the weather in Tokyo?")
response = take_response(agent)
stop_agent(agent)
```
## Available Tools
| Tool | Description | Validation |
|---|---|---|
| `getWeather` | Fetch weather for a city | Default (JSON Schema required) |
| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) |
## Example: Error Flow
When the LLM calls a tool with invalid arguments:
```
User: "What's the weather?"
└── LLM: call getWeather() with no arguments
└── prepareToolCall → validateRequiredArgs → "Missing required arguments: city"
└── immediateOutcome → error tool result
└── LLM sees: "Missing required arguments: city"
└── LLM retries: call getWeather(city="Tokyo")
└── executeTool → "Weather in Tokyo: Sunny, 22°C"
```
The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct.
+54 -14
View File
@@ -1,5 +1,43 @@
"""
Execute the get_time tool.
Validate required arguments for the getTime tool.
Demonstrates custom validation beyond simple required-field checking:
- Ensures at least one time source (timezone or city) is provided
- Validates timezone is in IANA format if specified
- Validates city name is not empty if specified
# Arguments
- `args::Dict{String,Any}`: Arguments from the LLM
# Returns
- `nothing` if validation passes
- `String` error message if validation fails
"""
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
hasTz = tz !== nothing && !isempty(tz)
hasCity = !isempty(city)
# At least one of timezone or city is required
if !hasTz && !hasCity
return "Missing required argument: provide at least one of 'timezone' or 'city'"
end
# Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity")
if hasTz
tz_str = string(tz)
if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str)
return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'"
end
end
return nothing
end
"""
Execute the getTime tool.
# Arguments
- `toolCallId::String`: Unique identifier for this tool call
@@ -8,42 +46,44 @@ Execute the get_time tool.
- `onPartialResult::Function`: Callback for streaming partial results
# Returns
- `agentToolResult`: Result content with current time
- `agentToolResult`: Result content with current time data
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
tz = get(args, "timezone", "local")
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
t = now()
if tz == "local"
timeStr = string(t)
# Simulate time lookup — replace with actual timezone API call
if tz !== nothing
result = "Current time in $(tz): $(now())"
else
timeStr = string(t)
result = "Current time in $(city): $(now())"
end
return agentToolResult(
[textContent("Current time: $(timeStr)")],
[textContent(result)],
Dict{Any,Any}(), nothing, false
)
end
"""
Define and return the get_time agentTool.
Define and return the getTime agentTool.
"""
function getTool()::agentTool
return agentTool(
name = "get_time",
label = "Get Current Time",
description = "Get the current date and time.",
name = "getTime",
label = "Time Lookup",
description = "Get current local time for a timezone or city.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"timezone" => Dict("type" => "string", "description" => "Timezone (currently only 'local' is supported)")
"timezone" => Dict("type" => "string", "description", "IANA timezone, e.g. 'America/New_York'"),
"city" => Dict("type" => "string", "description", "City name as fallback")
),
"required" => []
),
execute = executeTool,
prepareArguments = nothing,
validateRequiredArgs = validateRequiredArgs,
parallelToolExecute = false
)
end
+1 -8
View File
@@ -14,14 +14,6 @@ function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{N
city = get(args, "city", "")
units = get(args, "units", "celsius")
# Validate required arguments
if isempty(city)
return agentToolResult(
[textContent("Error: 'city' argument is required.")],
Dict{Any,Any}(), nothing, false
)
end
# Simulate weather fetch — replace with actual API call
# You can call onPartialResult() here for streaming progress updates:
# onPartialResult(Dict("status" => "Fetching weather data..."))
@@ -54,6 +46,7 @@ function getTool()::agentTool
),
execute = executeTool, # reference the function defined above
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
+9 -3
View File
@@ -8,8 +8,8 @@
textContent, imageContent,
# Message types
userMessage, assistantMessage, toolResultMessage,
# Tool types
agentTool,
# Tool types
agentTool, validateRequiredArgs
# Context types
agentContext, agentState, agentToolCall, prepareNextTurnContext,
# Loop & execution types
@@ -207,6 +207,8 @@ Maps MCP server tool definitions to an executable Julia tool.
- `execute::Function`: Tool execution function, signature:
`execute(toolCallId::String, args::Dict, signal::Union{Nothing,AbortSignal}, onPartialResult::Function)`
- `prepareArguments::Union{Function, Nothing}`: Optional argument preparation callback
- `validateRequiredArgs::Union{Function, Nothing}`: Optional validation hook, signature:
`validateRequiredArgs(args::Dict) -> Union{Nothing, String}` where `String` is an error message
- `parallelToolExecute::Bool`: Override: run tool calls sequentially or in parallel
# Returns
@@ -249,7 +251,10 @@ tool = agentTool(
[textContent("Sunny, 22C in $(city)")],
Dict{Any,Any}(), nothing, false
)
end
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
```
"""
@@ -260,6 +265,7 @@ struct agentTool # A tool available to the agent
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
execute::Function # Tool execution function
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
end
+101 -2
View File
@@ -1,6 +1,6 @@
module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, _userMessageToOpenAI,
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks
using UUIDs, Dates, DataStructures, HTTP, JSON
@@ -275,10 +275,109 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{
end
end
return blocks
return blocks
end
"""
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
Validates that all required fields listed in the tool's JSON Schema are present
in `args`. Returns `nothing` if validation passes, or a descriptive error string
listing the missing required fields.
This is the default `validateRequiredArgs` hook. Tool authors can override it
with a custom validation function that performs additional checks (e.g. type
coercion, format validation, cross-field constraints).
# Arguments
- `args::Dict{String,Any}`: The arguments provided by the LLM
- `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format)
# Returns
- `nothing` if all required args are present
- `String` error message listing missing fields otherwise
# Examples
```julia
schema = Dict("required" => ["city"])
args = Dict{String,Any}()
validateRequiredArgs(args, schema) # => "Missing required arguments: city"
args2 = Dict("city" => "Tokyo")
validateRequiredArgs(args2, schema) # => nothing
```
"""
function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String}
required = get(inputSchema, "required", Any[])
if isempty(required)
return nothing
end
missing = String[]
for field in required
if !(field in keys(args))
push!(missing, field)
end
end
if !isempty(missing)
return "Missing required arguments: $(join(missing, ", "))"
end
return nothing
end
"""
validateToolArguments(tool::agentTool, prepared::agentToolCall) -> Dict{String,Any}
Validates the prepared tool call arguments by calling the tool's
`validateRequiredArgs` hook (or the default implementation). If validation
fails, returns a modified `agentToolCall` with an empty arguments dict
so downstream code can detect the failure. If the hook exists on the tool
and returns an error string, that error is returned.
This runs **before** the `beforeToolCall` hook, allowing the agent to
reject invalid calls without invoking lifecycle callbacks or logging
false `toolExecutionStart` events.
# Arguments
- `tool::agentTool`: The resolved tool definition
- `prepared::agentToolCall`: The prepared tool call with potentially transformed arguments
# Returns
- `Dict{String,Any}`: The validated arguments if successful
# Errors
- Throws `ArgumentError` if validation fails — this is caught by `prepareToolCall`
and converted to an `immediateOutcome`
# Examples
```julia
# With validateRequiredArgs hook set on the tool
validateToolArguments(toolWithHook, tc) # => validated args or throws
# With default validation (nothing on tool)
validateToolArguments(toolDefault, tc) # => args or throws
```
"""
function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any}
# Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only)
if isnothing(tool.validateRequiredArgs)
result = validateRequiredArgs(prepared.arguments, tool.inputSchema)
else
result = tool.validateRequiredArgs(prepared.arguments)
end
if result !== nothing
throw(ArgumentError(result))
end
return prepared.arguments
end