From ed9126046835e0e5d9c4c3df5135040747a812ef Mon Sep 17 00:00:00 2001 From: narawat Date: Mon, 10 Aug 2026 20:37:28 +0700 Subject: [PATCH 01/23] update --- src/toolRegistry.jl | 46 ++++++++++++++++++++++----------------------- src/tools/README.md | 34 ++++++++++++++++----------------- src/type.jl | 6 +++--- test/toolTest.jl | 12 ++++++------ 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 8cf9a45..3d786b3 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,6 +1,6 @@ module toolRegistry -export ToolStore, loadTools, registerTool, getTools, clearTools, listTool +export toolStore, loadTools, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures @@ -9,14 +9,14 @@ using ..type """ 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. # Fields - `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration - `name::String` — identifier for debugging/logs """ -struct ToolStore +struct toolStore tools::OrderedDict{String, agentTool} name::String end @@ -29,14 +29,14 @@ Create a new isolated tool store. # Examples ```julia -store = ToolStore(name="agent1") +store = toolStore(name="agent1") tools = loadTools(store, "src/tools") registerTool(store, my_tool) agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) ``` """ -function ToolStore(; name::String="default")::ToolStore - ToolStore(OrderedDict{String, agentTool}(), name) +function toolStore(; name::String="default")::toolStore + toolStore(OrderedDict{String, agentTool}(), name) end """ @@ -44,12 +44,12 @@ List tool definition — lets the agent query available tools for collision dete when creating new tools via writeTool. # Arguments -- `store::ToolStore`: The tool store to list from +- `store::toolStore`: The tool store to list from -Each `ToolStore` gets its own `listTool` instance bound to that store, +Each `toolStore` gets its own `listTool` instance bound to that store, so each agent sees only its own tools. """ -function listTool(store::ToolStore)::agentTool +function listTool(store::toolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -80,7 +80,7 @@ function listTool(store::ToolStore)::agentTool end """ -Load all tool modules from a directory into a specific ToolStore. +Load all tool modules from a directory into a specific toolStore. Scans `dir` for `.jl` files. Each file must define a function named `getTool()::agentTool`. Files are sorted alphabetically so tool @@ -91,7 +91,7 @@ defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`, and any helper functions) are namespaced and never collide with other tools. # Arguments -- `store::ToolStore`: The tool store to register tools into +- `store::toolStore`: The tool store to register tools into - `dir::String`: Directory path to scan for `.jl` tool files # Returns @@ -102,7 +102,7 @@ and any helper functions) are namespaced and never collide with other tools. # Examples ```julia -julia> store = ToolStore(name="agent1") +julia> store = toolStore(name="agent1") julia> tools = loadTools(store, "src/tools") OrderedDict{String, agentTool} with 3 entries: "getWeather" => agentTool(...) @@ -110,7 +110,7 @@ OrderedDict{String, agentTool} with 3 entries: "listTools" => agentTool(...) ``` """ -function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} if !isdir(dir) throw(ArgumentError("Tool directory does not exist: $dir")) end @@ -172,10 +172,10 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool end """ -Register a single agentTool into a specific ToolStore. +Register a single agentTool into a specific toolStore. # Arguments -- `store::ToolStore`: The tool store to register into +- `store::toolStore`: The tool store to register into - `tool::agentTool`: The tool to register # Returns @@ -183,25 +183,25 @@ Register a single agentTool into a specific ToolStore. # Examples ```julia -julia> store = ToolStore(name="agent1") +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} +function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} store.tools[tool.name] = tool println("[$(store.name)] Registered tool: $(tool.name)") return store.tools end """ -Get the registered tools from a specific ToolStore. +Get the registered tools from a specific toolStore. Returns the internal `OrderedDict` directly — O(1) lookup by name, ordered iteration preserving registration order. # Arguments -- `store::ToolStore`: The tool store to query +- `store::toolStore`: The tool store to query # Returns - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order @@ -215,15 +215,15 @@ OrderedDict{String, agentTool} with 3 entries: "getTime" => agentTool(...) ``` """ -function getTools(store::ToolStore)::OrderedDict{String, agentTool} +function getTools(store::toolStore)::OrderedDict{String, agentTool} return store.tools end """ -Clear all registered tools from a specific ToolStore. +Clear all registered tools from a specific toolStore. # Arguments -- `store::ToolStore`: The tool store to clear +- `store::toolStore`: The tool store to clear # Returns - `nothing` @@ -234,7 +234,7 @@ julia> clearTools(store) [toolRegistry:agent1] Registry cleared ``` """ -function clearTools(store::ToolStore)::Nothing +function clearTools(store::toolStore)::Nothing empty!(store.tools) println("[$(store.name)] Registry cleared") return nothing diff --git a/src/tools/README.md b/src/tools/README.md index de6986f..f981e3c 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -52,12 +52,12 @@ result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) ### Step 2: Load — `loadTools()` -Load all tool modules from a directory into a `ToolStore`. Each `.jl` file must define `getTool()::agentTool`. +Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. ```julia using YiemAgent, YiemAgent.toolRegistry -store = ToolStore(name="myAgent") +store = toolStore(name="myAgent") tools = loadTools(store, "src/tools") # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools ``` @@ -84,8 +84,8 @@ Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up ToolStore and load tools -store = ToolStore(name="myAgent") +# 1. Set up toolStore and load tools +store = toolStore(name="myAgent") loadTools(store, "src/tools") # 2. Create agent — pass tools + _tool_store @@ -102,7 +102,7 @@ agent = yiemAgent( **Manual registration** (without `loadTools`): ```julia -store = ToolStore(name="myAgent") +store = toolStore(name="myAgent") registerTool(store, getTime_tool) registerTool(store, getWeather_tool) @@ -124,7 +124,7 @@ agent = yiemAgent( | `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `_tool_store` | `ToolStore` | No | Runtime tool registry for `registerTool()` | +| `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` | Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. @@ -278,12 +278,12 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca **Source:** `toolRegistry.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 -struct ToolStore +struct toolStore tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration name::String # identifier for debugging/logs end @@ -294,7 +294,7 @@ end ### How `loadTools(store, dir)` Works ```julia -function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} +function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} ``` **Source:** `toolRegistry.jl:113-172` @@ -325,8 +325,8 @@ Each tool file is loaded into its own **namespaced submodule**. This means: ```julia # Create per-agent stores -store1 = ToolStore(name="agent1") -store2 = ToolStore(name="agent2") +store1 = toolStore(name="agent1") +store2 = toolStore(name="agent2") # Load tools into specific stores tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only @@ -350,11 +350,11 @@ clearTools(store1) # only clears store1 ### 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 -storeA = ToolStore(name="A") -storeB = ToolStore(name="B") +storeA = toolStore(name="A") +storeB = toolStore(name="B") registerTool(storeA, getTime_tool) registerTool(storeB, getWeather_tool) @@ -382,7 +382,7 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() - - _tool_store (ToolStore) ← per-agent isolated tool registry + - _tool_store (toolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -1284,7 +1284,7 @@ The framework supports tools that modify the tool system itself at runtime. **Source:** `toolRegistry.jl:54-82` -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. Primarily useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow diff --git a/src/type.jl b/src/type.jl index 37b614f..f01b623 100644 --- a/src/type.jl +++ b/src/type.jl @@ -569,7 +569,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper 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 + _tool_store::Any # Reference to the toolStore for runtime registration end """ @@ -594,14 +594,14 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `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`) +- `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> 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) diff --git a/test/toolTest.jl b/test/toolTest.jl index 8057cf1..69e59b3 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -6,12 +6,12 @@ using YiemAgent.type # Path to the real tools directory TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") -@testset "loadTools with ToolStore" begin +@testset "loadTools with toolStore" begin # ------------------------------------------------------------------ # # 1. loadTools throws on non-existent directory # # ------------------------------------------------------------------ # - store = ToolStore(name="test1") + store = toolStore(name="test1") @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") # ------------------------------------------------------------------ # @@ -26,7 +26,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 3. loadTools loads actual tool files from src/tools/ # # ------------------------------------------------------------------ # - store2 = ToolStore(name="test2") + store2 = toolStore(name="test2") loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) @test length(loaded) == 3 @@ -102,7 +102,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 7. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # - store3 = ToolStore(name="test3") + store3 = toolStore(name="test3") registry_tools = getTools(store3) @test isempty(registry_tools) @@ -153,8 +153,8 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # ------------------------------------------------------------------ # # 9. Per-store isolation — two stores don't share tools # # ------------------------------------------------------------------ # - storeA = ToolStore(name="isolationA") - storeB = ToolStore(name="isolationB") + storeA = toolStore(name="isolationA") + storeB = toolStore(name="isolationB") registerTool(storeA, loaded["getTime"]) registerTool(storeB, loaded["getWeather"]) From 5a27630ccfcde8f446e50e356c3986f27024bf8d Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 12:15:05 +0700 Subject: [PATCH 02/23] update --- src/toolRegistry.jl | 120 +++++++++++++++++++++++++--------------- src/tools/README.md | 21 ++++--- src/tools/getTime.jl | 3 +- src/tools/getWeather.jl | 3 +- test/toolTest.jl | 45 ++++++++++++++- 5 files changed, 132 insertions(+), 60 deletions(-) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 3d786b3..ef33baa 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -22,17 +22,17 @@ struct toolStore end """ -Create a new isolated tool store. + toolStore(; name="default") -> toolStore + +Create a new empty tool store. # Keyword Arguments -- `name::String`: Identifier for this store (default: "default") +- `name::String`: Display name for logging (default: `"default"`) -# Examples +# Example ```julia -store = toolStore(name="agent1") -tools = loadTools(store, "src/tools") -registerTool(store, my_tool) -agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store) +julia> store = toolStore(name="agent1") +toolStore(OrderedDict{String, agentTool}(), "agent1") ``` """ function toolStore(; name::String="default")::toolStore @@ -40,14 +40,32 @@ function toolStore(; name::String="default")::toolStore end """ -List tool definition — lets the agent query available tools for collision detection -when creating new tools via writeTool. + listTool(store::toolStore) -> agentTool + +Return an `agentTool` definition for listing registered tools. + +Each call produces a **new** tool object that captures (closes over) +`store`. `loadTools` auto-registers one so the LLM can discover tools +at runtime. # Arguments -- `store::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, -so each agent sees only its own tools. +# Example +```julia +julia> store = toolStore(name="agent1"); + +julia> loadTools(store, "src/tools") # auto-registers listTools +[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) +[toolRegistry:agent1] Registered tool: listTools + +julia> tools = getTools(store) +OrderedDict{String, agentTool} with 4 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "writeTool" => agentTool(...) + "listTools" => agentTool(...) +``` """ function listTool(store::toolStore)::agentTool return agentTool( @@ -80,30 +98,31 @@ function listTool(store::toolStore)::agentTool end """ -Load all tool modules from a directory into a specific toolStore. +Load `.jl` tool files from `dir` into `store`, then auto-register +`listTool` so the LLM can discover available tools at runtime. -Scans `dir` for `.jl` files. Each file must define a function named -`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. +Each `.jl` file must define `function getTool()::agentTool ... end`. +Files are sorted alphabetically for deterministic registration order. +Each file is loaded into its own Julia submodule to avoid name collisions. # Arguments -- `store::toolStore`: The tool store to register tools into -- `dir::String`: Directory path to scan for `.jl` tool files +- `store`: Tool store to populate +- `dir`: Directory containing `.jl` tool files # Returns -- `OrderedDict{String, agentTool}`: All loaded tools keyed by name +- The same `store.tools` dict (modified in place) # Errors -- Throws `ArgumentError` if a tool file does not define a `getTool` function +- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()` -# Examples +# Example ```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") +julia> store = toolStore(name="agent1"); + +julia> loadTools(store, "src/tools") +[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) +[toolRegistry:agent1] Loaded tool: getTime (Time Lookup) +[toolRegistry:agent1] Registered tool: listTools OrderedDict{String, agentTool} with 3 entries: "getWeather" => agentTool(...) "getTime" => agentTool(...) @@ -168,24 +187,30 @@ function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool end end + registerTool(store, listTool(store)) return store.tools end """ -Register a single agentTool into a specific toolStore. + registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} + +Add `tool` to `store`, overwriting any existing tool with the same name. # Arguments -- `store::toolStore`: The tool store to register into -- `tool::agentTool`: The tool to register +- `store`: Tool store to modify +- `tool`: The `agentTool` to register # Returns -- `OrderedDict{String, agentTool}`: Updated tool dict for this store +- The same `store.tools` dict (modified in place) -# Examples +# Example ```julia -julia> store = toolStore(name="agent1") -julia> registerTool(store, my_tool) -[toolRegistry:agent1] Registered tool: my_tool +julia> store = toolStore(name="agent1"); + +julia> registerTool(store, listTool(store)) +[toolRegistry:agent1] Registered tool: listTools +OrderedDict{String, agentTool} with 1 entry: + "listTools" => agentTool(...) ``` """ function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool} @@ -195,22 +220,21 @@ function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, ag 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, -ordered iteration preserving registration order. +The returned dict is the **same object** stored inside `store` — mutations +to it (e.g. via `registerTool`) are visible through subsequent calls. # Arguments -- `store::toolStore`: The tool store to query +- `store`: Tool store to query # Returns - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order -# Examples +# Example ```julia -julia> getTools(store) -OrderedDict{String, agentTool} with 3 entries: - "listTools" => agentTool(...) +julia> tools = getTools(store) +OrderedDict{String, agentTool} with 2 entries: "getWeather" => agentTool(...) "getTime" => agentTool(...) ``` @@ -220,18 +244,22 @@ function getTools(store::toolStore)::OrderedDict{String, agentTool} end """ -Clear all registered tools from a specific toolStore. +Remove all tools from `store`. # Arguments -- `store::toolStore`: The tool store to clear +- `store`: Tool store to clear # Returns - `nothing` -# Examples +# Example ```julia julia> clearTools(store) [toolRegistry:agent1] Registry cleared +nothing + +julia> getTools(store) +OrderedDict{String, agentTool} with 0 entries ``` """ function clearTools(store::toolStore)::Nothing diff --git a/src/tools/README.md b/src/tools/README.md index f981e3c..3404b54 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -52,7 +52,7 @@ result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) ### Step 2: Load — `loadTools()` -Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. +Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. ```julia using YiemAgent, YiemAgent.toolRegistry @@ -60,12 +60,13 @@ using YiemAgent, YiemAgent.toolRegistry store = toolStore(name="myAgent") tools = loadTools(store, "src/tools") # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools +# Also auto-registers listTools for runtime discovery ``` **Result extraction:** ```julia all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getTime", "getWeather", "writeTool" +# Keys: "getTime", "getWeather", "writeTool", "listTools" getTime_tool = all_tools["getTime"] # Manual registration (alternative to loadTools) @@ -73,7 +74,7 @@ registerTool(store, my_tool) clearTools(store) # Clear all tools from store ``` -**Source:** `toolRegistry.jl:113-172` +**Source:** `toolRegistry.jl:126-178` --- @@ -84,7 +85,7 @@ Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up toolStore and load tools +# 1. Set up toolStore and load tools (auto-registers listTools) store = toolStore(name="myAgent") loadTools(store, "src/tools") @@ -105,6 +106,7 @@ agent = yiemAgent( store = toolStore(name="myAgent") registerTool(store, getTime_tool) registerTool(store, getWeather_tool) +registerTool(store, listTool(store)) # needed for manual registration agent = yiemAgent( tools = getTools(store), @@ -297,7 +299,7 @@ end function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} ``` -**Source:** `toolRegistry.jl:113-172` +**Source:** `toolRegistry.jl:126-178` 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 2. **Sorts** filenames alphabetically for deterministic registration order @@ -312,7 +314,8 @@ function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool ``` 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}` +6. **Registers** the tool in `store.tools` +7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime ### Why Submodules? @@ -328,7 +331,7 @@ Each tool file is loaded into its own **namespaced submodule**. This means: store1 = toolStore(name="agent1") store2 = toolStore(name="agent2") -# Load tools into specific stores +# Load tools into specific stores (auto-registers listTools) tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only @@ -1282,9 +1285,9 @@ The framework supports tools that modify the tool system itself at runtime. ### `listTool` — Discover Available Tools -**Source:** `toolRegistry.jl:54-82` +**Source:** `toolRegistry.jl:55-82` -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. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 94b7752..88098d7 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -43,7 +43,8 @@ Execute the getTime tool. Returns mock time data for the given timezone or city. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult tz = get(args, "timezone", nothing) city = get(args, "city", "") if tz !== nothing diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index e98d6a9..61639cf 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -3,7 +3,8 @@ Execute the getWeather tool. Returns mock weather data for the given city and temperature units. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult +function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult city = get(args, "city", "") units = get(args, "units", "celsius") temp = units == "fahrenheit" ? "72" : "22" diff --git a/test/toolTest.jl b/test/toolTest.jl index 69e59b3..b239f36 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -29,21 +29,22 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") store2 = toolStore(name="test2") loaded = loadTools(store2, TOOLS_DIR) @test !isempty(loaded) - @test length(loaded) == 3 + @test length(loaded) == 4 # 3 files + auto-registered listTools names = [k for k in keys(loaded)] @test "getTime" in names @test "getWeather" in names @test "writeTool" in names + @test "listTools" in names # ------------------------------------------------------------------ # # 4. loadTools returns tools sorted alphabetically by filename # - # (getTime.jl < getWeather.jl < writeTool.jl) # - # because 'T' < 'W' in ASCII # + # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end # # ------------------------------------------------------------------ # @test collect(keys(loaded))[1] == "getTime" @test collect(keys(loaded))[2] == "getWeather" @test collect(keys(loaded))[3] == "writeTool" + @test collect(keys(loaded))[4] == "listTools" # ------------------------------------------------------------------ # # 5. Verify loaded tool fields are correct # @@ -171,3 +172,41 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test isempty(getTools(storeA)) @test !isempty(getTools(storeB)) # storeB unaffected end + +@testset "listTool" begin + store = toolStore(name="test_list") + loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools + + # loadTools auto-registers listTool + @test "listTools" in keys(loaded) + + # listTool returns an agentTool, not a string or array + list_t = listTool(store) + @test list_t isa agentTool + @test list_t.name == "listTools" + @test list_t.label == "List Tools" + @test isempty(list_t.inputSchema["required"]) + + # Verify all tools appear (3 loaded + listTools = 4) + result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x) + @test result isa agentToolResult + @test result.content[1] isa textContent + @test occursin("listTools", result.content[1].text) + @test occursin("getWeather", result.content[1].text) + @test occursin("getTime", result.content[1].text) + @test occursin("writeTool", result.content[1].text) + @test result.details["count"] == 4 + + # Each listTool call creates an independent closure + storeB = toolStore(name="test_listB") + registerTool(storeB, loaded["getWeather"]) + list_tB = listTool(storeB) + + resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) + resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x) + + @test occursin("getWeather", resultA.content[1].text) + @test occursin("getWeather", resultB.content[1].text) + @test occursin("getTime", resultA.content[1].text) + @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather +end From 89885c15838f66752c0dfc1bb30d6db2a5aaa01f Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 16:43:48 +0700 Subject: [PATCH 03/23] update --- src/agentCore.jl | 65 +++++++++---------- src/api.jl | 143 ++++++++++++++++++++++++++++++++++++++++- src/tools/README.md | 18 +++--- src/type.jl | 150 +++----------------------------------------- src/utils.jl | 15 ++++- 5 files changed, 206 insertions(+), 185 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index b854d18..41fd969 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -509,41 +509,42 @@ prepareToolCall(context, msg, tc, config, abortedSignal) ``` """ function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall,immediateOutcome} - tool = get(context.tools, toolCall.name, nothing) - if tool === nothing - return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + tool = get(context.tools, toolCall.name, nothing) + if tool === nothing + return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + end + + try + # 1. prepare arguments (tool-specific transform) + prepared = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, prepared) + + #WORKING 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + before = config.beforeToolCall( + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), + signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end end - try - # 1. prepare arguments (tool-specific transform) - prepared = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, prepared) - - # 2. beforeToolCall hook — can block - if config.beforeToolCall !== nothing - before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end - end - - return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) - end + return preparedToolCall(tool, toolCall, validatedArgs) + catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + end end # ── per-call execution ────────────────────────────────────────── @@ -687,7 +688,7 @@ function finalizeExecutedToolCall( if config.afterToolCall !== nothing try 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 result = merge(result, dict(:content=>get(after,:content,result.content), diff --git a/src/api.jl b/src/api.jl index b126b19..deddf01 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,11 +5,152 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # +""" +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( + toolsFolderPath::String, + llmCall::Function, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function, + afterToolCall::Function, + # 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) + + # load tools from toolsFolderPath + toolStore = YiemAgent.toolStore(name="myagent") + loadTools(toolStore, toolsFolderPath) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore), 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 + + """ Send a message to the agent's input channel. diff --git a/src/tools/README.md b/src/tools/README.md index 3404b54..5062bed 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -633,7 +633,7 @@ function prepareToolCall( - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below 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) - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` @@ -722,10 +722,10 @@ function finalizeExecutedToolCall( ``` 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: ```julia - after = config.afterToolCall(afterCtx(...)) + after = config.afterToolCall(afterToolCallContext(...)) if after !== nothing result = merge(result, dict( :content => get(after, :content, result.content), @@ -1146,8 +1146,8 @@ The `agentEventSink` function is a user-provided callback that receives all even | `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `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 | -| `afterToolCall` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | +| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | +| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | ### `beforeToolCall` Hook @@ -1157,7 +1157,7 @@ The `agentEventSink` function is a user-provided callback that receives all even ```julia if config.beforeToolCall !== nothing before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) if signal !== nothing && signal.aborted return immediateOutcome(createErrorToolResult("Operation aborted"), true) @@ -1182,7 +1182,7 @@ end if config.afterToolCall !== nothing try 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 result = merge(result, dict( @@ -1606,8 +1606,8 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | -| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | -| `afterCtx` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | +| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | +| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | ### Event Types diff --git a/src/type.jl b/src/type.jl index f01b623..efd9ad1 100644 --- a/src/type.jl +++ b/src/type.jl @@ -13,8 +13,8 @@ # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types - agentLoopConfig, abortSignal, agentToolResult, - assistantMsgCtx, afterCtx, + agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext, + beforeToolCallResult, afterToolCallContext, # Event types toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, # Agent @@ -442,13 +442,18 @@ Context passed to the `beforeToolCall` hook. - `args::Dict{String,Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ -struct assistantMsgCtx +struct beforeToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} context::agentContext end +struct beforeToolCallResult + block::Bool + reason::String +end + """ Context passed to the `afterToolCall` hook. @@ -460,7 +465,7 @@ Context passed to the `afterToolCall` hook. - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context """ -struct afterCtx +struct afterToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} @@ -521,143 +526,6 @@ 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) diff --git a/src/utils.jl b/src/utils.jl index fb75d77..4300cb0 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,7 +1,8 @@ module utils -export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, + validateToolArguments, _userMessageToOpenAI, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -219,6 +220,16 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + """ Convert a userMessage to OpenAI message format. """ From 7c14390400d00a8a651df887551456e6cf3735ed Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 17:28:25 +0700 Subject: [PATCH 04/23] update --- src/agentCore.jl | 145 ++++++++++++++++++++++++++++++++++++++++++++++- src/api.jl | 144 +--------------------------------------------- src/utils.jl | 5 +- 3 files changed, 147 insertions(+), 147 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 41fd969..e39e7a6 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export _agent_loop, OpenAiToUserMessage +export yiemAgent, _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads @@ -9,6 +9,147 @@ using ..type, ..utils # ---------------------------------------------- 100 --------------------------------------------- # +""" +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( + toolsFolderPath::String, + llmCall::Function, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function=beforeToolCall, + afterToolCall::Function, + # 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) + + # load tools from toolsFolderPath + toolStore = YiemAgent.toolStore(name="myagent") + loadTools(toolStore, toolsFolderPath) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore), 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 + + """ Private agent loop. Runs in a background `@spawn` task. @@ -526,7 +667,7 @@ function prepareToolCall( prepared = prepareToolCallArguments(tool, toolCall) validatedArgs = validateToolArguments(tool, prepared) - #WORKING 2. beforeToolCall hook — can block + # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), diff --git a/src/api.jl b/src/api.jl index deddf01..e7e2dae 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,153 +5,11 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils, ..toolRegistry +using ..type, ..utils, ..agentCore, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # -""" -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( - toolsFolderPath::String, - llmCall::Function, - ; - systemPrompt::String="You are helpful assistant.", - model=nothing, - messages::Vector{agentMessage}=agentMessage[], - prepareContext::Function=prepareContext, - formatMsgForLLM::Function=formatMsgForLLM, - beforeToolCall::Function, - afterToolCall::Function, - # 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) - - # load tools from toolsFolderPath - toolStore = YiemAgent.toolStore(name="myagent") - loadTools(toolStore, toolsFolderPath) - - # Create struct with a placeholder task, then spawn and replace it - agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore), 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 - - - """ Send a message to the agent's input channel. diff --git a/src/utils.jl b/src/utils.jl index 4300cb0..8dca2a4 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,7 +2,8 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + beforeToolCall using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -219,7 +220,7 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} return Dict("messages" => messages) end - +#TODO function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult # final context check From ae3e432b024b35233d1d475ddb92a8625736e86e Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 17:35:56 +0700 Subject: [PATCH 05/23] update --- src/agentCore.jl | 2 +- src/utils.jl | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index e39e7a6..ae7cf38 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -104,7 +104,7 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function, + afterToolCall::Function, #WORKING # prepareNextTurn::Union{Function, Nothing}=nothing, # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, sessionId::Union{String, Nothing}=nothing, diff --git a/src/utils.jl b/src/utils.jl index 8dca2a4..3191473 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -231,6 +231,18 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::be return beforeToolCallResult(false, "N/A") end +#TODO +function afterToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + + """ Convert a userMessage to OpenAI message format. """ From 578e8f55bd893dfdc05e4a1a5893bf52590d0f0a Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:28:03 +0700 Subject: [PATCH 06/23] update --- src/tools/README.md => README_tools.md | 0 src/agentCore.jl | 55 +++++++++++--------------- src/utils.jl | 16 ++++---- 3 files changed, 30 insertions(+), 41 deletions(-) rename src/tools/README.md => README_tools.md (100%) diff --git a/src/tools/README.md b/README_tools.md similarity index 100% rename from src/tools/README.md rename to README_tools.md diff --git a/src/agentCore.jl b/src/agentCore.jl index ae7cf38..3540a17 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -56,9 +56,8 @@ mutable struct yiemAgent <: agent # High-level agent wrapper 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 + agentEventSink::Function # agent emits its status via this function + end """ Create a new yiemAgent instance with a background loop task. @@ -82,17 +81,9 @@ on `inputChannel` and `followUpChannel` channels concurrently. - `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( toolsFolderPath::String, @@ -104,14 +95,13 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function, #WORKING + afterToolCall::Function=afterToolCall, #WORKING # 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) @@ -140,7 +130,6 @@ function yiemAgent( maxRetryDelayMs, parallelToolExecute, agentEventSink, - tool_store, ) # Spawn the background loop and attach it @@ -823,28 +812,28 @@ function finalizeExecutedToolCall( signal::Union{Nothing,abortSignal}, )::finalizedOutcome - result = executed.result - isError = executed.isError + result = executed.result + isError = executed.isError - if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal - ) - if after !== nothing - result = merge(result, dict(:content=>get(after,:content,result.content), - :details=>get(after,:details,result.details), - :usage=>get(after,:usage,result.usage), - :terminate=>get(after,:terminate,result.terminate))) - isError = get(after, :isError, isError) - end - catch err - result = createErrorToolResult(sprint(showerror, err)) - isError = true - end + if config.afterToolCall !== nothing + try + after = config.afterToolCall( + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + ) + if after !== nothing + result = merge(result, dict(:content=>get(after,:content,result.content), + :details=>get(after,:details,result.details), + :usage=>get(after,:usage,result.usage), + :terminate=>get(after,:terminate,result.terminate))) + isError = get(after, :isError, isError) + end + catch err + result = createErrorToolResult(sprint(showerror, err)) + isError = true end + end - return finalizedOutcome(prep.toolCall, result, isError) + return finalizedOutcome(prep.toolCall, result, isError) end """ diff --git a/src/utils.jl b/src/utils.jl index 3191473..4a89c4d 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, - beforeToolCall + beforeToolCall, afterToolCall using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -221,7 +221,9 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end #TODO -function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal + )::beforeToolCallResult + # final context check # seek user approval via UI @@ -232,14 +234,12 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::be end #TODO -function afterToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult - # final context check +function afterToolCall(context::beforeToolCallContext, signal::abortSignal + )::Union{agentToolResult, Nothing} - # seek user approval via UI - - # other check + # modify context.result if needed and return agentToolResult - return beforeToolCallResult(false, "N/A") + return nothing end From bad14fbe7f121ae976a0bf388f68acab9b09b682 Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:42:34 +0700 Subject: [PATCH 07/23] update --- src/agentCore.jl | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 3540a17..fbe2dcc 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -46,8 +46,6 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # 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} @@ -95,13 +93,13 @@ function yiemAgent( prepareContext::Function=prepareContext, formatMsgForLLM::Function=formatMsgForLLM, beforeToolCall::Function=beforeToolCall, - afterToolCall::Function=afterToolCall, #WORKING + afterToolCall::Function=afterToolCall, # 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, + agentEventSink::Function, #WORKING ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) From 83c7770877051fe68c43830da34c3307b6d6c0bf Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 18:57:53 +0700 Subject: [PATCH 08/23] update --- README.md | 4 ++-- README_tools.md | 12 ++++++------ src/agentCore.jl | 2 +- src/api.jl | 26 +++++++++++++------------- src/type.jl | 2 +- src/utils.jl | 8 +++++++- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index aa9f04c..ed1558c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Julia framework for building agents with tool use. 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` 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 @@ -16,7 +16,7 @@ src/ ├── type.jl # Core types (messages, tools, agent state) ├── utils.jl # Message formatting, validation ├── agentCore.jl # Agent loop, tool execution pipeline -├── api.jl # Public API (run_agent, take_response, etc.) +├── api.jl # Public API (runAgent, takeResponse, etc.) └── tools/ ├── registry.jl # Tool registry (loadTools, registerTool, listTools) ├── getWeather.jl # Weather lookup tool diff --git a/README_tools.md b/README_tools.md index 5062bed..baf5b2d 100644 --- a/README_tools.md +++ b/README_tools.md @@ -151,7 +151,7 @@ result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) **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) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls @@ -382,9 +382,9 @@ The `_agent_loop()` function runs as a background `@spawn` task, created when `y ``` yiemAgent struct contains: - - inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - - outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() + - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() + - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() + - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - _tool_store (toolStore) ← per-agent isolated tool registry ``` @@ -1327,7 +1327,7 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT ``` 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" => [...])) @@ -1430,7 +1430,7 @@ LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE AGENT LOOP: SEND RESPONSE TO USER └─> 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.") ``` --- diff --git a/src/agentCore.jl b/src/agentCore.jl index fbe2dcc..1e42a48 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function, #WORKING + agentEventSink::Function=agentEventSink, #WORKING ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) diff --git a/src/api.jl b/src/api.jl index e7e2dae..524d09f 100644 --- a/src/api.jl +++ b/src/api.jl @@ -24,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task. - The same `agent` instance for chaining # Notes -- Use `take_response(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 `takeResponse(agent)` to receive the agent's response after sending a message. +- Use `followUp(agent, msg)` to send messages while the agent is still processing. # Examples ```jldoctest -julia> run_agent(agent, "Hello!") +julia> runAgent(agent, "Hello!") yiemAgent(...) ``` """ -function run_agent(agent::yiemAgent, msg) +function runAgent(agent::yiemAgent, msg) put!(agent.inputChannel, msg) return agent end @@ -50,15 +50,15 @@ Blocks until the agent sends a response. - An `assistantMessage` instance representing the agent's response # 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 ```jldoctest -julia> response = take_response(agent) +julia> response = takeResponse(agent) assistantMessage(...) ``` """ -function take_response(agent::yiemAgent) +function takeResponse(agent::yiemAgent) return take!(agent.outputChannel) end @@ -76,17 +76,17 @@ and before any tool call results are sent. - The same `agent` instance for chaining # 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. - Follow-up messages are buffered in a separate channel (capacity 32 by default). # Examples ```jldoctest -julia> follow_up(agent, "Also consider red wines") +julia> followUp(agent, "Also consider red wines") yiemAgent(...) ``` """ -function follow_up(agent::yiemAgent, msg) +function followUp(agent::yiemAgent, msg) put!(agent.followUpChannel, msg) return agent end @@ -104,16 +104,16 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`). - `nothing` # 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. - If the background task throws a `TaskFailedException`, it is rethrown. # Examples ```jldoctest -julia> stop_agent(agent) +julia> stopAgent(agent) ``` """ -function stop_agent(agent::yiemAgent) +function stopAgent(agent::yiemAgent) put!(agent.inputChannel, :shutdown) try fetch(agent._agent_loop) diff --git a/src/type.jl b/src/type.jl index efd9ad1..11a4aac 100644 --- a/src/type.jl +++ b/src/type.jl @@ -23,7 +23,7 @@ preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, agentToolCallBatch, # Functions (defined elsewhere) - run_agent, take_response, follow_up, stop_agent + runAgent, takeResponse, followUp, stopAgent using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads diff --git a/src/utils.jl b/src/utils.jl index 4a89c4d..8dc73ad 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,7 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, - beforeToolCall, afterToolCall + beforeToolCall, afterToolCall, agentEventSink using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -243,6 +243,12 @@ function afterToolCall(context::beforeToolCallContext, signal::abortSignal end +#TODO +function agentEventSink() + +end + + """ Convert a userMessage to OpenAI message format. """ From 2ad3d1df38fb267ababf1d97db5a034a9362101d Mon Sep 17 00:00:00 2001 From: narawat Date: Tue, 11 Aug 2026 19:10:37 +0700 Subject: [PATCH 09/23] update --- src/agentCore.jl | 42 ++++++------------------------------------ src/utils.jl | 2 +- 2 files changed, 7 insertions(+), 37 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 1e42a48..98c197c 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -834,39 +834,6 @@ function finalizeExecutedToolCall( return finalizedOutcome(prep.toolCall, result, isError) end -""" - emitToolExecutionEnd(finalized, emit) - -Emits the `toolExecutionEnd` event with the finalized outcome, -signalling to listeners that the tool call has completed. - -This event is part of the tool execution lifecycle: -`toolExecutionStart` → (zero or more `toolExecutionUpdate` events) → -`toolExecutionEnd`. Listeners (such as the TUI or logging systems) -use this lifecycle to track individual tool calls. The event carries -the final result so listeners have all the data they need without -requiring external state lookups. - -# Arguments -- `finalized::finalizedOutcome`: The finalized outcome to report -- `emit::Function`: Event emitter - -# Notes -- Part of a three-event lifecycle per tool call -- Carries the complete result so listeners need no external lookups - -# Examples -```julia -# Emits a single event; returns nothing -emitToolExecutionEnd(finalized, emit) -# (emit receives toolExecEndEvent("call_1", "search_wine", result, false)) -``` -""" -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) -end - # ── sequential execution ──────────────────────────────────────── """ @@ -945,7 +912,8 @@ function executeToolCallsSequential( finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -1032,13 +1000,15 @@ function executeToolCallsParallel( if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) push!(entries, finalized) else task = task() do executed = executePreparedToolCall(prep, signal, emit) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) + emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) return finalized end schedule(task) diff --git a/src/utils.jl b/src/utils.jl index 8dc73ad..37848c8 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -244,7 +244,7 @@ end #TODO -function agentEventSink() +function agentEventSink(x) end From 06d51c1ee956533fa5f18cb637ba551af7d84014 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:00:09 +0700 Subject: [PATCH 10/23] update --- src/agentCore.jl | 10 +- src/type.jl | 32 +-- test/runtest.jl | 631 +++++++++++++++++++++-------------------------- 3 files changed, 297 insertions(+), 376 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 98c197c..af7fdb4 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -5,7 +5,7 @@ export yiemAgent, _agent_loop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # @@ -85,7 +85,7 @@ on `inputChannel` and `followUpChannel` channels concurrently. """ function yiemAgent( toolsFolderPath::String, - llmCall::Function, + llmCall, ; systemPrompt::String="You are helpful assistant.", model=nothing, @@ -107,12 +107,12 @@ function yiemAgent( outputChannel = Channel(16) # load tools from toolsFolderPath - toolStore = YiemAgent.toolStore(name="myagent") - loadTools(toolStore, toolsFolderPath) + toolStore1 = toolStore(name="myagent") + loadTools(toolStore1, toolsFolderPath) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( - agentState(systemPrompt, model, getTools(toolStore), messages), + agentState(systemPrompt, model, getTools(toolStore1), messages), inputChannel, followUp, outputChannel, diff --git a/src/type.jl b/src/type.jl index 11a4aac..e1836b5 100644 --- a/src/type.jl +++ b/src/type.jl @@ -144,7 +144,7 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en ``` """ 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()) return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) end @@ -309,7 +309,7 @@ end mutable struct agentState # Mutable runtime state of an 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 # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt @@ -341,21 +341,21 @@ julia> state = agentState(systemPrompt="You are a helpful assistant") agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing) """ function agentState( - systemPrompt::String="", - model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], - modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), - tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), - messages::Vector{agentMessage}=agentMessage[], + systemPrompt::String="", + model::llmModel=llmModel{String}("", "unknown", "unknown", "", false, String[], + modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), + messages::Vector{agentMessage}=agentMessage[], ) - agentState( - systemPrompt, - model, - deepcopy(tools), - deepcopy(messages), - Vector{String}(), - false, - nothing, - ) + agentState( + systemPrompt, + model, + deepcopy(tools), + deepcopy(messages), + Vector{String}(), + false, + nothing, + ) end diff --git a/test/runtest.jl b/test/runtest.jl index b03f7c1..54d6850 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -1,232 +1,293 @@ using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, NATS, Base.Threads -using YiemAgent, GeneralUtils, msghandler +using YiemAgent, GeneralUtils - 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"]) +struct text2textInstructLLM + natsConn::NATS.Connection + topic::String + senderID::String + fileserver_url::String +end - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) +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) - 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 + reply = NATS.request(t.natsConn, t.topic, msg_envelope_json_str, timeout=180) - """ 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"]) + 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 - 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 +# 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"]) - """ 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 +# 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=1) 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] # remove 'Any' from Any[...] + +# # 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, "'" => "") - LibPQ.close(db_connection) - return result - end +# 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 - """ 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 +# function find_related_tables_for_user_question(question::String; top_row_num::Integer=20) - """ 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, "'" => "") +# metadata_df = GeneralUtils.extract_column_metadata(pg_conn_str) +# embedding_ready = GeneralUtils.generate_embedding_payloads(metadata_df) - 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 +# # use only text content +# embedding_ready_2 = [i["text_content"] for i in embedding_ready] - """ 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 +# table_embedding = get_embedding(embedding_ready_2) - """ search similar decision llm made from vectordb - """ - function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} +# _user_question_embedding = get_embedding([question]) +# user_question_embedding = Float64.(_user_question_embedding["data"][1]["embedding"]) +# user_question_similarity = [] - 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 +# for i in table_embedding["data"] +# i_data = i["embedding"] +# i_float = Float64.(i_data) +# r = 1 - Distances.cosine_dist(i_float, user_question_embedding) +# push!(user_question_similarity, r) +# 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" +# new_df = hcat(metadata_df, DataFrame(user_question_similarity = user_question_similarity)) +# sorted_df = sort(new_df, :user_question_similarity, rev=true) # sort max to min +# _top_20_tables = unique(sorted_df[1:top_row_num, :table_name]) +# top_20_tables = [i for i in _top_20_tables] # convert to Vector{String} +# g, id_to_table, table_to_id = GeneralUtils.harvest_db_undirected_schema_graph(pg_conn_str) +# table_relationship = GeneralUtils.resolve_semantic_cluster(top_20_tables, g, table_to_id, id_to_table) - embedding = _embedding[4:end] +# # tables that I should put schema in LLM context +# return table_relationship +# 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 +# function prepareContext(state::agentState)::agentContext + +# #TODO filter tools from state.tools based on user intend in user message and tool description +# filteredTools = state.tools + +# #TODO add filtered tools to the current system prompt / modify systemPrompt here +# preparedSystemPrompt = state.systemPrompt + +# #TODO add system prompt, adjust/modify and inject additional context into messages +# preparedMessages = deepcopy(state.messages) # messages that will be send to LLM + +# agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) + +# return agentCtx +# end + +# function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} + +# 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") +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"] +pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" sessionId = "0" backend_session_topic = "sommpanion.testsubject" agent_ch = Channel(8) @@ -235,159 +296,19 @@ 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") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +#WORKING load tools +text2text_llm = text2textInstructLLM(agent_conn, + config["externalservice"]["servicesloadbalancer"]["nats"], + "sender", + config["externalservice"]["fileserver"]["url"]) +agent = YiemAgent.yiemAgent( + "/home/ton/docker-apps/sommpanion/agent-backend/tools", + text2text_llm +) From 6c9640996923966de478ad1c4b646b8e11df9fe2 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:33:19 +0700 Subject: [PATCH 11/23] update --- src/agentCore.jl | 4 ++-- src/type.jl | 4 ++-- test/runtest.jl | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index af7fdb4..ff4089f 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -54,7 +54,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper 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 + agentEventSink::Function # agent emits its status via this function end """ @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function=agentEventSink, #WORKING + agentEventSink::Function=agentEventSink, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) diff --git a/src/type.jl b/src/type.jl index e1836b5..0db7744 100644 --- a/src/type.jl +++ b/src/type.jl @@ -342,8 +342,8 @@ agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agen """ function agentState( systemPrompt::String="", - model::llmModel=llmModel{String}("", "unknown", "unknown", "", false, String[], - modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), + model=llmModel("model_1", "unknown", "unknown", "", false, String[], + modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), messages::Vector{agentMessage}=agentMessage[], ) diff --git a/test/runtest.jl b/test/runtest.jl index 54d6850..18c4353 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -299,6 +299,11 @@ end +# model=YiemAgent.llmModel("model_1", "unknown", "unknown", "", false, String[], +# YiemAgent.modelCost(0.0, 0.0, 0.0, 0.0), 0, 0) + + + #WORKING load tools text2text_llm = text2textInstructLLM(agent_conn, config["externalservice"]["servicesloadbalancer"]["nats"], From 0cacb5c94abb0d597ae901ddc77445a902ec12c1 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 04:35:34 +0700 Subject: [PATCH 12/23] update --- src/agentCore.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index ff4089f..e913e49 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -37,11 +37,11 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # 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. + # A callable struct. 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 + llmCall # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) beforeToolCall::Union{Function, Nothing} From 4e592173a655467dc1ed04804984c241a7df2ba1 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 14:37:11 +0700 Subject: [PATCH 13/23] update --- etc.jl | 79 +++++++++++- src/agentCore.jl | 26 ++-- test/runtest.jl | 327 ----------------------------------------------- 3 files changed, 95 insertions(+), 337 deletions(-) diff --git a/etc.jl b/etc.jl index 4ce0950..7d92ff2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,2 +1,77 @@ -# ── executeToolCalls() Julia pseudo code ────────────────────────── -# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit +i am not sure that's the case. see my NATS message log: + +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "new user msg" +┌ Info: debug +└ payload = "_process_message 3" +┌ Info: debug +└ payload = "_process_message 5" +┌ Info: debug +└ payload = "_process_message 6" +┌ Info: debug +└ payload = "_process_message 7" + + +my NATS receiver report the following for a long time +┌ Info: debug +└ payload = "new user msg" + +untill I Ctrl + d so shutdown the process then i got the following report +┌ Info: debug +└ payload = "_process_message 3" +┌ Info: debug +└ payload = "_process_message 5" +┌ Info: debug +└ payload = "_process_message 6" +┌ Info: debug +└ payload = "_process_message 7" + + + + + + + + + +my point is if _process_message() actually run then this code in _process_message() +"raw_msg = take!(agent.inputChannel)" +should take the new msg message out of agent.inputChannel and there should be only one debug message showing +┌ Info: debug +└ payload = "new user msg" + +before reaching error("debug marker") + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/agentCore.jl b/src/agentCore.jl index e913e49..1526ce2 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -54,7 +54,7 @@ mutable struct yiemAgent <: agent # High-level agent wrapper 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 + agentEventSink # agent emits its status via this function end """ @@ -99,7 +99,7 @@ function yiemAgent( sessionId::Union{String, Nothing}=nothing, maxRetryDelayMs::Union{Int64, Nothing}=nothing, parallelToolExecute::Bool=false, - agentEventSink::Function=agentEventSink, + agentEventSink=agentEventSink, ) # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) inputChannel = Channel(16) @@ -209,7 +209,8 @@ function _agent_loop(agent::yiemAgent) if isready(agent.inputChannel) # message will be taken in _process_message() - msg = fetch!(agent.inputChannel) + msg = fetch(agent.inputChannel) + agent.agentEventSink("new user msg") else yield() end @@ -235,9 +236,11 @@ function _agent_loop(agent::yiemAgent) # start _process_message loop if agent._state.activeRun == false + agent.agentEventSink("_agent_loop 2") # Dispatch message through the processing pipeline - processingTask = Threads.@spawn _process_message(agent) + processingTask = Threads.@spawn _process_message(agent) agent._state.activeRun = true + agent.agentEventSink("_agent_loop 3") end # during agent runs, check followUp message after _process_message() is done @@ -301,6 +304,7 @@ julia> # Currently returns a placeholder echo response ``` """ function _process_message(agent::yiemAgent)::assistantMessage + agent.agentEventSink("_process_message 1") # loop until llmCall() response didn't use tool calls final_response = nothing while true @@ -313,21 +317,26 @@ function _process_message(agent::yiemAgent)::assistantMessage Dict( "type" => "image_url", "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") - ) + ), ] - ), + ) """ # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(agent.inputChannel) + agent.agentEventSink("_process_message 2") raw_msg = take!(agent.inputChannel) + agent.agentEventSink("_process_message 3") if raw_msg === :shutdown + agent.agentEventSink("_process_message 4") # Re-emit shutdown signal for the loop to handle put!(agent.inputChannel, :shutdown) break end + agent.agentEventSink("_process_message 5") user_msg = OpenAiToUserMessage(raw_msg) push!(agent._state.messages, user_msg) + agent.agentEventSink("_process_message 6") end # call agent.prepareContext() @@ -336,10 +345,11 @@ function _process_message(agent::yiemAgent)::assistantMessage # Call agent.formatMsgForLLM(agent._state) to format for LLM formatted_messages = agent.formatMsgForLLM(preparedContext) + agent.agentEventSink("_process_message 7") # Call llmCall() (blocking — the task waits here) + error("debug marker") response = agent.llmCall(formatted_messages) - - error(5555555) + agent.agentEventSink("_process_message 8") #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) has_tool_calls = false diff --git a/test/runtest.jl b/test/runtest.jl index 18c4353..e69de29 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -1,327 +0,0 @@ -using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, - NATS, Base.Threads -using YiemAgent, GeneralUtils - -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]["message"]["content"] - return llm_response -end - - -# 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=1) 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] # remove 'Any' from Any[...] - -# # 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 - -# function find_related_tables_for_user_question(question::String; top_row_num::Integer=20) - -# metadata_df = GeneralUtils.extract_column_metadata(pg_conn_str) -# embedding_ready = GeneralUtils.generate_embedding_payloads(metadata_df) - -# # use only text content -# embedding_ready_2 = [i["text_content"] for i in embedding_ready] - -# table_embedding = get_embedding(embedding_ready_2) - -# _user_question_embedding = get_embedding([question]) -# user_question_embedding = Float64.(_user_question_embedding["data"][1]["embedding"]) -# user_question_similarity = [] - -# for i in table_embedding["data"] -# i_data = i["embedding"] -# i_float = Float64.(i_data) -# r = 1 - Distances.cosine_dist(i_float, user_question_embedding) -# push!(user_question_similarity, r) -# end - -# new_df = hcat(metadata_df, DataFrame(user_question_similarity = user_question_similarity)) -# sorted_df = sort(new_df, :user_question_similarity, rev=true) # sort max to min -# _top_20_tables = unique(sorted_df[1:top_row_num, :table_name]) -# top_20_tables = [i for i in _top_20_tables] # convert to Vector{String} -# g, id_to_table, table_to_id = GeneralUtils.harvest_db_undirected_schema_graph(pg_conn_str) -# table_relationship = GeneralUtils.resolve_semantic_cluster(top_20_tables, g, table_to_id, id_to_table) - -# # tables that I should put schema in LLM context -# return table_relationship -# end - - -# function prepareContext(state::agentState)::agentContext - -# #TODO filter tools from state.tools based on user intend in user message and tool description -# filteredTools = state.tools - -# #TODO add filtered tools to the current system prompt / modify systemPrompt here -# preparedSystemPrompt = state.systemPrompt - -# #TODO add system prompt, adjust/modify and inject additional context into messages -# preparedMessages = deepcopy(state.messages) # messages that will be send to LLM - -# agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) - -# return agentCtx -# end - -# function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} - -# end - - -config = JSON.parsefile("./appconfig.json") -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"] -pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password" -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 - - - -# model=YiemAgent.llmModel("model_1", "unknown", "unknown", "", false, String[], -# YiemAgent.modelCost(0.0, 0.0, 0.0, 0.0), 0, 0) - - - -#WORKING load tools -text2text_llm = text2textInstructLLM(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - "sender", - config["externalservice"]["fileserver"]["url"]) - -agent = YiemAgent.yiemAgent( - "/home/ton/docker-apps/sommpanion/agent-backend/tools", - text2text_llm -) - - - - - - - - - - - From 77adeb3a6b59b743aad71bcc7c3c75b2dd6f630b Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 20:10:51 +0700 Subject: [PATCH 14/23] update --- README_tools.md | 8 +-- src/agentCore.jl | 147 +++++++++++++++++++++++++++++------------------ src/api.jl | 2 +- src/utils.jl | 22 +++++++ 4 files changed, 119 insertions(+), 60 deletions(-) diff --git a/README_tools.md b/README_tools.md index baf5b2d..de61209 100644 --- a/README_tools.md +++ b/README_tools.md @@ -152,7 +152,7 @@ result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) **Via agent loop (production):** ``` user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agent_loop detects message → @spawn _process_message(agent) + → _agentLoop detects message → @spawn _process_message(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) @@ -376,7 +376,7 @@ This ensures that `yiemAgent` instances with different `tool_store` references o **Source:** `agentCore.jl:35-145` -The `_agent_loop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. +The `_agentLoop()` function runs as a background `@spawn` task, created when `yiemAgent` is constructed. ### Channel Architecture @@ -404,7 +404,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`): ### Loop Logic (simplified) ```julia -function _agent_loop(agent::yiemAgent) +function _agentLoop(agent::yiemAgent) while true # 1. Wait for message from inputChannel (blocking poll) msg = fetch!(agent.inputChannel) # agentCore.jl:84 @@ -1332,7 +1332,7 @@ USER SENDS MESSAGE 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) ── _process_message ────────────────────────────────────────────── diff --git a/src/agentCore.jl b/src/agentCore.jl index 1526ce2..e438904 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export yiemAgent, _agent_loop, OpenAiToUserMessage +export yiemAgent, _agentLoop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads @@ -27,10 +27,10 @@ mutable struct yiemAgent <: agent # High-level agent wrapper # and all followUp messages. outputChannel::Channel - _agent_loop::Union{Task, Nothing} # agent loop running in the background + _agentLoop::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. + # reorder, ...) for a single LLM call in _processMessage()'s loop. # returns new Vector{agentMessage} prepareContext::Union{Function, Nothing} @@ -131,7 +131,7 @@ function yiemAgent( ) # Spawn the background loop and attach it - agent._agent_loop = @spawn _agent_loop(agent) + agent._agentLoop = @spawn _agentLoop(agent) return agent end @@ -141,7 +141,7 @@ end Private agent loop. Runs in a background `@spawn` task. Waits on `inputChannel` and `followUpChannel`, processing whichever has a message first. -On each iteration, dispatches the message through `_process_message` and sends the result +On each iteration, dispatches the message through `_processMessage` and sends the result to `outputChannel`. Exits on `:shutdown` signal. # Arguments @@ -160,7 +160,8 @@ to `outputChannel`. Exits on `:shutdown` signal. julia> # Called automatically by yiemAgent constructor ``` """ -function _agent_loop(agent::yiemAgent) +function _agentLoop(agent::yiemAgent) + processMessageInputCh = Channel(32) try processingTask = nothing @@ -208,8 +209,8 @@ function _agent_loop(agent::yiemAgent) while msg === nothing if isready(agent.inputChannel) - # message will be taken in _process_message() - msg = fetch(agent.inputChannel) + # message will be taken in _processMessage() + msg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else yield() @@ -232,29 +233,50 @@ function _agent_loop(agent::yiemAgent) #TODO make sure every running tools ended properly break + else + agent.agentEventSink("_agentLoop push 1") + put!(processMessageInputCh, msg) #WORKING + agent.agentEventSink("_agentLoop push 2") end - # start _process_message loop + # start _processMessage loop if agent._state.activeRun == false - agent.agentEventSink("_agent_loop 2") + agent.agentEventSink("_agentLoop 2") # Dispatch message through the processing pipeline - processingTask = Threads.@spawn _process_message(agent) + processingTask = @spawn _processMessage( + processMessageInputCh, + agent.agentEventSink, + agent._state.messages, + agent._state.systemPrompt, + agent._state.tools, + agent.prepareContext, + agent.formatMsgForLLM, + agent.llmCall, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute, + ) agent._state.activeRun = true - agent.agentEventSink("_agent_loop 3") + agent.agentEventSink("_agentLoop 3") end - - # during agent runs, check followUp message after _process_message() is done + + # during agent runs, check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == false + agent.agentEventSink("_agentLoop 4") # if followUp message available, add them all to agent.inputChannel if isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 4-1") while isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 4-2") followMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followMsg) end end + agent.agentEventSink("_agentLoop 4-3") continue # continue to process user message in the next loop elseif typeof(processingTask) == Task && istaskdone(processingTask) == true + agent.agentEventSink("_agentLoop 5") # if agent runs is done but followUpChannel has messages, discard all message in it. # when agent work is done it should not accept follow up msg. # user should put new message in inputChannel instead @@ -268,6 +290,7 @@ function _agent_loop(agent::yiemAgent) agent._state.activeRun = false # reset processingTask = nothing # reset end + agent.agentEventSink("_agentLoop 6") end catch e # On any error, send error response and exit the loop @@ -303,13 +326,25 @@ should be implemented. Currently a placeholder that echoes back the received mes julia> # Currently returns a placeholder echo response ``` """ -function _process_message(agent::yiemAgent)::assistantMessage - agent.agentEventSink("_process_message 1") +function _processMessage( + inputChannel::Channel, + agentEventSink, + messages::Vector{agentMessage}, + systemPrompt::String, + tools::OrderedDict{String, agentTool}, + prepareContext::Function, + formatMessagesForLLM::Function, + llmCall, + beforeToolCall::Union{Function, Nothing}, + afterToolCall::Union{Function, Nothing}, + parallelToolExecute::Bool, +)::assistantMessage + agentEventSink("_processMessage 1") # loop until llmCall() response didn't use tool calls final_response = nothing while true - """ example message in agent.inputChannel + """ example message in inputChannel Dict( "role" => "user", "content" => [ @@ -323,42 +358,44 @@ function _process_message(agent::yiemAgent)::assistantMessage """ # Drain inputChannel and convert OpenAI-format messages to userMessage type - while isready(agent.inputChannel) - agent.agentEventSink("_process_message 2") - raw_msg = take!(agent.inputChannel) - agent.agentEventSink("_process_message 3") + while isready(inputChannel) + agentEventSink("_processMessage 2") + raw_msg = take!(inputChannel) + agentEventSink("_processMessage 3") if raw_msg === :shutdown - agent.agentEventSink("_process_message 4") + agentEventSink("_processMessage 4") # Re-emit shutdown signal for the loop to handle - put!(agent.inputChannel, :shutdown) + put!(inputChannel, :shutdown) break end - agent.agentEventSink("_process_message 5") + agentEventSink("_processMessage 5") user_msg = OpenAiToUserMessage(raw_msg) - push!(agent._state.messages, user_msg) - agent.agentEventSink("_process_message 6") + push!(messages, user_msg) + agentEventSink("_processMessage 6") end + agentEventSink("_processMessage 7") + # call prepareContext() + state = agentState(systemPrompt, nothing, tools, messages) + agentEventSink("_processMessage 8") + preparedContext = prepareContext(state) + agentEventSink("_processMessage 8") + # Call formatMessagesForLLM() to format for LLM + formattedMessages = formatMessagesForLLM(preparedContext) - # call agent.prepareContext() - preparedContext = agent.prepareContext(agent._state) - - # Call agent.formatMsgForLLM(agent._state) to format for LLM - formatted_messages = agent.formatMsgForLLM(preparedContext) - - agent.agentEventSink("_process_message 7") + agentEventSink("_processMessage 10") # Call llmCall() (blocking — the task waits here) + response = llmCall(formattedMessages) + agentEventSink(response) + agentEventSink("_processMessage 11") error("debug marker") - response = agent.llmCall(formatted_messages) - agent.agentEventSink("_process_message 8") - - #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) - has_tool_calls = false - tool_call_list = agentToolCall[] + # Check if LLM used tool calls (inspect content for tool_call blocks) + hasToolCalls = false + toolCallList = agentToolCall[] for content_block in response.content if content_block isa Dict if get(content_block, :type, "") == "tool_calls" - has_tool_calls = true + hasToolCalls = true for tc_data in get(content_block, :tool_calls, []) tc = agentToolCall( type="function", @@ -366,10 +403,10 @@ function _process_message(agent::yiemAgent)::assistantMessage name=get(tc_data, :function, Dict{String,Any}())[:name], arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], ) - push!(tool_call_list, tc) + push!(toolCallList, tc) end elseif get(content_block, :type, "") == "tool_call" - has_tool_calls = true + hasToolCalls = true tc_data = content_block tc = agentToolCall( type="function", @@ -377,35 +414,35 @@ function _process_message(agent::yiemAgent)::assistantMessage name=get(tc_data, :name, ""), arguments=get(tc_data, :arguments, Dict{String,Any}()), ) - push!(tool_call_list, tc) + push!(toolCallList, tc) end end end - if has_tool_calls && length(tool_call_list) > 0 + if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls context = agentContext( - agent._state.systemPrompt, - agent._state.messages, - agent._state.tools, + systemPrompt, + messages, + tools, ) config = agentLoopConfig( - agent._state.tools, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute ? "parallel" : "sequential", + tools, + beforeToolCall, + afterToolCall, + parallelToolExecute ? "parallel" : "sequential", ) signal = nothing - emit = agent.agentEventSink + emit = agentEventSink # call executeToolCalls() - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + batch = executeToolCalls(context, response, toolCallList, config, signal, emit) - # save toolResults to agent._state.messages + # save toolResults to messages for tool_result in batch.messages - push!(agent._state.messages, tool_result) + push!(messages, tool_result) end if batch.terminate diff --git a/src/api.jl b/src/api.jl index 524d09f..50d6dbd 100644 --- a/src/api.jl +++ b/src/api.jl @@ -116,7 +116,7 @@ julia> stopAgent(agent) function stopAgent(agent::yiemAgent) put!(agent.inputChannel, :shutdown) try - fetch(agent._agent_loop) + fetch(agent._agentLoop) catch e if e isa TaskFailedException rethrow(e) diff --git a/src/utils.jl b/src/utils.jl index 37848c8..67c4a1b 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -192,6 +192,22 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} ] ), ], + "tools"=> [ + Dict( + "type" => "function", + "function" => Dict( + "name" => "get_weather", + "description" => "Get current weather", + "parameters" => Dict( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string") + ), + "required" => ["city"] + ) + ) + ) + ], "temperature" => 0.7 ) """ @@ -217,6 +233,12 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end end + #WORKING convert ctx.tools into openai's tools format. + # ctx.tools has the following format + # tools = OrderedDict{String, YiemAgent.type.agentTool}("getTime" => YiemAgent.type.agentTool("getTime", "Time Lookup", "Get current local time for a timezone or city.", Dict{String, Any}("properties" => Dict("city" => Dict("type" => "string", "description" => "City name as fallback"), "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'")), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry._tool_getTime.executeTool, nothing, YiemAgent.toolRegistry._tool_getTime.validateRequiredArgs, false), "getWeather" => YiemAgent.type.agentTool("getWeather", "Weather Lookup", "Fetch current weather and forecast for a given city.", Dict{String, Any}("properties" => Dict{String, Dict{String}}("units" => Dict{String, Any}("default" => "celsius", "type" => "string", "description" => "Temperature scale", "enum" => ["celsius", "fahrenheit"]), "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'")), "required" => ["city"], "type" => "object"), YiemAgent.toolRegistry._tool_getWeather.executeTool, nothing, nothing, false), "listTools" => YiemAgent.type.agentTool("listTools", "List Tools", "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", Dict{String, Any}("properties" => Dict{String, Any}(), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry.var"#listTool##0#listTool##1"{YiemAgent.toolRegistry.toolStore}(YiemAgent.toolRegistry.toolStore(OrderedDict{String, YiemAgent.type.agentTool}(#= circular reference @-4 =#), "myagent")), nothing, nothing, false)) + + + return Dict("messages" => messages) end From 90fb97a4e7227c6e0cc232eff2ba39c09a1de978 Mon Sep 17 00:00:00 2001 From: narawat Date: Wed, 12 Aug 2026 23:47:10 +0700 Subject: [PATCH 15/23] update --- src/agentCore.jl | 45 ++++++++++++++++------------- src/type.jl | 4 +-- src/utils.jl | 73 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index e438904..3d3d357 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -3,7 +3,7 @@ module agentCore export yiemAgent, _agentLoop, OpenAiToUserMessage using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Base.Threads + DataFrames, Base.Threads, NATS using GeneralUtils using ..type, ..utils, ..toolRegistry @@ -342,20 +342,23 @@ function _processMessage( agentEventSink("_processMessage 1") # loop until llmCall() response didn't use tool calls final_response = nothing + + """ example message in inputChannel + Dict( + "role" => "user", + "content" => [ + Dict("type" => "text", "text" => "Do you have something similar to the one in the image?"), + Dict( + "type" => "image_url", + "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") + ), + ] + ) + """ + while true - """ example message in inputChannel - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you have something similar to the one in the image?"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") - ), - ] - ) - """ + # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) @@ -377,22 +380,26 @@ function _processMessage( # call prepareContext() state = agentState(systemPrompt, nothing, tools, messages) agentEventSink("_processMessage 8") - preparedContext = prepareContext(state) + preparedContext = prepareContext(state, agentEventSink) agentEventSink("_processMessage 8") # Call formatMessagesForLLM() to format for LLM - formattedMessages = formatMessagesForLLM(preparedContext) + formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) agentEventSink("_processMessage 10") # Call llmCall() (blocking — the task waits here) response = llmCall(formattedMessages) - agentEventSink(response) + + """ response example + response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + """ + + agentEventSink(string(response)) agentEventSink("_processMessage 11") - error("debug marker") - # Check if LLM used tool calls (inspect content for tool_call blocks) + #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) hasToolCalls = false toolCallList = agentToolCall[] - for content_block in response.content + for content_block in response.content # extract response if content_block isa Dict if get(content_block, :type, "") == "tool_calls" hasToolCalls = true diff --git a/src/type.jl b/src/type.jl index 0db7744..9b8d9bd 100644 --- a/src/type.jl +++ b/src/type.jl @@ -291,7 +291,7 @@ Snapshot of the agent's conversation context. # Arguments - `systemPrompt::String`: System prompt for the agent - `messages::Vector{agentMessage}`: Conversation messages -- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup +- `tools::Union{OrderedDict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup # Returns - A new `agentContext` instance @@ -299,7 +299,7 @@ Snapshot of the agent's conversation context. struct agentContext # Snapshot of the agent's conversation context systemPrompt::String # System prompt for the agent messages::Vector{agentMessage} # Conversation messages - tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name + tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name end diff --git a/src/utils.jl b/src/utils.jl index 67c4a1b..18e5147 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -2,10 +2,10 @@ module utils export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, beforeToolCall, afterToolCall, agentEventSink -using UUIDs, Dates, DataStructures, HTTP, JSON +using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils using ..type @@ -75,7 +75,6 @@ function availableWineToText(vecd::Vector)::String end - """ prepareContext(state::agentState) -> agentContext @@ -110,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState)::agentContext +function prepareContext(state::agentState, agentEventSink)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools @@ -157,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [ ]) ``` """ -function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} """ openai message format example msg = Dict( @@ -185,18 +184,12 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} Dict("type" => "text", "text" => "let me check."), ] ), - Dict( - "role" => "toolResult", - "content" => [ - Dict("type" => "text", "text" => "name: Chateau Montelena ..."), - ] - ), ], "tools"=> [ Dict( "type" => "function", "function" => Dict( - "name" => "get_weather", + "name" => "getWeather", "description" => "Get current weather", "parameters" => Dict( "type" => "object", @@ -212,8 +205,10 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} ) """ + openaiReadyMsg = Dict{String, Any}() + # openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL" messages = Vector{Dict{String, Any}}() - + agentEventSink("formatMsgForLLM 1") # System prompt as system message if !isempty(ctx.systemPrompt) push!(messages, Dict( @@ -221,7 +216,7 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)] )) end - + agentEventSink("formatMsgForLLM 2") # Conversation messages for msg in ctx.messages if msg isa userMessage @@ -232,14 +227,18 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} push!(messages, _toolResultMessageToOpenAI(msg)) end end + agentEventSink("formatMsgForLLM 3") + # Convert ctx.tools into OpenAI tools format + tools_array = _toolsToOpenAI(ctx.tools, agentEventSink) + agentEventSink("formatMsgForLLM 4") + openaiReadyMsg["messages"] = messages + openaiReadyMsg["temperature"] = 0.7 - #WORKING convert ctx.tools into openai's tools format. - # ctx.tools has the following format - # tools = OrderedDict{String, YiemAgent.type.agentTool}("getTime" => YiemAgent.type.agentTool("getTime", "Time Lookup", "Get current local time for a timezone or city.", Dict{String, Any}("properties" => Dict("city" => Dict("type" => "string", "description" => "City name as fallback"), "timezone" => Dict("type" => "string", "description" => "IANA timezone, e.g. 'America/New_York'")), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry._tool_getTime.executeTool, nothing, YiemAgent.toolRegistry._tool_getTime.validateRequiredArgs, false), "getWeather" => YiemAgent.type.agentTool("getWeather", "Weather Lookup", "Fetch current weather and forecast for a given city.", Dict{String, Any}("properties" => Dict{String, Dict{String}}("units" => Dict{String, Any}("default" => "celsius", "type" => "string", "description" => "Temperature scale", "enum" => ["celsius", "fahrenheit"]), "city" => Dict("type" => "string", "description" => "City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'")), "required" => ["city"], "type" => "object"), YiemAgent.toolRegistry._tool_getWeather.executeTool, nothing, nothing, false), "listTools" => YiemAgent.type.agentTool("listTools", "List Tools", "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.", Dict{String, Any}("properties" => Dict{String, Any}(), "required" => Any[], "type" => "object"), YiemAgent.toolRegistry.var"#listTool##0#listTool##1"{YiemAgent.toolRegistry.toolStore}(YiemAgent.toolRegistry.toolStore(OrderedDict{String, YiemAgent.type.agentTool}(#= circular reference @-4 =#), "myagent")), nothing, nothing, false)) - + if !isempty(tools_array) + openaiReadyMsg["tools"] = tools_array + end - - return Dict("messages" => messages) + return openaiReadyMsg end #TODO @@ -331,6 +330,40 @@ function _messageContentToBlocks(contents::Vector{messageContent})::Vector{Dict{ end +""" + _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}) -> Vector{Dict{String, Any}} + +Convert an OrderedDict of agentTool definitions into OpenAI function tool format. + +Returns an empty vector when `tools` is `nothing` or empty. + +# Examples +```julia +_toolsToOpenAI(nothing) # => Dict{String, Any}[] +_toolsToOpenAI(tools) # => [Dict("type" => "function", "function" => Dict("name" => "getWeather", ...))] +``` +""" +function _toolsToOpenAI(tools::Union{OrderedDict{String, agentTool}, Nothing}, agentEventSink)::Vector{Dict{String, Any}} + tools_array = Vector{Dict{String, Any}}() + agentEventSink("_toolsToOpenAI 1") + agentEventSink(string(typeof(tools))) + if tools !== nothing + for (_, tool) in tools + push!(tools_array, Dict( + "type" => "function", + "function" => Dict( + "name" => tool.name, + "description" => tool.description, + "parameters" => tool.inputSchema + ) + )) + end + end + agentEventSink("_toolsToOpenAI 2") + return tools_array +end + + """ validateRequiredArgs(args::Dict{String,Any}, inputSchema::Dict{String,Any}) -> Union{Nothing,String} From 510cf6126c87f773c7ca078549aa6137187fd2fd Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 13 Aug 2026 05:56:08 +0700 Subject: [PATCH 16/23] update --- src/agentCore.jl | 144 +++++++++++----- test/_extractToolCalls.jl | 343 ++++++++++++++++++++++++++++++++++++++ test/runtest.jl | 5 + 3 files changed, 452 insertions(+), 40 deletions(-) create mode 100644 test/_extractToolCalls.jl diff --git a/src/agentCore.jl b/src/agentCore.jl index 3d3d357..9f28e77 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,6 +1,6 @@ module agentCore -export yiemAgent, _agentLoop, OpenAiToUserMessage +export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads, NATS @@ -235,7 +235,7 @@ function _agentLoop(agent::yiemAgent) break else agent.agentEventSink("_agentLoop push 1") - put!(processMessageInputCh, msg) #WORKING + put!(processMessageInputCh, msg) agent.agentEventSink("_agentLoop push 2") end @@ -357,9 +357,6 @@ function _processMessage( """ while true - - - # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) agentEventSink("_processMessage 2") @@ -385,47 +382,19 @@ function _processMessage( # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - agentEventSink("_processMessage 10") - # Call llmCall() (blocking — the task waits here) - response = llmCall(formattedMessages) + agentEventSink("_processMessage 10") """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ - + response = llmCall(formattedMessages) agentEventSink(string(response)) + agentEventSink("_processMessage 11") - #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) - hasToolCalls = false - toolCallList = agentToolCall[] - - for content_block in response.content # extract response - if content_block isa Dict - if get(content_block, :type, "") == "tool_calls" - hasToolCalls = true - for tc_data in get(content_block, :tool_calls, []) - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :function, Dict{String,Any}())[:name], - arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], - ) - push!(toolCallList, tc) - end - elseif get(content_block, :type, "") == "tool_call" - hasToolCalls = true - tc_data = content_block - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :name, ""), - arguments=get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(toolCallList, tc) - end - end - end - + # Extract tool calls from LLM response content blocks + hasToolCalls, toolCallList = _extractToolCalls(response) + agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") + error("debug marker") if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls context = agentContext( @@ -560,6 +529,101 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage ) end +""" + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}} + +Extracts tool calls from the LLM response. Supports two response formats: + +1. **Message format** (e.g. from LMStudio.jl / vLLM): + `response["message"]["tool_calls"]` — array of tool call objects with + `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, + and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. + +2. **Content blocks format** (e.g. from OpenAI API): + `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` + contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` + have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). + +Returns `(hasToolCalls, toolCallList)` where `hasToolCalls` is `true` if any +tool calls were found, and `toolCallList` is a vector of `agentToolCall` structs. + +# Arguments +- `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) + +# Returns +- `Tuple{Bool, Vector{agentToolCall}}`: `(hasToolCalls, toolCallList)` +""" +function _extractToolCalls(response) + hasToolCalls = false + toolCallList = agentToolCall[] + + # Helper: parse args (JSON string -> Dict, or pass through) + parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) : + raw isa String ? JSON.parse(raw) : Dict{String,Any}() + + # Helper: build agentToolCall (positional) + make_tc(tc_data, default_id=string(uuid4())) = begin + func = get(tc_data, "function", Dict{String,Any}()) + args = parse_args(get(func, "arguments", "{}")) + name = get(func, "name", "") + id_val = get(tc_data, "id", default_id) + agentToolCall("function", id_val, name, args) + end + + # Format 1: response["message"]["tool_calls"] (LMStudio.jl / vLLM style) + msg = get(response, "message", nothing) + if msg !== nothing && msg isa AbstractDict + tc_array = get(msg, "tool_calls", nothing) + if tc_array !== nothing && tc_array isa Vector + for tc_data in tc_array + if tc_data isa AbstractDict + hasToolCalls = true + push!(toolCallList, make_tc(tc_data)) + end + end + end + end + + # Format 2: response.content blocks (OpenAI API style) + if !hasToolCalls + content = nothing + if response isa AbstractDict + content = get(response, "content", nothing) + else + try + content = getfield(response, :content) + catch + content = nothing + end + end + if content isa Vector + for content_block in content + if content_block isa AbstractDict + if get(content_block, "type", "") == "tool_calls" + hasToolCalls = true + for tc_data in get(content_block, "tool_calls", []) + if tc_data isa AbstractDict + push!(toolCallList, make_tc(tc_data)) + end + end + elseif get(content_block, "type", "") == "tool_call" + hasToolCalls = true + tc = agentToolCall( + "function", + get(content_block, "id", string(uuid4())), + get(content_block, "name", ""), + get(content_block, "arguments", Dict{String,Any}()), + ) + push!(toolCallList, tc) + end + end + end + end + end + + return hasToolCalls, toolCallList +end + """ shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl new file mode 100644 index 0000000..3d97fee --- /dev/null +++ b/test/_extractToolCalls.jl @@ -0,0 +1,343 @@ +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 = _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].arguments["city"] == "Bangkok, Thailand" + 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 = _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" + 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 = _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}() + 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 = _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)" 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 = _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 + + # ----------------------------------------------------------- # + # 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 = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Paris" + 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 = _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 + + # ----------------------------------------------------------- # + # Format 2 via struct-like object (no .content field) # + # ----------------------------------------------------------- # + + @testset "no tool calls found" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Hello world."), + ], + ) + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "empty message" begin + response = Dict{String,Any}() + has_toolcalls, tc_list = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 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 = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + 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 = _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 = _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 = _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 = _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 = _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 + +end diff --git a/test/runtest.jl b/test/runtest.jl index e69de29..6f086e5 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -0,0 +1,5 @@ +using Test +using YiemAgent + +include("toolTest.jl") +include("_extractToolCalls.jl") From b8067c2d3379a0d0c9c5a85adf858b8cf8cb4163 Mon Sep 17 00:00:00 2001 From: narawat Date: Thu, 13 Aug 2026 18:14:46 +0700 Subject: [PATCH 17/23] update --- src/agentCore.jl | 400 ++++++++++++++++++++++++-------------- src/type.jl | 5 +- test/_extractToolCalls.jl | 351 ++++++++++++++++++++++++++++++--- 3 files changed, 582 insertions(+), 174 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 9f28e77..d6e408c 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -392,30 +392,29 @@ function _processMessage( agentEventSink("_processMessage 11") # Extract tool calls from LLM response content blocks - hasToolCalls, toolCallList = _extractToolCalls(response) + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") - error("debug marker") + if hasToolCalls && length(toolCallList) > 0 - # Build context and config for executeToolCalls - context = agentContext( - systemPrompt, - messages, - tools, - ) + #WORKING Build context and config for executeToolCalls config = agentLoopConfig( - tools, beforeToolCall, afterToolCall, parallelToolExecute ? "parallel" : "sequential", ) - signal = nothing - emit = agentEventSink + signal = abortSignal(false) + agentEventSink("_processMessage 12") # call executeToolCalls() - batch = executeToolCalls(context, response, toolCallList, config, signal, emit) + batch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, + agentEventSink) + + + agentEventSink("_processMessage 13") + error("debug marker") # save toolResults to messages for tool_result in batch.messages push!(messages, tool_result) @@ -438,9 +437,9 @@ function _processMessage( final_response = assistantMessage( role="assistant", content=final_content, - api=response.api, - model=response.model, - usage=response.usage, + api=assistant_msg.api, + model=assistant_msg.model, + usage=assistant_msg.usage, stopReason="tool_use_terminated", errorMessage=if any(x -> x.isError, batch.messages) "One or more tool calls failed" @@ -453,7 +452,7 @@ function _processMessage( end else # LLM did not use tool calls — this is the final response - final_response = response + final_response = assistant_msg break end end @@ -488,7 +487,7 @@ agentToolResult([textContent("text", "Tool not found")], Dict{Any,Any}()) ``` """ function createErrorToolResult(msg::String)::agentToolResult - return agentToolResult([textContent("text", msg)], dict{any,any}()) + return agentToolResult([textContent("text", msg)], dict{any,any}()) end """ @@ -530,9 +529,10 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage end """ - _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}} + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, assistantMessage} -Extracts tool calls from the LLM response. Supports two response formats: +Extracts tool calls from the LLM response and constructs an `assistantMessage`. +Supports two response formats: 1. **Message format** (e.g. from LMStudio.jl / vLLM): `response["message"]["tool_calls"]` — array of tool call objects with @@ -544,14 +544,19 @@ Extracts tool calls from the LLM response. Supports two response formats: contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). -Returns `(hasToolCalls, toolCallList)` where `hasToolCalls` is `true` if any -tool calls were found, and `toolCallList` is a vector of `agentToolCall` structs. +The `assistantMessage` is constructed from: +- `reasoning_content` (string) → added as a `textContent` block +- `response.content` blocks (text/ reasoning) → added to content +- Top-level `api`, `provider`, `model`, `usage` → copied to the message +- `finish_reason` → used as `stopReason` # Arguments - `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) # Returns -- `Tuple{Bool, Vector{agentToolCall}}`: `(hasToolCalls, toolCallList)` +- `Tuple{Bool, Vector{agentToolCall}, assistantMessage}`: `(hasToolCalls, toolCallList, assistantMsg)` + +# Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ function _extractToolCalls(response) hasToolCalls = false @@ -600,9 +605,9 @@ function _extractToolCalls(response) for content_block in content if content_block isa AbstractDict if get(content_block, "type", "") == "tool_calls" - hasToolCalls = true for tc_data in get(content_block, "tool_calls", []) if tc_data isa AbstractDict + hasToolCalls = true push!(toolCallList, make_tc(tc_data)) end end @@ -621,7 +626,88 @@ function _extractToolCalls(response) end end - return hasToolCalls, toolCallList + # ── Construct assistantMessage from response ────────────────────── + # Check Format 1 nested message for reasoning_content, role, etc. + reasoning = get(response, "reasoning_content", nothing) + if reasoning === nothing && msg !== nothing && msg isa AbstractDict + reasoning = get(msg, "reasoning_content", nothing) + end + if reasoning isa String + reasoning_text = reasoning + elseif reasoning isa textContent + reasoning_text = reasoning.text + else + reasoning_text = "" + end + reasoning_block = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + + finish_reason = get(response, "finish_reason", nothing) + stop_reason = finish_reason isa AbstractString ? String(finish_reason) : "end_turn" + + api = get(response, "api", "") + provider = get(response, "provider", "") + model = get(response, "model", nothing) + usage = get(response, "usage", nothing) + + content_from_response = get(response, "content", nothing) + content_blocks = Vector{messageContent}() + if content_from_response isa Vector + for block in content_from_response + if block isa AbstractDict + if get(block, "type", "") == "text" + push!(content_blocks, textContent(get(block, "text", ""))) + elseif get(block, "type", "") == "tool_call" + # tool_call blocks — don't add text content for these + elseif get(block, "type", "") == "tool_calls" + # tool_calls blocks — don't add text content for these + elseif get(block, "type", "") == "reasoning" + push!(content_blocks, textContent(get(block, "text", ""))) + else + push!(content_blocks, textContent(get(block, "text", ""))) + end + end + end + end + + # Combine content blocks and reasoning + if !isempty(content_blocks) && !isempty(reasoning_block) + all_content = vcat(reasoning_block, content_blocks) + elseif !isempty(content_blocks) + all_content = content_blocks + elseif !isempty(reasoning_block) + all_content = reasoning_block + else + all_content = textContent[] + end + + error_msg = get(response, "error_message", get(response, "errorMessage", nothing)) + + if usage === nothing || !(usage isa llmUsage) + usage = llmUsage(0, 0) + end + + # Role: prefer Format 1 nested message, fallback to top-level, default "assistant" + role = get(response, "role", "assistant") + if role == "assistant" && msg !== nothing && msg isa AbstractDict + nested_role = get(msg, "role", nothing) + if nested_role isa AbstractString + role = nested_role + end + end + + assistant_msg = assistantMessage( + role = role, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + + return hasToolCalls, toolCallList, assistant_msg end """ @@ -689,14 +775,14 @@ prepareToolCallArguments(normalizeTool, tc) ``` """ function prepareToolCallArguments(tool::agentTool, toolCall::agentToolCall)::agentToolCall - if tool.prepareArguments === nothing - return toolCall - end - prepared = tool.prepareArguments(toolCall.arguments) - if prepared == toolCall.arguments - return toolCall - end - return merge(toolCall, dict(:arguments => prepared)) + if tool.prepareArguments === nothing + return toolCall + end + prepared = tool.prepareArguments(toolCall.arguments) + if prepared == toolCall.arguments + return toolCall + end + return merge(toolCall, dict(:arguments => prepared)) end """ @@ -759,37 +845,55 @@ function prepareToolCall( assistantMsg::assistantMessage, toolCall::agentToolCall, config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + signal::abortSignal, + agentEventSink )::Union{preparedToolCall,immediateOutcome} - + agentEventSink("prepareToolCall 1") tool = get(context.tools, toolCall.name, nothing) if tool === nothing + agentEventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) end try + agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) + agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) - + agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing + agentEventSink("prepareToolCall 6") + before = config.beforeToolCall( beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) - if signal !== nothing && signal.aborted + agentEventSink("prepareToolCall 7") + if signal.aborted + agentEventSink("prepareToolCall 8") return immediateOutcome(createErrorToolResult("Operation aborted"), true) end + if before !== nothing && before.block + agentEventSink("prepareToolCall 9") return immediateOutcome( createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) end end - + agentEventSink("prepareToolCall 10") return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + catch e + bt = catch_backtrace() + err_msg = sprint() do io + showerror(io, e, bt) + println(io) + end + + agentEventSink(err_msg) + + return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end end @@ -836,31 +940,31 @@ executePreparedToolCall(prep, nothing, emit) function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, - emit::Function, -)::executedOutcome + agentEventSink, + )::executedOutcome + agentEventSink("executePreparedToolCall 1") + updateEvents = promise[] + accepting = true - updateEvents = promise[] - accepting = true - - try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - accepting = false - wait.(updateEvents) - return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end + try + result = prep.tool.execute( + prep.toolCall.id, prep.args, signal, + partialResult -> begin + if accepting + push!(updateEvents, + agentEventSink(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, + prep.toolCall.arguments, partialResult))) + end + end + ) + accepting = false + wait.(updateEvents) + return executedOutcome(result, false) + catch err + accepting = false + wait.(updateEvents) + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end end # ── per-call finalization ─────────────────────────────────────── @@ -1011,36 +1115,41 @@ function executeToolCallsSequential( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, + signal::abortSignal, + agentEventSink, )::agentToolCallBatch + agentEventSink("executeToolCallsSequential 1") + finalizedCalls = finalizedOutcome[] + messages = toolResultMessage[] - finalizedCalls = finalizedOutcome[] - messages = toolResultMessage[] - - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) - - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - else - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - end - - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break - end - end - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) + for tc in toolCalls + agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 2") + if prep isa immediateOutcome + agentEventSink("executeToolCallsSequential 2-1") + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink("executeToolCallsSequential 2-2") + else + agentEventSink("executeToolCallsSequential 3") + executed = executePreparedToolCall(prep, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-1") + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + signal) + agentEventSink("executeToolCallsSequential 3-2") + end + agentEventSink("executeToolCallsSequential 4") + agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + $(finalized.result), $(finalized.isError)") + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + agentEventSink("executeToolCallsSequential 5") + if signal !== nothing && signal.aborted + break + end + end + agentEventSink("executeToolCallsSequential 6") + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end # ── parallel execution ────────────────────────────────────────── @@ -1105,51 +1214,51 @@ function executeToolCallsParallel( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, + signal::abortSignal, + agentEventSink, )::agentToolCallBatch - entries = union{finalizedOutcome,task{finalizedOutcome}}[] + entries = union{finalizedOutcome,task{finalizedOutcome}}[] - for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) + for tc in toolCalls + agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal) - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - push!(entries, finalized) - else - task = task() do - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) - return finalized - end - schedule(task) - push!(entries, task) - end - - if signal !== nothing && signal.aborted - break - end + if prep isa immediateOutcome + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) + push!(entries, finalized) + else + task = task() do + executed = executePreparedToolCall(prep, signal, agentEventSink) + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) + agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, + finalized.result, finalized.isError)) + return finalized + end + schedule(task) + push!(entries, task) end - finalizedCalls = finalizedOutcome[] - for entry in entries - outcome = entry isa task ? fetch(entry) : entry - push!(finalizedCalls, outcome) + if signal !== nothing && signal.aborted + break end + end - messages = toolResultMessage[] - for f in finalizedCalls - push!(messages, createToolResultMessage(f)) - end + finalizedCalls = finalizedOutcome[] + for entry in entries + outcome = entry isa task ? fetch(entry) : entry + push!(finalizedCalls, outcome) + end - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) + messages = toolResultMessage[] + for f in finalizedCalls + push!(messages, createToolResultMessage(f)) + end + + return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) end @@ -1207,24 +1316,29 @@ function executeToolCalls( assistantMsg::assistantMessage, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, -)::agentToolCallBatch + signal::abortSignal, + agentEventSink, + )::agentToolCallBatch - hasSequential = false - for tc in toolCalls - t = get(context.tools, tc.name, nothing) - if t !== nothing && !t.parallelToolExecute - hasSequential = true - break - end - end - - if config.toolExecution == "sequential" || hasSequential - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, emit) - else - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, emit) - end + agentEventSink("_executeToolCalls 1") + hasSequential = false + for tc in toolCalls + t = get(context.tools, tc.name, nothing) + if t !== nothing && !t.parallelToolExecute + hasSequential = true + break + end + end + agentEventSink("_executeToolCalls 2") + if config.toolExecution == "sequential" || hasSequential + agentEventSink("_executeToolCalls 3") + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, + agentEventSink) + else + agentEventSink("_executeToolCalls 4") + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, + agentEventSink) + end end diff --git a/src/type.jl b/src/type.jl index 9b8d9bd..b6bf8a9 100644 --- a/src/type.jl +++ b/src/type.jl @@ -146,7 +146,8 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en function assistantMessage(; role="assistant", content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn", errorMessage=nothing, timestamp=now()) - return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) + model_str = model isa AbstractString ? String(model) : "" + return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp) end struct toolResultMessage <: agentMessage # Result returned from a tool execution @@ -395,13 +396,11 @@ end Configuration for the agent tool execution loop. # Arguments -- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name - `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution - `afterToolCall::Union{Function, Nothing}`: Callback after tool execution - `toolExecution::String`: Execution mode — "sequential" or "parallel" """ struct agentLoopConfig - tools::OrderedDict{String, agentTool} beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl index 3d97fee..94abd46 100644 --- a/test/_extractToolCalls.jl +++ b/test/_extractToolCalls.jl @@ -9,9 +9,9 @@ import YiemAgent.agentCore: _extractToolCalls @testset "_extractToolCalls" begin - # -------------------------------------------------------------- # - # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # - # -------------------------------------------------------------- # + # ------------------------------------------------------------------ # + # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # + # ------------------------------------------------------------------ # @testset "single tool call via message format" begin response = Dict{String,Any}( @@ -33,12 +33,19 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getWeather" @test tc_list[1].id == "tc_001" + @test tc_list[1].type == "function" @test tc_list[1].arguments["city"] == "Bangkok, Thailand" + @test assistant_msg isa assistantMessage + @test assistant_msg.role == "assistant" + @test assistant_msg.stopReason == "tool_calls" + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1].text == "Let me check the weather." end @testset "multiple tool calls via message format" begin @@ -66,13 +73,14 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 @@ -91,11 +99,12 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 @@ -113,14 +122,14 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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)" begin + @testset "tool call with non-string arguments (pre-parsed dict)" begin response = Dict{String,Any}( "message" => Dict{String,Any}( "role" => "assistant", @@ -136,16 +145,42 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 - # ----------------------------------------------------------- # - # Format 2: response.content blocks (OpenAI API style) # - # ----------------------------------------------------------- # + @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}( @@ -166,11 +201,14 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 @@ -184,7 +222,7 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getTime" @@ -192,9 +230,90 @@ import YiemAgent.agentCore: _extractToolCalls @test tc_list[1].arguments["timezone"] == "Europe/London" end - # ----------------------------------------------------------- # - # Format 2 via struct-like object (no .content field) # - # ----------------------------------------------------------- # + @testset "content blocks with reasoning and text" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."), + Dict{String,Any}("type" => "text", "text" => "Here's the answer."), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1].text == "Thinking..." + @test assistant_msg.content[2].text == "Here's the answer." + end + + @testset "content blocks with text and tool_call (tool_call not in content)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."), + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_mix", + "name" => "getWeather", + "arguments" => Dict{String,Any}("city" => "London"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + # text block included, tool_call block excluded from content + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "Sure, I'll check." + end + + # ------------------------------------------------------------------ # + # assistantMessage construction # + # ------------------------------------------------------------------ # + + @testset "assistantMessage with error_message and errorMessage fallback" begin + response = Dict{String,Any}( + "error_message" => "rate limit", + "content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test assistant_msg.errorMessage == "rate limit" + end + + @testset "assistantMessage with usage tracking" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => llmUsage(100, 50), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 100 + @test assistant_msg.usage.outputTokens == 50 + end + + @testset "assistantMessage with invalid usage defaults to zero" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => "invalid", + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 0 + @test assistant_msg.usage.outputTokens == 0 + end + + @testset "reasoning_content as textContent" begin + response = Dict{String,Any}( + "reasoning_content" => textContent("internal thought"), + "content" => Any[Dict{String,Any}("type" => "text", "text" => "output")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1].text == "internal thought" + @test assistant_msg.content[2].text == "output" + end + + # ------------------------------------------------------------------ # + # No tool call cases # + # ------------------------------------------------------------------ # @testset "no tool calls found" begin response = Dict{String,Any}( @@ -202,16 +321,20 @@ import YiemAgent.agentCore: _extractToolCalls Dict{String,Any}("type" => "text", "text" => "Hello world."), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 = _extractToolCalls(response) + 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 @@ -221,9 +344,21 @@ import YiemAgent.agentCore: _extractToolCalls "tool_calls" => Any[], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 @@ -250,15 +385,15 @@ import YiemAgent.agentCore: _extractToolCalls ), ], ) - has_toolcalls, tc_list = _extractToolCalls(response) + 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 # - # ----------------------------------------------------------- # + # ------------------------------------------------------------------ # + # Edge cases # + # ------------------------------------------------------------------ # @testset "tool call with null arguments" begin response = Dict{String,Any}( @@ -276,7 +411,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "getTime" @@ -294,7 +429,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "" @@ -313,7 +448,7 @@ import YiemAgent.agentCore: _extractToolCalls ], ), ) - has_toolcalls, tc_list = _extractToolCalls(response) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) @test has_toolcalls == true @test length(tc_list) == 1 @test tc_list[1].name == "" @@ -333,11 +468,171 @@ import YiemAgent.agentCore: _extractToolCalls ), )) parsed = JSON.parse(json_str) - has_toolcalls, tc_list = _extractToolCalls(parsed) + 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 From 2543e6cbf1d5d3f4a786ee50bf12221a6f4e8cde Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 15 Aug 2026 16:50:28 +0700 Subject: [PATCH 18/23] static tool loading --- Manifest.toml | 51 +++- Project.toml | 2 + README_tools.md | 544 +++++++++++++++++++--------------------- src/YiemAgent.jl | 15 +- src/agentCore.jl | 221 +++++++++------- src/toolRegistry.jl | 100 +------- src/tools/getTime.jl | 13 +- src/tools/getWeather.jl | 33 ++- src/tools/writeTool.jl | 21 +- src/type.jl | 4 +- src/utils.jl | 60 ++++- test/toolTest.jl | 229 ++++++++++++++--- user_code.jl | 113 +++++++++ 13 files changed, 864 insertions(+), 542 deletions(-) create mode 100644 user_code.jl diff --git a/Manifest.toml b/Manifest.toml index 2e462e1..0bbbd2e 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "0db36d4fb31037ba05065476e6aebaf4cd0e1e8c" +project_hash = "aa163e2bf572632825162936e107be18384fd40f" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] @@ -44,6 +44,12 @@ git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6" uuid = "ec485272-7323-5ecc-a04f-4719b315124d" version = "0.4.0" +[[deps.Arrow]] +deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01" +uuid = "69666777-d1a9-59fb-9406-91d4454c9d45" +version = "2.8.1" + [[deps.ArrowTypes]] deps = ["Sockets", "UUIDs"] git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" @@ -58,6 +64,12 @@ version = "1.11.0" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" version = "1.11.0" +[[deps.BitIntegers]] +deps = ["Random"] +git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46" +uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" +version = "0.3.7" + [[deps.BufferedStreams]] git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643" uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d" @@ -90,12 +102,24 @@ git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a" uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" version = "0.3.4" +[[deps.CodecLz4]] +deps = ["Lz4_jll", "TranscodingStreams"] +git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2" +uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561" +version = "0.4.6" + [[deps.CodecZlib]] deps = ["TranscodingStreams", "Zlib_jll"] git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" uuid = "944b1d66-785c-5afd-91f1-9de20f533193" version = "0.7.8" +[[deps.CodecZstd]] +deps = ["TranscodingStreams", "Zstd_jll"] +git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56" +uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" +version = "0.8.7" + [[deps.CommonSolve]] git-tree-sha1 = "cf963add2340ad9960e5eb22844e61ad8f931fe1" uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" @@ -130,6 +154,12 @@ weakdeps = ["InverseFunctions"] [deps.CompositionsBase.extensions] CompositionsBaseInverseFunctionsExt = "InverseFunctions" +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "3c9be947934c38475bafe822c6d61aaed17f0738" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.6.0" + [[deps.ConstructionBase]] git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" @@ -531,6 +561,12 @@ git-tree-sha1 = "1d4c737ab26f51ceed52ab2019c09b7660eb7440" uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" version = "3.8.0" +[[deps.Lz4_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" +uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" +version = "1.10.1+0" + [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" @@ -938,6 +974,11 @@ git-tree-sha1 = "8a90c1d77c3277a5d43b83927b3cbe2c70a37484" uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" version = "0.4.7" +[[deps.StringViews]] +git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8" +uuid = "354b36f9-a18e-4713-926e-db85100087ba" +version = "1.3.7" + [[deps.StructTypes]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" @@ -1080,6 +1121,14 @@ git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026" uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" version = "1.0.21+0" +[[deps.msghandler]] +deps = ["Arrow", "Base64", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"] +git-tree-sha1 = "e82a79cf6602541ea25409aded57b2ace4a7c29f" +repo-rev = "main" +repo-url = "https://git.yiem.cc/ton/msghandler" +uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" +version = "1.2.1" + [[deps.nghttp2_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" diff --git a/Project.toml b/Project.toml index f687431..7fea2af 100644 --- a/Project.toml +++ b/Project.toml @@ -22,6 +22,7 @@ SQLLLM = "2ebc79c7-cc10-4a3a-9665-d2e1d61e63d3" Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +msghandler = "f2724d33-f338-4a57-b9f8-1be882570d10" [compat] Base64 = "1.11.0" @@ -33,3 +34,4 @@ JSON = "1.6.1" LLMMCTS = "0.1.5" NATS = "0.1.0" SQLLLM = "0.2.8" +msghandler = "1.2.1" diff --git a/README_tools.md b/README_tools.md index de61209..9560f36 100644 --- a/README_tools.md +++ b/README_tools.md @@ -9,7 +9,7 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 1. [Quick Start: Tool Lifecycle](#1-quick-start-tool-lifecycle) 2. [Overview](#2-overview) 3. [Tool Definition — The `agentTool` Struct](#3-tool-definition--the-agenttool-struct) -4. [Tool Registration — Per-Agent Tool Stores](#4-tool-registration--per-agent-tool-stores) +4. [Tool Registration — Static Registration](#4-tool-registration--static-registration) 5. [The Agent Loop — High-Level Flow](#5-the-agent-loop--high-level-flow) 6. [Message Processing Pipeline](#6-message-processing-pipeline) 7. [Tool Call Extraction from LLM Response](#7-tool-call-extraction-from-llm-response) @@ -23,7 +23,8 @@ This document describes the complete tool lifecycle in the YiemAgent framework, 15. [Self-Modifying Tools](#15-self-modifying-tools) 16. [Complete End-to-End Example](#16-complete-end-to-end-example) 17. [Tool File Contract](#17-tool-file-contract) -18. [Appendix: Type Reference](#18-appendix-type-reference) +18. [Adding New Tools](#18-adding-new-tools) +19. [Appendix: Type Reference](#19-appendix-type-reference) --- @@ -43,76 +44,52 @@ tool = listTool(store) # Returns an agentTool that, when executed, lists all to **Result extraction:** ```julia result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x) -# result.content[1].text => "Available tools:\n- getTime: Time Lookup — Get current local time...\n- getWeather: Weather Lookup — Fetch current weather..." +# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..." ``` -**Source:** `toolRegistry.jl:54-82` +**Source:** `toolRegistry.jl:43-98` --- -### Step 2: Load — `loadTools()` +### Step 2: Register — `register_all_tools()` -Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools. +Tools are statically defined in `src/tools/` and registered at module initialization via `register_all_tools()`. Each tool function (e.g., `getWeatherTool()`, `getTimeTool()`, `writeToolTool()`) is called to create the `agentTool` struct. `listTool` is auto-registered so the LLM can discover available tools. ```julia using YiemAgent, YiemAgent.toolRegistry store = toolStore(name="myAgent") -tools = loadTools(store, "src/tools") -# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools +tools = register_all_tools(store) +# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs # Also auto-registers listTools for runtime discovery ``` **Result extraction:** ```julia all_tools = getTools(store) # OrderedDict{String, agentTool} -# Keys: "getTime", "getWeather", "writeTool", "listTools" -getTime_tool = all_tools["getTime"] +# Keys: "getWeather", "getTime", "writeTool", "listTools" +getWeather_tool = all_tools["getWeather"] -# Manual registration (alternative to loadTools) +# Manual registration (alternative to register_all_tools) registerTool(store, my_tool) clearTools(store) # Clear all tools from store ``` -**Source:** `toolRegistry.jl:126-178` +**Source:** `toolRegistry.jl:38-40, 127-143` --- ### Step 2.5: Create Agent with Tools -Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is deep-copied into `agent._state.tools`; `_tool_store` is kept for runtime registration. +Wire the registered tools into a new `yiemAgent` instance. The `yiemAgent` constructor calls `register_all_tools()` automatically. ```julia using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry -# 1. Set up toolStore and load tools (auto-registers listTools) -store = toolStore(name="myAgent") -loadTools(store, "src/tools") - -# 2. Create agent — pass tools + _tool_store +# Create agent — tools are registered automatically via register_all_tools() agent = yiemAgent( - systemPrompt = "You are a helpful assistant.", - model = my_model, - tools = getTools(store), # OrderedDict{String, agentTool} llmCall = my_llm_call, # Function that calls the LLM API agentEventSink = my_event_sink, # Function for TUI/logging - _tool_store = store, # For runtime registerTool() calls -) -``` - -**Manual registration** (without `loadTools`): - -```julia -store = toolStore(name="myAgent") -registerTool(store, getTime_tool) -registerTool(store, getWeather_tool) -registerTool(store, listTool(store)) # needed for manual registration - -agent = yiemAgent( - tools = getTools(store), - llmCall = my_llm_call, - agentEventSink = my_event_sink, - _tool_store = store, ) ``` @@ -120,17 +97,15 @@ agent = yiemAgent( | Parameter | Type | Required | Purpose | |-----------|------|----------|---------| -| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | -| `model` | `llmModel` | No | LLM model config | -| `tools` | `OrderedDict{String, agentTool}` | No | Available tools (deep-copied) | -| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | -| `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` | +| `systemPrompt` | `String` | No (default: "You are helpful assistant.") | System prompt text | +| `model` | `llmModel` | No | LLM model config | +| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. -**Source:** `type.jl:609-657`, `toolRegistry.jl:38-40, 191-195` +**Source:** `type.jl:609-657`, `toolRegistry.jl:127-143` --- @@ -146,13 +121,13 @@ sig = nothing op = x -> x # no-op partial result callback # Execute a loaded tool directly -result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) +result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op) ``` **Via agent loop (production):** ``` user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) - → _agentLoop detects message → @spawn _process_message(agent) + → _agentLoop detects message → @spawn _processMessage(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) @@ -168,10 +143,10 @@ user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) **`agentToolResult`** (raw tool output, `type.jl:429-434`): ```julia -result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) +result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x) -result.content[1] # textContent("Current time in Tokyo: ...") -result.content[1].text # "Current time in Tokyo: 2026-08-10T..." +result.content[1] # textContent("Weather in Tokyo: Sunny, 22°C") +result.content[1].text # "Weather in Tokyo: Sunny, 22°C" result.details # Dict{Any,Any}() — tool-specific metadata result.usage # nothing — llmUsage tracking (optional) result.terminate # false — signals loop termination @@ -182,7 +157,7 @@ result.terminate # false — signals loop termination msg = batch.messages[1] # toolResultMessage msg.toolCallId # "call-1" -msg.toolName # "getTime" +msg.toolName # "getWeather" msg.content # Vector{messageContent} msg.isError # false msg.details # tool-specific metadata @@ -204,7 +179,7 @@ Each phase has a single responsibility and produces an intermediate result: | Phase | Function | Input | Output | Purpose | |-------|----------|-------|--------|---------| | Prepare | `prepareToolCall()` | `agentContext`, `assistantMessage`, `agentToolCall`, `agentLoopConfig`, `abortSignal` | `preparedToolCall` or `immediateOutcome` | Resolve tool, validate args, run pre-hook | -| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `emit` | `executedOutcome` | Call `tool.execute()`, stream partial results | +| Execute | `executePreparedToolCall()` | `preparedToolCall`, `abortSignal`, `agentEventSink` | `executedOutcome` | Call `tool.execute()`, stream partial results | | Finalize | `finalizeExecutedToolCall()` | `agentContext`, `assistantMessage`, `preparedToolCall`, `executedOutcome`, `agentLoopConfig`, `abortSignal` | `finalizedOutcome` | Run post-hook, emit end event | The pipeline ensures that **every tool call produces a result**, even on failure. Errors are captured as `immediateOutcome`, `executedOutcome`, or `finalizedOutcome` with `isError=true`, then converted to `toolResultMessage` objects that are fed back to the LLM conversation history. @@ -276,9 +251,9 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca --- -## 4. Tool Registration — Per-Agent Tool Stores +## 4. Tool Registration — Static Registration -**Source:** `toolRegistry.jl` +**Source:** `toolRegistry.jl`, `YiemAgent.jl` ### How `toolStore` Works @@ -293,36 +268,25 @@ end `store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. -### How `loadTools(store, dir)` Works +### Static Registration — `register_all_tools()` ```julia -function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} +function register_all_tools(store::toolStore)::OrderedDict{String, agentTool} ``` -**Source:** `toolRegistry.jl:126-178` +**Source:** `YiemAgent.jl:20-29` -1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) -2. **Sorts** filenames alphabetically for deterministic registration order -3. **Wraps** each file in a dynamically created submodule: - ```julia - # For "getWeather.jl" → module _tool_getWeather - module _tool_getWeather - using ..type - using Dates, UUIDs, DataStructures, JSON - # (file contents here) - end - ``` -4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues -5. **Validates** the return value is an `agentTool` instance -6. **Registers** the tool in `store.tools` -7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime +1. **Calls each tool's definition function** — `getWeatherTool()`, `getTimeTool()`, `writeToolTool()` — which return `agentTool` structs +2. **Registers each tool** via `registerTool(store, tool)` +3. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime -### Why Submodules? +### Why Static? -Each tool file is loaded into its own **namespaced submodule**. This means: -- `validateRequiredArgs`, `prepareArguments`, `executeTool`, and helper functions defined in `getTime.jl` are scoped under `_tool_getTime` -- No name collisions between tools — `getTime.validateRequiredArgs` is distinct from `getWeather.validateRequiredArgs` -- The module reference is kept alive by the functions stored in `agentTool` (closures in `execute`, `validateRequiredArgs`, `prepareArguments`) so they don't get garbage collected +Tools are **statically included** in `YiemAgent.jl` via `include()`. This means: +- Tool functions live in the `YiemAgent` module, not in dynamically created submodules +- No world-age issues when calling `tool.execute()` (Julia compiles dispatch in the same world) +- Simpler tool definition — no need to wrap in a `module ... end` block +- Better compiler optimization (inlining, type inference) ### Registration API @@ -331,9 +295,9 @@ Each tool file is loaded into its own **namespaced submodule**. This means: store1 = toolStore(name="agent1") store2 = toolStore(name="agent2") -# Load tools into specific stores (auto-registers listTools) -tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only -tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only +# Load all tools (auto-registers listTools) +tools1 = register_all_tools(store1) # all agents get the same tools +tools2 = register_all_tools(store2) # Manual registration (per-store) registerTool(store1, my_tool) @@ -359,8 +323,8 @@ Each `toolStore` is completely independent — tools registered in one store do storeA = toolStore(name="A") storeB = toolStore(name="B") -registerTool(storeA, getTime_tool) -registerTool(storeB, getWeather_tool) +registerTool(storeA, getTimeTool()) +registerTool(storeB, getWeatherTool()) getTools(storeA) # only contains getTime getTools(storeB) # only contains getWeather @@ -385,7 +349,6 @@ yiemAgent struct contains: - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse() - - _tool_store (toolStore) ← per-agent isolated tool registry ``` ### Loop States @@ -395,7 +358,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`): | State | `processingTask` | `activeRun` | `inputChannel` | `followUpChannel` | Behavior | |-------|-----------------|-------------|----------------|-------------------|----------| | 1 | `nothing` | `false` | empty | empty | Idle, waiting | -| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_process_message` | +| 2 | `nothing` | `false` | has msg | empty | New message → spawn `_processMessage` | | 3 | running | `true` | empty | empty | Processing, no new input | | 4 | running | `true` | has msg | empty | New message while busy → queued | | 5 | running | `true` | empty | has msg | Follow-up while busy → queued | @@ -414,9 +377,9 @@ function _agentLoop(agent::yiemAgent) drain both channels, break loop end - # 3. If agent is idle, spawn _process_message + # 3. If agent is idle, spawn _processMessage if agent._state.activeRun == false - processingTask = Threads.@spawn _process_message(agent) + processingTask = Threads.@spawn _processMessage(agent) agent._state.activeRun = true end @@ -445,12 +408,12 @@ end **Source:** `agentCore.jl:175-311` -`_process_message(agent)` is the core function that processes a batch of user messages through the LLM pipeline. +`_processMessage(agent)` is the core function that processes a batch of user messages through the LLM pipeline. ### Pipeline Steps ```julia -function _process_message(agent::yiemAgent)::assistantMessage +function _processMessage(agent::yiemAgent)::assistantMessage final_response = nothing while true # Loop until LLM returns response without tool calls @@ -466,38 +429,38 @@ function _process_message(agent::yiemAgent)::assistantMessage end # ── Step 2: Prepare context ───────────────────────────────── - preparedContext = agent.prepareContext(agent._state) + state = agentState(systemPrompt, nothing, tools, messages) + preparedContext = prepareContext(state, agentEventSink) # Default: deep copies systemPrompt, messages, tools from agentState → agentContext # Override point: filter tools, inject context, modify system prompt # ── Step 3: Format for LLM ────────────────────────────────── - formatted_messages = agent.formatMsgForLLM(preparedContext) + formattedMessages = formatMsgForLLM(preparedContext, agentEventSink) # Converts agentContext → Dict("messages" => [...]) in OpenAI format # Wraps systemPrompt as system role, converts each messageContent block # ── Step 4: Call LLM ──────────────────────────────────────── - response = agent.llmCall(formatted_messages) + response = llmCall(formattedMessages) # Returns assistantMessage with content::Vector{messageContent} # Each content block has a type: "text", "thinking", or "tool_call" # ── Step 5: Extract tool calls ────────────────────────────── - has_tool_calls, tool_call_list = extract_tool_calls(response.content) + hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) # Inspects content blocks for "tool_calls" or "tool_call" Dict entries # ── Step 6: Execute tool calls or return ──────────────────── - if has_tool_calls && !isempty(tool_call_list) + if hasToolCalls && !isempty(toolCallList) # Build context and config - context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools) - config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, ...) - signal = nothing - emit = agent.agentEventSink + context = agentContext(systemPrompt, messages, tools) + config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") + signal = abortSignal(false) # Execute tool calls (sequential or parallel) - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink) # Save results to conversation history for tool_result in batch.messages - push!(agent._state.messages, tool_result) + push!(messages, tool_result) end # Check termination @@ -517,10 +480,6 @@ function _process_message(agent::yiemAgent)::assistantMessage end ``` -### Debug Note - -There is a deliberate `error(5555555)` at `agentCore.jl:214` that halts execution after the LLM call. This appears to be a debugging/staging marker. Remove or replace it before production use. - --- ## 7. Tool Call Extraction from LLM Response @@ -652,51 +611,29 @@ function prepareToolCall( function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing, abortSignal}, - emit::Function, + agentEventSink, )::executedOutcome ``` **Steps:** -1. **Initialize streaming state:** - ```julia - updateEvents = promise[] # vector to collect update event handles - accepting = true # guard to prevent duplicate emissions - ``` +1. **Call `tool.execute()`:** + ```julia + result = prep.tool.execute( + prep.toolCall.id, + prep.args, + signal, + agentEventSink # serves as onPartialResult callback + ) + return executedOutcome(result, false) + ``` -2. **Call `tool.execute()`:** - ```julia - result = prep.tool.execute( - prep.toolCall.id, - prep.args, - signal, - partialResult -> begin - if accepting - push!(updateEvents, - emit(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - ``` - -3. **Wait for streaming to settle:** - ```julia - accepting = false - wait.(updateEvents) # wait for all pending update event handlers - return executedOutcome(result, false) - ``` - -4. **On error:** - ```julia - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) - end - ``` - -**Streaming design:** The `accepting` guard prevents emitting updates after the call completes. If the tool's `execute` function yields after emitting updates but before returning, no duplicate or stale updates are emitted. +2. **On error:** + ```julia + catch err + return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + end + ``` ### 8.3 Phase 3: Finalize — `finalizeExecutedToolCall()` @@ -716,49 +653,36 @@ function finalizeExecutedToolCall( **Steps:** 1. **Extract execution result:** - ```julia - result = executed.result - isError = executed.isError - ``` + ```julia + result = executed.result + isError = executed.isError + ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterToolCallContext(...)) - if after !== nothing - result = merge(result, dict( - :content => get(after, :content, result.content), - :details => get(after, :details, result.details), - :usage => get(after, :usage, result.usage), - :terminate => get(after, :terminate, result.terminate) - )) - isError = get(after, :isError, isError) - end - ``` - - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic - - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` + - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Hook can mutate the result: + ```julia + after = config.afterToolCall(afterToolCallContext(...)) + if after !== nothing + result = merge(result, dict( + :content => get(after, :content, result.content), + :details => get(after, :details, result.details), + :usage => get(after, :usage, result.usage), + :terminate => get(after, :terminate, result.terminate) + )) + isError = get(after, :isError, isError) + end + ``` + - Common use cases: mask sensitive data, normalize usage, flip `terminate` based on business logic + - On error: `result = createErrorToolResult(sprint(showerror, err)); isError = true` 3. **Return:** - ```julia - return finalizedOutcome(prep.toolCall, result, isError) - ``` + ```julia + return finalizedOutcome(prep.toolCall, result, isError) + ``` **Source:** `type.jl:787-791` — `finalizedOutcome` holds the original tool call reference, final result (post-hook), and error status. -### 8.4 Emission — `emitToolExecutionEnd()` - -**Source:** `agentCore.jl:736-739` - -```julia -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, - finalized.result, finalized.isError)) -end -``` - -This is called immediately after finalization, before building the `toolResultMessage`. - --- ## 9. Execution Modes — Sequential vs Parallel @@ -774,7 +698,7 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, - emit::Function, + agentEventSink, )::agentToolCallBatch ``` @@ -809,18 +733,15 @@ function executeToolCallsSequential(...)::agentToolCallBatch messages = toolResultMessage[] for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) else - executed = executePreparedToolCall(prep, signal, emit) + executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) end - emitToolExecutionEnd(finalized, emit) push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -842,19 +763,15 @@ function executeToolCallsParallel(...)::agentToolCallBatch entries = union{finalizedOutcome, task{finalizedOutcome}}[] for tc in toolCalls - emit(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) push!(entries, finalized) # immediate outcome — no task else task = task() do - executed = executePreparedToolCall(prep, signal, emit) + executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) return finalized end schedule(task) @@ -923,12 +840,12 @@ From the type documentation (`type.jl:803-815`): | Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | | Async handoff | A tool triggers a long-running external operation; the external system will later resume via `continue()` | -### Batch Processing in `_process_message()` +### Batch Processing in `_processMessage()` **Source:** `agentCore.jl:266-307` ```julia -batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) +batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) # Save results to conversation history for tool_result in batch.messages @@ -985,9 +902,9 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage f.result.content, # content (Vector{messageContent}) f.result.details, # details f.result.usage, # usage - get(f.result, :addedToolNames, string[]), # addedToolNames (for dynamic tools) + nothing, # addedToolNames (for dynamic tools) f.isError, # isError - nowMillis(), # timestamp + now(), # timestamp ) end ``` @@ -1048,9 +965,6 @@ Tool call fails at any phase └─────────────────────────┘ │ ▼ -emitToolExecutionEnd(finalized, emit) - │ - ▼ createToolResultMessage(finalized) │ ▼ @@ -1119,14 +1033,14 @@ end |-------|-------------|------| | `toolExecStartEvent` | `executeToolCalls*()` loop | Before `prepareToolCall()` for each tool call | | `toolExecUpdateEvent` | `executePreparedToolCall()` | Inside `onPartialResult` callback during `tool.execute()` | -| `toolExecEndEvent` | `emitToolExecutionEnd()` | After `finalizeExecutedToolCall()` for each tool call | +| `toolExecEndEvent` | `finalizeExecutedToolCall()` | After finalization for each tool call | ### Event Sink -The `emit` function is passed through the entire call chain: +The `agentEventSink` function is passed through the entire call chain: ```julia -emit = agent.agentEventSink # set during yiemAgent construction +agentEventSink = agent.agentEventSink # set during yiemAgent construction ``` The `agentEventSink` function is a user-provided callback that receives all events. This is typically used by: @@ -1143,8 +1057,8 @@ The `agentEventSink` function is a user-provided callback that receives all even | Hook | Signature | Called | Purpose | |------|-----------|--------|---------| -| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | -| `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | +| `prepareContext` | `(state::agentState, agentEventSink) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | +| `formatMsgForLLM` | `(ctx::agentContext, agentEventSink) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | @@ -1211,7 +1125,7 @@ end **Source:** `utils.jl:111-125` ```julia -function prepareContext(state::agentState)::agentContext +function prepareContext(state::agentState, agentEventSink)::agentContext # TODO: filter tools from state.tools based on user intent filteredTools = state.tools @@ -1238,7 +1152,7 @@ end Default implementation converts `agentContext` to OpenAI-compatible format: ```julia -function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} messages = Vector{Dict{String, Any}}() # System prompt as system message @@ -1280,14 +1194,14 @@ The framework supports tools that modify the tool system itself at runtime. 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 2. Indents `executeCode` with 4 spaces 3. Wraps it inside `function executeTool(...)::agentToolResult ... end` -4. Appends `getTool()` returning an `agentTool` struct +4. Appends `writeToolTool()` returning an `agentTool` struct 5. Writes the combined string to `src/tools/.jl` ### `listTool` — Discover Available Tools -**Source:** `toolRegistry.jl:55-82` +**Source:** `toolRegistry.jl:43-98` -Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. +Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow @@ -1301,9 +1215,11 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (optional) validateCode, prepareCode 3. writeTool generates src/tools/searchWine.jl -4. Agent restarts (or hot-reloads) → loadTools(agent._tool_store, "src/tools") picks up the new file -5. Agent calls searchWine(query="cabernet") -6. Result: "Found 5 cabernet wines..." +4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl +5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl +6. Developer restarts Julia — new tool is loaded +7. Agent calls searchWine(query="cabernet") +8. Result: "Found 5 cabernet wines..." ``` ### `writeTool` Input Schema @@ -1328,14 +1244,14 @@ Each `toolStore` gets its own `listTool` instance bound to that store via `listT ``` USER SENDS MESSAGE └─> runAgent(agent, "What's the weather in Tokyo?") - └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) + └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL └─> _agentLoop: detects msg in inputChannel - └─> Threads.@spawn _process_message(agent) + └─> @spawn _processMessage(agent) - ── _process_message ────────────────────────────────────────────── + ── _processMessage ────────────────────────────────────────────── │ │ Step 1: Drain inputChannel │ raw_msg = Dict("role" => "user", "content" => [...]) @@ -1367,36 +1283,34 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL │ │ Step 6: Execute tool calls │ context = agentContext(systemPrompt, messages, tools) - │ config = agentLoopConfig(tools, beforeToolCall, afterToolCall, "sequential") - │ batch = executeToolCalls(context, response, tool_call_list, config, nothing, emit) + │ config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential") + │ batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink) + + +LOOP ITERATION 1 — executeToolCallsSequential │ - │ ── executeToolCallsSequential ────────────────────────────── - │ │ - │ │ For tc = agentToolCall("call_1", "getWeather", ...): - │ │ - │ │ emit(toolExecStartEvent("call_1", "getWeather", {"city": "Tokyo"})) - │ │ - │ │ PREPARE: - │ │ tool = context.tools["getWeather"] → found! - │ │ validatedArgs = validateToolArguments(tool, tc) - │ │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes - │ │ beforeToolCall_hook(...) → nothing (skipped) - │ │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) - │ │ - │ │ EXECUTE: - │ │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, onPartialResult) - │ │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) - │ │ → executedOutcome(result, false) - │ │ - │ │ FINALIZE: - │ │ afterToolCall_hook(...) → nothing (skipped) - │ │ → finalizedOutcome(tc, result, false) - │ │ - │ │ emit(toolExecEndEvent("call_1", "getWeather", result, false)) - │ │ msg = createToolResultMessage(finalized) - │ │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) - │ │ - │ └─> agentToolCallBatch([msg], false) + │ For tc = agentToolCall("call_1", "getWeather", ...): + │ + │ PREPARE: + │ tool = context.tools["getWeather"] → found! + │ validatedArgs = validateToolArguments(tool, tc) + │ → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema) → passes + │ beforeToolCall_hook(...) → nothing (skipped) + │ → preparedToolCall(tool, tc, {"city" => "Tokyo"}) + │ + │ EXECUTE: + │ result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink) + │ → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false) + │ → executedOutcome(result, false) + │ + │ FINALIZE: + │ afterToolCall_hook(...) → nothing (skipped) + │ → finalizedOutcome(tc, result, false) + │ + │ msg = createToolResultMessage(finalized) + │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts) + │ + └─> agentToolCallBatch([msg], false) │ │ Save results: │ for tool_result in batch.messages @@ -1408,7 +1322,7 @@ LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE - ── _process_message (second iteration) ─────────────────────────── + ── _processMessage (second iteration) ─────────────────────────── │ │ Step 1: Drain inputChannel → empty │ @@ -1437,12 +1351,12 @@ AGENT LOOP: SEND RESPONSE TO USER ## 17. Tool File Contract -Each `.jl` file in `src/tools/` must conform to the following contract: +Each `.jl` file in `src/tools/` follows a flat, static structure: ### Required Function ```julia -function getTool()::agentTool +function Tool()::agentTool # Must return an agentTool instance end ``` @@ -1451,22 +1365,22 @@ end ```julia # Argument preparation (before validation) -function prepareArguments(args::Dict{String,Any})::Dict{String,Any} +function PrepareArguments(args::Dict{String,Any})::Dict{String,Any} # Return modified args, or args unchanged return args end # Custom validation (before execution) -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} # Return nothing to pass, or error string to fail return nothing end # Core execution -function executeTool(toolCallId::String, - args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +function Execute(toolCallId::String, + args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult # Return agentToolResult with content, details, usage, terminate return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false) end @@ -1477,7 +1391,8 @@ end ```julia # src/tools/myTool.jl -using Dates # ← tool declares its own dependencies (registry injects only `using ..type`) +using .type # ← provides agentTool, textContent, agentToolResult, etc. +using Dates # ← tool's own dependencies # Optional: helper functions function helper_function(...) @@ -1485,24 +1400,24 @@ function helper_function(...) end # Optional: prepareArguments -function prepareArguments(args::Dict{String,Any})::Dict{String,Any} +function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any} return args end # Optional: validateRequiredArgs -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} return nothing end -# Required: executeTool -function executeTool(toolCallId::String, args::Dict{String,Any}, - signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +# Required: execute function +function myToolExecute(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + onPartialResult::Function)::agentToolResult ... end -# Required: getTool -function getTool()::agentTool +# Required: getTool function +function myToolTool()::agentTool return agentTool( name = "myTool", label = "My Tool", @@ -1512,9 +1427,9 @@ function getTool()::agentTool "properties" => Dict(...), "required" => [...] ), - execute = executeTool, - prepareArguments = prepareArguments, - validateRequiredArgs = validateRequiredArgs, + execute = myToolExecute, + prepareArguments = myToolPrepareArguments, + validateRequiredArgs = myToolValidateRequiredArgs, parallelToolExecute = false ) end @@ -1522,51 +1437,110 @@ end ### Dependencies -Each tool file **declares its own dependencies** via `using` statements at the top of the file. The registry does **not** inject any standard library packages — if a tool needs `Dates`, `JSON`, `HTTP`, `CSV`, or any other package, it must include its own `using` statements. +Each tool file declares its own dependencies via `using` statements: ```julia # src/tools/getTime.jl +using .type using Dates -function executeTool(...) +function getTimeExecute(...) now() # Dates.now requires `using Dates` end ``` ```julia # src/tools/myApiTool.jl +using .type using HTTP, JSON -function executeTool(...) +function myApiToolExecute(...) response = HTTP.get("https://api.example.com") data = JSON.parse(String(response.body)) ... end ``` -### Module Isolation +### Why Flat Modules? -When `loadTools()` loads a file, it wraps it in a dynamically created submodule. The registry injects **only** `using ..type` to make core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`, etc.) available: - -```julia -# User writes in src/tools/myTool.jl: -using Dates, HTTP, JSON # ← tool's own dependencies - -function getTool()::agentTool ... end - -# loadTools() creates: -module _tool_myTool - using ..type # ← injected by registry (core types only) - using Dates, HTTP, JSON # ← from tool file - # (user's code here) -end -``` - -All functions in the file are scoped under `_tool_myTool`, preventing name collisions with other tools. The module reference is kept alive by the function objects stored in `agentTool`, preventing garbage collection of closures. +All tool files are **statically included** in `YiemAgent.jl` via `include()`. This means: +- All functions live in the `YiemAgent` module, avoiding world-age issues +- `using .type` makes core types (`agentTool`, `textContent`, `agentToolResult`, `abortSignal`) available +- Functions are named with a `` prefix to avoid name collisions (e.g., `getWeatherExecute`, `getTimeExecute`) +- The `...Tool()` function (e.g., `getWeatherTool()`) returns the `agentTool` struct for registration --- -## 18. Appendix: Type Reference +## 18. Adding New Tools + +To add a new tool (e.g., `searchWine.jl`): + +### Step 1: Create `src/tools/searchWine.jl` + +```julia +using .type +# using AdditionalPkg # add if needed + +function searchWineExecute(toolCallId::String, args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, onPartialResult) + query = get(args, "query", "") + result = search_wine_db(query) + return agentToolResult( + [textContent("Found $(length(result)) wines")], + Dict{Any,Any}("count" => length(result)), + nothing, false + ) +end + +function searchWineTool()::agentTool + return agentTool( + name = "searchWine", + label = "Search Wine", + description = "Search wine database...", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "query" => Dict("type" => "string", "description" => "Search query") + ), + "required" => ["query"] + ), + execute = searchWineExecute, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false + ) +end +``` + +### Step 2: Include in `src/YiemAgent.jl` (before `toolRegistry.jl`) + +```julia +include("tools/getWeather.jl") +include("tools/getTime.jl") +include("tools/searchWine.jl") # ← add here +include("tools/writeTool.jl") +``` + +### Step 3: Register in `register_all_tools()` in `YiemAgent.jl` + +```julia +function register_all_tools(store::toolRegistry.toolStore) + registerTool(store, getWeatherTool()) + registerTool(store, getTimeTool()) + registerTool(store, searchWineTool()) # ← add here + registerTool(store, writeToolTool()) + registerTool(store, listTool(store)) + return store.tools +end +``` + +### Step 4: Restart Julia + +The module recompiles on next load. The new tool is available immediately. + +--- + +## 19. Appendix: Type Reference ### Message Types @@ -1604,7 +1578,7 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli |------|--------|-------------| | `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | -| `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | +| `agentLoopConfig` | `type.jl:403` | Loop config (beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 5659d95..11de2b9 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -1,7 +1,6 @@ module YiemAgent - # export agent - + export register_all_tools """ Order by dependencies of each file. The 1st included file must not depend on any other files and each file can only depend on the file included before it. @@ -13,9 +12,21 @@ module YiemAgent include("utils.jl") using .utils + include("tools/getWeather.jl") + include("tools/getTime.jl") + include("tools/writeTool.jl") + include("toolRegistry.jl") using .toolRegistry + function register_all_tools(store::toolRegistry.toolStore) + registerTool(store, getWeatherTool()) + registerTool(store, getTimeTool()) + registerTool(store, writeToolTool()) + registerTool(store, listTool(store)) + return store.tools + end + # include("llmfunction.jl") # using .llmfunction diff --git a/src/agentCore.jl b/src/agentCore.jl index d6e408c..c45945e 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,16 +1,23 @@ module agentCore -export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls +export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls, + executePreparedToolCall, prepareToolCall, executeToolCallsSequential, + executeToolCallsParallel, executeToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames, Base.Threads, NATS using GeneralUtils using ..type, ..utils, ..toolRegistry +function register_all_tools(store::toolRegistry.toolStore) + # Call parent module's version which has access to tool functions + parentmodule(@__MODULE__).register_all_tools(store) +end + # ---------------------------------------------- 100 --------------------------------------------- # """ -docstring + docstring """ mutable struct yiemAgent <: agent # High-level agent wrapper _state::agentState # Current state (prompt, model, messages, tools, etc.) @@ -84,7 +91,6 @@ on `inputChannel` and `followUpChannel` channels concurrently. - A new `yiemAgent` instance with an active background task """ function yiemAgent( - toolsFolderPath::String, llmCall, ; systemPrompt::String="You are helpful assistant.", @@ -106,9 +112,9 @@ function yiemAgent( followUp = Channel(32) outputChannel = Channel(16) - # load tools from toolsFolderPath + # load tools (statically registered at module init) toolStore1 = toolStore(name="myagent") - loadTools(toolStore1, toolsFolderPath) + register_all_tools(toolStore1) # Create struct with a placeholder task, then spawn and replace it agent = yiemAgent( @@ -291,6 +297,8 @@ function _agentLoop(agent::yiemAgent) processingTask = nothing # reset end agent.agentEventSink("_agentLoop 6") + agent.agentEventSink(string(typeof(processingTask))) + agent.agentEventSink("_agentLoop 7") end catch e # On any error, send error response and exit the loop @@ -329,7 +337,7 @@ julia> # Currently returns a placeholder echo response function _processMessage( inputChannel::Channel, agentEventSink, - messages::Vector{agentMessage}, + agentMsgHistory::Vector{agentMessage}, systemPrompt::String, tools::OrderedDict{String, agentTool}, prepareContext::Function, @@ -370,12 +378,12 @@ function _processMessage( end agentEventSink("_processMessage 5") user_msg = OpenAiToUserMessage(raw_msg) - push!(messages, user_msg) + push!(agentMsgHistory, user_msg) agentEventSink("_processMessage 6") end agentEventSink("_processMessage 7") # call prepareContext() - state = agentState(systemPrompt, nothing, tools, messages) + state = agentState(systemPrompt, nothing, tools, agentMsgHistory) agentEventSink("_processMessage 8") preparedContext = prepareContext(state, agentEventSink) agentEventSink("_processMessage 8") @@ -396,7 +404,7 @@ function _processMessage( agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") if hasToolCalls && length(toolCallList) > 0 - #WORKING Build context and config for executeToolCalls + # Build context and config for executeToolCalls config = agentLoopConfig( beforeToolCall, @@ -408,23 +416,21 @@ function _processMessage( agentEventSink("_processMessage 12") # call executeToolCalls() - - batch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, - agentEventSink) - - + toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, + signal, agentEventSink) agentEventSink("_processMessage 13") - error("debug marker") - # save toolResults to messages - for tool_result in batch.messages - push!(messages, tool_result) - end - if batch.terminate - # If batch requested termination, build a final response + # save toolResults to messages + for toolResult in toolResultBatch.messages + push!(agentMsgHistory, toolResult) + end + agentEventSink("_processMessage 14") + if toolResultBatch.terminate + agentEventSink("_processMessage 15") + # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] - for tool_result in batch.messages - for content_block in tool_result.content + for toolResult in toolResultBatch.messages + for content_block in toolResult.content if content_block isa textContent append!(final_content, [content_block]) elseif content_block isa Dict @@ -434,6 +440,7 @@ function _processMessage( end end end + agentEventSink("_processMessage 16") final_response = assistantMessage( role="assistant", content=final_content, @@ -441,7 +448,7 @@ function _processMessage( model=assistant_msg.model, usage=assistant_msg.usage, stopReason="tool_use_terminated", - errorMessage=if any(x -> x.isError, batch.messages) + errorMessage=if any(x -> x.isError, toolResultBatch.messages) "One or more tool calls failed" else nothing @@ -451,12 +458,13 @@ function _processMessage( break end else + agentEventSink("_processMessage 17") # LLM did not use tool calls — this is the final response final_response = assistant_msg break end end - + agentEventSink("_processMessage 18") return final_response end @@ -524,7 +532,7 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage return toolResultMessage( "toolResult", f.toolCall.id, f.toolCall.name, f.result.content, f.result.details, f.result.usage, - get(f.result, :addedToolNames, string[]), f.isError, nowMillis() + nothing, f.isError, now() ) end @@ -563,8 +571,10 @@ function _extractToolCalls(response) toolCallList = agentToolCall[] # Helper: parse args (JSON string -> Dict, or pass through) - parse_args(raw) = raw isa AbstractDict ? Dict{String,Any}(raw) : - raw isa String ? JSON.parse(raw) : Dict{String,Any}() + parse_args(raw) = raw isa AbstractDict && !(raw isa Dict{String,Any}) ? + Dict{String,Any}(raw) : + raw isa String ? JSON.parse(raw) : + raw isa Dict{String,Any} ? raw : Dict{String,Any}() # Helper: build agentToolCall (positional) make_tc(tc_data, default_id=string(uuid4())) = begin @@ -713,12 +723,27 @@ end """ shouldTerminate(finalizedCalls::Vector{finalizedOutcome}) -> Bool +The `terminate` flag is set by tool implementations, not by the agent +or the LLM. It signals that the tool itself has completed the user's +request or encountered a fatal condition, so the agent should stop +processing further turns without calling the LLM again. + +Common scenarios where a tool sets `terminate: true`: + - **Task completion**: one-shot tools like `deploy`, `submit`, or + `send_payment` finish their work and report directly to the user + instead of asking the LLM "what next?" + - **Unrecoverable error**: a tool hits a fatal condition (database + connection lost, auth token expired) and stops the agent from + retrying endlessly. + - **Async handoff**: a tool triggers a long-running external operation + and wants the agent to stop now; the external system will resume + the agent later via `continue()`. + Returns `true` only when every finalized call in the batch has `result.terminate == true`. All tools must agree — if any tool did not request termination, the agent continues. This prevents -a single tool that happens to set `terminate: true` (e.g. for -metadata purposes) from accidentally stopping the agent when -other tools in the batch did not intend to terminate. +a single tool that happens to set `terminate: true` from accidentally +stopping the agent when other tools in the batch did not intend to terminate. # Arguments - `finalizedCalls`: Vector of finalized tool call outcomes @@ -739,7 +764,7 @@ true ``` """ function shouldTerminate(batches::Vector{finalizedOutcome})::Bool - return !isempty(batches) && all(b -> b.result.terminate, batches) + return !isempty(batches) && all(b -> b.result.terminate, batches) end """ @@ -849,7 +874,7 @@ function prepareToolCall( agentEventSink )::Union{preparedToolCall,immediateOutcome} agentEventSink("prepareToolCall 1") - tool = get(context.tools, toolCall.name, nothing) + tool = get(context.tools, toolCall.name, nothing) # pick a called tool from tool store if tool === nothing agentEventSink("prepareToolCall 2") return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) @@ -859,8 +884,10 @@ function prepareToolCall( agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) + agentEventSink(string(prepared.arguments)) agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) + agentEventSink(string(validatedArgs)) agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing @@ -886,12 +913,12 @@ function prepareToolCall( return preparedToolCall(tool, toolCall, validatedArgs) catch e bt = catch_backtrace() - err_msg = sprint() do io + errMsg = sprint() do io showerror(io, e, bt) println(io) end - agentEventSink(err_msg) + agentEventSink(errMsg) return immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) end @@ -937,36 +964,40 @@ executePreparedToolCall(prep, nothing, emit) # => executedOutcome(createErrorToolResult("Connection timeout"), true) ``` """ + function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, agentEventSink, )::executedOutcome agentEventSink("executePreparedToolCall 1") - updateEvents = promise[] - accepting = true + agentEventSink(prep.toolCall.id) + agentEventSink(prep.toolCall.name) + agentEventSink("executePreparedToolCall 2") + s = string(prep.args) + agentEventSink(s) + agentEventSink("executePreparedToolCall 3") + t = string(fieldnames(typeof(prep.tool))) + agentEventSink("executePreparedToolCall 3-1 " * t) try - result = prep.tool.execute( - prep.toolCall.id, prep.args, signal, - partialResult -> begin - if accepting - push!(updateEvents, - agentEventSink(toolExecUpdateEvent(prep.toolCall.id, prep.toolCall.name, - prep.toolCall.arguments, partialResult))) - end - end - ) - accepting = false - wait.(updateEvents) + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) + agentEventSink(result.content[1].text) + agentEventSink("executePreparedToolCall 4") return executedOutcome(result, false) - catch err - accepting = false - wait.(updateEvents) - return executedOutcome(createErrorToolResult(sprint(showerror, err)), true) + catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) + + return executedOutcome(createErrorToolResult(sprint(showerror, e)), true) end end + # ── per-call finalization ─────────────────────────────────────── """ @@ -1030,16 +1061,19 @@ function finalizeExecutedToolCall( executed::executedOutcome, config::agentLoopConfig, signal::Union{Nothing,abortSignal}, + agentEventSink )::finalizedOutcome - + agentEventSink("finalizeExecutedToolCall 1") result = executed.result isError = executed.isError - + agentEventSink("finalizeExecutedToolCall 2") if config.afterToolCall !== nothing try after = config.afterToolCall( - afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), + signal ) + agentEventSink("finalizeExecutedToolCall 3") if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), :details=>get(after,:details,result.details), @@ -1047,12 +1081,19 @@ function finalizeExecutedToolCall( :terminate=>get(after,:terminate,result.terminate))) isError = get(after, :isError, isError) end - catch err - result = createErrorToolResult(sprint(showerror, err)) + catch e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) + + result = createErrorToolResult(sprint(showerror, e)) isError = true end end - + agentEventSink("finalizeExecutedToolCall 4") return finalizedOutcome(prep.toolCall, result, isError) end @@ -1123,30 +1164,32 @@ function executeToolCallsSequential( messages = toolResultMessage[] for tc in toolCalls - agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") - prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) - agentEventSink("executeToolCallsSequential 2") - if prep isa immediateOutcome - agentEventSink("executeToolCallsSequential 2-1") - finalized = finalizedOutcome(tc, prep.result, prep.isError) - agentEventSink("executeToolCallsSequential 2-2") - else - agentEventSink("executeToolCallsSequential 3") - executed = executePreparedToolCall(prep, signal, agentEventSink) - agentEventSink("executeToolCallsSequential 3-1") - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, - signal) - agentEventSink("executeToolCallsSequential 3-2") - end - agentEventSink("executeToolCallsSequential 4") - agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), - $(finalized.result), $(finalized.isError)") - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - agentEventSink("executeToolCallsSequential 5") - if signal !== nothing && signal.aborted - break - end + agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + agentEventSink("executeToolCallsSequential " * string(prep.args)) + + if prep isa immediateOutcome + agentEventSink("executeToolCallsSequential 2-1") + finalized = finalizedOutcome(tc, prep.result, prep.isError) + agentEventSink("executeToolCallsSequential 2-2") + else + agentEventSink("executeToolCallsSequential 3") + #XXX + executed = executePreparedToolCall(prep, signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-1") + finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, + signal, agentEventSink) + agentEventSink("executeToolCallsSequential 3-2") + end + agentEventSink("executeToolCallsSequential 4") + agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + $(finalized.result), $(finalized.isError)") + push!(messages, createToolResultMessage(finalized)) + push!(finalizedCalls, finalized) + agentEventSink("executeToolCallsSequential 5") + if signal !== nothing && signal.aborted + break + end end agentEventSink("executeToolCallsSequential 6") return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) @@ -1218,12 +1261,12 @@ function executeToolCallsParallel( agentEventSink, )::agentToolCallBatch - entries = union{finalizedOutcome,task{finalizedOutcome}}[] + entries = Union{finalizedOutcome,Task}[] for tc in toolCalls agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) - prep = prepareToolCall(context, assistantMsg, tc, config, signal) + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) if prep isa immediateOutcome finalized = finalizedOutcome(tc, prep.result, prep.isError) @@ -1231,15 +1274,15 @@ function executeToolCallsParallel( finalized.result, finalized.isError)) push!(entries, finalized) else - task = task() do + t = Task() do executed = executePreparedToolCall(prep, signal, agentEventSink) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) return finalized end - schedule(task) - push!(entries, task) + schedule(t) + push!(entries, t) end if signal !== nothing && signal.aborted @@ -1249,7 +1292,7 @@ function executeToolCallsParallel( finalizedCalls = finalizedOutcome[] for entry in entries - outcome = entry isa task ? fetch(entry) : entry + outcome = entry isa Task ? fetch(entry) : entry push!(finalizedCalls, outcome) end diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index ef33baa..1d6bd52 100644 --- a/src/toolRegistry.jl +++ b/src/toolRegistry.jl @@ -1,6 +1,6 @@ module toolRegistry -export toolStore, loadTools, registerTool, getTools, clearTools, listTool +export toolStore, registerTool, getTools, clearTools, listTool using Dates using JSON, DataStructures @@ -45,7 +45,7 @@ end Return an `agentTool` definition for listing registered tools. Each call produces a **new** tool object that captures (closes over) -`store`. `loadTools` auto-registers one so the LLM can discover tools +`store`. `register_all_tools` auto-registers one so the LLM can discover tools at runtime. # Arguments @@ -55,7 +55,7 @@ at runtime. ```julia julia> store = toolStore(name="agent1"); -julia> loadTools(store, "src/tools") # auto-registers listTools +julia> register_all_tools(store) # auto-registers listTools [toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) [toolRegistry:agent1] Registered tool: listTools @@ -97,99 +97,7 @@ function listTool(store::toolStore)::agentTool ) end -""" -Load `.jl` tool files from `dir` into `store`, then auto-register -`listTool` so the LLM can discover available tools at runtime. - -Each `.jl` file must define `function getTool()::agentTool ... end`. -Files are sorted alphabetically for deterministic registration order. -Each file is loaded into its own Julia submodule to avoid name collisions. - -# Arguments -- `store`: Tool store to populate -- `dir`: Directory containing `.jl` tool files - -# Returns -- The same `store.tools` dict (modified in place) - -# Errors -- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()` - -# Example -```julia -julia> store = toolStore(name="agent1"); - -julia> loadTools(store, "src/tools") -[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) -[toolRegistry:agent1] Loaded tool: getTime (Time Lookup) -[toolRegistry:agent1] Registered tool: listTools -OrderedDict{String, agentTool} with 3 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) - "listTools" => agentTool(...) -``` -""" -function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool} - if !isdir(dir) - throw(ArgumentError("Tool directory does not exist: $dir")) - end - - jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir)) - sort!(jl_files) - - for filename in jl_files - filepath = joinpath(dir, filename) - - # Derive a unique module name from the filename only (not full path). - # e.g. "getWeather.jl" -> "_tool_getWeather" - mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => "")) - - # Build the complete module as a string and eval the parsed code. - # Julia does not allow `module ... end` inside eval(quote ...), - # and constructing the module AST by hand is fragile. - # Instead, we generate the full module source as a string, - # parse it, and eval the resulting expression. - # Each tool file declares its own dependencies via `using` statements - # at the top of the file — the registry only injects `using ..type` - # to make core types (agentTool, textContent, etc.) available. - file_content = read(filepath, String) - module_code = """ - module $(mod_name) - using ..type - $(file_content) - end - """ - mod = eval(Meta.parse(module_code)) - - # Call getTool() via Core.eval in the submodule's scope. - # This evaluates getTool() entirely within the new module's world, - # completely avoiding world-age issues — no invokelatest needed. - # Note: all uses of `tool` must be inside the `try` block because - # Julia 1.12's SSA form doesn't track `tool` as definitely assigned - # after a `try-catch` where it's only assigned inside `try`. - try - tool = Core.eval(mod, :(getTool())) - if !(tool isa agentTool) - throw(ArgumentError( - "getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))" - )) - end - store.tools[tool.name] = tool - println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))") - catch e - if e isa UndefVarError || occursin("getTool", sprint(showerror, e)) - throw(ArgumentError( - "Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " * - "Each tool file must define: function getTool()::agentTool ... end" - )) - end - rethrow(e) - end - end - - registerTool(store, listTool(store)) - return store.tools -end +# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope """ registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 88098d7..748d5e5 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -1,3 +1,4 @@ +using .type using Dates """ @@ -15,7 +16,7 @@ Demonstrates custom validation beyond simple required-field checking: - `nothing` if validation passes - `String` error message if validation fails """ -function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} +function getTimeValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String} tz = get(args, "timezone", nothing) city = get(args, "city", "") @@ -43,8 +44,8 @@ Execute the getTime tool. Returns mock time data for the given timezone or city. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult +function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + onPartialResult) tz = get(args, "timezone", nothing) city = get(args, "city", "") if tz !== nothing @@ -61,7 +62,7 @@ end """ Define and return the getTime agentTool. """ -function getTool()::agentTool +function getTimeTool()::agentTool return agentTool( name = "getTime", label = "Time Lookup", @@ -74,9 +75,9 @@ function getTool()::agentTool ), "required" => [] ), - execute = executeTool, + execute = getTimeExecute, prepareArguments = nothing, - validateRequiredArgs = validateRequiredArgs, + validateRequiredArgs = getTimeValidateRequiredArgs, parallelToolExecute = false ) end diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 61639cf..5cca9cd 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,24 +1,33 @@ +using msghandler +using .type + """ Execute the getWeather tool. Returns mock weather data for the given city and temperature units. """ -function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult::Function)::agentToolResult - city = get(args, "city", "") - units = get(args, "units", "celsius") - temp = units == "fahrenheit" ? "72" : "22" - unit_symbol = units == "celsius" ? "°C" : "°F" - return agentToolResult( - [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], - Dict{Any,Any}(), nothing, false - ) +function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, + agentEventSink) + + agentEventSink("Getting weather...") + + city = get(args, "city", "") + units = get(args, "units", "celsius") + temp = units == "fahrenheit" ? "72" : "22" + unit_symbol = units == "celsius" ? "°C" : "°F" + + return agentToolResult( + [textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")], + Dict{Any,Any}(), + nothing, + false + ) end """ Define and return the getWeather agentTool. """ -function getTool()::agentTool +function getWeatherTool()::agentTool return agentTool( name = "getWeather", label = "Weather Lookup", @@ -31,7 +40,7 @@ function getTool()::agentTool ), "required" => ["city"] ), - execute = executeTool, + execute = getWeatherExecute, prepareArguments = nothing, validateRequiredArgs = nothing, parallelToolExecute = false diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index c682e1d..8524772 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -1,3 +1,4 @@ +using .type using JSON """ @@ -7,15 +8,17 @@ The agent can use this tool when it encounters a task that no existing tool can handle. Provide the tool's name, label, description, inputSchema, and execute logic as Julia code. The tool is written to `src/tools/.jl`. -After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks -up the new file. The new tool is immediately available. +After calling this tool, add the new file to `YiemAgent.jl` with an `include()` +statement (after `include("toolRegistry.jl")`), then restart the agent. +The new tool must be registered in `register_all_tools()` in `toolRegistry.jl`. # Example 1. Agent calls writeTool with a spec for a "searchWine" tool 2. writeTool generates src/tools/searchWine.jl -3. Restart agent — loadTools() picks up the new file -4. Agent calls searchWine with args +3. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl +4. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() +5. Restart agent — new tool is available # How It Works @@ -24,7 +27,7 @@ tool logic as `executeCode`, and writeTool wraps it in Julia boilerplate: - Converts `inputSchema` Dict into Julia `Dict{String,Any}(...)` string - Indents `executeCode` with 4 spaces - Wraps it inside `function executeTool(...)::agentToolResult ... end` - - Appends `getTool()` returning an `agentTool` struct + - Appends `writeToolTool()` returning an `agentTool` struct - Writes the combined string to `src/tools/.jl` # Important Notes @@ -105,7 +108,7 @@ end """ Define and return the writeTool agentTool. """ -function getTool()::agentTool +function writeToolTool()::agentTool return agentTool( name = "writeTool", label = "Create Tool", @@ -127,7 +130,7 @@ function getTool()::agentTool ), "required" => ["name", "label", "description", "inputSchema", "executeCode"] ), - execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function) -> begin + execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin tool_name = get(args, "name", "")::String tool_label = get(args, "label", tool_name)::String tool_description = get(args, "description", "")::String @@ -250,13 +253,13 @@ function getTool()::agentTool tool_code = join(parts) - # Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools") + # Write the file — tool must be included in YiemAgent.jl and registered in register_all_tools() write(filepath, tool_code) onPartialResult(Dict("status" => "Done")) return agentToolResult( - [textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")], + [textContent("Tool '$(tool_name)' written to $filepath. Add include(\"tools/$(tool_name).jl\") to YiemAgent.jl and registerTool(store, $(tool_name)Tool()) to register_all_tools(), then restart the agent.")], Dict{Any,Any}( "file" => filepath, "name" => tool_name, diff --git a/src/type.jl b/src/type.jl index b6bf8a9..6eab694 100644 --- a/src/type.jl +++ b/src/type.jl @@ -264,7 +264,7 @@ struct agentTool # A tool available to the agent label::String # Human-readable tool name description::String # What the tool does inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) - execute::Function # Tool execution function + execute # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel @@ -274,7 +274,7 @@ end Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. """ function agentTool(; name::String, label::String, description::String, inputSchema::Any, - execute::Function, prepareArguments::Union{Function, Nothing}=nothing, + execute, prepareArguments::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing, parallelToolExecute::Bool=false) return agentTool(name, label, description, inputSchema, execute, diff --git a/src/utils.jl b/src/utils.jl index 18e5147..f2dc740 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -241,7 +241,38 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} return openaiReadyMsg end -#TODO +""" + beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult + +Callback invoked before executing a tool call. Use this hook to inspect +the tool call and decide whether to allow, block, or modify it. + +Common use cases: + - Request user approval via UI before running destructive tools. + - Validate business rules that cannot be expressed in the JSON schema. + - Check final context (e.g. session state, rate limits, permissions). + +# Arguments +- `context::beforeToolCallContext`: Contains the assistant message, tool call, + validated arguments, and current conversation context. +- `signal::abortSignal`: Signal that may be set to abort the operation. + +# Returns +- `beforeToolCallResult(false, "N/A")` to allow the call to proceed. +- `beforeToolCallResult(true, "Reason")` to block the call with a reason. +- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`). + +# Example +```julia +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) + if context.toolCall.name == "deleteFile" + # Block file deletion unless explicitly approved + return beforeToolCallResult(true, "User must approve file deletion") + end + return beforeToolCallResult(false, "N/A") +end +``` +""" function beforeToolCall(context::beforeToolCallContext, signal::abortSignal )::beforeToolCallResult @@ -254,8 +285,31 @@ function beforeToolCall(context::beforeToolCallContext, signal::abortSignal return beforeToolCallResult(false, "N/A") end -#TODO -function afterToolCall(context::beforeToolCallContext, signal::abortSignal +""" + afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing} + +Callback invoked after a tool call finishes executing (before and after errors). +Use this hook to post-process the tool result before it is fed back to the LLM. + +Common use cases: + - Mask sensitive data (API keys, tokens) from result content. + - Normalize usage tracking data into a consistent format. + - Inspect the result and set `terminate: true` based on business logic + (e.g. "if deployment failed, stop the agent rather than retrying"). + - Wrap error results in friendlier messages for the LLM to understand. + +# Arguments +- `context::afterToolCallContext`: Contains the assistant message, tool call, + arguments, raw result, error status, and current conversation context. +- `signal::abortSignal`: Signal that may be set to abort the operation. + +# Returns +- `nothing` to pass the result through unchanged. +- `agentToolResult(...)` to return a modified result (content, details, usage, + terminate flag can all be overridden). + +""" +function afterToolCall(context::afterToolCallContext, signal::abortSignal )::Union{agentToolResult, Nothing} # modify context.result if needed and return agentToolResult diff --git a/test/toolTest.jl b/test/toolTest.jl index b239f36..f613397 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -1,35 +1,19 @@ using Test +using Dates using YiemAgent using YiemAgent.toolRegistry using YiemAgent.type +using YiemAgent.agentCore -# Path to the real tools directory -TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") - -@testset "loadTools with toolStore" begin +@testset "register_all_tools with toolStore" begin # ------------------------------------------------------------------ # - # 1. loadTools throws on non-existent directory # + # 1. register_all_tools registers all static tools + listTools # # ------------------------------------------------------------------ # store = toolStore(name="test1") - @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") - - # ------------------------------------------------------------------ # - # 2. loadTools throws if a .jl file does not define getTool() # - # Must run BEFORE any other loadTools call (getTool binding # - # persists in module scope after include()). # - # ------------------------------------------------------------------ # - bad_dir = mktempdir() - write(joinpath(bad_dir, "noTool.jl"), "x = 42\n") - @test_throws ArgumentError loadTools(store, bad_dir) - - # ------------------------------------------------------------------ # - # 3. loadTools loads actual tool files from src/tools/ # - # ------------------------------------------------------------------ # - store2 = toolStore(name="test2") - loaded = loadTools(store2, TOOLS_DIR) + loaded = register_all_tools(store) @test !isempty(loaded) - @test length(loaded) == 4 # 3 files + auto-registered listTools + @test length(loaded) == 4 # getWeather + getTime + writeTool + listTools names = [k for k in keys(loaded)] @test "getTime" in names @@ -38,16 +22,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test "listTools" in names # ------------------------------------------------------------------ # - # 4. loadTools returns tools sorted alphabetically by filename # - # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end # + # 2. register_all_tools returns tools in registration order # # ------------------------------------------------------------------ # - @test collect(keys(loaded))[1] == "getTime" - @test collect(keys(loaded))[2] == "getWeather" + @test collect(keys(loaded))[1] == "getWeather" + @test collect(keys(loaded))[2] == "getTime" @test collect(keys(loaded))[3] == "writeTool" @test collect(keys(loaded))[4] == "listTools" # ------------------------------------------------------------------ # - # 5. Verify loaded tool fields are correct # + # 3. Verify loaded tool fields are correct # # ------------------------------------------------------------------ # # getTime time_tool = loaded["getTime"] @@ -74,7 +57,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test "executeCode" in wt.inputSchema["required"] # ------------------------------------------------------------------ # - # 6. Tool execution returns valid results # + # 4. Tool execution returns valid results # # ------------------------------------------------------------------ # sig = nothing op = x -> x # no-op partial result callback @@ -98,16 +81,15 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") # execute getWeather with units result_w2 = weather.execute("call-4", Dict{String,Any}("city" => "London", "units" => "fahrenheit"), sig, op) - @test occursin("72°F", result_w2.content[1].text) + @test occursin("72\u00b0F", result_w2.content[1].text) # ------------------------------------------------------------------ # - # 7. getTools / registerTool / clearTools (per-store isolation) # + # 5. getTools / registerTool / clearTools (per-store isolation) # # ------------------------------------------------------------------ # store3 = toolStore(name="test3") registry_tools = getTools(store3) @test isempty(registry_tools) - # listTool is not auto-registered anymore — each store starts empty # Register tools manually registerTool(store3, loaded["getTime"]) registerTool(store3, loaded["getWeather"]) @@ -143,7 +125,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test reg["manualTool"].parallelToolExecute == true # ------------------------------------------------------------------ # - # 8. getTools returns direct reference (mutations affect registry) # + # 6. getTools returns direct reference (mutations affect registry) # # ------------------------------------------------------------------ # copy1 = getTools(store3) copy2 = getTools(store3) @@ -152,7 +134,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") @test isempty(getTools(store3)) # mutation propagates # ------------------------------------------------------------------ # - # 9. Per-store isolation — two stores don't share tools # + # 7. Per-store isolation — two stores don't share tools # # ------------------------------------------------------------------ # storeA = toolStore(name="isolationA") storeB = toolStore(name="isolationB") @@ -175,10 +157,10 @@ end @testset "listTool" begin store = toolStore(name="test_list") - loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools + register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools - # loadTools auto-registers listTool - @test "listTools" in keys(loaded) + # register_all_tools auto-registers listTool + @test "listTools" in keys(store.tools) # listTool returns an agentTool, not a string or array list_t = listTool(store) @@ -199,7 +181,7 @@ end # Each listTool call creates an independent closure storeB = toolStore(name="test_listB") - registerTool(storeB, loaded["getWeather"]) + registerTool(storeB, store.tools["getWeather"]) list_tB = listTool(storeB) resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) @@ -210,3 +192,176 @@ end @test occursin("getTime", resultA.content[1].text) @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather end + +@testset "executePreparedToolCall with static tools" begin + # Tests executePreparedToolCall with statically loaded tools. + # The world-age issue is resolved because tool.execute comes from + # a statically included module, not a dynamically created one. + + store = toolStore(name="test_static") + register_all_tools(store) + + weather_tool = store.tools["getWeather"] + + # Create a preparedToolCall that mimics what prepareToolCall() returns + tool_call = agentToolCall( + "function", "call-static-1", "getWeather", + Dict{String,Any}("city" => "San Francisco") + ) + prep = preparedToolCall( + weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco") + ) + sig = abortSignal(false) + + # This call goes through: executePreparedToolCall -> prep.tool.execute(...) + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("San Francisco", result.result.content[1].text) +end + +@testset "executePreparedToolCall with validation (static tools)" begin + # Tests executePreparedToolCall with a tool that has custom validation hooks. + # This exercises the full tool execution path including validation. + + store = toolStore(name="test_static_validate") + register_all_tools(store) + + time_tool = store.tools["getTime"] + + tool_call = agentToolCall( + "function", "call-static-2", "getTime", + Dict{String,Any}("timezone" => "America/New_York") + ) + prep = preparedToolCall( + time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York") + ) + sig = abortSignal(false) + + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("America/New_York", result.result.content[1].text) +end + +@testset "executeToolCallsSequential with static tools (full pipeline)" begin + # Tests the full tool execution pipeline: executeToolCallsSequential + # which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall + # with statically loaded tools. + + store = toolStore(name="test_full_pipeline") + register_all_tools(store) + + # Build agentContext from the store's tools + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + # Create an assistant message containing tool calls + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + # Create tool calls for multiple statically loaded tools + tool_calls = [ + agentToolCall( + "function", "call-seq-1", "getWeather", + Dict{String,Any}("city" => "Tokyo") + ), + agentToolCall( + "function", "call-seq-2", "getTime", + Dict{String,Any}("timezone" => "Europe/London") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "sequential" + ) + sig = abortSignal(false) + + # Execute the full pipeline + batch = executeToolCallsSequential( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test occursin("Tokyo", batch.messages[1].content[1].text) + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false + @test occursin("Europe/London", batch.messages[2].content[1].text) +end + +@testset "executeToolCallsParallel with static tools (full pipeline)" begin + # Same as above but tests parallel execution path. + + store = toolStore(name="test_parallel") + register_all_tools(store) + + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + tool_calls = [ + agentToolCall( + "function", "call-par-1", "getWeather", + Dict{String,Any}("city" => "Paris") + ), + agentToolCall( + "function", "call-par-2", "getTime", + Dict{String,Any}("city" => "Sydney") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "parallel" + ) + sig = abortSignal(false) + + batch = executeToolCallsParallel( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false +end diff --git a/user_code.jl b/user_code.jl new file mode 100644 index 0000000..2123d8a --- /dev/null +++ b/user_code.jl @@ -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) + + + + From fd616409dddd4a61eba90557eb860d8455d99a7f Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 15 Aug 2026 20:03:01 +0700 Subject: [PATCH 19/23] update --- etc.jl | 84 ++++--------------------------- src/agentCore.jl | 127 ++++++++++++++++++++--------------------------- src/type.jl | 2 - 3 files changed, 62 insertions(+), 151 deletions(-) diff --git a/etc.jl b/etc.jl index 7d92ff2..c6c563a 100644 --- a/etc.jl +++ b/etc.jl @@ -1,77 +1,11 @@ -i am not sure that's the case. see my NATS message log: - -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "new user msg" -┌ Info: debug -└ payload = "_process_message 3" -┌ Info: debug -└ payload = "_process_message 5" -┌ Info: debug -└ payload = "_process_message 6" -┌ Info: debug -└ payload = "_process_message 7" - - -my NATS receiver report the following for a long time -┌ Info: debug -└ payload = "new user msg" - -untill I Ctrl + d so shutdown the process then i got the following report -┌ Info: debug -└ payload = "_process_message 3" -┌ Info: debug -└ payload = "_process_message 5" -┌ Info: debug -└ payload = "_process_message 6" -┌ Info: debug -└ payload = "_process_message 7" - - - - - - - - - -my point is if _process_message() actually run then this code in _process_message() -"raw_msg = take!(agent.inputChannel)" -should take the new msg message out of agent.inputChannel and there should be only one debug message showing -┌ Info: debug -└ payload = "new user msg" - -before reaching error("debug marker") - - - - - - - - - - - - - - - - - - - - +_processMessage 10 +JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in Bangkok is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user is asking for the weather in Bangkok.\nI have already retrieved the weather information in the previous turn and provided it to the user.\nThe user's current input is \"What's the weather in Bangkok?\", which is the same question as before.\nI should provide the same answer again.\nNo new tool calls are needed.\nI will simply state the weather information retrieved previously.\nWeather in Bangkok: Sunny, 22°C.\nI will output the answer directly.\n")) +_processMessage 11 +hasToolCalls: false +toolCallList: YiemAgent.type.agentToolCall[] +_processMessage 17 +_processMessage 18 +--- +here is my log. from the log, it seems like _processMessage() is finished but somehow the log didn't show _agentLoop 5 message. _processMessage() may not exit properly but why? diff --git a/src/agentCore.jl b/src/agentCore.jl index c45945e..c1a4168 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -169,62 +169,77 @@ julia> # Called automatically by yiemAgent constructor function _agentLoop(agent::yiemAgent) processMessageInputCh = Channel(32) try + newUserMsg = nothing processingTask = nothing + result = nothing """ cases: 1) agent -> idle, user msg -> nothing typeof(processingTask) == Nothing - agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing 2) agent -> idle, user msg -> new msg typeof(processingTask) == Nothing - agent._state.activeRun -> false agent.inputChannel -> new msg agent.followUpChannel -> nothing 3) agent -> running, user msg -> nothing typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> nothing 4) agent -> running, user msg -> new msg typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> new msg agent.followUpChannel -> nothing 5) agent -> running, user msg -> nothing, user msg follow up -> new msg typeof(processingTask) == Task, istaskdone(processingTask) -> false - agent._state.activeRun -> true agent.inputChannel -> nothing agent.followUpChannel -> new msg 6) agent -> idle, user msg -> nothing typeof(processingTask) == Task, istaskdone(processingTask) -> true - agent._state.activeRun -> false agent.inputChannel -> nothing agent.followUpChannel -> nothing """ while true - result = nothing - msg = nothing - while msg === nothing + while newUserMsg === nothing if isready(agent.inputChannel) - - # message will be taken in _processMessage() - msg = take!(agent.inputChannel) + agent.agentEventSink("_agentLoop 1") + # agent process new user msg immediately after the current tool call finished. + newUserMsg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else + # check followUp message after _processMessage() is done + if typeof(processingTask) == Task && istaskdone(processingTask) == true + agent.agentEventSink("_agentLoop 2") + # if agent runs is done but followUpChannel has messages, + # put new message in inputChannel instead + if isready(agent.followUpChannel) + agent.agentEventSink("_agentLoop 3") + while isready(agent.followUpChannel) + followUpMsg = take!(agent.followUpChannel) + put!(agent.inputChannel, followUpMsg) + end + else # _processMessage() done and no followUp message. + agent.agentEventSink("_agentLoop 4") + result = fetch(processingTask) + put!(agent.outputChannel, result) + agent.agentEventSink(result.content[1].text) + processingTask = nothing # reset + newUserMsg = nothing # reset + result = nothing # reset + end + end yield() end end # Check for shutdown signal - if msg === :shutdown + if newUserMsg === :shutdown # Drain all remaining messages in the input channel if isready(agent.inputChannel) while isready(agent.inputChannel) @@ -238,67 +253,31 @@ function _agentLoop(agent::yiemAgent) end #TODO make sure every running tools ended properly + + newUserMsg = nothing # reset break else - agent.agentEventSink("_agentLoop push 1") - put!(processMessageInputCh, msg) - agent.agentEventSink("_agentLoop push 2") - end - - # start _processMessage loop - if agent._state.activeRun == false - agent.agentEventSink("_agentLoop 2") - # Dispatch message through the processing pipeline - processingTask = @spawn _processMessage( - processMessageInputCh, - agent.agentEventSink, - agent._state.messages, - agent._state.systemPrompt, - agent._state.tools, - agent.prepareContext, - agent.formatMsgForLLM, - agent.llmCall, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute, - ) - agent._state.activeRun = true - agent.agentEventSink("_agentLoop 3") - end - - # during agent runs, check followUp message after _processMessage() is done - if typeof(processingTask) == Task && istaskdone(processingTask) == false - agent.agentEventSink("_agentLoop 4") - # if followUp message available, add them all to agent.inputChannel - if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 4-1") - while isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 4-2") - followMsg = take!(agent.followUpChannel) - put!(agent.inputChannel, followMsg) - end + # spawn new _processMessage() if it is not already running. + if processingTask === nothing + agent.agentEventSink("_agentLoop 2") + # Dispatch message through the processing pipeline + processingTask = @spawn _processMessage( + processMessageInputCh, + agent.agentEventSink, + agent._state.messages, + agent._state.systemPrompt, + agent._state.tools, + agent.prepareContext, + agent.formatMsgForLLM, + agent.llmCall, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute, + ) end - agent.agentEventSink("_agentLoop 4-3") - continue # continue to process user message in the next loop - - elseif typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 5") - # if agent runs is done but followUpChannel has messages, discard all message in it. - # when agent work is done it should not accept follow up msg. - # user should put new message in inputChannel instead - if isready(agent.followUpChannel) - while isready(agent.followUpChannel) - _ = take!(agent.followUpChannel) - end - end - result = fetch(processingTask) - put!(agent.outputChannel, result) - agent._state.activeRun = false # reset - processingTask = nothing # reset + put!(processMessageInputCh, newUserMsg) + newUserMsg = nothing # reset end - agent.agentEventSink("_agentLoop 6") - agent.agentEventSink(string(typeof(processingTask))) - agent.agentEventSink("_agentLoop 7") end catch e # On any error, send error response and exit the loop @@ -368,17 +347,17 @@ function _processMessage( # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) agentEventSink("_processMessage 2") - raw_msg = take!(inputChannel) + newUserMsg_openai = take!(inputChannel) agentEventSink("_processMessage 3") - if raw_msg === :shutdown + if newUserMsg_openai === :shutdown agentEventSink("_processMessage 4") # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end agentEventSink("_processMessage 5") - user_msg = OpenAiToUserMessage(raw_msg) - push!(agentMsgHistory, user_msg) + newUserMsg = OpenAiToUserMessage(newUserMsg_openai) + push!(agentMsgHistory, newUserMsg) agentEventSink("_processMessage 6") end agentEventSink("_processMessage 7") diff --git a/src/type.jl b/src/type.jl index 6eab694..54ff272 100644 --- a/src/type.jl +++ b/src/type.jl @@ -317,7 +317,6 @@ mutable struct agentState # Mutable runtime state of an agen messages::Vector{agentMessage} pendingToolCalls::Vector{String} # Tool call IDs waiting for results - activeRun::Bool # is agent processing user message? errorMessage::Union{String, Nothing} # Last error message end @@ -354,7 +353,6 @@ function agentState( deepcopy(tools), deepcopy(messages), Vector{String}(), - false, nothing, ) end From a29a82b74c6f49a20ddef1233782ee490308a9c8 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 13:36:01 +0700 Subject: [PATCH 20/23] update --- etc.jl | 16 ++-- etc.md | 8 ++ src/agentCore.jl | 197 +++++++++++++++++++++++++---------------------- src/type.jl | 58 ++++++++++++-- src/utils.jl | 40 ++++++++++ 5 files changed, 212 insertions(+), 107 deletions(-) create mode 100644 etc.md diff --git a/etc.jl b/etc.jl index c6c563a..be4b0e2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,11 +1,5 @@ -_processMessage 10 -JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in Bangkok is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user is asking for the weather in Bangkok.\nI have already retrieved the weather information in the previous turn and provided it to the user.\nThe user's current input is \"What's the weather in Bangkok?\", which is the same question as before.\nI should provide the same answer again.\nNo new tool calls are needed.\nI will simply state the weather information retrieved previously.\nWeather in Bangkok: Sunny, 22°C.\nI will output the answer directly.\n")) -_processMessage 11 -hasToolCalls: false -toolCallList: YiemAgent.type.agentToolCall[] -_processMessage 17 -_processMessage 18 - ---- -here is my log. from the log, it seems like _processMessage() is finished but somehow the log didn't show _agentLoop 5 message. _processMessage() may not exit properly but why? - +check my understand: +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 \ No newline at end of file diff --git a/etc.md b/etc.md new file mode 100644 index 0000000..4664add --- /dev/null +++ b/etc.md @@ -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? + + diff --git a/src/agentCore.jl b/src/agentCore.jl index c1a4168..5284d54 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -208,25 +208,25 @@ function _agentLoop(agent::yiemAgent) while true while newUserMsg === nothing if isready(agent.inputChannel) - agent.agentEventSink("_agentLoop 1") + agent.agentEventSink("_agentLoop 1 agent._state.messages length $(length(agent._state.messages))") # agent process new user msg immediately after the current tool call finished. newUserMsg = take!(agent.inputChannel) agent.agentEventSink("new user msg") else # check followUp message after _processMessage() is done if typeof(processingTask) == Task && istaskdone(processingTask) == true - agent.agentEventSink("_agentLoop 2") + agent.agentEventSink("_agentLoop 2 agent._state.messages length $(length(agent._state.messages))") # if agent runs is done but followUpChannel has messages, # put new message in inputChannel instead if isready(agent.followUpChannel) - agent.agentEventSink("_agentLoop 3") + agent.agentEventSink("_agentLoop 3 agent._state.messages length $(length(agent._state.messages))") while isready(agent.followUpChannel) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) end else # _processMessage() done and no followUp message. - agent.agentEventSink("_agentLoop 4") - result = fetch(processingTask) + agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") + result = agent._state.messages[end] put!(agent.outputChannel, result) agent.agentEventSink(result.content[1].text) processingTask = nothing # reset @@ -259,7 +259,7 @@ function _agentLoop(agent::yiemAgent) else # spawn new _processMessage() if it is not already running. if processingTask === nothing - agent.agentEventSink("_agentLoop 2") + agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, @@ -325,8 +325,8 @@ function _processMessage( beforeToolCall::Union{Function, Nothing}, afterToolCall::Union{Function, Nothing}, parallelToolExecute::Bool, -)::assistantMessage - agentEventSink("_processMessage 1") +)::Nothing + agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") # loop until llmCall() response didn't use tool calls final_response = nothing @@ -346,41 +346,46 @@ function _processMessage( while true # Drain inputChannel and convert OpenAI-format messages to userMessage type while isready(inputChannel) - agentEventSink("_processMessage 2") + agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") newUserMsg_openai = take!(inputChannel) - agentEventSink("_processMessage 3") + agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") if newUserMsg_openai === :shutdown - agentEventSink("_processMessage 4") + agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") # Re-emit shutdown signal for the loop to handle put!(inputChannel, :shutdown) break end - agentEventSink("_processMessage 5") + agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") newUserMsg = OpenAiToUserMessage(newUserMsg_openai) push!(agentMsgHistory, newUserMsg) - agentEventSink("_processMessage 6") + agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") end - agentEventSink("_processMessage 7") + agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) - agentEventSink("_processMessage 8") + agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") preparedContext = prepareContext(state, agentEventSink) - agentEventSink("_processMessage 8") + agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - - agentEventSink("_processMessage 10") + + agentEventSink("_processMessage 10 formattedMessages $formattedMessages") """ response example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ response = llmCall(formattedMessages) - agentEventSink(string(response)) + agentEventSink(" llmCall " * string(response)) - agentEventSink("_processMessage 11") + agentEventSink("_processMessage 11 _state.messages length $(length(agentMsgHistory))") # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) - agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList") + agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") + agentEventSink(string(assistant_msg)) + agentEventSink("_processMessage 11-1") + + # Add assistant message (tool calls or text) to history for next LLM turn + push!(agentMsgHistory, assistant_msg) if hasToolCalls && length(toolCallList) > 0 # Build context and config for executeToolCalls @@ -392,20 +397,20 @@ function _processMessage( ) signal = abortSignal(false) - agentEventSink("_processMessage 12") + agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") # call executeToolCalls() toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, signal, agentEventSink) - agentEventSink("_processMessage 13") + agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") # save toolResults to messages for toolResult in toolResultBatch.messages push!(agentMsgHistory, toolResult) end - agentEventSink("_processMessage 14") + agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") if toolResultBatch.terminate - agentEventSink("_processMessage 15") + agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") # If toolResultBatch requested termination, build a final response final_content = [textContent("Tool execution completed.")] for toolResult in toolResultBatch.messages @@ -419,7 +424,7 @@ function _processMessage( end end end - agentEventSink("_processMessage 16") + agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") final_response = assistantMessage( role="assistant", content=final_content, @@ -434,17 +439,17 @@ function _processMessage( end, timestamp=now(), ) + push!(agentMsgHistory, final_response) break end else - agentEventSink("_processMessage 17") - # LLM did not use tool calls — this is the final response - final_response = assistant_msg + agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + # LLM did not use tool calls — break end end - agentEventSink("_processMessage 18") - return final_response + agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + return nothing end @@ -516,24 +521,28 @@ function createToolResultMessage(f::finalizedOutcome)::toolResultMessage end """ - _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, assistantMessage} + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, assistantMessage}} -Extracts tool calls from the LLM response and constructs an `assistantMessage`. +Extracts tool calls from the LLM response and constructs a message object. Supports two response formats: 1. **Message format** (e.g. from LMStudio.jl / vLLM): - `response["message"]["tool_calls"]` — array of tool call objects with - `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, - and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. + `response["message"]["tool_calls"]` — array of tool call objects with + `"type" => "function"`, `"function" => Dict("name" => ..., "arguments" => "...")`, + and `"id"`. The `"arguments"` value is a JSON string that gets parsed via `JSON.parse`. 2. **Content blocks format** (e.g. from OpenAI API): - `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` - contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` - have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). + `response.content` — array of content blocks. Blocks with `"type" => "tool_calls"` + contain a `"tool_calls"` array in format 1. Blocks with `"type" => "tool_call"` + have `"name"`, `"arguments"`, `"id"` at the top level (already-parsed args dict). -The `assistantMessage` is constructed from: -- `reasoning_content` (string) → added as a `textContent` block -- `response.content` blocks (text/ reasoning) → added to content +When `hasToolCalls` is true, returns an `assistantMessageToolCall` with the tool calls +and reasoning content. When `hasToolCalls` is false, returns an `assistantMessage` +with text/content blocks from the response. + +The message is constructed from: +- `reasoning_content` (string) → stored in `reasoning` field (for tool calls) or `textContent` (for text) +- `response.content` blocks (text/reasoning) → added to content for text responses - Top-level `api`, `provider`, `model`, `usage` → copied to the message - `finish_reason` → used as `stopReason` @@ -541,7 +550,9 @@ The `assistantMessage` is constructed from: - `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) # Returns -- `Tuple{Bool, Vector{agentToolCall}, assistantMessage}`: `(hasToolCalls, toolCallList, assistantMsg)` +- `Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, assistantMessage}}`: + `(hasToolCalls, toolCallList, message)` where message is `assistantMessageToolCall` + when tool calls exist, `assistantMessage` otherwise # Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) """ @@ -616,20 +627,6 @@ function _extractToolCalls(response) end # ── Construct assistantMessage from response ────────────────────── - # Check Format 1 nested message for reasoning_content, role, etc. - reasoning = get(response, "reasoning_content", nothing) - if reasoning === nothing && msg !== nothing && msg isa AbstractDict - reasoning = get(msg, "reasoning_content", nothing) - end - if reasoning isa String - reasoning_text = reasoning - elseif reasoning isa textContent - reasoning_text = reasoning.text - else - reasoning_text = "" - end - reasoning_block = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] - finish_reason = get(response, "finish_reason", nothing) stop_reason = finish_reason isa AbstractString ? String(finish_reason) : "end_turn" @@ -638,6 +635,21 @@ function _extractToolCalls(response) model = get(response, "model", nothing) usage = get(response, "usage", nothing) + # Collect reasoning from reasoning_content field (Format 1: Anthropic-style) + reasoning_text = get(response, "reasoning_content", nothing) + if reasoning_text === nothing && msg !== nothing && msg isa AbstractDict + reasoning_text = get(msg, "reasoning_content", nothing) + end + if reasoning_text isa String + reasoning_text = reasoning_text + elseif reasoning_text isa textContent + reasoning_text = reasoning_text.text + else + reasoning_text = "" + end + reasoning_content = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + + # Collect content blocks from response.content array (Format 2: OpenAI-style) content_from_response = get(response, "content", nothing) content_blocks = Vector{messageContent}() if content_from_response isa Vector @@ -658,15 +670,11 @@ function _extractToolCalls(response) end end - # Combine content blocks and reasoning - if !isempty(content_blocks) && !isempty(reasoning_block) - all_content = vcat(reasoning_block, content_blocks) - elseif !isempty(content_blocks) - all_content = content_blocks - elseif !isempty(reasoning_block) - all_content = reasoning_block + # Combine reasoning_content field + content array blocks, deduplicating reasoning + if !isempty(reasoning_content) + all_content = vcat(reasoning_content, content_blocks) else - all_content = textContent[] + all_content = content_blocks end error_msg = get(response, "error_message", get(response, "errorMessage", nothing)) @@ -684,17 +692,32 @@ function _extractToolCalls(response) end end - assistant_msg = assistantMessage( - role = role, - content = all_content, - api = api isa AbstractString ? String(api) : "", - provider = provider isa AbstractString ? String(provider) : "", - model = model, - usage = usage, - stopReason = stop_reason, - errorMessage = error_msg, - timestamp = now(), - ) + if hasToolCalls + assistant_msg = assistantMessageToolCall( + role = role, + toolCalls = toolCallList, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + else + assistant_msg = assistantMessage( + role = role, + content = all_content, + api = api isa AbstractString ? String(api) : "", + provider = provider isa AbstractString ? String(provider) : "", + model = model, + usage = usage, + stopReason = stop_reason, + errorMessage = error_msg, + timestamp = now(), + ) + end return hasToolCalls, toolCallList, assistant_msg end @@ -846,7 +869,7 @@ prepareToolCall(context, msg, tc, config, abortedSignal) """ function prepareToolCall( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCall::agentToolCall, config::agentLoopConfig, signal::abortSignal, @@ -863,10 +886,10 @@ function prepareToolCall( agentEventSink("prepareToolCall 3") # 1. prepare arguments (tool-specific transform) prepared = prepareToolCallArguments(tool, toolCall) - agentEventSink(string(prepared.arguments)) + agentEventSink("prepared " * string(prepared.arguments)) agentEventSink("prepareToolCall 4") validatedArgs = validateToolArguments(tool, prepared) - agentEventSink(string(validatedArgs)) + agentEventSink("validatedArgs " * string(validatedArgs)) agentEventSink("prepareToolCall 5") # 2. beforeToolCall hook — can block if config.beforeToolCall !== nothing @@ -950,14 +973,8 @@ function executePreparedToolCall( agentEventSink, )::executedOutcome agentEventSink("executePreparedToolCall 1") - agentEventSink(prep.toolCall.id) - agentEventSink(prep.toolCall.name) agentEventSink("executePreparedToolCall 2") - s = string(prep.args) - agentEventSink(s) agentEventSink("executePreparedToolCall 3") - t = string(fieldnames(typeof(prep.tool))) - agentEventSink("executePreparedToolCall 3-1 " * t) try result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) @@ -1035,7 +1052,7 @@ finalizeExecutedToolCall(context, msg, prep, execFail, config, nothing) """ function finalizeExecutedToolCall( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, prep::preparedToolCall, executed::executedOutcome, config::agentLoopConfig, @@ -1132,7 +1149,7 @@ executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit) """ function executeToolCallsSequential( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, @@ -1161,7 +1178,7 @@ function executeToolCallsSequential( agentEventSink("executeToolCallsSequential 3-2") end agentEventSink("executeToolCallsSequential 4") - agentEventSink("$(finalized.toolCall.id), $(finalized.toolCall.name), + agentEventSink("finalized $(finalized.toolCall.id), $(finalized.toolCall.name), $(finalized.result), $(finalized.isError)") push!(messages, createToolResultMessage(finalized)) push!(finalizedCalls, finalized) @@ -1233,7 +1250,7 @@ executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) """ function executeToolCallsParallel( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, @@ -1335,7 +1352,7 @@ executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit) """ function executeToolCalls( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::abortSignal, diff --git a/src/type.jl b/src/type.jl index 54ff272..d7eee74 100644 --- a/src/type.jl +++ b/src/type.jl @@ -6,8 +6,8 @@ modelCost, llmModel, llmUsage, # Message content types textContent, imageContent, - # Message types - userMessage, assistantMessage, toolResultMessage, + # Message types + userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, # Tool types agentTool, validateRequiredArgs, # Context types @@ -108,6 +108,52 @@ function userMessage(; role="user", content=Vector{messageContent}(), timestamp= return userMessage(role, content, timestamp) end +struct assistantMessageToolCall <: agentMessage # Assistant message containing tool calls + role::String # Always "assistant" + toolCalls::Vector{agentToolCall} # Tool calls to execute + content::Vector{messageContent} # Reasoning/thinking content blocks + api::String # API name used (e.g., "openai") + provider::String # Provider name (e.g., "anthropic") + model::String # Model identifier + usage::llmUsage # Token usage for this message + stopReason::String # Why generation stopped (e.g., "tool_calls") + errorMessage::Union{String, Nothing} # Error if generation failed + timestamp::Timestamp # When the message was received +end + +""" +Create a new assistant message containing tool calls. + +# Arguments +- `role::String`: Always "assistant" +- `toolCalls::Vector{agentToolCall}`: Tool calls to execute +- `content::Vector{messageContent}`: Reasoning/thinking content blocks +- `api::String`: API name used +- `provider::String`: Provider name +- `model::String`: Model identifier +- `usage::llmUsage`: Token usage +- `stopReason::String`: Why generation stopped +- `errorMessage::Union{String, Nothing}`: Error if generation failed +- `timestamp::Timestamp`: When the message was received + +# Returns +- A new `assistantMessageToolCall` instance + +# Examples +```julia +julia> tc = agentToolCall("function", "call_1", "getWeather", Dict("city" => "Tokyo")) +julia> msg = assistantMessageToolCall(toolCalls=[tc], stopReason="tool_calls") +assistantMessageToolCall("assistant", [agentToolCall(...)], messageContent[], "", "", "", llmUsage(0, 0), "tool_calls", nothing, DateTime(...)) +``` +""" +function assistantMessageToolCall(; role="assistant", toolCalls=agentToolCall[], + content=Vector{messageContent}(), api="", provider="", model=nothing, usage=llmUsage(0, 0), + stopReason="tool_calls", errorMessage=nothing, timestamp=now()) + model_str = model isa AbstractString ? String(model) : "" + return assistantMessageToolCall(role, toolCalls, content, api, provider, model_str, + usage, stopReason, errorMessage, timestamp) +end + struct assistantMessage <: agentMessage # Message from the AI assistant role::String # Always "assistant" content::Vector{messageContent} # Text and/or image content @@ -434,13 +480,13 @@ end Context passed to the `beforeToolCall` hook. # Arguments -- `message::assistantMessage`: The assistant message containing the tool call +- `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call being prepared - `args::Dict{String,Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ struct beforeToolCallContext - message::assistantMessage + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} context::agentContext @@ -455,7 +501,7 @@ end Context passed to the `afterToolCall` hook. # Arguments -- `message::assistantMessage`: The assistant message containing the tool call +- `message::assistantMessageToolCall`: The assistant message containing the tool call - `toolCall::agentToolCall`: The tool call that was executed - `args::Dict{String,Any}`: Tool arguments - `result::agentToolResult`: The raw tool result @@ -463,7 +509,7 @@ Context passed to the `afterToolCall` hook. - `context::agentContext`: Current conversation context """ struct afterToolCallContext - message::assistantMessage + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} result::agentToolResult diff --git a/src/utils.jl b/src/utils.jl index f2dc740..29ac6a2 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -221,6 +221,8 @@ function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} for msg in ctx.messages if msg isa userMessage push!(messages, _userMessageToOpenAI(msg)) + elseif msg isa assistantMessageToolCall + push!(messages, _assistantMessageToolCallToOpenAI(msg)) elseif msg isa assistantMessage push!(messages, _assistantMessageToOpenAI(msg)) elseif msg isa toolResultMessage @@ -336,6 +338,44 @@ 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. """ function _assistantMessageToOpenAI(msg::assistantMessage)::Dict{String, Any} From 25f84686964178230d69005bae497223b915b5d5 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 17:24:29 +0700 Subject: [PATCH 21/23] text message process works --- src/agentCore.jl | 29 +++++++++++++++++++++++------ src/type.jl | 23 +++++++++++++---------- test/_extractToolCalls.jl | 6 +++++- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 5284d54..09c4297 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -227,8 +227,11 @@ function _agentLoop(agent::yiemAgent) else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = agent._state.messages[end] + filtered_content = [c for c in result.content if !(c isa reasoningContent)] put!(agent.outputChannel, result) - agent.agentEventSink(result.content[1].text) + if !isempty(filtered_content) && filtered_content[1] isa textContent + agent.agentEventSink(filtered_content[1].text) + end processingTask = nothing # reset newUserMsg = nothing # reset result = nothing # reset @@ -381,7 +384,7 @@ function _processMessage( # Extract tool calls from LLM response content blocks hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response) agentEventSink("hasToolCalls: $hasToolCalls\ntoolCallList: $toolCallList _state.messages length $(length(agentMsgHistory))") - agentEventSink(string(assistant_msg)) + agentEventSink("assistant_msg " * string(assistant_msg)) agentEventSink("_processMessage 11-1") # Add assistant message (tool calls or text) to history for next LLM turn @@ -406,6 +409,7 @@ function _processMessage( # save toolResults to messages for toolResult in toolResultBatch.messages + agentEventSink("toolResult " * string(toolResult)) push!(agentMsgHistory, toolResult) end agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") @@ -554,7 +558,9 @@ The message is constructed from: `(hasToolCalls, toolCallList, message)` where message is `assistantMessageToolCall` when tool calls exist, `assistantMessage` otherwise -# Example response = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) +# Example + 1) llm_useTool = JSON.Object{String, Any}("finish_reason" => "tool_calls", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "", "reasoning_content" => "Here's a thinking process:\n\n1. **Identify User Request**: The user is asking for the weather in Bangkok.\n2. **Locate Relevant Tool**: I have a `getWeather` function available.\n3. **Check Function Parameters**:\n - `city` (required): City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\n - `units` (optional, default \"celsius\"): Temperature scale (\"celsius\" or \"fahrenheit\")\n4. **Prepare Parameters**:\n - `city`: \"Bangkok, Thailand\" (adding country for clarity, though just \"Bangkok\" might work, following the example format is safer)\n - `units`: Not specified, so I'll use the default (\"celsius\")\n5. **Execute Tool Call**: Call `getWeather` with `city: \"Bangkok, Thailand\"`\n6. **Anticipate Response**: The function will return current weather and forecast data for Bangkok. I'll then format it nicely for the user.\n - *Self-Correction/Verification during thought*: The prompt says \"city: City and country, e.g., 'San Francisco, CA' or 'Tokyo, Japan'\". I'll use \"Bangkok, Thailand\". The `units` parameter is optional, so I'll omit it to use the default.\n - Proceed with tool call.✅\n", "tool_calls" => Any[JSON.Object{String, Any}("type" => "function", "function" => JSON.Object{String, Any}("name" => "getWeather", "arguments" => "{\"city\":\"Bangkok, Thailand\"}"), "id" => "6fOilR5QPcdppbAAHluhkRyUDu3oWMAL")])) + 2) llm_notUseTool = JSON.Object{String, Any}("finish_reason" => "stop", "index" => 0, "message" => JSON.Object{String, Any}("role" => "assistant", "content" => "The weather in London, UK is currently Sunny with a temperature of 22°C.", "reasoning_content" => "The user asked for the weather in London.\nI called the `getWeather` tool for London, UK.\nThe response indicates it's Sunny and 22°C.\nI will convey this information to the user.\n")) """ function _extractToolCalls(response) hasToolCalls = false @@ -642,14 +648,15 @@ function _extractToolCalls(response) end if reasoning_text isa String reasoning_text = reasoning_text - elseif reasoning_text isa textContent + elseif reasoning_text isa reasoningContent reasoning_text = reasoning_text.text else reasoning_text = "" end - reasoning_content = !isempty(reasoning_text) ? [textContent(reasoning_text)] : textContent[] + reasoning_content = !isempty(reasoning_text) ? [reasoningContent(reasoning_text)] : reasoningContent[] # Collect content blocks from response.content array (Format 2: OpenAI-style) + # or as a plain string (Format 1: LMStudio.jl non-tool-calls response) content_from_response = get(response, "content", nothing) content_blocks = Vector{messageContent}() if content_from_response isa Vector @@ -662,12 +669,22 @@ function _extractToolCalls(response) elseif get(block, "type", "") == "tool_calls" # tool_calls blocks — don't add text content for these elseif get(block, "type", "") == "reasoning" - push!(content_blocks, textContent(get(block, "text", ""))) + push!(content_blocks, reasoningContent(get(block, "text", ""))) else push!(content_blocks, textContent(get(block, "text", ""))) end end end + elseif content_from_response isa AbstractString && !isempty(content_from_response) + push!(content_blocks, textContent(content_from_response)) + end + + # Format 1 fallback: response["message"]["content"] as plain string + if isempty(content_blocks) && msg !== nothing && msg isa AbstractDict + msg_content = get(msg, "content", nothing) + if msg_content isa AbstractString && !isempty(msg_content) + push!(content_blocks, textContent(msg_content)) + end end # Combine reasoning_content field + content array blocks, deduplicating reasoning diff --git a/src/type.jl b/src/type.jl index d7eee74..bc224af 100644 --- a/src/type.jl +++ b/src/type.jl @@ -4,8 +4,8 @@ messageContent, agentMessage, agent, # Model types modelCost, llmModel, llmUsage, - # Message content types - textContent, imageContent, + # Message content types + textContent, imageContent, reasoningContent, # Message types userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, # Tool types @@ -31,6 +31,13 @@ using GeneralUtils const Timestamp = DateTime +struct agentToolCall # A tool invocation from the LLM + type::String # Always "function" + id::String # Unique tool call identifier + name::String # Tool name + arguments::Dict{String, Any} # Parsed tool arguments +end + # ------------------------------------------------------------------------------------------------ # # LLM model info # # ------------------------------------------------------------------------------------------------ # @@ -75,6 +82,10 @@ struct imageContent <: messageContent # Image message content mimeType::String # MIME type (e.g., "image/png") end +struct reasoningContent <: messageContent # LLM reasoning/thinking content + text::String # The reasoning text +end + # ------------------------------------------------------------------------------------------------ # # Message types # @@ -404,14 +415,6 @@ function agentState( 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. diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl index 94abd46..6540f26 100644 --- a/test/_extractToolCalls.jl +++ b/test/_extractToolCalls.jl @@ -44,7 +44,7 @@ import YiemAgent.agentCore: _extractToolCalls @test assistant_msg.role == "assistant" @test assistant_msg.stopReason == "tool_calls" @test length(assistant_msg.content) == 1 - @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1] isa reasoningContent @test assistant_msg.content[1].text == "Let me check the weather." end @@ -241,7 +241,9 @@ import YiemAgent.agentCore: _extractToolCalls @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 @@ -307,7 +309,9 @@ import YiemAgent.agentCore: _extractToolCalls ) 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 From 00447e4ddef75a1e570a135322b20504f2a06081 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 18:25:26 +0700 Subject: [PATCH 22/23] update --- src/agentCore.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 09c4297..399b19a 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -224,6 +224,8 @@ function _agentLoop(agent::yiemAgent) followUpMsg = take!(agent.followUpChannel) put!(agent.inputChannel, followUpMsg) end + processingTask = nothing # reset + result = nothing # reset else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") result = agent._state.messages[end] @@ -233,7 +235,6 @@ function _agentLoop(agent::yiemAgent) agent.agentEventSink(filtered_content[1].text) end processingTask = nothing # reset - newUserMsg = nothing # reset result = nothing # reset end end @@ -263,6 +264,11 @@ function _agentLoop(agent::yiemAgent) # spawn new _processMessage() if it is not already running. if processingTask === nothing agent.agentEventSink("_agentLoop 5 agent._state.messages length $(length(agent._state.messages))") + # discard all messages in followUpChannel + while isready(agent.followUpChannel) + _ = take!(agent.followUpChannel) + end + # Dispatch message through the processing pipeline processingTask = @spawn _processMessage( processMessageInputCh, From c7abf844eaceb9e83e82b89125d39ad3853fafd5 Mon Sep 17 00:00:00 2001 From: narawat Date: Sun, 16 Aug 2026 20:14:54 +0700 Subject: [PATCH 23/23] update --- src/agentCore.jl | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agentCore.jl b/src/agentCore.jl index 399b19a..58670f6 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -228,11 +228,17 @@ function _agentLoop(agent::yiemAgent) result = nothing # reset else # _processMessage() done and no followUp message. agent.agentEventSink("_agentLoop 4 agent._state.messages length $(length(agent._state.messages))") - result = agent._state.messages[end] - filtered_content = [c for c in result.content if !(c isa reasoningContent)] - put!(agent.outputChannel, result) - if !isempty(filtered_content) && filtered_content[1] isa textContent - agent.agentEventSink(filtered_content[1].text) + result = deepcopy(agent._state.messages[end]) + + # filter out reasoningContent in-place + filter!(c -> !(c isa reasoningContent), result.content) + + # format output + respondToUI = _assistantMessageToOpenAI(result) + + put!(agent.outputChannel, respondToUI) + if !isempty(result.content) && result.content[1] isa textContent + agent.agentEventSink(result.content[1].text) end processingTask = nothing # reset result = nothing # reset