20 KiB
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).
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:
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:
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 LLMsignal— abort signal for cancellable operationsonPartialResult— callback for streaming progress updates- Returns —
agentToolResultwith content, details, usage, and termination flag
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:
# 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:
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if !haskey(args, "timezone") && isempty(city)
return "Missing required argument: provide at least one of 'timezone' or 'city'"
end
if tz !== nothing
tz_str = string(tz)
if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str)
return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'"
end
end
return nothing
end
Return nothing to pass, or an error String to fail. The error is fed back to the LLM so it can retry with corrected arguments.
Tool Discovery and Lifecycle
The agent iterates through tools via a discover → execute → loop cycle. Here is the complete flow from the framework author's perspective:
The Agent Loop
# agentCore.jl:175 - _process_message()
while true
# 1. Drain messages from inputChannel
while isready(agent.inputChannel)
raw_msg = take!(agent.inputChannel)
user_msg = OpenAiToUserMessage(raw_msg)
push!(agent._state.messages, user_msg)
end
# 2. Format messages for LLM
ctx = agent.prepareContext(agent._state)
formatted = agent.formatMsgForLLM(ctx)
# 3. Call LLM
response = agent.llmCall(formatted)
# 4. Check if LLM used tool calls
if has_tool_calls(response.content)
# 5. Execute tools, feed results back to LLM, loop
else
# 6. No tool calls — return final response
break
end
end
Step 1: Tool Discovery
Tools are discovered from agent._state.tools, which is a Vector{agentTool} populated during agent creation:
# Loading tools
tools = loadTools("src/tools") # returns Vector{agentTool}
# Passing to agent
agent = yiemAgent(
systemPrompt = "...",
tools = tools, # ← tools stored in agent._state.tools
llmCall = my_llm_call,
agentEventSink = my_event_sink,
)
When the LLM response contains tool calls, the agent builds an agentContext with those tools:
context = agentContext(
agent._state.systemPrompt,
agent._state.messages,
agent._state.tools, # ← tools available for discovery
)
Step 2: Extract Tool Calls from LLM Response
The agent inspects the response.content blocks for tool_calls:
# agentCore.jl:217-245
tool_call_list = agentToolCall[]
for content_block in response.content
if content_block isa Dict
if get(content_block, :type, "") == "tool_calls"
# OpenAI format: {"type": "tool_calls", "tool_calls": [...]}
for tc_data in get(content_block, :tool_calls, [])
tc = agentToolCall(
type = "function",
id = get(tc_data, :id, string(uuid4())),
name = get(tc_data, :function, Dict())[:name],
arguments = get(tc_data, :function, Dict())[:arguments],
)
push!(tool_call_list, tc)
end
elseif get(content_block, :type, "") == "tool_call"
# Alternative format: single tool_call block
tc = agentToolCall(
type = "function",
id = get(content_block, :id, string(uuid4())),
name = get(content_block, :name, ""),
arguments = get(content_block, :arguments, Dict()),
)
push!(tool_call_list, tc)
end
end
end
Step 3: Execute Each Tool Call
For each tool call, the agent runs through the prepare → execute → finalize pipeline:
# agentCore.jl:247-302
if has_tool_calls && length(tool_call_list) > 0
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode)
signal = nothing
emit = agent.agentEventSink
# Execute all tool calls (sequential or parallel)
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
# Save results to conversation history
for tool_result in batch.messages
push!(agent._state.messages, tool_result)
end
# If any tool requested termination, break the loop
if batch.terminate
final_response = build_final_response(batch)
break
end
# Otherwise, loop back to step 2 (format + call LLM again)
end
Step 4: The Per-Call Pipeline
Each tool call goes through three phases:
┌─────────────────────────────────────────────────────────────────┐
│ PREPARE → prepareToolCall() │
│ │
│ 1. Find tool by name in context.tools │
│ 2. Transform args via tool.prepareArguments (if defined) │
│ 3. Validate via tool.validateRequiredArgs (or default) │
│ 4. Run beforeToolCall hook (if defined) │
│ └── on any failure → return immediateOutcome (skip execution) │
│ └── success → return preparedToolCall │
├─────────────────────────────────────────────────────────────────┤
│ EXECUTE → executePreparedToolCall() │
│ │
│ 1. emit toolExecutionStart event │
│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│
│ 3. wait for all pending update events │
│ └── on error → return executedOutcome(isError=true) │
│ └── success → return executedOutcome(isError=false) │
├─────────────────────────────────────────────────────────────────┤
│ FINALIZE → finalizeExecutedToolCall() │
│ │
│ 1. Run afterToolCall hook (if defined) │
│ - can mutate content, details, usage, terminate, isError │
│ 2. emit toolExecutionEnd event │
│ 3. createToolResultMessage → adds to conversation history │
│ └── return finalizedOutcome │
└─────────────────────────────────────────────────────────────────┘
Step 5: Feed Results Back to LLM
Tool results are added to agent._state.messages as toolResultMessage objects. On the next loop iteration, formatMsgForLLM() converts them to OpenAI format and the LLM receives the results:
Conversation history after tool execution:
[system] "You are a helpful assistant."
[user] "What's the weather in Tokyo?"
[assistant] (tool_calls: getWeather(city="Tokyo"))
[tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C"
The LLM then decides: call another tool, or return a final text answer.
Execution Modes
Sequential
Tools execute one at a time in order. Required when:
- Tools have implicit dependencies
- Tools share state (e.g. writing to the same file)
- Tools have
parallelToolExecute = false
Set globally via agentLoopConfig.toolExecution = "sequential", or per-tool via parallelToolExecute = false.
Parallel
Tools execute concurrently when all are independent. Reduces wall-clock time. Set parallelToolExecute = true on individual tools, or set agentLoopConfig.toolExecution = "parallel".
Streaming Partial Results
For long-running tools (API calls, file uploads, training), use onPartialResult to stream progress:
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
using .toolRegistry
tools = loadTools("src/tools") # scans for *.jl files with getTool()
Files are loaded alphabetically for deterministic registration order.
Manual Registration
tool = getTool() # from your tool module
registerTool(tool)
Complete Lifecycle Example
# ─── USER SENDS MESSAGE ───────────────────────────────────────────
run_agent(agent, "What's the weather in Tokyo?")
# ─── LOOP ITERATION 1 ─────────────────────────────────────────────
# Agent formats messages and calls LLM
formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state))
response = agent.llmCall(formatted)
# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]}
# Agent extracts tool call, builds context
context = agentContext(systemPrompt, messages, agent._state.tools)
tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))]
# PREPARE: find tool, validate args
tool = find(t -> t.name == "getWeather", context.tools) # found!
validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes
beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped
# EXECUTE: call tool.execute()
result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult)
# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false)
# FINALIZE: afterToolCall hook, emit events
finalized = finalizedOutcome(tc, result, false)
emit(toolExecEndEvent("call_1", "getWeather", result, false))
msg = createToolResultMessage(finalized) # toolResultMessage for conversation history
# Add result to conversation
push!(agent._state.messages, msg)
# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"]
# ─── LOOP ITERATION 2 ─────────────────────────────────────────────
# LLM called again with tool result included
formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state))
response = agent.llmCall(formatted)
# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]}
# No tool calls detected → break loop, return final response
return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...)
# ─── USER RECEIVES RESPONSE ───────────────────────────────────────
response = take_response(agent)
println(response.content)
# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]"
Self-Modifying Tools
The framework includes tools that allow the agent to create new tools at runtime.
writeTool — Create New Tool Files
writeTool is a file writer, not a code generator. The LLM provides the tool logic as executeCode (the actual Julia code), and writeTool wraps it in the required boilerplate.
How it works:
The LLM constructs writeTool with:
executeCode— the actual tool logic (Julia code body, NOT wrapped in a function)name,label,description— tool metadatainputSchema— parameter schema in MCP formatvalidateCode,prepareCode(optional) — custom validation/preparation logic
writeTool produces src/tools/<name>.jl by:
- Converting the
inputSchemaDict into a JuliaDict{String,Any}(...)string literal - Indenting
executeCodewith 4 spaces - Wrapping it inside a
function executeTool(...)::agentToolResult ... endtemplate - Appending the
getTool()definition that returns anagentToolstruct - Writing the combined string to disk
Workflow:
LLM decides: "Need a searchWine tool. I'll provide the logic."
LLM calls writeTool:
name: "searchWine"
executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..."
writeTool wraps it → src/tools/searchWine.jl:
function executeTool(...)::agentToolResult
query = args["query"] ← LLM code (indented 4 spaces)
result = search(query)
return agentToolResult(...)
end
function getTool()::agentTool
return agentTool(name="searchWine", ...)
end
Restart → loadTools("src/tools") loads searchWine.jl
Example specification:
Dict(
"name" => "searchWine",
"label" => "Wine Search",
"description" => "Search a wine database by name, region, or variety",
"inputSchema" => Dict(
"type" => "object",
"properties" => Dict(
"query" => Dict("type" => "string", "description" => "Search query"),
"maxResults" => Dict("type" => "integer", "default" => 10)
),
"required" => ["query"]
),
"executeCode" => """
query = args["query"]
max_results = get(args, "maxResults", 10)
# Perform search logic here
result = "Found 3 wines matching: $query"
return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false)
""",
"parallel" => false
)
Optional hooks:
| Field | Description |
|---|---|
validateCode |
Custom validation Julia code (runs before execute). Return nothing to pass, or an error String to fail. |
prepareCode |
Argument preparation code (runs before validation). Return modified args dict. |
listTools — Discover Available Tools
Returns all registered tools. Primarily useful for collision detection before creating a new tool via writeTool — the LLM checks existing names before picking a unique one.
# Result from listTools:
# Available tools:
# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city.
# - getTime: Time Lookup — Get current local time for a timezone or city.
# - writeTool: Create Tool — Generate new tool files...
# - listTools: List Tools — List all available tools with their names and labels...
Complete Self-Tooling Example
User: "I need to search for wines. Do you have a tool for that?"
# ─── LOOP: Agent realizes no wine search tool exists ─────────────────
# LLM generates the tool logic and calls writeTool to write it to disk
[Tool Call] writeTool(name="searchWine", label="Wine Search",
description="Search a wine database by name, region, or variety",
inputSchema={...},
executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)")
# writeTool generates src/tools/searchWine.jl
# ─── SYSTEM RESTARTS ─────────────────────────────────────────────────
# loadTools("src/tools") loads searchWine.jl alongside all other tools
# ─── Agent calls the new tool ─────────────────────────────────────────
[Tool Call] searchWine(query="cabernet", maxResults=5)
# Result: "Found 5 cabernet wines..."
# ─── Final response ──────────────────────────────────────────────────
"The search found 5 cabernet wines: ..."
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) |
writeTool |
Create a new Julia tool module at runtime | Built-in (name + schema validation) |
listTools |
List all available tools with descriptions | None (no arguments) |