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.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/src/tools/README.md b/README_tools.md similarity index 73% rename from src/tools/README.md rename to README_tools.md index de6986f..9560f36 100644 --- a/src/tools/README.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,74 +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`. +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 +store = toolStore(name="myAgent") +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" -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:113-172` +**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 -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) - -agent = yiemAgent( - tools = getTools(store), - llmCall = my_llm_call, - agentEventSink = my_event_sink, - _tool_store = store, ) ``` @@ -118,17 +97,15 @@ agent = yiemAgent( | Parameter | Type | Required | Purpose | |-----------|------|----------|---------| -| `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` --- @@ -144,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 → run_agent(agent, Dict("role"=>"user", "content"=>...)) - → _agent_loop detects message → @spawn _process_message(agent) +user message → runAgent(agent, Dict("role"=>"user", "content"=>...)) + → _agentLoop detects message → @spawn _processMessage(agent) → prepareContext → formatMsgForLLM → llmCall → LLM returns tool_calls → executeToolCalls(context, response, tool_call_list, config, signal, emit) @@ -166,10 +143,10 @@ user message → run_agent(agent, Dict("role"=>"user", "content"=>...)) **`agentToolResult`** (raw tool output, `type.jl:429-434`): ```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 @@ -180,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 @@ -202,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. @@ -274,16 +251,16 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca --- -## 4. Tool Registration — Per-Agent Tool Stores +## 4. Tool Registration — Static Registration -**Source:** `toolRegistry.jl` +**Source:** `toolRegistry.jl`, `YiemAgent.jl` -### How `ToolStore` Works +### How `toolStore` Works -The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. +The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. ```julia -struct ToolStore +struct toolStore tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration name::String # identifier for debugging/logs end @@ -291,46 +268,36 @@ end `store.tools` is an `OrderedDict` — it provides O(1) lookup by tool name and preserves insertion order for iteration. `getTools(store)` returns this `OrderedDict` directly (not a copy), so mutations on the returned value affect the store. -### 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:113-172` +**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` and returns an `OrderedDict{String, agentTool}` +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 ```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 -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) @@ -350,14 +317,14 @@ 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) +registerTool(storeA, getTimeTool()) +registerTool(storeB, getWeatherTool()) getTools(storeA) # only contains getTime getTools(storeB) # only contains getWeather @@ -373,16 +340,15 @@ This ensures that `yiemAgent` instances with different `tool_store` references o **Source:** `agentCore.jl:35-145` -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 ``` 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 + - 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() ``` ### Loop States @@ -392,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 | @@ -401,7 +367,7 @@ The loop tracks 6 states (documented at `agentCore.jl:39-75`): ### Loop Logic (simplified) ```julia -function _agent_loop(agent::yiemAgent) +function _agentLoop(agent::yiemAgent) while true # 1. Wait for message from inputChannel (blocking poll) msg = fetch!(agent.inputChannel) # agentCore.jl:84 @@ -411,9 +377,9 @@ function _agent_loop(agent::yiemAgent) drain both channels, break loop end - # 3. If agent is idle, spawn _process_message + # 3. If agent is idle, spawn _processMessage if agent._state.activeRun == false - processingTask = Threads.@spawn _process_message(agent) + processingTask = Threads.@spawn _processMessage(agent) agent._state.activeRun = true end @@ -442,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 @@ -463,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 @@ -514,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 @@ -630,7 +592,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)` @@ -649,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()` @@ -713,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 `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - - Hook can mutate the result: - ```julia - after = config.afterToolCall(afterCtx(...)) - 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 @@ -771,7 +698,7 @@ function executeToolCalls( toolCalls::Vector{agentToolCall}, config::agentLoopConfig, signal::Union{Nothing, abortSignal}, - emit::Function, + agentEventSink, )::agentToolCallBatch ``` @@ -806,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) @@ -839,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) @@ -920,12 +840,12 @@ From the type documentation (`type.jl:803-815`): | Unrecoverable error | A tool hits a fatal condition (auth token expired, database connection lost) | | 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 @@ -982,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 ``` @@ -1045,9 +965,6 @@ Tool call fails at any phase └─────────────────────────┘ │ ▼ -emitToolExecutionEnd(finalized, emit) - │ - ▼ createToolResultMessage(finalized) │ ▼ @@ -1116,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: @@ -1140,11 +1057,11 @@ 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::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 @@ -1154,7 +1071,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) @@ -1179,7 +1096,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( @@ -1208,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 @@ -1235,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 @@ -1277,14 +1194,14 @@ The framework supports tools that modify the tool system itself at runtime. 1. Converts `inputSchema` Dict into `Dict{String,Any}(...)` string literal 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:54-82` +**Source:** `toolRegistry.jl:43-98` -Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. +Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `register_all_tools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`. ### Self-Tooling Workflow @@ -1298,9 +1215,11 @@ Each `ToolStore` gets its own `listTool` instance bound to that store via `listT - executeCode: "query = args[\"query\"]\nresult = search(query)\n..." - (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 @@ -1324,15 +1243,15 @@ 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?") - └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) + └─> runAgent(agent, "What's the weather in Tokyo?") + └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL - └─> _agent_loop: detects msg in inputChannel - └─> Threads.@spawn _process_message(agent) + └─> _agentLoop: detects msg in inputChannel + └─> @spawn _processMessage(agent) - ── _process_message ────────────────────────────────────────────── + ── _processMessage ────────────────────────────────────────────── │ │ Step 1: Drain inputChannel │ raw_msg = Dict("role" => "user", "content" => [...]) @@ -1364,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 @@ -1405,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 │ @@ -1427,19 +1344,19 @@ 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.") ``` --- ## 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 ``` @@ -1448,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 @@ -1474,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(...) @@ -1482,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", @@ -1509,9 +1427,9 @@ function getTool()::agentTool "properties" => Dict(...), "required" => [...] ), - execute = executeTool, - prepareArguments = prepareArguments, - validateRequiredArgs = validateRequiredArgs, + execute = myToolExecute, + prepareArguments = myToolPrepareArguments, + validateRequiredArgs = myToolValidateRequiredArgs, parallelToolExecute = false ) end @@ -1519,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 @@ -1601,10 +1578,10 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli |------|--------|-------------| | `agentContext` | `type.jl:299` | Conversation snapshot (systemPrompt, messages, tools) | | `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`) | -| `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/etc.jl b/etc.jl index 4ce0950..be4b0e2 100644 --- a/etc.jl +++ b/etc.jl @@ -1,2 +1,5 @@ -# ── executeToolCalls() Julia pseudo code ────────────────────────── -# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit +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/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 b854d18..58670f6 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -1,19 +1,153 @@ module agentCore -export _agent_loop, OpenAiToUserMessage +export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls, + executePreparedToolCall, prepareToolCall, executeToolCallsSequential, + executeToolCallsParallel, executeToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Base.Threads + DataFrames, Base.Threads, NATS using GeneralUtils -using ..type, ..utils +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 +""" +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 + + _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 _processMessage()'s loop. + # returns new Vector{agentMessage} + prepareContext::Union{Function, Nothing} + + # Convert prepareContext()'s new Vector{agentMessage} to LLM message format + formatMsgForLLM::Function + + # 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 + + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) + beforeToolCall::Union{Function, Nothing} + + # 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 # agent emits its status via this function + 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 + +# Returns +- A new `yiemAgent` instance with an active background task +""" +function yiemAgent( + llmCall, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function=beforeToolCall, + 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=agentEventSink, + ) + # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) + inputChannel = Channel(16) + followUp = Channel(32) + outputChannel = Channel(16) + + # load tools (statically registered at module init) + toolStore1 = toolStore(name="myagent") + register_all_tools(toolStore1) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore1), messages), + inputChannel, + followUp, + outputChannel, + nothing, # placeholder — replaced below + prepareContext, + formatMsgForLLM, + llmCall, + beforeToolCall, + afterToolCall, + # prepareNextTurn, + # prepareNextTurnWithContext, + sessionId, + maxRetryDelayMs, + parallelToolExecute, + agentEventSink, + ) + + # Spawn the background loop and attach it + agent._agentLoop = @spawn _agentLoop(agent) + + return agent +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 @@ -32,63 +166,90 @@ 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 + 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 _process_message() - msg = fetch!(agent.inputChannel) + 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._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._state.messages length $(length(agent._state.messages))") + while isready(agent.followUpChannel) + 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 = 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 + 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) @@ -102,40 +263,35 @@ function _agent_loop(agent::yiemAgent) end #TODO make sure every running tools ended properly + + newUserMsg = nothing # reset break - end - - # start _process_message loop - if agent._state.activeRun == false - # Dispatch message through the processing pipeline - processingTask = Threads.@spawn _process_message(agent) - agent._state.activeRun = true - end - - # during agent runs, check followUp message after _process_message() is done - if typeof(processingTask) == Task && istaskdone(processingTask) == false - # if followUp message available, add them all to agent.inputChannel - if isready(agent.followUpChannel) - while isready(agent.followUpChannel) - followMsg = take!(agent.followUpChannel) - put!(agent.inputChannel, followMsg) - end - end - continue # continue to process user message in the next loop - - elseif typeof(processingTask) == Task && istaskdone(processingTask) == true - # 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) + else + # 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, + agent.agentEventSink, + agent._state.messages, + agent._state.systemPrompt, + agent._state.tools, + agent.prepareContext, + agent.formatMsgForLLM, + agent.llmCall, + agent.beforeToolCall, + agent.afterToolCall, + agent.parallelToolExecute, + ) end - result = fetch(processingTask) - put!(agent.outputChannel, result) - agent._state.activeRun = false # reset - processingTask = nothing # reset + put!(processMessageInputCh, newUserMsg) + newUserMsg = nothing # reset end end catch e @@ -172,109 +328,109 @@ 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 +function _processMessage( + inputChannel::Channel, + agentEventSink, + agentMsgHistory::Vector{agentMessage}, + systemPrompt::String, + tools::OrderedDict{String, agentTool}, + prepareContext::Function, + formatMessagesForLLM::Function, + llmCall, + beforeToolCall::Union{Function, Nothing}, + afterToolCall::Union{Function, Nothing}, + parallelToolExecute::Bool, +)::Nothing + agentEventSink("_processMessage 1 _state.messages length $(length(agentMsgHistory))") # 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 agent.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(agent.inputChannel) - raw_msg = take!(agent.inputChannel) - if raw_msg === :shutdown + while isready(inputChannel) + agentEventSink("_processMessage 2 _state.messages length $(length(agentMsgHistory))") + newUserMsg_openai = take!(inputChannel) + agentEventSink("_processMessage 3 _state.messages length $(length(agentMsgHistory))") + if newUserMsg_openai === :shutdown + agentEventSink("_processMessage 4 _state.messages length $(length(agentMsgHistory))") # Re-emit shutdown signal for the loop to handle - put!(agent.inputChannel, :shutdown) + put!(inputChannel, :shutdown) break end - user_msg = OpenAiToUserMessage(raw_msg) - push!(agent._state.messages, user_msg) + agentEventSink("_processMessage 5 _state.messages length $(length(agentMsgHistory))") + newUserMsg = OpenAiToUserMessage(newUserMsg_openai) + push!(agentMsgHistory, newUserMsg) + agentEventSink("_processMessage 6 _state.messages length $(length(agentMsgHistory))") end - - # call agent.prepareContext() - preparedContext = agent.prepareContext(agent._state) - - # Call agent.formatMsgForLLM(agent._state) to format for LLM - formatted_messages = agent.formatMsgForLLM(preparedContext) - - # Call llmCall() (blocking — the task waits here) - response = agent.llmCall(formatted_messages) + agentEventSink("_processMessage 7 _state.messages length $(length(agentMsgHistory))") + # call prepareContext() + state = agentState(systemPrompt, nothing, tools, agentMsgHistory) + agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") + preparedContext = prepareContext(state, agentEventSink) + agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") + # Call formatMessagesForLLM() to format for LLM + formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) - error(5555555) + agentEventSink("_processMessage 10 formattedMessages $formattedMessages") - #WORKING Check if LLM used tool calls (inspect content for tool_call blocks) - has_tool_calls = false - tool_call_list = agentToolCall[] + """ 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(" llmCall " * string(response)) - for content_block in response.content - if content_block isa Dict - if get(content_block, :type, "") == "tool_calls" - has_tool_calls = 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!(tool_call_list, tc) - end - elseif get(content_block, :type, "") == "tool_call" - has_tool_calls = 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!(tool_call_list, tc) - end - end - end + 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 _state.messages length $(length(agentMsgHistory))") + agentEventSink("assistant_msg " * string(assistant_msg)) + agentEventSink("_processMessage 11-1") - if has_tool_calls && length(tool_call_list) > 0 + # 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 - context = agentContext( - agent._state.systemPrompt, - agent._state.messages, - agent._state.tools, - ) config = agentLoopConfig( - agent._state.tools, - agent.beforeToolCall, - agent.afterToolCall, - agent.parallelToolExecute ? "parallel" : "sequential", + beforeToolCall, + afterToolCall, + parallelToolExecute ? "parallel" : "sequential", ) - signal = nothing - emit = agent.agentEventSink + signal = abortSignal(false) + agentEventSink("_processMessage 12 _state.messages length $(length(agentMsgHistory))") # call executeToolCalls() - batch = executeToolCalls(context, response, tool_call_list, config, signal, emit) + toolResultBatch = executeToolCalls(preparedContext, assistant_msg, toolCallList, config, + signal, agentEventSink) + agentEventSink("_processMessage 13 _state.messages length $(length(agentMsgHistory))") - # save toolResults to agent._state.messages - for tool_result in batch.messages - push!(agent._state.messages, tool_result) + # save toolResults to messages + for toolResult in toolResultBatch.messages + agentEventSink("toolResult " * string(toolResult)) + push!(agentMsgHistory, toolResult) end - - if batch.terminate - # If batch requested termination, build a final response + agentEventSink("_processMessage 14 _state.messages length $(length(agentMsgHistory))") + if toolResultBatch.terminate + agentEventSink("_processMessage 15 _state.messages length $(length(agentMsgHistory))") + # 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 @@ -284,30 +440,32 @@ function _process_message(agent::yiemAgent)::assistantMessage end end end + agentEventSink("_processMessage 16 _state.messages length $(length(agentMsgHistory))") 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) + errorMessage=if any(x -> x.isError, toolResultBatch.messages) "One or more tool calls failed" else nothing end, timestamp=now(), ) + push!(agentMsgHistory, final_response) break end else - # LLM did not use tool calls — this is the final response - final_response = response + agentEventSink("_processMessage 17 _state.messages length $(length(agentMsgHistory))") + # LLM did not use tool calls — break end end - - return final_response + agentEventSink("_processMessage 18 _state.messages length $(length(agentMsgHistory))") + return nothing end @@ -337,7 +495,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 """ @@ -374,19 +532,249 @@ 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 +""" + _extractToolCalls(response) -> Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, 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`. + +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). + +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` + +# Arguments +- `response`: LLM response object (Dict/JSON.Object or struct with `.content` field) + +# Returns +- `Tuple{Bool, Vector{agentToolCall}, Union{assistantMessageToolCall, assistantMessage}}`: + `(hasToolCalls, toolCallList, message)` where message is `assistantMessageToolCall` + when tool calls exist, `assistantMessage` otherwise + +# 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 + toolCallList = agentToolCall[] + + # Helper: parse args (JSON string -> Dict, or pass through) + 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 + 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" + for tc_data in get(content_block, "tool_calls", []) + if tc_data isa AbstractDict + hasToolCalls = true + 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 + + # ── Construct assistantMessage from response ────────────────────── + 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) + + # 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 reasoningContent + reasoning_text = reasoning_text.text + else + reasoning_text = "" + end + 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 + 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, 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 + if !isempty(reasoning_content) + all_content = vcat(reasoning_content, content_blocks) + else + all_content = content_blocks + 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 + + 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 + """ 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 @@ -407,7 +795,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 """ @@ -443,14 +831,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 """ @@ -509,41 +897,62 @@ prepareToolCall(context, msg, tc, config, abortedSignal) ``` """ function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + context::agentContext, + assistantMsg::assistantMessageToolCall, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::abortSignal, + agentEventSink )::Union{preparedToolCall,immediateOutcome} + agentEventSink("prepareToolCall 1") + 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) + end - tool = get(context.tools, toolCall.name, nothing) - if tool === nothing - return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + try + agentEventSink("prepareToolCall 3") + # 1. prepare arguments (tool-specific transform) + prepared = prepareToolCallArguments(tool, toolCall) + agentEventSink("prepared " * string(prepared.arguments)) + agentEventSink("prepareToolCall 4") + validatedArgs = validateToolArguments(tool, prepared) + agentEventSink("validatedArgs " * string(validatedArgs)) + agentEventSink("prepareToolCall 5") + # 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + agentEventSink("prepareToolCall 6") + + before = config.beforeToolCall( + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), + signal + ) + 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 e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) end - try - # 1. prepare arguments (tool-specific transform) - prepared = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, prepared) + agentEventSink(errMsg) - # 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 immediateOutcome(createErrorToolResult(sprint(showerror, e)), true) + end end # ── per-call execution ────────────────────────────────────────── @@ -586,36 +995,34 @@ executePreparedToolCall(prep, nothing, emit) # => executedOutcome(createErrorToolResult("Connection timeout"), true) ``` """ + function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, - emit::Function, -)::executedOutcome + agentEventSink, + )::executedOutcome + agentEventSink("executePreparedToolCall 1") + agentEventSink("executePreparedToolCall 2") + agentEventSink("executePreparedToolCall 3") - 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) + try + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) + agentEventSink(result.content[1].text) + agentEventSink("executePreparedToolCall 4") + return executedOutcome(result, false) + 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 ─────────────────────────────────────── """ @@ -674,68 +1081,45 @@ finalizeExecutedToolCall(context, msg, prep, execFail, config, nothing) """ function finalizeExecutedToolCall( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, prep::preparedToolCall, 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 + ) + agentEventSink("finalizeExecutedToolCall 3") + 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 e + bt = catch_backtrace() + errMsg = sprint() do io + showerror(io, e, bt) + println(io) + end + agentEventSink(errMsg) - result = executed.result - isError = executed.isError - - if config.afterToolCall !== nothing - try - after = config.afterToolCall( - afterCtx(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 + result = createErrorToolResult(sprint(showerror, e)) + isError = true end - - 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 + agentEventSink("finalizeExecutedToolCall 4") + return finalizedOutcome(prep.toolCall, result, isError) end # ── sequential execution ──────────────────────────────────────── @@ -794,38 +1178,46 @@ executeToolCallsSequential(ctx, msg, [deployTc], config, nothing, emit) """ function executeToolCallsSequential( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, 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 + agentEventSink("start tool execute $(tc.id), $(tc.name), $(tc.arguments)") + prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink) + agentEventSink("executeToolCallsSequential " * string(prep.args)) - 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 - - emitToolExecutionEnd(finalized, emit) - push!(messages, createToolResultMessage(finalized)) - push!(finalizedCalls, finalized) - - if signal !== nothing && signal.aborted - break - end + 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 - - return agentToolCallBatch(messages, shouldTerminate(finalizedCalls)) + agentEventSink("executeToolCallsSequential 4") + agentEventSink("finalized $(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 ────────────────────────────────────────── @@ -887,52 +1279,54 @@ executeToolCallsParallel(ctx, msg, [tc1, tc2, tc3], config, abortedSignal, emit) """ function executeToolCallsParallel( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, toolCalls::Vector{agentToolCall}, config::agentLoopConfig, - signal::Union{Nothing,abortSignal}, - emit::Function, + signal::abortSignal, + agentEventSink, )::agentToolCallBatch - entries = union{finalizedOutcome,task{finalizedOutcome}}[] + entries = Union{finalizedOutcome,Task}[] - 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, agentEventSink) - if prep isa immediateOutcome - finalized = finalizedOutcome(tc, prep.result, prep.isError) - emitToolExecutionEnd(finalized, emit) - push!(entries, finalized) - else - task = task() do - executed = executePreparedToolCall(prep, signal, emit) - finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) - emitToolExecutionEnd(finalized, emit) - 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 + 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(t) + push!(entries, t) 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 @@ -987,27 +1381,32 @@ executeToolCalls(ctx, msg, [searchTc, fetchTc], configSequential, nothing, emit) """ function executeToolCalls( context::agentContext, - assistantMsg::assistantMessage, + assistantMsg::assistantMessageToolCall, 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/api.jl b/src/api.jl index b126b19..50d6dbd 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,12 +5,11 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..agentCore, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # - """ Send a message to the agent's input channel. @@ -25,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task. - The same `agent` instance for chaining # 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 @@ -51,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 @@ -77,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 @@ -105,19 +104,19 @@ 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) + fetch(agent._agentLoop) catch e if e isa TaskFailedException rethrow(e) diff --git a/src/toolRegistry.jl b/src/toolRegistry.jl index 8cf9a45..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 @@ -9,47 +9,65 @@ 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 """ -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 - ToolStore(OrderedDict{String, agentTool}(), name) +function toolStore(; name::String="default")::toolStore + toolStore(OrderedDict{String, agentTool}(), name) 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`. `register_all_tools` 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> register_all_tools(store) # auto-registers listTools +[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup) +[toolRegistry:agent1] Registered tool: listTools + +julia> tools = getTools(store) +OrderedDict{String, agentTool} with 4 entries: + "getWeather" => agentTool(...) + "getTime" => agentTool(...) + "writeTool" => agentTool(...) + "listTools" => agentTool(...) +``` """ -function listTool(store::ToolStore)::agentTool +function listTool(store::toolStore)::agentTool return agentTool( name = "listTools", label = "List Tools", @@ -79,162 +97,80 @@ function listTool(store::ToolStore)::agentTool ) end +# Note: register_all_tools is defined in YiemAgent.jl where tool functions are in scope + """ -Load all tool modules from a directory into a specific ToolStore. + registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool} -Scans `dir` for `.jl` files. Each file must define a function named -`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. +Add `tool` to `store`, overwriting any existing tool with the same name. # Arguments -- `store::ToolStore`: The tool store to register tools into -- `dir::String`: Directory path to scan for `.jl` tool files +- `store`: Tool store to modify +- `tool`: The `agentTool` to register # 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 - -# Examples +# Example ```julia -julia> store = ToolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") -OrderedDict{String, agentTool} with 3 entries: - "getWeather" => agentTool(...) - "getTime" => agentTool(...) +julia> store = toolStore(name="agent1"); + +julia> registerTool(store, listTool(store)) +[toolRegistry:agent1] Registered tool: listTools +OrderedDict{String, agentTool} with 1 entry: "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 - - return store.tools -end - -""" -Register a single agentTool into a specific ToolStore. - -# Arguments -- `store::ToolStore`: The tool store to register into -- `tool::agentTool`: The tool to register - -# Returns -- `OrderedDict{String, agentTool}`: Updated tool dict for this store - -# Examples -```julia -julia> store = ToolStore(name="agent1") -julia> registerTool(store, my_tool) -[toolRegistry:agent1] Registered tool: my_tool -``` -""" -function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool} +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. +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(...) ``` """ -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. +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 +function clearTools(store::toolStore)::Nothing empty!(store.tools) println("[$(store.name)] Registry cleared") return nothing diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 94b7752..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,7 +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 @@ -60,7 +62,7 @@ end """ Define and return the getTime agentTool. """ -function getTool()::agentTool +function getTimeTool()::agentTool return agentTool( name = "getTime", label = "Time Lookup", @@ -73,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 e98d6a9..5cca9cd 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -1,23 +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", @@ -30,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 37b614f..bc224af 100644 --- a/src/type.jl +++ b/src/type.jl @@ -4,17 +4,17 @@ messageContent, agentMessage, agent, # Model types modelCost, llmModel, llmUsage, - # Message content types - textContent, imageContent, - # Message types - userMessage, assistantMessage, toolResultMessage, + # Message content types + textContent, imageContent, reasoningContent, + # Message types + userMessage, assistantMessageToolCall, assistantMessage, toolResultMessage, # Tool types agentTool, validateRequiredArgs, # 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 @@ -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 @@ -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 # @@ -108,6 +119,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 @@ -144,9 +201,10 @@ 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) + model_str = model isa AbstractString ? String(model) : "" + return assistantMessage(role, content, api, provider, model_str, usage, stopReason, errorMessage, timestamp) end struct toolResultMessage <: agentMessage # Result returned from a tool execution @@ -263,7 +321,7 @@ struct agentTool # A tool available to the agent label::String # Human-readable tool name description::String # What the tool does inputSchema::Any # Tool parameters schema (JSON schema, MCP inputSchema format) - execute::Function # Tool execution function + execute # Tool execution function prepareArguments::Union{Function, Nothing} # Optional argument preparation callback validateRequiredArgs::Union{Function, Nothing} # Optional validation hook for required args parallelToolExecute::Bool # Override: run tool calls sequentially or in parallel @@ -273,7 +331,7 @@ end Keyword constructor for agentTool — allows `agentTool(name=..., label=..., ...)`. """ function agentTool(; name::String, label::String, description::String, inputSchema::Any, - execute::Function, prepareArguments::Union{Function, Nothing}=nothing, + execute, prepareArguments::Union{Function, Nothing}=nothing, validateRequiredArgs::Union{Function, Nothing}=nothing, parallelToolExecute::Bool=false) return agentTool(name, label, description, inputSchema, execute, @@ -291,7 +349,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 +357,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 @@ -309,14 +367,13 @@ 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 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 @@ -341,29 +398,20 @@ 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("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[], ) - agentState( - systemPrompt, - model, - deepcopy(tools), - deepcopy(messages), - Vector{String}(), - false, - nothing, - ) -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 + agentState( + systemPrompt, + model, + deepcopy(tools), + deepcopy(messages), + Vector{String}(), + nothing, + ) end @@ -395,13 +443,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 @@ -437,31 +483,36 @@ 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 assistantMsgCtx - message::assistantMessage +struct beforeToolCallContext + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} context::agentContext end +struct beforeToolCallResult + block::Bool + reason::String +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 - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context """ -struct afterCtx - message::assistantMessage +struct afterToolCallContext + message::assistantMessageToolCall toolCall::agentToolCall args::Dict{String,Any} result::agentToolResult @@ -521,143 +572,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..29ac6a2 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,9 +1,11 @@ module utils -export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, + validateToolArguments, _userMessageToOpenAI, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks, _toolsToOpenAI, + beforeToolCall, afterToolCall, agentEventSink -using UUIDs, Dates, DataStructures, HTTP, JSON +using UUIDs, Dates, DataStructures, HTTP, JSON, NATS using GeneralUtils using ..type @@ -73,7 +75,6 @@ function availableWineToText(vecd::Vector)::String end - """ prepareContext(state::agentState) -> agentContext @@ -108,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 @@ -155,7 +156,7 @@ formatMsgForLLm(ctx) == Dict("messages" => [ ]) ``` """ -function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} +function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any} """ openai message format example msg = Dict( @@ -183,19 +184,31 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} Dict("type" => "text", "text" => "let me check."), ] ), + ], + "tools"=> [ Dict( - "role" => "toolResult", - "content" => [ - Dict("type" => "text", "text" => "name: Chateau Montelena ..."), - ] - ), + "type" => "function", + "function" => Dict( + "name" => "getWeather", + "description" => "Get current weather", + "parameters" => Dict( + "type" => "object", + "properties" => Dict( + "city" => Dict("type" => "string") + ), + "required" => ["city"] + ) + ) + ) ], "temperature" => 0.7 ) """ + openaiReadyMsg = Dict{String, Any}() + # openaiReadyMsg["model"] = "gemma-4-E4B-it-UD-Q4_K_XL" messages = Vector{Dict{String, Any}}() - + agentEventSink("formatMsgForLLM 1") # System prompt as system message if !isempty(ctx.systemPrompt) push!(messages, Dict( @@ -203,19 +216,113 @@ 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 push!(messages, _userMessageToOpenAI(msg)) + elseif msg isa assistantMessageToolCall + push!(messages, _assistantMessageToolCallToOpenAI(msg)) elseif msg isa assistantMessage push!(messages, _assistantMessageToOpenAI(msg)) elseif msg isa toolResultMessage push!(messages, _toolResultMessageToOpenAI(msg)) end end + agentEventSink("formatMsgForLLM 3") + # Convert ctx.tools into OpenAI tools format + tools_array = _toolsToOpenAI(ctx.tools, agentEventSink) + agentEventSink("formatMsgForLLM 4") + openaiReadyMsg["messages"] = messages + openaiReadyMsg["temperature"] = 0.7 - return Dict("messages" => messages) + if !isempty(tools_array) + openaiReadyMsg["tools"] = tools_array + end + + return openaiReadyMsg +end + +""" + beforeToolCall(context::beforeToolCallContext, signal::abortSignal) -> beforeToolCallResult + +Callback invoked before executing a tool call. Use this hook to inspect +the tool call and decide whether to allow, block, or modify it. + +Common use cases: + - Request user approval via UI before running destructive tools. + - Validate business rules that cannot be expressed in the JSON schema. + - Check final context (e.g. session state, rate limits, permissions). + +# Arguments +- `context::beforeToolCallContext`: Contains the assistant message, tool call, + validated arguments, and current conversation context. +- `signal::abortSignal`: Signal that may be set to abort the operation. + +# Returns +- `beforeToolCallResult(false, "N/A")` to allow the call to proceed. +- `beforeToolCallResult(true, "Reason")` to block the call with a reason. +- `nothing` is treated as allow (equivalent to `beforeToolCallResult(false, "N/A")`). + +# Example +```julia +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal) + if context.toolCall.name == "deleteFile" + # Block file deletion unless explicitly approved + return beforeToolCallResult(true, "User must approve file deletion") + end + return beforeToolCallResult(false, "N/A") +end +``` +""" +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal + )::beforeToolCallResult + + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + +""" + afterToolCall(context::afterToolCallContext, signal::abortSignal) -> Union{agentToolResult, Nothing} + +Callback invoked after a tool call finishes executing (before and after errors). +Use this hook to post-process the tool result before it is fed back to the LLM. + +Common use cases: + - Mask sensitive data (API keys, tokens) from result content. + - Normalize usage tracking data into a consistent format. + - Inspect the result and set `terminate: true` based on business logic + (e.g. "if deployment failed, stop the agent rather than retrying"). + - Wrap error results in friendlier messages for the LLM to understand. + +# Arguments +- `context::afterToolCallContext`: Contains the assistant message, tool call, + arguments, raw result, error status, and current conversation context. +- `signal::abortSignal`: Signal that may be set to abort the operation. + +# Returns +- `nothing` to pass the result through unchanged. +- `agentToolResult(...)` to return a modified result (content, details, usage, + terminate flag can all be overridden). + +""" +function afterToolCall(context::afterToolCallContext, signal::abortSignal + )::Union{agentToolResult, Nothing} + + # modify context.result if needed and return agentToolResult + + return nothing +end + + +#TODO +function agentEventSink(x) + end @@ -231,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} @@ -279,6 +424,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} diff --git a/test/_extractToolCalls.jl b/test/_extractToolCalls.jl new file mode 100644 index 0000000..6540f26 --- /dev/null +++ b/test/_extractToolCalls.jl @@ -0,0 +1,642 @@ +using Test +using YiemAgent +using YiemAgent.agentCore +using YiemAgent.type +using JSON + +# Import the function from the private module scope +import YiemAgent.agentCore: _extractToolCalls + +@testset "_extractToolCalls" begin + + # ------------------------------------------------------------------ # + # Format 1: response["message"]["tool_calls"] (LMStudio.jl style) # + # ------------------------------------------------------------------ # + + @testset "single tool call via message format" begin + response = Dict{String,Any}( + "finish_reason" => "tool_calls", + "index" => 0, + "message" => Dict{String,Any}( + "role" => "assistant", + "content" => "", + "reasoning_content" => "Let me check the weather.", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Bangkok, Thailand\"}", + ), + "id" => "tc_001", + ) + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].id == "tc_001" + @test tc_list[1].type == "function" + @test tc_list[1].arguments["city"] == "Bangkok, Thailand" + @test assistant_msg isa assistantMessage + @test assistant_msg.role == "assistant" + @test assistant_msg.stopReason == "tool_calls" + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1] isa reasoningContent + @test assistant_msg.content[1].text == "Let me check the weather." + end + + @testset "multiple tool calls via message format" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "content" => "", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Tokyo, Japan\"}", + ), + "id" => "tc_001", + ), + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"timezone\":\"Asia/Tokyo\"}", + ), + "id" => "tc_002", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 2 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Tokyo, Japan" + @test tc_list[2].name == "getTime" + @test tc_list[2].arguments["timezone"] == "Asia/Tokyo" + @test assistant_msg.role == "assistant" + end + + @testset "tool call with empty arguments string" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "listTools", + "arguments" => "{}", + ), + "id" => "tc_empty", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "listTools" + @test tc_list[1].arguments == Dict{String,Any}() + @test assistant_msg.stopReason == "end_turn" + end + + @testset "tool call with missing id falls back to uuid" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"city\":\"NYC\"}", + ), + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test !isempty(tc_list[1].id) + @test tc_list[1].name == "getTime" + end + + @testset "tool call with non-string arguments (pre-parsed dict)" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => Dict{String,Any}("city" => "London", "units" => "fahrenheit"), + ), + "id" => "tc_parsed", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].arguments["city"] == "London" + @test tc_list[1].arguments["units"] == "fahrenheit" + end + + @testset "tool call with api/provider/model/usage metadata" begin + response = Dict{String,Any}( + "api" => "openai", + "provider" => "anthropic", + "model" => "claude-3-opus", + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{}", + ), + "id" => "tc_meta", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test assistant_msg.api == "openai" + @test assistant_msg.provider == "anthropic" + @test assistant_msg.model == "claude-3-opus" + end + + # --------------------------------------------------------------- # + # Format 2: response.content blocks (OpenAI API style) # + # --------------------------------------------------------------- # + + @testset "content blocks with tool_calls" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Let me check."), + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Paris\"}", + ), + "id" => "tc_block_1", + ), + ], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Paris" + # text block before tool_calls should be included in content + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "Let me check." + end + + @testset "content blocks with tool_call (single-call format)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_single", + "name" => "getTime", + "arguments" => Dict{String,Any}("timezone" => "Europe/London"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + @test tc_list[1].id == "tc_single" + @test tc_list[1].arguments["timezone"] == "Europe/London" + end + + @testset "content blocks with reasoning and text" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "reasoning", "text" => "Thinking..."), + Dict{String,Any}("type" => "text", "text" => "Here's the answer."), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa reasoningContent + @test assistant_msg.content[1].text == "Thinking..." + @test assistant_msg.content[2] isa textContent + @test assistant_msg.content[2].text == "Here's the answer." + end + + @testset "content blocks with text and tool_call (tool_call not in content)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Sure, I'll check."), + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_mix", + "name" => "getWeather", + "arguments" => Dict{String,Any}("city" => "London"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + # text block included, tool_call block excluded from content + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "Sure, I'll check." + end + + # ------------------------------------------------------------------ # + # assistantMessage construction # + # ------------------------------------------------------------------ # + + @testset "assistantMessage with error_message and errorMessage fallback" begin + response = Dict{String,Any}( + "error_message" => "rate limit", + "content" => Any[Dict{String,Any}("type" => "text", "text" => "fail")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test assistant_msg.errorMessage == "rate limit" + end + + @testset "assistantMessage with usage tracking" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => llmUsage(100, 50), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 100 + @test assistant_msg.usage.outputTokens == 50 + end + + @testset "assistantMessage with invalid usage defaults to zero" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "hi")], + "usage" => "invalid", + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.usage.inputTokens == 0 + @test assistant_msg.usage.outputTokens == 0 + end + + @testset "reasoning_content as textContent" begin + response = Dict{String,Any}( + "reasoning_content" => textContent("internal thought"), + "content" => Any[Dict{String,Any}("type" => "text", "text" => "output")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa reasoningContent + @test assistant_msg.content[1].text == "internal thought" + @test assistant_msg.content[2] isa textContent + @test assistant_msg.content[2].text == "output" + end + + # ------------------------------------------------------------------ # + # No tool call cases # + # ------------------------------------------------------------------ # + + @testset "no tool calls found" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "Hello world."), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test assistant_msg.stopReason == "end_turn" + end + + @testset "empty message" begin + response = Dict{String,Any}() + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test assistant_msg.role == "assistant" + @test assistant_msg.stopReason == "end_turn" + @test length(assistant_msg.content) == 0 + end + + @testset "message with empty tool_calls array" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test assistant_msg.role == "assistant" + end + + @testset "message field is not a Dict" begin + response = Dict{String,Any}( + "message" => "not a dict", + "content" => Any[Dict{String,Any}("type" => "text", "text" => "fallback")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 1 + end + + @testset "Format 1 takes priority over Format 2" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"Format1\"}", + ), + "id" => "tc_fmt1", + ), + ], + ), + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_fmt2", + "name" => "getTime", + "arguments" => Dict{String,Any}("city" => "Format2"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + end + + # ------------------------------------------------------------------ # + # Edge cases # + # ------------------------------------------------------------------ # + + @testset "tool call with null arguments" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => nothing, + ), + "id" => "tc_null", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + end + + @testset "tool call with missing function key" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "id" => "tc_nofunc", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "" + end + + @testset "tool call with missing name in function block" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "assistant", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}("arguments" => "{}"), + "id" => "tc_noname", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "" + end + + @testset "message format with JSON.Object (JSON.parse result)" begin + json_str = JSON.json(Dict( + "message" => Dict( + "role" => "assistant", + "tool_calls" => [ + Dict( + "type" => "function", + "function" => Dict("name" => "getWeather", "arguments" => "{\"city\":\"Test\"}"), + "id" => "tc_jsonobj", + ), + ], + ), + )) + parsed = JSON.parse(json_str) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(parsed) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getWeather" + @test tc_list[1].arguments["city"] == "Test" + end + + @testset "tool_calls block with mixed content types (text + tool_calls)" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}("type" => "text", "text" => "I'll check both."), + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{\"city\":\"London\"}", + ), + "id" => "tc_mix1", + ), + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getTime", + "arguments" => "{\"timezone\":\"UTC\"}", + ), + "id" => "tc_mix2", + ), + ], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 2 + @test tc_list[1].name == "getWeather" + @test tc_list[2].name == "getTime" + @test length(assistant_msg.content) == 1 + @test assistant_msg.content[1].text == "I'll check both." + end + + @testset "tool_call block without arguments field" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_noargs", + "name" => "getTime", + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].arguments == Dict{String,Any}() + end + + @testset "tool_calls block with empty tool_calls array" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any[], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "tool_calls block with non-AbstractDict elements" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "tool_calls", + "tool_calls" => Any["not a dict", 42, nothing], + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + end + + @testset "content field is not a Vector" begin + response = Dict{String,Any}( + "content" => "not a vector", + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(tc_list) == 0 + @test length(assistant_msg.content) == 0 + end + + @testset "Dict-based response with all metadata fields" begin + response = Dict{String,Any}( + "api" => "openai", + "provider" => "anthropic", + "model" => "claude-3-sonnet", + "content" => Any[ + Dict{String,Any}( + "type" => "tool_call", + "id" => "tc_meta", + "name" => "getTime", + "arguments" => Dict{String,Any}("city" => "Seoul"), + ), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == true + @test length(tc_list) == 1 + @test tc_list[1].name == "getTime" + @test tc_list[1].arguments["city"] == "Seoul" + @test assistant_msg.api == "openai" + @test assistant_msg.provider == "anthropic" + @test assistant_msg.model == "claude-3-sonnet" + end + + @testset "default role is assistant" begin + response = Dict{String,Any}( + "content" => Any[Dict{String,Any}("type" => "text", "text" => "no role specified")], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.role == "assistant" + end + + @testset "tool call with custom role in message format" begin + response = Dict{String,Any}( + "message" => Dict{String,Any}( + "role" => "custom_role", + "tool_calls" => Any[ + Dict{String,Any}( + "type" => "function", + "function" => Dict{String,Any}( + "name" => "getWeather", + "arguments" => "{}", + ), + "id" => "tc_role", + ), + ], + ), + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test assistant_msg.role == "custom_role" + end + + @testset "image content block handling" begin + response = Dict{String,Any}( + "content" => Any[ + Dict{String,Any}( + "type" => "image_url", + "image_url" => Dict("url" => "data:image/png;base64,abc123"), + ), + Dict{String,Any}("type" => "text", "text" => "What is this?"), + ], + ) + has_toolcalls, tc_list, assistant_msg = _extractToolCalls(response) + @test has_toolcalls == false + @test length(assistant_msg.content) == 2 + @test assistant_msg.content[1] isa textContent + @test assistant_msg.content[1].text == "" + @test assistant_msg.content[2].text == "What is this?" + end + +end diff --git a/test/runtest.jl b/test/runtest.jl index b03f7c1..6f086e5 100644 --- a/test/runtest.jl +++ b/test/runtest.jl @@ -1,401 +1,5 @@ -using JSON, Dates, UUIDs, PrettyPrinting, LibPQ, Base64, DataFrames, DataStructures, HTTP, Base64, - NATS, Base.Threads -using YiemAgent, GeneralUtils, msghandler - - function text2text_instruct_llm(sender_id::String, openai_msg::Dict{String, Any}) - payloads = [("msg", openai_msg, "dictionary")] # List of tuples - _, msg_envelope_json_str = msghandler.smartpack( - config["externalservice"]["servicesloadbalancer"]["nats"], - payloads; - sender_id=sender_id, - msg_purpose="text2text", - broker_url=config["nats_server_info"]["url"], - fileserver_url=config["externalservice"]["fileserver"]["url"]) - - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) - - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - _llm_response = incoming_env["payloads"][1][2] - llm_response = _llm_response["choices"][1]["message"]["content"] - return llm_response - end - - """ get a single text embedding from a LLM service - Example - text = ["hello"] - embedding = get_embedding(text) - """ - function get_embedding(text::AbstractArray{String}) - documents_dict = Dict("documents" => text) - payloads = [("documents", documents_dict, "dictionary")] - _, msg_envelope_json_str = msghandler.smartpack( - config["externalservice"]["servicesloadbalancer"]["nats"], - payloads; - msg_purpose="embedding", - broker_url=config["nats_server_info"]["url"], - fileserver_url=config["externalservice"]["fileserver"]["url"]) - - reply = NATS.request(agent_conn, - config["externalservice"]["servicesloadbalancer"]["nats"], - msg_envelope_json_str, timeout=120) - incoming_env_json_str = String(reply.payload) - incoming_env = msghandler.smartunpack(incoming_env_json_str) - embedding_response = incoming_env["payloads"][1][2] - - return embedding_response - end - - """ sql = "SELECT * FROM wine;" - result = execute_sql_winedb(sql) - """ - function execute_sql_winedb(sql::T) where {T<:AbstractString} - host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':') - port = parse(Int, _port) - dbname = "winedb" - user = config["externalservice"]["sommpanion_db"]["user"] - password = config["externalservice"]["sommpanion_db"]["password"] - db_connection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") - result = nothing - try - result = LibPQ.execute(db_connection, sql) - catch e - LibPQ.close(db_connection) - end - - LibPQ.close(db_connection) - return result - end - - """ find similar sql from vector database - sql = "SELECT * FROM wine;" - result, distance = similar_sql_vectordb(sql) - """ - function similar_sql_vectordb(sql::T; maxdistance::Number=0.2) where {T<:AbstractString} - tablename = "sqlllm_decision_repository" - # get embedding of the query - df = find_similar_text_from_vectordb(sql, tablename, - "function_input_embedding", execute_sql_vectordb) - # println(df[1, [:id, :function_output]]) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable SQL, return it. - output_b64 = df[1, :function_output_base64] # pick the closest match - output_str = String(base64decode(output_b64)) - rowid = df[1, :id] - println("\n--| similar sql found. row id $rowid, distance $distance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(output_str) - return (result=output_str, distance=distance) - else - println("\n--| similar sql not found, max distance $maxdistance ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - return (result=nothing, distance=nothing) - end - end - - """ insert query and sql into vector database - query = "get all wines from wine table" - sql = "SELECT * FROM wine;" - insert_sql_vectordb(query, sql) - """ - function insert_sql_vectordb(query::T1, SQL::T2; maxdistance::Number=3 - ) where {T1<:AbstractString, T2<:AbstractString} - - tablename = "sqlllm_decision_repository" - # get embedding of the query - # query = state[:thoughtHistory][:question] - df = find_similar_text_from_vectordb(query, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - _query_embedding = get_embedding([query]) - _query_embedding = GeneralUtils.dictify(_query_embedding) - # println("\n--- _query_embedding() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(_query_embedding) - # println("---\n") - query_embedding = _query_embedding["data"][1]["embedding"] - query = replace(query, "'" => "") - sql_base64 = base64encode(SQL) - sql_ = replace(SQL, "'" => "") - - sql = - """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$query', '$sql_', '$sql_base64', '$query_embedding'); - """ - # println("\n--| added new decision to vectorDB ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # println(sql) - _ = execute_sql_vectordb(sql) - end - end - - """ execute sql against vectordb - sql = "SELECT * FROM wine;" - result = execute_sql_vectordb(sql) - """ - function execute_sql_vectordb(sql::T) where {T<:AbstractString} - host_url, _port = split(config["externalservice"]["sommpanion_vectordb"]["url"], ':') - port = parse(Int, _port) - dbname = config["externalservice"]["sommpanion_vectordb"]["dbname"] - user = config["externalservice"]["sommpanion_vectordb"]["user"] - password = config["externalservice"]["sommpanion_vectordb"]["password"] - DBconnection = LibPQ.Connection("host=$host_url port=$port dbname=$dbname user=$user password=$password") - result = LibPQ.execute(DBconnection, sql) - close(DBconnection) - return result - end - - """ search similar decision llm made from vectordb - """ - function similar_sommelier_decision(recentevents::T1; maxdistance::Integer=3 - )::Union{AbstractDict, Nothing} where {T1<:AbstractString} - - tablename = "sommelier_decision_repository" - # find similar - df = find_similar_text_from_vectordb(recentevents, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row != 0 && distance < maxdistance - # if there is usable decision, return it. - rowid = df[1, :id] - println("\n--| found similar decision. row id $rowid, distance $distance ", @__FILE__, " ", @__LINE__) - output_b64 = df[1, :function_output_base64] # pick the closest match - _output_str = String(base64decode(output_b64)) - output = copy(JSON.read(_output_str)) - return output - else - println("\n--| similar decision not found, max distance $maxdistance ", @__FILE__, " ", @__LINE__) - return nothing - end - end - - """ search similar text from vectordb - """ - function find_similar_text_from_vectordb(text::T1, tablename::T2, embeddingColumnName::T3, - vectorDB::Function; limit::Integer=1 - )::DataFrame where {T1<:AbstractString, T2<:AbstractString, T3<:AbstractString} - # get embedding from LLM service - _embedding = get_embedding([text]) - _embedding = _embedding["data"][1]["embedding"] - _embedding = "$_embedding" - - embedding = _embedding[4:end] - - # check whether there is close enough vector already store in vectorDB. if no, add, else skip - sql = """ - SELECT *, $embeddingColumnName <-> '$embedding' as distance - FROM $tablename - ORDER BY distance LIMIT $limit; - """ - response = vectorDB(sql) - df = DataFrame(response) - - return df - end - - """ insert decision llm made to vectordb - """ - function insert_sommelier_decision(recentevents::T1, decision::T2; maxdistance::Integer=5 - ) where {T1<:AbstractString, T2<:AbstractDict} - tablename = "sommelier_decision_repository" - # find similar - df = find_similar_text_from_vectordb(recentevents, tablename, - "function_input_embedding", execute_sql_vectordb) - row, col = size(df) - distance = row == 0 ? Inf : df[1, :distance] - if row == 0 || distance > maxdistance # no close enough SQL stored in the database - _embedding = get_embedding([recentevents])[1] - recentevents_embedding = _embedding["data"][1]["embedding"] - recentevents = replace(recentevents, "'" => "") - decision_json = JSON.json(decision) - decision_base64 = base64encode(decision_json) - decision = replace(decision_json, "'" => "") - - sql = - """ - INSERT INTO $tablename (function_input, function_output, function_output_base64, function_input_embedding) VALUES ('$recentevents', '$decision', '$decision_base64', '$recentevents_embedding'); - """ - println("\n--| added new decision to vectorDB ", @__FILE__, " ", @__LINE__) - println(sql) - _ = execute_sql_vectordb(sql) - else - println("--| similar decision previously cached, distance $distance ", @__FILE__, " ", @__LINE__) - end - end - -config = JSON.parsefile("./appconfig.json") -sessionId = "0" -backend_session_topic = "sommpanion.testsubject" -agent_ch = Channel(8) -agent_conn = NATS.connect(config["nats_server_info"]["url"]) - -sub2 = NATS.subscribe(agent_conn, backend_session_topic) do msg - put!(agent_ch, msg) -end - -agent_context = YiemAgent.agentcontext( - text2text_instruct_llm, - get_embedding, - execute_sql_winedb, - similar_sql_vectordb, - insert_sql_vectordb, - similar_sommelier_decision, - insert_sommelier_decision - ) - - # can't instantiate - agent = YiemAgent.sommelier( - agent_context; - name="Janie", - id=sessionId, # agent instance id - retailername="Yiem Wine Ltd.", - llmFormatName="" - ) - - -image1_path = "test/large_image.png" -image1_bytes = read(image1_path) -image1_base64_string = base64encode(image1_bytes) -mime_type = "image/png" -data1_uri = "data:$(mime_type);base64,$(image1_base64_string)" - -# 1. Read local file and encode to base64 string -image2_path = "test/small_image.png" -image2_bytes = read(image2_path) -image2_base64_string = base64encode(image2_bytes) -mime_type = "image/png" -data2_uri = "data:$(mime_type);base64,$(image2_base64_string)" - -# 3. Construct payload with the Data URI -message = Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know type of wine in the image?"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - -result = YiemAgent.conversation(agent; userinput=message) -println("\n$result") - - - - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => -# " -# เป็นงานเลี้ยงทั่วไป -# "), -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => "no thanks. that's all"), -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - -# message = Dict( -# "role" => "user", -# "content" => [ -# Dict("type" => "text", "text" => "What about this wine?"), -# Dict( -# "type" => "image_url", -# "image_url" => Dict("url" => data2_uri) -# ) -# ] -# ) - -# result = YiemAgent.conversation(agent; userinput=message) -# println("\n$result") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +using Test +using YiemAgent +include("toolTest.jl") +include("_extractToolCalls.jl") diff --git a/test/toolTest.jl b/test/toolTest.jl index 8057cf1..f613397 100644 --- a/test/toolTest.jl +++ b/test/toolTest.jl @@ -1,52 +1,36 @@ 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) + store = toolStore(name="test1") + loaded = register_all_tools(store) @test !isempty(loaded) - @test length(loaded) == 3 + @test length(loaded) == 4 # getWeather + getTime + writeTool + 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 # + # 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"] @@ -73,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 @@ -97,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") + 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"]) @@ -142,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) @@ -151,10 +134,10 @@ 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") + storeA = toolStore(name="isolationA") + storeB = toolStore(name="isolationB") registerTool(storeA, loaded["getTime"]) registerTool(storeB, loaded["getWeather"]) @@ -171,3 +154,214 @@ 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") + register_all_tools(store) # auto-registers getWeather, getTime, writeTool + listTools + + # register_all_tools auto-registers listTool + @test "listTools" in keys(store.tools) + + # listTool returns an agentTool, not a string or array + list_t = listTool(store) + @test list_t isa agentTool + @test list_t.name == "listTools" + @test list_t.label == "List Tools" + @test isempty(list_t.inputSchema["required"]) + + # Verify all tools appear (3 loaded + listTools = 4) + result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x) + @test result isa agentToolResult + @test result.content[1] isa textContent + @test occursin("listTools", result.content[1].text) + @test occursin("getWeather", result.content[1].text) + @test occursin("getTime", result.content[1].text) + @test occursin("writeTool", result.content[1].text) + @test result.details["count"] == 4 + + # Each listTool call creates an independent closure + storeB = toolStore(name="test_listB") + registerTool(storeB, store.tools["getWeather"]) + list_tB = listTool(storeB) + + resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x) + resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x) + + @test occursin("getWeather", resultA.content[1].text) + @test occursin("getWeather", resultB.content[1].text) + @test occursin("getTime", resultA.content[1].text) + @test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather +end + +@testset "executePreparedToolCall with static tools" begin + # Tests executePreparedToolCall with statically loaded tools. + # The world-age issue is resolved because tool.execute comes from + # a statically included module, not a dynamically created one. + + store = toolStore(name="test_static") + register_all_tools(store) + + weather_tool = store.tools["getWeather"] + + # Create a preparedToolCall that mimics what prepareToolCall() returns + tool_call = agentToolCall( + "function", "call-static-1", "getWeather", + Dict{String,Any}("city" => "San Francisco") + ) + prep = preparedToolCall( + weather_tool, tool_call, Dict{String,Any}("city" => "San Francisco") + ) + sig = abortSignal(false) + + # This call goes through: executePreparedToolCall -> prep.tool.execute(...) + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("San Francisco", result.result.content[1].text) +end + +@testset "executePreparedToolCall with validation (static tools)" begin + # Tests executePreparedToolCall with a tool that has custom validation hooks. + # This exercises the full tool execution path including validation. + + store = toolStore(name="test_static_validate") + register_all_tools(store) + + time_tool = store.tools["getTime"] + + tool_call = agentToolCall( + "function", "call-static-2", "getTime", + Dict{String,Any}("timezone" => "America/New_York") + ) + prep = preparedToolCall( + time_tool, tool_call, Dict{String,Any}("timezone" => "America/New_York") + ) + sig = abortSignal(false) + + result = executePreparedToolCall( + prep, sig, x -> nothing + ) + + @test result isa executedOutcome + @test result.isError == false + @test result.result.content[1] isa textContent + @test occursin("America/New_York", result.result.content[1].text) +end + +@testset "executeToolCallsSequential with static tools (full pipeline)" begin + # Tests the full tool execution pipeline: executeToolCallsSequential + # which calls prepareToolCall -> executePreparedToolCall -> finalizeExecutedToolCall + # with statically loaded tools. + + store = toolStore(name="test_full_pipeline") + register_all_tools(store) + + # Build agentContext from the store's tools + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + # Create an assistant message containing tool calls + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + # Create tool calls for multiple statically loaded tools + tool_calls = [ + agentToolCall( + "function", "call-seq-1", "getWeather", + Dict{String,Any}("city" => "Tokyo") + ), + agentToolCall( + "function", "call-seq-2", "getTime", + Dict{String,Any}("timezone" => "Europe/London") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "sequential" + ) + sig = abortSignal(false) + + # Execute the full pipeline + batch = executeToolCallsSequential( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test occursin("Tokyo", batch.messages[1].content[1].text) + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false + @test occursin("Europe/London", batch.messages[2].content[1].text) +end + +@testset "executeToolCallsParallel with static tools (full pipeline)" begin + # Same as above but tests parallel execution path. + + store = toolStore(name="test_parallel") + register_all_tools(store) + + tools = getTools(store) + ctx = agentContext( + "test system prompt", + agentMessage[], + tools + ) + + assistant_msg = assistantMessage( + role="assistant", + content=Vector{messageContent}(), + api="openai", + provider="test", + model="test-model", + usage=llmUsage(0, 0), + stopReason="tool_calls", + errorMessage=nothing, + timestamp=now() + ) + + tool_calls = [ + agentToolCall( + "function", "call-par-1", "getWeather", + Dict{String,Any}("city" => "Paris") + ), + agentToolCall( + "function", "call-par-2", "getTime", + Dict{String,Any}("city" => "Sydney") + ), + ] + + config = agentLoopConfig( + nothing, nothing, "parallel" + ) + sig = abortSignal(false) + + batch = executeToolCallsParallel( + ctx, assistant_msg, tool_calls, config, sig, x -> nothing + ) + + @test batch.messages isa Vector{toolResultMessage} + @test length(batch.messages) == 2 + @test batch.messages[1].toolName == "getWeather" + @test batch.messages[1].isError == false + @test batch.messages[2].toolName == "getTime" + @test batch.messages[2].isError == false +end 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) + + + +