diff --git a/src/tools/README.md b/src/tools/README.md new file mode 100644 index 0000000..d7c6380 --- /dev/null +++ b/src/tools/README.md @@ -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. diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index d100f9b..97636ab 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -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 diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index f62cb80..0411841 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -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 diff --git a/src/type.jl b/src/type.jl index 5146870..60ae3c5 100644 --- a/src/type.jl +++ b/src/type.jl @@ -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 diff --git a/src/utils.jl b/src/utils.jl index 0c92f43..db3e51d 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -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 + +