Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f284883b3 | |||
| d2592ab6d6 | |||
| da21790263 | |||
| c59f6bfa61 | |||
| 3c91222462 | |||
| f62b8f14e7 | |||
| 7ffb720f86 |
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 `promptAgent(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 (promptAgent, 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 |
|
||||
|----------|-------------|
|
||||
| `promptAgent(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 `promptAgent()` 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 |
|
||||
|
||||
+35
-35
@@ -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 |
|
||||
@@ -126,7 +126,7 @@ result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)
|
||||
|
||||
**Via agent loop (production):**
|
||||
```
|
||||
user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
|
||||
user message → promptAgent(agent, Dict("role"=>"user", "content"=>...))
|
||||
→ _agentLoop detects message → @spawn _processMessage(agent)
|
||||
→ prepareContext → formatMsgForLLM → llmCall
|
||||
→ LLM returns tool_calls
|
||||
@@ -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
|
||||
```
|
||||
@@ -346,7 +346,7 @@ The `_agentLoop()` function runs as a background `@spawn` task, created when `yi
|
||||
|
||||
```
|
||||
yiemAgent struct contains:
|
||||
- inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
|
||||
- inputChannel (Channel, capacity 16) ← user sends messages here via promptAgent()
|
||||
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
|
||||
- outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
|
||||
```
|
||||
@@ -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
|
||||
@@ -1243,7 +1243,7 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT
|
||||
|
||||
```
|
||||
USER SENDS MESSAGE
|
||||
└─> runAgent(agent, "What's the weather in Tokyo?")
|
||||
└─> promptAgent(agent, "What's the weather in Tokyo?")
|
||||
└─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
+33
-66
@@ -1,66 +1,33 @@
|
||||
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
|
||||
|
||||
export promptAgent, takeResponse, followUp, stopAgent
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------- 100 --------------------------------------------- #
|
||||
|
||||
|
||||
|
||||
|
||||
end # module YiemAgent_v1
|
||||
|
||||
+273
-180
@@ -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,20 +179,21 @@ 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)
|
||||
followUp = Channel(32)
|
||||
outputChannel = Channel(16)
|
||||
|
||||
# load tools (statically registered at module init)
|
||||
toolStore1 = toolStore(name="myagent")
|
||||
register_all_tools(toolStore1)
|
||||
|
||||
tools = OrderedDict{String, agentTool}()
|
||||
registerAllTools(tools, mcpServer; eventSink=eventSink)
|
||||
|
||||
# Create struct with a placeholder task, then spawn and replace it
|
||||
agent = yiemAgent(
|
||||
agentState(systemPrompt, model, getTools(toolStore1), messages),
|
||||
agentState(systemPrompt, model, tools, messages),
|
||||
inputChannel,
|
||||
followUp,
|
||||
outputChannel,
|
||||
@@ -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
|
||||
|
||||
@@ -359,113 +442,124 @@ 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))")
|
||||
newUserMsg_openai = take!(inputChannel)
|
||||
agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))")
|
||||
if newUserMsg_openai === :shutdown
|
||||
agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))")
|
||||
# Re-emit shutdown signal for the loop to handle
|
||||
put!(inputChannel, :shutdown)
|
||||
break
|
||||
try
|
||||
# Drain inputChannel and convert OpenAI-format messages to userMessage type
|
||||
while isready(inputChannel)
|
||||
eventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))")
|
||||
newUserMsg_openai = take!(inputChannel)
|
||||
eventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))")
|
||||
if newUserMsg_openai === :shutdown
|
||||
eventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))")
|
||||
# Re-emit shutdown signal for the loop to handle
|
||||
put!(inputChannel, :shutdown)
|
||||
break
|
||||
end
|
||||
eventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))")
|
||||
newUserMsg = OpenAiToUserMessage(newUserMsg_openai)
|
||||
push!(agentMsgHistory, newUserMsg)
|
||||
eventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))")
|
||||
end
|
||||
agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))")
|
||||
newUserMsg = OpenAiToUserMessage(newUserMsg_openai)
|
||||
push!(agentMsgHistory, newUserMsg)
|
||||
agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))")
|
||||
end
|
||||
agentEventSink("_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))")
|
||||
# Call formatMessagesForLLM() to format for LLM
|
||||
formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink)
|
||||
|
||||
agentEventSink("_processMessage 10 formattedMessages $formattedMessages")
|
||||
eventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))")
|
||||
# call prepareContext()
|
||||
state = agentState(systemPrompt, nothing, tools, agentMsgHistory)
|
||||
eventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))")
|
||||
preparedContext = prepareContext(state, eventSink)
|
||||
eventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))")
|
||||
# Call formatMessagesForLLM() to format for LLM
|
||||
formattedMessages = formatMessagesForLLM(preparedContext, eventSink)
|
||||
|
||||
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))
|
||||
""" 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)
|
||||
eventSink("LLM response " * string(response))
|
||||
|
||||
agentEventSink("_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("_processMessage 11 _state.messages length $(length(agentMsgHistory))")
|
||||
# Extract tool calls from LLM response content blocks
|
||||
hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response)
|
||||
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)
|
||||
# Add assistant message (tool calls or text) to history for next LLM turn
|
||||
push!(agentMsgHistory, assistant_msg)
|
||||
|
||||
if hasToolCalls && length(toolCallList) > 0
|
||||
# Build context and config for executeToolCalls
|
||||
if hasToolCalls && length(toolCallList) > 0
|
||||
# Build context and config for executeToolCalls
|
||||
|
||||
config = agentLoopConfig(
|
||||
beforeToolCall,
|
||||
afterToolCall,
|
||||
parallelToolExecute ? "parallel" : "sequential",
|
||||
llmCall,
|
||||
)
|
||||
config = agentLoopConfig(
|
||||
beforeToolCall,
|
||||
afterToolCall,
|
||||
parallelToolExecute ? "parallel" : "sequential",
|
||||
)
|
||||
|
||||
signal = abortSignal(false)
|
||||
agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))")
|
||||
signal = abortSignal(false)
|
||||
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))")
|
||||
# call executeToolCalls()
|
||||
toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config,
|
||||
signal, eventSink)
|
||||
eventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))")
|
||||
|
||||
# save toolResults to messages
|
||||
for toolResult in toolResultBatch.messages
|
||||
agentEventSink("toolResult " * string(toolResult))
|
||||
push!(agentMsgHistory, toolResult)
|
||||
end
|
||||
agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))")
|
||||
if toolResultBatch.terminate
|
||||
agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))")
|
||||
# If toolResultBatch requested termination, build a final response
|
||||
final_content = [textContent("Tool execution completed.")]
|
||||
# save toolResults to messages
|
||||
for toolResult in toolResultBatch.messages
|
||||
for content_block in toolResult.content
|
||||
if content_block isa textContent
|
||||
append!(final_content, [content_block])
|
||||
elseif content_block isa Dict
|
||||
if haskey(content_block, :text)
|
||||
push!(final_content, textContent(content_block[:text]))
|
||||
eventSink("toolResult " * string(toolResult))
|
||||
push!(agentMsgHistory, toolResult)
|
||||
end
|
||||
eventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))")
|
||||
if toolResultBatch.terminate
|
||||
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
|
||||
for content_block in toolResult.content
|
||||
if content_block isa textContent
|
||||
append!(final_content, [content_block])
|
||||
elseif content_block isa Dict
|
||||
if haskey(content_block, :text)
|
||||
push!(final_content, textContent(content_block[:text]))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
eventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))")
|
||||
final_response = assistantMessage(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
api=assistant_msg.api,
|
||||
model=assistant_msg.model,
|
||||
usage=assistant_msg.usage,
|
||||
stopReason="tool_use_terminated",
|
||||
errorMessage=if any(x -> x.isError, toolResultBatch.messages)
|
||||
"One or more tool calls failed"
|
||||
else
|
||||
nothing
|
||||
end,
|
||||
timestamp=now(),
|
||||
)
|
||||
push!(agentMsgHistory, final_response)
|
||||
break
|
||||
end
|
||||
agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))")
|
||||
final_response = assistantMessage(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
api=assistant_msg.api,
|
||||
model=assistant_msg.model,
|
||||
usage=assistant_msg.usage,
|
||||
stopReason="tool_use_terminated",
|
||||
errorMessage=if any(x -> x.isError, toolResultBatch.messages)
|
||||
"One or more tool calls failed"
|
||||
else
|
||||
nothing
|
||||
end,
|
||||
timestamp=now(),
|
||||
)
|
||||
push!(agentMsgHistory, final_response)
|
||||
else
|
||||
eventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))")
|
||||
# LLM did not use tool calls —
|
||||
break
|
||||
end
|
||||
else
|
||||
agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))")
|
||||
# LLM did not use tool calls —
|
||||
break
|
||||
catch e
|
||||
bt = catch_backtrace()
|
||||
err_msg = sprint() do io
|
||||
showerror(io, e, bt)
|
||||
println(io)
|
||||
end
|
||||
|
||||
eventSink(err_msg)
|
||||
end
|
||||
end
|
||||
agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))")
|
||||
|
||||
|
||||
eventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))")
|
||||
return nothing
|
||||
end
|
||||
|
||||
@@ -903,45 +997,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 +1047,7 @@ function prepareToolCall(
|
||||
println(io)
|
||||
end
|
||||
|
||||
agentEventSink(errMsg)
|
||||
eventSink(errMsg)
|
||||
|
||||
return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true)
|
||||
end
|
||||
@@ -1000,17 +1097,15 @@ executePreparedToolCall(prep, nothing, emit)
|
||||
function executePreparedToolCall(
|
||||
prep::preparedToolCall,
|
||||
signal::Union{Nothing,abortSignal},
|
||||
agentEventSink,
|
||||
llmCall::Union{Any,Nothing}=nothing,
|
||||
eventSink,
|
||||
)::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)
|
||||
eventSink("executePreparedToolCall 2")
|
||||
eventSink("tool execute raw result: " * sprint(show, result))
|
||||
eventSink("executePreparedToolCall 3")
|
||||
return executedOutcome(result, false)
|
||||
catch e
|
||||
bt = catch_backtrace()
|
||||
@@ -1018,7 +1113,7 @@ function executePreparedToolCall(
|
||||
showerror(io, e, bt)
|
||||
println(io)
|
||||
end
|
||||
agentEventSink(errMsg)
|
||||
eventSink(errMsg)
|
||||
|
||||
return executedOutcome(createErrorToolResult(sprint(showerror, e)), true)
|
||||
end
|
||||
@@ -1088,19 +1183,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 +1209,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 +1279,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")
|
||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config,
|
||||
signal, agentEventSink)
|
||||
agentEventSink("executeToolCallsSequential 3-2")
|
||||
eventSink("executeToolCallsSequential 3")
|
||||
executed = executePreparedToolCall(prep, signal, eventSink)
|
||||
|
||||
eventSink("executeToolCallsSequential 3-1")
|
||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config,
|
||||
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 +1380,26 @@ 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)
|
||||
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 +1482,10 @@ 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 +1494,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
|
||||
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
module api
|
||||
|
||||
export prompt
|
||||
export promptAgent, takeResponse, followUp, stopAgent
|
||||
|
||||
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
|
||||
DataFrames
|
||||
@@ -18,7 +18,7 @@ The agent processes messages from `inputChannel` in the background task.
|
||||
|
||||
# Arguments
|
||||
- `agent::yiemAgent`: The agent instance to send a message to
|
||||
- `msg`: The message to send (any type accepted by the agent's processing pipeline)
|
||||
- `msg`: The message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Hello!")`)
|
||||
|
||||
# Returns
|
||||
- The same `agent` instance for chaining
|
||||
@@ -29,11 +29,11 @@ The agent processes messages from `inputChannel` in the background task.
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
julia> runAgent(agent, "Hello!")
|
||||
julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!"))
|
||||
yiemAgent(...)
|
||||
```
|
||||
"""
|
||||
function runAgent(agent::yiemAgent, msg)
|
||||
function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any})
|
||||
put!(agent.inputChannel, msg)
|
||||
return agent
|
||||
end
|
||||
@@ -50,7 +50,7 @@ Blocks until the agent sends a response.
|
||||
- An `assistantMessage` instance representing the agent's response
|
||||
|
||||
# Notes
|
||||
- Use `runAgent(agent, msg)` to send a message before calling this function.
|
||||
- Use `promptAgent(agent, msg)` to send a message before calling this function.
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
@@ -70,23 +70,23 @@ and before any tool call results are sent.
|
||||
|
||||
# Arguments
|
||||
- `agent::yiemAgent`: The agent instance to send a follow-up message to
|
||||
- `msg`: The follow-up message to send
|
||||
- `msg`: The follow-up message to send, in OpenAI message format (e.g. `Dict("role" => "user", "content" => "Also consider red wines")`)
|
||||
|
||||
# Returns
|
||||
- The same `agent` instance for chaining
|
||||
|
||||
# Notes
|
||||
- Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional
|
||||
- Use `promptAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional
|
||||
messages while the agent is processing.
|
||||
- Follow-up messages are buffered in a separate channel (capacity 32 by default).
|
||||
|
||||
# Examples
|
||||
```jldoctest
|
||||
julia> followUp(agent, "Also consider red wines")
|
||||
julia> followUp(agent, Dict("role" => "user", "content" => "Also consider red wines"))
|
||||
yiemAgent(...)
|
||||
```
|
||||
"""
|
||||
function followUp(agent::yiemAgent, msg)
|
||||
function followUp(agent::yiemAgent, msg::AbstractDict{String, Any})
|
||||
put!(agent.followUpChannel, msg)
|
||||
return agent
|
||||
end
|
||||
|
||||
+247
-133
@@ -1,179 +1,293 @@
|
||||
module toolRegistry
|
||||
|
||||
export toolStore, registerTool, getTools, clearTools, listTool
|
||||
export registerTool, registerAllTools, clearTools, listTool
|
||||
|
||||
using Dates
|
||||
using JSON, DataStructures
|
||||
using ..type
|
||||
|
||||
"""
|
||||
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.
|
||||
|
||||
# Fields
|
||||
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
|
||||
- `name::String` — identifier for debugging/logs
|
||||
# ── MCP helper functions ────────────────────────────────────────────
|
||||
|
||||
"""
|
||||
struct toolStore
|
||||
tools::OrderedDict{String, agentTool}
|
||||
name::String
|
||||
Extract text from MCP tool result content array.
|
||||
|
||||
Handles JSON-RPC 2.0 result content format:
|
||||
{"content": [{"type": "text", "text": "..."}], "isError": false}
|
||||
"""
|
||||
function _extract_text_content(result::AbstractDict)::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
|
||||
|
||||
"""
|
||||
toolStore(; name="default") -> toolStore
|
||||
Wrap an MCP tool definition as an `agentTool`.
|
||||
|
||||
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")
|
||||
```
|
||||
The returned tool's `execute` function calls the MCP server's "tools/call"
|
||||
method with the validated arguments.
|
||||
"""
|
||||
function toolStore(; name::String="default")::toolStore
|
||||
toolStore(OrderedDict{String, agentTool}(), name)
|
||||
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[],
|
||||
)
|
||||
end
|
||||
|
||||
return agentTool(
|
||||
name=name,
|
||||
label=title,
|
||||
description=desc,
|
||||
inputSchema=params,
|
||||
execute=(toolCallId::String, args::AbstractDict{String, Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
eventSink) -> 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
|
||||
end,
|
||||
prepareArguments=nothing,
|
||||
validateRequiredArgs=nothing,
|
||||
parallelToolExecute=false,
|
||||
)
|
||||
end
|
||||
|
||||
"""
|
||||
listTool(store::toolStore) -> agentTool
|
||||
Discover and register MCP tools into `store.tools`.
|
||||
|
||||
Return an `agentTool` definition for listing registered 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.
|
||||
|
||||
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.
|
||||
# Returns
|
||||
- `Int`: number of new tools registered
|
||||
"""
|
||||
function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::Int
|
||||
if mcpserver === nothing
|
||||
return 0
|
||||
end
|
||||
|
||||
new_count = 0
|
||||
|
||||
try
|
||||
eventSink("register_mcp_tools 1")
|
||||
response = mcpserver("tools/list")
|
||||
eventSink("register_mcp_tools 2")
|
||||
# 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] 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(tools, name)
|
||||
continue
|
||||
end
|
||||
|
||||
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
|
||||
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(tools, name)
|
||||
continue
|
||||
end
|
||||
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink)
|
||||
tools[name] = wrapped
|
||||
new_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(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] MCP tools/list failed: $errMsg")
|
||||
end
|
||||
|
||||
return new_count
|
||||
end
|
||||
|
||||
"""
|
||||
registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||
|
||||
Add `tool` to `tools`, overwriting any existing tool with the same name.
|
||||
|
||||
# Arguments
|
||||
- `store`: The tool store whose tools will be listed when the tool runs
|
||||
- `tools`: Tool dict to modify
|
||||
- `tool`: The `agentTool` to register
|
||||
|
||||
# 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(...)
|
||||
```
|
||||
# Returns
|
||||
- The same `tools` dict (modified in place)
|
||||
"""
|
||||
function listTool(store::toolStore)::agentTool
|
||||
function registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool; eventSink=nothing
|
||||
)::OrderedDict{String, agentTool}
|
||||
tools[tool.name] = tool
|
||||
println("[toolRegistry] Registered tool: $(tool.name)")
|
||||
return tools
|
||||
end
|
||||
|
||||
"""
|
||||
Remove all tools from the dict.
|
||||
|
||||
# Arguments
|
||||
- `tools`: Tool dict to clear
|
||||
|
||||
# Returns
|
||||
- `nothing`
|
||||
"""
|
||||
function clearTools(tools::OrderedDict{String, agentTool})::Nothing
|
||||
empty!(tools)
|
||||
println("[toolRegistry] Registry cleared")
|
||||
return nothing
|
||||
end
|
||||
|
||||
# ── listTools tool (auto-discover new MCP tools at runtime) ─────────
|
||||
|
||||
"""
|
||||
listTool(tools::OrderedDict{String, agentTool}, 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 `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(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing)::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}(
|
||||
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, 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")
|
||||
end
|
||||
execute=(toolCallId::String, args::AbstractDict{String, Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
eventSink) -> begin
|
||||
# Discover and register MCP tools (idempotent — skips already registered)
|
||||
new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink)
|
||||
eventSink("tools dump: " * sprint(show, tools))
|
||||
|
||||
# Always include listTools itself in the count
|
||||
total = length(tools)
|
||||
|
||||
lines = String[
|
||||
"- $(t.name): $(t.label) — $(t.description)"
|
||||
for (k, t) in tools
|
||||
]
|
||||
result_text = "Available tools ($total):\n" * join(lines, "\n")
|
||||
|
||||
return agentToolResult(
|
||||
[textContent(result_text)],
|
||||
Dict{Any,Any}("count" => length(tools)),
|
||||
Dict{Any,Any}("count" => total),
|
||||
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
|
||||
# ── High-level API ──────────────────────────────────────────────────
|
||||
|
||||
"""
|
||||
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||
registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver)
|
||||
|
||||
Add `tool` to `store`, overwriting any existing tool with the same name.
|
||||
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`: Tool store to modify
|
||||
- `tool`: The `agentTool` to register
|
||||
|
||||
# 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(...)
|
||||
```
|
||||
- `tools`: The tool dict to populate
|
||||
- `mcpserver`: A callable struct that communicates with the MCP server
|
||||
"""
|
||||
function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
|
||||
store.tools[tool.name] = tool
|
||||
println("[$(store.name)] Registered tool: $(tool.name)")
|
||||
return store.tools
|
||||
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.
|
||||
|
||||
# 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
|
||||
end
|
||||
|
||||
"""
|
||||
Remove all tools from `store`.
|
||||
|
||||
# Arguments
|
||||
- `store`: Tool store to clear
|
||||
|
||||
# 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)
|
||||
println("[$(store.name)] Registry cleared")
|
||||
return nothing
|
||||
function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing)
|
||||
list_t = listTool(tools, mcpserver; eventSink=eventSink)
|
||||
registerTool(tools, list_t; eventSink=eventSink)
|
||||
end
|
||||
|
||||
end # module
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+11
-13
@@ -23,7 +23,7 @@
|
||||
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
|
||||
agentToolCallBatch,
|
||||
# Functions (defined elsewhere)
|
||||
runAgent, takeResponse, followUp, stopAgent
|
||||
promptAgent, takeResponse, followUp, stopAgent
|
||||
|
||||
|
||||
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
|
||||
@@ -358,7 +358,6 @@ struct agentContext # Snapshot of the agent's conversa
|
||||
systemPrompt::String # System prompt for the agent
|
||||
messages::Vector{agentMessage} # Conversation messages
|
||||
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||
llmCall::Union{Any, Nothing} # LLM call function (for tools that need it)
|
||||
end
|
||||
|
||||
|
||||
@@ -408,7 +407,7 @@ function agentState(
|
||||
agentState(
|
||||
systemPrompt,
|
||||
model,
|
||||
deepcopy(tools),
|
||||
tools,
|
||||
deepcopy(messages),
|
||||
Vector{String}(),
|
||||
nothing,
|
||||
@@ -443,7 +442,7 @@ end
|
||||
"""
|
||||
Configuration for the agent tool execution loop.
|
||||
|
||||
# Arguments
|
||||
# Fields
|
||||
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
||||
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
||||
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
||||
@@ -452,7 +451,6 @@ struct agentLoopConfig
|
||||
beforeToolCall::Union{Function, Nothing}
|
||||
afterToolCall::Union{Function, Nothing}
|
||||
toolExecution::String
|
||||
llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine)
|
||||
end
|
||||
|
||||
"""
|
||||
@@ -487,13 +485,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 +506,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 +514,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 +526,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 +540,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
|
||||
|
||||
|
||||
+22
-22
@@ -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,18 +109,18 @@ prepareContext(state).messages == deepcopy(state.messages)
|
||||
# end
|
||||
```
|
||||
"""
|
||||
function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext
|
||||
|
||||
function prepareContext(state::agentState, eventSink)::agentContext
|
||||
|
||||
#TODO filter tools from state.tools based on user intend in user message and tool description
|
||||
filteredTools = state.tools
|
||||
|
||||
#TODO add filtered tools to the current system prompt / modify systemPrompt here
|
||||
preparedSystemPrompt = state.systemPrompt
|
||||
|
||||
|
||||
#TODO add system prompt, adjust/modify and inject additional context into messages
|
||||
preparedMessages = deepcopy(state.messages) # messages that will be send to LLM
|
||||
|
||||
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools, llmCall)
|
||||
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools)
|
||||
|
||||
return agentCtx
|
||||
end
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user