Compare commits

..

25 Commits

Author SHA1 Message Date
ton 5829c82d05 update 2026-08-17 03:04:10 +07:00
ton c7a98f1710 Merge pull request 'V0.8.0 process message debug' (#44) from v0.8.0-process_message_debug into v0.8.0
Reviewed-on: #44
2026-08-16 13:22:51 +00:00
ton c7abf844ea update 2026-08-16 20:14:54 +07:00
ton 00447e4dde update 2026-08-16 18:25:26 +07:00
ton 25f8468696 text message process works 2026-08-16 17:24:29 +07:00
ton a29a82b74c update 2026-08-16 13:36:01 +07:00
ton fd616409dd update 2026-08-15 20:03:01 +07:00
ton 2543e6cbf1 static tool loading 2026-08-15 16:50:28 +07:00
ton b8067c2d33 update 2026-08-13 18:14:46 +07:00
ton 510cf6126c update 2026-08-13 05:56:08 +07:00
ton 90fb97a4e7 update 2026-08-12 23:47:10 +07:00
ton 77adeb3a6b update 2026-08-12 20:10:51 +07:00
ton 4e592173a6 update 2026-08-12 14:37:11 +07:00
ton 0cacb5c94a update 2026-08-12 04:35:34 +07:00
ton 6c96409969 update 2026-08-12 04:33:19 +07:00
ton 06d51c1ee9 update 2026-08-12 04:00:09 +07:00
ton 2ad3d1df38 update 2026-08-11 19:10:37 +07:00
ton 83c7770877 update 2026-08-11 18:57:53 +07:00
ton bad14fbe7f update 2026-08-11 18:42:34 +07:00
ton 578e8f55bd update 2026-08-11 18:28:03 +07:00
ton ae3e432b02 update 2026-08-11 17:35:56 +07:00
ton 7c14390400 update 2026-08-11 17:28:25 +07:00
ton 89885c1583 update 2026-08-11 16:43:48 +07:00
ton 5a27630ccf update 2026-08-11 12:15:05 +07:00
ton ed91260468 update 2026-08-10 20:37:28 +07:00
23 changed files with 2907 additions and 5157 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"
+2 -2
View File
@@ -6,7 +6,7 @@ Julia framework for building agents with tool use.
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
2. Create a `yiemAgent` with `loadTools("src/tools")` 2. Create a `yiemAgent` with `loadTools("src/tools")`
3. Call `run_agent(agent, "message")` then `take_response(agent)` 3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
## Architecture ## Architecture
@@ -16,7 +16,7 @@ src/
├── type.jl # Core types (messages, tools, agent state) ├── type.jl # Core types (messages, tools, agent state)
├── utils.jl # Message formatting, validation ├── utils.jl # Message formatting, validation
├── agentCore.jl # Agent loop, tool execution pipeline ├── agentCore.jl # Agent loop, tool execution pipeline
├── api.jl # Public API (run_agent, take_response, etc.) ├── api.jl # Public API (runAgent, takeResponse, etc.)
└── tools/ └── tools/
├── registry.jl # Tool registry (loadTools, registerTool, listTools) ├── registry.jl # Tool registry (loadTools, registerTool, listTools)
├── getWeather.jl # Weather lookup tool ├── getWeather.jl # Weather lookup tool
+245 -268
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,74 +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`. 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
``` ```
**Result extraction:** **Result extraction:**
```julia ```julia
all_tools = getTools(store) # OrderedDict{String, agentTool} all_tools = getTools(store) # OrderedDict{String, agentTool}
# Keys: "getTime", "getWeather", "writeTool" # 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:113-172` **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 # 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)
agent = yiemAgent(
tools = getTools(store),
llmCall = my_llm_call,
agentEventSink = my_event_sink,
_tool_store = store,
) )
``` ```
@@ -118,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`
--- ---
@@ -144,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 → run_agent(agent, Dict("role"=>"user", "content"=>...)) user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
→ _agent_loop detects message → @spawn _process_message(agent) → _agentLoop detects message → @spawn _processMessage(agent)
→ prepareContext → formatMsgForLLM → llmCall → 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)
@@ -166,10 +143,10 @@ user message → run_agent(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
@@ -180,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
@@ -202,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.
@@ -274,16 +251,16 @@ 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
The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set.
```julia ```julia
struct ToolStore struct toolStore
tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration
name::String # identifier for debugging/logs name::String # identifier for debugging/logs
end end
@@ -291,46 +268,36 @@ 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:113-172` **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` and returns an `OrderedDict{String, agentTool}`
### 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
```julia ```julia
# Create per-agent stores # Create per-agent stores
store1 = ToolStore(name="agent1") store1 = toolStore(name="agent1")
store2 = ToolStore(name="agent2") store2 = toolStore(name="agent2")
# Load tools into specific stores # 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)
@@ -350,14 +317,14 @@ clearTools(store1) # only clears store1
### Per-Agent Isolation ### Per-Agent Isolation
Each `ToolStore` is completely independent — tools registered in one store do not appear in another: Each `toolStore` is completely independent — tools registered in one store do not appear in another:
```julia ```julia
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
@@ -373,16 +340,15 @@ This ensures that `yiemAgent` instances with different `tool_store` references o
**Source:** `agentCore.jl:35-145` **Source:** `agentCore.jl:35-145`
The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed.
### Channel Architecture ### Channel Architecture
``` ```
yiemAgent struct contains: yiemAgent struct contains:
- inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
- outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
- _tool_store (ToolStore) ← per-agent isolated tool registry
``` ```
### Loop States ### Loop States
@@ -392,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 |
@@ -401,7 +367,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`):
### Loop Logic (simplified) ### Loop Logic (simplified)
```julia ```julia
function _agent_loop(agent::yiemAgent) function _agentLoop(agent::yiemAgent)
while true while true
# 1. Wait for message from inputChannel (blocking poll) # 1. Wait for message from inputChannel (blocking poll)
msg = fetch!(agent.inputChannel) # agentCore.jl:84 msg = fetch!(agent.inputChannel) # agentCore.jl:84
@@ -411,9 +377,9 @@ function _agent_loop(agent::yiemAgent)
drain both channels, break loop drain both channels, break loop
end end
# 3. If agent is idle, spawn _process_message # 3. If agent is idle, spawn _processMessage
if agent._state.activeRun == false if agent._state.activeRun == false
processingTask = Threads.@spawn _process_message(agent) processingTask = Threads.@spawn _processMessage(agent)
agent._state.activeRun = true agent._state.activeRun = true
end end
@@ -442,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
@@ -463,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
@@ -514,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
@@ -630,7 +592,7 @@ function prepareToolCall(
- On failure: throws `ArgumentError(error_string)`, caught by the try-catch below - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below
4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` 4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing`
- Passes `assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context)` and `signal` - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal`
- Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject)
- If `signal.aborted == true``immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `signal.aborted == true``immediateOutcome(createErrorToolResult("Operation aborted"), true)`
- If `before.block == true``immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` - If `before.block == true``immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)`
@@ -649,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`
@@ -719,10 +659,10 @@ function finalizeExecutedToolCall(
``` ```
2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`:
- Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal`
- Hook can mutate the result: - Hook can mutate the result:
```julia ```julia
after = config.afterToolCall(afterCtx(...)) after = config.afterToolCall(afterToolCallContext(...))
if after !== nothing if after !== nothing
result = merge(result, dict( result = merge(result, dict(
:content => get(after, :content, result.content), :content => get(after, :content, result.content),
@@ -743,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
@@ -771,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
``` ```
@@ -806,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)
@@ -839,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)
@@ -920,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
@@ -982,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
``` ```
@@ -1045,9 +965,6 @@ Tool call fails at any phase
└─────────────────────────┘ └─────────────────────────┘
emitToolExecutionEnd(finalized, emit)
createToolResultMessage(finalized) createToolResultMessage(finalized)
@@ -1116,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:
@@ -1140,11 +1057,11 @@ 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::assistantMsgCtx, 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` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring |
### `beforeToolCall` Hook ### `beforeToolCall` Hook
@@ -1154,7 +1071,7 @@ The `agentEventSink` function is a user-provided callback that receives all even
```julia ```julia
if config.beforeToolCall !== nothing if config.beforeToolCall !== nothing
before = config.beforeToolCall( before = config.beforeToolCall(
assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal
) )
if signal !== nothing && signal.aborted if signal !== nothing && signal.aborted
return immediateOutcome(createErrorToolResult("Operation aborted"), true) return immediateOutcome(createErrorToolResult("Operation aborted"), true)
@@ -1179,7 +1096,7 @@ end
if config.afterToolCall !== nothing if config.afterToolCall !== nothing
try try
after = config.afterToolCall( after = config.afterToolCall(
afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
) )
if after !== nothing if after !== nothing
result = merge(result, dict( result = merge(result, dict(
@@ -1208,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
@@ -1235,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
@@ -1277,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:54-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. Primarily 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
@@ -1298,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
@@ -1324,15 +1243,15 @@ Each `ToolStore` gets its own `listTool` instance bound to that store via `listT
``` ```
USER SENDS MESSAGE USER SENDS MESSAGE
└─> run_agent(agent, "What's the weather in Tokyo?") └─> runAgent(agent, "What's the weather in Tokyo?")
└─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))
LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
└─> _agent_loop: detects msg in inputChannel └─> _agentLoop: detects msg in inputChannel
└─> Threads.@spawn _process_message(agent) └─> @spawn _processMessage(agent)
── _process_message ────────────────────────────────────────────── ── _processMessage ──────────────────────────────────────────────
│ Step 1: Drain inputChannel │ Step 1: Drain inputChannel
│ raw_msg = Dict("role" => "user", "content" => [...]) │ raw_msg = Dict("role" => "user", "content" => [...])
@@ -1364,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
@@ -1405,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
@@ -1427,19 +1344,19 @@ LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
AGENT LOOP: SEND RESPONSE TO USER AGENT LOOP: SEND RESPONSE TO USER
└─> put!(agent.outputChannel, final_response) └─> put!(agent.outputChannel, final_response)
└─> take_response(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.")
``` ```
--- ---
## 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
``` ```
@@ -1448,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
@@ -1474,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(...)
@@ -1482,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",
@@ -1509,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
@@ -1519,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
@@ -1601,10 +1578,10 @@ 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`) |
| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
| `afterCtx` | `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) |
### Event Types ### Event Types
+5 -2
View File
@@ -1,2 +1,5 @@
# ── executeToolCalls() Julia pseudo code ────────────────────────── check my understand:
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit 1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and
it will be the latest message in agent._state.messages. then _agentLoop() can pick it as
the output to outputChannel
2) if LLM use tool calls but toolResultBatch.terminate is false, assistantMessageToolCall
+8
View File
@@ -0,0 +1,8 @@
check my understanding
1) if LLM didn't use tool calls, assistantMessage get pushed into agent._state.messages and it will be the latest message in agent._state.messages. then _agentLoop() can pick it as the output to outputChannel
2) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is false then _processMessage() loop continue
3) if LLM use tool calls, assistantMessageToolCall get pushed into agent._state.messages. then toolResult get pushed into agent._state.messages. if toolResultBatch.terminate is true then final_response message get pushed into agent._state.messages. _processMessage() loop exit. then _agentLoop() can pick it as the output to outputChannel
Is my understanding correct?
+15 -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,23 @@ module YiemAgent
include("utils.jl") include("utils.jl")
using .utils using .utils
include("tools/getWeather.jl")
include("tools/getTime.jl")
include("tools/searchWine.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, searchWineTool())
registerTool(store, writeToolTool())
registerTool(store, listTool(store))
return store.tools
end
# include("llmfunction.jl") # include("llmfunction.jl")
# using .llmfunction # using .llmfunction
+636 -233
View File
File diff suppressed because it is too large Load Diff
+15 -16
View File
@@ -5,12 +5,11 @@ export prompt
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames DataFrames
using GeneralUtils using GeneralUtils
using ..type, ..utils using ..type, ..utils, ..agentCore, ..toolRegistry
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
""" """
Send a message to the agent's input channel. Send a message to the agent's input channel.
@@ -25,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `take_response(agent)` to receive the agent's response after sending a message. - Use `takeResponse(agent)` to receive the agent's response after sending a message.
- Use `follow_up(agent, msg)` to send messages while the agent is still processing. - Use `followUp(agent, msg)` to send messages while the agent is still processing.
# Examples # Examples
```jldoctest ```jldoctest
julia> run_agent(agent, "Hello!") julia> runAgent(agent, "Hello!")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function run_agent(agent::yiemAgent, msg) function runAgent(agent::yiemAgent, msg)
put!(agent.inputChannel, msg) put!(agent.inputChannel, msg)
return agent return agent
end end
@@ -51,15 +50,15 @@ Blocks until the agent sends a response.
- An `assistantMessage` instance representing the agent's response - An `assistantMessage` instance representing the agent's response
# Notes # Notes
- Use `run_agent(agent, msg)` to send a message before calling this function. - Use `runAgent(agent, msg)` to send a message before calling this function.
# Examples # Examples
```jldoctest ```jldoctest
julia> response = take_response(agent) julia> response = takeResponse(agent)
assistantMessage(...) assistantMessage(...)
``` ```
""" """
function take_response(agent::yiemAgent) function takeResponse(agent::yiemAgent)
return take!(agent.outputChannel) return take!(agent.outputChannel)
end end
@@ -77,17 +76,17 @@ and before any tool call results are sent.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `run_agent(agent, msg)` for the primary message and `follow_up(agent, msg)` for additional - Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional
messages while the agent is processing. messages while the agent is processing.
- Follow-up messages are buffered in a separate channel (capacity 32 by default). - Follow-up messages are buffered in a separate channel (capacity 32 by default).
# Examples # Examples
```jldoctest ```jldoctest
julia> follow_up(agent, "Also consider red wines") julia> followUp(agent, "Also consider red wines")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function follow_up(agent::yiemAgent, msg) function followUp(agent::yiemAgent, msg)
put!(agent.followUpChannel, msg) put!(agent.followUpChannel, msg)
return agent return agent
end end
@@ -105,19 +104,19 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`).
- `nothing` - `nothing`
# Notes # Notes
- After calling `stop_agent`, the agent is no longer usable. A new agent must be created - After calling `stopAgent`, the agent is no longer usable. A new agent must be created
for further interaction. for further interaction.
- If the background task throws a `TaskFailedException`, it is rethrown. - If the background task throws a `TaskFailedException`, it is rethrown.
# Examples # Examples
```jldoctest ```jldoctest
julia> stop_agent(agent) julia> stopAgent(agent)
``` ```
""" """
function stop_agent(agent::yiemAgent) function stopAgent(agent::yiemAgent)
put!(agent.inputChannel, :shutdown) put!(agent.inputChannel, :shutdown)
try try
fetch(agent._agent_loop) fetch(agent._agentLoop)
catch e catch e
if e isa TaskFailedException if e isa TaskFailedException
rethrow(e) rethrow(e)
+66 -130
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
@@ -9,47 +9,65 @@ using ..type
""" """
Per-agent isolated tool storage. Per-agent isolated tool storage.
Each agent gets its own `ToolStore` so tool registration is independent — Each agent gets its own `toolStore` so tool registration is independent —
`registerTool(store, tool)` only affects that agent's tool set. `registerTool(store, tool)` only affects that agent's tool set.
# Fields # Fields
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration - `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
- `name::String` — identifier for debugging/logs - `name::String` — identifier for debugging/logs
""" """
struct ToolStore struct toolStore
tools::OrderedDict{String, agentTool} tools::OrderedDict{String, agentTool}
name::String name::String
end end
""" """
Create a new isolated tool store. toolStore(; name="default") -> toolStore
Create a new empty tool store.
# Keyword Arguments # Keyword Arguments
- `name::String`: Identifier for this store (default: "default") - `name::String`: Display name for logging (default: `"default"`)
# Examples # Example
```julia ```julia
store = ToolStore(name="agent1") julia> store = toolStore(name="agent1")
tools = loadTools(store, "src/tools") toolStore(OrderedDict{String, agentTool}(), "agent1")
registerTool(store, my_tool)
agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store)
``` ```
""" """
function ToolStore(; name::String="default")::ToolStore function toolStore(; name::String="default")::toolStore
ToolStore(OrderedDict{String, agentTool}(), name) toolStore(OrderedDict{String, agentTool}(), name)
end end
""" """
List tool definition — lets the agent query available tools for collision detection listTool(store::toolStore) -> agentTool
when creating new tools via writeTool.
Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over)
`store`. `register_all_tools` auto-registers one so the LLM can discover tools
at runtime.
# Arguments # Arguments
- `store::ToolStore`: The tool store to list from - `store`: The tool store whose tools will be listed when the tool runs
Each `ToolStore` gets its own `listTool` instance bound to that store, # Example
so each agent sees only its own tools. ```julia
julia> store = toolStore(name="agent1");
julia> register_all_tools(store) # auto-registers listTools
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Registered tool: listTools
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 4 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"writeTool" => agentTool(...)
"listTools" => agentTool(...)
```
""" """
function listTool(store::ToolStore)::agentTool function listTool(store::toolStore)::agentTool
return agentTool( return agentTool(
name = "listTools", name = "listTools",
label = "List Tools", label = "List Tools",
@@ -79,162 +97,80 @@ function listTool(store::ToolStore)::agentTool
) )
end end
# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope
""" """
Load all tool modules from a directory into a specific ToolStore. registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
Scans `dir` for `.jl` files. Each file must define a function named Add `tool` to `store`, overwriting any existing tool with the same name.
`getTool()::agentTool`. Files are sorted alphabetically so tool
registration order is deterministic.
Each `.jl` file is loaded into its own **submodule** so that all functions
defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`,
and any helper functions) are namespaced and never collide with other tools.
# Arguments # Arguments
- `store::ToolStore`: The tool store to register tools into - `store`: Tool store to modify
- `dir::String`: Directory path to scan for `.jl` tool files - `tool`: The `agentTool` to register
# Returns # Returns
- `OrderedDict{String, agentTool}`: All loaded tools keyed by name - The same `store.tools` dict (modified in place)
# Errors # Example
- Throws `ArgumentError` if a tool file does not define a `getTool` function
# Examples
```julia ```julia
julia> store = ToolStore(name="agent1") julia> store = toolStore(name="agent1");
julia> tools = loadTools(store, "src/tools")
OrderedDict{String, agentTool} with 3 entries: julia> registerTool(store, listTool(store))
"getWeather" => agentTool(...) [toolRegistry:agent1] Registered tool: listTools
"getTime" => agentTool(...) OrderedDict{String, agentTool} with 1 entry:
"listTools" => agentTool(...) "listTools" => agentTool(...)
``` ```
""" """
function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} function registerTool(store::toolStore, tool::agentTool)::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
return store.tools
end
"""
Register a single agentTool into a specific ToolStore.
# Arguments
- `store::ToolStore`: The tool store to register into
- `tool::agentTool`: The tool to register
# Returns
- `OrderedDict{String, agentTool}`: Updated tool dict for this store
# Examples
```julia
julia> store = ToolStore(name="agent1")
julia> registerTool(store, my_tool)
[toolRegistry:agent1] Registered tool: my_tool
```
"""
function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)") println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools return store.tools
end end
""" """
Get the registered tools from a specific ToolStore. Return the tools registered in `store`.
Returns the internal `OrderedDict` directly — O(1) lookup by name, The returned dict is the **same object** stored inside `store` — mutations
ordered iteration preserving registration order. to it (e.g. via `registerTool`) are visible through subsequent calls.
# Arguments # Arguments
- `store::ToolStore`: The tool store to query - `store`: Tool store to query
# Returns # Returns
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
# Examples # Example
```julia ```julia
julia> getTools(store) julia> tools = getTools(store)
OrderedDict{String, agentTool} with 3 entries: OrderedDict{String, agentTool} with 2 entries:
"listTools" => agentTool(...)
"getWeather" => agentTool(...) "getWeather" => agentTool(...)
"getTime" => agentTool(...) "getTime" => agentTool(...)
``` ```
""" """
function getTools(store::ToolStore)::OrderedDict{String, agentTool} function getTools(store::toolStore)::OrderedDict{String, agentTool}
return store.tools return store.tools
end end
""" """
Clear all registered tools from a specific ToolStore. Remove all tools from `store`.
# Arguments # Arguments
- `store::ToolStore`: The tool store to clear - `store`: Tool store to clear
# Returns # Returns
- `nothing` - `nothing`
# Examples # Example
```julia ```julia
julia> clearTools(store) julia> clearTools(store)
[toolRegistry:agent1] Registry cleared [toolRegistry:agent1] Registry cleared
nothing
julia> getTools(store)
OrderedDict{String, agentTool} with 0 entries
``` ```
""" """
function clearTools(store::ToolStore)::Nothing function clearTools(store::toolStore)::Nothing
empty!(store.tools) empty!(store.tools)
println("[$(store.name)] Registry cleared") println("[$(store.name)] Registry cleared")
return nothing return nothing
+7 -5
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,7 +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}, onPartialResult::Function)::agentToolResult function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult, llmCall=nothing)
tz = get(args, "timezone", nothing) tz = get(args, "timezone", nothing)
city = get(args, "city", "") city = get(args, "city", "")
if tz !== nothing if tz !== nothing
@@ -60,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",
@@ -73,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 -4
View File
@@ -1,23 +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}, onPartialResult::Function)::agentToolResult function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
agentEventSink, llmCall=nothing)
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",
@@ -30,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
+397
View File
@@ -0,0 +1,397 @@
using .type
using LibPQ, DataFrames, JSON, DataStructures
using Dates, Random, HTTP
using GeneralUtils
# ── Database config — update for your environment ───────────────────────
const DB_CONFIG = Dict{String,Any}(
"host" => "localhost",
"port" => 5432,
"dbname" => "winedb",
"user" => "postgres",
"password" => "",
)
"""
Execute the search_wine_database! tool.
Uses the agent's LLM to generate SQL from the free-form text query,
then executes it against the wine database and returns formatted results.
"""
function searchWineExecute(
toolCallId::String,
args::Dict{String,Any},
signal::Union{Nothing,abortSignal},
agentEventSink,
llmCall,
)::agentToolResult
#WORKING
search_query = get(args, "searchQuery", "")::String
if isempty(search_query)
return agentToolResult(
[textContent("Please provide a search query for the wine database.")],
Dict{Any,Any}(), nothing, false
)
end
agentEventSink("searchWineExecute: query=$search_query")
# ── SQL generation prompt ───────────────────────────────────────────
systemmsg = """
# database_search_guidelines
- Keep SQL queries focused only on the provided information.
- Use wildcard character (%) to search more effectively.
- Do not create any table in the database.
- Text information in the database is usually stored in lower case.
If your search returns empty, try using lower case to search.
- Overly strict conditions usually yield empty results.
- Use ILIKE for case-insensitive text matching.
- Only output the SQL query — do not wrap it in backticks or add comments.
# situation
You are a wine store database assistant. You will be given a user's
natural language search query and the database table schema.
# objective
Generate a single SQL query to find wines matching the user's request.
# your responsibility includes
Fulfill the objective.
# you should respond with ONLY the SQL query string, ending with ';'
"""
table_schema = """
CREATE TABLE wine (
wine_id uuid primary key default gen_random_uuid (),
wine_name varchar(128) not null,
winery varchar(128) not null,
vintage integer not null,
region varchar(128) not null,
country varchar(128) not null,
wine_type varchar(128) not null,
grape varchar(128) not null,
serving_temperature varchar(128) not null,
intensity integer,
sweetness integer,
tannin integer,
acidity integer,
fizziness integer,
tasting_notes text,
image_url jsonb,
manufacturer_sku text,
note text,
other_attributes jsonb,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp,
description text
);
CREATE TABLE retailer (
retailer_id uuid primary key default gen_random_uuid (),
retailer_name varchar(128) not null,
retailer_username varchar(128) not null,
retailer_password varchar(128) not null,
retailer_address text not null,
country varchar(128) not null,
contact_person varchar(128) not null,
telephone varchar(128) not null,
email varchar(128) not null,
note text,
other_attributes jsonb,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp,
description text
);
CREATE TABLE retailer_wine (
retailer_id uuid references retailer(retailer_id),
wine_id uuid references wine(wine_id),
constraint retailer_wine_id primary key (retailer_id, wine_id),
price NUMERIC(10, 2),
currency varchar(3) not null,
created_time timestamptz default current_timestamp,
updated_time timestamptz default current_timestamp
);
"""
context = "<internal_context_for_assistant>\n<database_table_schema>\n$table_schema\n</database_table_schema>\n</internal_context_for_assistant>\n\n"
input = context * "User query: $search_query\n\nGenerate the SQL query:"
# ── Call LLM for SQL generation ────────────────────────────────────
max_attempts = 5
generated_sql = nothing
for attempt in 1:max_attempts
msg = Dict(
"messages" => [
Dict(
"role" => "system",
"content" => [Dict("type" => "text", "text" => systemmsg)],
),
Dict(
"role" => "user",
"content" => [Dict("type" => "text", "text" => input)],
),
],
"temperature" => 0.7,
)
llm_response = llmCall(msg)
# Clean the response — extract SQL from potential markdown/code blocks
sql_text = _clean_sql_response(llm_response)
# Validate it looks like SQL
if _is_valid_sql(sql_text)
generated_sql = sql_text
agentEventSink("searchWine: generated SQL (attempt $attempt)\n$sql_text")
break
else
agentEventSink("searchWine: invalid SQL attempt $attempt: $sql_text")
end
end
if generated_sql === nothing
return agentToolResult(
[textContent("Failed to generate a valid SQL query for your search. Please try rephrasing.")],
Dict{Any,Any}("error" => "sql_generation_failed"), nothing, false
)
end
# ── Execute SQL ────────────────────────────────────────────────────
try
conn = LibPQ.Connection(DB_CONFIG)
# Ensure LIMIT to prevent large result sets
sanitized_sql = _ensure_limit(generated_sql)
agentEventSink("searchWine: executing\n$sanitized_sql")
result = LibPQ.execute(conn, sanitized_sql)
close(conn)
if !LibPQ.hasdata(result)
return agentToolResult(
[textContent("No wines found matching your search. Try loosening your criteria.")],
Dict{Any,Any}("count" => 0), nothing, false
)
end
df = DataFrame(result)
num_rows, num_cols = size(df)
if num_cols > 30
return agentToolResult(
[textContent("The result has more than 30 columns. Please be more specific in your search.")],
Dict{Any,Any}("error" => "too_many_columns"), nothing, false
)
end
# Randomly sample up to 2 rows for display if more than 2 results
display_df = df
if num_rows > 2
idx = sample(1:num_rows, min(2, num_rows), replace=false)
display_df = df[idx, :]
end
# Convert to vector of dicts
result_vec = GeneralUtils.dfToVectorDict(display_df)
# Fetch bottle images if available
for d in result_vec
image_url_json_str = get(d, "image_url", nothing)
if image_url_json_str !== nothing && !isempty(string(image_url_json_str))
try
image_url_json_obj = JSON.parse(string(image_url_json_str))
base_url = "http://192.168.88.106:8080/"
if haskey(image_url_json_obj, "bottle")
url = base_url * string(image_url_json_obj["bottle"])
image_data = HTTP.get(url)
image_base64_string = base64encode(image_data.body)
d["image"] = image_base64_string
end
catch
# Skip image fetch on error
end
end
end
# Format results as readable text
result_str = _format_wine_results(display_df)
return agentToolResult(
[textContent(result_str)],
Dict{Any,Any}(
"count" => num_rows,
"displayed" => size(display_df, 1),
),
nothing, false
)
catch e
errMsg = sprint(showerror, e)
return agentToolResult(
[textContent("Database error: $errMsg")],
Dict{Any,Any}("error" => errMsg), nothing, false
)
end
end
"""
Extract a SQL query string from the LLM response, handling potential
markdown code blocks, extra text, or JSON wrapping.
"""
function _clean_sql_response(response)::String
text = string(response)
# Try to extract from code block
if occursin("```", text)
extracted = GeneralUtils.extract_triple_backtick_text(text)
if !isempty(extracted)
text = extracted[1]
# Remove "sql\n" prefix if present
if startswith(text, "sql\n") || startswith(text, "SQL\n")
text = text[5:end]
end
end
end
# Remove JSON wrapping if present
text = strip(text)
if startswith(text, "{") && occursin("action_input", text)
# Parse as JSON and extract action_input
try
parsed = JSON.parse(text)
if parsed isa Dict
text = get(parsed, "action_input", text)
end
catch
# Keep original
end
end
# Extract SQL keywords to find the actual query
lines = split(strip(text), '\n')
sql_lines = String[]
for line in lines
stripped = strip(line)
if occursin(r"(?i)(SELECT|FROM|WHERE|JOIN|ORDER|LIMIT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)", stripped)
# Take everything from this line to the end
push!(sql_lines, line)
elseif !isempty(sql_lines)
# Continue collecting if we already found SQL
push!(sql_lines, line)
end
end
result = join(sql_lines, "\n")
# Ensure it ends with semicolon
result = strip(result)
if !endswith(result, ";")
result *= ";"
end
return result
end
"""
Check if a string looks like a valid SQL query.
"""
function _is_valid_sql(sql::String)::Bool
sql = strip(sql)
# Must start with a SQL keyword
has_sql_keyword = occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s", sql) ||
occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s*;", sql)
# Must end with semicolon
has_semicolon = endswith(sql, ";")
# Must not be too short (reject single words)
reasonable_length = length(sql) > 10
return has_sql_keyword && has_semicolon && reasonable_length
end
"""
Ensure the SQL query has a LIMIT clause to prevent loading excessive data.
"""
function _ensure_limit(sql::String)::String
sql = strip(sql)
if !occursin(r"(?i)LIMIT", sql)
# Remove existing semicolon, add LIMIT, re-add semicolon
if endswith(sql, ";")
sql = sql[1:end-1]
end
sql *= " ORDER BY RANDOM() LIMIT 2;"
end
return sql
end
"""
Format wine database results as human-readable text.
"""
function _format_wine_results(df::DataFrame)::String
lines = String[]
num_rows = size(df, 1)
for i in 1:num_rows
row = df[i, :]
push!(lines, "$(i). $(get(row, :wine_name, "Unknown")) $(get(row, :vintage, ""))")
winery = get(row, :winery, "Unknown")
region = get(row, :region, "Unknown")
country = get(row, :country, "Unknown")
push!(lines, " Winery: $winery")
push!(lines, " Region: $region, $country")
grape = get(row, :grape, "Unknown")
wtype = get(row, :wine_type, "Unknown")
push!(lines, " Grape: $grape")
push!(lines, " Type: $wtype")
sweetness = get(row, :sweetness, "N/A")
intensity = get(row, :intensity, "N/A")
tannin_val = get(row, :tannin, "N/A")
acidity = get(row, :acidity, "N/A")
push!(lines, " Profile: Sweetness: $sweetness, Intensity: $intensity, Tannin: $tannin_val, Acidity: $acidity")
tasting = get(row, :tasting_notes, nothing)
if tasting !== nothing && !isempty(string(tasting))
tn = string(tasting)
limit = min(200, length(tn))
push!(lines, " Notes: $(tn[1:limit])$(length(tn) > limit ? "..." : "")")
end
price = get(row, :price, "N/A")
currency = get(row, :currency, "")
retailer = get(row, :retailer_name, "N/A")
push!(lines, " Price: $price $currency at $retailer")
push!(lines, "")
end
return join(lines, "\n")
end
"""
Define and return the searchWine agentTool.
"""
function searchWineTool()::agentTool
return agentTool(
name = "searchWine",
label = "Search Wine Database",
description = "Search the wine database for wines matching a free-text query. Uses the LLM to generate SQL and execute it against the database. Returns wine details including name, winery, vintage, tasting notes, and price.",
inputSchema = Dict{String,Any}(
"type" => "object",
"properties" => Dict(
"searchQuery" => Dict(
"type" => "string",
"description" => "Free-text description of the wine you're looking for, e.g., 'a light-bodied red wine from France under 50 dollars'",
),
),
"required" => ["searchQuery"],
),
execute = searchWineExecute,
prepareArguments = nothing,
validateRequiredArgs = nothing,
parallelToolExecute = false,
)
end
+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, llmCall=nothing) -> 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,
+84 -168
View File
@@ -5,16 +5,16 @@
# Model types # Model types
modelCost, llmModel, llmUsage, modelCost, llmModel, llmUsage,
# Message content types # Message content types
textContent, imageContent, textContent, imageContent, reasoningContent,
# Message types # Message types
userMessage, assistantMessage, toolResultMessage, userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage,
# Tool types # Tool types
agentTool, validateRequiredArgs, agentTool, validateRequiredArgs,
# Context types # Context types
agentContext, agentState, agentToolCall, prepareNextTurnContext, agentContext, agentState, agentToolCall, prepareNextTurnContext,
# Loop & execution types # Loop & execution types
agentLoopConfig, abortSignal, agentToolResult, agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext,
assistantMsgCtx, afterCtx, beforeToolCallResult, afterToolCallContext,
# Event types # Event types
toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent,
# Agent # Agent
@@ -23,7 +23,7 @@
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
agentToolCallBatch, agentToolCallBatch,
# Functions (defined elsewhere) # Functions (defined elsewhere)
run_agent, take_response, follow_up, stop_agent runAgent, takeResponse, followUp, stopAgent
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
@@ -31,6 +31,13 @@ using GeneralUtils
const Timestamp = DateTime const Timestamp = DateTime
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# LLM model info # # LLM model info #
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
@@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content
mimeType::String # MIME type (e.g., "image/png") mimeType::String # MIME type (e.g., "image/png")
end end
struct reasoningContent <: messageContent # LLM reasoning/thinking content
text::String # The reasoning text
end
# ------------------------------------------------------------------------------------------------ # # ------------------------------------------------------------------------------------------------ #
# Message types # # Message types #
@@ -108,6 +119,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp=
return userMessage(role, content, timestamp) return userMessage(role, content, timestamp)
end end
struct assistantMessageToolCall <: agentMessage # Assistant message containing tool calls
role::String # Always "assistant"
toolCalls::Vector{agentToolCall} # Tool calls to execute
content::Vector{messageContent} # Reasoning/thinking content blocks
api::String # API name used (e.g., "openai")
provider::String # Provider name (e.g., "anthropic")
model::String # Model identifier
usage::llmUsage # Token usage for this message
stopReason::String # Why generation stopped (e.g., "tool_calls")
errorMessage::Union{String, Nothing} # Error if generation failed
timestamp::Timestamp # When the message was received
end
"""
Create a new assistant message containing tool calls.
# Arguments
- `role::String`: Always "assistant"
- `toolCalls::Vector{agentToolCall}`: Tool calls to execute
- `content::Vector{messageContent}`: Reasoning/thinking content blocks
- `api::String`: API name used
- `provider::String`: Provider name
- `model::String`: Model identifier
- `usage::llmUsage`: Token usage
- `stopReason::String`: Why generation stopped
- `errorMessage::Union{String, Nothing}`: Error if generation failed
- `timestamp::Timestamp`: When the message was received
# Returns
- A new `assistantMessageToolCall` instance
# Examples
```julia
julia> tc = agentToolCall("function", "call_1", "getWeather", Dict("city" => "Tokyo"))
julia> msg = assistantMessageToolCall(toolCalls=[tc], stopReason="tool_calls")
assistantMessageToolCall("assistant", [agentToolCall(...)], messageContent[], "", "", "", llmUsage(0, 0), "tool_calls", nothing, DateTime(...))
```
"""
function assistantMessageToolCall(; role="assistant", toolCalls=agentToolCall[],
content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0),
stopReason="tool_calls", errorMessage=nothing, timestamp=now())
model_str = model isa AbstractString ? String(model) : ""
return assistantMessageToolCall(role, toolCalls, content, api, provider, model_str,
usage, stopReason, errorMessage, timestamp)
end
struct assistantMessage <: agentMessage # Message from the AI assistant struct assistantMessage <: agentMessage # Message from the AI assistant
role::String # Always "assistant" role::String # Always "assistant"
content::Vector{messageContent} # Text and/or image content content::Vector{messageContent} # Text and/or image content
@@ -144,9 +201,10 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
``` ```
""" """
function assistantMessage(; role="assistant", content=Vector{messageContent}(), function assistantMessage(; role="assistant", content=Vector{messageContent}(),
api="", provider="", model="", usage=llmUsage(0, 0), stopReason="end_turn", api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
errorMessage=nothing, timestamp=now()) errorMessage=nothing, timestamp=now())
return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) model_str = model isa AbstractString ? String(model) : ""
return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp)
end end
struct toolResultMessage <: agentMessage # Result returned from a tool execution struct toolResultMessage <: agentMessage # Result returned from a tool execution
@@ -263,7 +321,7 @@ struct agentTool # A tool available to the agent
label::String # Human-readable tool name label::String # Human-readable tool name
description::String # What the tool does description::String # What the tool does
inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format)
execute::Function # Tool execution function execute # Tool execution function
prepareArguments::Union{Function, Nothing} # Optional argument preparation callback prepareArguments::Union{Function, Nothing} # Optional argument preparation callback
validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args
parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel
@@ -273,7 +331,7 @@ end
Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`.
""" """
function agentTool(; name::String, label::String, description::String, inputSchema::Any, function agentTool(; name::String, label::String, description::String, inputSchema::Any,
execute::Function, prepareArguments::Union{Function, Nothing}=nothing, execute, prepareArguments::Union{Function, Nothing}=nothing,
validateRequiredArgs::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing,
parallelToolExecute::Bool=false) parallelToolExecute::Bool=false)
return agentTool(name, label, description, inputSchema, execute, return agentTool(name, label, description, inputSchema, execute,
@@ -291,7 +349,7 @@ Snapshot of the agent's conversation context.
# Arguments # Arguments
- `systemPrompt::String`: System prompt for the agent - `systemPrompt::String`: System prompt for the agent
- `messages::Vector{agentMessage}`: Conversation messages - `messages::Vector{agentMessage}`: Conversation messages
- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup - `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
# Returns # Returns
- A new `agentContext` instance - A new `agentContext` instance
@@ -299,7 +357,8 @@ Snapshot of the agent's conversation context.
struct agentContext # Snapshot of the agent's conversation context struct agentContext # Snapshot of the agent's conversation context
systemPrompt::String # System prompt for the agent systemPrompt::String # System prompt for the agent
messages::Vector{agentMessage} # Conversation messages messages::Vector{agentMessage} # Conversation messages
tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name
llmCall::Union{Any, Nothing} # LLM call function (for tools that need it)
end end
@@ -309,14 +368,13 @@ end
mutable struct agentState # Mutable runtime state of an agent mutable struct agentState # Mutable runtime state of an agent
systemPrompt::String # System prompt for the agent systemPrompt::String # System prompt for the agent
model::llmModel # LLM model to use model::Union{llmModel, Nothing} # LLM model to use
tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
messages::Vector{agentMessage} messages::Vector{agentMessage}
pendingToolCalls::Vector{String} # Tool call IDs waiting for results pendingToolCalls::Vector{String} # Tool call IDs waiting for results
activeRun::Bool # is agent processing user message?
errorMessage::Union{String, Nothing} # Last error message errorMessage::Union{String, Nothing} # Last error message
end end
@@ -342,7 +400,7 @@ agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agen
""" """
function agentState( function agentState(
systemPrompt::String="", systemPrompt::String="",
model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], model=llmModel("model_1", "unknown", "unknown", "", false, String[],
modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[], messages::Vector{agentMessage}=agentMessage[],
@@ -353,20 +411,11 @@ function agentState(
deepcopy(tools), deepcopy(tools),
deepcopy(messages), deepcopy(messages),
Vector{String}(), Vector{String}(),
false,
nothing, nothing,
) )
end end
struct agentToolCall # A tool invocation from the LLM
type::String # Always "function"
id::String # Unique tool call identifier
name::String # Tool name
arguments::Dict{String, Any} # Parsed tool arguments
end
""" """
Context for preparing the next conversation turn. Context for preparing the next conversation turn.
@@ -395,16 +444,15 @@ end
Configuration for the agent tool execution loop. Configuration for the agent tool execution loop.
# Arguments # Arguments
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
- `toolExecution::String`: Execution mode — "sequential" or "parallel" - `toolExecution::String`: Execution mode — "sequential" or "parallel"
""" """
struct agentLoopConfig struct agentLoopConfig
tools::OrderedDict{String, agentTool}
beforeToolCall::Union{Function, Nothing} beforeToolCall::Union{Function, Nothing}
afterToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing}
toolExecution::String toolExecution::String
llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine)
end end
""" """
@@ -437,31 +485,36 @@ end
Context passed to the `beforeToolCall` hook. Context passed to the `beforeToolCall` hook.
# Arguments # Arguments
- `message::assistantMessage`: The assistant message containing the tool call - `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call being prepared - `toolCall::agentToolCall`: The tool call being prepared
- `args::Dict{String,Any}`: Validated tool arguments - `args::Dict{String,Any}`: Validated tool arguments
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct assistantMsgCtx struct beforeToolCallContext
message::assistantMessage message::assistantMessageToolCall
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::Dict{String,Any}
context::agentContext context::agentContext
end end
struct beforeToolCallResult
block::Bool
reason::String
end
""" """
Context passed to the `afterToolCall` hook. Context passed to the `afterToolCall` hook.
# Arguments # Arguments
- `message::assistantMessage`: The assistant message containing the tool call - `message::assistantMessageToolCall`: The assistant message containing the tool call
- `toolCall::agentToolCall`: The tool call that was executed - `toolCall::agentToolCall`: The tool call that was executed
- `args::Dict{String,Any}`: Tool arguments - `args::Dict{String,Any}`: Tool arguments
- `result::agentToolResult`: The raw tool result - `result::agentToolResult`: The raw tool result
- `isError::Bool`: Whether execution resulted in an error - `isError::Bool`: Whether execution resulted in an error
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct afterCtx struct afterToolCallContext
message::assistantMessage message::assistantMessageToolCall
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::Dict{String,Any}
result::agentToolResult result::agentToolResult
@@ -521,143 +574,6 @@ end
abstract type agent end abstract type agent end
"""
docstring
"""
mutable struct yiemAgent <: agent # High-level agent wrapper
_state::agentState # Current state (prompt, model, messages, tools, etc.)
# user sends prompt message to agent. if agent is idle, it process user message right away.
# if agent is running, it process user message after the current tool call finished.
inputChannel::Channel
# Buffers messages the user sends while the agent is busy. Processed after all inputChannel
# messages are handled and the agent is idle (not using a tool call).
followUpChannel::Channel
# agent sends response message to user after processing all user messages in inputChannel
# and all followUp messages.
outputChannel::Channel
_agent_loop::Union{Task, Nothing} # agent loop running in the background
# Preprocess/transform messages and context (modify, filter, prune, inject context from memory,
# reorder, ...) for a single LLM call in _process_message()'s loop.
# returns new Vector{agentMessage}
prepareContext ::Union{Function, Nothing}
# Convert prepareContext()'s new Vector{agentMessage} to LLM message format
formatMsgForLLM::Function
# Actually invoke the LLM to get a completion response. The LLM response comes back as an
# assistantMessage whose content is an array of content blocks.
# Each block has a type — "text", "thinking", or "toolCall".
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
llmCall::Function
# Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..)
beforeToolCall::Union{Function, Nothing}
executeToolCalls::Function # execute tool calls ()
# Callback invoked after executing a tool call to sanitize tools output so the output is ready
# to be converted into toolResults message
afterToolCall::Union{Function, Nothing}
# prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn
# prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
_tool_store::Any # Reference to the ToolStore for runtime registration
end
"""
Create a new yiemAgent instance with a background loop task.
Spawns a background `@spawn` task that runs the agent loop, listening
on `inputChannel` and `followUpChannel` channels concurrently.
# Keyword Arguments
- `systemPrompt::String`: System prompt for the agent
- `model`: LLM model to use
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty)
- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty)
- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`)
- `llmCall::Function`: Function to invoke the LLM (required)
- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`)
- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`)
- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`)
- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`)
- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`)
- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`)
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`)
# Returns
- A new `yiemAgent` instance with an active background task
# Examples
```julia
julia> store = ToolStore(name="agent1")
julia> tools = loadTools(store, "src/tools")
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store)
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store)
"""
function yiemAgent(
; systemPrompt::String="You are helpful assistant.",
model=nothing,
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[],
prepareContext::Union{Function, Nothing}=nothing,
formatMsgForLLM::Function=defaultformatMsgForLLM,
llmCall::Function,
beforeToolCall::Union{Function, Nothing}=nothing,
afterToolCall::Union{Function, Nothing}=nothing,
# prepareNextTurn::Union{Function, Nothing}=nothing,
# prepareNextTurnWithContext::Union{Function, Nothing}=nothing,
sessionId::Union{String, Nothing}=nothing,
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink::Function,
tool_store::Union{Any, Nothing}=nothing,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
followUp = Channel(32)
outputChannel = Channel(16)
# Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent(
agentState(systemPrompt, model, tools, messages),
inputChannel,
followUp,
outputChannel,
nothing, # placeholder — replaced below
prepareContext,
formatMsgForLLM,
llmCall,
beforeToolCall,
afterToolCall,
# prepareNextTurn,
# prepareNextTurnWithContext,
sessionId,
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
tool_store,
)
# Spawn the background loop and attach it
agent._agent_loop = @spawn _agent_loop(agent)
return agent
end
""" """
preparedToolCall(tool, toolCall, args) preparedToolCall(tool, toolCall, args)
+193 -14
View File
@@ -1,9 +1,11 @@
module utils module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI,
beforeToolCall, afterToolCall, agentEventSink
using UUIDs, Dates, DataStructures, HTTP, JSON using UUIDs, Dates, DataStructures, HTTP, JSON, NATS
using GeneralUtils using GeneralUtils
using ..type using ..type
@@ -73,7 +75,6 @@ function availableWineToText(vecd::Vector)::String
end end
""" """
prepareContext(state::agentState) -> agentContext prepareContext(state::agentState) -> agentContext
@@ -108,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages)
# end # end
``` ```
""" """
function prepareContext(state::agentState)::agentContext function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext
#TODO filter tools from state.tools based on user intend in user message and tool description #TODO filter tools from state.tools based on user intend in user message and tool description
filteredTools = state.tools filteredTools = state.tools
@@ -119,7 +120,7 @@ function prepareContext(state::agentState)::agentContext
#TODO add system prompt, adjust/modify and inject additional context into messages #TODO add system prompt, adjust/modify and inject additional context into messages
preparedMessages = deepcopy(state.messages) # messages that will be send to LLM preparedMessages = deepcopy(state.messages) # messages that will be send to LLM
agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools, llmCall)
return agentCtx return agentCtx
end end
@@ -155,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [
]) ])
``` ```
""" """
function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
""" openai message format example """ openai message format example
msg = Dict( msg = Dict(
@@ -183,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
Dict("type" => "text", "text" => "let me check."), Dict("type" => "text", "text" => "let me check."),
] ]
), ),
],
"tools"=> [
Dict( Dict(
"role" => "toolResult", "type" => "function",
"content" => [ "function" => Dict(
Dict("type" => "text", "text" => "name: Chateau Montelena ..."), "name" => "getWeather",
] "description" => "Get current weather",
"parameters" => Dict(
"type" => "object",
"properties" => Dict(
"city" => Dict("type" => "string")
), ),
"required" => ["city"]
)
)
)
], ],
"temperature" => 0.7 "temperature" => 0.7
) )
""" """
openaiReadyMsg = Dict{String, Any}()
# openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL"
messages = Vector{Dict{String, Any}}() messages = Vector{Dict{String, Any}}()
agentEventSink("formatMsgForLLM 1")
# System prompt as system message # System prompt as system message
if !isempty(ctx.systemPrompt) if !isempty(ctx.systemPrompt)
push!(messages, Dict( push!(messages, Dict(
@@ -203,19 +216,113 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
"content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
)) ))
end end
agentEventSink("formatMsgForLLM 2")
# Conversation messages # Conversation messages
for msg in ctx.messages for msg in ctx.messages
if msg isa userMessage if msg isa userMessage
push!(messages, _userMessageToOpenAI(msg)) push!(messages, _userMessageToOpenAI(msg))
elseif msg isa assistantMessageToolCall
push!(messages, _assistantMessageToolCallToOpenAI(msg))
elseif msg isa assistantMessage elseif msg isa assistantMessage
push!(messages, _assistantMessageToOpenAI(msg)) push!(messages, _assistantMessageToOpenAI(msg))
elseif msg isa toolResultMessage elseif msg isa toolResultMessage
push!(messages, _toolResultMessageToOpenAI(msg)) push!(messages, _toolResultMessageToOpenAI(msg))
end end
end end
agentEventSink("formatMsgForLLM 3")
# Convert ctx.tools into OpenAI tools format
tools_array = _toolsToOpenAI(ctx.tools, agentEventSink)
agentEventSink("formatMsgForLLM 4")
openaiReadyMsg["messages"] = messages
openaiReadyMsg["temperature"] = 0.7
if !isempty(tools_array)
openaiReadyMsg["tools"] = tools_array
end
return openaiReadyMsg
end
"""
beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult
Callback invoked before executing a tool call. Use this hook to inspect
the tool call and decide whether to allow, block, or modify it.
Common use cases:
- Request user approval via UI before running destructive tools.
- Validate business rules that cannot be expressed in the JSON schema.
- Check final context (e.g. session state, rate limits, permissions).
# Arguments
- `context::beforeToolCallContext`: Contains the assistant message, tool call,
validated arguments, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `beforeToolCallResult(false, "N/A")` to allow the call to proceed.
- `beforeToolCallResult(true, "Reason")` to block the call with a reason.
- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`).
# Example
```julia
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)
if context.toolCall.name == "deleteFile"
# Block file deletion unless explicitly approved
return beforeToolCallResult(true, "User must approve file deletion")
end
return beforeToolCallResult(false, "N/A")
end
```
"""
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
)::beforeToolCallResult
# final context check
# seek user approval via UI
# other check
return beforeToolCallResult(false, "N/A")
end
"""
afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing}
Callback invoked after a tool call finishes executing (before and after errors).
Use this hook to post-process the tool result before it is fed back to the LLM.
Common use cases:
- Mask sensitive data (API keys, tokens) from result content.
- Normalize usage tracking data into a consistent format.
- Inspect the result and set `terminate: true` based on business logic
(e.g. "if deployment failed, stop the agent rather than retrying").
- Wrap error results in friendlier messages for the LLM to understand.
# Arguments
- `context::afterToolCallContext`: Contains the assistant message, tool call,
arguments, raw result, error status, and current conversation context.
- `signal::abortSignal`: Signal that may be set to abort the operation.
# Returns
- `nothing` to pass the result through unchanged.
- `agentToolResult(...)` to return a modified result (content, details, usage,
terminate flag can all be overridden).
"""
function afterToolCall(context::afterToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult
return nothing
end
#TODO
function agentEventSink(x)
return Dict("messages" => messages)
end end
@@ -230,6 +337,44 @@ function _userMessageToOpenAI(msg::userMessage)::Dict{String, Any}
end end
"""
Convert an assistantMessageToolCall to OpenAI message format.
Produces a message with role="assistant", content=null, and a tool_calls array:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\"}"
}
}
]
}
"""
function _assistantMessageToolCallToOpenAI(msg::assistantMessageToolCall)::Dict{String, Any}
tool_calls = Dict{String, Any}[]
for tc in msg.toolCalls
push!(tool_calls, Dict(
"id" => tc.id,
"type" => tc.type,
"function" => Dict(
"name" => tc.name,
"arguments" => JSON.json(tc.arguments)
)
))
end
return Dict(
"role" => "assistant",
"content" => nothing,
"tool_calls" => tool_calls
)
end
""" """
Convert an assistantMessage to OpenAI message format. Convert an assistantMessage to OpenAI message format.
""" """
@@ -279,6 +424,40 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{
end end
"""
_toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}}
Convert an OrderedDict of agentTool definitions into OpenAI function tool format.
Returns an empty vector when `tools` is `nothing` or empty.
# Examples
```julia
_toolsToOpenAI(nothing) # => Dict{String, Any}[]
_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))]
```
"""
function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, agentEventSink)::Vector{Dict{String, Any}}
tools_array = Vector{Dict{String, Any}}()
agentEventSink("_toolsToOpenAI 1")
agentEventSink(string(typeof(tools)))
if tools !== nothing
for (_, tool) in tools
push!(tools_array, Dict(
"type" => "function",
"function" => Dict(
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.inputSchema
)
))
end
end
agentEventSink("_toolsToOpenAI 2")
return tools_array
end
""" """
validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String} validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String}
File diff suppressed because it is too large Load Diff
-375
View File
@@ -1,375 +0,0 @@
module type
export agent, sommelier, companion, virtualcustomer, agentcontext
using Dates, UUIDs, DataStructures, JSON, NATS
using GeneralUtils
# ---------------------------------------------- 100 --------------------------------------------- #
mutable struct agentcontext
text2textInstructLLM::Function
getTextEmbedding::Function
executeSQL::Function
similarSQLVectorDB::Function
insertSQLVectorDB::Function
similarSommelierDecision::Function
insertSommelierDecision::Function
find_related_tables_for_user_question::Function
pg_conn_str::String
agentconfig::AbstractDict
end
abstract type agent end
mutable struct sommelier <: agent
name::String # agent name
id::String # agent id
retailername::String
retailerid::String
tools::Dict
maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized
chathistory::Vector{Dict{String, Any}}
memory::Dict{String, Any}
context::agentcontext
llmFormatName::String
end
""" A sommelier agent.
# Arguments
- `context::agentcontext`
Application context containing shared functions for LLM, SQL, and vector database operations.
# Keyword Arguments
- `name::String`
Agent's name. Default: `"Assistant"`
- `id::String`
Agent's ID. Default: generated UUID string.
- `retailername::String`
Retailer name associated with the sommelier. Default: `"retailer_name"`
- `maxHistoryMsg::Integer`
Maximum history messages. Default: `20`
- `chathistory::Vector{Dict{String, String}}`
Chat history. Default: empty vector.
- `llmFormatName::String`
LLM format name. Default: `"granite3"`
# Return
- `sommelier`: An instantiated sommelier agent.
# Example
```julia
julia> using YiemAgent
julia> context = agentcontext(
text2textInstructLLM,
getTextEmbedding,
executeSQL,
similarSQLVectorDB,
insertSQLVectorDB,
similarSommelierDecision,
insertSommelierDecision
)
julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop")
```
"""
function sommelier(
context::agentcontext, # agent functions, db connect and other context
;
name::String= "Assistant",
id::String= string(uuid4()),
retailername::String= "not specified",
retailerid::String= "not specified",
maxHistoryMsg::Integer= 20,
chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(),
llmFormatName::String= "granite3"
)
tools = Dict( # update input format
"chatbox"=> Dict(
"description" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"output" => "" ,
),
"winestock"=> Dict(
"description" => "<winestock tool description>A handy tool for searching wine in your inventory that match the user preferences.</winestock tool description>",
"input" => """<input>Input is a JSON-formatted string that contains a detailed and precise search query.</input><input example>{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}</input example>""",
"output" => """<output>Output are wines that match the search query in JSON format.""",
),
)
""" Memory
Chat history use openai format as follow:
image1_path = "test/large_image.png" ---
image1_bytes = read(image1_path) | this part must be done
image1_base64_string = base64encode(image1_bytes) | in frontend
mime_type = "image/png" | not in agent code
data1_uri = "data:<mime_type>;base64,<image1_base64_string>" ---
chathistory= [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => "You are a helpful assistant"),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "<internal_context_for_assistant>
LLM context here...
</internal_context_for_assistant>
Do you know this wine? Just give me brief intro."
),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
),
]
),
]
shortmem = Dict(
"1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."),
"2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."),
...
)
"""
memory = Dict{String, Any}(
"shortmem"=> OrderedDict{String, Any}(),
"scratchpad"=> "",
"recap"=> OrderedDict{String, Any}(),
)
newAgent = sommelier(
name,
id,
retailername,
retailerid,
tools,
maxHistoryMsg,
chathistory,
memory,
context,
llmFormatName
)
systemmsg =
"""
# store_policy
- Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory.
- If you found wines in the store's database, they are in stock.
- You can only recommend wines that are currently in our inventory
- Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences.
- Ask the user one question at a time.
- Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services.
- Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future.
- Spicy foods should be paired only with light red wines.
- We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such.
- Gift box, gift card, and custom messages are available. Inform the user to contact our sales team.
# store_guidelines
- Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting.
- Customer may provide images for you to look up.
- Encourage the customer to explore different options and try new things.
- If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead.
- Your store carries only wine.
- Vintage 0 means non-vintage.
- Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently.
- User usually ask for something similar. This means you should use the search term based on the profile they like.
# situation
You are having conversation with a customer.
# your role
Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store.
# objective
- Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences.
- Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences.
# your responsibility includes
- According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective.
- Keep the conversation with the customer going smoothly
# your responsibility does NOT includes
- Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store.
- Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store.
- Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store.
# you should then respond to the user with interleaving plan, action_name, action_input in JSON format
1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.
2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name
3) "action_input", The input to the action you are about to perform according to your plan.
After the action is executed you gets "action_result". It is the output from the action you selected.
# available actions
"CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan.
"SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity.
Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD."
Example query 2: "Red or white wine, medium tannin, price under 700 USD"
Example query 3: "white wine from Tuscany, Italy or Bordeaux, France
"WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow.
"END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow.
"""
system_msg = Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => systemmsg),
]
)
push!(newAgent.chathistory, system_msg)
return newAgent
end
mutable struct virtualcustomer <: agent
name::String # agent name
id::String # agent id
systemmsg::String # system message
tools::Dict
maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized
chathistory::Vector{Dict{String, Any}}
memory::Dict{String, Any}
context # NamedTuple of functions
llmFormatName::String
end
function virtualcustomer(
context, # NamedTuple of functions
;
name::String= "Assistant",
id::String= string(uuid4()),
maxHistoryMsg::Integer= 20,
chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(),
llmFormatName::String= "granite3",
systemmsg::String=
"""
Your name: $name
Your sex: Female
Your role: You are a helpful assistant.
You should follow the following guidelines:
- Focus on the latest conversation.
- Your like to be short and concise.
Let's begin!
""",
)
tools = Dict( # update input format
"chatbox"=> Dict(
"description" => "<askbox tool description>Useful for when you need to ask the user for more context. Do not ask the user their own question.</askbox tool description>",
"input" => """<input>Input is a text in JSON format.</input><input example>{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}</input example>""",
"output" => "" ,
),
)
""" Memory
Ref: Chat prompt format is openai
chathistory = [
Dict(
"role" => "system",
"content" => [
Dict("type" => "text", "text" => system_msg),
]
),
Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
)
]
)
]
"""
memory = Dict{String, Any}(
"shortmem"=> OrderedDict{String, Any}(
),
"scratchpad"=> "",
"events"=> Vector{Dict{String, Any}}(),
"state"=> Dict{String, Any}(
),
"recap"=> OrderedDict{String, Any}(),
)
newAgent = virtualcustomer(
name,
id,
systemmsg,
tools,
maxHistoryMsg,
chathistory,
memory,
context,
llmFormatName
)
return newAgent
end
end # module type
File diff suppressed because it is too large Load Diff
+642
View File
@@ -0,0 +1,642 @@
using Test
using YiemAgent
using YiemAgent.agentCore
using YiemAgent.type
using JSON
# Import the function from the private module scope
import YiemAgent.agentCore: _extractToolCalls
@testset "_extractToolCalls" begin
# ------------------------------------------------------------------ #
# Format 1: response["message"]["tool_calls"] (LMStudio.jl style) #
# ------------------------------------------------------------------ #
@testset "single tool call via message format" begin
response = Dict{String,Any}(
"finish_reason" => "tool_calls",
"index" => 0,
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"reasoning_content" => "Let me check the weather.",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Bangkok, Thailand\"}",
),
"id" => "tc_001",
)
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].id == "tc_001"
@test tc_list[1].type == "function"
@test tc_list[1].arguments["city"] == "Bangkok, Thailand"
@test assistant_msg isa assistantMessage
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "tool_calls"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Let me check the weather."
end
@testset "multiple tool calls via message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"content" => "",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Tokyo, Japan\"}",
),
"id" => "tc_001",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"Asia/Tokyo\"}",
),
"id" => "tc_002",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Tokyo, Japan"
@test tc_list[2].name == "getTime"
@test tc_list[2].arguments["timezone"] == "Asia/Tokyo"
@test assistant_msg.role == "assistant"
end
@testset "tool call with empty arguments string" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "listTools",
"arguments" => "{}",
),
"id" => "tc_empty",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "listTools"
@test tc_list[1].arguments == Dict{String,Any}()
@test assistant_msg.stopReason == "end_turn"
end
@testset "tool call with missing id falls back to uuid" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"city\":\"NYC\"}",
),
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test !isempty(tc_list[1].id)
@test tc_list[1].name == "getTime"
end
@testset "tool call with non-string arguments (pre-parsed dict)" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"),
),
"id" => "tc_parsed",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments["city"] == "London"
@test tc_list[1].arguments["units"] == "fahrenheit"
end
@testset "tool call with api/provider/model/usage metadata" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-opus",
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{}",
),
"id" => "tc_meta",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-opus"
end
# --------------------------------------------------------------- #
# Format 2: response.content blocks (OpenAI API style) #
# --------------------------------------------------------------- #
@testset "content blocks with tool_calls" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Let me check."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Paris\"}",
),
"id" => "tc_block_1",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Paris"
# text block before tool_calls should be included in content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Let me check."
end
@testset "content blocks with tool_call (single-call format)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_single",
"name" => "getTime",
"arguments" => Dict{String,Any}("timezone" => "Europe/London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].id == "tc_single"
@test tc_list[1].arguments["timezone"] == "Europe/London"
end
@testset "content blocks with reasoning and text" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."),
Dict{String,Any}("type" => "text", "text" => "Here's the answer."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "Thinking..."
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "Here's the answer."
end
@testset "content blocks with text and tool_call (tool_call not in content)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."),
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_mix",
"name" => "getWeather",
"arguments" => Dict{String,Any}("city" => "London"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
# text block included, tool_call block excluded from content
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "Sure, I'll check."
end
# ------------------------------------------------------------------ #
# assistantMessage construction #
# ------------------------------------------------------------------ #
@testset "assistantMessage with error_message and errorMessage fallback" begin
response = Dict{String,Any}(
"error_message" => "rate limit",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test assistant_msg.errorMessage == "rate limit"
end
@testset "assistantMessage with usage tracking" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => llmUsage(100, 50),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 100
@test assistant_msg.usage.outputTokens == 50
end
@testset "assistantMessage with invalid usage defaults to zero" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")],
"usage" => "invalid",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.usage.inputTokens == 0
@test assistant_msg.usage.outputTokens == 0
end
@testset "reasoning_content as textContent" begin
response = Dict{String,Any}(
"reasoning_content" => textContent("internal thought"),
"content" => Any[Dict{String,Any}("type" => "text", "text" => "output")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa reasoningContent
@test assistant_msg.content[1].text == "internal thought"
@test assistant_msg.content[2] isa textContent
@test assistant_msg.content[2].text == "output"
end
# ------------------------------------------------------------------ #
# No tool call cases #
# ------------------------------------------------------------------ #
@testset "no tool calls found" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "Hello world."),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.stopReason == "end_turn"
end
@testset "empty message" begin
response = Dict{String,Any}()
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
@test assistant_msg.stopReason == "end_turn"
@test length(assistant_msg.content) == 0
end
@testset "message with empty tool_calls array" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test assistant_msg.role == "assistant"
end
@testset "message field is not a Dict" begin
response = Dict{String,Any}(
"message" => "not a dict",
"content" => Any[Dict{String,Any}("type" => "text", "text" => "fallback")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 1
end
@testset "Format 1 takes priority over Format 2" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"Format1\"}",
),
"id" => "tc_fmt1",
),
],
),
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_fmt2",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Format2"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
end
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
@testset "tool call with null arguments" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => nothing,
),
"id" => "tc_null",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
end
@testset "tool call with missing function key" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"id" => "tc_nofunc",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "tool call with missing name in function block" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "assistant",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}("arguments" => "{}"),
"id" => "tc_noname",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == ""
end
@testset "message format with JSON.Object (JSON.parse result)" begin
json_str = JSON.json(Dict(
"message" => Dict(
"role" => "assistant",
"tool_calls" => [
Dict(
"type" => "function",
"function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"),
"id" => "tc_jsonobj",
),
],
),
))
parsed = JSON.parse(json_str)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(parsed)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getWeather"
@test tc_list[1].arguments["city"] == "Test"
end
@testset "tool_calls block with mixed content types (text + tool_calls)" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}("type" => "text", "text" => "I'll check both."),
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{\"city\":\"London\"}",
),
"id" => "tc_mix1",
),
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getTime",
"arguments" => "{\"timezone\":\"UTC\"}",
),
"id" => "tc_mix2",
),
],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 2
@test tc_list[1].name == "getWeather"
@test tc_list[2].name == "getTime"
@test length(assistant_msg.content) == 1
@test assistant_msg.content[1].text == "I'll check both."
end
@testset "tool_call block without arguments field" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_noargs",
"name" => "getTime",
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].arguments == Dict{String,Any}()
end
@testset "tool_calls block with empty tool_calls array" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any[],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "tool_calls block with non-AbstractDict elements" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "tool_calls",
"tool_calls" => Any["not a dict", 42, nothing],
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
end
@testset "content field is not a Vector" begin
response = Dict{String,Any}(
"content" => "not a vector",
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(tc_list) == 0
@test length(assistant_msg.content) == 0
end
@testset "Dict-based response with all metadata fields" begin
response = Dict{String,Any}(
"api" => "openai",
"provider" => "anthropic",
"model" => "claude-3-sonnet",
"content" => Any[
Dict{String,Any}(
"type" => "tool_call",
"id" => "tc_meta",
"name" => "getTime",
"arguments" => Dict{String,Any}("city" => "Seoul"),
),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == true
@test length(tc_list) == 1
@test tc_list[1].name == "getTime"
@test tc_list[1].arguments["city"] == "Seoul"
@test assistant_msg.api == "openai"
@test assistant_msg.provider == "anthropic"
@test assistant_msg.model == "claude-3-sonnet"
end
@testset "default role is assistant" begin
response = Dict{String,Any}(
"content" => Any[Dict{String,Any}("type" => "text", "text" => "no role specified")],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "assistant"
end
@testset "tool call with custom role in message format" begin
response = Dict{String,Any}(
"message" => Dict{String,Any}(
"role" => "custom_role",
"tool_calls" => Any[
Dict{String,Any}(
"type" => "function",
"function" => Dict{String,Any}(
"name" => "getWeather",
"arguments" => "{}",
),
"id" => "tc_role",
),
],
),
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test assistant_msg.role == "custom_role"
end
@testset "image content block handling" begin
response = Dict{String,Any}(
"content" => Any[
Dict{String,Any}(
"type" => "image_url",
"image_url" => Dict("url" => "data:image/png;base64,abc123"),
),
Dict{String,Any}("type" => "text", "text" => "What is this?"),
],
)
has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response)
@test has_toolcalls == false
@test length(assistant_msg.content) == 2
@test assistant_msg.content[1] isa textContent
@test assistant_msg.content[1].text == ""
@test assistant_msg.content[2].text == "What is this?"
end
end
+4 -400
View File
@@ -1,401 +1,5 @@
using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, using Test
NATS, Base.Threads using YiemAgent
using YiemAgent, GeneralUtils, msghandler
function text2text_instruct_llm(sender_id::String, openai_msg::Dict{String, Any})
payloads = [("msg", openai_msg, "dictionary")] # List of tuples
_, msg_envelope_json_str = msghandler.smartpack(
config["externalservice"]["servicesloadbalancer"]["nats"],
payloads;
sender_id=sender_id,
msg_purpose="text2text",
broker_url=config["nats_server_info"]["url"],
fileserver_url=config["externalservice"]["fileserver"]["url"])
reply = NATS.request(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
msg_envelope_json_str, timeout=120)
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]["message"]["content"]
return llm_response
end
""" get a single text embedding from a LLM service
Example
text = ["hello"]
embedding = get_embedding(text)
"""
function get_embedding(text::AbstractArray{String})
documents_dict = Dict("documents" => text)
payloads = [("documents", documents_dict, "dictionary")]
_, msg_envelope_json_str = msghandler.smartpack(
config["externalservice"]["servicesloadbalancer"]["nats"],
payloads;
msg_purpose="embedding",
broker_url=config["nats_server_info"]["url"],
fileserver_url=config["externalservice"]["fileserver"]["url"])
reply = NATS.request(agent_conn,
config["externalservice"]["servicesloadbalancer"]["nats"],
msg_envelope_json_str, timeout=120)
incoming_env_json_str = String(reply.payload)
incoming_env = msghandler.smartunpack(incoming_env_json_str)
embedding_response = incoming_env["payloads"][1][2]
return embedding_response
end
""" sql = "SELECT * FROM wine;"
result = execute_sql_winedb(sql)
"""
function execute_sql_winedb(sql::T) where {T<:AbstractString}
host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':')
port = parse(Int, _port)
dbname = "winedb"
user = config["externalservice"]["sommpanion_db"]["user"]
password = config["externalservice"]["sommpanion_db"]["password"]
db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password")
result = nothing
try
result = LibPQ.execute(db_connection, sql)
catch e
LibPQ.close(db_connection)
end
LibPQ.close(db_connection)
return result
end
""" find similar sql from vector database
sql = "SELECT * FROM wine;"
result, distance = similar_sql_vectordb(sql)
"""
function similar_sql_vectordb(sql::T; maxdistance::Number=0.2) where {T<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
df = find_similar_text_from_vectordb(sql, tablename,
"function_input_embedding", execute_sql_vectordb)
# println(df[1, [:id, :function_output]])
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row != 0 && distance < maxdistance
# if there is usable SQL, return it.
output_b64 = df[1, :function_output_base64] # pick the closest match
output_str = String(base64decode(output_b64))
rowid = df[1, :id]
println("\n--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
pprintln(output_str)
return (result=output_str, distance=distance)
else
println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
return (result=nothing, distance=nothing)
end
end
""" insert query and sql into vector database
query = "get all wines from wine table"
sql = "SELECT * FROM wine;"
insert_sql_vectordb(query, sql)
"""
function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3
) where {T1<:AbstractString, T2<:AbstractString}
tablename = "sqlllm_decision_repository"
# get embedding of the query
# query = state[:thoughtHistory][:question]
df = find_similar_text_from_vectordb(query, tablename,
"function_input_embedding", execute_sql_vectordb)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
_query_embedding = get_embedding([query])
_query_embedding = GeneralUtils.dictify(_query_embedding)
# println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(_query_embedding)
# println("---\n")
query_embedding = _query_embedding["data"][1]["embedding"]
query = replace(query, "'" => "")
sql_base64 = base64encode(SQL)
sql_ = replace(SQL, "'" => "")
sql =
"""
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding');
"""
# println("\n--| added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(sql)
_ = execute_sql_vectordb(sql)
end
end
""" execute sql against vectordb
sql = "SELECT * FROM wine;"
result = execute_sql_vectordb(sql)
"""
function execute_sql_vectordb(sql::T) where {T<:AbstractString}
host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':')
port = parse(Int, _port)
dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"]
user = config["externalservice"]["sommpanion_vectordb"]["user"]
password = config["externalservice"]["sommpanion_vectordb"]["password"]
DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password")
result = LibPQ.execute(DBconnection, sql)
close(DBconnection)
return result
end
""" search similar decision llm made from vectordb
"""
function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3
)::Union{AbstractDict, Nothing} where {T1<:AbstractString}
tablename = "sommelier_decision_repository"
# find similar
df = find_similar_text_from_vectordb(recentevents, tablename,
"function_input_embedding", execute_sql_vectordb)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row != 0 && distance < maxdistance
# if there is usable decision, return it.
rowid = df[1, :id]
println("\n--| found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__)
output_b64 = df[1, :function_output_base64] # pick the closest match
_output_str = String(base64decode(output_b64))
output = copy(JSON.read(_output_str))
return output
else
println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__)
return nothing
end
end
""" search similar text from vectordb
"""
function find_similar_text_from_vectordb(text::T1, tablename::T2, embeddingColumnName::T3,
vectorDB::Function; limit::Integer=1
)::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString}
# get embedding from LLM service
_embedding = get_embedding([text])
_embedding = _embedding["data"][1]["embedding"]
_embedding = "$_embedding"
embedding = _embedding[4:end]
# check whether there is close enough vector already store in vectorDB. if no, add, else skip
sql = """
SELECT *, $embeddingColumnName <-> '$embedding' as distance
FROM $tablename
ORDER BY distance LIMIT $limit;
"""
response = vectorDB(sql)
df = DataFrame(response)
return df
end
""" insert decision llm made to vectordb
"""
function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5
) where {T1<:AbstractString, T2<:AbstractDict}
tablename = "sommelier_decision_repository"
# find similar
df = find_similar_text_from_vectordb(recentevents, tablename,
"function_input_embedding", execute_sql_vectordb)
row, col = size(df)
distance = row == 0 ? Inf : df[1, :distance]
if row == 0 || distance > maxdistance # no close enough SQL stored in the database
_embedding = get_embedding([recentevents])[1]
recentevents_embedding = _embedding["data"][1]["embedding"]
recentevents = replace(recentevents, "'" => "")
decision_json = JSON.json(decision)
decision_base64 = base64encode(decision_json)
decision = replace(decision_json, "'" => "")
sql =
"""
INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding');
"""
println("\n--| added new decision to vectorDB ", @__FILE__, " ", @__LINE__)
println(sql)
_ = execute_sql_vectordb(sql)
else
println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__)
end
end
config = JSON.parsefile("./appconfig.json")
sessionId = "0"
backend_session_topic = "sommpanion.testsubject"
agent_ch = Channel(8)
agent_conn = NATS.connect(config["nats_server_info"]["url"])
sub2 = NATS.subscribe(agent_conn, backend_session_topic) do msg
put!(agent_ch, msg)
end
agent_context = YiemAgent.agentcontext(
text2text_instruct_llm,
get_embedding,
execute_sql_winedb,
similar_sql_vectordb,
insert_sql_vectordb,
similar_sommelier_decision,
insert_sommelier_decision
)
# can't instantiate
agent = YiemAgent.sommelier(
agent_context;
name="Janie",
id=sessionId, # agent instance id
retailername="Yiem Wine Ltd.",
llmFormatName=""
)
image1_path = "test/large_image.png"
image1_bytes = read(image1_path)
image1_base64_string = base64encode(image1_bytes)
mime_type = "image/png"
data1_uri = "data:$(mime_type);base64,$(image1_base64_string)"
# 1. Read local file and encode to base64 string
image2_path = "test/small_image.png"
image2_bytes = read(image2_path)
image2_base64_string = base64encode(image2_bytes)
mime_type = "image/png"
data2_uri = "data:$(mime_type);base64,$(image2_base64_string)"
# 3. Construct payload with the Data URI
message = Dict(
"role" => "user",
"content" => [
Dict("type" => "text", "text" => "Do you know type of wine in the image?"),
Dict(
"type" => "image_url",
"image_url" => Dict("url" => data1_uri)
)
]
)
result = YiemAgent.conversation(agent; userinput=message)
println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" =>
# "
# เป็นงานเลี้ยงทั่วไป
# "),
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" => "no thanks. that's all"),
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
# message = Dict(
# "role" => "user",
# "content" => [
# Dict("type" => "text", "text" => "What about this wine?"),
# Dict(
# "type" => "image_url",
# "image_url" => Dict("url" => data2_uri)
# )
# ]
# )
# result = YiemAgent.conversation(agent; userinput=message)
# println("\n$result")
include("toolTest.jl")
include("_extractToolCalls.jl")
+232 -38
View File
@@ -1,52 +1,36 @@
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) == 3 @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
@test "getWeather" in names @test "getWeather" in names
@test "writeTool" in names @test "writeTool" 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) #
# because 'T' < 'W' in ASCII #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@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"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Verify loaded tool fields are correct # # 3. Verify loaded tool fields are correct #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# getTime # getTime
time_tool = loaded["getTime"] time_tool = loaded["getTime"]
@@ -73,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
@@ -97,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"])
@@ -142,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)
@@ -151,10 +134,10 @@ 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")
registerTool(storeA, loaded["getTime"]) registerTool(storeA, loaded["getTime"])
registerTool(storeB, loaded["getWeather"]) registerTool(storeB, loaded["getWeather"])
@@ -171,3 +154,214 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test isempty(getTools(storeA)) @test isempty(getTools(storeA))
@test !isempty(getTools(storeB)) # storeB unaffected @test !isempty(getTools(storeB)) # storeB unaffected
end end
@testset "listTool" begin
store = toolStore(name="test_list")
register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools
# register_all_tools auto-registers listTool
@test "listTools" in keys(store.tools)
# listTool returns an agentTool, not a string or array
list_t = listTool(store)
@test list_t isa agentTool
@test list_t.name == "listTools"
@test list_t.label == "List Tools"
@test isempty(list_t.inputSchema["required"])
# Verify all tools appear (3 loaded + listTools = 4)
result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x)
@test result isa agentToolResult
@test result.content[1] isa textContent
@test occursin("listTools", result.content[1].text)
@test occursin("getWeather", result.content[1].text)
@test occursin("getTime", result.content[1].text)
@test occursin("writeTool", result.content[1].text)
@test result.details["count"] == 4
# Each listTool call creates an independent closure
storeB = toolStore(name="test_listB")
registerTool(storeB, store.tools["getWeather"])
list_tB = listTool(storeB)
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x)
@test occursin("getWeather", resultA.content[1].text)
@test occursin("getWeather", resultB.content[1].text)
@test occursin("getTime", resultA.content[1].text)
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
end
@testset "executePreparedToolCall with static tools" begin
# Tests executePreparedToolCall with statically loaded tools.
# The world-age issue is resolved because tool.execute comes from
# a statically included module, not a dynamically created one.
store = toolStore(name="test_static")
register_all_tools(store)
weather_tool = store.tools["getWeather"]
# Create a preparedToolCall that mimics what prepareToolCall() returns
tool_call = agentToolCall(
"function", "call-static-1", "getWeather",
Dict{String,Any}("city" => "San Francisco")
)
prep = preparedToolCall(
weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco")
)
sig = abortSignal(false)
# This call goes through: executePreparedToolCall -> prep.tool.execute(...)
result = executePreparedToolCall(
prep, sig, x -> nothing
)
@test result isa executedOutcome
@test result.isError == false
@test result.result.content[1] isa textContent
@test occursin("San Francisco", result.result.content[1].text)
end
@testset "executePreparedToolCall with validation (static tools)" begin
# Tests executePreparedToolCall with a tool that has custom validation hooks.
# This exercises the full tool execution path including validation.
store = toolStore(name="test_static_validate")
register_all_tools(store)
time_tool = store.tools["getTime"]
tool_call = agentToolCall(
"function", "call-static-2", "getTime",
Dict{String,Any}("timezone" => "America/New_York")
)
prep = preparedToolCall(
time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York")
)
sig = abortSignal(false)
result = executePreparedToolCall(
prep, sig, x -> nothing
)
@test result isa executedOutcome
@test result.isError == false
@test result.result.content[1] isa textContent
@test occursin("America/New_York", result.result.content[1].text)
end
@testset "executeToolCallsSequential with static tools (full pipeline)" begin
# Tests the full tool execution pipeline: executeToolCallsSequential
# which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall
# with statically loaded tools.
store = toolStore(name="test_full_pipeline")
register_all_tools(store)
# Build agentContext from the store's tools
tools = getTools(store)
ctx = agentContext(
"test system prompt",
agentMessage[],
tools
)
# Create an assistant message containing tool calls
assistant_msg = assistantMessage(
role="assistant",
content=Vector{messageContent}(),
api="openai",
provider="test",
model="test-model",
usage=llmUsage(0, 0),
stopReason="tool_calls",
errorMessage=nothing,
timestamp=now()
)
# Create tool calls for multiple statically loaded tools
tool_calls = [
agentToolCall(
"function", "call-seq-1", "getWeather",
Dict{String,Any}("city" => "Tokyo")
),
agentToolCall(
"function", "call-seq-2", "getTime",
Dict{String,Any}("timezone" => "Europe/London")
),
]
config = agentLoopConfig(
nothing, nothing, "sequential"
)
sig = abortSignal(false)
# Execute the full pipeline
batch = executeToolCallsSequential(
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
)
@test batch.messages isa Vector{toolResultMessage}
@test length(batch.messages) == 2
@test batch.messages[1].toolName == "getWeather"
@test batch.messages[1].isError == false
@test occursin("Tokyo", batch.messages[1].content[1].text)
@test batch.messages[2].toolName == "getTime"
@test batch.messages[2].isError == false
@test occursin("Europe/London", batch.messages[2].content[1].text)
end
@testset "executeToolCallsParallel with static tools (full pipeline)" begin
# Same as above but tests parallel execution path.
store = toolStore(name="test_parallel")
register_all_tools(store)
tools = getTools(store)
ctx = agentContext(
"test system prompt",
agentMessage[],
tools
)
assistant_msg = assistantMessage(
role="assistant",
content=Vector{messageContent}(),
api="openai",
provider="test",
model="test-model",
usage=llmUsage(0, 0),
stopReason="tool_calls",
errorMessage=nothing,
timestamp=now()
)
tool_calls = [
agentToolCall(
"function", "call-par-1", "getWeather",
Dict{String,Any}("city" => "Paris")
),
agentToolCall(
"function", "call-par-2", "getTime",
Dict{String,Any}("city" => "Sydney")
),
]
config = agentLoopConfig(
nothing, nothing, "parallel"
)
sig = abortSignal(false)
batch = executeToolCallsParallel(
ctx, assistant_msg, tool_calls, config, sig, x -> nothing
)
@test batch.messages isa Vector{toolResultMessage}
@test length(batch.messages) == 2
@test batch.messages[1].toolName == "getWeather"
@test batch.messages[1].isError == false
@test batch.messages[2].toolName == "getTime"
@test batch.messages[2].isError == false
end
+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)