Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd616409dd | |||
| 2543e6cbf1 | |||
| b8067c2d33 | |||
| 510cf6126c | |||
| 90fb97a4e7 | |||
| 77adeb3a6b |
+50
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
julia_version = "1.12.6"
|
||||
manifest_format = "2.0"
|
||||
project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c"
|
||||
project_hash = "aa163e2bf572632825162936e107be18384fd40f"
|
||||
|
||||
[[deps.Accessors]]
|
||||
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
|
||||
@@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
|
||||
uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
|
||||
version = "0.4.0"
|
||||
|
||||
[[deps.Arrow]]
|
||||
deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
|
||||
git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01"
|
||||
uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
|
||||
version = "2.8.1"
|
||||
|
||||
[[deps.ArrowTypes]]
|
||||
deps = ["Sockets", "UUIDs"]
|
||||
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
|
||||
@@ -58,6 +64,12 @@ version = "1.11.0"
|
||||
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
|
||||
version = "1.11.0"
|
||||
|
||||
[[deps.BitIntegers]]
|
||||
deps = ["Random"]
|
||||
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
|
||||
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
|
||||
version = "0.3.7"
|
||||
|
||||
[[deps.BufferedStreams]]
|
||||
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
|
||||
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
|
||||
@@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
|
||||
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
|
||||
version = "0.3.4"
|
||||
|
||||
[[deps.CodecLz4]]
|
||||
deps = ["Lz4_jll", "TranscodingStreams"]
|
||||
git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2"
|
||||
uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
|
||||
version = "0.4.6"
|
||||
|
||||
[[deps.CodecZlib]]
|
||||
deps = ["TranscodingStreams", "Zlib_jll"]
|
||||
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
|
||||
uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
|
||||
version = "0.7.8"
|
||||
|
||||
[[deps.CodecZstd]]
|
||||
deps = ["TranscodingStreams", "Zstd_jll"]
|
||||
git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56"
|
||||
uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
|
||||
version = "0.8.7"
|
||||
|
||||
[[deps.CommonSolve]]
|
||||
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
|
||||
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
|
||||
@@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"]
|
||||
[deps.CompositionsBase.extensions]
|
||||
CompositionsBaseInverseFunctionsExt = "InverseFunctions"
|
||||
|
||||
[[deps.ConcurrentUtilities]]
|
||||
deps = ["Serialization", "Sockets"]
|
||||
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
|
||||
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
|
||||
version = "2.6.0"
|
||||
|
||||
[[deps.ConstructionBase]]
|
||||
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
|
||||
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
|
||||
@@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
|
||||
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
|
||||
version = "3.8.0"
|
||||
|
||||
[[deps.Lz4_jll]]
|
||||
deps = ["Artifacts", "JLLWrappers", "Libdl"]
|
||||
git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33"
|
||||
uuid = "5ced341a-0733-55b8-9ab6-a4889d929147"
|
||||
version = "1.10.1+0"
|
||||
|
||||
[[deps.MacroTools]]
|
||||
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
|
||||
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
|
||||
@@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
|
||||
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
|
||||
version = "0.4.7"
|
||||
|
||||
[[deps.StringViews]]
|
||||
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
|
||||
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
|
||||
version = "1.3.7"
|
||||
|
||||
[[deps.StructTypes]]
|
||||
deps = ["Dates", "UUIDs"]
|
||||
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
|
||||
@@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
|
||||
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
|
||||
version = "1.0.21+0"
|
||||
|
||||
[[deps.msghandler]]
|
||||
deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"]
|
||||
git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f"
|
||||
repo-rev = "main"
|
||||
repo-url = "https://git.yiem.cc/ton/msghandler"
|
||||
uuid = "f2724d33-f338-4a57-b9f8-1be882570d10"
|
||||
version = "1.2.1"
|
||||
|
||||
[[deps.nghttp2_jll]]
|
||||
deps = ["Artifacts", "Libdl"]
|
||||
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
|
||||
|
||||
@@ -22,6 +22,7 @@ SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
|
||||
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
|
||||
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
|
||||
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
||||
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
|
||||
|
||||
[compat]
|
||||
Base64 = "1.11.0"
|
||||
@@ -33,3 +34,4 @@ JSON = "1.6.1"
|
||||
LLMMCTS = "0.1.5"
|
||||
NATS = "0.1.0"
|
||||
SQLLLM = "0.2.8"
|
||||
msghandler = "1.2.1"
|
||||
|
||||
+262
-288
@@ -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)
|
||||
2. [Overview](#2-overview)
|
||||
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)
|
||||
6. [Message Processing Pipeline](#6-message-processing-pipeline)
|
||||
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)
|
||||
16. [Complete End-to-End Example](#16-complete-end-to-end-example)
|
||||
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:**
|
||||
```julia
|
||||
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
|
||||
using YiemAgent, YiemAgent.toolRegistry
|
||||
|
||||
store = toolStore(name="myAgent")
|
||||
tools = loadTools(store, "src/tools")
|
||||
# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools
|
||||
tools = register_all_tools(store)
|
||||
# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs
|
||||
# Also auto-registers listTools for runtime discovery
|
||||
```
|
||||
|
||||
**Result extraction:**
|
||||
```julia
|
||||
all_tools = getTools(store) # OrderedDict{String, agentTool}
|
||||
# Keys: "getTime", "getWeather", "writeTool", "listTools"
|
||||
getTime_tool = all_tools["getTime"]
|
||||
# Keys: "getWeather", "getTime", "writeTool", "listTools"
|
||||
getWeather_tool = all_tools["getWeather"]
|
||||
|
||||
# Manual registration (alternative to loadTools)
|
||||
# Manual registration (alternative to register_all_tools)
|
||||
registerTool(store, my_tool)
|
||||
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
|
||||
|
||||
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
|
||||
using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
|
||||
|
||||
# 1. Set up toolStore and load tools (auto-registers listTools)
|
||||
store = toolStore(name="myAgent")
|
||||
loadTools(store, "src/tools")
|
||||
|
||||
# 2. Create agent — pass tools + _tool_store
|
||||
# Create agent — tools are registered automatically via register_all_tools()
|
||||
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
|
||||
agentEventSink = 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 |
|
||||
|-----------|------|----------|---------|
|
||||
| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text |
|
||||
| `model` | `llmModel` | No | LLM model config |
|
||||
| `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) |
|
||||
| `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()` |
|
||||
| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text |
|
||||
| `model` | `llmModel` | No | LLM model config |
|
||||
| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history |
|
||||
|
||||
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
|
||||
|
||||
# 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):**
|
||||
```
|
||||
user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
|
||||
→ _agent_loop detects message → @spawn _process_message(agent)
|
||||
→ _agentLoop detects message → @spawn _processMessage(agent)
|
||||
→ prepareContext → formatMsgForLLM → llmCall
|
||||
→ LLM returns tool_calls
|
||||
→ 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`):
|
||||
```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].text # "Current time in Tokyo: 2026-08-10T..."
|
||||
result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C")
|
||||
result.content[1].text # "Weather in Tokyo: Sunny, 22°C"
|
||||
result.details # Dict{Any,Any}() — tool-specific metadata
|
||||
result.usage # nothing — llmUsage tracking (optional)
|
||||
result.terminate # false — signals loop termination
|
||||
@@ -182,7 +157,7 @@ result.terminate # false — signals loop termination
|
||||
msg = batch.messages[1] # toolResultMessage
|
||||
|
||||
msg.toolCallId # "call-1"
|
||||
msg.toolName # "getTime"
|
||||
msg.toolName # "getWeather"
|
||||
msg.content # Vector{messageContent}
|
||||
msg.isError # false
|
||||
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 |
|
||||
|-------|----------|-------|--------|---------|
|
||||
| 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`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results |
|
||||
| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event |
|
||||
|
||||
The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### How `loadTools(store, dir)` Works
|
||||
### Static Registration — `register_all_tools()`
|
||||
|
||||
```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)
|
||||
2. **Sorts** filenames alphabetically for deterministic registration order
|
||||
3. **Wraps** each file in a dynamically created submodule:
|
||||
```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
|
||||
1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs
|
||||
2. **Registers each tool** via `registerTool(store, tool)`
|
||||
3. **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:
|
||||
- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime`
|
||||
- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs`
|
||||
- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected
|
||||
Tools are **statically included** in `YiemAgent.jl` via `include()`. This means:
|
||||
- Tool functions live in the `YiemAgent` module, not in dynamically created submodules
|
||||
- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world)
|
||||
- Simpler tool definition — no need to wrap in a `module ... end` block
|
||||
- Better compiler optimization (inlining, type inference)
|
||||
|
||||
### Registration API
|
||||
|
||||
@@ -331,9 +295,9 @@ Each tool file is loaded into its own **namespaced submodule**. This means:
|
||||
store1 = toolStore(name="agent1")
|
||||
store2 = toolStore(name="agent2")
|
||||
|
||||
# Load tools into specific stores (auto-registers listTools)
|
||||
tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only
|
||||
tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only
|
||||
# Load all tools (auto-registers listTools)
|
||||
tools1 = register_all_tools(store1) # all agents get the same tools
|
||||
tools2 = register_all_tools(store2)
|
||||
|
||||
# Manual registration (per-store)
|
||||
registerTool(store1, my_tool)
|
||||
@@ -359,8 +323,8 @@ Each `toolStore` is completely independent — tools registered in one store do
|
||||
storeA = toolStore(name="A")
|
||||
storeB = toolStore(name="B")
|
||||
|
||||
registerTool(storeA, getTime_tool)
|
||||
registerTool(storeB, getWeather_tool)
|
||||
registerTool(storeA, getTimeTool())
|
||||
registerTool(storeB, getWeatherTool())
|
||||
|
||||
getTools(storeA) # only contains getTime
|
||||
getTools(storeB) # only contains getWeather
|
||||
@@ -376,7 +340,7 @@ This ensures that `yiemAgent` instances with different `tool_store` references o
|
||||
|
||||
**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
|
||||
|
||||
@@ -385,7 +349,6 @@ yiemAgent struct contains:
|
||||
- inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
|
||||
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
|
||||
- outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
|
||||
- _tool_store (toolStore) ← per-agent isolated tool registry
|
||||
```
|
||||
|
||||
### Loop States
|
||||
@@ -395,7 +358,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`):
|
||||
| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior |
|
||||
|-------|-----------------|-------------|----------------|-------------------|----------|
|
||||
| 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 |
|
||||
| 4 | running | `true` | has msg | empty | New message 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)
|
||||
|
||||
```julia
|
||||
function _agent_loop(agent::yiemAgent)
|
||||
function _agentLoop(agent::yiemAgent)
|
||||
while true
|
||||
# 1. Wait for message from inputChannel (blocking poll)
|
||||
msg = fetch!(agent.inputChannel) # agentCore.jl:84
|
||||
@@ -414,9 +377,9 @@ function _agent_loop(agent::yiemAgent)
|
||||
drain both channels, break loop
|
||||
end
|
||||
|
||||
# 3. If agent is idle, spawn _process_message
|
||||
# 3. If agent is idle, spawn _processMessage
|
||||
if agent._state.activeRun == false
|
||||
processingTask = Threads.@spawn _process_message(agent)
|
||||
processingTask = Threads.@spawn _processMessage(agent)
|
||||
agent._state.activeRun = true
|
||||
end
|
||||
|
||||
@@ -445,12 +408,12 @@ end
|
||||
|
||||
**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
|
||||
|
||||
```julia
|
||||
function _process_message(agent::yiemAgent)::assistantMessage
|
||||
function _processMessage(agent::yiemAgent)::assistantMessage
|
||||
final_response = nothing
|
||||
|
||||
while true # Loop until LLM returns response without tool calls
|
||||
@@ -466,38 +429,38 @@ function _process_message(agent::yiemAgent)::assistantMessage
|
||||
end
|
||||
|
||||
# ── Step 2: Prepare context ─────────────────────────────────
|
||||
preparedContext = agent.prepareContext(agent._state)
|
||||
state = agentState(systemPrompt, nothing, tools, messages)
|
||||
preparedContext = prepareContext(state, agentEventSink)
|
||||
# Default: deep copies systemPrompt, messages, tools from agentState → agentContext
|
||||
# Override point: filter tools, inject context, modify system prompt
|
||||
|
||||
# ── Step 3: Format for LLM ──────────────────────────────────
|
||||
formatted_messages = agent.formatMsgForLLM(preparedContext)
|
||||
formattedMessages = formatMsgForLLM(preparedContext, agentEventSink)
|
||||
# Converts agentContext → Dict("messages" => [...]) in OpenAI format
|
||||
# Wraps systemPrompt as system role, converts each messageContent block
|
||||
|
||||
# ── Step 4: Call LLM ────────────────────────────────────────
|
||||
response = agent.llmCall(formatted_messages)
|
||||
response = llmCall(formattedMessages)
|
||||
# Returns assistantMessage with content::Vector{messageContent}
|
||||
# Each content block has a type: "text", "thinking", or "tool_call"
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── Step 6: Execute tool calls or return ────────────────────
|
||||
if has_tool_calls && !isempty(tool_call_list)
|
||||
if hasToolCalls && !isempty(toolCallList)
|
||||
# Build context and config
|
||||
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
|
||||
config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...)
|
||||
signal = nothing
|
||||
emit = agent.agentEventSink
|
||||
context = agentContext(systemPrompt, messages, tools)
|
||||
config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
|
||||
signal = abortSignal(false)
|
||||
|
||||
# Execute tool calls (sequential or parallel)
|
||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
||||
batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink)
|
||||
|
||||
# Save results to conversation history
|
||||
for tool_result in batch.messages
|
||||
push!(agent._state.messages, tool_result)
|
||||
push!(messages, tool_result)
|
||||
end
|
||||
|
||||
# Check termination
|
||||
@@ -517,10 +480,6 @@ function _process_message(agent::yiemAgent)::assistantMessage
|
||||
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
|
||||
@@ -652,51 +611,29 @@ function prepareToolCall(
|
||||
function executePreparedToolCall(
|
||||
prep::preparedToolCall,
|
||||
signal::Union{Nothing, abortSignal},
|
||||
emit::Function,
|
||||
agentEventSink,
|
||||
)::executedOutcome
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Initialize streaming state:**
|
||||
```julia
|
||||
updateEvents = promise[] # vector to collect update event handles
|
||||
accepting = true # guard to prevent duplicate emissions
|
||||
```
|
||||
1. **Call `tool.execute()`:**
|
||||
```julia
|
||||
result = prep.tool.execute(
|
||||
prep.toolCall.id,
|
||||
prep.args,
|
||||
signal,
|
||||
agentEventSink # serves as onPartialResult callback
|
||||
)
|
||||
return executedOutcome(result, false)
|
||||
```
|
||||
|
||||
2. **Call `tool.execute()`:**
|
||||
```julia
|
||||
result = prep.tool.execute(
|
||||
prep.toolCall.id,
|
||||
prep.args,
|
||||
signal,
|
||||
partialResult -> begin
|
||||
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)
|
||||
```
|
||||
|
||||
4. **On error:**
|
||||
```julia
|
||||
catch err
|
||||
accepting = false
|
||||
wait.(updateEvents)
|
||||
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
||||
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.
|
||||
2. **On error:**
|
||||
```julia
|
||||
catch err
|
||||
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
||||
end
|
||||
```
|
||||
|
||||
### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()`
|
||||
|
||||
@@ -716,49 +653,36 @@ function finalizeExecutedToolCall(
|
||||
**Steps:**
|
||||
|
||||
1. **Extract execution result:**
|
||||
```julia
|
||||
result = executed.result
|
||||
isError = executed.isError
|
||||
```
|
||||
```julia
|
||||
result = executed.result
|
||||
isError = executed.isError
|
||||
```
|
||||
|
||||
2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`:
|
||||
- Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal`
|
||||
- Hook can mutate the result:
|
||||
```julia
|
||||
after = config.afterToolCall(afterToolCallContext(...))
|
||||
if after !== nothing
|
||||
result = merge(result, dict(
|
||||
:content => get(after, :content, result.content),
|
||||
:details => get(after, :details, result.details),
|
||||
:usage => get(after, :usage, result.usage),
|
||||
:terminate => get(after, :terminate, result.terminate)
|
||||
))
|
||||
isError = get(after, :isError, isError)
|
||||
end
|
||||
```
|
||||
- Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic
|
||||
- On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true`
|
||||
- Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal`
|
||||
- Hook can mutate the result:
|
||||
```julia
|
||||
after = config.afterToolCall(afterToolCallContext(...))
|
||||
if after !== nothing
|
||||
result = merge(result, dict(
|
||||
:content => get(after, :content, result.content),
|
||||
:details => get(after, :details, result.details),
|
||||
:usage => get(after, :usage, result.usage),
|
||||
:terminate => get(after, :terminate, result.terminate)
|
||||
))
|
||||
isError = get(after, :isError, isError)
|
||||
end
|
||||
```
|
||||
- Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic
|
||||
- On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true`
|
||||
|
||||
3. **Return:**
|
||||
```julia
|
||||
return finalizedOutcome(prep.toolCall, result, isError)
|
||||
```
|
||||
```julia
|
||||
return finalizedOutcome(prep.toolCall, result, isError)
|
||||
```
|
||||
|
||||
**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
|
||||
@@ -774,7 +698,7 @@ function executeToolCalls(
|
||||
toolCalls::Vector{agentToolCall},
|
||||
config::agentLoopConfig,
|
||||
signal::Union{Nothing, abortSignal},
|
||||
emit::Function,
|
||||
agentEventSink,
|
||||
)::agentToolCallBatch
|
||||
```
|
||||
|
||||
@@ -809,18 +733,15 @@ function executeToolCallsSequential(...)::agentToolCallBatch
|
||||
messages = toolResultMessage[]
|
||||
|
||||
for tc in toolCalls
|
||||
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
|
||||
|
||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
|
||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
|
||||
|
||||
if prep isa immediateOutcome
|
||||
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
||||
else
|
||||
executed = executePreparedToolCall(prep, signal, emit)
|
||||
executed = executePreparedToolCall(prep, signal, agentEventSink)
|
||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
||||
end
|
||||
|
||||
emitToolExecutionEnd(finalized, emit)
|
||||
push!(messages, createToolResultMessage(finalized))
|
||||
push!(finalizedCalls, finalized)
|
||||
|
||||
@@ -842,19 +763,15 @@ function executeToolCallsParallel(...)::agentToolCallBatch
|
||||
entries = union{finalizedOutcome, task{finalizedOutcome}}[]
|
||||
|
||||
for tc in toolCalls
|
||||
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments))
|
||||
|
||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
|
||||
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
|
||||
|
||||
if prep isa immediateOutcome
|
||||
finalized = finalizedOutcome(tc, prep.result, prep.isError)
|
||||
emitToolExecutionEnd(finalized, emit)
|
||||
push!(entries, finalized) # immediate outcome — no task
|
||||
else
|
||||
task = task() do
|
||||
executed = executePreparedToolCall(prep, signal, emit)
|
||||
executed = executePreparedToolCall(prep, signal, agentEventSink)
|
||||
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
|
||||
emitToolExecutionEnd(finalized, emit)
|
||||
return finalized
|
||||
end
|
||||
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) |
|
||||
| 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`
|
||||
|
||||
```julia
|
||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
|
||||
|
||||
# Save results to conversation history
|
||||
for tool_result in batch.messages
|
||||
@@ -985,9 +902,9 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
|
||||
f.result.content, # content (Vector{messageContent})
|
||||
f.result.details, # details
|
||||
f.result.usage, # usage
|
||||
get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools)
|
||||
nothing, # addedToolNames (for dynamic tools)
|
||||
f.isError, # isError
|
||||
nowMillis(), # timestamp
|
||||
now(), # timestamp
|
||||
)
|
||||
end
|
||||
```
|
||||
@@ -1048,9 +965,6 @@ Tool call fails at any phase
|
||||
└─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
emitToolExecutionEnd(finalized, emit)
|
||||
│
|
||||
▼
|
||||
createToolResultMessage(finalized)
|
||||
│
|
||||
▼
|
||||
@@ -1119,14 +1033,14 @@ end
|
||||
|-------|-------------|------|
|
||||
| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call |
|
||||
| `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
|
||||
|
||||
The `emit` function is passed through the entire call chain:
|
||||
The `agentEventSink` function is passed through the entire call chain:
|
||||
|
||||
```julia
|
||||
emit = agent.agentEventSink # set during yiemAgent construction
|
||||
agentEventSink = agent.agentEventSink # set during yiemAgent construction
|
||||
```
|
||||
|
||||
The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by:
|
||||
@@ -1143,8 +1057,8 @@ The `agentEventSink` function is a user-provided callback that receives all even
|
||||
|
||||
| Hook | Signature | Called | Purpose |
|
||||
|------|-----------|--------|---------|
|
||||
| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
|
||||
| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
|
||||
| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
|
||||
| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
|
||||
| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API |
|
||||
| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort |
|
||||
| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
|
||||
@@ -1211,7 +1125,7 @@ end
|
||||
**Source:** `utils.jl:111-125`
|
||||
|
||||
```julia
|
||||
function prepareContext(state::agentState)::agentContext
|
||||
function prepareContext(state::agentState, agentEventSink)::agentContext
|
||||
# TODO: filter tools from state.tools based on user intent
|
||||
filteredTools = state.tools
|
||||
|
||||
@@ -1238,7 +1152,7 @@ end
|
||||
Default implementation converts `agentContext` to OpenAI-compatible format:
|
||||
|
||||
```julia
|
||||
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
|
||||
messages = Vector{Dict{String, Any}}()
|
||||
|
||||
# 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
|
||||
2. Indents `executeCode` with 4 spaces
|
||||
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`
|
||||
|
||||
### `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
|
||||
|
||||
@@ -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..."
|
||||
- (optional) validateCode, prepareCode
|
||||
3. writeTool generates src/tools/searchWine.jl
|
||||
4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file
|
||||
5. Agent calls searchWine(query="cabernet")
|
||||
6. Result: "Found 5 cabernet wines..."
|
||||
4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
|
||||
5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl
|
||||
6. Developer restarts Julia — new tool is loaded
|
||||
7. Agent calls searchWine(query="cabernet")
|
||||
8. Result: "Found 5 cabernet wines..."
|
||||
```
|
||||
|
||||
### `writeTool` Input Schema
|
||||
@@ -1328,14 +1244,14 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT
|
||||
```
|
||||
USER SENDS MESSAGE
|
||||
└─> runAgent(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
|
||||
└─> _agent_loop: detects msg in inputChannel
|
||||
└─> Threads.@spawn _process_message(agent)
|
||||
└─> _agentLoop: detects msg in inputChannel
|
||||
└─> @spawn _processMessage(agent)
|
||||
|
||||
── _process_message ──────────────────────────────────────────────
|
||||
── _processMessage ──────────────────────────────────────────────
|
||||
│
|
||||
│ Step 1: Drain inputChannel
|
||||
│ raw_msg = Dict("role" => "user", "content" => [...])
|
||||
@@ -1367,36 +1283,34 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
|
||||
│
|
||||
│ Step 6: Execute tool calls
|
||||
│ context = agentContext(systemPrompt, messages, tools)
|
||||
│ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential")
|
||||
│ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit)
|
||||
│ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
|
||||
│ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
|
||||
|
||||
|
||||
LOOP ITERATION 1 — executeToolCallsSequential
|
||||
│
|
||||
│ ── executeToolCallsSequential ──────────────────────────────
|
||||
│ │
|
||||
│ │ For tc = agentToolCall("call_1", "getWeather", ...):
|
||||
│ │
|
||||
│ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"}))
|
||||
│ │
|
||||
│ │ PREPARE:
|
||||
│ │ tool = context.tools["getWeather"] → found!
|
||||
│ │ validatedArgs = validateToolArguments(tool, tc)
|
||||
│ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes
|
||||
│ │ beforeToolCall_hook(...) → nothing (skipped)
|
||||
│ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"})
|
||||
│ │
|
||||
│ │ EXECUTE:
|
||||
│ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult)
|
||||
│ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
|
||||
│ │ → executedOutcome(result, false)
|
||||
│ │
|
||||
│ │ FINALIZE:
|
||||
│ │ afterToolCall_hook(...) → nothing (skipped)
|
||||
│ │ → finalizedOutcome(tc, result, false)
|
||||
│ │
|
||||
│ │ emit(toolExecEndEvent("call_1", "getWeather", result, false))
|
||||
│ │ msg = createToolResultMessage(finalized)
|
||||
│ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
|
||||
│ │
|
||||
│ └─> agentToolCallBatch([msg], false)
|
||||
│ For tc = agentToolCall("call_1", "getWeather", ...):
|
||||
│
|
||||
│ PREPARE:
|
||||
│ tool = context.tools["getWeather"] → found!
|
||||
│ validatedArgs = validateToolArguments(tool, tc)
|
||||
│ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes
|
||||
│ beforeToolCall_hook(...) → nothing (skipped)
|
||||
│ → preparedToolCall(tool, tc, {"city" => "Tokyo"})
|
||||
│
|
||||
│ EXECUTE:
|
||||
│ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink)
|
||||
│ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
|
||||
│ → executedOutcome(result, false)
|
||||
│
|
||||
│ FINALIZE:
|
||||
│ afterToolCall_hook(...) → nothing (skipped)
|
||||
│ → finalizedOutcome(tc, result, false)
|
||||
│
|
||||
│ msg = createToolResultMessage(finalized)
|
||||
│ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
|
||||
│
|
||||
└─> agentToolCallBatch([msg], false)
|
||||
│
|
||||
│ Save results:
|
||||
│ 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
|
||||
── _process_message (second iteration) ───────────────────────────
|
||||
── _processMessage (second iteration) ───────────────────────────
|
||||
│
|
||||
│ Step 1: Drain inputChannel → empty
|
||||
│
|
||||
@@ -1437,12 +1351,12 @@ AGENT LOOP: SEND RESPONSE TO USER
|
||||
|
||||
## 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
|
||||
|
||||
```julia
|
||||
function getTool()::agentTool
|
||||
function <name>Tool()::agentTool
|
||||
# Must return an agentTool instance
|
||||
end
|
||||
```
|
||||
@@ -1451,22 +1365,22 @@ end
|
||||
|
||||
```julia
|
||||
# Argument preparation (before validation)
|
||||
function prepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
||||
function <name>PrepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
||||
# Return modified args, or args unchanged
|
||||
return args
|
||||
end
|
||||
|
||||
# Custom validation (before execution)
|
||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
function <name>ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
# Return nothing to pass, or error string to fail
|
||||
return nothing
|
||||
end
|
||||
|
||||
# Core execution
|
||||
function executeTool(toolCallId::String,
|
||||
args::Dict{String,Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
onPartialResult::Function)::agentToolResult
|
||||
function <name>Execute(toolCallId::String,
|
||||
args::Dict{String,Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
onPartialResult::Function)::agentToolResult
|
||||
# Return agentToolResult with content, details, usage, terminate
|
||||
return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false)
|
||||
end
|
||||
@@ -1477,7 +1391,8 @@ end
|
||||
```julia
|
||||
# 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
|
||||
function helper_function(...)
|
||||
@@ -1485,24 +1400,24 @@ function helper_function(...)
|
||||
end
|
||||
|
||||
# Optional: prepareArguments
|
||||
function prepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
||||
function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any}
|
||||
return args
|
||||
end
|
||||
|
||||
# Optional: validateRequiredArgs
|
||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
return nothing
|
||||
end
|
||||
|
||||
# Required: executeTool
|
||||
function executeTool(toolCallId::String, args::Dict{String,Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
onPartialResult::Function)::agentToolResult
|
||||
# Required: execute function
|
||||
function myToolExecute(toolCallId::String, args::Dict{String,Any},
|
||||
signal::Union{Nothing,abortSignal},
|
||||
onPartialResult::Function)::agentToolResult
|
||||
...
|
||||
end
|
||||
|
||||
# Required: getTool
|
||||
function getTool()::agentTool
|
||||
# Required: getTool function
|
||||
function myToolTool()::agentTool
|
||||
return agentTool(
|
||||
name = "myTool",
|
||||
label = "My Tool",
|
||||
@@ -1512,9 +1427,9 @@ function getTool()::agentTool
|
||||
"properties" => Dict(...),
|
||||
"required" => [...]
|
||||
),
|
||||
execute = executeTool,
|
||||
prepareArguments = prepareArguments,
|
||||
validateRequiredArgs = validateRequiredArgs,
|
||||
execute = myToolExecute,
|
||||
prepareArguments = myToolPrepareArguments,
|
||||
validateRequiredArgs = myToolValidateRequiredArgs,
|
||||
parallelToolExecute = false
|
||||
)
|
||||
end
|
||||
@@ -1522,51 +1437,110 @@ end
|
||||
|
||||
### 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
|
||||
# src/tools/getTime.jl
|
||||
using .type
|
||||
using Dates
|
||||
|
||||
function executeTool(...)
|
||||
function getTimeExecute(...)
|
||||
now() # Dates.now requires `using Dates`
|
||||
end
|
||||
```
|
||||
|
||||
```julia
|
||||
# src/tools/myApiTool.jl
|
||||
using .type
|
||||
using HTTP, JSON
|
||||
|
||||
function executeTool(...)
|
||||
function myApiToolExecute(...)
|
||||
response = HTTP.get("https://api.example.com")
|
||||
data = JSON.parse(String(response.body))
|
||||
...
|
||||
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:
|
||||
|
||||
```julia
|
||||
# User writes in src/tools/myTool.jl:
|
||||
using Dates, HTTP, JSON # ← tool's own dependencies
|
||||
|
||||
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.
|
||||
All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means:
|
||||
- All functions live in the `YiemAgent` module, avoiding world-age issues
|
||||
- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available
|
||||
- Functions are named with a `<toolName>` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`)
|
||||
- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration
|
||||
|
||||
---
|
||||
|
||||
## 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::Dict{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
|
||||
|
||||
@@ -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) |
|
||||
| `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`) |
|
||||
| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
|
||||
| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) |
|
||||
|
||||
@@ -1,77 +1,11 @@
|
||||
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")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_processMessage 10
|
||||
JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in Bangkok is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user is asking for the weather in Bangkok.\nI have already retrieved the weather information in the previous turn and provided it to the user.\nThe user's current input is \"What's the weather in Bangkok?\", which is the same question as before.\nI should provide the same answer again.\nNo new tool calls are needed.\nI will simply state the weather information retrieved previously.\nWeather in Bangkok: Sunny, 22°C.\nI will output the answer directly.\n"))
|
||||
_processMessage 11
|
||||
hasToolCalls: false
|
||||
toolCallList: YiemAgent.type.agentToolCall[]
|
||||
_processMessage 17
|
||||
_processMessage 18
|
||||
|
||||
---
|
||||
here is my log. from the log, it seems like _processMessage() is finished but somehow the log didn't show _agentLoop 5 message. _processMessage() may not exit properly but why?
|
||||
|
||||
|
||||
+13
-2
@@ -1,7 +1,6 @@
|
||||
module YiemAgent
|
||||
|
||||
# export agent
|
||||
|
||||
export register_all_tools
|
||||
|
||||
""" Order by dependencies of each file. The 1st included file must not depend on any other
|
||||
files and each file can only depend on the file included before it.
|
||||
@@ -13,9 +12,21 @@ module YiemAgent
|
||||
include("utils.jl")
|
||||
using .utils
|
||||
|
||||
include("tools/getWeather.jl")
|
||||
include("tools/getTime.jl")
|
||||
include("tools/writeTool.jl")
|
||||
|
||||
include("toolRegistry.jl")
|
||||
using .toolRegistry
|
||||
|
||||
function register_all_tools(store::toolRegistry.toolStore)
|
||||
registerTool(store, getWeatherTool())
|
||||
registerTool(store, getTimeTool())
|
||||
registerTool(store, writeToolTool())
|
||||
registerTool(store, listTool(store))
|
||||
return store.tools
|
||||
end
|
||||
|
||||
# include("llmfunction.jl")
|
||||
# using .llmfunction
|
||||
|
||||
|
||||
+525
-281
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -116,7 +116,7 @@ julia> stopAgent(agent)
|
||||
function stopAgent(agent::yiemAgent)
|
||||
put!(agent.inputChannel, :shutdown)
|
||||
try
|
||||
fetch(agent._agent_loop)
|
||||
fetch(agent._agentLoop)
|
||||
catch e
|
||||
if e isa TaskFailedException
|
||||
rethrow(e)
|
||||
|
||||
+4
-96
@@ -1,6 +1,6 @@
|
||||
module toolRegistry
|
||||
|
||||
export toolStore, loadTools, registerTool, getTools, clearTools, listTool
|
||||
export toolStore, registerTool, getTools, clearTools, listTool
|
||||
|
||||
using Dates
|
||||
using JSON, DataStructures
|
||||
@@ -45,7 +45,7 @@ end
|
||||
Return an `agentTool` definition for listing registered tools.
|
||||
|
||||
Each call produces a **new** tool object that captures (closes over)
|
||||
`store`. `loadTools` auto-registers one so the LLM can discover tools
|
||||
`store`. `register_all_tools` auto-registers one so the LLM can discover tools
|
||||
at runtime.
|
||||
|
||||
# Arguments
|
||||
@@ -55,7 +55,7 @@ at runtime.
|
||||
```julia
|
||||
julia> store = toolStore(name="agent1");
|
||||
|
||||
julia> loadTools(store, "src/tools") # auto-registers listTools
|
||||
julia> register_all_tools(store) # auto-registers listTools
|
||||
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
|
||||
[toolRegistry:agent1] Registered tool: listTools
|
||||
|
||||
@@ -97,99 +97,7 @@ function listTool(store::toolStore)::agentTool
|
||||
)
|
||||
end
|
||||
|
||||
"""
|
||||
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.
|
||||
Each file is loaded into its own Julia submodule to avoid name collisions.
|
||||
|
||||
# Arguments
|
||||
- `store`: Tool store to populate
|
||||
- `dir`: Directory containing `.jl` tool files
|
||||
|
||||
# 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}
|
||||
if !isdir(dir)
|
||||
throw(ArgumentError("Tool directory does not exist: $dir"))
|
||||
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
|
||||
# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope
|
||||
|
||||
"""
|
||||
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using .type
|
||||
using Dates
|
||||
|
||||
"""
|
||||
@@ -15,7 +16,7 @@ Demonstrates custom validation beyond simple required-field checking:
|
||||
- `nothing` if validation passes
|
||||
- `String` error message if validation fails
|
||||
"""
|
||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
||||
tz = get(args, "timezone", nothing)
|
||||
city = get(args, "city", "")
|
||||
|
||||
@@ -43,8 +44,8 @@ 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
|
||||
function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
|
||||
onPartialResult)
|
||||
tz = get(args, "timezone", nothing)
|
||||
city = get(args, "city", "")
|
||||
if tz !== nothing
|
||||
@@ -61,7 +62,7 @@ end
|
||||
"""
|
||||
Define and return the getTime agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function getTimeTool()::agentTool
|
||||
return agentTool(
|
||||
name = "getTime",
|
||||
label = "Time Lookup",
|
||||
@@ -74,9 +75,9 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => []
|
||||
),
|
||||
execute = executeTool,
|
||||
execute = getTimeExecute,
|
||||
prepareArguments = nothing,
|
||||
validateRequiredArgs = validateRequiredArgs,
|
||||
validateRequiredArgs = getTimeValidateRequiredArgs,
|
||||
parallelToolExecute = false
|
||||
)
|
||||
end
|
||||
|
||||
+21
-12
@@ -1,24 +1,33 @@
|
||||
using msghandler
|
||||
using .type
|
||||
|
||||
"""
|
||||
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
|
||||
)
|
||||
function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
|
||||
agentEventSink)
|
||||
|
||||
agentEventSink("Getting weather...")
|
||||
|
||||
city = get(args, "city", "")
|
||||
units = get(args, "units", "celsius")
|
||||
temp = units == "fahrenheit" ? "72" : "22"
|
||||
unit_symbol = units == "celsius" ? "°C" : "°F"
|
||||
|
||||
return agentToolResult(
|
||||
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
|
||||
Dict{Any,Any}(),
|
||||
nothing,
|
||||
false
|
||||
)
|
||||
end
|
||||
|
||||
"""
|
||||
Define and return the getWeather agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function getWeatherTool()::agentTool
|
||||
return agentTool(
|
||||
name = "getWeather",
|
||||
label = "Weather Lookup",
|
||||
@@ -31,7 +40,7 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => ["city"]
|
||||
),
|
||||
execute = executeTool,
|
||||
execute = getWeatherExecute,
|
||||
prepareArguments = nothing,
|
||||
validateRequiredArgs = nothing,
|
||||
parallelToolExecute = false
|
||||
|
||||
+12
-9
@@ -1,3 +1,4 @@
|
||||
using .type
|
||||
using JSON
|
||||
|
||||
"""
|
||||
@@ -7,15 +8,17 @@ 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.
|
||||
After calling this tool, add the new file to `YiemAgent.jl` with an `include()`
|
||||
statement (after `include("toolRegistry.jl")`), then restart the agent.
|
||||
The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`.
|
||||
|
||||
# Example
|
||||
|
||||
1. Agent calls writeTool with a spec for a "searchWine" tool
|
||||
2. writeTool generates src/tools/searchWine.jl
|
||||
3. Restart agent — loadTools() picks up the new file
|
||||
4. Agent calls searchWine with args
|
||||
3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
|
||||
4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools()
|
||||
5. Restart agent — new tool is available
|
||||
|
||||
# How It Works
|
||||
|
||||
@@ -24,7 +27,7 @@ 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
|
||||
- Appends `writeToolTool()` returning an `agentTool` struct
|
||||
- Writes the combined string to `src/tools/<name>.jl`
|
||||
|
||||
# Important Notes
|
||||
@@ -105,7 +108,7 @@ end
|
||||
"""
|
||||
Define and return the writeTool agentTool.
|
||||
"""
|
||||
function getTool()::agentTool
|
||||
function writeToolTool()::agentTool
|
||||
return agentTool(
|
||||
name = "writeTool",
|
||||
label = "Create Tool",
|
||||
@@ -127,7 +130,7 @@ function getTool()::agentTool
|
||||
),
|
||||
"required" => ["name", "label", "description", "inputSchema", "executeCode"]
|
||||
),
|
||||
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin
|
||||
execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin
|
||||
tool_name = get(args, "name", "")::String
|
||||
tool_label = get(args, "label", tool_name)::String
|
||||
tool_description = get(args, "description", "")::String
|
||||
@@ -250,13 +253,13 @@ function getTool()::agentTool
|
||||
|
||||
tool_code = join(parts)
|
||||
|
||||
# Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools")
|
||||
# Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools()
|
||||
write(filepath, tool_code)
|
||||
|
||||
onPartialResult(Dict("status" => "Done"))
|
||||
|
||||
return agentToolResult(
|
||||
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
|
||||
[textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")],
|
||||
Dict{Any,Any}(
|
||||
"file" => filepath,
|
||||
"name" => tool_name,
|
||||
|
||||
+6
-9
@@ -146,7 +146,8 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
|
||||
function assistantMessage(; role="assistant", content=Vector{messageContent}(),
|
||||
api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
|
||||
errorMessage=nothing, timestamp=now())
|
||||
return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp)
|
||||
model_str = model isa AbstractString ? String(model) : ""
|
||||
return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp)
|
||||
end
|
||||
|
||||
struct toolResultMessage <: agentMessage # Result returned from a tool execution
|
||||
@@ -263,7 +264,7 @@ struct agentTool # A tool available to the agent
|
||||
label::String # Human-readable tool name
|
||||
description::String # What the tool does
|
||||
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
|
||||
execute::Function # Tool execution function
|
||||
execute # Tool execution function
|
||||
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
|
||||
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
|
||||
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
|
||||
@@ -273,7 +274,7 @@ end
|
||||
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
|
||||
"""
|
||||
function agentTool(; name::String, label::String, description::String, inputSchema::Any,
|
||||
execute::Function, prepareArguments::Union{Function, Nothing}=nothing,
|
||||
execute, prepareArguments::Union{Function, Nothing}=nothing,
|
||||
validateRequiredArgs::Union{Function, Nothing}=nothing,
|
||||
parallelToolExecute::Bool=false)
|
||||
return agentTool(name, label, description, inputSchema, execute,
|
||||
@@ -291,7 +292,7 @@ Snapshot of the agent's conversation context.
|
||||
# Arguments
|
||||
- `systemPrompt::String`: System prompt for the agent
|
||||
- `messages::Vector{agentMessage}`: Conversation messages
|
||||
- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
|
||||
- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
|
||||
|
||||
# Returns
|
||||
- A new `agentContext` instance
|
||||
@@ -299,7 +300,7 @@ Snapshot of the agent's conversation context.
|
||||
struct agentContext # Snapshot of the agent's conversation context
|
||||
systemPrompt::String # System prompt for the agent
|
||||
messages::Vector{agentMessage} # Conversation messages
|
||||
tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||
tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||
end
|
||||
|
||||
|
||||
@@ -316,7 +317,6 @@ mutable struct agentState # Mutable runtime state of an agen
|
||||
messages::Vector{agentMessage}
|
||||
|
||||
pendingToolCalls::Vector{String} # Tool call IDs waiting for results
|
||||
activeRun::Bool # is agent processing user message?
|
||||
errorMessage::Union{String, Nothing} # Last error message
|
||||
end
|
||||
|
||||
@@ -353,7 +353,6 @@ function agentState(
|
||||
deepcopy(tools),
|
||||
deepcopy(messages),
|
||||
Vector{String}(),
|
||||
false,
|
||||
nothing,
|
||||
)
|
||||
end
|
||||
@@ -395,13 +394,11 @@ end
|
||||
Configuration for the agent tool execution loop.
|
||||
|
||||
# Arguments
|
||||
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
|
||||
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
||||
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
||||
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
||||
"""
|
||||
struct agentLoopConfig
|
||||
tools::OrderedDict{String, agentTool}
|
||||
beforeToolCall::Union{Function, Nothing}
|
||||
afterToolCall::Union{Function, Nothing}
|
||||
toolExecution::String
|
||||
|
||||
+125
-16
@@ -2,10 +2,10 @@ module utils
|
||||
|
||||
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
|
||||
validateToolArguments, _userMessageToOpenAI,
|
||||
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks,
|
||||
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
|
||||
beforeToolCall, afterToolCall, agentEventSink
|
||||
|
||||
using UUIDs, Dates, DataStructures, HTTP, JSON
|
||||
using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
|
||||
using GeneralUtils
|
||||
using ..type
|
||||
|
||||
@@ -75,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
|
||||
end
|
||||
|
||||
|
||||
|
||||
"""
|
||||
prepareContext(state::agentState) -> agentContext
|
||||
|
||||
@@ -110,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
|
||||
# end
|
||||
```
|
||||
"""
|
||||
function prepareContext(state::agentState)::agentContext
|
||||
function prepareContext(state::agentState, agentEventSink)::agentContext
|
||||
|
||||
#TODO filter tools from state.tools based on user intend in user message and tool description
|
||||
filteredTools = state.tools
|
||||
@@ -157,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
|
||||
])
|
||||
```
|
||||
"""
|
||||
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
|
||||
|
||||
""" openai message format example
|
||||
msg = Dict(
|
||||
@@ -185,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
Dict("type" => "text", "text" => "let me check."),
|
||||
]
|
||||
),
|
||||
],
|
||||
"tools"=> [
|
||||
Dict(
|
||||
"role" => "toolResult",
|
||||
"content" => [
|
||||
Dict("type" => "text", "text" => "name: Chateau Montelena ..."),
|
||||
]
|
||||
),
|
||||
"type" => "function",
|
||||
"function" => Dict(
|
||||
"name" => "getWeather",
|
||||
"description" => "Get current weather",
|
||||
"parameters" => Dict(
|
||||
"type" => "object",
|
||||
"properties" => Dict(
|
||||
"city" => Dict("type" => "string")
|
||||
),
|
||||
"required" => ["city"]
|
||||
)
|
||||
)
|
||||
)
|
||||
],
|
||||
"temperature" => 0.7
|
||||
)
|
||||
"""
|
||||
|
||||
openaiReadyMsg = Dict{String, Any}()
|
||||
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
|
||||
messages = Vector{Dict{String, Any}}()
|
||||
|
||||
agentEventSink("formatMsgForLLM 1")
|
||||
# System prompt as system message
|
||||
if !isempty(ctx.systemPrompt)
|
||||
push!(messages, Dict(
|
||||
@@ -205,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
|
||||
))
|
||||
end
|
||||
|
||||
agentEventSink("formatMsgForLLM 2")
|
||||
# Conversation messages
|
||||
for msg in ctx.messages
|
||||
if msg isa userMessage
|
||||
@@ -216,11 +227,52 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
|
||||
push!(messages, _toolResultMessageToOpenAI(msg))
|
||||
end
|
||||
end
|
||||
agentEventSink("formatMsgForLLM 3")
|
||||
# Convert ctx.tools into OpenAI tools format
|
||||
tools_array = _toolsToOpenAI(ctx.tools, agentEventSink)
|
||||
agentEventSink("formatMsgForLLM 4")
|
||||
openaiReadyMsg["messages"] = messages
|
||||
openaiReadyMsg["temperature"] = 0.7
|
||||
|
||||
return Dict("messages" => messages)
|
||||
if !isempty(tools_array)
|
||||
openaiReadyMsg["tools"] = tools_array
|
||||
end
|
||||
|
||||
return openaiReadyMsg
|
||||
end
|
||||
|
||||
#TODO
|
||||
"""
|
||||
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult
|
||||
|
||||
Callback invoked before executing a tool call. Use this hook to inspect
|
||||
the tool call and decide whether to allow, block, or modify it.
|
||||
|
||||
Common use cases:
|
||||
- Request user approval via UI before running destructive tools.
|
||||
- Validate business rules that cannot be expressed in the JSON schema.
|
||||
- Check final context (e.g. session state, rate limits, permissions).
|
||||
|
||||
# Arguments
|
||||
- `context::beforeToolCallContext`: Contains the assistant message, tool call,
|
||||
validated arguments, and current conversation context.
|
||||
- `signal::abortSignal`: Signal that may be set to abort the operation.
|
||||
|
||||
# Returns
|
||||
- `beforeToolCallResult(false, "N/A")` to allow the call to proceed.
|
||||
- `beforeToolCallResult(true, "Reason")` to block the call with a reason.
|
||||
- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`).
|
||||
|
||||
# Example
|
||||
```julia
|
||||
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)
|
||||
if context.toolCall.name == "deleteFile"
|
||||
# Block file deletion unless explicitly approved
|
||||
return beforeToolCallResult(true, "User must approve file deletion")
|
||||
end
|
||||
return beforeToolCallResult(false, "N/A")
|
||||
end
|
||||
```
|
||||
"""
|
||||
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
|
||||
)::beforeToolCallResult
|
||||
|
||||
@@ -233,8 +285,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
|
||||
return beforeToolCallResult(false, "N/A")
|
||||
end
|
||||
|
||||
#TODO
|
||||
function afterToolCall(context::beforeToolCallContext, signal::abortSignal
|
||||
"""
|
||||
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
|
||||
|
||||
Callback invoked after a tool call finishes executing (before and after errors).
|
||||
Use this hook to post-process the tool result before it is fed back to the LLM.
|
||||
|
||||
Common use cases:
|
||||
- Mask sensitive data (API keys, tokens) from result content.
|
||||
- Normalize usage tracking data into a consistent format.
|
||||
- Inspect the result and set `terminate: true` based on business logic
|
||||
(e.g. "if deployment failed, stop the agent rather than retrying").
|
||||
- Wrap error results in friendlier messages for the LLM to understand.
|
||||
|
||||
# Arguments
|
||||
- `context::afterToolCallContext`: Contains the assistant message, tool call,
|
||||
arguments, raw result, error status, and current conversation context.
|
||||
- `signal::abortSignal`: Signal that may be set to abort the operation.
|
||||
|
||||
# Returns
|
||||
- `nothing` to pass the result through unchanged.
|
||||
- `agentToolResult(...)` to return a modified result (content, details, usage,
|
||||
terminate flag can all be overridden).
|
||||
|
||||
"""
|
||||
function afterToolCall(context::afterToolCallContext, signal::abortSignal
|
||||
)::Union{agentToolResult, Nothing}
|
||||
|
||||
# modify context.result if needed and return agentToolResult
|
||||
@@ -309,6 +384,40 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
_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}, agentEventSink)::Vector{Dict{String, Any}}
|
||||
tools_array = Vector{Dict{String, Any}}()
|
||||
agentEventSink("_toolsToOpenAI 1")
|
||||
agentEventSink(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
|
||||
agentEventSink("_toolsToOpenAI 2")
|
||||
return tools_array
|
||||
end
|
||||
|
||||
|
||||
"""
|
||||
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
|
||||
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
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 textContent
|
||||
@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].text == "Thinking..."
|
||||
@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].text == "internal thought"
|
||||
@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")
|
||||
|
||||
+192
-37
@@ -1,35 +1,19 @@
|
||||
using Test
|
||||
using Dates
|
||||
using YiemAgent
|
||||
using YiemAgent.toolRegistry
|
||||
using YiemAgent.type
|
||||
using YiemAgent.agentCore
|
||||
|
||||
# Path to the real tools directory
|
||||
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
|
||||
@testset "loadTools with toolStore" begin
|
||||
@testset "register_all_tools with toolStore" begin
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. loadTools throws on non-existent directory #
|
||||
# 1. register_all_tools registers all static tools + listTools #
|
||||
# ------------------------------------------------------------------ #
|
||||
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)
|
||||
loaded = register_all_tools(store)
|
||||
@test !isempty(loaded)
|
||||
@test length(loaded) == 4 # 3 files + auto-registered listTools
|
||||
@test length(loaded) == 4 # getWeather + getTime + writeTool + listTools
|
||||
|
||||
names = [k for k in keys(loaded)]
|
||||
@test "getTime" in names
|
||||
@@ -38,16 +22,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test "listTools" in names
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. loadTools returns tools sorted alphabetically by filename #
|
||||
# (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
|
||||
# 2. register_all_tools returns tools in registration order #
|
||||
# ------------------------------------------------------------------ #
|
||||
@test collect(keys(loaded))[1] == "getTime"
|
||||
@test collect(keys(loaded))[2] == "getWeather"
|
||||
@test collect(keys(loaded))[1] == "getWeather"
|
||||
@test collect(keys(loaded))[2] == "getTime"
|
||||
@test collect(keys(loaded))[3] == "writeTool"
|
||||
@test collect(keys(loaded))[4] == "listTools"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Verify loaded tool fields are correct #
|
||||
# 3. Verify loaded tool fields are correct #
|
||||
# ------------------------------------------------------------------ #
|
||||
# getTime
|
||||
time_tool = loaded["getTime"]
|
||||
@@ -74,7 +57,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test "executeCode" in wt.inputSchema["required"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 6. Tool execution returns valid results #
|
||||
# 4. Tool execution returns valid results #
|
||||
# ------------------------------------------------------------------ #
|
||||
sig = nothing
|
||||
op = x -> x # no-op partial result callback
|
||||
@@ -98,16 +81,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
|
||||
# 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)
|
||||
@test occursin("72\u00b0F", result_w2.content[1].text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 7. getTools / registerTool / clearTools (per-store isolation) #
|
||||
# 5. 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"])
|
||||
@@ -143,7 +125,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test reg["manualTool"].parallelToolExecute == true
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 8. getTools returns direct reference (mutations affect registry) #
|
||||
# 6. getTools returns direct reference (mutations affect registry) #
|
||||
# ------------------------------------------------------------------ #
|
||||
copy1 = getTools(store3)
|
||||
copy2 = getTools(store3)
|
||||
@@ -152,7 +134,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
||||
@test isempty(getTools(store3)) # mutation propagates
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 9. Per-store isolation — two stores don't share tools #
|
||||
# 7. Per-store isolation — two stores don't share tools #
|
||||
# ------------------------------------------------------------------ #
|
||||
storeA = toolStore(name="isolationA")
|
||||
storeB = toolStore(name="isolationB")
|
||||
@@ -175,10 +157,10 @@ end
|
||||
|
||||
@testset "listTool" begin
|
||||
store = toolStore(name="test_list")
|
||||
loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools
|
||||
register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools
|
||||
|
||||
# loadTools auto-registers listTool
|
||||
@test "listTools" in keys(loaded)
|
||||
# register_all_tools auto-registers listTool
|
||||
@test "listTools" in keys(store.tools)
|
||||
|
||||
# listTool returns an agentTool, not a string or array
|
||||
list_t = listTool(store)
|
||||
@@ -199,7 +181,7 @@ end
|
||||
|
||||
# Each listTool call creates an independent closure
|
||||
storeB = toolStore(name="test_listB")
|
||||
registerTool(storeB, loaded["getWeather"])
|
||||
registerTool(storeB, store.tools["getWeather"])
|
||||
list_tB = listTool(storeB)
|
||||
|
||||
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
|
||||
@@ -210,3 +192,176 @@ end
|
||||
@test occursin("getTime", resultA.content[1].text)
|
||||
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
|
||||
end
|
||||
|
||||
@testset "executePreparedToolCall with static tools" begin
|
||||
# Tests executePreparedToolCall with statically loaded tools.
|
||||
# The world-age issue is resolved because tool.execute comes from
|
||||
# a statically included module, not a dynamically created one.
|
||||
|
||||
store = toolStore(name="test_static")
|
||||
register_all_tools(store)
|
||||
|
||||
weather_tool = store.tools["getWeather"]
|
||||
|
||||
# Create a preparedToolCall that mimics what prepareToolCall() returns
|
||||
tool_call = agentToolCall(
|
||||
"function", "call-static-1", "getWeather",
|
||||
Dict{String,Any}("city" => "San Francisco")
|
||||
)
|
||||
prep = preparedToolCall(
|
||||
weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco")
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
# This call goes through: executePreparedToolCall -> prep.tool.execute(...)
|
||||
result = executePreparedToolCall(
|
||||
prep, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test result isa executedOutcome
|
||||
@test result.isError == false
|
||||
@test result.result.content[1] isa textContent
|
||||
@test occursin("San Francisco", result.result.content[1].text)
|
||||
end
|
||||
|
||||
@testset "executePreparedToolCall with validation (static tools)" begin
|
||||
# Tests executePreparedToolCall with a tool that has custom validation hooks.
|
||||
# This exercises the full tool execution path including validation.
|
||||
|
||||
store = toolStore(name="test_static_validate")
|
||||
register_all_tools(store)
|
||||
|
||||
time_tool = store.tools["getTime"]
|
||||
|
||||
tool_call = agentToolCall(
|
||||
"function", "call-static-2", "getTime",
|
||||
Dict{String,Any}("timezone" => "America/New_York")
|
||||
)
|
||||
prep = preparedToolCall(
|
||||
time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York")
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
result = executePreparedToolCall(
|
||||
prep, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test result isa executedOutcome
|
||||
@test result.isError == false
|
||||
@test result.result.content[1] isa textContent
|
||||
@test occursin("America/New_York", result.result.content[1].text)
|
||||
end
|
||||
|
||||
@testset "executeToolCallsSequential with static tools (full pipeline)" begin
|
||||
# Tests the full tool execution pipeline: executeToolCallsSequential
|
||||
# which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall
|
||||
# with statically loaded tools.
|
||||
|
||||
store = toolStore(name="test_full_pipeline")
|
||||
register_all_tools(store)
|
||||
|
||||
# Build agentContext from the store's tools
|
||||
tools = getTools(store)
|
||||
ctx = agentContext(
|
||||
"test system prompt",
|
||||
agentMessage[],
|
||||
tools
|
||||
)
|
||||
|
||||
# Create an assistant message containing tool calls
|
||||
assistant_msg = assistantMessage(
|
||||
role="assistant",
|
||||
content=Vector{messageContent}(),
|
||||
api="openai",
|
||||
provider="test",
|
||||
model="test-model",
|
||||
usage=llmUsage(0, 0),
|
||||
stopReason="tool_calls",
|
||||
errorMessage=nothing,
|
||||
timestamp=now()
|
||||
)
|
||||
|
||||
# Create tool calls for multiple statically loaded tools
|
||||
tool_calls = [
|
||||
agentToolCall(
|
||||
"function", "call-seq-1", "getWeather",
|
||||
Dict{String,Any}("city" => "Tokyo")
|
||||
),
|
||||
agentToolCall(
|
||||
"function", "call-seq-2", "getTime",
|
||||
Dict{String,Any}("timezone" => "Europe/London")
|
||||
),
|
||||
]
|
||||
|
||||
config = agentLoopConfig(
|
||||
nothing, nothing, "sequential"
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
# Execute the full pipeline
|
||||
batch = executeToolCallsSequential(
|
||||
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test batch.messages isa Vector{toolResultMessage}
|
||||
@test length(batch.messages) == 2
|
||||
@test batch.messages[1].toolName == "getWeather"
|
||||
@test batch.messages[1].isError == false
|
||||
@test occursin("Tokyo", batch.messages[1].content[1].text)
|
||||
@test batch.messages[2].toolName == "getTime"
|
||||
@test batch.messages[2].isError == false
|
||||
@test occursin("Europe/London", batch.messages[2].content[1].text)
|
||||
end
|
||||
|
||||
@testset "executeToolCallsParallel with static tools (full pipeline)" begin
|
||||
# Same as above but tests parallel execution path.
|
||||
|
||||
store = toolStore(name="test_parallel")
|
||||
register_all_tools(store)
|
||||
|
||||
tools = getTools(store)
|
||||
ctx = agentContext(
|
||||
"test system prompt",
|
||||
agentMessage[],
|
||||
tools
|
||||
)
|
||||
|
||||
assistant_msg = assistantMessage(
|
||||
role="assistant",
|
||||
content=Vector{messageContent}(),
|
||||
api="openai",
|
||||
provider="test",
|
||||
model="test-model",
|
||||
usage=llmUsage(0, 0),
|
||||
stopReason="tool_calls",
|
||||
errorMessage=nothing,
|
||||
timestamp=now()
|
||||
)
|
||||
|
||||
tool_calls = [
|
||||
agentToolCall(
|
||||
"function", "call-par-1", "getWeather",
|
||||
Dict{String,Any}("city" => "Paris")
|
||||
),
|
||||
agentToolCall(
|
||||
"function", "call-par-2", "getTime",
|
||||
Dict{String,Any}("city" => "Sydney")
|
||||
),
|
||||
]
|
||||
|
||||
config = agentLoopConfig(
|
||||
nothing, nothing, "parallel"
|
||||
)
|
||||
sig = abortSignal(false)
|
||||
|
||||
batch = executeToolCallsParallel(
|
||||
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
|
||||
)
|
||||
|
||||
@test batch.messages isa Vector{toolResultMessage}
|
||||
@test length(batch.messages) == 2
|
||||
@test batch.messages[1].toolName == "getWeather"
|
||||
@test batch.messages[1].isError == false
|
||||
@test batch.messages[2].toolName == "getTime"
|
||||
@test batch.messages[2].isError == false
|
||||
end
|
||||
|
||||
+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
|
||||
|
||||
agentEventSink(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 agentEventSink
|
||||
natsConn::NATS.Connection
|
||||
topic::String
|
||||
senderID::String
|
||||
end
|
||||
|
||||
function (aes::agentEventSink)(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 = agentEventSink(agent_conn, "sommanion.debug", "sender")
|
||||
|
||||
agent = YiemAgent.yiemAgent(
|
||||
text2text_llm;
|
||||
agentEventSink=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