Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9193260539 | |||
| 5f284883b3 | |||
| d2592ab6d6 | |||
| da21790263 | |||
| c59f6bfa61 | |||
| 3c91222462 | |||
| f62b8f14e7 | |||
| 7ffb720f86 | |||
| 5829c82d05 | |||
| c7a98f1710 | |||
| c7abf844ea | |||
| 00447e4dde | |||
| 25f8468696 | |||
| a29a82b74c | |||
| fd616409dd | |||
| 2543e6cbf1 | |||
| b8067c2d33 | |||
| 510cf6126c | |||
| 90fb97a4e7 | |||
| 77adeb3a6b |
+50
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
julia_version = "1.12.6"
|
julia_version = "1.12.6"
|
||||||
manifest_format = "2.0"
|
manifest_format = "2.0"
|
||||||
project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c"
|
project_hash = "3ff1783eadf40ccb51801954aa0a8df935689752"
|
||||||
|
|
||||||
[[deps.Accessors]]
|
[[deps.Accessors]]
|
||||||
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
||||||
@@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
|
|||||||
uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
|
uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
|
||||||
version = "0.4.0"
|
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.ArrowTypes]]
|
||||||
deps = ["Sockets", "UUIDs"]
|
deps = ["Sockets", "UUIDs"]
|
||||||
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
|
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
|
||||||
@@ -58,6 +64,12 @@ version = "1.11.0"
|
|||||||
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
|
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
|
|
||||||
|
[[deps.BitIntegers]]
|
||||||
|
deps = ["Random"]
|
||||||
|
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
|
||||||
|
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
|
||||||
|
version = "0.3.7"
|
||||||
|
|
||||||
[[deps.BufferedStreams]]
|
[[deps.BufferedStreams]]
|
||||||
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
|
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
|
||||||
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
|
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
|
||||||
@@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
|
|||||||
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
|
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
|
||||||
version = "0.3.4"
|
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.CodecZlib]]
|
||||||
deps = ["TranscodingStreams", "Zlib_jll"]
|
deps = ["TranscodingStreams", "Zlib_jll"]
|
||||||
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
|
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
|
||||||
uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
|
uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
|
||||||
version = "0.7.8"
|
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]]
|
[[deps.CommonSolve]]
|
||||||
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
|
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
|
||||||
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
|
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
|
||||||
@@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"]
|
|||||||
[deps.CompositionsBase.extensions]
|
[deps.CompositionsBase.extensions]
|
||||||
CompositionsBaseInverseFunctionsExt = "InverseFunctions"
|
CompositionsBaseInverseFunctionsExt = "InverseFunctions"
|
||||||
|
|
||||||
|
[[deps.ConcurrentUtilities]]
|
||||||
|
deps = ["Serialization", "Sockets"]
|
||||||
|
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
|
||||||
|
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
|
||||||
|
version = "2.6.0"
|
||||||
|
|
||||||
[[deps.ConstructionBase]]
|
[[deps.ConstructionBase]]
|
||||||
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
|
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
|
||||||
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
|
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
|
||||||
@@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
|
|||||||
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
|
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
|
||||||
version = "3.8.0"
|
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]]
|
[[deps.MacroTools]]
|
||||||
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
|
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
|
||||||
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
|
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
|
||||||
@@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
|
|||||||
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
|
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
|
||||||
version = "0.4.7"
|
version = "0.4.7"
|
||||||
|
|
||||||
|
[[deps.StringViews]]
|
||||||
|
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
|
||||||
|
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
|
||||||
|
version = "1.3.7"
|
||||||
|
|
||||||
[[deps.StructTypes]]
|
[[deps.StructTypes]]
|
||||||
deps = ["Dates", "UUIDs"]
|
deps = ["Dates", "UUIDs"]
|
||||||
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
|
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
|
||||||
@@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
|
|||||||
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
|
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
|
||||||
version = "1.0.21+0"
|
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.nghttp2_jll]]
|
||||||
deps = ["Artifacts", "Libdl"]
|
deps = ["Artifacts", "Libdl"]
|
||||||
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
|
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
|
||||||
|
|||||||
+2
-4
@@ -12,16 +12,15 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
|||||||
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
||||||
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
||||||
LLMMCTS = "d76c5a4d-449e-4835-8cc4-dd86ec44f241"
|
|
||||||
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
|
LibPQ = "194296ae-ab2e-5f79-8cd4-7183a0a5a0d1"
|
||||||
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
||||||
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
|
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
|
||||||
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
|
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
|
||||||
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
||||||
SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
|
|
||||||
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
||||||
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
||||||
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
||||||
|
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
|
||||||
|
|
||||||
[compat]
|
[compat]
|
||||||
Base64 = "1.11.0"
|
Base64 = "1.11.0"
|
||||||
@@ -30,6 +29,5 @@ DataFrames = "1.7.0"
|
|||||||
GeneralUtils = "0.5.10"
|
GeneralUtils = "0.5.10"
|
||||||
HTTP = "2.4.0"
|
HTTP = "2.4.0"
|
||||||
JSON = "1.6.1"
|
JSON = "1.6.1"
|
||||||
LLMMCTS = "0.1.5"
|
|
||||||
NATS = "0.1.0"
|
NATS = "0.1.0"
|
||||||
SQLLLM = "0.2.8"
|
msghandler = "1.2.1"
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# YiemAgent
|
# 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
|
## Getting Started
|
||||||
|
|
||||||
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
|
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, NATS, DataFrames`
|
||||||
2. Create a `yiemAgent` with `loadTools("src/tools")`
|
2. Create a callable `mcpServer` struct that communicates with an MCP server via NATS
|
||||||
3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -14,21 +18,122 @@ Julia framework for building agents with tool use.
|
|||||||
src/
|
src/
|
||||||
├── YiemAgent.jl # Module entry point
|
├── YiemAgent.jl # Module entry point
|
||||||
├── type.jl # Core types (messages, tools, agent state)
|
├── 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
|
├── agentCore.jl # Agent loop, tool execution pipeline
|
||||||
├── api.jl # Public API (runAgent, takeResponse, etc.)
|
├── api.jl # Public API (promptAgent, takeResponse, followUp, stopAgent)
|
||||||
└── tools/
|
└── toolRegistry.jl # Tool store, MCP discovery, listTools registration
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tool Development
|
## MCP Protocol
|
||||||
|
|
||||||
See `src/tools/README.md` for:
|
YiemAgent uses JSON-RPC 2.0 for MCP communication. The `mcpServer` callable struct must implement:
|
||||||
- Tool anatomy (schema, execute, getTool)
|
|
||||||
- Validation hooks
|
```julia
|
||||||
- Agent loop lifecycle
|
# tools/list — returns tool definitions
|
||||||
- Self-modifying tools (`writeTool`)
|
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 |
|
||||||
|
|||||||
+227
-253
@@ -9,7 +9,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework,
|
|||||||
1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle)
|
1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle)
|
||||||
2. [Overview](#2-overview)
|
2. [Overview](#2-overview)
|
||||||
3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct)
|
3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct)
|
||||||
4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores)
|
4. [Tool Registration — Static Registration](#4-tool-registration--static-registration)
|
||||||
5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow)
|
5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow)
|
||||||
6. [Message Processing Pipeline](#6-message-processing-pipeline)
|
6. [Message Processing Pipeline](#6-message-processing-pipeline)
|
||||||
7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response)
|
7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response)
|
||||||
@@ -23,7 +23,8 @@ This document describes the complete tool lifecycle in the YiemAgent framework,
|
|||||||
15. [Self-Modifying Tools](#15-self-modifying-tools)
|
15. [Self-Modifying Tools](#15-self-modifying-tools)
|
||||||
16. [Complete End-to-End Example](#16-complete-end-to-end-example)
|
16. [Complete End-to-End Example](#16-complete-end-to-end-example)
|
||||||
17. [Tool File Contract](#17-tool-file-contract)
|
17. [Tool File Contract](#17-tool-file-contract)
|
||||||
18. [Appendix: Type Reference](#18-appendix-type-reference)
|
18. [Adding New Tools](#18-adding-new-tools)
|
||||||
|
19. [Appendix: Type Reference](#19-appendix-type-reference)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,76 +44,52 @@ tool = listTool(store) # Returns an agentTool that, when executed, lists all to
|
|||||||
**Result extraction:**
|
**Result extraction:**
|
||||||
```julia
|
```julia
|
||||||
result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x)
|
result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x)
|
||||||
# result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..."
|
# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..."
|
||||||
```
|
```
|
||||||
|
|
||||||
**Source:** `toolRegistry.jl:54-82`
|
**Source:** `toolRegistry.jl:43-98`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 2: Load — `loadTools()`
|
### Step 2: Register — `register_all_tools()`
|
||||||
|
|
||||||
Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools.
|
Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools.
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
using YiemAgent, YiemAgent.toolRegistry
|
using YiemAgent, YiemAgent.toolRegistry
|
||||||
|
|
||||||
store = toolStore(name="myAgent")
|
store = toolStore(name="myAgent")
|
||||||
tools = loadTools(store, "src/tools")
|
tools = register_all_tools(store)
|
||||||
# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools
|
# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs
|
||||||
# Also auto-registers listTools for runtime discovery
|
# Also auto-registers listTools for runtime discovery
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result extraction:**
|
**Result extraction:**
|
||||||
```julia
|
```julia
|
||||||
all_tools = getTools(store) # OrderedDict{String, agentTool}
|
all_tools = getTools(store) # OrderedDict{String, agentTool}
|
||||||
# Keys: "getTime", "getWeather", "writeTool", "listTools"
|
# Keys: "getWeather", "getTime", "writeTool", "listTools"
|
||||||
getTime_tool = all_tools["getTime"]
|
getWeather_tool = all_tools["getWeather"]
|
||||||
|
|
||||||
# Manual registration (alternative to loadTools)
|
# Manual registration (alternative to register_all_tools)
|
||||||
registerTool(store, my_tool)
|
registerTool(store, my_tool)
|
||||||
clearTools(store) # Clear all tools from store
|
clearTools(store) # Clear all tools from store
|
||||||
```
|
```
|
||||||
|
|
||||||
**Source:** `toolRegistry.jl:126-178`
|
**Source:** `toolRegistry.jl:38-40, 127-143`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 2.5: Create Agent with Tools
|
### Step 2.5: Create Agent with Tools
|
||||||
|
|
||||||
Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration.
|
Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically.
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
|
using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
|
||||||
|
|
||||||
# 1. Set up toolStore and load tools (auto-registers listTools)
|
# Create agent — tools are registered automatically via register_all_tools()
|
||||||
store = toolStore(name="myAgent")
|
|
||||||
loadTools(store, "src/tools")
|
|
||||||
|
|
||||||
# 2. Create agent — pass tools + _tool_store
|
|
||||||
agent = yiemAgent(
|
agent = yiemAgent(
|
||||||
systemPrompt = "You are a helpful assistant.",
|
|
||||||
model = my_model,
|
|
||||||
tools = getTools(store), # OrderedDict{String, agentTool}
|
|
||||||
llmCall = my_llm_call, # Function that calls the LLM API
|
llmCall = my_llm_call, # Function that calls the LLM API
|
||||||
agentEventSink = my_event_sink, # Function for TUI/logging
|
eventSink = my_event_sink, # Function for TUI/logging
|
||||||
_tool_store = store, # For runtime registerTool() calls
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual registration** (without `loadTools`):
|
|
||||||
|
|
||||||
```julia
|
|
||||||
store = toolStore(name="myAgent")
|
|
||||||
registerTool(store, getTime_tool)
|
|
||||||
registerTool(store, getWeather_tool)
|
|
||||||
registerTool(store, listTool(store)) # needed for manual registration
|
|
||||||
|
|
||||||
agent = yiemAgent(
|
|
||||||
tools = getTools(store),
|
|
||||||
llmCall = my_llm_call,
|
|
||||||
agentEventSink = my_event_sink,
|
|
||||||
_tool_store = store,
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,17 +97,15 @@ agent = yiemAgent(
|
|||||||
|
|
||||||
| Parameter | Type | Required | Purpose |
|
| Parameter | Type | Required | Purpose |
|
||||||
|-----------|------|----------|---------|
|
|-----------|------|----------|---------|
|
||||||
|
| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM |
|
||||||
|
| `eventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events |
|
||||||
| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text |
|
| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text |
|
||||||
| `model` | `llmModel` | No | LLM model config |
|
| `model` | `llmModel` | No | LLM model config |
|
||||||
| `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) |
|
|
||||||
| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history |
|
| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history |
|
||||||
| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM |
|
|
||||||
| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events |
|
|
||||||
| `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` |
|
|
||||||
|
|
||||||
Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`.
|
Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`.
|
||||||
|
|
||||||
**Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195`
|
**Source:** `type.jl:609-657`, `toolRegistry.jl:127-143`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,13 +121,13 @@ sig = nothing
|
|||||||
op = x -> x # no-op partial result callback
|
op = x -> x # no-op partial result callback
|
||||||
|
|
||||||
# Execute a loaded tool directly
|
# Execute a loaded tool directly
|
||||||
result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)
|
result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Via agent loop (production):**
|
**Via agent loop (production):**
|
||||||
```
|
```
|
||||||
user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
|
user message → promptAgent(agent, Dict("role"=>"user", "content"=>...))
|
||||||
→ _agent_loop detects message → @spawn _process_message(agent)
|
→ _agentLoop detects message → @spawn _processMessage(agent)
|
||||||
→ prepareContext → formatMsgForLLM → llmCall
|
→ prepareContext → formatMsgForLLM → llmCall
|
||||||
→ LLM returns tool_calls
|
→ LLM returns tool_calls
|
||||||
→ executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
→ executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
||||||
@@ -168,10 +143,10 @@ user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
|
|||||||
|
|
||||||
**`agentToolResult`** (raw tool output, `type.jl:429-434`):
|
**`agentToolResult`** (raw tool output, `type.jl:429-434`):
|
||||||
```julia
|
```julia
|
||||||
result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x)
|
result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x)
|
||||||
|
|
||||||
result.content[1] # textContent("Current time in Tokyo: ...")
|
result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C")
|
||||||
result.content[1].text # "Current time in Tokyo: 2026-08-10T..."
|
result.content[1].text # "Weather in Tokyo: Sunny, 22°C"
|
||||||
result.details # Dict{Any,Any}() — tool-specific metadata
|
result.details # Dict{Any,Any}() — tool-specific metadata
|
||||||
result.usage # nothing — llmUsage tracking (optional)
|
result.usage # nothing — llmUsage tracking (optional)
|
||||||
result.terminate # false — signals loop termination
|
result.terminate # false — signals loop termination
|
||||||
@@ -182,7 +157,7 @@ result.terminate # false — signals loop termination
|
|||||||
msg = batch.messages[1] # toolResultMessage
|
msg = batch.messages[1] # toolResultMessage
|
||||||
|
|
||||||
msg.toolCallId # "call-1"
|
msg.toolCallId # "call-1"
|
||||||
msg.toolName # "getTime"
|
msg.toolName # "getWeather"
|
||||||
msg.content # Vector{messageContent}
|
msg.content # Vector{messageContent}
|
||||||
msg.isError # false
|
msg.isError # false
|
||||||
msg.details # tool-specific metadata
|
msg.details # tool-specific metadata
|
||||||
@@ -204,7 +179,7 @@ Each phase has a single responsibility and produces an intermediate result:
|
|||||||
| Phase | Function | Input | Output | Purpose |
|
| Phase | Function | Input | Output | Purpose |
|
||||||
|-------|----------|-------|--------|---------|
|
|-------|----------|-------|--------|---------|
|
||||||
| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook |
|
| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook |
|
||||||
| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results |
|
| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `eventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results |
|
||||||
| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event |
|
| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event |
|
||||||
|
|
||||||
The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history.
|
The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history.
|
||||||
@@ -247,7 +222,7 @@ end
|
|||||||
|
|
||||||
```julia
|
```julia
|
||||||
execute(toolCallId::String,
|
execute(toolCallId::String,
|
||||||
args::Dict{String,Any},
|
args::AbstractDict{String, Any},
|
||||||
signal::Union{Nothing,abortSignal},
|
signal::Union{Nothing,abortSignal},
|
||||||
onPartialResult::Function)::agentToolResult
|
onPartialResult::Function)::agentToolResult
|
||||||
```
|
```
|
||||||
@@ -276,9 +251,9 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Tool Registration — Per-Agent Tool Stores
|
## 4. Tool Registration — Static Registration
|
||||||
|
|
||||||
**Source:** `toolRegistry.jl`
|
**Source:** `toolRegistry.jl`, `YiemAgent.jl`
|
||||||
|
|
||||||
### How `toolStore` Works
|
### How `toolStore` Works
|
||||||
|
|
||||||
@@ -293,36 +268,25 @@ end
|
|||||||
|
|
||||||
`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store.
|
`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store.
|
||||||
|
|
||||||
### How `loadTools(store, dir)` Works
|
### Static Registration — `register_all_tools()`
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
|
function register_all_tools(store::toolStore)::OrderedDict{String, agentTool}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Source:** `toolRegistry.jl:126-178`
|
**Source:** `YiemAgent.jl:20-29`
|
||||||
|
|
||||||
1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name)
|
1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs
|
||||||
2. **Sorts** filenames alphabetically for deterministic registration order
|
2. **Registers each tool** via `registerTool(store, tool)`
|
||||||
3. **Wraps** each file in a dynamically created submodule:
|
3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime
|
||||||
```julia
|
|
||||||
# For "getWeather.jl" → module _tool_getWeather
|
|
||||||
module _tool_getWeather
|
|
||||||
using ..type
|
|
||||||
using Dates, UUIDs, DataStructures, JSON
|
|
||||||
# (file contents here)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues
|
|
||||||
5. **Validates** the return value is an `agentTool` instance
|
|
||||||
6. **Registers** the tool in `store.tools`
|
|
||||||
7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime
|
|
||||||
|
|
||||||
### Why Submodules?
|
### Why Static?
|
||||||
|
|
||||||
Each tool file is loaded into its own **namespaced submodule**. This means:
|
Tools are **statically included** in `YiemAgent.jl` via `include()`. This means:
|
||||||
- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime`
|
- Tool functions live in the `YiemAgent` module, not in dynamically created submodules
|
||||||
- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs`
|
- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world)
|
||||||
- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected
|
- Simpler tool definition — no need to wrap in a `module ... end` block
|
||||||
|
- Better compiler optimization (inlining, type inference)
|
||||||
|
|
||||||
### Registration API
|
### Registration API
|
||||||
|
|
||||||
@@ -331,9 +295,9 @@ Each tool file is loaded into its own **namespaced submodule**. This means:
|
|||||||
store1 = toolStore(name="agent1")
|
store1 = toolStore(name="agent1")
|
||||||
store2 = toolStore(name="agent2")
|
store2 = toolStore(name="agent2")
|
||||||
|
|
||||||
# Load tools into specific stores (auto-registers listTools)
|
# Load all tools (auto-registers listTools)
|
||||||
tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only
|
tools1 = register_all_tools(store1) # all agents get the same tools
|
||||||
tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only
|
tools2 = register_all_tools(store2)
|
||||||
|
|
||||||
# Manual registration (per-store)
|
# Manual registration (per-store)
|
||||||
registerTool(store1, my_tool)
|
registerTool(store1, my_tool)
|
||||||
@@ -359,8 +323,8 @@ Each `toolStore` is completely independent — tools registered in one store do
|
|||||||
storeA = toolStore(name="A")
|
storeA = toolStore(name="A")
|
||||||
storeB = toolStore(name="B")
|
storeB = toolStore(name="B")
|
||||||
|
|
||||||
registerTool(storeA, getTime_tool)
|
registerTool(storeA, getTimeTool())
|
||||||
registerTool(storeB, getWeather_tool)
|
registerTool(storeB, getWeatherTool())
|
||||||
|
|
||||||
getTools(storeA) # only contains getTime
|
getTools(storeA) # only contains getTime
|
||||||
getTools(storeB) # only contains getWeather
|
getTools(storeB) # only contains getWeather
|
||||||
@@ -376,16 +340,15 @@ This ensures that `yiemAgent` instances with different `tool_store` references o
|
|||||||
|
|
||||||
**Source:** `agentCore.jl:35-145`
|
**Source:** `agentCore.jl:35-145`
|
||||||
|
|
||||||
The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed.
|
The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed.
|
||||||
|
|
||||||
### Channel Architecture
|
### Channel Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
yiemAgent struct contains:
|
yiemAgent struct contains:
|
||||||
- inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
|
- inputChannel (Channel, capacity 16) ← user sends messages here via promptAgent()
|
||||||
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
|
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
|
||||||
- outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
|
- outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
|
||||||
- _tool_store (toolStore) ← per-agent isolated tool registry
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Loop States
|
### Loop States
|
||||||
@@ -395,7 +358,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`):
|
|||||||
| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior |
|
| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior |
|
||||||
|-------|-----------------|-------------|----------------|-------------------|----------|
|
|-------|-----------------|-------------|----------------|-------------------|----------|
|
||||||
| 1 | `nothing` | `false` | empty | empty | Idle, waiting |
|
| 1 | `nothing` | `false` | empty | empty | Idle, waiting |
|
||||||
| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` |
|
| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` |
|
||||||
| 3 | running | `true` | empty | empty | Processing, no new input |
|
| 3 | running | `true` | empty | empty | Processing, no new input |
|
||||||
| 4 | running | `true` | has msg | empty | New message while busy → queued |
|
| 4 | running | `true` | has msg | empty | New message while busy → queued |
|
||||||
| 5 | running | `true` | empty | has msg | Follow-up while busy → queued |
|
| 5 | running | `true` | empty | has msg | Follow-up while busy → queued |
|
||||||
@@ -404,7 +367,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`):
|
|||||||
### Loop Logic (simplified)
|
### Loop Logic (simplified)
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function _agent_loop(agent::yiemAgent)
|
function _agentLoop(agent::yiemAgent)
|
||||||
while true
|
while true
|
||||||
# 1. Wait for message from inputChannel (blocking poll)
|
# 1. Wait for message from inputChannel (blocking poll)
|
||||||
msg = fetch!(agent.inputChannel) # agentCore.jl:84
|
msg = fetch!(agent.inputChannel) # agentCore.jl:84
|
||||||
@@ -414,9 +377,9 @@ function _agent_loop(agent::yiemAgent)
|
|||||||
drain both channels, break loop
|
drain both channels, break loop
|
||||||
end
|
end
|
||||||
|
|
||||||
# 3. If agent is idle, spawn _process_message
|
# 3. If agent is idle, spawn _processMessage
|
||||||
if agent._state.activeRun == false
|
if agent._state.activeRun == false
|
||||||
processingTask = Threads.@spawn _process_message(agent)
|
processingTask = Threads.@spawn _processMessage(agent)
|
||||||
agent._state.activeRun = true
|
agent._state.activeRun = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -445,12 +408,12 @@ end
|
|||||||
|
|
||||||
**Source:** `agentCore.jl:175-311`
|
**Source:** `agentCore.jl:175-311`
|
||||||
|
|
||||||
`_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline.
|
`_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline.
|
||||||
|
|
||||||
### Pipeline Steps
|
### Pipeline Steps
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function _process_message(agent::yiemAgent)::assistantMessage
|
function _processMessage(agent::yiemAgent)::assistantMessage
|
||||||
final_response = nothing
|
final_response = nothing
|
||||||
|
|
||||||
while true # Loop until LLM returns response without tool calls
|
while true # Loop until LLM returns response without tool calls
|
||||||
@@ -466,38 +429,38 @@ function _process_message(agent::yiemAgent)::assistantMessage
|
|||||||
end
|
end
|
||||||
|
|
||||||
# ── Step 2: Prepare context ─────────────────────────────────
|
# ── Step 2: Prepare context ─────────────────────────────────
|
||||||
preparedContext = agent.prepareContext(agent._state)
|
state = agentState(systemPrompt, nothing, tools, messages)
|
||||||
|
preparedContext = prepareContext(state, eventSink)
|
||||||
# Default: deep copies systemPrompt, messages, tools from agentState → agentContext
|
# Default: deep copies systemPrompt, messages, tools from agentState → agentContext
|
||||||
# Override point: filter tools, inject context, modify system prompt
|
# Override point: filter tools, inject context, modify system prompt
|
||||||
|
|
||||||
# ── Step 3: Format for LLM ──────────────────────────────────
|
# ── Step 3: Format for LLM ──────────────────────────────────
|
||||||
formatted_messages = agent.formatMsgForLLM(preparedContext)
|
formattedMessages = formatMsgForLLM(preparedContext, eventSink)
|
||||||
# Converts agentContext → Dict("messages" => [...]) in OpenAI format
|
# Converts agentContext → Dict("messages" => [...]) in OpenAI format
|
||||||
# Wraps systemPrompt as system role, converts each messageContent block
|
# Wraps systemPrompt as system role, converts each messageContent block
|
||||||
|
|
||||||
# ── Step 4: Call LLM ────────────────────────────────────────
|
# ── Step 4: Call LLM ────────────────────────────────────────
|
||||||
response = agent.llmCall(formatted_messages)
|
response = llmCall(formattedMessages)
|
||||||
# Returns assistantMessage with content::Vector{messageContent}
|
# Returns assistantMessage with content::Vector{messageContent}
|
||||||
# Each content block has a type: "text", "thinking", or "tool_call"
|
# Each content block has a type: "text", "thinking", or "tool_call"
|
||||||
|
|
||||||
# ── Step 5: Extract tool calls ──────────────────────────────
|
# ── Step 5: Extract tool calls ──────────────────────────────
|
||||||
has_tool_calls, tool_call_list = extract_tool_calls(response.content)
|
hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response)
|
||||||
# Inspects content blocks for "tool_calls" or "tool_call" Dict entries
|
# Inspects content blocks for "tool_calls" or "tool_call" Dict entries
|
||||||
|
|
||||||
# ── Step 6: Execute tool calls or return ────────────────────
|
# ── Step 6: Execute tool calls or return ────────────────────
|
||||||
if has_tool_calls && !isempty(tool_call_list)
|
if hasToolCalls && !isempty(toolCallList)
|
||||||
# Build context and config
|
# Build context and config
|
||||||
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
|
context = agentContext(systemPrompt, messages, tools)
|
||||||
config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...)
|
config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
|
||||||
signal = nothing
|
signal = abortSignal(false)
|
||||||
emit = agent.agentEventSink
|
|
||||||
|
|
||||||
# Execute tool calls (sequential or parallel)
|
# Execute tool calls (sequential or parallel)
|
||||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, eventSink)
|
||||||
|
|
||||||
# Save results to conversation history
|
# Save results to conversation history
|
||||||
for tool_result in batch.messages
|
for tool_result in batch.messages
|
||||||
push!(agent._state.messages, tool_result)
|
push!(messages, tool_result)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check termination
|
# Check termination
|
||||||
@@ -517,10 +480,6 @@ function _process_message(agent::yiemAgent)::assistantMessage
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug Note
|
|
||||||
|
|
||||||
There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Tool Call Extraction from LLM Response
|
## 7. Tool Call Extraction from LLM Response
|
||||||
@@ -652,52 +611,30 @@ function prepareToolCall(
|
|||||||
function executePreparedToolCall(
|
function executePreparedToolCall(
|
||||||
prep::preparedToolCall,
|
prep::preparedToolCall,
|
||||||
signal::Union{Nothing, abortSignal},
|
signal::Union{Nothing, abortSignal},
|
||||||
emit::Function,
|
eventSink,
|
||||||
)::executedOutcome
|
)::executedOutcome
|
||||||
```
|
```
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. **Initialize streaming state:**
|
1. **Call `tool.execute()`:**
|
||||||
```julia
|
|
||||||
updateEvents = promise[] # vector to collect update event handles
|
|
||||||
accepting = true # guard to prevent duplicate emissions
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Call `tool.execute()`:**
|
|
||||||
```julia
|
```julia
|
||||||
result = prep.tool.execute(
|
result = prep.tool.execute(
|
||||||
prep.toolCall.id,
|
prep.toolCall.id,
|
||||||
prep.args,
|
prep.args,
|
||||||
signal,
|
signal,
|
||||||
partialResult -> begin
|
eventSink # serves as onPartialResult callback
|
||||||
if accepting
|
|
||||||
push!(updateEvents,
|
|
||||||
emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name,
|
|
||||||
prep.toolCall.arguments, partialResult)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
)
|
)
|
||||||
```
|
|
||||||
|
|
||||||
3. **Wait for streaming to settle:**
|
|
||||||
```julia
|
|
||||||
accepting = false
|
|
||||||
wait.(updateEvents) # wait for all pending update event handlers
|
|
||||||
return executedOutcome(result, false)
|
return executedOutcome(result, false)
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **On error:**
|
2. **On error:**
|
||||||
```julia
|
```julia
|
||||||
catch err
|
catch err
|
||||||
accepting = false
|
|
||||||
wait.(updateEvents)
|
|
||||||
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted.
|
|
||||||
|
|
||||||
### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()`
|
### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()`
|
||||||
|
|
||||||
**Source:** `agentCore.jl:675-706`
|
**Source:** `agentCore.jl:675-706`
|
||||||
@@ -746,19 +683,6 @@ function finalizeExecutedToolCall(
|
|||||||
|
|
||||||
**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status.
|
**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status.
|
||||||
|
|
||||||
### 8.4 Emission — `emitToolExecutionEnd()`
|
|
||||||
|
|
||||||
**Source:** `agentCore.jl:736-739`
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function)
|
|
||||||
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
|
|
||||||
finalized.result, finalized.isError))
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
This is called immediately after finalization, before building the `toolResultMessage`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Execution Modes — Sequential vs Parallel
|
## 9. Execution Modes — Sequential vs Parallel
|
||||||
@@ -774,7 +698,7 @@ function executeToolCalls(
|
|||||||
toolCalls::Vector{agentToolCall},
|
toolCalls::Vector{agentToolCall},
|
||||||
config::agentLoopConfig,
|
config::agentLoopConfig,
|
||||||
signal::Union{Nothing, abortSignal},
|
signal::Union{Nothing, abortSignal},
|
||||||
emit::Function,
|
eventSink,
|
||||||
)::agentToolCallBatch
|
)::agentToolCallBatch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -809,18 +733,15 @@ function executeToolCallsSequential(...)::agentToolCallBatch
|
|||||||
messages = toolResultMessage[]
|
messages = toolResultMessage[]
|
||||||
|
|
||||||
for tc in toolCalls
|
for tc in toolCalls
|
||||||
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
|
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
|
||||||
|
|
||||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
|
|
||||||
|
|
||||||
if prep isa immediateOutcome
|
if prep isa immediateOutcome
|
||||||
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
||||||
else
|
else
|
||||||
executed = executePreparedToolCall(prep, signal, emit)
|
executed = executePreparedToolCall(prep, signal, eventSink)
|
||||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
||||||
end
|
end
|
||||||
|
|
||||||
emitToolExecutionEnd(finalized, emit)
|
|
||||||
push!(messages, createToolResultMessage(finalized))
|
push!(messages, createToolResultMessage(finalized))
|
||||||
push!(finalizedCalls, finalized)
|
push!(finalizedCalls, finalized)
|
||||||
|
|
||||||
@@ -842,19 +763,15 @@ function executeToolCallsParallel(...)::agentToolCallBatch
|
|||||||
entries = union{finalizedOutcome, task{finalizedOutcome}}[]
|
entries = union{finalizedOutcome, task{finalizedOutcome}}[]
|
||||||
|
|
||||||
for tc in toolCalls
|
for tc in toolCalls
|
||||||
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
|
prep = prepareToolCall(context, assistantMsg, tc, config, signal, eventSink)
|
||||||
|
|
||||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
|
|
||||||
|
|
||||||
if prep isa immediateOutcome
|
if prep isa immediateOutcome
|
||||||
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
||||||
emitToolExecutionEnd(finalized, emit)
|
|
||||||
push!(entries, finalized) # immediate outcome — no task
|
push!(entries, finalized) # immediate outcome — no task
|
||||||
else
|
else
|
||||||
task = task() do
|
task = task() do
|
||||||
executed = executePreparedToolCall(prep, signal, emit)
|
executed = executePreparedToolCall(prep, signal, eventSink)
|
||||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
||||||
emitToolExecutionEnd(finalized, emit)
|
|
||||||
return finalized
|
return finalized
|
||||||
end
|
end
|
||||||
schedule(task)
|
schedule(task)
|
||||||
@@ -923,12 +840,12 @@ From the type documentation (`type.jl:803-815`):
|
|||||||
| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) |
|
| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) |
|
||||||
| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` |
|
| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` |
|
||||||
|
|
||||||
### Batch Processing in `_process_message()`
|
### Batch Processing in `_processMessage()`
|
||||||
|
|
||||||
**Source:** `agentCore.jl:266-307`
|
**Source:** `agentCore.jl:266-307`
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink)
|
||||||
|
|
||||||
# Save results to conversation history
|
# Save results to conversation history
|
||||||
for tool_result in batch.messages
|
for tool_result in batch.messages
|
||||||
@@ -985,9 +902,9 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
|
|||||||
f.result.content, # content (Vector{messageContent})
|
f.result.content, # content (Vector{messageContent})
|
||||||
f.result.details, # details
|
f.result.details, # details
|
||||||
f.result.usage, # usage
|
f.result.usage, # usage
|
||||||
get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools)
|
nothing, # addedToolNames (for dynamic tools)
|
||||||
f.isError, # isError
|
f.isError, # isError
|
||||||
nowMillis(), # timestamp
|
now(), # timestamp
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
@@ -1048,9 +965,6 @@ Tool call fails at any phase
|
|||||||
└─────────────────────────┘
|
└─────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
emitToolExecutionEnd(finalized, emit)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
createToolResultMessage(finalized)
|
createToolResultMessage(finalized)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -1119,17 +1033,17 @@ end
|
|||||||
|-------|-------------|------|
|
|-------|-------------|------|
|
||||||
| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call |
|
| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call |
|
||||||
| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` |
|
| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` |
|
||||||
| `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call |
|
| `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call |
|
||||||
|
|
||||||
### Event Sink
|
### Event Sink
|
||||||
|
|
||||||
The `emit` function is passed through the entire call chain:
|
The `eventSink` function is passed through the entire call chain:
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
emit = agent.agentEventSink # set during yiemAgent construction
|
eventSink = agent.eventSink # set during yiemAgent construction
|
||||||
```
|
```
|
||||||
|
|
||||||
The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by:
|
The `eventSink` function is a user-provided callback that receives all events. This is typically used by:
|
||||||
- **TUI (Terminal UI):** Display real-time progress, tool names, results
|
- **TUI (Terminal UI):** Display real-time progress, tool names, results
|
||||||
- **Logging systems:** Record tool execution history
|
- **Logging systems:** Record tool execution history
|
||||||
- **Monitoring:** Track tool usage, execution times, error rates
|
- **Monitoring:** Track tool usage, execution times, error rates
|
||||||
@@ -1143,12 +1057,12 @@ The `agentEventSink` function is a user-provided callback that receives all even
|
|||||||
|
|
||||||
| Hook | Signature | Called | Purpose |
|
| Hook | Signature | Called | Purpose |
|
||||||
|------|-----------|--------|---------|
|
|------|-----------|--------|---------|
|
||||||
| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
|
| `prepareContext` | `(state::agentState, eventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
|
||||||
| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
|
| `formatMsgForLLM` | `(ctx::agentContext, eventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
|
||||||
| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API |
|
| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API |
|
||||||
| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort |
|
| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort |
|
||||||
| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
|
| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
|
||||||
| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring |
|
| `eventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring |
|
||||||
|
|
||||||
### `beforeToolCall` Hook
|
### `beforeToolCall` Hook
|
||||||
|
|
||||||
@@ -1211,7 +1125,7 @@ end
|
|||||||
**Source:** `utils.jl:111-125`
|
**Source:** `utils.jl:111-125`
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function prepareContext(state::agentState)::agentContext
|
function prepareContext(state::agentState, eventSink)::agentContext
|
||||||
# TODO: filter tools from state.tools based on user intent
|
# TODO: filter tools from state.tools based on user intent
|
||||||
filteredTools = state.tools
|
filteredTools = state.tools
|
||||||
|
|
||||||
@@ -1238,7 +1152,7 @@ end
|
|||||||
Default implementation converts `agentContext` to OpenAI-compatible format:
|
Default implementation converts `agentContext` to OpenAI-compatible format:
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
function formatMsgForLLM(ctx::agentContext, eventSink)::Dict{String, Any}
|
||||||
messages = Vector{Dict{String, Any}}()
|
messages = Vector{Dict{String, Any}}()
|
||||||
|
|
||||||
# System prompt as system message
|
# System prompt as system message
|
||||||
@@ -1280,14 +1194,14 @@ The framework supports tools that modify the tool system itself at runtime.
|
|||||||
1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal
|
1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal
|
||||||
2. Indents `executeCode` with 4 spaces
|
2. Indents `executeCode` with 4 spaces
|
||||||
3. Wraps it inside `function executeTool(...)::agentToolResult ... end`
|
3. Wraps it inside `function executeTool(...)::agentToolResult ... end`
|
||||||
4. Appends `getTool()` returning an `agentTool` struct
|
4. Appends `writeToolTool()` returning an `agentTool` struct
|
||||||
5. Writes the combined string to `src/tools/<name>.jl`
|
5. Writes the combined string to `src/tools/<name>.jl`
|
||||||
|
|
||||||
### `listTool` — Discover Available Tools
|
### `listTool` — Discover Available Tools
|
||||||
|
|
||||||
**Source:** `toolRegistry.jl:55-82`
|
**Source:** `toolRegistry.jl:43-98`
|
||||||
|
|
||||||
Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`.
|
Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`.
|
||||||
|
|
||||||
### Self-Tooling Workflow
|
### Self-Tooling Workflow
|
||||||
|
|
||||||
@@ -1301,9 +1215,11 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT
|
|||||||
- executeCode: "query = args[\"query\"]\nresult = search(query)\n..."
|
- executeCode: "query = args[\"query\"]\nresult = search(query)\n..."
|
||||||
- (optional) validateCode, prepareCode
|
- (optional) validateCode, prepareCode
|
||||||
3. writeTool generates src/tools/searchWine.jl
|
3. writeTool generates src/tools/searchWine.jl
|
||||||
4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file
|
4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
|
||||||
5. Agent calls searchWine(query="cabernet")
|
5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl
|
||||||
6. Result: "Found 5 cabernet wines..."
|
6. Developer restarts Julia — new tool is loaded
|
||||||
|
7. Agent calls searchWine(query="cabernet")
|
||||||
|
8. Result: "Found 5 cabernet wines..."
|
||||||
```
|
```
|
||||||
|
|
||||||
### `writeTool` Input Schema
|
### `writeTool` Input Schema
|
||||||
@@ -1327,15 +1243,15 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT
|
|||||||
|
|
||||||
```
|
```
|
||||||
USER SENDS MESSAGE
|
USER SENDS MESSAGE
|
||||||
└─> runAgent(agent, "What's the weather in Tokyo?")
|
└─> promptAgent(agent, "What's the weather in Tokyo?")
|
||||||
└─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))
|
└─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))
|
||||||
|
|
||||||
|
|
||||||
LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
|
LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
|
||||||
└─> _agent_loop: detects msg in inputChannel
|
└─> _agentLoop: detects msg in inputChannel
|
||||||
└─> Threads.@spawn _process_message(agent)
|
└─> @spawn _processMessage(agent)
|
||||||
|
|
||||||
── _process_message ──────────────────────────────────────────────
|
── _processMessage ──────────────────────────────────────────────
|
||||||
│
|
│
|
||||||
│ Step 1: Drain inputChannel
|
│ Step 1: Drain inputChannel
|
||||||
│ raw_msg = Dict("role" => "user", "content" => [...])
|
│ raw_msg = Dict("role" => "user", "content" => [...])
|
||||||
@@ -1367,36 +1283,34 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
|
|||||||
│
|
│
|
||||||
│ Step 6: Execute tool calls
|
│ Step 6: Execute tool calls
|
||||||
│ context = agentContext(systemPrompt, messages, tools)
|
│ context = agentContext(systemPrompt, messages, tools)
|
||||||
│ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential")
|
│ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
|
||||||
│ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit)
|
│ batch = executeToolCalls(context, response, tool_call_list, config, signal, eventSink)
|
||||||
|
|
||||||
|
|
||||||
|
LOOP ITERATION 1 — executeToolCallsSequential
|
||||||
│
|
│
|
||||||
│ ── executeToolCallsSequential ──────────────────────────────
|
│ For tc = agentToolCall("call_1", "getWeather", ...):
|
||||||
│ │
|
│
|
||||||
│ │ For tc = agentToolCall("call_1", "getWeather", ...):
|
│ PREPARE:
|
||||||
│ │
|
│ tool = context.tools["getWeather"] → found!
|
||||||
│ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"}))
|
│ validatedArgs = validateToolArguments(tool, tc)
|
||||||
│ │
|
│ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes
|
||||||
│ │ PREPARE:
|
│ beforeToolCall_hook(...) → nothing (skipped)
|
||||||
│ │ tool = context.tools["getWeather"] → found!
|
│ → preparedToolCall(tool, tc, {"city" => "Tokyo"})
|
||||||
│ │ validatedArgs = validateToolArguments(tool, tc)
|
│
|
||||||
│ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes
|
│ EXECUTE:
|
||||||
│ │ beforeToolCall_hook(...) → nothing (skipped)
|
│ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, eventSink)
|
||||||
│ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"})
|
│ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
|
||||||
│ │
|
│ → executedOutcome(result, false)
|
||||||
│ │ EXECUTE:
|
│
|
||||||
│ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult)
|
│ FINALIZE:
|
||||||
│ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
|
│ afterToolCall_hook(...) → nothing (skipped)
|
||||||
│ │ → executedOutcome(result, false)
|
│ → finalizedOutcome(tc, result, false)
|
||||||
│ │
|
│
|
||||||
│ │ FINALIZE:
|
│ msg = createToolResultMessage(finalized)
|
||||||
│ │ afterToolCall_hook(...) → nothing (skipped)
|
│ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
|
||||||
│ │ → finalizedOutcome(tc, result, false)
|
│
|
||||||
│ │
|
└─> agentToolCallBatch([msg], false)
|
||||||
│ │ emit(toolExecEndEvent("call_1", "getWeather", result, false))
|
|
||||||
│ │ msg = createToolResultMessage(finalized)
|
|
||||||
│ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
|
|
||||||
│ │
|
|
||||||
│ └─> agentToolCallBatch([msg], false)
|
|
||||||
│
|
│
|
||||||
│ Save results:
|
│ Save results:
|
||||||
│ for tool_result in batch.messages
|
│ for tool_result in batch.messages
|
||||||
@@ -1408,7 +1322,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
|
|||||||
|
|
||||||
|
|
||||||
LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
|
LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
|
||||||
── _process_message (second iteration) ───────────────────────────
|
── _processMessage (second iteration) ───────────────────────────
|
||||||
│
|
│
|
||||||
│ Step 1: Drain inputChannel → empty
|
│ Step 1: Drain inputChannel → empty
|
||||||
│
|
│
|
||||||
@@ -1437,12 +1351,12 @@ AGENT LOOP: SEND RESPONSE TO USER
|
|||||||
|
|
||||||
## 17. Tool File Contract
|
## 17. Tool File Contract
|
||||||
|
|
||||||
Each `.jl` file in `src/tools/` must conform to the following contract:
|
Each `.jl` file in `src/tools/` follows a flat, static structure:
|
||||||
|
|
||||||
### Required Function
|
### Required Function
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function getTool()::agentTool
|
function <name>Tool()::agentTool
|
||||||
# Must return an agentTool instance
|
# Must return an agentTool instance
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
@@ -1451,20 +1365,20 @@ end
|
|||||||
|
|
||||||
```julia
|
```julia
|
||||||
# Argument preparation (before validation)
|
# Argument preparation (before validation)
|
||||||
function prepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
function <name>PrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any}
|
||||||
# Return modified args, or args unchanged
|
# Return modified args, or args unchanged
|
||||||
return args
|
return args
|
||||||
end
|
end
|
||||||
|
|
||||||
# Custom validation (before execution)
|
# Custom validation (before execution)
|
||||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
function <name>ValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String}
|
||||||
# Return nothing to pass, or error string to fail
|
# Return nothing to pass, or error string to fail
|
||||||
return nothing
|
return nothing
|
||||||
end
|
end
|
||||||
|
|
||||||
# Core execution
|
# Core execution
|
||||||
function executeTool(toolCallId::String,
|
function <name>Execute(toolCallId::String,
|
||||||
args::Dict{String,Any},
|
args::AbstractDict{String, Any},
|
||||||
signal::Union{Nothing,abortSignal},
|
signal::Union{Nothing,abortSignal},
|
||||||
onPartialResult::Function)::agentToolResult
|
onPartialResult::Function)::agentToolResult
|
||||||
# Return agentToolResult with content, details, usage, terminate
|
# Return agentToolResult with content, details, usage, terminate
|
||||||
@@ -1477,7 +1391,8 @@ end
|
|||||||
```julia
|
```julia
|
||||||
# src/tools/myTool.jl
|
# src/tools/myTool.jl
|
||||||
|
|
||||||
using Dates # ← tool declares its own dependencies (registry injects only `using ..type`)
|
using .type # ← provides agentTool, textContent, agentToolResult, etc.
|
||||||
|
using Dates # ← tool's own dependencies
|
||||||
|
|
||||||
# Optional: helper functions
|
# Optional: helper functions
|
||||||
function helper_function(...)
|
function helper_function(...)
|
||||||
@@ -1485,24 +1400,24 @@ function helper_function(...)
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Optional: prepareArguments
|
# Optional: prepareArguments
|
||||||
function prepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
function myToolPrepareArguments(args::AbstractDict{String, Any})::AbstractDict{String, Any}
|
||||||
return args
|
return args
|
||||||
end
|
end
|
||||||
|
|
||||||
# Optional: validateRequiredArgs
|
# Optional: validateRequiredArgs
|
||||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
function myToolValidateRequiredArgs(args::AbstractDict{String, Any})::Union{Nothing,String}
|
||||||
return nothing
|
return nothing
|
||||||
end
|
end
|
||||||
|
|
||||||
# Required: executeTool
|
# Required: execute function
|
||||||
function executeTool(toolCallId::String, args::Dict{String,Any},
|
function myToolExecute(toolCallId::String, args::AbstractDict{String, Any},
|
||||||
signal::Union{Nothing,abortSignal},
|
signal::Union{Nothing,abortSignal},
|
||||||
onPartialResult::Function)::agentToolResult
|
onPartialResult::Function)::agentToolResult
|
||||||
...
|
...
|
||||||
end
|
end
|
||||||
|
|
||||||
# Required: getTool
|
# Required: getTool function
|
||||||
function getTool()::agentTool
|
function myToolTool()::agentTool
|
||||||
return agentTool(
|
return agentTool(
|
||||||
name = "myTool",
|
name = "myTool",
|
||||||
label = "My Tool",
|
label = "My Tool",
|
||||||
@@ -1512,9 +1427,9 @@ function getTool()::agentTool
|
|||||||
"properties" => Dict(...),
|
"properties" => Dict(...),
|
||||||
"required" => [...]
|
"required" => [...]
|
||||||
),
|
),
|
||||||
execute = executeTool,
|
execute = myToolExecute,
|
||||||
prepareArguments = prepareArguments,
|
prepareArguments = myToolPrepareArguments,
|
||||||
validateRequiredArgs = validateRequiredArgs,
|
validateRequiredArgs = myToolValidateRequiredArgs,
|
||||||
parallelToolExecute = false
|
parallelToolExecute = false
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -1522,51 +1437,110 @@ end
|
|||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements.
|
Each tool file declares its own dependencies via `using` statements:
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
# src/tools/getTime.jl
|
# src/tools/getTime.jl
|
||||||
|
using .type
|
||||||
using Dates
|
using Dates
|
||||||
|
|
||||||
function executeTool(...)
|
function getTimeExecute(...)
|
||||||
now() # Dates.now requires `using Dates`
|
now() # Dates.now requires `using Dates`
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
# src/tools/myApiTool.jl
|
# src/tools/myApiTool.jl
|
||||||
|
using .type
|
||||||
using HTTP, JSON
|
using HTTP, JSON
|
||||||
|
|
||||||
function executeTool(...)
|
function myApiToolExecute(...)
|
||||||
response = HTTP.get("https://api.example.com")
|
response = HTTP.get("https://api.example.com")
|
||||||
data = JSON.parse(String(response.body))
|
data = JSON.parse(String(response.body))
|
||||||
...
|
...
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### Module Isolation
|
### Why Flat Modules?
|
||||||
|
|
||||||
When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available:
|
All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means:
|
||||||
|
- All functions live in the `YiemAgent` module, avoiding world-age issues
|
||||||
```julia
|
- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available
|
||||||
# User writes in src/tools/myTool.jl:
|
- Functions are named with a `<toolName>` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`)
|
||||||
using Dates, HTTP, JSON # ← tool's own dependencies
|
- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration
|
||||||
|
|
||||||
function getTool()::agentTool ... end
|
|
||||||
|
|
||||||
# loadTools() creates:
|
|
||||||
module _tool_myTool
|
|
||||||
using ..type # ← injected by registry (core types only)
|
|
||||||
using Dates, HTTP, JSON # ← from tool file
|
|
||||||
# (user's code here)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Appendix: Type Reference
|
## 18. Adding New Tools
|
||||||
|
|
||||||
|
To add a new tool (e.g., `searchWine.jl`):
|
||||||
|
|
||||||
|
### Step 1: Create `src/tools/searchWine.jl`
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using .type
|
||||||
|
# using AdditionalPkg # add if needed
|
||||||
|
|
||||||
|
function searchWineExecute(toolCallId::String, args::AbstractDict{String, Any},
|
||||||
|
signal::Union{Nothing,abortSignal}, onPartialResult)
|
||||||
|
query = get(args, "query", "")
|
||||||
|
result = search_wine_db(query)
|
||||||
|
return agentToolResult(
|
||||||
|
[textContent("Found $(length(result)) wines")],
|
||||||
|
Dict{Any,Any}("count" => length(result)),
|
||||||
|
nothing, false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
function searchWineTool()::agentTool
|
||||||
|
return agentTool(
|
||||||
|
name = "searchWine",
|
||||||
|
label = "Search Wine",
|
||||||
|
description = "Search wine database...",
|
||||||
|
inputSchema = Dict{String,Any}(
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Dict(
|
||||||
|
"query" => Dict("type" => "string", "description" => "Search query")
|
||||||
|
),
|
||||||
|
"required" => ["query"]
|
||||||
|
),
|
||||||
|
execute = searchWineExecute,
|
||||||
|
prepareArguments = nothing,
|
||||||
|
validateRequiredArgs = nothing,
|
||||||
|
parallelToolExecute = false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Include in `src/YiemAgent.jl` (before `toolRegistry.jl`)
|
||||||
|
|
||||||
|
```julia
|
||||||
|
include("tools/getWeather.jl")
|
||||||
|
include("tools/getTime.jl")
|
||||||
|
include("tools/searchWine.jl") # ← add here
|
||||||
|
include("tools/writeTool.jl")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Register in `register_all_tools()` in `YiemAgent.jl`
|
||||||
|
|
||||||
|
```julia
|
||||||
|
function register_all_tools(store::toolRegistry.toolStore)
|
||||||
|
registerTool(store, getWeatherTool())
|
||||||
|
registerTool(store, getTimeTool())
|
||||||
|
registerTool(store, searchWineTool()) # ← add here
|
||||||
|
registerTool(store, writeToolTool())
|
||||||
|
registerTool(store, listTool(store))
|
||||||
|
return store.tools
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Restart Julia
|
||||||
|
|
||||||
|
The module recompiles on next load. The new tool is available immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Appendix: Type Reference
|
||||||
|
|
||||||
### Message Types
|
### Message Types
|
||||||
|
|
||||||
@@ -1604,7 +1578,7 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli
|
|||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) |
|
| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) |
|
||||||
| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) |
|
| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) |
|
||||||
| `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) |
|
| `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) |
|
||||||
| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) |
|
| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) |
|
||||||
| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
|
| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
|
||||||
| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) |
|
| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) |
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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?
|
||||||
+5
-28
@@ -1,11 +1,7 @@
|
|||||||
module YiemAgent
|
module YiemAgent
|
||||||
|
|
||||||
# export agent
|
|
||||||
|
|
||||||
|
|
||||||
"""Order by dependencies of each file. The 1st included file must not depend on any other
|
"""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.
|
files and each file can only depend on the file included before it."""
|
||||||
"""
|
|
||||||
|
|
||||||
include("type.jl")
|
include("type.jl")
|
||||||
using .type
|
using .type
|
||||||
@@ -16,38 +12,19 @@ module YiemAgent
|
|||||||
include("toolRegistry.jl")
|
include("toolRegistry.jl")
|
||||||
using .toolRegistry
|
using .toolRegistry
|
||||||
|
|
||||||
# include("llmfunction.jl")
|
|
||||||
# using .llmfunction
|
|
||||||
|
|
||||||
include("agentCore.jl")
|
include("agentCore.jl")
|
||||||
using .agentCore
|
using .agentCore
|
||||||
|
|
||||||
include("api.jl")
|
include("api.jl")
|
||||||
using .api
|
using .api
|
||||||
|
|
||||||
|
export promptAgent, takeResponse, followUp, stopAgent
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------- 100 --------------------------------------------- #
|
# ---------------------------------------------- 100 --------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
end # end module
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
end # module YiemAgent_v1
|
|
||||||
|
|||||||
+603
-216
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
module api
|
module api
|
||||||
|
|
||||||
export prompt
|
export promptAgent, takeResponse, followUp, stopAgent
|
||||||
|
|
||||||
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
|
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
|
||||||
DataFrames
|
DataFrames
|
||||||
@@ -18,7 +18,7 @@ The agent processes messages from `inputChannel` in the background task.
|
|||||||
|
|
||||||
# Arguments
|
# Arguments
|
||||||
- `agent::yiemAgent`: The agent instance to send a message to
|
- `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
|
# Returns
|
||||||
- The same `agent` instance for chaining
|
- The same `agent` instance for chaining
|
||||||
@@ -29,11 +29,11 @@ The agent processes messages from `inputChannel` in the background task.
|
|||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
```jldoctest
|
```jldoctest
|
||||||
julia> runAgent(agent, "Hello!")
|
julia> promptAgent(agent, Dict("role" => "user", "content" => "Hello!"))
|
||||||
yiemAgent(...)
|
yiemAgent(...)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
function runAgent(agent::yiemAgent, msg)
|
function promptAgent(agent::yiemAgent, msg::AbstractDict{String, Any})
|
||||||
put!(agent.inputChannel, msg)
|
put!(agent.inputChannel, msg)
|
||||||
return agent
|
return agent
|
||||||
end
|
end
|
||||||
@@ -50,7 +50,7 @@ Blocks until the agent sends a response.
|
|||||||
- An `assistantMessage` instance representing the agent's response
|
- An `assistantMessage` instance representing the agent's response
|
||||||
|
|
||||||
# Notes
|
# 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
|
# Examples
|
||||||
```jldoctest
|
```jldoctest
|
||||||
@@ -70,23 +70,23 @@ and before any tool call results are sent.
|
|||||||
|
|
||||||
# Arguments
|
# Arguments
|
||||||
- `agent::yiemAgent`: The agent instance to send a follow-up message to
|
- `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
|
# Returns
|
||||||
- The same `agent` instance for chaining
|
- The same `agent` instance for chaining
|
||||||
|
|
||||||
# Notes
|
# 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.
|
messages while the agent is processing.
|
||||||
- Follow-up messages are buffered in a separate channel (capacity 32 by default).
|
- Follow-up messages are buffered in a separate channel (capacity 32 by default).
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
```jldoctest
|
```jldoctest
|
||||||
julia> followUp(agent, "Also consider red wines")
|
julia> followUp(agent, Dict("role" => "user", "content" => "Also consider red wines"))
|
||||||
yiemAgent(...)
|
yiemAgent(...)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
function followUp(agent::yiemAgent, msg)
|
function followUp(agent::yiemAgent, msg::AbstractDict{String, Any})
|
||||||
put!(agent.followUpChannel, msg)
|
put!(agent.followUpChannel, msg)
|
||||||
return agent
|
return agent
|
||||||
end
|
end
|
||||||
@@ -116,7 +116,7 @@ julia> stopAgent(agent)
|
|||||||
function stopAgent(agent::yiemAgent)
|
function stopAgent(agent::yiemAgent)
|
||||||
put!(agent.inputChannel, :shutdown)
|
put!(agent.inputChannel, :shutdown)
|
||||||
try
|
try
|
||||||
fetch(agent._agent_loop)
|
fetch(agent._agentLoop)
|
||||||
catch e
|
catch e
|
||||||
if e isa TaskFailedException
|
if e isa TaskFailedException
|
||||||
rethrow(e)
|
rethrow(e)
|
||||||
|
|||||||
+242
-222
@@ -1,271 +1,291 @@
|
|||||||
module toolRegistry
|
module toolRegistry
|
||||||
|
|
||||||
export toolStore, loadTools, registerTool, getTools, clearTools, listTool
|
export registerTool, registerAllTools, clearTools, listTool
|
||||||
|
|
||||||
using Dates
|
using Dates
|
||||||
using JSON, DataStructures
|
using JSON, DataStructures
|
||||||
using ..type
|
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
|
# ── MCP helper functions ────────────────────────────────────────────
|
||||||
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
|
|
||||||
- `name::String` — identifier for debugging/logs
|
|
||||||
"""
|
"""
|
||||||
struct toolStore
|
Extract text from MCP tool result content array.
|
||||||
tools::OrderedDict{String, agentTool}
|
|
||||||
name::String
|
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
|
end
|
||||||
|
|
||||||
"""
|
"""
|
||||||
toolStore(; name="default") -> toolStore
|
Wrap an MCP tool definition as an `agentTool`.
|
||||||
|
|
||||||
Create a new empty tool store.
|
The returned tool's `execute` function calls the MCP server's "tools/call"
|
||||||
|
method with the validated arguments.
|
||||||
# Keyword Arguments
|
|
||||||
- `name::String`: Display name for logging (default: `"default"`)
|
|
||||||
|
|
||||||
# Example
|
|
||||||
```julia
|
|
||||||
julia> store = toolStore(name="agent1")
|
|
||||||
toolStore(OrderedDict{String, agentTool}(), "agent1")
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
function toolStore(; name::String="default")::toolStore
|
function _wrap_mcp_tool(mcpserver, tool_def::AbstractDict{String, Any}; eventSink=nothing, sessionId=nothing)::agentTool
|
||||||
toolStore(OrderedDict{String, agentTool}(), name)
|
name = tool_def["name"]
|
||||||
|
title = get(tool_def, "title", get(tool_def, "label", name))
|
||||||
|
desc = get(tool_def, "description", "")
|
||||||
|
input_schema = get(tool_def, "inputSchema", Dict{String,Any}())
|
||||||
|
|
||||||
|
# Normalize inputSchema to OpenAI function format
|
||||||
|
if haskey(input_schema, "properties") && input_schema["type"] == "object"
|
||||||
|
params = Dict(
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => input_schema["properties"],
|
||||||
|
"required" => get(input_schema, "required", Any[]),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
params = Dict(
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Dict{String,Any}(),
|
||||||
|
"required" => Any[],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
return agentTool(
|
||||||
|
name=name,
|
||||||
|
label=title,
|
||||||
|
description=desc,
|
||||||
|
inputSchema=params,
|
||||||
|
execute=(toolCallId::String, args::AbstractDict{String, Any},
|
||||||
|
signal::Union{Nothing,abortSignal},
|
||||||
|
eventSink) -> begin
|
||||||
|
try
|
||||||
|
response = mcpserver("tools/call", name, args, 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
|
||||||
|
end,
|
||||||
|
prepareArguments=nothing,
|
||||||
|
validateRequiredArgs=nothing,
|
||||||
|
parallelToolExecute=false,
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
"""
|
"""
|
||||||
listTool(store::toolStore) -> agentTool
|
Discover and register MCP tools into `store.tools`.
|
||||||
|
|
||||||
Return an `agentTool` definition for listing registered tools.
|
Queries the MCP server via `mcpserver("tools/list")` (JSON-RPC 2.0 format),
|
||||||
|
parses the response envelope, and registers each discovered tool.
|
||||||
|
Handles pagination via `nextCursor`. Skips tools already registered.
|
||||||
|
|
||||||
Each call produces a **new** tool object that captures (closes over)
|
# Returns
|
||||||
`store`. `loadTools` auto-registers one so the LLM can discover tools
|
- `Int`: number of new tools registered
|
||||||
at runtime.
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
tools_array = response["tools"]
|
||||||
|
cursor = get(response, "nextCursor", nothing)
|
||||||
|
|
||||||
|
for tool_def in tools_array
|
||||||
|
name = tool_def["name"]
|
||||||
|
if haskey(tools, name)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, 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
|
||||||
|
wrapped = _wrap_mcp_tool(mcpserver, tool_def, eventSink=eventSink, sessionId=sessionId)
|
||||||
|
tools[name] = wrapped
|
||||||
|
new_count += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
println("[toolRegistry] Discovered $new_count MCP tools. Total: $(length(tools))")
|
||||||
|
catch e
|
||||||
|
bt = catch_backtrace()
|
||||||
|
err_msg = sprint() do io
|
||||||
|
showerror(io, e, bt)
|
||||||
|
println(io)
|
||||||
|
end
|
||||||
|
|
||||||
|
eventSink(err_msg)
|
||||||
|
|
||||||
|
errMsg = sprint(showerror, e)
|
||||||
|
println("[toolRegistry] MCP tools/list failed: $errMsg")
|
||||||
|
end
|
||||||
|
|
||||||
|
return new_count
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||||
|
|
||||||
|
Add `tool` to `tools`, overwriting any existing tool with the same name.
|
||||||
|
|
||||||
# Arguments
|
# Arguments
|
||||||
- `store`: The tool store whose tools will be listed when the tool runs
|
- `tools`: Tool dict to modify
|
||||||
|
- `tool`: The `agentTool` to register
|
||||||
|
|
||||||
# Example
|
# Returns
|
||||||
```julia
|
- The same `tools` dict (modified in place)
|
||||||
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
|
function registerTool(tools::OrderedDict{String, agentTool}, tool::agentTool; eventSink=nothing
|
||||||
|
)::OrderedDict{String, agentTool}
|
||||||
|
tools[tool.name] = tool
|
||||||
|
println("[toolRegistry] Registered tool: $(tool.name)")
|
||||||
|
return tools
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
Remove all tools from the dict.
|
||||||
|
|
||||||
|
# Arguments
|
||||||
|
- `tools`: Tool dict to clear
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
- `nothing`
|
||||||
|
"""
|
||||||
|
function clearTools(tools::OrderedDict{String, agentTool})::Nothing
|
||||||
|
empty!(tools)
|
||||||
|
println("[toolRegistry] Registry cleared")
|
||||||
|
return nothing
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── listTools tool (auto-discover new MCP tools at runtime) ─────────
|
||||||
|
|
||||||
|
"""
|
||||||
|
listTool(tools::OrderedDict{String, agentTool}, mcpserver) -> agentTool
|
||||||
|
|
||||||
|
MCP-aware listTools tool (JSON-RPC 2.0 protocol).
|
||||||
|
|
||||||
|
First call: queries the MCP server via `mcpserver("tools/list")`, registers
|
||||||
|
all discovered tools into `tools` (in-place mutation, with
|
||||||
|
pagination via nextCursor), then returns the full tool list.
|
||||||
|
|
||||||
|
Subsequent calls: returns the current list (tools remain registered).
|
||||||
|
"""
|
||||||
|
function listTool(tools::OrderedDict{String, agentTool}, mcpserver; eventSink=nothing, sessionId=nothing)::agentTool
|
||||||
return agentTool(
|
return agentTool(
|
||||||
name="listTools",
|
name="listTools",
|
||||||
label="List Tools",
|
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.",
|
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}(
|
inputSchema=Dict{String,Any}(
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => Dict{String,Any}(),
|
"properties" => Dict{String,Any}(),
|
||||||
"required" => Any[]
|
"required" => Any[]
|
||||||
),
|
),
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
execute=(toolCallId::String, args::AbstractDict{String, Any},
|
||||||
tools = getTools(store)
|
signal::Union{Nothing,abortSignal},
|
||||||
if isempty(tools)
|
eventSink) -> begin
|
||||||
result_text = "No tools registered."
|
# Discover and register MCP tools (idempotent — skips already registered)
|
||||||
else
|
new_count = register_mcp_tools(tools, mcpserver; eventSink=eventSink, sessionId=sessionId)
|
||||||
lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools]
|
eventSink("tools dump: " * sprint(show, tools))
|
||||||
result_text = "Available tools:\n" * join(lines, "\n")
|
|
||||||
end
|
# 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(
|
return agentToolResult(
|
||||||
[textContent(result_text)],
|
[textContent(result_text)],
|
||||||
Dict{Any,Any}("count" => length(tools)),
|
Dict{Any,Any}("count" => total),
|
||||||
nothing, false
|
nothing, false
|
||||||
)
|
)
|
||||||
end,
|
end,
|
||||||
prepareArguments=nothing,
|
prepareArguments=nothing,
|
||||||
validateRequiredArgs=nothing,
|
validateRequiredArgs=nothing,
|
||||||
parallelToolExecute = false
|
parallelToolExecute=false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
"""
|
# ── High-level API ──────────────────────────────────────────────────
|
||||||
Load `.jl` tool files from `dir` into `store`, then auto-register
|
|
||||||
`listTool` so the LLM can discover available tools at runtime.
|
|
||||||
|
|
||||||
Each `.jl` file must define `function getTool()::agentTool ... end`.
|
"""
|
||||||
Files are sorted alphabetically for deterministic registration order.
|
registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver)
|
||||||
Each file is loaded into its own Julia submodule to avoid name collisions.
|
|
||||||
|
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
|
# Arguments
|
||||||
- `store`: Tool store to populate
|
- `tools`: The tool dict to populate
|
||||||
- `dir`: Directory containing `.jl` tool files
|
- `mcpserver`: A callable struct that communicates with the MCP server
|
||||||
|
|
||||||
# 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(...)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
|
function registerAllTools(tools::OrderedDict{String, agentTool}, mcpserver=nothing; eventSink=nothing, sessionId=nothing)
|
||||||
if !isdir(dir)
|
list_t = listTool(tools, mcpserver; eventSink=eventSink, sessionId=sessionId)
|
||||||
throw(ArgumentError("Tool directory does not exist: $dir"))
|
registerTool(tools, list_t; eventSink=eventSink)
|
||||||
end
|
|
||||||
|
|
||||||
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
|
|
||||||
sort!(jl_files)
|
|
||||||
|
|
||||||
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)
|
|
||||||
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))"
|
|
||||||
))
|
|
||||||
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)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
registerTool(store, listTool(store))
|
|
||||||
return store.tools
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
|
|
||||||
|
|
||||||
Add `tool` to `store`, overwriting any existing tool with the same name.
|
|
||||||
|
|
||||||
# Arguments
|
|
||||||
- `store`: Tool store to modify
|
|
||||||
- `tool`: The `agentTool` to register
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- The same `store.tools` dict (modified in place)
|
|
||||||
|
|
||||||
# Example
|
|
||||||
```julia
|
|
||||||
julia> store = toolStore(name="agent1");
|
|
||||||
|
|
||||||
julia> registerTool(store, listTool(store))
|
|
||||||
[toolRegistry:agent1] Registered tool: listTools
|
|
||||||
OrderedDict{String, agentTool} with 1 entry:
|
|
||||||
"listTools" => agentTool(...)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
|
|
||||||
store.tools[tool.name] = tool
|
|
||||||
println("[$(store.name)] Registered tool: $(tool.name)")
|
|
||||||
return store.tools
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Return the tools registered in `store`.
|
|
||||||
|
|
||||||
The returned dict is the **same object** stored inside `store` — mutations
|
|
||||||
to it (e.g. via `registerTool`) are visible through subsequent calls.
|
|
||||||
|
|
||||||
# Arguments
|
|
||||||
- `store`: Tool store to query
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
|
|
||||||
|
|
||||||
# Example
|
|
||||||
```julia
|
|
||||||
julia> tools = getTools(store)
|
|
||||||
OrderedDict{String, agentTool} with 2 entries:
|
|
||||||
"getWeather" => agentTool(...)
|
|
||||||
"getTime" => agentTool(...)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
function getTools(store::toolStore)::OrderedDict{String, agentTool}
|
|
||||||
return store.tools
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Remove all tools from `store`.
|
|
||||||
|
|
||||||
# Arguments
|
|
||||||
- `store`: Tool store to clear
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- `nothing`
|
|
||||||
|
|
||||||
# Example
|
|
||||||
```julia
|
|
||||||
julia> clearTools(store)
|
|
||||||
[toolRegistry:agent1] Registry cleared
|
|
||||||
nothing
|
|
||||||
|
|
||||||
julia> getTools(store)
|
|
||||||
OrderedDict{String, agentTool} with 0 entries
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
function clearTools(store::toolStore)::Nothing
|
|
||||||
empty!(store.tools)
|
|
||||||
println("[$(store.name)] Registry cleared")
|
|
||||||
return nothing
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end # module
|
end # module
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+80
-34
@@ -5,9 +5,9 @@
|
|||||||
# Model types
|
# Model types
|
||||||
modelCost, llmModel, llmUsage,
|
modelCost, llmModel, llmUsage,
|
||||||
# Message content types
|
# Message content types
|
||||||
textContent, imageContent,
|
textContent, imageContent, reasoningContent,
|
||||||
# Message types
|
# Message types
|
||||||
userMessage, assistantMessage, toolResultMessage,
|
userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage,
|
||||||
# Tool types
|
# Tool types
|
||||||
agentTool, validateRequiredArgs,
|
agentTool, validateRequiredArgs,
|
||||||
# Context types
|
# Context types
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
|
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
|
||||||
agentToolCallBatch,
|
agentToolCallBatch,
|
||||||
# Functions (defined elsewhere)
|
# Functions (defined elsewhere)
|
||||||
runAgent, takeResponse, followUp, stopAgent
|
promptAgent, takeResponse, followUp, stopAgent
|
||||||
|
|
||||||
|
|
||||||
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
|
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
|
||||||
@@ -31,6 +31,13 @@ using GeneralUtils
|
|||||||
|
|
||||||
const Timestamp = DateTime
|
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 #
|
# LLM model info #
|
||||||
# ------------------------------------------------------------------------------------------------ #
|
# ------------------------------------------------------------------------------------------------ #
|
||||||
@@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content
|
|||||||
mimeType::String # MIME type (e.g., "image/png")
|
mimeType::String # MIME type (e.g., "image/png")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
struct reasoningContent <: messageContent # LLM reasoning/thinking content
|
||||||
|
text::String # The reasoning text
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------------------------ #
|
# ------------------------------------------------------------------------------------------------ #
|
||||||
# Message types #
|
# Message types #
|
||||||
@@ -108,6 +119,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp=
|
|||||||
return userMessage(role, content, timestamp)
|
return userMessage(role, content, timestamp)
|
||||||
end
|
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
|
struct assistantMessage <: agentMessage # Message from the AI assistant
|
||||||
role::String # Always "assistant"
|
role::String # Always "assistant"
|
||||||
content::Vector{messageContent} # Text and/or image content
|
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}(),
|
function assistantMessage(; role="assistant", content=Vector{messageContent}(),
|
||||||
api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
|
api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
|
||||||
errorMessage=nothing, timestamp=now())
|
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
|
end
|
||||||
|
|
||||||
struct toolResultMessage <: agentMessage # Result returned from a tool execution
|
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
|
label::String # Human-readable tool name
|
||||||
description::String # What the tool does
|
description::String # What the tool does
|
||||||
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
|
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
|
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
|
||||||
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
|
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
|
||||||
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
|
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
|
||||||
@@ -273,7 +331,7 @@ end
|
|||||||
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
|
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
|
||||||
"""
|
"""
|
||||||
function agentTool(; name::String, label::String, description::String, inputSchema::Any,
|
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,
|
validateRequiredArgs::Union{Function, Nothing}=nothing,
|
||||||
parallelToolExecute::Bool=false)
|
parallelToolExecute::Bool=false)
|
||||||
return agentTool(name, label, description, inputSchema, execute,
|
return agentTool(name, label, description, inputSchema, execute,
|
||||||
@@ -291,7 +349,7 @@ Snapshot of the agent's conversation context.
|
|||||||
# Arguments
|
# Arguments
|
||||||
- `systemPrompt::String`: System prompt for the agent
|
- `systemPrompt::String`: System prompt for the agent
|
||||||
- `messages::Vector{agentMessage}`: Conversation messages
|
- `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
|
# Returns
|
||||||
- A new `agentContext` instance
|
- A new `agentContext` instance
|
||||||
@@ -299,7 +357,7 @@ Snapshot of the agent's conversation context.
|
|||||||
struct agentContext # Snapshot of the agent's conversation context
|
struct agentContext # Snapshot of the agent's conversation context
|
||||||
systemPrompt::String # System prompt for the agent
|
systemPrompt::String # System prompt for the agent
|
||||||
messages::Vector{agentMessage} # Conversation messages
|
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
|
end
|
||||||
|
|
||||||
|
|
||||||
@@ -316,7 +374,6 @@ mutable struct agentState # Mutable runtime state of an agen
|
|||||||
messages::Vector{agentMessage}
|
messages::Vector{agentMessage}
|
||||||
|
|
||||||
pendingToolCalls::Vector{String} # Tool call IDs waiting for results
|
pendingToolCalls::Vector{String} # Tool call IDs waiting for results
|
||||||
activeRun::Bool # is agent processing user message?
|
|
||||||
errorMessage::Union{String, Nothing} # Last error message
|
errorMessage::Union{String, Nothing} # Last error message
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -350,23 +407,14 @@ function agentState(
|
|||||||
agentState(
|
agentState(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
model,
|
model,
|
||||||
deepcopy(tools),
|
tools,
|
||||||
deepcopy(messages),
|
deepcopy(messages),
|
||||||
Vector{String}(),
|
Vector{String}(),
|
||||||
false,
|
|
||||||
nothing,
|
nothing,
|
||||||
)
|
)
|
||||||
end
|
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.
|
Context for preparing the next conversation turn.
|
||||||
|
|
||||||
@@ -394,14 +442,12 @@ end
|
|||||||
"""
|
"""
|
||||||
Configuration for the agent tool execution loop.
|
Configuration for the agent tool execution loop.
|
||||||
|
|
||||||
# Arguments
|
# Fields
|
||||||
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
|
|
||||||
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
||||||
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
||||||
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
||||||
"""
|
"""
|
||||||
struct agentLoopConfig
|
struct agentLoopConfig
|
||||||
tools::OrderedDict{String, agentTool}
|
|
||||||
beforeToolCall::Union{Function, Nothing}
|
beforeToolCall::Union{Function, Nothing}
|
||||||
afterToolCall::Union{Function, Nothing}
|
afterToolCall::Union{Function, Nothing}
|
||||||
toolExecution::String
|
toolExecution::String
|
||||||
@@ -437,15 +483,15 @@ end
|
|||||||
Context passed to the `beforeToolCall` hook.
|
Context passed to the `beforeToolCall` hook.
|
||||||
|
|
||||||
# Arguments
|
# 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
|
- `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
|
- `context::agentContext`: Current conversation context
|
||||||
"""
|
"""
|
||||||
struct beforeToolCallContext
|
struct beforeToolCallContext
|
||||||
message::assistantMessage
|
message::assistantMessageToolCall
|
||||||
toolCall::agentToolCall
|
toolCall::agentToolCall
|
||||||
args::Dict{String,Any}
|
args::AbstractDict{String, Any}
|
||||||
context::agentContext
|
context::agentContext
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -458,17 +504,17 @@ end
|
|||||||
Context passed to the `afterToolCall` hook.
|
Context passed to the `afterToolCall` hook.
|
||||||
|
|
||||||
# Arguments
|
# 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
|
- `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
|
- `result::agentToolResult`: The raw tool result
|
||||||
- `isError::Bool`: Whether execution resulted in an error
|
- `isError::Bool`: Whether execution resulted in an error
|
||||||
- `context::agentContext`: Current conversation context
|
- `context::agentContext`: Current conversation context
|
||||||
"""
|
"""
|
||||||
struct afterToolCallContext
|
struct afterToolCallContext
|
||||||
message::assistantMessage
|
message::assistantMessageToolCall
|
||||||
toolCall::agentToolCall
|
toolCall::agentToolCall
|
||||||
args::Dict{String,Any}
|
args::AbstractDict{String, Any}
|
||||||
result::agentToolResult
|
result::agentToolResult
|
||||||
isError::Bool
|
isError::Bool
|
||||||
context::agentContext
|
context::agentContext
|
||||||
@@ -480,12 +526,12 @@ Event emitted when a tool call execution starts.
|
|||||||
# Arguments
|
# Arguments
|
||||||
- `toolCallId::String`: ID of the tool call
|
- `toolCallId::String`: ID of the tool call
|
||||||
- `toolName::String`: Name of the tool
|
- `toolName::String`: Name of the tool
|
||||||
- `arguments::Dict{String,Any}`: Tool arguments
|
- `arguments::AbstractDict{String, Any}`: Tool arguments
|
||||||
"""
|
"""
|
||||||
struct toolExecStartEvent
|
struct toolExecStartEvent
|
||||||
toolCallId::String
|
toolCallId::String
|
||||||
toolName::String
|
toolName::String
|
||||||
arguments::Dict{String,Any}
|
arguments::AbstractDict{String, Any}
|
||||||
end
|
end
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -494,13 +540,13 @@ Event emitted with partial results during tool execution.
|
|||||||
# Arguments
|
# Arguments
|
||||||
- `toolCallId::String`: ID of the tool call
|
- `toolCallId::String`: ID of the tool call
|
||||||
- `toolName::String`: Name of the tool
|
- `toolName::String`: Name of the tool
|
||||||
- `arguments::Dict{String,Any}`: Tool arguments
|
- `arguments::AbstractDict{String, Any}`: Tool arguments
|
||||||
- `partialResult::Any`: The partial result data
|
- `partialResult::Any`: The partial result data
|
||||||
"""
|
"""
|
||||||
struct toolExecUpdateEvent
|
struct toolExecUpdateEvent
|
||||||
toolCallId::String
|
toolCallId::String
|
||||||
toolName::String
|
toolName::String
|
||||||
arguments::Dict{String,Any}
|
arguments::AbstractDict{String, Any}
|
||||||
partialResult::Any
|
partialResult::Any
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+171
-22
@@ -2,10 +2,10 @@ module utils
|
|||||||
|
|
||||||
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
|
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
|
||||||
validateToolArguments, _userMessageToOpenAI,
|
validateToolArguments, _userMessageToOpenAI,
|
||||||
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks,
|
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
|
||||||
beforeToolCall, afterToolCall, agentEventSink
|
beforeToolCall, afterToolCall, eventSink
|
||||||
|
|
||||||
using UUIDs, Dates, DataStructures, HTTP, JSON
|
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
|
||||||
using GeneralUtils
|
using GeneralUtils
|
||||||
using ..type
|
using ..type
|
||||||
|
|
||||||
@@ -75,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
|
|||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
prepareContext(state::agentState) -> agentContext
|
prepareContext(state::agentState) -> agentContext
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
|
|||||||
# end
|
# 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
|
#TODO filter tools from state.tools based on user intend in user message and tool description
|
||||||
filteredTools = state.tools
|
filteredTools = state.tools
|
||||||
@@ -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
|
""" openai message format example
|
||||||
msg = Dict(
|
msg = Dict(
|
||||||
@@ -185,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
|||||||
Dict("type" => "text", "text" => "let me check."),
|
Dict("type" => "text", "text" => "let me check."),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
"tools"=> [
|
||||||
Dict(
|
Dict(
|
||||||
"role" => "toolResult",
|
"type" => "function",
|
||||||
"content" => [
|
"function" => Dict(
|
||||||
Dict("type" => "text", "text" => "name: Chateau Montelena ..."),
|
"name" => "getWeather",
|
||||||
]
|
"description" => "Get current weather",
|
||||||
|
"parameters" => Dict(
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Dict(
|
||||||
|
"city" => Dict("type" => "string")
|
||||||
),
|
),
|
||||||
|
"required" => ["city"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
],
|
],
|
||||||
"temperature" => 0.7
|
"temperature" => 0.7
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
openaiReadyMsg = Dict{String, Any}()
|
||||||
|
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
|
||||||
messages = Vector{Dict{String, Any}}()
|
messages = Vector{Dict{String, Any}}()
|
||||||
|
eventSink("formatMsgForLLM 1")
|
||||||
# System prompt as system message
|
# System prompt as system message
|
||||||
if !isempty(ctx.systemPrompt)
|
if !isempty(ctx.systemPrompt)
|
||||||
push!(messages, Dict(
|
push!(messages, Dict(
|
||||||
@@ -205,22 +216,65 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
|||||||
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
|
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
|
||||||
))
|
))
|
||||||
end
|
end
|
||||||
|
eventSink("formatMsgForLLM 2")
|
||||||
# Conversation messages
|
# Conversation messages
|
||||||
for msg in ctx.messages
|
for msg in ctx.messages
|
||||||
if msg isa userMessage
|
if msg isa userMessage
|
||||||
push!(messages, _userMessageToOpenAI(msg))
|
push!(messages, _userMessageToOpenAI(msg))
|
||||||
|
elseif msg isa assistantMessageToolCall
|
||||||
|
push!(messages, _assistantMessageToolCallToOpenAI(msg))
|
||||||
elseif msg isa assistantMessage
|
elseif msg isa assistantMessage
|
||||||
push!(messages, _assistantMessageToOpenAI(msg))
|
push!(messages, _assistantMessageToOpenAI(msg))
|
||||||
elseif msg isa toolResultMessage
|
elseif msg isa toolResultMessage
|
||||||
push!(messages, _toolResultMessageToOpenAI(msg))
|
push!(messages, _toolResultMessageToOpenAI(msg))
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
#TODO
|
return openaiReadyMsg
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
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
|
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
|
||||||
)::beforeToolCallResult
|
)::beforeToolCallResult
|
||||||
|
|
||||||
@@ -233,8 +287,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
|
|||||||
return beforeToolCallResult(false, "N/A")
|
return beforeToolCallResult(false, "N/A")
|
||||||
end
|
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}
|
)::Union{agentToolResult, Nothing}
|
||||||
|
|
||||||
# modify context.result if needed and return agentToolResult
|
# modify context.result if needed and return agentToolResult
|
||||||
@@ -244,7 +321,7 @@ end
|
|||||||
|
|
||||||
|
|
||||||
#TODO
|
#TODO
|
||||||
function agentEventSink(x)
|
function eventSink(x)
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -260,6 +337,44 @@ function _userMessageToOpenAI(msg::userMessage)::Dict{String, Any}
|
|||||||
end
|
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.
|
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
|
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
|
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).
|
coercion, format validation, cross-field constraints).
|
||||||
|
|
||||||
# Arguments
|
# Arguments
|
||||||
- `args::Dict{String,Any}`: The arguments provided by the LLM
|
- `args::AbstractDict{String, Any}`: The arguments provided by the LLM
|
||||||
- `inputSchema::Dict{String,Any}`: The tool's `inputSchema` (JSON Schema format)
|
- `inputSchema::AbstractDict{String, Any}`: The tool's `inputSchema` (JSON Schema format)
|
||||||
|
|
||||||
# Returns
|
# Returns
|
||||||
- `nothing` if all required args are present
|
- `nothing` if all required args are present
|
||||||
@@ -338,7 +487,7 @@ args2 = Dict("city" => "Tokyo")
|
|||||||
validateRequiredArgs(args2, schema) # => nothing
|
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[])
|
required = get(inputSchema, "required", Any[])
|
||||||
if isempty(required)
|
if isempty(required)
|
||||||
return nothing
|
return nothing
|
||||||
@@ -392,7 +541,7 @@ validateToolArguments(toolWithHook, tc) # => validated args or throws
|
|||||||
validateToolArguments(toolDefault, tc) # => 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)
|
# Use default (2-arg: args + schema) or tool-specific hook (1-arg: args only)
|
||||||
if isnothing(tool.validateRequiredArgs)
|
if isnothing(tool.validateRequiredArgs)
|
||||||
result = validateRequiredArgs(prepared.arguments, tool.inputSchema)
|
result = validateRequiredArgs(prepared.arguments, tool.inputSchema)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Test
|
||||||
|
using YiemAgent
|
||||||
|
|
||||||
|
include("toolTest.jl")
|
||||||
|
include("_extractToolCalls.jl")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user