static tool loading

This commit is contained in:
2026-08-15 16:50:28 +07:00
parent b8067c2d33
commit 2543e6cbf1
13 changed files with 864 additions and 542 deletions
+50 -1
View File
@@ -2,7 +2,7 @@
julia_version = "1.12.6" julia_version = "1.12.6"
manifest_format = "2.0" manifest_format = "2.0"
project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c" project_hash = "aa163e2bf572632825162936e107be18384fd40f"
[[deps.Accessors]] [[deps.Accessors]]
deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
@@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6"
uuid = "ec485272-7323-5ecc-a04f-4719b315124d" uuid = "ec485272-7323-5ecc-a04f-4719b315124d"
version = "0.4.0" version = "0.4.0"
[[deps.Arrow]]
deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01"
uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
version = "2.8.1"
[[deps.ArrowTypes]] [[deps.ArrowTypes]]
deps = ["Sockets", "UUIDs"] deps = ["Sockets", "UUIDs"]
git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
@@ -58,6 +64,12 @@ version = "1.11.0"
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
version = "1.11.0" version = "1.11.0"
[[deps.BitIntegers]]
deps = ["Random"]
git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46"
uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
version = "0.3.7"
[[deps.BufferedStreams]] [[deps.BufferedStreams]]
git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643" git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643"
uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d" uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d"
@@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a"
uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf"
version = "0.3.4" version = "0.3.4"
[[deps.CodecLz4]]
deps = ["Lz4_jll", "TranscodingStreams"]
git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2"
uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
version = "0.4.6"
[[deps.CodecZlib]] [[deps.CodecZlib]]
deps = ["TranscodingStreams", "Zlib_jll"] deps = ["TranscodingStreams", "Zlib_jll"]
git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
uuid = "944b1d66-785c-5afd-91f1-9de20f533193" uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
version = "0.7.8" version = "0.7.8"
[[deps.CodecZstd]]
deps = ["TranscodingStreams", "Zstd_jll"]
git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56"
uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
version = "0.8.7"
[[deps.CommonSolve]] [[deps.CommonSolve]]
git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1"
uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
@@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"]
[deps.CompositionsBase.extensions] [deps.CompositionsBase.extensions]
CompositionsBaseInverseFunctionsExt = "InverseFunctions" CompositionsBaseInverseFunctionsExt = "InverseFunctions"
[[deps.ConcurrentUtilities]]
deps = ["Serialization", "Sockets"]
git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738"
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
version = "2.6.0"
[[deps.ConstructionBase]] [[deps.ConstructionBase]]
git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb"
uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9"
@@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440"
uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b"
version = "3.8.0" version = "3.8.0"
[[deps.Lz4_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33"
uuid = "5ced341a-0733-55b8-9ab6-a4889d929147"
version = "1.10.1+0"
[[deps.MacroTools]] [[deps.MacroTools]]
git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522"
uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
@@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484"
uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
version = "0.4.7" version = "0.4.7"
[[deps.StringViews]]
git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8"
uuid = "354b36f9-a18e-4713-926e-db85100087ba"
version = "1.3.7"
[[deps.StructTypes]] [[deps.StructTypes]]
deps = ["Dates", "UUIDs"] deps = ["Dates", "UUIDs"]
git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8"
@@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026"
uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
version = "1.0.21+0" version = "1.0.21+0"
[[deps.msghandler]]
deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"]
git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f"
repo-rev = "main"
repo-url = "https://git.yiem.cc/ton/msghandler"
uuid = "f2724d33-f338-4a57-b9f8-1be882570d10"
version = "1.2.1"
[[deps.nghttp2_jll]] [[deps.nghttp2_jll]]
deps = ["Artifacts", "Libdl"] deps = ["Artifacts", "Libdl"]
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+2
View File
@@ -22,6 +22,7 @@ SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3"
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10"
[compat] [compat]
Base64 = "1.11.0" Base64 = "1.11.0"
@@ -33,3 +34,4 @@ JSON = "1.6.1"
LLMMCTS = "0.1.5" LLMMCTS = "0.1.5"
NATS = "0.1.0" NATS = "0.1.0"
SQLLLM = "0.2.8" SQLLLM = "0.2.8"
msghandler = "1.2.1"
+217 -243
View File
@@ -9,7 +9,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework,
1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) 1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle)
2. [Overview](#2-overview) 2. [Overview](#2-overview)
3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) 3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct)
4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) 4. [Tool Registration — Static Registration](#4-tool-registration--static-registration)
5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) 5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow)
6. [Message Processing Pipeline](#6-message-processing-pipeline) 6. [Message Processing Pipeline](#6-message-processing-pipeline)
7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) 7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response)
@@ -23,7 +23,8 @@ This document describes the complete tool lifecycle in the YiemAgent framework,
15. [Self-Modifying Tools](#15-self-modifying-tools) 15. [Self-Modifying Tools](#15-self-modifying-tools)
16. [Complete End-to-End Example](#16-complete-end-to-end-example) 16. [Complete End-to-End Example](#16-complete-end-to-end-example)
17. [Tool File Contract](#17-tool-file-contract) 17. [Tool File Contract](#17-tool-file-contract)
18. [Appendix: Type Reference](#18-appendix-type-reference) 18. [Adding New Tools](#18-adding-new-tools)
19. [Appendix: Type Reference](#19-appendix-type-reference)
--- ---
@@ -43,76 +44,52 @@ tool = listTool(store) # Returns an agentTool that, when executed, lists all to
**Result extraction:** **Result extraction:**
```julia ```julia
result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x)
# result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..." # result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..."
``` ```
**Source:** `toolRegistry.jl:54-82` **Source:** `toolRegistry.jl:43-98`
--- ---
### Step 2: Load — `loadTools()` ### Step 2: Register — `register_all_tools()`
Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools.
```julia ```julia
using YiemAgent, YiemAgent.toolRegistry using YiemAgent, YiemAgent.toolRegistry
store = toolStore(name="myAgent") store = toolStore(name="myAgent")
tools = loadTools(store, "src/tools") tools = register_all_tools(store)
# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools # Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs
# Also auto-registers listTools for runtime discovery # Also auto-registers listTools for runtime discovery
``` ```
**Result extraction:** **Result extraction:**
```julia ```julia
all_tools = getTools(store) # OrderedDict{String, agentTool} all_tools = getTools(store) # OrderedDict{String, agentTool}
# Keys: "getTime", "getWeather", "writeTool", "listTools" # Keys: "getWeather", "getTime", "writeTool", "listTools"
getTime_tool = all_tools["getTime"] getWeather_tool = all_tools["getWeather"]
# Manual registration (alternative to loadTools) # Manual registration (alternative to register_all_tools)
registerTool(store, my_tool) registerTool(store, my_tool)
clearTools(store) # Clear all tools from store clearTools(store) # Clear all tools from store
``` ```
**Source:** `toolRegistry.jl:126-178` **Source:** `toolRegistry.jl:38-40, 127-143`
--- ---
### Step 2.5: Create Agent with Tools ### Step 2.5: Create Agent with Tools
Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration. Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically.
```julia ```julia
using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
# 1. Set up toolStore and load tools (auto-registers listTools) # Create agent — tools are registered automatically via register_all_tools()
store = toolStore(name="myAgent")
loadTools(store, "src/tools")
# 2. Create agent — pass tools + _tool_store
agent = yiemAgent( agent = yiemAgent(
systemPrompt = "You are a helpful assistant.",
model = my_model,
tools = getTools(store), # OrderedDict{String, agentTool}
llmCall = my_llm_call, # Function that calls the LLM API llmCall = my_llm_call, # Function that calls the LLM API
agentEventSink = my_event_sink, # Function for TUI/logging 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 | | 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 | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM |
| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | | `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`. Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`.
**Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195` **Source:** `type.jl:609-657`, `toolRegistry.jl:127-143`
--- ---
@@ -146,13 +121,13 @@ sig = nothing
op = x -> x # no-op partial result callback op = x -> x # no-op partial result callback
# Execute a loaded tool directly # Execute a loaded tool directly
result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)
``` ```
**Via agent loop (production):** **Via agent loop (production):**
``` ```
user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
→ _agentLoop detects message → @spawn _process_message(agent) → _agentLoop detects message → @spawn _processMessage(agent)
→ prepareContext → formatMsgForLLM → llmCall → prepareContext → formatMsgForLLM → llmCall
→ LLM returns tool_calls → LLM returns tool_calls
→ executeToolCalls(context, response, tool_call_list, config, signal, emit) → executeToolCalls(context, response, tool_call_list, config, signal, emit)
@@ -168,10 +143,10 @@ user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
**`agentToolResult`** (raw tool output, `type.jl:429-434`): **`agentToolResult`** (raw tool output, `type.jl:429-434`):
```julia ```julia
result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x)
result.content[1] # textContent("Current time in Tokyo: ...") result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C")
result.content[1].text # "Current time in Tokyo: 2026-08-10T..." result.content[1].text # "Weather in Tokyo: Sunny, 22°C"
result.details # Dict{Any,Any}() — tool-specific metadata result.details # Dict{Any,Any}() — tool-specific metadata
result.usage # nothing — llmUsage tracking (optional) result.usage # nothing — llmUsage tracking (optional)
result.terminate # false — signals loop termination result.terminate # false — signals loop termination
@@ -182,7 +157,7 @@ result.terminate # false — signals loop termination
msg = batch.messages[1] # toolResultMessage msg = batch.messages[1] # toolResultMessage
msg.toolCallId # "call-1" msg.toolCallId # "call-1"
msg.toolName # "getTime" msg.toolName # "getWeather"
msg.content # Vector{messageContent} msg.content # Vector{messageContent}
msg.isError # false msg.isError # false
msg.details # tool-specific metadata msg.details # tool-specific metadata
@@ -204,7 +179,7 @@ Each phase has a single responsibility and produces an intermediate result:
| Phase | Function | Input | Output | Purpose | | Phase | Function | Input | Output | Purpose |
|-------|----------|-------|--------|---------| |-------|----------|-------|--------|---------|
| Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | | Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook |
| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results | | Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results |
| Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | | Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event |
The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history.
@@ -276,9 +251,9 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca
--- ---
## 4. Tool Registration — Per-Agent Tool Stores ## 4. Tool Registration — Static Registration
**Source:** `toolRegistry.jl` **Source:** `toolRegistry.jl`, `YiemAgent.jl`
### How `toolStore` Works ### How `toolStore` Works
@@ -293,36 +268,25 @@ end
`store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. `store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store.
### How `loadTools(store, dir)` Works ### Static Registration — `register_all_tools()`
```julia ```julia
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} function register_all_tools(store::toolStore)::OrderedDict{String, agentTool}
``` ```
**Source:** `toolRegistry.jl:126-178` **Source:** `YiemAgent.jl:20-29`
1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 1. **Calls each tool's definition function**`getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs
2. **Sorts** filenames alphabetically for deterministic registration order 2. **Registers each tool** via `registerTool(store, tool)`
3. **Wraps** each file in a dynamically created submodule: 3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime
```julia
# For "getWeather.jl" → module _tool_getWeather
module _tool_getWeather
using ..type
using Dates, UUIDs, DataStructures, JSON
# (file contents here)
end
```
4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues
5. **Validates** the return value is an `agentTool` instance
6. **Registers** the tool in `store.tools`
7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime
### Why Submodules? ### Why Static?
Each tool file is loaded into its own **namespaced submodule**. This means: Tools are **statically included** in `YiemAgent.jl` via `include()`. This means:
- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` - Tool functions live in the `YiemAgent` module, not in dynamically created submodules
- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` - No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world)
- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected - Simpler tool definition — no need to wrap in a `module ... end` block
- Better compiler optimization (inlining, type inference)
### Registration API ### Registration API
@@ -331,9 +295,9 @@ Each tool file is loaded into its own **namespaced submodule**. This means:
store1 = toolStore(name="agent1") store1 = toolStore(name="agent1")
store2 = toolStore(name="agent2") store2 = toolStore(name="agent2")
# Load tools into specific stores (auto-registers listTools) # Load all tools (auto-registers listTools)
tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only tools1 = register_all_tools(store1) # all agents get the same tools
tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only tools2 = register_all_tools(store2)
# Manual registration (per-store) # Manual registration (per-store)
registerTool(store1, my_tool) registerTool(store1, my_tool)
@@ -359,8 +323,8 @@ Each `toolStore` is completely independent — tools registered in one store do
storeA = toolStore(name="A") storeA = toolStore(name="A")
storeB = toolStore(name="B") storeB = toolStore(name="B")
registerTool(storeA, getTime_tool) registerTool(storeA, getTimeTool())
registerTool(storeB, getWeather_tool) registerTool(storeB, getWeatherTool())
getTools(storeA) # only contains getTime getTools(storeA) # only contains getTime
getTools(storeB) # only contains getWeather getTools(storeB) # only contains getWeather
@@ -385,7 +349,6 @@ yiemAgent struct contains:
- inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
- outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
- _tool_store (toolStore) ← per-agent isolated tool registry
``` ```
### Loop States ### Loop States
@@ -395,7 +358,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`):
| State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | | State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior |
|-------|-----------------|-------------|----------------|-------------------|----------| |-------|-----------------|-------------|----------------|-------------------|----------|
| 1 | `nothing` | `false` | empty | empty | Idle, waiting | | 1 | `nothing` | `false` | empty | empty | Idle, waiting |
| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` | | 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` |
| 3 | running | `true` | empty | empty | Processing, no new input | | 3 | running | `true` | empty | empty | Processing, no new input |
| 4 | running | `true` | has msg | empty | New message while busy → queued | | 4 | running | `true` | has msg | empty | New message while busy → queued |
| 5 | running | `true` | empty | has msg | Follow-up while busy → queued | | 5 | running | `true` | empty | has msg | Follow-up while busy → queued |
@@ -414,9 +377,9 @@ function _agentLoop(agent::yiemAgent)
drain both channels, break loop drain both channels, break loop
end end
# 3. If agent is idle, spawn _process_message # 3. If agent is idle, spawn _processMessage
if agent._state.activeRun == false if agent._state.activeRun == false
processingTask = Threads.@spawn _process_message(agent) processingTask = Threads.@spawn _processMessage(agent)
agent._state.activeRun = true agent._state.activeRun = true
end end
@@ -445,12 +408,12 @@ end
**Source:** `agentCore.jl:175-311` **Source:** `agentCore.jl:175-311`
`_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline. `_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline.
### Pipeline Steps ### Pipeline Steps
```julia ```julia
function _process_message(agent::yiemAgent)::assistantMessage function _processMessage(agent::yiemAgent)::assistantMessage
final_response = nothing final_response = nothing
while true # Loop until LLM returns response without tool calls while true # Loop until LLM returns response without tool calls
@@ -466,38 +429,38 @@ function _process_message(agent::yiemAgent)::assistantMessage
end end
# ── Step 2: Prepare context ───────────────────────────────── # ── Step 2: Prepare context ─────────────────────────────────
preparedContext = agent.prepareContext(agent._state) state = agentState(systemPrompt, nothing, tools, messages)
preparedContext = prepareContext(state, agentEventSink)
# Default: deep copies systemPrompt, messages, tools from agentState → agentContext # Default: deep copies systemPrompt, messages, tools from agentState → agentContext
# Override point: filter tools, inject context, modify system prompt # Override point: filter tools, inject context, modify system prompt
# ── Step 3: Format for LLM ────────────────────────────────── # ── Step 3: Format for LLM ──────────────────────────────────
formatted_messages = agent.formatMsgForLLM(preparedContext) formattedMessages = formatMsgForLLM(preparedContext, agentEventSink)
# Converts agentContext → Dict("messages" => [...]) in OpenAI format # Converts agentContext → Dict("messages" => [...]) in OpenAI format
# Wraps systemPrompt as system role, converts each messageContent block # Wraps systemPrompt as system role, converts each messageContent block
# ── Step 4: Call LLM ──────────────────────────────────────── # ── Step 4: Call LLM ────────────────────────────────────────
response = agent.llmCall(formatted_messages) response = llmCall(formattedMessages)
# Returns assistantMessage with content::Vector{messageContent} # Returns assistantMessage with content::Vector{messageContent}
# Each content block has a type: "text", "thinking", or "tool_call" # Each content block has a type: "text", "thinking", or "tool_call"
# ── Step 5: Extract tool calls ────────────────────────────── # ── Step 5: Extract tool calls ──────────────────────────────
has_tool_calls, tool_call_list = extract_tool_calls(response.content) hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response)
# Inspects content blocks for "tool_calls" or "tool_call" Dict entries # Inspects content blocks for "tool_calls" or "tool_call" Dict entries
# ── Step 6: Execute tool calls or return ──────────────────── # ── Step 6: Execute tool calls or return ────────────────────
if has_tool_calls && !isempty(tool_call_list) if hasToolCalls && !isempty(toolCallList)
# Build context and config # Build context and config
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) context = agentContext(systemPrompt, messages, tools)
config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...) config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
signal = nothing signal = abortSignal(false)
emit = agent.agentEventSink
# Execute tool calls (sequential or parallel) # Execute tool calls (sequential or parallel)
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink)
# Save results to conversation history # Save results to conversation history
for tool_result in batch.messages for tool_result in batch.messages
push!(agent._state.messages, tool_result) push!(messages, tool_result)
end end
# Check termination # Check termination
@@ -517,10 +480,6 @@ function _process_message(agent::yiemAgent)::assistantMessage
end end
``` ```
### Debug Note
There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use.
--- ---
## 7. Tool Call Extraction from LLM Response ## 7. Tool Call Extraction from LLM Response
@@ -652,52 +611,30 @@ function prepareToolCall(
function executePreparedToolCall( function executePreparedToolCall(
prep::preparedToolCall, prep::preparedToolCall,
signal::Union{Nothing, abortSignal}, signal::Union{Nothing, abortSignal},
emit::Function, agentEventSink,
)::executedOutcome )::executedOutcome
``` ```
**Steps:** **Steps:**
1. **Initialize streaming state:** 1. **Call `tool.execute()`:**
```julia
updateEvents = promise[] # vector to collect update event handles
accepting = true # guard to prevent duplicate emissions
```
2. **Call `tool.execute()`:**
```julia ```julia
result = prep.tool.execute( result = prep.tool.execute(
prep.toolCall.id, prep.toolCall.id,
prep.args, prep.args,
signal, signal,
partialResult -> begin agentEventSink # serves as onPartialResult callback
if accepting
push!(updateEvents,
emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name,
prep.toolCall.arguments, partialResult)))
end
end
) )
```
3. **Wait for streaming to settle:**
```julia
accepting = false
wait.(updateEvents) # wait for all pending update event handlers
return executedOutcome(result, false) return executedOutcome(result, false)
``` ```
4. **On error:** 2. **On error:**
```julia ```julia
catch err catch err
accepting = false
wait.(updateEvents)
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
end end
``` ```
**Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted.
### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` ### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()`
**Source:** `agentCore.jl:675-706` **Source:** `agentCore.jl:675-706`
@@ -746,19 +683,6 @@ function finalizeExecutedToolCall(
**Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. **Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status.
### 8.4 Emission — `emitToolExecutionEnd()`
**Source:** `agentCore.jl:736-739`
```julia
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function)
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
end
```
This is called immediately after finalization, before building the `toolResultMessage`.
--- ---
## 9. Execution Modes — Sequential vs Parallel ## 9. Execution Modes — Sequential vs Parallel
@@ -774,7 +698,7 @@ function executeToolCalls(
toolCalls::Vector{agentToolCall}, toolCalls::Vector{agentToolCall},
config::agentLoopConfig, config::agentLoopConfig,
signal::Union{Nothing, abortSignal}, signal::Union{Nothing, abortSignal},
emit::Function, agentEventSink,
)::agentToolCallBatch )::agentToolCallBatch
``` ```
@@ -809,18 +733,15 @@ function executeToolCallsSequential(...)::agentToolCallBatch
messages = toolResultMessage[] messages = toolResultMessage[]
for tc in toolCalls for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError) finalized = finalizedOutcome(tc, prep.result, prep.isError)
else else
executed = executePreparedToolCall(prep, signal, emit) executed = executePreparedToolCall(prep, signal, agentEventSink)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
end end
emitToolExecutionEnd(finalized, emit)
push!(messages, createToolResultMessage(finalized)) push!(messages, createToolResultMessage(finalized))
push!(finalizedCalls, finalized) push!(finalizedCalls, finalized)
@@ -842,19 +763,15 @@ function executeToolCallsParallel(...)::agentToolCallBatch
entries = union{finalizedOutcome, task{finalizedOutcome}}[] entries = union{finalizedOutcome, task{finalizedOutcome}}[]
for tc in toolCalls for tc in toolCalls
emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
prep = prepareToolCall(context, assistantMsg, tc, config, signal)
if prep isa immediateOutcome if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError) finalized = finalizedOutcome(tc, prep.result, prep.isError)
emitToolExecutionEnd(finalized, emit)
push!(entries, finalized) # immediate outcome — no task push!(entries, finalized) # immediate outcome — no task
else else
task = task() do task = task() do
executed = executePreparedToolCall(prep, signal, emit) executed = executePreparedToolCall(prep, signal, agentEventSink)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
emitToolExecutionEnd(finalized, emit)
return finalized return finalized
end end
schedule(task) schedule(task)
@@ -923,12 +840,12 @@ From the type documentation (`type.jl:803-815`):
| Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | | Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) |
| Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | | Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` |
### Batch Processing in `_process_message()` ### Batch Processing in `_processMessage()`
**Source:** `agentCore.jl:266-307` **Source:** `agentCore.jl:266-307`
```julia ```julia
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
# Save results to conversation history # Save results to conversation history
for tool_result in batch.messages for tool_result in batch.messages
@@ -985,9 +902,9 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
f.result.content, # content (Vector{messageContent}) f.result.content, # content (Vector{messageContent})
f.result.details, # details f.result.details, # details
f.result.usage, # usage f.result.usage, # usage
get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools) nothing, # addedToolNames (for dynamic tools)
f.isError, # isError f.isError, # isError
nowMillis(), # timestamp now(), # timestamp
) )
end end
``` ```
@@ -1048,9 +965,6 @@ Tool call fails at any phase
└─────────────────────────┘ └─────────────────────────┘
emitToolExecutionEnd(finalized, emit)
createToolResultMessage(finalized) createToolResultMessage(finalized)
@@ -1119,14 +1033,14 @@ end
|-------|-------------|------| |-------|-------------|------|
| `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | | `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call |
| `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | | `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` |
| `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call | | `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call |
### Event Sink ### Event Sink
The `emit` function is passed through the entire call chain: The `agentEventSink` function is passed through the entire call chain:
```julia ```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: 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 | | Hook | Signature | Called | Purpose |
|------|-----------|--------|---------| |------|-----------|--------|---------|
| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt |
| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API |
| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort |
| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
@@ -1211,7 +1125,7 @@ end
**Source:** `utils.jl:111-125` **Source:** `utils.jl:111-125`
```julia ```julia
function prepareContext(state::agentState)::agentContext function prepareContext(state::agentState, agentEventSink)::agentContext
# TODO: filter tools from state.tools based on user intent # TODO: filter tools from state.tools based on user intent
filteredTools = state.tools filteredTools = state.tools
@@ -1238,7 +1152,7 @@ end
Default implementation converts `agentContext` to OpenAI-compatible format: Default implementation converts `agentContext` to OpenAI-compatible format:
```julia ```julia
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
messages = Vector{Dict{String, Any}}() messages = Vector{Dict{String, Any}}()
# System prompt as system message # System prompt as system message
@@ -1280,14 +1194,14 @@ The framework supports tools that modify the tool system itself at runtime.
1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal
2. Indents `executeCode` with 4 spaces 2. Indents `executeCode` with 4 spaces
3. Wraps it inside `function executeTool(...)::agentToolResult ... end` 3. Wraps it inside `function executeTool(...)::agentToolResult ... end`
4. Appends `getTool()` returning an `agentTool` struct 4. Appends `writeToolTool()` returning an `agentTool` struct
5. Writes the combined string to `src/tools/<name>.jl` 5. Writes the combined string to `src/tools/<name>.jl`
### `listTool` — Discover Available Tools ### `listTool` — Discover Available Tools
**Source:** `toolRegistry.jl:55-82` **Source:** `toolRegistry.jl:43-98`
Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`.
### Self-Tooling Workflow ### Self-Tooling Workflow
@@ -1301,9 +1215,11 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT
- executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - executeCode: "query = args[\"query\"]\nresult = search(query)\n..."
- (optional) validateCode, prepareCode - (optional) validateCode, prepareCode
3. writeTool generates src/tools/searchWine.jl 3. writeTool generates src/tools/searchWine.jl
4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file 4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
5. Agent calls searchWine(query="cabernet") 5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl
6. Result: "Found 5 cabernet wines..." 6. Developer restarts Julia — new tool is loaded
7. Agent calls searchWine(query="cabernet")
8. Result: "Found 5 cabernet wines..."
``` ```
### `writeTool` Input Schema ### `writeTool` Input Schema
@@ -1333,9 +1249,9 @@ USER SENDS MESSAGE
LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
└─> _agentLoop: detects msg in inputChannel └─> _agentLoop: detects msg in inputChannel
└─> Threads.@spawn _process_message(agent) └─> @spawn _processMessage(agent)
── _process_message ────────────────────────────────────────────── ── _processMessage ──────────────────────────────────────────────
│ Step 1: Drain inputChannel │ Step 1: Drain inputChannel
│ raw_msg = Dict("role" => "user", "content" => [...]) │ raw_msg = Dict("role" => "user", "content" => [...])
@@ -1367,36 +1283,34 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
│ Step 6: Execute tool calls │ Step 6: Execute tool calls
│ context = agentContext(systemPrompt, messages, tools) │ context = agentContext(systemPrompt, messages, tools)
│ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential") │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
│ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit) │ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)
LOOP ITERATION 1 — executeToolCallsSequential
── executeToolCallsSequential ────────────────────────────── For tc = agentToolCall("call_1", "getWeather", ...):
│ For tc = agentToolCall("call_1", "getWeather", ...): PREPARE:
tool = context.tools["getWeather"] → found!
│ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"})) validatedArgs = validateToolArguments(tool, tc)
→ validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes
│ PREPARE: beforeToolCall_hook(...) → nothing (skipped)
│ tool = context.tools["getWeather"] → found! → preparedToolCall(tool, tc, {"city" => "Tokyo"})
│ validatedArgs = validateToolArguments(tool, tc)
│ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes EXECUTE:
│ beforeToolCall_hook(...) → nothing (skipped) result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink)
│ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
→ executedOutcome(result, false)
│ EXECUTE:
│ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult) FINALIZE:
│ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) afterToolCall_hook(...) → nothing (skipped)
│ → executedOutcome(result, false) → finalizedOutcome(tc, result, false)
│ FINALIZE: msg = createToolResultMessage(finalized)
│ afterToolCall_hook(...) → nothing (skipped) → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
│ → finalizedOutcome(tc, result, false)
│ │ └─> agentToolCallBatch([msg], false)
│ │ emit(toolExecEndEvent("call_1", "getWeather", result, false))
│ │ msg = createToolResultMessage(finalized)
│ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
│ │
│ └─> agentToolCallBatch([msg], false)
│ Save results: │ Save results:
│ for tool_result in batch.messages │ for tool_result in batch.messages
@@ -1408,7 +1322,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
── _process_message (second iteration) ─────────────────────────── ── _processMessage (second iteration) ───────────────────────────
│ Step 1: Drain inputChannel → empty │ Step 1: Drain inputChannel → empty
@@ -1437,12 +1351,12 @@ AGENT LOOP: SEND RESPONSE TO USER
## 17. Tool File Contract ## 17. Tool File Contract
Each `.jl` file in `src/tools/` must conform to the following contract: Each `.jl` file in `src/tools/` follows a flat, static structure:
### Required Function ### Required Function
```julia ```julia
function getTool()::agentTool function <name>Tool()::agentTool
# Must return an agentTool instance # Must return an agentTool instance
end end
``` ```
@@ -1451,19 +1365,19 @@ end
```julia ```julia
# Argument preparation (before validation) # 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 modified args, or args unchanged
return args return args
end end
# Custom validation (before execution) # Custom validation (before execution)
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} function <name>ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
# Return nothing to pass, or error string to fail # Return nothing to pass, or error string to fail
return nothing return nothing
end end
# Core execution # Core execution
function executeTool(toolCallId::String, function <name>Execute(toolCallId::String,
args::Dict{String,Any}, args::Dict{String,Any},
signal::Union{Nothing,abortSignal}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult onPartialResult::Function)::agentToolResult
@@ -1477,7 +1391,8 @@ end
```julia ```julia
# src/tools/myTool.jl # src/tools/myTool.jl
using Dates # ← tool declares its own dependencies (registry injects only `using ..type`) using .type # ← provides agentTool, textContent, agentToolResult, etc.
using Dates # ← tool's own dependencies
# Optional: helper functions # Optional: helper functions
function helper_function(...) function helper_function(...)
@@ -1485,24 +1400,24 @@ function helper_function(...)
end end
# Optional: prepareArguments # Optional: prepareArguments
function prepareArguments(args::Dict{String,Any})::Dict{String,Any} function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any}
return args return args
end end
# Optional: validateRequiredArgs # Optional: validateRequiredArgs
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
return nothing return nothing
end end
# Required: executeTool # Required: execute function
function executeTool(toolCallId::String, args::Dict{String,Any}, function myToolExecute(toolCallId::String, args::Dict{String,Any},
signal::Union{Nothing,abortSignal}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult onPartialResult::Function)::agentToolResult
... ...
end end
# Required: getTool # Required: getTool function
function getTool()::agentTool function myToolTool()::agentTool
return agentTool( return agentTool(
name = "myTool", name = "myTool",
label = "My Tool", label = "My Tool",
@@ -1512,9 +1427,9 @@ function getTool()::agentTool
"properties" => Dict(...), "properties" => Dict(...),
"required" => [...] "required" => [...]
), ),
execute = executeTool, execute = myToolExecute,
prepareArguments = prepareArguments, prepareArguments = myToolPrepareArguments,
validateRequiredArgs = validateRequiredArgs, validateRequiredArgs = myToolValidateRequiredArgs,
parallelToolExecute = false parallelToolExecute = false
) )
end end
@@ -1522,51 +1437,110 @@ end
### Dependencies ### Dependencies
Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements. Each tool file declares its own dependencies via `using` statements:
```julia ```julia
# src/tools/getTime.jl # src/tools/getTime.jl
using .type
using Dates using Dates
function executeTool(...) function getTimeExecute(...)
now() # Dates.now requires `using Dates` now() # Dates.now requires `using Dates`
end end
``` ```
```julia ```julia
# src/tools/myApiTool.jl # src/tools/myApiTool.jl
using .type
using HTTP, JSON using HTTP, JSON
function executeTool(...) function myApiToolExecute(...)
response = HTTP.get("https://api.example.com") response = HTTP.get("https://api.example.com")
data = JSON.parse(String(response.body)) data = JSON.parse(String(response.body))
... ...
end end
``` ```
### Module Isolation ### Why Flat Modules?
When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available: All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means:
- All functions live in the `YiemAgent` module, avoiding world-age issues
```julia - `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available
# User writes in src/tools/myTool.jl: - Functions are named with a `<toolName>` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`)
using Dates, HTTP, JSON # ← tool's own dependencies - The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration
function getTool()::agentTool ... end
# loadTools() creates:
module _tool_myTool
using ..type # ← injected by registry (core types only)
using Dates, HTTP, JSON # ← from tool file
# (user's code here)
end
```
All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures.
--- ---
## 18. Appendix: Type Reference ## 18. Adding New Tools
To add a new tool (e.g., `searchWine.jl`):
### Step 1: Create `src/tools/searchWine.jl`
```julia
using .type
# using AdditionalPkg # add if needed
function searchWineExecute(toolCallId::String, args::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 ### Message Types
@@ -1604,7 +1578,7 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli
|------|--------|-------------| |------|--------|-------------|
| `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | | `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) |
| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) |
| `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | | `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) |
| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) |
| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | | `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) |
+13 -2
View File
@@ -1,7 +1,6 @@
module YiemAgent module YiemAgent
# export agent export register_all_tools
""" Order by dependencies of each file. The 1st included file must not depend on any other """ Order by dependencies of each file. The 1st included file must not depend on any other
files and each file can only depend on the file included before it. files and each file can only depend on the file included before it.
@@ -13,9 +12,21 @@ module YiemAgent
include("utils.jl") include("utils.jl")
using .utils using .utils
include("tools/getWeather.jl")
include("tools/getTime.jl")
include("tools/writeTool.jl")
include("toolRegistry.jl") include("toolRegistry.jl")
using .toolRegistry 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") # include("llmfunction.jl")
# using .llmfunction # using .llmfunction
+109 -66
View File
@@ -1,16 +1,23 @@
module agentCore module agentCore
export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls,
executePreparedToolCall, prepareToolCall, executeToolCallsSequential,
executeToolCallsParallel, executeToolCalls
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames, Base.Threads, NATS DataFrames, Base.Threads, NATS
using GeneralUtils using GeneralUtils
using ..type, ..utils, ..toolRegistry using ..type, ..utils, ..toolRegistry
function register_all_tools(store::toolRegistry.toolStore)
# Call parent module's version which has access to tool functions
parentmodule(@__MODULE__).register_all_tools(store)
end
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
""" """
docstring docstring
""" """
mutable struct yiemAgent <: agent # High-level agent wrapper mutable struct yiemAgent <: agent # High-level agent wrapper
_state::agentState # Current state (prompt, model, messages, tools, etc.) _state::agentState # Current state (prompt, model, messages, tools, etc.)
@@ -84,7 +91,6 @@ on `inputChannel` and `followUpChannel` channels concurrently.
- A new `yiemAgent` instance with an active background task - A new `yiemAgent` instance with an active background task
""" """
function yiemAgent( function yiemAgent(
toolsFolderPath::String,
llmCall, llmCall,
; ;
systemPrompt::String="You are helpful assistant.", systemPrompt::String="You are helpful assistant.",
@@ -106,9 +112,9 @@ function yiemAgent(
followUp = Channel(32) followUp = Channel(32)
outputChannel = Channel(16) outputChannel = Channel(16)
# load tools from toolsFolderPath # load tools (statically registered at module init)
toolStore1 = toolStore(name="myagent") toolStore1 = toolStore(name="myagent")
loadTools(toolStore1, toolsFolderPath) register_all_tools(toolStore1)
# Create struct with a placeholder task, then spawn and replace it # Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent( agent = yiemAgent(
@@ -291,6 +297,8 @@ function _agentLoop(agent::yiemAgent)
processingTask = nothing # reset processingTask = nothing # reset
end end
agent.agentEventSink("_agentLoop 6") agent.agentEventSink("_agentLoop 6")
agent.agentEventSink(string(typeof(processingTask)))
agent.agentEventSink("_agentLoop 7")
end end
catch e catch e
# On any error, send error response and exit the loop # On any error, send error response and exit the loop
@@ -329,7 +337,7 @@ julia> # Currently returns a placeholder echo response
function _processMessage( function _processMessage(
inputChannel::Channel, inputChannel::Channel,
agentEventSink, agentEventSink,
messages::Vector{agentMessage}, agentMsgHistory::Vector{agentMessage},
systemPrompt::String, systemPrompt::String,
tools::OrderedDict{String, agentTool}, tools::OrderedDict{String, agentTool},
prepareContext::Function, prepareContext::Function,
@@ -370,12 +378,12 @@ function _processMessage(
end end
agentEventSink("_processMessage 5") agentEventSink("_processMessage 5")
user_msg = OpenAiToUserMessage(raw_msg) user_msg = OpenAiToUserMessage(raw_msg)
push!(messages, user_msg) push!(agentMsgHistory, user_msg)
agentEventSink("_processMessage 6") agentEventSink("_processMessage 6")
end end
agentEventSink("_processMessage 7") agentEventSink("_processMessage 7")
# call prepareContext() # call prepareContext()
state = agentState(systemPrompt, nothing, tools, messages) state = agentState(systemPrompt, nothing, tools, agentMsgHistory)
agentEventSink("_processMessage 8") agentEventSink("_processMessage 8")
preparedContext = prepareContext(state, agentEventSink) preparedContext = prepareContext(state, agentEventSink)
agentEventSink("_processMessage 8") agentEventSink("_processMessage 8")
@@ -396,7 +404,7 @@ function _processMessage(
agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList")
if hasToolCalls && length(toolCallList) > 0 if hasToolCalls && length(toolCallList) > 0
#WORKING Build context and config for executeToolCalls # Build context and config for executeToolCalls
config = agentLoopConfig( config = agentLoopConfig(
beforeToolCall, beforeToolCall,
@@ -408,23 +416,21 @@ function _processMessage(
agentEventSink("_processMessage 12") agentEventSink("_processMessage 12")
# call executeToolCalls() # call executeToolCalls()
toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config,
batch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, signal, agentEventSink)
agentEventSink)
agentEventSink("_processMessage 13") agentEventSink("_processMessage 13")
error("debug marker")
# save toolResults to messages
for tool_result in batch.messages
push!(messages, tool_result)
end
if batch.terminate # save toolResults to messages
# If batch requested termination, build a final response for toolResult in toolResultBatch.messages
push!(agentMsgHistory, toolResult)
end
agentEventSink("_processMessage 14")
if toolResultBatch.terminate
agentEventSink("_processMessage 15")
# If toolResultBatch requested termination, build a final response
final_content = [textContent("Tool execution completed.")] final_content = [textContent("Tool execution completed.")]
for tool_result in batch.messages for toolResult in toolResultBatch.messages
for content_block in tool_result.content for content_block in toolResult.content
if content_block isa textContent if content_block isa textContent
append!(final_content, [content_block]) append!(final_content, [content_block])
elseif content_block isa Dict elseif content_block isa Dict
@@ -434,6 +440,7 @@ function _processMessage(
end end
end end
end end
agentEventSink("_processMessage 16")
final_response = assistantMessage( final_response = assistantMessage(
role="assistant", role="assistant",
content=final_content, content=final_content,
@@ -441,7 +448,7 @@ function _processMessage(
model=assistant_msg.model, model=assistant_msg.model,
usage=assistant_msg.usage, usage=assistant_msg.usage,
stopReason="tool_use_terminated", stopReason="tool_use_terminated",
errorMessage=if any(x -> x.isError, batch.messages) errorMessage=if any(x -> x.isError, toolResultBatch.messages)
"One or more tool calls failed" "One or more tool calls failed"
else else
nothing nothing
@@ -451,12 +458,13 @@ function _processMessage(
break break
end end
else else
agentEventSink("_processMessage 17")
# LLM did not use tool calls — this is the final response # LLM did not use tool calls — this is the final response
final_response = assistant_msg final_response = assistant_msg
break break
end end
end end
agentEventSink("_processMessage 18")
return final_response return final_response
end end
@@ -524,7 +532,7 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
return toolResultMessage( return toolResultMessage(
"toolResult", f.toolCall.id, f.toolCall.name, "toolResult", f.toolCall.id, f.toolCall.name,
f.result.content, f.result.details, f.result.usage, f.result.content, f.result.details, f.result.usage,
get(f.result, :addedToolNames, string[]), f.isError, nowMillis() nothing, f.isError, now()
) )
end end
@@ -563,8 +571,10 @@ function _extractToolCalls(response)
toolCallList = agentToolCall[] toolCallList = agentToolCall[]
# Helper: parse args (JSON string -> Dict, or pass through) # Helper: parse args (JSON string -> Dict, or pass through)
parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) : parse_args(raw) = raw isa AbstractDict && !(raw isa Dict{String,Any}) ?
raw isa String ? JSON.parse(raw) : Dict{String,Any}() Dict{String,Any}(raw) :
raw isa String ? JSON.parse(raw) :
raw isa Dict{String,Any} ? raw : Dict{String,Any}()
# Helper: build agentToolCall (positional) # Helper: build agentToolCall (positional)
make_tc(tc_data, default_id=string(uuid4())) = begin make_tc(tc_data, default_id=string(uuid4())) = begin
@@ -713,12 +723,27 @@ end
""" """
shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool
The `terminate` flag is set by tool implementations, not by the agent
or the LLM. It signals that the tool itself has completed the user's
request or encountered a fatal condition, so the agent should stop
processing further turns without calling the LLM again.
Common scenarios where a tool sets `terminate: true`:
- **Task completion**: one-shot tools like `deploy`, `submit`, or
`send_payment` finish their work and report directly to the user
instead of asking the LLM "what next?"
- **Unrecoverable error**: a tool hits a fatal condition (database
connection lost, auth token expired) and stops the agent from
retrying endlessly.
- **Async handoff**: a tool triggers a long-running external operation
and wants the agent to stop now; the external system will resume
the agent later via `continue()`.
Returns `true` only when every finalized call in the batch has Returns `true` only when every finalized call in the batch has
`result.terminate == true`. All tools must agree — if any tool `result.terminate == true`. All tools must agree — if any tool
did not request termination, the agent continues. This prevents did not request termination, the agent continues. This prevents
a single tool that happens to set `terminate: true` (e.g. for a single tool that happens to set `terminate: true` from accidentally
metadata purposes) from accidentally stopping the agent when stopping the agent when other tools in the batch did not intend to terminate.
other tools in the batch did not intend to terminate.
# Arguments # Arguments
- `finalizedCalls`: Vector of finalized tool call outcomes - `finalizedCalls`: Vector of finalized tool call outcomes
@@ -849,7 +874,7 @@ function prepareToolCall(
agentEventSink agentEventSink
)::Union{preparedToolCall,immediateOutcome} )::Union{preparedToolCall,immediateOutcome}
agentEventSink("prepareToolCall 1") agentEventSink("prepareToolCall 1")
tool = get(context.tools, toolCall.name, nothing) tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store
if tool === nothing if tool === nothing
agentEventSink("prepareToolCall 2") agentEventSink("prepareToolCall 2")
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
@@ -859,8 +884,10 @@ function prepareToolCall(
agentEventSink("prepareToolCall 3") agentEventSink("prepareToolCall 3")
# 1. prepare arguments (tool-specific transform) # 1. prepare arguments (tool-specific transform)
prepared = prepareToolCallArguments(tool, toolCall) prepared = prepareToolCallArguments(tool, toolCall)
agentEventSink(string(prepared.arguments))
agentEventSink("prepareToolCall 4") agentEventSink("prepareToolCall 4")
validatedArgs = validateToolArguments(tool, prepared) validatedArgs = validateToolArguments(tool, prepared)
agentEventSink(string(validatedArgs))
agentEventSink("prepareToolCall 5") agentEventSink("prepareToolCall 5")
# 2. beforeToolCall hook — can block # 2. beforeToolCall hook — can block
if config.beforeToolCall !== nothing if config.beforeToolCall !== nothing
@@ -886,12 +913,12 @@ function prepareToolCall(
return preparedToolCall(tool, toolCall, validatedArgs) return preparedToolCall(tool, toolCall, validatedArgs)
catch e catch e
bt = catch_backtrace() bt = catch_backtrace()
err_msg = sprint() do io errMsg = sprint() do io
showerror(io, e, bt) showerror(io, e, bt)
println(io) println(io)
end end
agentEventSink(err_msg) agentEventSink(errMsg)
return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true)
end end
@@ -937,36 +964,40 @@ executePreparedToolCall(prep, nothing, emit)
# => executedOutcome(createErrorToolResult("Connection timeout"), true) # => executedOutcome(createErrorToolResult("Connection timeout"), true)
``` ```
""" """
function executePreparedToolCall( function executePreparedToolCall(
prep::preparedToolCall, prep::preparedToolCall,
signal::Union{Nothing,abortSignal}, signal::Union{Nothing,abortSignal},
agentEventSink, agentEventSink,
)::executedOutcome )::executedOutcome
agentEventSink("executePreparedToolCall 1") agentEventSink("executePreparedToolCall 1")
updateEvents = promise[] agentEventSink(prep.toolCall.id)
accepting = true agentEventSink(prep.toolCall.name)
agentEventSink("executePreparedToolCall 2")
s = string(prep.args)
agentEventSink(s)
agentEventSink("executePreparedToolCall 3")
t = string(fieldnames(typeof(prep.tool)))
agentEventSink("executePreparedToolCall 3-1 " * t)
try try
result = prep.tool.execute( result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink)
prep.toolCall.id, prep.args, signal, agentEventSink(result.content[1].text)
partialResult -> begin agentEventSink("executePreparedToolCall 4")
if accepting
push!(updateEvents,
agentEventSink(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name,
prep.toolCall.arguments, partialResult)))
end
end
)
accepting = false
wait.(updateEvents)
return executedOutcome(result, false) return executedOutcome(result, false)
catch err catch e
accepting = false bt = catch_backtrace()
wait.(updateEvents) errMsg = sprint() do io
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) showerror(io, e, bt)
println(io)
end
agentEventSink(errMsg)
return executedOutcome(createErrorToolResult(sprint(showerror, e)), true)
end end
end end
# ── per-call finalization ─────────────────────────────────────── # ── per-call finalization ───────────────────────────────────────
""" """
@@ -1030,16 +1061,19 @@ function finalizeExecutedToolCall(
executed::executedOutcome, executed::executedOutcome,
config::agentLoopConfig, config::agentLoopConfig,
signal::Union{Nothing,abortSignal}, signal::Union{Nothing,abortSignal},
agentEventSink
)::finalizedOutcome )::finalizedOutcome
agentEventSink("finalizeExecutedToolCall 1")
result = executed.result result = executed.result
isError = executed.isError isError = executed.isError
agentEventSink("finalizeExecutedToolCall 2")
if config.afterToolCall !== nothing if config.afterToolCall !== nothing
try try
after = config.afterToolCall( after = config.afterToolCall(
afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context),
signal
) )
agentEventSink("finalizeExecutedToolCall 3")
if after !== nothing if after !== nothing
result = merge(result, dict(:content=>get(after,:content,result.content), result = merge(result, dict(:content=>get(after,:content,result.content),
:details=>get(after,:details,result.details), :details=>get(after,:details,result.details),
@@ -1047,12 +1081,19 @@ function finalizeExecutedToolCall(
:terminate=>get(after,:terminate,result.terminate))) :terminate=>get(after,:terminate,result.terminate)))
isError = get(after, :isError, isError) isError = get(after, :isError, isError)
end end
catch err catch e
result = createErrorToolResult(sprint(showerror, err)) bt = catch_backtrace()
errMsg = sprint() do io
showerror(io, e, bt)
println(io)
end
agentEventSink(errMsg)
result = createErrorToolResult(sprint(showerror, e))
isError = true isError = true
end end
end end
agentEventSink("finalizeExecutedToolCall 4")
return finalizedOutcome(prep.toolCall, result, isError) return finalizedOutcome(prep.toolCall, result, isError)
end end
@@ -1125,17 +1166,19 @@ function executeToolCallsSequential(
for tc in toolCalls for tc in toolCalls
agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)")
prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)
agentEventSink("executeToolCallsSequential 2") agentEventSink("executeToolCallsSequential " * string(prep.args))
if prep isa immediateOutcome if prep isa immediateOutcome
agentEventSink("executeToolCallsSequential 2-1") agentEventSink("executeToolCallsSequential 2-1")
finalized = finalizedOutcome(tc, prep.result, prep.isError) finalized = finalizedOutcome(tc, prep.result, prep.isError)
agentEventSink("executeToolCallsSequential 2-2") agentEventSink("executeToolCallsSequential 2-2")
else else
agentEventSink("executeToolCallsSequential 3") agentEventSink("executeToolCallsSequential 3")
#XXX
executed = executePreparedToolCall(prep, signal, agentEventSink) executed = executePreparedToolCall(prep, signal, agentEventSink)
agentEventSink("executeToolCallsSequential 3-1") agentEventSink("executeToolCallsSequential 3-1")
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config,
signal) signal, agentEventSink)
agentEventSink("executeToolCallsSequential 3-2") agentEventSink("executeToolCallsSequential 3-2")
end end
agentEventSink("executeToolCallsSequential 4") agentEventSink("executeToolCallsSequential 4")
@@ -1218,12 +1261,12 @@ function executeToolCallsParallel(
agentEventSink, agentEventSink,
)::agentToolCallBatch )::agentToolCallBatch
entries = union{finalizedOutcome,task{finalizedOutcome}}[] entries = Union{finalizedOutcome,Task}[]
for tc in toolCalls for tc in toolCalls
agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) agentEventSink(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 if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError) finalized = finalizedOutcome(tc, prep.result, prep.isError)
@@ -1231,15 +1274,15 @@ function executeToolCallsParallel(
finalized.result, finalized.isError)) finalized.result, finalized.isError))
push!(entries, finalized) push!(entries, finalized)
else else
task = task() do t = Task() do
executed = executePreparedToolCall(prep, signal, agentEventSink) executed = executePreparedToolCall(prep, signal, agentEventSink)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError)) finalized.result, finalized.isError))
return finalized return finalized
end end
schedule(task) schedule(t)
push!(entries, task) push!(entries, t)
end end
if signal !== nothing && signal.aborted if signal !== nothing && signal.aborted
@@ -1249,7 +1292,7 @@ function executeToolCallsParallel(
finalizedCalls = finalizedOutcome[] finalizedCalls = finalizedOutcome[]
for entry in entries for entry in entries
outcome = entry isa task ? fetch(entry) : entry outcome = entry isa Task ? fetch(entry) : entry
push!(finalizedCalls, outcome) push!(finalizedCalls, outcome)
end end
+4 -96
View File
@@ -1,6 +1,6 @@
module toolRegistry module toolRegistry
export toolStore, loadTools, registerTool, getTools, clearTools, listTool export toolStore, registerTool, getTools, clearTools, listTool
using Dates using Dates
using JSON, DataStructures using JSON, DataStructures
@@ -45,7 +45,7 @@ end
Return an `agentTool` definition for listing registered tools. Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over) 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. at runtime.
# Arguments # Arguments
@@ -55,7 +55,7 @@ at runtime.
```julia ```julia
julia> store = toolStore(name="agent1"); 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] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Registered tool: listTools [toolRegistry:agent1] Registered tool: listTools
@@ -97,99 +97,7 @@ function listTool(store::toolStore)::agentTool
) )
end end
""" # Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope
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
""" """
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
+7 -6
View File
@@ -1,3 +1,4 @@
using .type
using Dates using Dates
""" """
@@ -15,7 +16,7 @@ Demonstrates custom validation beyond simple required-field checking:
- `nothing` if validation passes - `nothing` if validation passes
- `String` error message if validation fails - `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) tz = get(args, "timezone", nothing)
city = get(args, "city", "") city = get(args, "city", "")
@@ -43,8 +44,8 @@ Execute the getTime tool.
Returns mock time data for the given timezone or city. Returns mock time data for the given timezone or city.
""" """
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult onPartialResult)
tz = get(args, "timezone", nothing) tz = get(args, "timezone", nothing)
city = get(args, "city", "") city = get(args, "city", "")
if tz !== nothing if tz !== nothing
@@ -61,7 +62,7 @@ end
""" """
Define and return the getTime agentTool. Define and return the getTime agentTool.
""" """
function getTool()::agentTool function getTimeTool()::agentTool
return agentTool( return agentTool(
name = "getTime", name = "getTime",
label = "Time Lookup", label = "Time Lookup",
@@ -74,9 +75,9 @@ function getTool()::agentTool
), ),
"required" => [] "required" => []
), ),
execute = executeTool, execute = getTimeExecute,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = validateRequiredArgs, validateRequiredArgs = getTimeValidateRequiredArgs,
parallelToolExecute = false parallelToolExecute = false
) )
end end
+14 -5
View File
@@ -1,24 +1,33 @@
using msghandler
using .type
""" """
Execute the getWeather tool. Execute the getWeather tool.
Returns mock weather data for the given city and temperature units. Returns mock weather data for the given city and temperature units.
""" """
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult agentEventSink)
agentEventSink("Getting weather...")
city = get(args, "city", "") city = get(args, "city", "")
units = get(args, "units", "celsius") units = get(args, "units", "celsius")
temp = units == "fahrenheit" ? "72" : "22" temp = units == "fahrenheit" ? "72" : "22"
unit_symbol = units == "celsius" ? "°C" : "°F" unit_symbol = units == "celsius" ? "°C" : "°F"
return agentToolResult( return agentToolResult(
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
Dict{Any,Any}(), nothing, false Dict{Any,Any}(),
nothing,
false
) )
end end
""" """
Define and return the getWeather agentTool. Define and return the getWeather agentTool.
""" """
function getTool()::agentTool function getWeatherTool()::agentTool
return agentTool( return agentTool(
name = "getWeather", name = "getWeather",
label = "Weather Lookup", label = "Weather Lookup",
@@ -31,7 +40,7 @@ function getTool()::agentTool
), ),
"required" => ["city"] "required" => ["city"]
), ),
execute = executeTool, execute = getWeatherExecute,
prepareArguments = nothing, prepareArguments = nothing,
validateRequiredArgs = nothing, validateRequiredArgs = nothing,
parallelToolExecute = false parallelToolExecute = false
+12 -9
View File
@@ -1,3 +1,4 @@
using .type
using JSON 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 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`. 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 After calling this tool, add the new file to `YiemAgent.jl` with an `include()`
up the new file. The new tool is immediately available. statement (after `include("toolRegistry.jl")`), then restart the agent.
The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`.
# Example # Example
1. Agent calls writeTool with a spec for a "searchWine" tool 1. Agent calls writeTool with a spec for a "searchWine" tool
2. writeTool generates src/tools/searchWine.jl 2. writeTool generates src/tools/searchWine.jl
3. Restart agent — loadTools() picks up the new file 3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
4. Agent calls searchWine with args 4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools()
5. Restart agent — new tool is available
# How It Works # 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 - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string
- Indents `executeCode` with 4 spaces - Indents `executeCode` with 4 spaces
- Wraps it inside `function executeTool(...)::agentToolResult ... end` - 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` - Writes the combined string to `src/tools/<name>.jl`
# Important Notes # Important Notes
@@ -105,7 +108,7 @@ end
""" """
Define and return the writeTool agentTool. Define and return the writeTool agentTool.
""" """
function getTool()::agentTool function writeToolTool()::agentTool
return agentTool( return agentTool(
name = "writeTool", name = "writeTool",
label = "Create Tool", label = "Create Tool",
@@ -127,7 +130,7 @@ function getTool()::agentTool
), ),
"required" => ["name", "label", "description", "inputSchema", "executeCode"] "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_name = get(args, "name", "")::String
tool_label = get(args, "label", tool_name)::String tool_label = get(args, "label", tool_name)::String
tool_description = get(args, "description", "")::String tool_description = get(args, "description", "")::String
@@ -250,13 +253,13 @@ function getTool()::agentTool
tool_code = join(parts) 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) write(filepath, tool_code)
onPartialResult(Dict("status" => "Done")) onPartialResult(Dict("status" => "Done"))
return agentToolResult( 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}( Dict{Any,Any}(
"file" => filepath, "file" => filepath,
"name" => tool_name, "name" => tool_name,
+2 -2
View File
@@ -264,7 +264,7 @@ struct agentTool # A tool available to the agent
label::String # Human-readable tool name label::String # Human-readable tool name
description::String # What the tool does description::String # What the tool does
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
execute::Function # Tool execution function execute # Tool execution function
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
@@ -274,7 +274,7 @@ end
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
""" """
function agentTool(; name::String, label::String, description::String, inputSchema::Any, function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute::Function, prepareArguments::Union{Function, Nothing}=nothing, execute, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false) parallelToolExecute::Bool=false)
return agentTool(name, label, description, inputSchema, execute, return agentTool(name, label, description, inputSchema, execute,
+57 -3
View File
@@ -241,7 +241,38 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
return openaiReadyMsg return openaiReadyMsg
end 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 function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
)::beforeToolCallResult )::beforeToolCallResult
@@ -254,8 +285,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
return beforeToolCallResult(false, "N/A") return beforeToolCallResult(false, "N/A")
end end
#TODO """
function afterToolCall(context::beforeToolCallContext, signal::abortSignal afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
Callback invoked after a tool call finishes executing (before and after errors).
Use this hook to post-process the tool result before it is fed back to the LLM.
Common use cases:
- Mask sensitive data (API keys, tokens) from result content.
- Normalize usage tracking data into a consistent format.
- Inspect the result and set `terminate: true` based on business logic
(e.g. "if deployment failed, stop the agent rather than retrying").
- Wrap error results in friendlier messages for the LLM to understand.
# Arguments
- `context::afterToolCallContext`: Contains the assistant message, tool call,
arguments, raw result, error status, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `nothing` to pass the result through unchanged.
- `agentToolResult(...)` to return a modified result (content, details, usage,
terminate flag can all be overridden).
"""
function afterToolCall(context::afterToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing} )::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult # modify context.result if needed and return agentToolResult
+192 -37
View File
@@ -1,35 +1,19 @@
using Test using Test
using Dates
using YiemAgent using YiemAgent
using YiemAgent.toolRegistry using YiemAgent.toolRegistry
using YiemAgent.type using YiemAgent.type
using YiemAgent.agentCore
# Path to the real tools directory @testset "register_all_tools with toolStore" begin
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools with toolStore" begin
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory # # 1. register_all_tools registers all static tools + listTools #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
store = toolStore(name="test1") store = toolStore(name="test1")
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") loaded = register_all_tools(store)
# ------------------------------------------------------------------ #
# 2. loadTools throws if a .jl file does not define getTool() #
# Must run BEFORE any other loadTools call (getTool binding #
# persists in module scope after include()). #
# ------------------------------------------------------------------ #
bad_dir = mktempdir()
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
@test_throws ArgumentError loadTools(store, bad_dir)
# ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ #
store2 = toolStore(name="test2")
loaded = loadTools(store2, TOOLS_DIR)
@test !isempty(loaded) @test !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)] names = [k for k in keys(loaded)]
@test "getTime" in names @test "getTime" in names
@@ -38,16 +22,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test "listTools" in names @test "listTools" in names
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename # # 2. register_all_tools returns tools in registration order #
# (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@test collect(keys(loaded))[1] == "getTime" @test collect(keys(loaded))[1] == "getWeather"
@test collect(keys(loaded))[2] == "getWeather" @test collect(keys(loaded))[2] == "getTime"
@test collect(keys(loaded))[3] == "writeTool" @test collect(keys(loaded))[3] == "writeTool"
@test collect(keys(loaded))[4] == "listTools" @test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Verify loaded tool fields are correct # # 3. Verify loaded tool fields are correct #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# getTime # getTime
time_tool = loaded["getTime"] time_tool = loaded["getTime"]
@@ -74,7 +57,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test "executeCode" in wt.inputSchema["required"] @test "executeCode" in wt.inputSchema["required"]
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 6. Tool execution returns valid results # # 4. Tool execution returns valid results #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
sig = nothing sig = nothing
op = x -> x # no-op partial result callback op = x -> x # no-op partial result callback
@@ -98,16 +81,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# execute getWeather with units # execute getWeather with units
result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) 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") store3 = toolStore(name="test3")
registry_tools = getTools(store3) registry_tools = getTools(store3)
@test isempty(registry_tools) @test isempty(registry_tools)
# listTool is not auto-registered anymore — each store starts empty
# Register tools manually # Register tools manually
registerTool(store3, loaded["getTime"]) registerTool(store3, loaded["getTime"])
registerTool(store3, loaded["getWeather"]) registerTool(store3, loaded["getWeather"])
@@ -143,7 +125,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test reg["manualTool"].parallelToolExecute == true @test reg["manualTool"].parallelToolExecute == true
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 8. getTools returns direct reference (mutations affect registry) # # 6. getTools returns direct reference (mutations affect registry) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
copy1 = getTools(store3) copy1 = getTools(store3)
copy2 = getTools(store3) copy2 = getTools(store3)
@@ -152,7 +134,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test isempty(getTools(store3)) # mutation propagates @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") storeA = toolStore(name="isolationA")
storeB = toolStore(name="isolationB") storeB = toolStore(name="isolationB")
@@ -175,10 +157,10 @@ end
@testset "listTool" begin @testset "listTool" begin
store = toolStore(name="test_list") 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 # register_all_tools auto-registers listTool
@test "listTools" in keys(loaded) @test "listTools" in keys(store.tools)
# listTool returns an agentTool, not a string or array # listTool returns an agentTool, not a string or array
list_t = listTool(store) list_t = listTool(store)
@@ -199,7 +181,7 @@ end
# Each listTool call creates an independent closure # Each listTool call creates an independent closure
storeB = toolStore(name="test_listB") storeB = toolStore(name="test_listB")
registerTool(storeB, loaded["getWeather"]) registerTool(storeB, store.tools["getWeather"])
list_tB = listTool(storeB) list_tB = listTool(storeB)
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) 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", resultA.content[1].text)
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
end 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
View File
@@ -0,0 +1,113 @@
using Revise, JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64,
NATS, Base.Threads
using YiemAgent, GeneralUtils, msghandler
""" Debug
using JSON, NATS, msghandler
using NATS
conn = NATS.connect("nats.yiem.cc")
sub = NATS.subscribe(conn, "sommanion.debug") do msg
payload = NATS.payload(msg)
@info "debug" payload
open("./log/error.log", "a") do io
println(io, payload)
end
end
NATS.publish(conn, "sommanion.debug", "order-123")
# ---------------------------- inject this code into codebase to debug --------------------------- #
try
batch = someFunction(x, y, z)
catch e
bt = catch_backtrace()
err_msg = sprint() do io
showerror(io, e, bt)
println(io)
end
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)