Compare commits

..

5 Commits

Author SHA1 Message Date
ton da21790263 update 2026-08-21 13:13:38 +07:00
ton c59f6bfa61 update 2026-08-21 07:13:52 +07:00
ton 3c91222462 update 2026-08-21 07:10:49 +07:00
ton f62b8f14e7 update 2026-08-20 18:45:39 +07:00
ton 7ffb720f86 update mcp definition example 2026-08-20 09:58:55 +07:00
17 changed files with 661 additions and 1527 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
julia_version = "1.12.6"
manifest_format = "2.0"
project_hash = "aa163e2bf572632825162936e107be18384fd40f"
project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752"
[[deps.Accessors]]
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
-4
View File
@@ -12,13 +12,11 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241"
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
@@ -31,7 +29,5 @@ DataFrames = "1.7.0"
GeneralUtils = "0.5.10"
HTTP = "2.4.0"
JSON = "1.6.1"
LLMMCTS = "0.1.5"
NATS = "0.1.0"
SQLLLM = "0.2.8"
msghandler = "1.2.1"
+123 -18
View File
@@ -1,12 +1,16 @@
# YiemAgent
Julia framework for building agents with tool use.
Julia framework for building agents with tool use and MCP (Model Context Protocol) support.
## Getting Started
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
2. Create a `yiemAgent` with `loadTools("src/tools")`
3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames`
2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS
3. Create a `yiemAgent` with an LLM callable and MCP server:
```julia
agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink)
```
4. Call `runAgent(agent, message)` then `takeResponse(agent)`
## Architecture
@@ -14,21 +18,122 @@ Julia framework for building agents with tool use.
src/
├── YiemAgent.jl # Module entry point
├── type.jl # Core types (messages, tools, agent state)
├── utils.jl # Message formatting, validation
├── utils.jl # Message formatting, context preparation, validation
├── agentCore.jl # Agent loop, tool execution pipeline
├── api.jl # Public API (runAgent, takeResponse, etc.)
└── tools/
├── registry.jl # Tool registry (loadTools, registerTool, listTools)
├── getWeather.jl # Weather lookup tool
├── getTime.jl # Time lookup tool
├── writeTool.jl # Create new tool files (self-modifying)
└── README.md # Tool development guide
├── api.jl # Public API (runAgent, takeResponse, followUp, stopAgent)
└── toolRegistry.jl # Tool store, MCP discovery, listTools registration
```
## Tool Development
## MCP Protocol
See `src/tools/README.md` for:
- Tool anatomy (schema, execute, getTool)
- Validation hooks
- Agent loop lifecycle
- Self-modifying tools (`writeTool`)
YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement:
```julia
# tools/list — returns tool definitions
mcpServer("tools/list") # → Dict("jsonrpc"=>"2.0", "id"=>1, "result"=>Dict("tools"=>[...], "nextCursor"=>...))
# tools/call — executes a tool
mcpServer("tools/call", toolName, arguments) # → Dict("jsonrpc"=>"2.0", "id"=>2, "result"=>Dict("content"=>[...], "isError"=>...))
```
Protocol error responses include `"error"` instead of `"result"`:
```julia
Dict("jsonrpc"=>"2.0", "id"=>2, "error"=>Dict("code"=>-32602, "message"=>"..."))
```
### Implementing the MCP Server Client
You must provide a callable struct that communicates with your MCP server. Example using NATS:
```julia
struct mcpServer
natsConn::NATS.Connection
topic::String
senderID::String
fileserver_url::String
end
# tools/list implementation
function (c::mcpServer)(method::String)
if method != "tools/list"
error("mcpServer: unexpected method '$method' (expected 'tools/list')")
end
payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}())
payloads = [("payload", payload, "dictionary")]
_, msg_envelope_json_str = msghandler.smartpack(
c.topic, payloads;
sender_id=c.senderID,
msg_purpose="mcp_tools_list",
fileserver_url=c.fileserver_url)
reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180)
incoming_env = msghandler.smartunpack(String(reply.payload))
return incoming_env["payloads"][1][2]
end
# tools/call implementation
function (c::mcpServer)(method::String, toolName::String, arguments::Dict{String, Any})
if method != "tools/call"
error("mcpServer: unexpected method '$method' (expected 'tools/call')")
end
payload = Dict(
"jsonrpc" => "2.0",
"id" => 2,
"method" => method,
"params" => Dict(
"name" => toolName,
"arguments" => arguments
)
)
payloads = [("payload", payload, "dictionary")]
_, msg_envelope_json_str = msghandler.smartpack(
c.topic, payloads;
sender_id=c.senderID,
msg_purpose="mcp_tool_call",
fileserver_url=c.fileserver_url)
reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180)
incoming_env = msghandler.smartunpack(String(reply.payload))
return incoming_env["payloads"][1][2]
end
```
See `test/runtest.jl` for the complete working example.
## Tool Discovery
Tools are discovered dynamically via MCP server:
1. `listTool` is the only pre-registered tool
2. When the LLM calls `listTools()`, tools are discovered from the MCP server and registered at runtime
3. Supports pagination via `nextCursor` for large tool sets
## Agent API
| Function | Description |
|----------|-------------|
| `runAgent(agent, msg)` | Send a message to the agent's input channel |
| `takeResponse(agent)` | Block and take the agent's response from output channel |
| `followUp(agent, msg)` | Send a follow-up message while agent is still processing |
| `stopAgent(agent)` | Gracefully stop the agent and close channels |
## Agent Lifecycle
1. `yiemAgent()` spawns a background task (`_agentLoop`) listening on `inputChannel` and `followUpChannel`
2. User messages enter via `runAgent()` or `followUp()`
3. `_processMessage()` handles LLM calls, tool execution, and conversation history
4. Tool execution follows three phases: `prepareToolCall` → `executePreparedToolCall` → `finalizeExecutedToolCall`
5. `beforeToolCall`/`afterToolCall` hooks allow pre/post-processing of tool calls
6. Tool `execute` functions can set `terminate=true` to stop the agent loop
## Hooks
| Hook | Signature | Purpose |
|------|-----------|---------|
| `prepareContext` | `(state, sink, llmCall) -> ctx` | Transform messages/context before LLM call |
| `formatMsgForLLM` | `(ctx, sink) -> dict` | Convert agent context to LLM API format |
| `beforeToolCall` | `(context, signal) -> result` | Block/allow tool execution |
| `afterToolCall` | `(context, signal) -> result` | Post-process tool results |
| `eventSink` | `(msg) -> nothing` | Callback for agent events/debug messages |
+32 -32
View File
@@ -89,7 +89,7 @@ using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
# Create agent — tools are registered automatically via register_all_tools()
agent = yiemAgent(
llmCall = my_llm_call, # Function that calls the LLM API
agentEventSink = my_event_sink, # Function for TUI/logging
eventSink = my_event_sink, # Function for TUI/logging
)
```
@@ -98,7 +98,7 @@ agent = yiemAgent(
| Parameter | Type | Required | Purpose |
|-----------|------|----------|---------|
| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM |
| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events |
| `eventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events |
| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text |
| `model` | `llmModel` | No | LLM model config |
| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history |
@@ -179,7 +179,7 @@ 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`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results |
| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `eventSink` | `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.
@@ -222,7 +222,7 @@ end
```julia
execute(toolCallId::String,
args::Dict{String,Any},
args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
```
@@ -430,12 +430,12 @@ function _processMessage(agent::yiemAgent)::assistantMessage
# ── Step 2: Prepare context ─────────────────────────────────
state = agentState(systemPrompt, nothing, tools, messages)
preparedContext = prepareContext(state, agentEventSink)
preparedContext = prepareContext(state, eventSink)
# Default: deep copies systemPrompt, messages, tools from agentState → agentContext
# Override point: filter tools, inject context, modify system prompt
# ── Step 3: Format for LLM ──────────────────────────────────
formattedMessages = formatMsgForLLM(preparedContext, agentEventSink)
formattedMessages = formatMsgForLLM(preparedContext, eventSink)
# Converts agentContext → Dict("messages" => [...]) in OpenAI format
# Wraps systemPrompt as system role, converts each messageContent block
@@ -456,7 +456,7 @@ function _processMessage(agent::yiemAgent)::assistantMessage
signal = abortSignal(false)
# Execute tool calls (sequential or parallel)
batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink)
batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, eventSink)
# Save results to conversation history
for tool_result in batch.messages
@@ -611,7 +611,7 @@ function prepareToolCall(
function executePreparedToolCall(
prep::preparedToolCall,
signal::Union{Nothing, abortSignal},
agentEventSink,
eventSink,
)::executedOutcome
```
@@ -623,7 +623,7 @@ function executePreparedToolCall(
prep.toolCall.id,
prep.args,
signal,
agentEventSink # serves as onPartialResult callback
eventSink # serves as onPartialResult callback
)
return executedOutcome(result, false)
```
@@ -698,7 +698,7 @@ function executeToolCalls(
toolCalls::Vector{agentToolCall},
config::agentLoopConfig,
signal::Union{Nothing, abortSignal},
agentEventSink,
eventSink,
)::agentToolCallBatch
```
@@ -733,12 +733,12 @@ function executeToolCallsSequential(...)::agentToolCallBatch
messages = toolResultMessage[]
for tc in toolCalls
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
else
executed = executePreparedToolCall(prep, signal, agentEventSink)
executed = executePreparedToolCall(prep, signal, eventSink)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
end
@@ -763,14 +763,14 @@ function executeToolCallsParallel(...)::agentToolCallBatch
entries = union{finalizedOutcome, task{finalizedOutcome}}[]
for tc in toolCalls
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
push!(entries, finalized) # immediate outcome — no task
else
task = task() do
executed = executePreparedToolCall(prep, signal, agentEventSink)
executed = executePreparedToolCall(prep, signal, eventSink)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
return finalized
end
@@ -845,7 +845,7 @@ From the type documentation (`type.jl:803-815`):
**Source:** `agentCore.jl:266-307`
```julia
batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink)
# Save results to conversation history
for tool_result in batch.messages
@@ -1037,13 +1037,13 @@ end
### Event Sink
The `agentEventSink` function is passed through the entire call chain:
The `eventSink` function is passed through the entire call chain:
```julia
agentEventSink = agent.agentEventSink # set during yiemAgent construction
eventSink = agent.eventSink # set during yiemAgent construction
```
The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by:
The `eventSink` 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
@@ -1057,12 +1057,12 @@ The `agentEventSink` function is a user-provided callback that receives all even
| Hook | Signature | Called | Purpose |
|------|-----------|--------|---------|
| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> 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 |
| `eventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring |
### `beforeToolCall` Hook
@@ -1125,7 +1125,7 @@ end
**Source:** `utils.jl:111-125`
```julia
function prepareContext(state::agentState, agentEventSink)::agentContext
function prepareContext(state::agentState, eventSink)::agentContext
# TODO: filter tools from state.tools based on user intent
filteredTools = state.tools
@@ -1152,7 +1152,7 @@ end
Default implementation converts `agentContext` to OpenAI-compatible format:
```julia
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
messages = Vector{Dict{String, Any}}()
# System prompt as system message
@@ -1284,7 +1284,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
│ Step 6: Execute tool calls
│ context = agentContext(systemPrompt, messages, tools)
│ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
│ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
│ batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink)
LOOP ITERATION 1 — executeToolCallsSequential
@@ -1299,7 +1299,7 @@ LOOP ITERATION 1 — executeToolCallsSequential
│ → preparedToolCall(tool, tc, {"city" => "Tokyo"})
│ EXECUTE:
│ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink)
│ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, eventSink)
│ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
│ → executedOutcome(result, false)
@@ -1365,20 +1365,20 @@ end
```julia
# Argument preparation (before validation)
function <name>PrepareArguments(args::Dict{String,Any})::Dict{String,Any}
function <name>PrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any}
# Return modified args, or args unchanged
return args
end
# Custom validation (before execution)
function <name>ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
function <name>ValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String}
# Return nothing to pass, or error string to fail
return nothing
end
# Core execution
function <name>Execute(toolCallId::String,
args::Dict{String,Any},
args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
# Return agentToolResult with content, details, usage, terminate
@@ -1400,17 +1400,17 @@ function helper_function(...)
end
# Optional: prepareArguments
function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any}
function myToolPrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any}
return args
end
# Optional: validateRequiredArgs
function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
function myToolValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String}
return nothing
end
# Required: execute function
function myToolExecute(toolCallId::String, args::Dict{String,Any},
function myToolExecute(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
...
@@ -1481,7 +1481,7 @@ To add a new tool (e.g., `searchWine.jl`):
using .type
# using AdditionalPkg # add if needed
function searchWineExecute(toolCallId::String, args::Dict{String,Any},
function searchWineExecute(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal}, onPartialResult)
query = get(args, "query", "")
result = search_wine_db(query)
-5
View File
@@ -1,5 +0,0 @@
check my understand:
1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and
it will be the latest message in agent._state.messages. then _agentLoop() can pick it as
the output to outputChannel
2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall
+3
View File
@@ -6,3 +6,6 @@ check my understanding
Is my understanding correct?
the user can provide NATS connection to MCP server by adding agent.mcpserver (a callable struct) for communication with MCP server just like agent.llmCall (also a callable struct). I think communicating with MCP server is just send/receive JSON text right?
Moreover, for simplicity I want to all tools into an MCP server so an agent can be instantiated with only listTools() in tool store then populate tools from MCP server later.
what do you think?
+31 -66
View File
@@ -1,66 +1,31 @@
module YiemAgent
export register_all_tools
""" Order by dependencies of each file. The 1st included file must not depend on any other
files and each file can only depend on the file included before it.
"""
include("type.jl")
using .type
include("utils.jl")
using .utils
include("tools/getWeather.jl")
include("tools/getTime.jl")
include("tools/searchWine.jl")
include("tools/writeTool.jl")
include("toolRegistry.jl")
using .toolRegistry
function register_all_tools(store::toolRegistry.toolStore)
registerTool(store, getWeatherTool())
registerTool(store, getTimeTool())
registerTool(store, searchWineTool())
registerTool(store, writeToolTool())
registerTool(store, listTool(store))
return store.tools
end
# include("llmfunction.jl")
# using .llmfunction
include("agentCore.jl")
using .agentCore
include("api.jl")
using .api
# ---------------------------------------------- 100 --------------------------------------------- #
end # module YiemAgent_v1
module YiemAgent
"""Order by dependencies of each file. The 1st included file must not depend on any other
files and each file can only depend on the file included before it."""
include("type.jl")
using .type
include("utils.jl")
using .utils
include("toolRegistry.jl")
using .toolRegistry
# include("llmfunction.jl")
# using .llmfunction
include("agentCore.jl")
using .agentCore
include("api.jl")
using .api
# ---------------------------------------------- 100 --------------------------------------------- #
end # module YiemAgent_v1
+193 -108
View File
@@ -9,11 +9,6 @@ using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serializ
using GeneralUtils
using ..type, ..utils, ..toolRegistry
function register_all_tools(store::toolRegistry.toolStore)
# Call parent module's version which has access to tool functions
parentmodule(@__MODULE__).register_all_tools(store)
end
# ---------------------------------------------- 100 --------------------------------------------- #
"""
@@ -49,7 +44,83 @@ mutable struct yiemAgent <: agent # High-level agent wrapper
# Each block has a type — "text", "thinking", or "toolCall".
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
llmCall
# Callable struct for MCP server communication.
# Called as: mcpServer("tools/list") → returns JSON-RPC 2.0 parsed response
# mcpServer("tools/call", toolName, arguments) → returns JSON-RPC 2.0 parsed response
#
# # Example (weather tool)
# # User provides a callable struct
# struct MyMCPClient
# natsConn::NATS.Connection
# topic::String
# senderID::String
# fileserver_url::String
# end
#
# function (c::MyMCPClient)(method::String)
# payload = Dict("jsonrpc" => "2.0", "id" => 1, "method" => method, "params" => Dict{String, Any}())
# payloads = [("payload", payload, "dictionary")]
# _, msg_envelope_json_str = msghandler.smartpack(
# c.topic, payloads; sender_id=c.senderID,
# msg_purpose="mcp_tools_list", fileserver_url=c.fileserver_url)
# reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180)
# incoming_env = msghandler.smartunpack(String(reply.payload))
# return incoming_env["payloads"][1][2]
# end
#
# function (c::MyMCPClient)(method::String, toolName::String, arguments::AbstractDict{String, Any})
# payload = Dict("jsonrpc" => "2.0", "id" => 2, "method" => method,
# "params" => Dict("name" => toolName, "arguments" => arguments))
# payloads = [("payload", payload, "dictionary"),]
# _, msg_envelope_json_str = msghandler.smartpack(
# c.topic, payloads; sender_id=c.senderID,
# msg_purpose="mcp_tool_call", fileserver_url=c.fileserver_url)
# reply = NATS.request(c.natsConn, c.topic, msg_envelope_json_str, timeout=180)
# incoming_env = msghandler.smartunpack(String(reply.payload))
# return incoming_env["payloads"][1][2]
# end
#
# # "tools/list" input:
# mcpServer("tools/list")
# # sending out payload before smart packed by msghandler:
# Dict("jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => Dict{String, Any}())
# # expected return after smart unpacked by msghandler:
# Dict(
# "jsonrpc" => "2.0", "id" => 1,
# "result" => Dict(
# "tools" => [
# Dict(
# "name" => "getWeather",
# "description" => "Fetch current weather for a city.",
# "inputSchema" => Dict("type"=>"object",
# "properties" => Dict("city"=>Dict("type"=>"string", "description"=>"City name"),
# "units"=>Dict("type"=>"string", "enum"=>["celsius","fahrenheit"], "default"=>"celsius")),
# "required" => ["city"])
# )
# ],
# "nextCursor" => nothing
# )
# )
#
# # "tools/call" input:
# mcpServer("tools/call", "getWeather", Dict("city"=>"Tokyo", "units"=>"celsius"))
# # sending out payload before smart packed by msghandler:
# Dict("jsonrpc" => "2.0", "id" => 2, "method" => "tools/call",
# "params" => Dict("name" => "getWeather", "arguments" => Dict("city"=>"Tokyo", "units"=>"celsius")))
# # expected return after smart unpacked by msghandler (success):
# Dict("jsonrpc" => "2.0", "id" => 2,
# "result" => Dict("content" => [{"type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C"}],
# "isError" => false))
# # expected return after smart unpacked by msghandler (tool error):
# Dict("jsonrpc" => "2.0", "id" => 2,
# "result" => Dict("content" => [{"type" => "text", "text" => "Error: City 'Atlantis' not found."}],
# "isError" => true))
# # expected return after smart unpacked by msghandler (protocol error):
# Dict("jsonrpc" => "2.0", "id" => 2,
# "error" => Dict("code" => -32602, "message" => "Missing required argument 'city'"))
mcpServer
# Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..)
beforeToolCall::Union{Function, Nothing}
@@ -61,7 +132,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink # agent emits its status via this function
eventSink # agent emits its status via this function
end
"""
@@ -85,7 +156,10 @@ on `inputChannel` and `followUpChannel` channels concurrently.
- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`)
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
- `eventSink::Function`: Callback to receive agent events
- `mcpServer`: Callable struct for MCP server communication. Called as
`mcpServer("tools/list")` to discover tools, or `mcpServer("tools/call", args)`
to execute a tool. Returns parsed JSON dicts. (default: `nothing`)
# Returns
- A new `yiemAgent` instance with an active background task
@@ -105,7 +179,8 @@ function yiemAgent(
sessionId::Union{String, Nothing}=nothing,
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink=agentEventSink,
eventSink=eventSink,
mcpServer=nothing,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
@@ -114,7 +189,7 @@ function yiemAgent(
# load tools (statically registered at module init)
toolStore1 = toolStore(name="myagent")
register_all_tools(toolStore1)
registerAllTools(toolStore1, mcpServer; eventSink=eventSink)
# Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent(
@@ -126,6 +201,7 @@ function yiemAgent(
prepareContext,
formatMsgForLLM,
llmCall,
mcpServer,
beforeToolCall,
afterToolCall,
# prepareNextTurn,
@@ -133,7 +209,7 @@ function yiemAgent(
sessionId,
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
eventSink,
)
# Spawn the background loop and attach it
@@ -208,18 +284,18 @@ function _agentLoop(agent::yiemAgent)
while true
while newUserMsg === nothing
if isready(agent.inputChannel)
agent.agentEventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))")
agent.eventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))")
# agent process new user msg immediately after the current tool call finished.
newUserMsg = take!(agent.inputChannel)
agent.agentEventSink("new user msg")
agent.eventSink("new user msg")
else
# check followUp message after _processMessage() is done
if typeof(processingTask) == Task && istaskdone(processingTask) == true
agent.agentEventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))")
agent.eventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))")
# if agent runs is done but followUpChannel has messages,
# put new message in inputChannel instead
if isready(agent.followUpChannel)
agent.agentEventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))")
agent.eventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))")
while isready(agent.followUpChannel)
followUpMsg = take!(agent.followUpChannel)
put!(agent.inputChannel, followUpMsg)
@@ -227,18 +303,18 @@ function _agentLoop(agent::yiemAgent)
processingTask = nothing # reset
result = nothing # reset
else # _processMessage() done and no followUp message.
agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))")
agent.eventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))")
result = deepcopy(agent._state.messages[end])
agent.eventSink("_agentLoop 4-1 ")
# filter out reasoningContent in-place
filter!(c -> !(c isa reasoningContent), result.content)
agent.eventSink("_agentLoop 5 ")
# format output
respondToUI = _assistantMessageToOpenAI(result)
agent.eventSink("_agentLoop 6 ")
put!(agent.outputChannel, respondToUI)
if !isempty(result.content) && result.content[1] isa textContent
agent.agentEventSink(result.content[1].text)
agent.eventSink(result.content[1].text)
end
processingTask = nothing # reset
result = nothing # reset
@@ -269,7 +345,7 @@ function _agentLoop(agent::yiemAgent)
else
# spawn new _processMessage() if it is not already running.
if processingTask === nothing
agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))")
agent.eventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))")
# discard all messages in followUpChannel
while isready(agent.followUpChannel)
_ = take!(agent.followUpChannel)
@@ -278,7 +354,7 @@ function _agentLoop(agent::yiemAgent)
# Dispatch message through the processing pipeline
processingTask = @spawn _processMessage(
processMessageInputCh,
agent.agentEventSink,
agent.eventSink,
agent._state.messages,
agent._state.systemPrompt,
agent._state.tools,
@@ -295,6 +371,13 @@ function _agentLoop(agent::yiemAgent)
end
end
catch e
bt = catch_backtrace()
errMsg = sprint() do io
showerror(io, e, bt)
println(io)
end
eventSink(errMsg)
# On any error, send error response and exit the loop
@error "Agent loop failed" error=e
end
@@ -330,7 +413,7 @@ julia> # Currently returns a placeholder echo response
"""
function _processMessage(
inputChannel::Channel,
agentEventSink,
eventSink,
agentMsgHistory::Vector{agentMessage},
systemPrompt::String,
tools::OrderedDict{String, agentTool},
@@ -341,7 +424,7 @@ function _processMessage(
afterToolCall::Union{Function, Nothing},
parallelToolExecute::Bool,
)::Nothing
agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))")
# loop until llmCall() response didn't use tool calls
final_response = nothing
@@ -361,43 +444,43 @@ function _processMessage(
while true
# Drain inputChannel and convert OpenAI-format messages to userMessage type
while isready(inputChannel)
agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))")
newUserMsg_openai = take!(inputChannel)
agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))")
if newUserMsg_openai === :shutdown
agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))")
# Re-emit shutdown signal for the loop to handle
put!(inputChannel, :shutdown)
break
end
agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))")
newUserMsg = OpenAiToUserMessage(newUserMsg_openai)
push!(agentMsgHistory, newUserMsg)
agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))")
end
agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))")
# call prepareContext()
state = agentState(systemPrompt, nothing, tools, agentMsgHistory)
agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))")
preparedContext = prepareContext(state, agentEventSink, llmCall)
agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))")
preparedContext = prepareContext(state, eventSink, llmCall)
eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))")
# Call formatMessagesForLLM() to format for LLM
formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink)
formattedMessages = formatMessagesForLLM(preparedContext, eventSink)
agentEventSink("_processMessage 10 formattedMessages $formattedMessages")
eventSink("_processMessage 10 formattedMessages $formattedMessages")
""" response example
response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")]))
"""
response = llmCall(formattedMessages)
agentEventSink(" llmCall " * string(response))
eventSink(" llmCall " * string(response))
agentEventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))")
# Extract tool calls from LLM response content blocks
hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response)
agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))")
agentEventSink("assistant_msg " * string(assistant_msg))
agentEventSink("_processMessage 11-1")
eventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))")
eventSink("assistant_msg " * string(assistant_msg))
eventSink("_processMessage 11-1")
# Add assistant message (tool calls or text) to history for next LLM turn
push!(agentMsgHistory, assistant_msg)
@@ -413,21 +496,21 @@ function _processMessage(
)
signal = abortSignal(false)
agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))")
# call executeToolCalls()
toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config,
signal, agentEventSink)
agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))")
signal, eventSink)
eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))")
# save toolResults to messages
for toolResult in toolResultBatch.messages
agentEventSink("toolResult " * string(toolResult))
eventSink("toolResult " * string(toolResult))
push!(agentMsgHistory, toolResult)
end
agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))")
if toolResultBatch.terminate
agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))")
# If toolResultBatch requested termination, build a final response
final_content = [textContent("Tool execution completed.")]
for toolResult in toolResultBatch.messages
@@ -441,7 +524,7 @@ function _processMessage(
end
end
end
agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))")
final_response = assistantMessage(
role="assistant",
content=final_content,
@@ -460,12 +543,12 @@ function _processMessage(
break
end
else
agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))")
# LLM did not use tool calls —
break
end
end
agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))")
eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))")
return nothing
end
@@ -903,45 +986,48 @@ function prepareToolCall(
toolCall::agentToolCall,
config::agentLoopConfig,
signal::abortSignal,
agentEventSink
eventSink
)::Union{preparedToolCall,immediateOutcome}
agentEventSink("prepareToolCall 1")
tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store
eventSink("prepareToolCall 1")
# pick a called tool from tool store
tool = get(context.tools, toolCall.name, nothing)
if tool === nothing
agentEventSink("prepareToolCall 2")
eventSink("prepareToolCall 2")
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
end
try
agentEventSink("prepareToolCall 3")
eventSink("prepareToolCall 3")
# 1. prepare arguments (tool-specific transform)
prepared = prepareToolCallArguments(tool, toolCall)
agentEventSink("prepared " * string(prepared.arguments))
agentEventSink("prepareToolCall 4")
eventSink("prepared " * string(prepared.arguments))
eventSink("prepareToolCall 4")
validatedArgs = validateToolArguments(tool, prepared)
agentEventSink("validatedArgs " * string(validatedArgs))
agentEventSink("prepareToolCall 5")
eventSink("validatedArgs " * string(validatedArgs))
eventSink("prepareToolCall 5")
# 2. beforeToolCall hook — can block
if config.beforeToolCall !== nothing
agentEventSink("prepareToolCall 6")
eventSink("prepareToolCall 6")
before = config.beforeToolCall(
beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context),
signal
)
agentEventSink("prepareToolCall 7")
eventSink("prepareToolCall 7")
if signal.aborted
agentEventSink("prepareToolCall 8")
eventSink("prepareToolCall 8")
return immediateOutcome(createErrorToolResult("Operation aborted"), true)
end
if before !== nothing && before.block
agentEventSink("prepareToolCall 9")
eventSink("prepareToolCall 9")
return immediateOutcome(
createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true)
end
end
agentEventSink("prepareToolCall 10")
eventSink("prepareToolCall 10")
return preparedToolCall(tool, toolCall, validatedArgs)
catch e
bt = catch_backtrace()
@@ -950,7 +1036,7 @@ function prepareToolCall(
println(io)
end
agentEventSink(errMsg)
eventSink(errMsg)
return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true)
end
@@ -1000,17 +1086,16 @@ executePreparedToolCall(prep, nothing, emit)
function executePreparedToolCall(
prep::preparedToolCall,
signal::Union{Nothing,abortSignal},
agentEventSink,
eventSink,
llmCall::Union{Any,Nothing}=nothing,
)::executedOutcome
agentEventSink("executePreparedToolCall 1")
agentEventSink("executePreparedToolCall 2")
agentEventSink("executePreparedToolCall 3")
eventSink("executePreparedToolCall 1")
try
result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink, llmCall)
agentEventSink(result.content[1].text)
agentEventSink("executePreparedToolCall 4")
try #WORKING
result = prep.tool.execute(prep.toolCall.id, prep.args, signal, eventSink, llmCall)
eventSink("executePreparedToolCall 2")
eventSink(result.content[1].text)
eventSink("executePreparedToolCall 3")
return executedOutcome(result, false)
catch e
bt = catch_backtrace()
@@ -1018,7 +1103,7 @@ function executePreparedToolCall(
showerror(io, e, bt)
println(io)
end
agentEventSink(errMsg)
eventSink(errMsg)
return executedOutcome(createErrorToolResult(sprint(showerror, e)), true)
end
@@ -1088,19 +1173,19 @@ function finalizeExecutedToolCall(
executed::executedOutcome,
config::agentLoopConfig,
signal::Union{Nothing,abortSignal},
agentEventSink
eventSink
)::finalizedOutcome
agentEventSink("finalizeExecutedToolCall 1")
eventSink("finalizeExecutedToolCall 1")
result = executed.result
isError = executed.isError
agentEventSink("finalizeExecutedToolCall 2")
eventSink("finalizeExecutedToolCall 2")
if config.afterToolCall !== nothing
try
after = config.afterToolCall(
afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context),
signal
)
agentEventSink("finalizeExecutedToolCall 3")
eventSink("finalizeExecutedToolCall 3")
if after !== nothing
result = merge(result, dict(:content=>get(after,:content,result.content),
:details=>get(after,:details,result.details),
@@ -1114,13 +1199,13 @@ function finalizeExecutedToolCall(
showerror(io, e, bt)
println(io)
end
agentEventSink(errMsg)
eventSink(errMsg)
result = createErrorToolResult(sprint(showerror, e))
isError = true
end
end
agentEventSink("finalizeExecutedToolCall 4")
eventSink("finalizeExecutedToolCall 4")
return finalizedOutcome(prep.toolCall, result, isError)
end
@@ -1184,41 +1269,41 @@ function executeToolCallsSequential(
toolCalls::Vector{agentToolCall},
config::agentLoopConfig,
signal::abortSignal,
agentEventSink,
eventSink,
)::agentToolCallBatch
llmCall = config.llmCall
agentEventSink("executeToolCallsSequential 1")
eventSink("executeToolCallsSequential 1")
finalizedCalls = finalizedOutcome[]
messages = toolResultMessage[]
for tc in toolCalls
agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)")
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
agentEventSink("executeToolCallsSequential " * string(prep.args))
eventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)")
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
eventSink("executeToolCallsSequential " * string(prep.args))
if prep isa immediateOutcome
agentEventSink("executeToolCallsSequential 2-1")
eventSink("executeToolCallsSequential 2-1")
finalized = finalizedOutcome(tc, prep.result, prep.isError)
agentEventSink("executeToolCallsSequential 2-2")
eventSink("executeToolCallsSequential 2-2")
else
agentEventSink("executeToolCallsSequential 3")
executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall)
agentEventSink("executeToolCallsSequential 3-1")
eventSink("executeToolCallsSequential 3")
executed = executePreparedToolCall(prep, signal, eventSink, llmCall)
eventSink("executeToolCallsSequential 3-1")
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config,
signal, agentEventSink)
agentEventSink("executeToolCallsSequential 3-2")
signal, eventSink)
eventSink("executeToolCallsSequential 3-2")
end
agentEventSink("executeToolCallsSequential 4")
agentEventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name),
eventSink("executeToolCallsSequential 4")
eventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name),
$(finalized.result), $(finalized.isError)")
push!(messages, createToolResultMessage(finalized))
push!(finalizedCalls, finalized)
agentEventSink("executeToolCallsSequential 5")
eventSink("executeToolCallsSequential 5")
if signal !== nothing && signal.aborted
break
end
end
agentEventSink("executeToolCallsSequential 6")
eventSink("executeToolCallsSequential 6")
return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
end
@@ -1285,27 +1370,27 @@ function executeToolCallsParallel(
toolCalls::Vector{agentToolCall},
config::agentLoopConfig,
signal::abortSignal,
agentEventSink,
eventSink,
)::agentToolCallBatch
entries = Union{finalizedOutcome,Task}[]
llmCall = config.llmCall
for tc in toolCalls
agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments))
eventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments))
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError)
agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
push!(entries, finalized)
else
t = Task() do
executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall)
executed = executePreparedToolCall(prep, signal, eventSink, llmCall)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
eventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
return finalized
end
@@ -1388,11 +1473,11 @@ function executeToolCalls(
toolCalls::Vector{agentToolCall},
config::agentLoopConfig,
signal::abortSignal,
agentEventSink,
eventSink,
)::agentToolCallBatch
llmCall = config.llmCall
agentEventSink("_executeToolCalls 1")
eventSink("_executeToolCalls 1")
hasSequential = false
for tc in toolCalls
t = get(context.tools, tc.name, nothing)
@@ -1401,15 +1486,15 @@ function executeToolCalls(
break
end
end
agentEventSink("_executeToolCalls 2")
eventSink("_executeToolCalls 2")
if config.toolExecution == "sequential" || hasSequential
agentEventSink("_executeToolCalls 3")
eventSink("_executeToolCalls 3")
return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal,
agentEventSink)
eventSink)
else
agentEventSink("_executeToolCalls 4")
eventSink("_executeToolCalls 4")
return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal,
agentEventSink)
eventSink)
end
end
+246 -90
View File
@@ -1,16 +1,15 @@
module toolRegistry
export toolStore, registerTool, getTools, clearTools, listTool
export toolStore, registerTool, registerAllTools, getTools, clearTools, listTool
using Dates
using JSON, DataStructures
using ..type
"""
Per-agent isolated tool storage.
Per-agent isolated tool storage.
Each agent gets its own `toolStore` so tool registration is independent
`registerTool(store, tool)` only affects that agent's tool set.
Each agent gets its own `toolStore` so tool registration is independent.
# Fields
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
@@ -28,76 +27,189 @@ Create a new empty tool store.
# Keyword Arguments
- `name::String`: Display name for logging (default: `"default"`)
# Example
```julia
julia> store = toolStore(name="agent1")
toolStore(OrderedDict{String, agentTool}(), "agent1")
```
"""
function toolStore(; name::String="default")::toolStore
toolStore(OrderedDict{String, agentTool}(), name)
end
# ── MCP helper functions ────────────────────────────────────────────
"""
listTool(store::toolStore) -> agentTool
Extract text from MCP tool result content array.
Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over)
`store`. `register_all_tools` auto-registers one so the LLM can discover tools
at runtime.
# Arguments
- `store`: The tool store whose tools will be listed when the tool runs
# Example
```julia
julia> store = toolStore(name="agent1");
julia> register_all_tools(store) # auto-registers listTools
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Registered tool: listTools
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 4 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"writeTool" => agentTool(...)
"listTools" => agentTool(...)
```
Handles JSON-RPC 2.0 result content format:
{"content": [{"type": "text", "text": "..."}], "isError": false}
"""
function listTool(store::toolStore)::agentTool
return agentTool(
name = "listTools",
label = "List Tools",
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
inputSchema = Dict{String,Any}(
function _extract_text_content(result::Dict)::String
content = get(result, "content", Any[])
if content isa Vector && !isempty(content)
lines = String[]
for block in content
if block isa Dict && get(block, "type", "") == "text"
push!(lines, string(get(block, "text", "")))
end
end
if !isempty(lines)
return join(lines, "\n")
end
end
return JSON.json(result)
end
"""
Wrap an MCP tool definition as an `agentTool`.
The returned tool's `execute` function calls the MCP server's "tools/call"
method with the validated arguments.
"""
function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing)::agentTool
name = tool_def["name"]
title = get(tool_def, "title", get(tool_def, "label", name))
desc = get(tool_def, "description", "")
input_schema = get(tool_def, "inputSchema", Dict{String,Any}())
# Normalize inputSchema to OpenAI function format
if haskey(input_schema, "properties") && input_schema["type"] == "object"
params = Dict(
"type" => "object",
"properties" => input_schema["properties"],
"required" => get(input_schema, "required", Any[]),
)
else
params = Dict(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools(store)
if isempty(tools)
result_text = "No tools registered."
else
lines = String["- $(t.name): $(t.label)$(t.description)" for (k, t) in tools]
result_text = "Available tools:\n" * join(lines, "\n")
"required" => Any[],
)
end
return agentTool(
name=name,
label=title,
description=desc,
inputSchema=params,
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink,
llmCall=nothing) -> begin
try
response = mcpserver("tools/call", name, args)
# Parse JSON-RPC 2.0 response envelope
if haskey(response, "error")
rpc_error = response["error"]
err_msg = get(rpc_error, "message", "Unknown MCP error")
return agentToolResult(
[textContent("MCP error: $err_msg")],
Dict{Any,Any}("error" => err_msg),
nothing, false
)
end
result_data = get(response, "result", response)
content_text = _extract_text_content(result_data)
is_error = get(result_data, "isError", false)
return agentToolResult(
[textContent(content_text)],
Dict{Any,Any}("isError" => is_error),
nothing, false
)
catch e
errMsg = sprint(showerror, e)
return agentToolResult(
[textContent("MCP call error: $errMsg")],
Dict{Any,Any}("error" => errMsg),
nothing, false
)
end
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => length(tools)),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
prepareArguments=nothing,
validateRequiredArgs=nothing,
parallelToolExecute=false,
)
end
# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope
"""
Discover and register MCP tools into `store.tools`.
Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format),
parses the response envelope, and registers each discovered tool.
Handles pagination via `nextCursor`. Skips tools already registered.
# Returns
- `Int`: number of new tools registered
"""
function register_mcp_tools(mcpserver, store::toolStore; eventSink=nothing)::Int
if mcpserver === nothing
return 0
end
new_count = 0
try
response = mcpserver("tools/list")
# Parse JSON-RPC 2.0 response envelope
if haskey(response, "error")
rpc_error = response["error"]
err_msg = get(rpc_error, "message", "Unknown MCP error")
println("[toolRegistry:$(store.name)] MCP tools/list failed: $err_msg")
return 0
end
if haskey(response, "result")
response = response["result"]
end
tools_array = response["tools"]
cursor = get(response, "nextCursor", nothing)
for tool_def in tools_array
name = tool_def["name"]
if haskey(store.tools, name)
continue
end
@show "tool_def $(typeof(tool_def))"
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
store.tools[name] = wrapped
new_count += 1
end
# Paginate: fetch remaining tools if nextCursor is present
while cursor !== nothing && cursor !== ""
response = mcpserver("tools/list")
if haskey(response, "result")
response = response["result"]
end
tools_array = get(response, "tools", Any[])
cursor = get(response, "nextCursor", nothing)
for tool_def in tools_array
name = tool_def["name"]
if haskey(store.tools, name)
continue
end
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
store.tools[name] = wrapped
new_count += 1
end
end
println("[toolRegistry:$(store.name)] Discovered $new_count MCP tools. Total: $(length(store.tools))")
catch e
bt = catch_backtrace()
err_msg = sprint() do io
showerror(io, e, bt)
println(io)
end
eventSink(err_msg)
errMsg = sprint(showerror, e)
println("[toolRegistry:$(store.name)] MCP tools/list failed: $errMsg")
end
return new_count
end
"""
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
@@ -110,18 +222,9 @@ Add `tool` to `store`, overwriting any existing tool with the same name.
# Returns
- The same `store.tools` dict (modified in place)
# Example
```julia
julia> store = toolStore(name="agent1");
julia> registerTool(store, listTool(store))
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 1 entry:
"listTools" => agentTool(...)
```
"""
function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
function registerTool(store::toolStore, tool::agentTool; eventSink=nothing
)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools
@@ -130,22 +233,13 @@ end
"""
Return the tools registered in `store`.
The returned dict is the **same object** stored inside `store` — mutations
to it (e.g. via `registerTool`) are visible through subsequent calls.
The returned dict is the **same object** stored inside `store`.
# Arguments
- `store`: Tool store to query
# Returns
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
# Example
```julia
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 2 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
```
"""
function getTools(store::toolStore)::OrderedDict{String, agentTool}
return store.tools
@@ -159,16 +253,6 @@ Remove all tools from `store`.
# Returns
- `nothing`
# Example
```julia
julia> clearTools(store)
[toolRegistry:agent1] Registry cleared
nothing
julia> getTools(store)
OrderedDict{String, agentTool} with 0 entries
```
"""
function clearTools(store::toolStore)::Nothing
empty!(store.tools)
@@ -176,4 +260,76 @@ function clearTools(store::toolStore)::Nothing
return nothing
end
# ── listTools tool (auto-discover new MCP tools at runtime) ─────────
"""
listTool(store::toolStore, mcpserver) -> agentTool
MCP-aware listTools tool (JSON-RPC 2.0 protocol).
First call: queries the MCP server via `mcpserver("tools/list")`, registers
all discovered tools into the shared `store.tools` (in-place mutation, with
pagination via nextCursor), then returns the full tool list.
Subsequent calls: returns the current list (tools remain registered).
"""
function listTool(store::toolStore, mcpserver)::agentTool
return agentTool(
name="listTools",
label="List Tools",
description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.",
inputSchema=Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink, llmCall=nothing) -> begin
# Discover and register MCP tools (idempotent — skips already registered)
new_count = register_mcp_tools(mcpserver, store)
# Always include listTools itself in the count
total = length(store.tools)
lines = String[
"- $(t.name): $(t.label)$(t.description)"
for (k, t) in store.tools
]
result_text = "Available tools ($total):\n" * join(lines, "\n")
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => total),
nothing, false
)
end,
prepareArguments=nothing,
validateRequiredArgs=nothing,
parallelToolExecute=false,
)
end
# ── High-level API ──────────────────────────────────────────────────
"""
registerAllTools(store::toolStore, mcpserver) -> toolStore
Register all tools for an agent:
1. Auto-discover existing tools from the MCP server
2. Register the `listTools` tool so the agent can discover new tools at runtime
# Arguments
- `store`: The tool store to populate
- `mcpserver`: A callable struct that communicates with the MCP server
# Returns
- The populated `toolStore`
"""
function registerAllTools(store::toolStore, mcpserver=nothing; eventSink=nothing)::toolStore
register_mcp_tools(mcpserver, store; eventSink=eventSink)
registerTool(store, listTool(store, mcpserver); eventSink=eventSink)
return store
end
end # module
-83
View File
@@ -1,83 +0,0 @@
using .type
using Dates
"""
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 getTimeValidateRequiredArgs(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.
Returns mock time data for the given timezone or city.
"""
function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult, llmCall=nothing)
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
if tz !== nothing
result = "Current time in $(tz): $(now())"
else
result = "Current time in $(city): $(now())"
end
return agentToolResult(
[textContent(result)],
Dict{Any,Any}(), nothing, false
)
end
"""
Define and return the getTime agentTool.
"""
function getTimeTool()::agentTool
return agentTool(
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" => "IANA timezone, e.g. 'America/New_York'"),
"city" => Dict("type" => "string", "description" => "City name as fallback")
),
"required" => []
),
execute = getTimeExecute,
prepareArguments = nothing,
validateRequiredArgs = getTimeValidateRequiredArgs,
parallelToolExecute = false
)
end
-48
View File
@@ -1,48 +0,0 @@
using msghandler
using .type
"""
Execute the getWeather tool.
Returns mock weather data for the given city and temperature units.
"""
function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
agentEventSink, llmCall=nothing)
agentEventSink("Getting weather...")
city = get(args, "city", "")
units = get(args, "units", "celsius")
temp = units == "fahrenheit" ? "72" : "22"
unit_symbol = units == "celsius" ? "°C" : "°F"
return agentToolResult(
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
Dict{Any,Any}(),
nothing,
false
)
end
"""
Define and return the getWeather agentTool.
"""
function getWeatherTool()::agentTool
return agentTool(
name = "getWeather",
label = "Weather Lookup",
description = "Fetch current weather and forecast for a given city.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'"),
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius", "description" => "Temperature scale")
),
"required" => ["city"]
),
execute = getWeatherExecute,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
-397
View File
@@ -1,397 +0,0 @@
using .type
using LibPQ, DataFrames, JSON, DataStructures
using Dates, Random, HTTP
using GeneralUtils
# ── Database config — update for your environment ───────────────────────
const DB_CONFIG = Dict{String,Any}(
"host" => "localhost",
"port" => 5432,
"dbname" => "winedb",
"user" => "postgres",
"password" => "",
)
"""
Execute the search_wine_database! tool.
Uses the agent's LLM to generate SQL from the free-form text query,
then executes it against the wine database and returns formatted results.
"""
function searchWineExecute(
toolCallId::String,
args::Dict{String,Any},
signal::Union{Nothing,abortSignal},
agentEventSink,
llmCall,
)::agentToolResult
#WORKING
search_query = get(args, "searchQuery", "")::String
if isempty(search_query)
return agentToolResult(
[textContent("Please provide a search query for the wine database.")],
Dict{Any,Any}(), nothing, false
)
end
agentEventSink("searchWineExecute: query=$search_query")
# ── SQL generation prompt ───────────────────────────────────────────
systemmsg = """
# database_search_guidelines
- Keep SQL queries focused only on the provided information.
- Use wildcard character (%) to search more effectively.
- Do not create any table in the database.
- Text information in the database is usually stored in lower case.
If your search returns empty, try using lower case to search.
- Overly strict conditions usually yield empty results.
- Use ILIKE for case-insensitive text matching.
- Only output the SQL query — do not wrap it in backticks or add comments.
# situation
You are a wine store database assistant. You will be given a user's
natural language search query and the database table schema.
# objective
Generate a single SQL query to find wines matching the user's request.
# your responsibility includes
Fulfill the objective.
# you should respond with ONLY the SQL query string, ending with ';'
"""
table_schema = """
CREATE TABLE wine (
wine_id uuid primary key default gen_random_uuid (),
wine_name varchar(128) not null,
winery varchar(128) not null,
vintage integer not null,
region varchar(128) not null,
country varchar(128) not null,
wine_type varchar(128) not null,
grape varchar(128) not null,
serving_temperature varchar(128) not null,
intensity integer,
sweetness integer,
tannin integer,
acidity integer,
fizziness integer,
tasting_notes text,
image_url jsonb,
manufacturer_sku text,
note text,
other_attributes jsonb,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp,
description text
);
CREATE TABLE retailer (
retailer_id uuid primary key default gen_random_uuid (),
retailer_name varchar(128) not null,
retailer_username varchar(128) not null,
retailer_password varchar(128) not null,
retailer_address text not null,
country varchar(128) not null,
contact_person varchar(128) not null,
telephone varchar(128) not null,
email varchar(128) not null,
note text,
other_attributes jsonb,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp,
description text
);
CREATE TABLE retailer_wine (
retailer_id uuid references retailer(retailer_id),
wine_id uuid references wine(wine_id),
constraint retailer_wine_id primary key (retailer_id, wine_id),
price NUMERIC(10, 2),
currency varchar(3) not null,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp
);
"""
context = "<internal_context_for_assistant>\n<database_table_schema>\n$table_schema\n</database_table_schema>\n</internal_context_for_assistant>\n\n"
input = context * "User query: $search_query\n\nGenerate the SQL query:"
# ── Call LLM for SQL generation ────────────────────────────────────
max_attempts = 5
generated_sql = nothing
for attempt in 1:max_attempts
msg = Dict(
"messages" => [
Dict(
"role" => "system",
"content" => [Dict("type" => "text", "text" => systemmsg)],
),
Dict(
"role" => "user",
"content" => [Dict("type" => "text", "text" => input)],
),
],
"temperature" => 0.7,
)
llm_response = llmCall(msg)
# Clean the response — extract SQL from potential markdown/code blocks
sql_text = _clean_sql_response(llm_response)
# Validate it looks like SQL
if _is_valid_sql(sql_text)
generated_sql = sql_text
agentEventSink("searchWine: generated SQL (attempt $attempt)\n$sql_text")
break
else
agentEventSink("searchWine: invalid SQL attempt $attempt: $sql_text")
end
end
if generated_sql === nothing
return agentToolResult(
[textContent("Failed to generate a valid SQL query for your search. Please try rephrasing.")],
Dict{Any,Any}("error" => "sql_generation_failed"), nothing, false
)
end
# ── Execute SQL ────────────────────────────────────────────────────
try
conn = LibPQ.Connection(DB_CONFIG)
# Ensure LIMIT to prevent large result sets
sanitized_sql = _ensure_limit(generated_sql)
agentEventSink("searchWine: executing\n$sanitized_sql")
result = LibPQ.execute(conn, sanitized_sql)
close(conn)
if !LibPQ.hasdata(result)
return agentToolResult(
[textContent("No wines found matching your search. Try loosening your criteria.")],
Dict{Any,Any}("count" => 0), nothing, false
)
end
df = DataFrame(result)
num_rows, num_cols = size(df)
if num_cols > 30
return agentToolResult(
[textContent("The result has more than 30 columns. Please be more specific in your search.")],
Dict{Any,Any}("error" => "too_many_columns"), nothing, false
)
end
# Randomly sample up to 2 rows for display if more than 2 results
display_df = df
if num_rows > 2
idx = sample(1:num_rows, min(2, num_rows), replace=false)
display_df = df[idx, :]
end
# Convert to vector of dicts
result_vec = GeneralUtils.dfToVectorDict(display_df)
# Fetch bottle images if available
for d in result_vec
image_url_json_str = get(d, "image_url", nothing)
if image_url_json_str !== nothing && !isempty(string(image_url_json_str))
try
image_url_json_obj = JSON.parse(string(image_url_json_str))
base_url = "http://192.168.88.106:8080/"
if haskey(image_url_json_obj, "bottle")
url = base_url * string(image_url_json_obj["bottle"])
image_data = HTTP.get(url)
image_base64_string = base64encode(image_data.body)
d["image"] = image_base64_string
end
catch
# Skip image fetch on error
end
end
end
# Format results as readable text
result_str = _format_wine_results(display_df)
return agentToolResult(
[textContent(result_str)],
Dict{Any,Any}(
"count" => num_rows,
"displayed" => size(display_df, 1),
),
nothing, false
)
catch e
errMsg = sprint(showerror, e)
return agentToolResult(
[textContent("Database error: $errMsg")],
Dict{Any,Any}("error" => errMsg), nothing, false
)
end
end
"""
Extract a SQL query string from the LLM response, handling potential
markdown code blocks, extra text, or JSON wrapping.
"""
function _clean_sql_response(response)::String
text = string(response)
# Try to extract from code block
if occursin("```", text)
extracted = GeneralUtils.extract_triple_backtick_text(text)
if !isempty(extracted)
text = extracted[1]
# Remove "sql\n" prefix if present
if startswith(text, "sql\n") || startswith(text, "SQL\n")
text = text[5:end]
end
end
end
# Remove JSON wrapping if present
text = strip(text)
if startswith(text, "{") && occursin("action_input", text)
# Parse as JSON and extract action_input
try
parsed = JSON.parse(text)
if parsed isa Dict
text = get(parsed, "action_input", text)
end
catch
# Keep original
end
end
# Extract SQL keywords to find the actual query
lines = split(strip(text), '\n')
sql_lines = String[]
for line in lines
stripped = strip(line)
if occursin(r"(?i)(SELECT|FROM|WHERE|JOIN|ORDER|LIMIT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)", stripped)
# Take everything from this line to the end
push!(sql_lines, line)
elseif !isempty(sql_lines)
# Continue collecting if we already found SQL
push!(sql_lines, line)
end
end
result = join(sql_lines, "\n")
# Ensure it ends with semicolon
result = strip(result)
if !endswith(result, ";")
result *= ";"
end
return result
end
"""
Check if a string looks like a valid SQL query.
"""
function _is_valid_sql(sql::String)::Bool
sql = strip(sql)
# Must start with a SQL keyword
has_sql_keyword = occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s", sql) ||
occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s*;", sql)
# Must end with semicolon
has_semicolon = endswith(sql, ";")
# Must not be too short (reject single words)
reasonable_length = length(sql) > 10
return has_sql_keyword && has_semicolon && reasonable_length
end
"""
Ensure the SQL query has a LIMIT clause to prevent loading excessive data.
"""
function _ensure_limit(sql::String)::String
sql = strip(sql)
if !occursin(r"(?i)LIMIT", sql)
# Remove existing semicolon, add LIMIT, re-add semicolon
if endswith(sql, ";")
sql = sql[1:end-1]
end
sql *= " ORDER BY RANDOM() LIMIT 2;"
end
return sql
end
"""
Format wine database results as human-readable text.
"""
function _format_wine_results(df::DataFrame)::String
lines = String[]
num_rows = size(df, 1)
for i in 1:num_rows
row = df[i, :]
push!(lines, "$(i). $(get(row, :wine_name, "Unknown")) $(get(row, :vintage, ""))")
winery = get(row, :winery, "Unknown")
region = get(row, :region, "Unknown")
country = get(row, :country, "Unknown")
push!(lines, " Winery: $winery")
push!(lines, " Region: $region, $country")
grape = get(row, :grape, "Unknown")
wtype = get(row, :wine_type, "Unknown")
push!(lines, " Grape: $grape")
push!(lines, " Type: $wtype")
sweetness = get(row, :sweetness, "N/A")
intensity = get(row, :intensity, "N/A")
tannin_val = get(row, :tannin, "N/A")
acidity = get(row, :acidity, "N/A")
push!(lines, " Profile: Sweetness: $sweetness, Intensity: $intensity, Tannin: $tannin_val, Acidity: $acidity")
tasting = get(row, :tasting_notes, nothing)
if tasting !== nothing && !isempty(string(tasting))
tn = string(tasting)
limit = min(200, length(tn))
push!(lines, " Notes: $(tn[1:limit])$(length(tn) > limit ? "..." : "")")
end
price = get(row, :price, "N/A")
currency = get(row, :currency, "")
retailer = get(row, :retailer_name, "N/A")
push!(lines, " Price: $price $currency at $retailer")
push!(lines, "")
end
return join(lines, "\n")
end
"""
Define and return the searchWine agentTool.
"""
function searchWineTool()::agentTool
return agentTool(
name = "searchWine",
label = "Search Wine Database",
description = "Search the wine database for wines matching a free-text query. Uses the LLM to generate SQL and execute it against the database. Returns wine details including name, winery, vintage, tasting notes, and price.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"searchQuery" => Dict(
"type" => "string",
"description" => "Free-text description of the wine you're looking for, e.g., 'a light-bodied red wine from France under 50 dollars'",
),
),
"required" => ["searchQuery"],
),
execute = searchWineExecute,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false,
)
end
-276
View File
@@ -1,276 +0,0 @@
using .type
using JSON
"""
Tool that writes new Julia tool module files to disk.
The agent can use this tool when it encounters a task that no existing tool
can handle. Provide the tool's name, label, description, inputSchema, and
execute logic as Julia code. The tool is written to `src/tools/<name>.jl`.
After calling this tool, add the new file to `YiemAgent.jl` with an `include()`
statement (after `include("toolRegistry.jl")`), then restart the agent.
The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`.
# Example
1. Agent calls writeTool with a spec for a "searchWine" tool
2. writeTool generates src/tools/searchWine.jl
3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools()
5. Restart agent — new tool is available
# How It Works
writeTool is a **file writer**, not a code generator. The LLM provides the
tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate:
- Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string
- Indents `executeCode` with 4 spaces
- Wraps it inside `function executeTool(...)::agentToolResult ... end`
- Appends `writeToolTool()` returning an `agentTool` struct
- Writes the combined string to `src/tools/<name>.jl`
# Important Notes
- The `executeCode` string is embedded literally into the generated tool.
Use `args["param_name"]` to access input parameters.
- The code string should be the function body (NOT wrapped in a function).
Lines will be indented with 4 spaces inside the execute function.
- Tool names must be valid Julia identifiers (lowercase letters, digits, underscores,
no leading digits or special characters).
"""
"""
Validate that a tool name is a valid Julia identifier.
"""
function validateToolName(name::String)::Union{Nothing,String}
if !occursin(r"^[a-zA-Z_][a-zA-Z0-9_!]*$", name)
return "Invalid tool name: '$name'. Tool names must be valid Julia identifiers (letters, digits, underscores, starting with a letter or underscore)."
end
return nothing
end
"""
Indent a multi-line code string by the specified number of spaces.
"""
function indent_code(code::String, n::Int)::String
prefix = " "^n
lines = split(code, '\n')
result_lines = String[prefix * line for line in lines]
return join(result_lines, "\n")
end
"""
Convert a Julia Dict to a valid Julia Dict{String,Any}(...) literal string.
"""
function dict_to_julia_literal(d)::String
if d isa Dict
items = String[]
for (k, v) in d
key_str = json_string(k)
val_str = value_to_julia(v)
push!(items, "$key_str => $val_str")
end
return "Dict{String,Any}(" * join(items, ", ") * ")"
else
return value_to_julia(d)
end
end
function value_to_julia(v)::String
if v isa Dict
return dict_to_julia_literal(v)
elseif v isa Vector
items = [value_to_julia(x) for x in v]
return "[" * join(items, ", ") * "]"
elseif v isa String
escaped = replace(v, "\\" => "\\\\")
escaped = replace(escaped, "\"" => "\\\"")
return "\"$escaped\""
elseif v isa Number
return string(v)
elseif v isa Bool
return string(v)
elseif v === nothing
return "nothing"
else
return "\"$(v)\""
end
end
"""
Convert any Julia value to a JSON string.
"""
function json_string(v)::String
return JSON.json(v)
end
"""
Define and return the writeTool agentTool.
"""
function writeToolTool()::agentTool
return agentTool(
name = "writeTool",
label = "Create Tool",
description = "Write a new Julia tool module file to src/tools/<name>.jl. The LLM provides the tool logic as executeCode; writeTool wraps it in Julia boilerplate and writes the file. Restart the agent to load the new tool.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"name" => Dict("type" => "string", "description" => "Unique tool name (valid Julia identifier, no spaces or special chars)"),
"label" => Dict("type" => "string", "description" => "Human-readable tool name shown in tool descriptions"),
"description" => Dict("type" => "string", "description" => "What the tool does (shown to LLM for tool selection decisions)"),
"inputSchema" => Dict(
"type" => "object",
"description" => "JSON Schema describing tool parameters in MCP format"
),
"executeCode" => Dict("type" => "string", "description" => "Julia code for the execute function body. Use args[\"key\"] to access parameters. Do NOT wrap in a function definition."),
"validateCode" => Dict("type" => "string", "optional" => true, "description" => "Optional custom validation Julia code (runs before execute). Use args[\"key\"] to access parameters. Return nothing to pass, or a string error message to fail."),
"prepareCode" => Dict("type" => "string", "optional" => true, "description" => "Optional argument preparation code (runs before validation). Return modified args dict."),
"parallel" => Dict("type" => "boolean", "default" => false, "description" => "Whether this tool can run in parallel with other tools")
),
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
),
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult, llmCall=nothing) -> begin
tool_name = get(args, "name", "")::String
tool_label = get(args, "label", tool_name)::String
tool_description = get(args, "description", "")::String
tool_schema = get(args, "inputSchema", Dict{String,Any}())::Dict{String,Any}
execute_code = get(args, "executeCode", "")::String
validate_code = get(args, "validateCode", nothing)::Union{String,Nothing}
prepare_code = get(args, "prepareCode", nothing)::Union{String,Nothing}
parallel = get(args, "parallel", false)::Bool
# Validate tool name
name_err = validateToolName(tool_name)
if name_err !== nothing
return agentToolResult(
[textContent(name_err)],
Dict{Any,Any}(), nothing, false
)
end
# Validate required fields
if isempty(tool_name)
return agentToolResult(
[textContent("Missing required field: 'name'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(tool_description)
return agentToolResult(
[textContent("Missing required field: 'description'")],
Dict{Any,Any}(), nothing, false
)
end
if isempty(execute_code)
return agentToolResult(
[textContent("Missing required field: 'executeCode'")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Generating tool: $tool_name"))
# Build the tool file path
script_dir = dirname(@__FILE__)
tools_dir = dirname(script_dir)
filepath = joinpath(tools_dir, "$(tool_name).jl")
# Check for naming conflicts
if isfile(filepath)
return agentToolResult(
[textContent("Tool file already exists: $filepath. Rename the tool or delete the existing file first.")],
Dict{Any,Any}(), nothing, false
)
end
onPartialResult(Dict("status" => "Writing file: $(basename(filepath))"))
# Convert schema Dict to a Julia Dict literal string
schema_literal = dict_to_julia_literal(tool_schema)
# Build optional validation function
validate_section = if validate_code !== nothing && !isempty(validate_code)
indented = indent_code(validate_code, 4)
"function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}\n$indented\n return nothing\nend\n"
else
""
end
# Build optional prepare function
prepare_section = if prepare_code !== nothing && !isempty(prepare_code)
indented = indent_code(prepare_code, 4)
"function prepareArguments(args::Dict{String,Any})::Dict{String,Any}\n$indented\n return args\nend\n"
else
""
end
# Indent user's execute code for embedding inside execute function body
indented_exec = indent_code(execute_code, 4)
# Escape description for Julia string literal
escaped_desc = replace(tool_description, "\\" => "\\\\")
escaped_desc = replace(escaped_desc, "\"" => "\\\"")
# Build the complete tool file content
parts = String[]
push!(parts, "# Auto-generated tool: $tool_name\n")
push!(parts, "# Generated by writeTool at $(now())\n\n")
if !isempty(validate_section)
push!(parts, validate_section)
push!(parts, "\n")
end
if !isempty(prepare_section)
push!(parts, prepare_section)
push!(parts, "\n")
end
push!(parts, "\n")
push!(parts, "# Execute function\n")
push!(parts, "function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult\n")
push!(parts, "$indented_exec\n")
push!(parts, "end\n\n")
push!(parts, "# Tool definition\n")
push!(parts, "function getTool()::agentTool\n")
push!(parts, " return agentTool(\n")
push!(parts, " name = \"$(tool_name)\",\n")
push!(parts, " label = \"$(tool_label)\",\n")
push!(parts, " description = \"$(escaped_desc)\",\n")
push!(parts, " inputSchema = $schema_literal,\n")
push!(parts, " execute = executeTool,\n")
if validate_code !== nothing && !isempty(validate_code)
push!(parts, " validateRequiredArgs = validateRequiredArgs,\n")
else
push!(parts, " validateRequiredArgs = nothing,\n")
end
if prepare_code !== nothing && !isempty(prepare_code)
push!(parts, " prepareArguments = prepareArguments,\n")
else
push!(parts, " prepareArguments = nothing,\n")
end
push!(parts, " parallelToolExecute = $parallel\n")
push!(parts, " )\n")
push!(parts, "end\n")
tool_code = join(parts)
# Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools()
write(filepath, tool_code)
onPartialResult(Dict("status" => "Done"))
return agentToolResult(
[textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
"label" => tool_label,
"description" => tool_description,
),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
+8 -8
View File
@@ -487,13 +487,13 @@ Context passed to the `beforeToolCall` hook.
# Arguments
- `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call being prepared
- `args::Dict{String,Any}`: Validated tool arguments
- `args::AbstractDict{String, Any}`: Validated tool arguments
- `context::agentContext`: Current conversation context
"""
struct beforeToolCallContext
message::assistantMessageToolCall
toolCall::agentToolCall
args::Dict{String,Any}
args::AbstractDict{String, Any}
context::agentContext
end
@@ -508,7 +508,7 @@ Context passed to the `afterToolCall` hook.
# Arguments
- `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call that was executed
- `args::Dict{String,Any}`: Tool arguments
- `args::AbstractDict{String, Any}`: Tool arguments
- `result::agentToolResult`: The raw tool result
- `isError::Bool`: Whether execution resulted in an error
- `context::agentContext`: Current conversation context
@@ -516,7 +516,7 @@ Context passed to the `afterToolCall` hook.
struct afterToolCallContext
message::assistantMessageToolCall
toolCall::agentToolCall
args::Dict{String,Any}
args::AbstractDict{String, Any}
result::agentToolResult
isError::Bool
context::agentContext
@@ -528,12 +528,12 @@ Event emitted when a tool call execution starts.
# Arguments
- `toolCallId::String`: ID of the tool call
- `toolName::String`: Name of the tool
- `arguments::Dict{String,Any}`: Tool arguments
- `arguments::AbstractDict{String, Any}`: Tool arguments
"""
struct toolExecStartEvent
toolCallId::String
toolName::String
arguments::Dict{String,Any}
arguments::AbstractDict{String, Any}
end
"""
@@ -542,13 +542,13 @@ Event emitted with partial results during tool execution.
# Arguments
- `toolCallId::String`: ID of the tool call
- `toolName::String`: Name of the tool
- `arguments::Dict{String,Any}`: Tool arguments
- `arguments::AbstractDict{String, Any}`: Tool arguments
- `partialResult::Any`: The partial result data
"""
struct toolExecUpdateEvent
toolCallId::String
toolName::String
arguments::Dict{String,Any}
arguments::AbstractDict{String, Any}
partialResult::Any
end
+19 -19
View File
@@ -3,7 +3,7 @@ module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
beforeToolCall, afterToolCall, agentEventSink
beforeToolCall, afterToolCall, eventSink
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
using GeneralUtils
@@ -109,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
# end
```
"""
function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext
function prepareContext(state::agentState, eventSink, llmCall=nothing)::agentContext
#TODO filter tools from state.tools based on user intend in user message and tool description
filteredTools = state.tools
@@ -156,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
])
```
"""
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
""" openai message format example
msg = Dict(
@@ -208,7 +208,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
openaiReadyMsg = Dict{String, Any}()
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
messages = Vector{Dict{String, Any}}()
agentEventSink("formatMsgForLLM 1")
eventSink("formatMsgForLLM 1")
# System prompt as system message
if !isempty(ctx.systemPrompt)
push!(messages, Dict(
@@ -216,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
))
end
agentEventSink("formatMsgForLLM 2")
eventSink("formatMsgForLLM 2")
# Conversation messages
for msg in ctx.messages
if msg isa userMessage
@@ -229,10 +229,10 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
push!(messages, _toolResultMessageToOpenAI(msg))
end
end
agentEventSink("formatMsgForLLM 3")
eventSink("formatMsgForLLM 3")
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, agentEventSink)
agentEventSink("formatMsgForLLM 4")
tools_array = _toolsToOpenAI(ctx.tools, eventSink)
eventSink("formatMsgForLLM 4")
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
@@ -321,7 +321,7 @@ end
#TODO
function agentEventSink(x)
function eventSink(x)
end
@@ -375,7 +375,7 @@ function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{
)
end
"""
"""
Convert an assistantMessage to OpenAI message format.
"""
function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any}
@@ -437,10 +437,10 @@ _toolsToOpenAI(nothing) # => Dict{String, Any}[]
_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))]
```
"""
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, agentEventSink)::Vector{Dict{String, Any}}
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, eventSink)::Vector{Dict{String, Any}}
tools_array = Vector{Dict{String, Any}}()
agentEventSink("_toolsToOpenAI 1")
agentEventSink(string(typeof(tools)))
eventSink("_toolsToOpenAI 1")
eventSink(string(typeof(tools)))
if tools !== nothing
for (_, tool) in tools
push!(tools_array, Dict(
@@ -453,13 +453,13 @@ function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, a
))
end
end
agentEventSink("_toolsToOpenAI 2")
eventSink("_toolsToOpenAI 2")
return tools_array
end
"""
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{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
@@ -470,8 +470,8 @@ 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)
- `args::AbstractDict{String, Any}`: The arguments provided by the LLM
- `inputSchema::AbstractDict{String, Any}`: The tool's `inputSchema` (JSON Schema format)
# Returns
- `nothing` if all required args are present
@@ -487,7 +487,7 @@ args2 = Dict("city" => "Tokyo")
validateRequiredArgs(args2, schema) # => nothing
```
"""
function validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any})::Union{Nothing,String}
function validateRequiredArgs(args::AbstractDict{String, Any}, inputSchema::AbstractDict{String, Any})::Union{Nothing,String}
required = get(inputSchema, "required", Any[])
if isempty(required)
return nothing
@@ -541,7 +541,7 @@ validateToolArguments(toolWithHook, tc) # => validated args or throws
validateToolArguments(toolDefault, tc) # => args or throws
```
"""
function validateToolArguments(tool::agentTool, prepared::agentToolCall)::Dict{String,Any}
function validateToolArguments(tool::agentTool, prepared::agentToolCall)::AbstractDict{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)
-367
View File
@@ -1,367 +0,0 @@
using Test
using Dates
using YiemAgent
using YiemAgent.toolRegistry
using YiemAgent.type
using YiemAgent.agentCore
@testset "register_all_tools with toolStore" begin
# ------------------------------------------------------------------ #
# 1. register_all_tools registers all static tools + listTools #
# ------------------------------------------------------------------ #
store = toolStore(name="test1")
loaded = register_all_tools(store)
@test !isempty(loaded)
@test length(loaded) == 4 # getWeather + getTime + writeTool + listTools
names = [k for k in keys(loaded)]
@test "getTime" in names
@test "getWeather" in names
@test "writeTool" in names
@test "listTools" in names
# ------------------------------------------------------------------ #
# 2. register_all_tools returns tools in registration order #
# ------------------------------------------------------------------ #
@test collect(keys(loaded))[1] == "getWeather"
@test collect(keys(loaded))[2] == "getTime"
@test collect(keys(loaded))[3] == "writeTool"
@test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ #
# 3. Verify loaded tool fields are correct #
# ------------------------------------------------------------------ #
# getTime
time_tool = loaded["getTime"]
@test time_tool.name == "getTime"
@test time_tool.label == "Time Lookup"
@test time_tool.validateRequiredArgs !== nothing
@test time_tool.parallelToolExecute == false
@test time_tool.inputSchema["required"] == Any[]
# getWeather
weather = loaded["getWeather"]
@test weather.name == "getWeather"
@test weather.label == "Weather Lookup"
@test weather.execute !== nothing
@test weather.parallelToolExecute == false
@test weather.inputSchema["required"] == ["city"]
# writeTool
wt = loaded["writeTool"]
@test wt.name == "writeTool"
@test wt.label == "Create Tool"
@test wt.execute !== nothing
@test "name" in wt.inputSchema["required"]
@test "executeCode" in wt.inputSchema["required"]
# ------------------------------------------------------------------ #
# 4. Tool execution returns valid results #
# ------------------------------------------------------------------ #
sig = nothing
op = x -> x # no-op partial result callback
# execute getTime
result_t = time_tool.execute("call-1", Dict{String,Any}("city" => "Tokyo"), sig, op)
@test result_t isa agentToolResult
@test result_t.content[1] isa textContent
@test occursin("Tokyo", result_t.content[1].text)
# execute getTime with timezone
result_tz = time_tool.execute("call-2", Dict{String,Any}("timezone" => "America/New_York"), sig, op)
@test result_tz isa agentToolResult
@test occursin("America/New_York", result_tz.content[1].text)
# execute getWeather
result_w = weather.execute("call-3", Dict{String,Any}("city" => "Bangkok"), sig, op)
@test result_w isa agentToolResult
@test result_w.content[1] isa textContent
@test occursin("Bangkok", result_w.content[1].text)
# execute getWeather with units
result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op)
@test occursin("72\u00b0F", result_w2.content[1].text)
# ------------------------------------------------------------------ #
# 5. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ #
store3 = toolStore(name="test3")
registry_tools = getTools(store3)
@test isempty(registry_tools)
# Register tools manually
registerTool(store3, loaded["getTime"])
registerTool(store3, loaded["getWeather"])
registerTool(store3, loaded["writeTool"])
reg = getTools(store3)
@test !isempty(reg)
@test "getTime" in keys(reg)
@test "getWeather" in keys(reg)
@test "writeTool" in keys(reg)
@test collect(keys(reg))[1] == "getTime"
@test collect(keys(reg))[2] == "getWeather"
@test collect(keys(reg))[3] == "writeTool"
clearTools(store3)
@test isempty(getTools(store3))
test_tool = agentTool(
name = "manualTool",
label = "Manual Tool",
description = "Registered manually",
inputSchema = Dict{String,Any}("type" => "object", "properties" => Dict{String,Any}(), "required" => Any[]),
execute = (toolCallId, args, signal, onPartialResult) ->
agentToolResult([textContent("manual")], Dict{Any,Any}(), nothing, false),
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = true
)
registerTool(store3, test_tool)
reg = getTools(store3)
@test haskey(reg, "manualTool")
@test length(reg) == 1
@test reg["manualTool"].parallelToolExecute == true
# ------------------------------------------------------------------ #
# 6. getTools returns direct reference (mutations affect registry) #
# ------------------------------------------------------------------ #
copy1 = getTools(store3)
copy2 = getTools(store3)
@test copy1 === copy2 # same reference, not a deep copy
empty!(copy1)
@test isempty(getTools(store3)) # mutation propagates
# ------------------------------------------------------------------ #
# 7. Per-store isolation — two stores don't share tools #
# ------------------------------------------------------------------ #
storeA = toolStore(name="isolationA")
storeB = toolStore(name="isolationB")
registerTool(storeA, loaded["getTime"])
registerTool(storeB, loaded["getWeather"])
regA = getTools(storeA)
regB = getTools(storeB)
@test "getTime" in keys(regA)
@test "getWeather" keys(regA)
@test "getWeather" in keys(regB)
@test "getTime" keys(regB)
clearTools(storeA)
@test isempty(getTools(storeA))
@test !isempty(getTools(storeB)) # storeB unaffected
end
@testset "listTool" begin
store = toolStore(name="test_list")
register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools
# register_all_tools auto-registers listTool
@test "listTools" in keys(store.tools)
# listTool returns an agentTool, not a string or array
list_t = listTool(store)
@test list_t isa agentTool
@test list_t.name == "listTools"
@test list_t.label == "List Tools"
@test isempty(list_t.inputSchema["required"])
# Verify all tools appear (3 loaded + listTools = 4)
result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x)
@test result isa agentToolResult
@test result.content[1] isa textContent
@test occursin("listTools", result.content[1].text)
@test occursin("getWeather", result.content[1].text)
@test occursin("getTime", result.content[1].text)
@test occursin("writeTool", result.content[1].text)
@test result.details["count"] == 4
# Each listTool call creates an independent closure
storeB = toolStore(name="test_listB")
registerTool(storeB, store.tools["getWeather"])
list_tB = listTool(storeB)
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x)
@test occursin("getWeather", resultA.content[1].text)
@test occursin("getWeather", resultB.content[1].text)
@test occursin("getTime", resultA.content[1].text)
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
end
@testset "executePreparedToolCall with static tools" begin
# Tests executePreparedToolCall with statically loaded tools.
# The world-age issue is resolved because tool.execute comes from
# a statically included module, not a dynamically created one.
store = toolStore(name="test_static")
register_all_tools(store)
weather_tool = store.tools["getWeather"]
# Create a preparedToolCall that mimics what prepareToolCall() returns
tool_call = agentToolCall(
"function", "call-static-1", "getWeather",
Dict{String,Any}("city" => "San Francisco")
)
prep = preparedToolCall(
weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco")
)
sig = abortSignal(false)
# This call goes through: executePreparedToolCall -> prep.tool.execute(...)
result = executePreparedToolCall(
prep, sig, x -> nothing
)
@test result isa executedOutcome
@test result.isError == false
@test result.result.content[1] isa textContent
@test occursin("San Francisco", result.result.content[1].text)
end
@testset "executePreparedToolCall with validation (static tools)" begin
# Tests executePreparedToolCall with a tool that has custom validation hooks.
# This exercises the full tool execution path including validation.
store = toolStore(name="test_static_validate")
register_all_tools(store)
time_tool = store.tools["getTime"]
tool_call = agentToolCall(
"function", "call-static-2", "getTime",
Dict{String,Any}("timezone" => "America/New_York")
)
prep = preparedToolCall(
time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York")
)
sig = abortSignal(false)
result = executePreparedToolCall(
prep, sig, x -> nothing
)
@test result isa executedOutcome
@test result.isError == false
@test result.result.content[1] isa textContent
@test occursin("America/New_York", result.result.content[1].text)
end
@testset "executeToolCallsSequential with static tools (full pipeline)" begin
# Tests the full tool execution pipeline: executeToolCallsSequential
# which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall
# with statically loaded tools.
store = toolStore(name="test_full_pipeline")
register_all_tools(store)
# Build agentContext from the store's tools
tools = getTools(store)
ctx = agentContext(
"test system prompt",
agentMessage[],
tools
)
# Create an assistant message containing tool calls
assistant_msg = assistantMessage(
role="assistant",
content=Vector{messageContent}(),
api="openai",
provider="test",
model="test-model",
usage=llmUsage(0, 0),
stopReason="tool_calls",
errorMessage=nothing,
timestamp=now()
)
# Create tool calls for multiple statically loaded tools
tool_calls = [
agentToolCall(
"function", "call-seq-1", "getWeather",
Dict{String,Any}("city" => "Tokyo")
),
agentToolCall(
"function", "call-seq-2", "getTime",
Dict{String,Any}("timezone" => "Europe/London")
),
]
config = agentLoopConfig(
nothing, nothing, "sequential"
)
sig = abortSignal(false)
# Execute the full pipeline
batch = executeToolCallsSequential(
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
)
@test batch.messages isa Vector{toolResultMessage}
@test length(batch.messages) == 2
@test batch.messages[1].toolName == "getWeather"
@test batch.messages[1].isError == false
@test occursin("Tokyo", batch.messages[1].content[1].text)
@test batch.messages[2].toolName == "getTime"
@test batch.messages[2].isError == false
@test occursin("Europe/London", batch.messages[2].content[1].text)
end
@testset "executeToolCallsParallel with static tools (full pipeline)" begin
# Same as above but tests parallel execution path.
store = toolStore(name="test_parallel")
register_all_tools(store)
tools = getTools(store)
ctx = agentContext(
"test system prompt",
agentMessage[],
tools
)
assistant_msg = assistantMessage(
role="assistant",
content=Vector{messageContent}(),
api="openai",
provider="test",
model="test-model",
usage=llmUsage(0, 0),
stopReason="tool_calls",
errorMessage=nothing,
timestamp=now()
)
tool_calls = [
agentToolCall(
"function", "call-par-1", "getWeather",
Dict{String,Any}("city" => "Paris")
),
agentToolCall(
"function", "call-par-2", "getTime",
Dict{String,Any}("city" => "Sydney")
),
]
config = agentLoopConfig(
nothing, nothing, "parallel"
)
sig = abortSignal(false)
batch = executeToolCallsParallel(
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
)
@test batch.messages isa Vector{toolResultMessage}
@test length(batch.messages) == 2
@test batch.messages[1].toolName == "getWeather"
@test batch.messages[1].isError == false
@test batch.messages[2].toolName == "getTime"
@test batch.messages[2].isError == false
end
+5 -5
View File
@@ -32,7 +32,7 @@ catch e
println(io)
end
agentEventSink(err_msg)
eventSink(err_msg)
end
"""
@@ -66,13 +66,13 @@ end
struct agentEventSink
struct eventSink
natsConn::NATS.Connection
topic::String
senderID::String
end
function (aes::agentEventSink)(msg::String)
function (aes::eventSink)(msg::String)
NATS.publish(aes.natsConn, aes.topic, msg)
end
@@ -88,11 +88,11 @@ text2text_llm = text2textInstructLLM(agent_conn,
"sender",
config["externalservice"]["fileserver"]["url"])
debugNats = agentEventSink(agent_conn, "sommanion.debug", "sender")
debugNats = eventSink(agent_conn, "sommanion.debug", "sender")
agent = YiemAgent.yiemAgent(
text2text_llm;
agentEventSink=debugNats
eventSink=debugNats
)
msg = Dict(