Compare commits

...

21 Commits

Author SHA1 Message Date
ton 7789748f25 update 2026-08-24 15:16:28 +07:00
ton 9193260539 update 2026-08-24 13:33:20 +07:00
ton 5f284883b3 update 2026-08-21 22:39:51 +07:00
ton d2592ab6d6 mcp works 2026-08-21 22:34:27 +07:00
ton da21790263 update 2026-08-21 13:13:38 +07:00
ton c59f6bfa61 update 2026-08-21 07:13:52 +07:00
ton 3c91222462 update 2026-08-21 07:10:49 +07:00
ton f62b8f14e7 update 2026-08-20 18:45:39 +07:00
ton 7ffb720f86 update mcp definition example 2026-08-20 09:58:55 +07:00
ton 5829c82d05 update 2026-08-17 03:04:10 +07:00
ton c7a98f1710 Merge pull request 'V0.8.0 process message debug' (#44) from v0.8.0-process_message_debug into v0.8.0
Reviewed-on: #44
2026-08-16 13:22:51 +00:00
ton c7abf844ea update 2026-08-16 20:14:54 +07:00
ton 00447e4dde update 2026-08-16 18:25:26 +07:00
ton 25f8468696 text message process works 2026-08-16 17:24:29 +07:00
ton a29a82b74c update 2026-08-16 13:36:01 +07:00
ton fd616409dd update 2026-08-15 20:03:01 +07:00
ton 2543e6cbf1 static tool loading 2026-08-15 16:50:28 +07:00
ton b8067c2d33 update 2026-08-13 18:14:46 +07:00
ton 510cf6126c update 2026-08-13 05:56:08 +07:00
ton 90fb97a4e7 update 2026-08-12 23:47:10 +07:00
ton 77adeb3a6b update 2026-08-12 20:10:51 +07:00
23 changed files with 2448 additions and 5409 deletions
+50 -1
View File
@@ -2,7 +2,7 @@
julia_version = "1.12.6"
manifest_format = "2.0"
project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c"
project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752"
[[deps.Accessors]]
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
@@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
version = "0.4.0"
[[deps.Arrow]]
deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01"
uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
version = "2.8.1"
[[deps.ArrowTypes]]
deps = ["Sockets", "UUIDs"]
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
@@ -58,6 +64,12 @@ version = "1.11.0"
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
version = "1.11.0"
[[deps.BitIntegers]]
deps = ["Random"]
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
version = "0.3.7"
[[deps.BufferedStreams]]
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
@@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
version = "0.3.4"
[[deps.CodecLz4]]
deps = ["Lz4_jll", "TranscodingStreams"]
git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2"
uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
version = "0.4.6"
[[deps.CodecZlib]]
deps = ["TranscodingStreams", "Zlib_jll"]
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
version = "0.7.8"
[[deps.CodecZstd]]
deps = ["TranscodingStreams", "Zstd_jll"]
git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56"
uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
version = "0.8.7"
[[deps.CommonSolve]]
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
@@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"]
[deps.CompositionsBase.extensions]
CompositionsBaseInverseFunctionsExt = "InverseFunctions"
[[deps.ConcurrentUtilities]]
deps = ["Serialization", "Sockets"]
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
version = "2.6.0"
[[deps.ConstructionBase]]
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
@@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
version = "3.8.0"
[[deps.Lz4_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33"
uuid = "5ced341a-0733-55b8-9ab6-a4889d929147"
version = "1.10.1+0"
[[deps.MacroTools]]
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
@@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
version = "0.4.7"
[[deps.StringViews]]
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
version = "1.3.7"
[[deps.StructTypes]]
deps = ["Dates", "UUIDs"]
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
@@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
version = "1.0.21+0"
[[deps.msghandler]]
deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"]
git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f"
repo-rev = "main"
repo-url = "https://git.yiem.cc/ton/msghandler"
uuid = "f2724d33-f338-4a57-b9f8-1be882570d10"
version = "1.2.1"
[[deps.nghttp2_jll]]
deps = ["Artifacts", "Libdl"]
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+2 -4
View File
@@ -12,16 +12,15 @@ 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"
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
[compat]
Base64 = "1.11.0"
@@ -30,6 +29,5 @@ DataFrames = "1.7.0"
GeneralUtils = "0.5.10"
HTTP = "2.4.0"
JSON = "1.6.1"
LLMMCTS = "0.1.5"
NATS = "0.1.0"
SQLLLM = "0.2.8"
msghandler = "1.2.1"
+123 -18
View File
@@ -1,12 +1,16 @@
# YiemAgent
Julia framework for building agents with tool use.
Julia framework for building agents with tool use and MCP (Model Context Protocol) support.
## Getting Started
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
2. Create a `yiemAgent` with `loadTools("src/tools")`
3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames`
2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS
3. Create a `yiemAgent` with an LLM callable and MCP server:
```julia
agent = YiemAgent.yiemAgent(llmCall; mcpServer=mcpServer, eventSink=yourSink)
```
4. Call `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 |
+268 -294
View File
File diff suppressed because it is too large Load Diff
-54
View File
@@ -1,54 +0,0 @@
{
"nats_server_info": {
"description": "nats server",
"url": "nats.yiem.cc"
},
"testingOrProduction": "testing",
"agentId": "2b74b87a-5413-4fe2-a4d3-405891051680",
"agentCentralConfigSubject": "/yiem/hq/agent/sommelier/backend/config/api/v1.1",
"this_service_name": "agent_backend",
"this_service_input_channel": {
"mqtt": [
"/yiem/hq/agent/sommpanion/backend/db/api_v1"
],
"nats": [
"sommpanion.backend.agentbackend.v1.inbox"
]
},
"agentRole": "sommelier",
"organization": "yiem_hq",
"externalservice": {
"servicesloadbalancer": {
"nats": "sommpanion.backend.servicesloadbalancer.v1.inbox"
},
"textembedding": {
"url": "textembedding.api.v1"
},
"textimage_to_text_llm": {
"url": "https://llmcoder.yiem.cc/v1/chat/completions",
"modelname": "Qwen3.6-35B-A3B-UD-Q4_K_M"
},
"virtualWineCustomer_1": {
"serviceSubject": "",
"modelName": "qwen3:8b"
},
"sommpanion_db" : {
"description": "A database connection info for LibPQ client",
"url": "192.168.88.106:5432",
"dbname": "winedb",
"user": "admin",
"password": "admin@Sommpanion_0.0"
},
"sommpanion_vectordb" : {
"description": "A wine database connection info for LibPQ client",
"url": "192.168.88.106:5433",
"dbname": "vectordb",
"user": "admin",
"password": "admin@Sommpanion_0.0"
},
"fileserver": {
"description": "temporary file server",
"url": "https://fileserver.yiem.cc"
}
}
}
-77
View File
@@ -1,77 +0,0 @@
i am not sure that's the case. see my NATS message log:
<NATS debug message>
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "_process_message 3"
Info: debug
payload = "_process_message 5"
Info: debug
payload = "_process_message 6"
Info: debug
payload = "_process_message 7"
</NATS debug message>
my NATS receiver report the following for a long time
Info: debug
payload = "new user msg"
untill I Ctrl + d so shutdown the process then i got the following report
Info: debug
payload = "_process_message 3"
Info: debug
payload = "_process_message 5"
Info: debug
payload = "_process_message 6"
Info: debug
payload = "_process_message 7"
my point is if _process_message() actually run then this code in _process_message()
"raw_msg = take!(agent.inputChannel)"
should take the new msg message out of agent.inputChannel and there should be only one debug message showing
Info: debug
payload = "new user msg"
before reaching error("debug marker")
+11
View File
@@ -0,0 +1,11 @@
check my understanding
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, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is false then _processMessage() loop continue
3) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is true then final_response message get pushed into agent._state.messages. _processMessage() loop exit. then _agentLoop() can pick it as the output to outputChannel
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?
+30 -53
View File
@@ -1,53 +1,30 @@
module YiemAgent
# export agent
""" Order by dependencies of each file. The 1st included file must not depend on any other
files and each file can only depend on the file included before it.
"""
include("type.jl")
using .type
include("utils.jl")
using .utils
include("toolRegistry.jl")
using .toolRegistry
# include("llmfunction.jl")
# using .llmfunction
include("agentCore.jl")
using .agentCore
include("api.jl")
using .api
# ---------------------------------------------- 100 --------------------------------------------- #
end # module YiemAgent_v1
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("agentCore.jl")
using .agentCore
include("api.jl")
using .api
export promptAgent, takeResponse, followUp, stopAgent
# ---------------------------------------------- 100 --------------------------------------------- #
end # end module
+707 -320
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -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
@@ -116,7 +116,7 @@ julia> stopAgent(agent)
function stopAgent(agent::yiemAgent)
put!(agent.inputChannel, :shutdown)
try
fetch(agent._agent_loop)
fetch(agent._agentLoop)
catch e
if e isa TaskFailedException
rethrow(e)
+231 -211
View File
@@ -1,271 +1,291 @@
module toolRegistry
export toolStore, loadTools, 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)
end
function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing, sessionId=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}())
"""
listTool(store::toolStore) -> agentTool
Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over)
`store`. `loadTools` auto-registers one so the LLM can discover tools
at runtime.
# Arguments
- `store`: The tool store whose tools will be listed when the tool runs
# Example
```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools") # 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(...)
```
"""
function listTool(store::toolStore)::agentTool
return agentTool(
name = "listTools",
label = "List Tools",
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
inputSchema = Dict{String,Any}(
# Normalize inputSchema to OpenAI function format
if haskey(input_schema, "properties") && input_schema["type"] == "object"
params = Dict(
"type" => "object",
"properties" => input_schema["properties"],
"required" => get(input_schema, "required", Any[]),
)
else
params = Dict(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute = (toolCallId, args, signal, onPartialResult) -> begin
tools = getTools(store)
if isempty(tools)
result_text = "No tools registered."
else
lines = String["- $(t.name): $(t.label)$(t.description)" for (k, t) in tools]
result_text = "Available tools:\n" * join(lines, "\n")
"required" => Any[],
)
end
return agentTool(
name=name,
label=title,
description=desc,
inputSchema=params,
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink) -> begin
try
response = mcpserver("tools/call", name, args, sessionId)
# Parse JSON-RPC 2.0 response envelope
if haskey(response, "error")
rpc_error = response["error"]
err_msg = get(rpc_error, "message", "Unknown MCP error")
return agentToolResult(
[textContent("MCP error: $err_msg")],
Dict{Any,Any}("error" => err_msg),
nothing, false
)
end
result_data = get(response, "result", response)
content_text = _extract_text_content(result_data)
is_error = get(result_data, "isError", false)
return agentToolResult(
[textContent(content_text)],
Dict{Any,Any}("isError" => is_error),
nothing, false
)
catch e
errMsg = sprint(showerror, e)
return agentToolResult(
[textContent("MCP call error: $errMsg")],
Dict{Any,Any}("error" => errMsg),
nothing, false
)
end
return agentToolResult(
[textContent(result_text)],
Dict{Any,Any}("count" => length(tools)),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
prepareArguments=nothing,
validateRequiredArgs=nothing,
parallelToolExecute=false,
)
end
"""
Load `.jl` tool files from `dir` into `store`, then auto-register
`listTool` so the LLM can discover available tools at runtime.
Discover and register MCP tools into `store.tools`.
Each `.jl` file must define `function getTool()::agentTool ... end`.
Files are sorted alphabetically for deterministic registration order.
Each file is loaded into its own Julia submodule to avoid name collisions.
# Arguments
- `store`: Tool store to populate
- `dir`: Directory containing `.jl` tool files
Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format),
parses the response envelope, and registers each discovered tool.
Handles pagination via `nextCursor`. Skips tools already registered.
# Returns
- The same `store.tools` dict (modified in place)
# Errors
- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()`
# Example
```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools")
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Loaded tool: getTime (Time Lookup)
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 3 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"listTools" => agentTool(...)
```
- `Int`: number of new tools registered
"""
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir"))
function register_mcp_tools(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::Int
if mcpserver === nothing
return 0
end
new_count = 0
try
eventSink("register_mcp_tools 1")
response = mcpserver("tools/list", sessionId)
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
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
sort!(jl_files)
tools_array = response["tools"]
cursor = get(response, "nextCursor", nothing)
for filename in jl_files
filepath = joinpath(dir, filename)
# Derive a unique module name from the filename only (not full path).
# e.g. "getWeather.jl" -> "_tool_getWeather"
mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => ""))
# Build the complete module as a string and eval the parsed code.
# Julia does not allow `module ... end` inside eval(quote ...),
# and constructing the module AST by hand is fragile.
# Instead, we generate the full module source as a string,
# parse it, and eval the resulting expression.
# Each tool file declares its own dependencies via `using` statements
# at the top of the file — the registry only injects `using ..type`
# to make core types (agentTool, textContent, etc.) available.
file_content = read(filepath, String)
module_code = """
module $(mod_name)
using ..type
$(file_content)
for tool_def in tools_array
name = tool_def["name"]
if haskey(tools, name)
continue
end
"""
mod = eval(Meta.parse(module_code))
# Call getTool() via Core.eval in the submodule's scope.
# This evaluates getTool() entirely within the new module's world,
# completely avoiding world-age issues — no invokelatest needed.
# Note: all uses of `tool` must be inside the `try` block because
# Julia 1.12's SSA form doesn't track `tool` as definitely assigned
# after a `try-catch` where it's only assigned inside `try`.
try
tool = Core.eval(mod, :(getTool()))
if !(tool isa agentTool)
throw(ArgumentError(
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
))
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId)
tools[name] = wrapped
new_count += 1
end
# Paginate: fetch remaining tools if nextCursor is present
while cursor !== nothing && cursor !== ""
response = sessionId !== nothing ? mcpserver("tools/list", sessionId) : 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
store.tools[tool.name] = tool
println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
catch e
if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
throw(ArgumentError(
"Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " *
"Each tool file must define: function getTool()::agentTool ... end"
))
end
rethrow(e)
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId)
tools[name] = wrapped
new_count += 1
end
end
registerTool(store, listTool(store))
return store.tools
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(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool) -> OrderedDict{String, agentTool}
Add `tool` to `store`, overwriting any existing tool with the same name.
Add `tool` to `tools`, overwriting any existing tool with the same name.
# Arguments
- `store`: Tool store to modify
- `tools`: Tool dict 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(...)
```
- The same `tools` dict (modified in place)
"""
function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools
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
"""
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.
Remove all tools from the dict.
# 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
- `tools`: Tool dict 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")
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, sessionId=nothing)::agentTool
return agentTool(
name="listTools",
label="List Tools",
description="List all available tools. First call discovers and registers all tools from the MCP server. After discovery, new tools become immediately available for use.",
inputSchema=Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(),
"required" => Any[]
),
execute=(toolCallId::String, args::AbstractDict{String, Any},
signal::Union{Nothing,abortSignal},
eventSink) -> begin
# Discover and register MCP tools (idempotent — skips already registered)
new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink, sessionId=sessionId)
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" => total),
nothing, false
)
end,
prepareArguments=nothing,
validateRequiredArgs=nothing,
parallelToolExecute=false,
)
end
# ── High-level API ──────────────────────────────────────────────────
"""
registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver)
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
- `tools`: The tool dict to populate
- `mcpserver`: A callable struct that communicates with the MCP server
"""
function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing, sessionId=nothing)
list_t = listTool(tools, mcpserver; eventSink=eventSink, sessionId=sessionId)
registerTool(tools, list_t; eventSink=eventSink)
end
end # module
-82
View File
@@ -1,82 +0,0 @@
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 validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
tz = get(args, "timezone", nothing)
city = get(args, "city", "")
hasTz = tz !== nothing && !isempty(tz)
hasCity = !isempty(city)
# At least one of timezone or city is required
if !hasTz && !hasCity
return "Missing required argument: provide at least one of 'timezone' or 'city'"
end
# Validate timezone format (IANA tz database: "Continent/City" or "Continent/City/SubCity")
if hasTz
tz_str = string(tz)
if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str)
return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York' or 'Asia/Tokyo'"
end
end
return nothing
end
"""
Execute the getTime tool.
Returns mock time data for the given timezone or city.
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
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 getTool()::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 = executeTool,
prepareArguments = nothing,
validateRequiredArgs = validateRequiredArgs,
parallelToolExecute = false
)
end
-39
View File
@@ -1,39 +0,0 @@
"""
Execute the getWeather tool.
Returns mock weather data for the given city and temperature units.
"""
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
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 getTool()::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 = executeTool,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
-273
View File
@@ -1,273 +0,0 @@
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, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks
up the new file. The new tool is immediately available.
# Example
1. Agent calls writeTool with a spec for a "searchWine" tool
2. writeTool generates src/tools/searchWine.jl
3. Restart agent — loadTools() picks up the new file
4. Agent calls searchWine with args
# 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 `getTool()` 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 getTool()::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::Function) -> 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 is loaded on next agent restart via loadTools(store, "src/tools")
write(filepath, tool_code)
onPartialResult(Dict("status" => "Done"))
return agentToolResult(
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
Dict{Any,Any}(
"file" => filepath,
"name" => tool_name,
"label" => tool_label,
"description" => tool_description,
),
nothing, false
)
end,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false
)
end
+82 -36
View File
@@ -4,10 +4,10 @@
messageContent, agentMessage, agent,
# Model types
modelCost, llmModel, llmUsage,
# Message content types
textContent, imageContent,
# Message types
userMessage, assistantMessage, toolResultMessage,
# Message content types
textContent, imageContent, reasoningContent,
# Message types
userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage,
# Tool types
agentTool, validateRequiredArgs,
# Context types
@@ -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
@@ -31,6 +31,13 @@ using GeneralUtils
const Timestamp = DateTime
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
# ------------------------------------------------------------------------------------------------ #
# LLM model info #
# ------------------------------------------------------------------------------------------------ #
@@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content
mimeType::String # MIME type (e.g., "image/png")
end
struct reasoningContent <: messageContent # LLM reasoning/thinking content
text::String # The reasoning text
end
# ------------------------------------------------------------------------------------------------ #
# Message types #
@@ -108,6 +119,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp=
return userMessage(role, content, timestamp)
end
struct assistantMessageToolCall <: agentMessage # Assistant message containing tool calls
role::String # Always "assistant"
toolCalls::Vector{agentToolCall} # Tool calls to execute
content::Vector{messageContent} # Reasoning/thinking content blocks
api::String # API name used (e.g., "openai")
provider::String # Provider name (e.g., "anthropic")
model::String # Model identifier
usage::llmUsage # Token usage for this message
stopReason::String # Why generation stopped (e.g., "tool_calls")
errorMessage::Union{String, Nothing} # Error if generation failed
timestamp::Timestamp # When the message was received
end
"""
Create a new assistant message containing tool calls.
# Arguments
- `role::String`: Always "assistant"
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute
- `content::Vector{messageContent}`: Reasoning/thinking content blocks
- `api::String`: API name used
- `provider::String`: Provider name
- `model::String`: Model identifier
- `usage::llmUsage`: Token usage
- `stopReason::String`: Why generation stopped
- `errorMessage::Union{String, Nothing}`: Error if generation failed
- `timestamp::Timestamp`: When the message was received
# Returns
- A new `assistantMessageToolCall` instance
# Examples
```julia
julia> tc = agentToolCall("function", "call_1", "getWeather", Dict("city" => "Tokyo"))
julia> msg = assistantMessageToolCall(toolCalls=[tc], stopReason="tool_calls")
assistantMessageToolCall("assistant", [agentToolCall(...)], messageContent[], "", "", "", llmUsage(0, 0), "tool_calls", nothing, DateTime(...))
```
"""
function assistantMessageToolCall(; role="assistant", toolCalls=agentToolCall[],
content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0),
stopReason="tool_calls", errorMessage=nothing, timestamp=now())
model_str = model isa AbstractString ? String(model) : ""
return assistantMessageToolCall(role, toolCalls, content, api, provider, model_str,
usage, stopReason, errorMessage, timestamp)
end
struct assistantMessage <: agentMessage # Message from the AI assistant
role::String # Always "assistant"
content::Vector{messageContent} # Text and/or image content
@@ -146,7 +203,8 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
function assistantMessage(; role="assistant", content=Vector{messageContent}(),
api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
errorMessage=nothing, timestamp=now())
return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp)
model_str = model isa AbstractString ? String(model) : ""
return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp)
end
struct toolResultMessage <: agentMessage # Result returned from a tool execution
@@ -263,7 +321,7 @@ struct agentTool # A tool available to the agent
label::String # Human-readable tool name
description::String # What the tool does
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
execute::Function # Tool execution function
execute # Tool execution function
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
@@ -273,7 +331,7 @@ end
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
"""
function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute::Function, prepareArguments::Union{Function, Nothing}=nothing,
execute, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false)
return agentTool(name, label, description, inputSchema, execute,
@@ -291,7 +349,7 @@ Snapshot of the agent's conversation context.
# Arguments
- `systemPrompt::String`: System prompt for the agent
- `messages::Vector{agentMessage}`: Conversation messages
- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
# Returns
- A new `agentContext` instance
@@ -299,7 +357,7 @@ Snapshot of the agent's conversation context.
struct agentContext # Snapshot of the agent's conversation context
systemPrompt::String # System prompt for the agent
messages::Vector{agentMessage} # Conversation messages
tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
end
@@ -316,7 +374,6 @@ mutable struct agentState # Mutable runtime state of an agen
messages::Vector{agentMessage}
pendingToolCalls::Vector{String} # Tool call IDs waiting for results
activeRun::Bool # is agent processing user message?
errorMessage::Union{String, Nothing} # Last error message
end
@@ -350,23 +407,14 @@ function agentState(
agentState(
systemPrompt,
model,
deepcopy(tools),
tools,
deepcopy(messages),
Vector{String}(),
false,
nothing,
)
end
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
"""
Context for preparing the next conversation turn.
@@ -394,14 +442,12 @@ end
"""
Configuration for the agent tool execution loop.
# Arguments
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
# Fields
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
"""
struct agentLoopConfig
tools::OrderedDict{String, agentTool}
beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing}
toolExecution::String
@@ -437,15 +483,15 @@ end
Context passed to the `beforeToolCall` hook.
# Arguments
- `message::assistantMessage`: The assistant message containing the tool call
- `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::assistantMessage
message::assistantMessageToolCall
toolCall::agentToolCall
args::Dict{String,Any}
args::AbstractDict{String, Any}
context::agentContext
end
@@ -458,17 +504,17 @@ end
Context passed to the `afterToolCall` hook.
# Arguments
- `message::assistantMessage`: The assistant message containing the tool call
- `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
"""
struct afterToolCallContext
message::assistantMessage
message::assistantMessageToolCall
toolCall::agentToolCall
args::Dict{String,Any}
args::AbstractDict{String, Any}
result::agentToolResult
isError::Bool
context::agentContext
@@ -480,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
"""
@@ -494,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
+174 -25
View File
@@ -2,10 +2,10 @@ module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks,
beforeToolCall, afterToolCall, agentEventSink
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
beforeToolCall, afterToolCall, eventSink
using UUIDs, Dates, DataStructures, HTTP, JSON
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
using GeneralUtils
using ..type
@@ -75,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
end
"""
prepareContext(state::agentState) -> agentContext
@@ -110,14 +109,14 @@ prepareContext(state).messages == deepcopy(state.messages)
# end
```
"""
function prepareContext(state::agentState)::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
@@ -157,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
])
```
"""
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
""" openai message format example
msg = Dict(
@@ -185,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
Dict("type" => "text", "text" => "let me check."),
]
),
],
"tools"=> [
Dict(
"role" => "toolResult",
"content" => [
Dict("type" => "text", "text" => "name: Chateau Montelena ..."),
]
),
"type" => "function",
"function" => Dict(
"name" => "getWeather",
"description" => "Get current weather",
"parameters" => Dict(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string")
),
"required" => ["city"]
)
)
)
],
"temperature" => 0.7
)
"""
openaiReadyMsg = Dict{String, Any}()
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
messages = Vector{Dict{String, Any}}()
eventSink("formatMsgForLLM 1")
# System prompt as system message
if !isempty(ctx.systemPrompt)
push!(messages, Dict(
@@ -205,22 +216,65 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
))
end
eventSink("formatMsgForLLM 2")
# Conversation messages
for msg in ctx.messages
if msg isa userMessage
push!(messages, _userMessageToOpenAI(msg))
elseif msg isa assistantMessageToolCall
push!(messages, _assistantMessageToolCallToOpenAI(msg))
elseif msg isa assistantMessage
push!(messages, _assistantMessageToOpenAI(msg))
elseif msg isa toolResultMessage
push!(messages, _toolResultMessageToOpenAI(msg))
end
end
eventSink("formatMsgForLLM 3")
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, eventSink)
eventSink("formatMsgForLLM 4")
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
return Dict("messages" => messages)
if !isempty(tools_array)
openaiReadyMsg["tools"] = tools_array
end
return openaiReadyMsg
end
#TODO
"""
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult
Callback invoked before executing a tool call. Use this hook to inspect
the tool call and decide whether to allow, block, or modify it.
Common use cases:
- Request user approval via UI before running destructive tools.
- Validate business rules that cannot be expressed in the JSON schema.
- Check final context (e.g. session state, rate limits, permissions).
# Arguments
- `context::beforeToolCallContext`: Contains the assistant message, tool call,
validated arguments, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `beforeToolCallResult(false, "N/A")` to allow the call to proceed.
- `beforeToolCallResult(true, "Reason")` to block the call with a reason.
- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`).
# Example
```julia
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)
if context.toolCall.name == "deleteFile"
# Block file deletion unless explicitly approved
return beforeToolCallResult(true, "User must approve file deletion")
end
return beforeToolCallResult(false, "N/A")
end
```
"""
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
)::beforeToolCallResult
@@ -233,8 +287,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
return beforeToolCallResult(false, "N/A")
end
#TODO
function afterToolCall(context::beforeToolCallContext, signal::abortSignal
"""
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
Callback invoked after a tool call finishes executing (before and after errors).
Use this hook to post-process the tool result before it is fed back to the LLM.
Common use cases:
- Mask sensitive data (API keys, tokens) from result content.
- Normalize usage tracking data into a consistent format.
- Inspect the result and set `terminate: true` based on business logic
(e.g. "if deployment failed, stop the agent rather than retrying").
- Wrap error results in friendlier messages for the LLM to understand.
# Arguments
- `context::afterToolCallContext`: Contains the assistant message, tool call,
arguments, raw result, error status, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `nothing` to pass the result through unchanged.
- `agentToolResult(...)` to return a modified result (content, details, usage,
terminate flag can all be overridden).
"""
function afterToolCall(context::afterToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult
@@ -244,7 +321,7 @@ end
#TODO
function agentEventSink(x)
function eventSink(x)
end
@@ -260,6 +337,44 @@ function _userMessageToOpenAI(msg::userMessage)::Dict{String, Any}
end
"""
Convert an assistantMessageToolCall to OpenAI message format.
Produces a message with role="assistant", content=null, and a tool_calls array:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\"}"
}
}
]
}
"""
function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{String, Any}
tool_calls = Dict{String, Any}[]
for tc in msg.toolCalls
push!(tool_calls, Dict(
"id" => tc.id,
"type" => tc.type,
"function" => Dict(
"name" => tc.name,
"arguments" => JSON.json(tc.arguments)
)
))
end
return Dict(
"role" => "assistant",
"content" => nothing,
"tool_calls" => tool_calls
)
end
"""
Convert an assistantMessage to OpenAI message format.
"""
@@ -310,7 +425,41 @@ end
"""
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
_toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}}
Convert an OrderedDict of agentTool definitions into OpenAI function tool format.
Returns an empty vector when `tools` is `nothing` or empty.
# Examples
```julia
_toolsToOpenAI(nothing) # => Dict{String, Any}[]
_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))]
```
"""
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, eventSink)::Vector{Dict{String, Any}}
tools_array = Vector{Dict{String, Any}}()
eventSink("_toolsToOpenAI 1")
eventSink(string(typeof(tools)))
if tools !== nothing
for (_, tool) in tools
push!(tools_array, Dict(
"type" => "function",
"function" => Dict(
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.inputSchema
)
))
end
end
eventSink("_toolsToOpenAI 2")
return tools_array
end
"""
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
@@ -321,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
@@ -338,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
@@ -392,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)
File diff suppressed because it is too large Load Diff
-375
View File
@@ -1,375 +0,0 @@
module type
export agent, sommelier, companion, virtualcustomer, agentcontext
using Dates, UUIDs, DataStructures, JSON, NATS
using GeneralUtils
# ---------------------------------------------- 100 --------------------------------------------- #
mutable struct agentcontext
text2textInstructLLM::Function
getTextEmbedding::Function
executeSQL::Function
similarSQLVectorDB::Function
insertSQLVectorDB::Function
similarSommelierDecision::Function
insertSommelierDecision::Function
find_related_tables_for_user_question::Function
pg_conn_str::String
agentconfig::AbstractDict
end
abstract type agent end
mutable struct sommelier <: agent
name::String # agent name
id::String # agent id
retailername::String
retailerid::String
tools::Dict
maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized
chathistory::Vector{Dict{String, Any}}
memory::Dict{String, Any}
context::agentcontext
llmFormatName::String
end
""" A sommelier agent.
# Arguments
- `context::agentcontext`
Application context containing shared functions for LLM, SQL, and vector database operations.
# Keyword Arguments
- `name::String`
Agent's name. Default: `"Assistant"`
- `id::String`
Agent's ID. Default: generated UUID string.
- `retailername::String`
Retailer name associated with the sommelier. Default: `"retailer_name"`
- `maxHistoryMsg::Integer`
Maximum history messages. Default: `20`
- `chathistory::Vector{Dict{String, String}}`
Chat history. Default: empty vector.
- `llmFormatName::String`
LLM format name. Default: `"granite3"`
# Return
- `sommelier`: An instantiated sommelier agent.
# Example
```julia
julia> using YiemAgent
julia> context = agentcontext(
text2textInstructLLM,
getTextEmbedding,
executeSQL,
similarSQLVectorDB,
insertSQLVectorDB,
similarSommelierDecision,
insertSommelierDecision
)
julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop")
```
"""
function sommelier(
context::agentcontext, # agent functions, db connect and other context
;
name::String= "Assistant",
id::String= string(uuid4()),
retailername::String= "not specified",
retailerid::String= "not specified",
maxHistoryMsg::Integer= 20,
chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(),
llmFormatName::String= "granite3"
)
tools = Dict( # update input format
"chatbox"=> Dict(
"description" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"output" => "" ,
),
"winestock"=> Dict(
"description" => "<winestock tool description>A handy tool for searching wine in your inventory that match the user preferences.</winestock tool description>",
"input" => """<input>Input is a JSON-formatted string that contains a detailed and precise search query.</input><input example>{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}</input example>""",
"output" => """<output>Output are wines that match the search query in JSON format.""",
),
)
""" Memory
Chat history use openai format as follow:
image1_path = "test/large_image.png" ---
image1_bytes = read(image1_path) | this part must be done
image1_base64_string = base64encode(image1_bytes) | in frontend
mime_type = "image/png" | not in agent code
data1_uri = "data:<mime_type>;base64,<image1_base64_string>" ---
chathistory= [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => "You are a helpful assistant"),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "<internal_context_for_assistant>
LLM context here...
</internal_context_for_assistant>
Do you know this wine? Just give me brief intro."
),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
),
]
),
]
shortmem = Dict(
"1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."),
"2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."),
...
)
"""
memory = Dict{String, Any}(
"shortmem"=> OrderedDict{String, Any}(),
"scratchpad"=> "",
"recap"=> OrderedDict{String, Any}(),
)
newAgent = sommelier(
name,
id,
retailername,
retailerid,
tools,
maxHistoryMsg,
chathistory,
memory,
context,
llmFormatName
)
systemmsg =
"""
# store_policy
- Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory.
- If you found wines in the store's database, they are in stock.
- You can only recommend wines that are currently in our inventory
- Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences.
- Ask the user one question at a time.
- Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services.
- Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future.
- Spicy foods should be paired only with light red wines.
- We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such.
- Gift box, gift card, and custom messages are available. Inform the user to contact our sales team.
# store_guidelines
- Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting.
- Customer may provide images for you to look up.
- Encourage the customer to explore different options and try new things.
- If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead.
- Your store carries only wine.
- Vintage 0 means non-vintage.
- Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently.
- User usually ask for something similar. This means you should use the search term based on the profile they like.
# situation
You are having conversation with a customer.
# your role
Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store.
# objective
- Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences.
- Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences.
# your responsibility includes
- According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective.
- Keep the conversation with the customer going smoothly
# your responsibility does NOT includes
- Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store.
- Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store.
- Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store.
# you should then respond to the user with interleaving plan, action_name, action_input in JSON format
1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.
2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name
3) "action_input", The input to the action you are about to perform according to your plan.
After the action is executed you gets "action_result". It is the output from the action you selected.
# available actions
"CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan.
"SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity.
Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD."
Example query 2: "Red or white wine, medium tannin, price under 700 USD"
Example query 3: "white wine from Tuscany, Italy or Bordeaux, France
"WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow.
"END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow.
"""
system_msg = Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => systemmsg),
]
)
push!(newAgent.chathistory, system_msg)
return newAgent
end
mutable struct virtualcustomer <: agent
name::String # agent name
id::String # agent id
systemmsg::String # system message
tools::Dict
maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized
chathistory::Vector{Dict{String, Any}}
memory::Dict{String, Any}
context # NamedTuple of functions
llmFormatName::String
end
function virtualcustomer(
context, # NamedTuple of functions
;
name::String= "Assistant",
id::String= string(uuid4()),
maxHistoryMsg::Integer= 20,
chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(),
llmFormatName::String= "granite3",
systemmsg::String=
"""
Your name: $name
Your sex: Female
Your role: You are a helpful assistant.
You should follow the following guidelines:
- Focus on the latest conversation.
- Your like to be short and concise.
Let's begin!
""",
)
tools = Dict( # update input format
"chatbox"=> Dict(
"description" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"output" => "" ,
),
)
""" Memory
Ref: Chat prompt format is openai
chathistory = [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => system_msg),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
)
]
)
]
"""
memory = Dict{String, Any}(
"shortmem"=> OrderedDict{String, Any}(
),
"scratchpad"=> "",
"events"=> Vector{Dict{String, Any}}(),
"state"=> Dict{String, Any}(
),
"recap"=> OrderedDict{String, Any}(),
)
newAgent = virtualcustomer(
name,
id,
systemmsg,
tools,
maxHistoryMsg,
chathistory,
memory,
context,
llmFormatName
)
return newAgent
end
end # module type
File diff suppressed because it is too large Load Diff
+642
View File
@@ -0,0 +1,642 @@
using Test
using YiemAgent
using YiemAgent.agentCore
using YiemAgent.type
using JSON
# Import the function from the private module scope
import YiemAgent.agentCore: _extractToolCalls
@testset "_extractToolCalls" begin
# ------------------------------------------------------------------ #
# Format 1: response["message"]["tool_calls"] (LMStudio.jl style) #
# ------------------------------------------------------------------ #
@testset "single tool call via message format" begin
response = Dict{String,Any}(
"finish_reason" => "tool_calls",
"index" => 0,
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"reasoning_content" => "Let me check the weather.",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Bangkok, Thailand\"}",
),
"id" => "tc_001",
)
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].id == "tc_001"
@test tc_list[1].type == "function"
@test tc_list[1].arguments["city"] == "Bangkok, Thailand"
@test assistant_msg isa assistantMessage
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "tool_calls"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Let me check the weather."
end
@testset "multiple tool calls via message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Tokyo, Japan\"}",
),
"id" => "tc_001",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"Asia/Tokyo\"}",
),
"id" => "tc_002",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Tokyo, Japan"
@test tc_list[2].name == "getTime"
@test tc_list[2].arguments["timezone"] == "Asia/Tokyo"
@test assistant_msg.role == "assistant"
end
@testset "tool call with empty arguments string" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "listTools",
"arguments" => "{}",
),
"id" => "tc_empty",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "listTools"
@test tc_list[1].arguments == Dict{String,Any}()
@test assistant_msg.stopReason == "end_turn"
end
@testset "tool call with missing id falls back to uuid" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"city\":\"NYC\"}",
),
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test !isempty(tc_list[1].id)
@test tc_list[1].name == "getTime"
end
@testset "tool call with non-string arguments (pre-parsed dict)" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"),
),
"id" => "tc_parsed",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments["city"] == "London"
@test tc_list[1].arguments["units"] == "fahrenheit"
end
@testset "tool call with api/provider/model/usage metadata" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-opus",
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{}",
),
"id" => "tc_meta",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-opus"
end
# --------------------------------------------------------------- #
# Format 2: response.content blocks (OpenAI API style) #
# --------------------------------------------------------------- #
@testset "content blocks with tool_calls" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Let me check."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Paris\"}",
),
"id" => "tc_block_1",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Paris"
# text block before tool_calls should be included in content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Let me check."
end
@testset "content blocks with tool_call (single-call format)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_single",
"name" => "getTime",
"arguments" => Dict{String,Any}("timezone" => "Europe/London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].id == "tc_single"
@test tc_list[1].arguments["timezone"] == "Europe/London"
end
@testset "content blocks with reasoning and text" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."),
Dict{String,Any}("type" => "text", "text" => "Here's the answer."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Thinking..."
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "Here's the answer."
end
@testset "content blocks with text and tool_call (tool_call not in content)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."),
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_mix",
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
# text block included, tool_call block excluded from content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Sure, I'll check."
end
# ------------------------------------------------------------------ #
# assistantMessage construction #
# ------------------------------------------------------------------ #
@testset "assistantMessage with error_message and errorMessage fallback" begin
response = Dict{String,Any}(
"error_message" => "rate limit",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test assistant_msg.errorMessage == "rate limit"
end
@testset "assistantMessage with usage tracking" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => llmUsage(100, 50),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 100
@test assistant_msg.usage.outputTokens == 50
end
@testset "assistantMessage with invalid usage defaults to zero" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => "invalid",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 0
@test assistant_msg.usage.outputTokens == 0
end
@testset "reasoning_content as textContent" begin
response = Dict{String,Any}(
"reasoning_content" => textContent("internal thought"),
"content" => Any[Dict{String,Any}("type" => "text", "text" => "output")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "internal thought"
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "output"
end
# ------------------------------------------------------------------ #
# No tool call cases #
# ------------------------------------------------------------------ #
@testset "no tool calls found" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Hello world."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.stopReason == "end_turn"
end
@testset "empty message" begin
response = Dict{String,Any}()
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "end_turn"
@test length(assistant_msg.content) == 0
end
@testset "message with empty tool_calls array" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
end
@testset "message field is not a Dict" begin
response = Dict{String,Any}(
"message" => "not a dict",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fallback")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 1
end
@testset "Format 1 takes priority over Format 2" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Format1\"}",
),
"id" => "tc_fmt1",
),
],
),
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_fmt2",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Format2"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
end
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
@testset "tool call with null arguments" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => nothing,
),
"id" => "tc_null",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
end
@testset "tool call with missing function key" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"id" => "tc_nofunc",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "tool call with missing name in function block" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}("arguments" => "{}"),
"id" => "tc_noname",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "message format with JSON.Object (JSON.parse result)" begin
json_str = JSON.json(Dict(
"message" => Dict(
"role" => "assistant",
"tool_calls" => [
Dict(
"type" => "function",
"function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"),
"id" => "tc_jsonobj",
),
],
),
))
parsed = JSON.parse(json_str)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(parsed)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Test"
end
@testset "tool_calls block with mixed content types (text + tool_calls)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "I'll check both."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"London\"}",
),
"id" => "tc_mix1",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"UTC\"}",
),
"id" => "tc_mix2",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[2].name == "getTime"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "I'll check both."
end
@testset "tool_call block without arguments field" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_noargs",
"name" => "getTime",
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments == Dict{String,Any}()
end
@testset "tool_calls block with empty tool_calls array" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "tool_calls block with non-AbstractDict elements" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any["not a dict", 42, nothing],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "content field is not a Vector" begin
response = Dict{String,Any}(
"content" => "not a vector",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 0
end
@testset "Dict-based response with all metadata fields" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-sonnet",
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_meta",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Seoul"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].arguments["city"] == "Seoul"
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-sonnet"
end
@testset "default role is assistant" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "no role specified")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "assistant"
end
@testset "tool call with custom role in message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "custom_role",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{}",
),
"id" => "tc_role",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "custom_role"
end
@testset "image content block handling" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "image_url",
"image_url" => Dict("url" => "data:image/png;base64,abc123"),
),
Dict{String,Any}("type" => "text", "text" => "What is this?"),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa textContent
@test assistant_msg.content[1].text == ""
@test assistant_msg.content[2].text == "What is this?"
end
end
+5
View File
@@ -0,0 +1,5 @@
using Test
using YiemAgent
include("toolTest.jl")
include("_extractToolCalls.jl")
-212
View File
@@ -1,212 +0,0 @@
using Test
using YiemAgent
using YiemAgent.toolRegistry
using YiemAgent.type
# Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools with toolStore" begin
# ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory #
# ------------------------------------------------------------------ #
store = toolStore(name="test1")
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
# ------------------------------------------------------------------ #
# 2. loadTools throws if a .jl file does not define getTool() #
# Must run BEFORE any other loadTools call (getTool binding #
# persists in module scope after include()). #
# ------------------------------------------------------------------ #
bad_dir = mktempdir()
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
@test_throws ArgumentError loadTools(store, bad_dir)
# ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ #
store2 = toolStore(name="test2")
loaded = loadTools(store2, TOOLS_DIR)
@test !isempty(loaded)
@test length(loaded) == 4 # 3 files + auto-registered listTools
names = [k for k in keys(loaded)]
@test "getTime" in names
@test "getWeather" in names
@test "writeTool" in names
@test "listTools" in names
# ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename #
# (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
# ------------------------------------------------------------------ #
@test collect(keys(loaded))[1] == "getTime"
@test collect(keys(loaded))[2] == "getWeather"
@test collect(keys(loaded))[3] == "writeTool"
@test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ #
# 5. 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"]
# ------------------------------------------------------------------ #
# 6. 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°F", result_w2.content[1].text)
# ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ #
store3 = toolStore(name="test3")
registry_tools = getTools(store3)
@test isempty(registry_tools)
# listTool is not auto-registered anymore — each store starts empty
# 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
# ------------------------------------------------------------------ #
# 8. 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
# ------------------------------------------------------------------ #
# 9. 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")
loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools
# loadTools auto-registers listTool
@test "listTools" in keys(loaded)
# 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, loaded["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
+113
View File
@@ -0,0 +1,113 @@
using Revise, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64,
NATS, Base.Threads
using YiemAgent, GeneralUtils, msghandler
""" Debug
using JSON, NATS, msghandler
using NATS
conn = NATS.connect("nats.yiem.cc")
sub = NATS.subscribe(conn, "sommanion.debug") do msg
payload = NATS.payload(msg)
@info "debug" payload
open("./log/error.log", "a") do io
println(io, payload)
end
end
NATS.publish(conn, "sommanion.debug", "order-123")
# ---------------------------- inject this code into codebase to debug --------------------------- #
try
batch = someFunction(x, y, z)
catch e
bt = catch_backtrace()
err_msg = sprint() do io
showerror(io, e, bt)
println(io)
end
eventSink(err_msg)
end
"""
struct text2textInstructLLM
natsConn::NATS.Connection
topic::String
senderID::String
fileserver_url::String
end
function (t::text2textInstructLLM)(openai_msg::Dict{String, Any})
payloads = [("msg", openai_msg, "dictionary")] # List of tuples
_, msg_envelope_json_str = msghandler.smartpack(
t.topic,
payloads;
sender_id=t.senderID,
msg_purpose="text2text",
fileserver_url=t.fileserver_url)
reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180)
incoming_env_json_str = String(reply.payload)
incoming_env = msghandler.smartunpack(incoming_env_json_str)
_llm_response = incoming_env["payloads"][1][2]
llm_response = _llm_response["choices"][1]
return llm_response
end
struct eventSink
natsConn::NATS.Connection
topic::String
senderID::String
end
function (aes::eventSink)(msg::String)
NATS.publish(aes.natsConn, aes.topic, msg)
end
config = JSON.parsefile("./appconfig.json")
agent_conn = NATS.connect(config["nats_server_info"]["url"])
#WORKING load tools
text2text_llm = text2textInstructLLM(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
"sender",
config["externalservice"]["fileserver"]["url"])
debugNats = eventSink(agent_conn, "sommanion.debug", "sender")
agent = YiemAgent.yiemAgent(
text2text_llm;
eventSink=debugNats
)
msg = Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "What's the weather in Bangkok?"),
# Dict(
# "type" => "image_url",
# "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string")
# ),
]
)
push!(agent.inputChannel, msg)