Compare commits

...

13 Commits

Author SHA1 Message Date
ton 4e592173a6 update 2026-08-12 14:37:11 +07:00
ton 0cacb5c94a update 2026-08-12 04:35:34 +07:00
ton 6c96409969 update 2026-08-12 04:33:19 +07:00
ton 06d51c1ee9 update 2026-08-12 04:00:09 +07:00
ton 2ad3d1df38 update 2026-08-11 19:10:37 +07:00
ton 83c7770877 update 2026-08-11 18:57:53 +07:00
ton bad14fbe7f update 2026-08-11 18:42:34 +07:00
ton 578e8f55bd update 2026-08-11 18:28:03 +07:00
ton ae3e432b02 update 2026-08-11 17:35:56 +07:00
ton 7c14390400 update 2026-08-11 17:28:25 +07:00
ton 89885c1583 update 2026-08-11 16:43:48 +07:00
ton 5a27630ccf update 2026-08-11 12:15:05 +07:00
ton ed91260468 update 2026-08-10 20:37:28 +07:00
12 changed files with 531 additions and 779 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ Julia framework for building agents with tool use.
1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...` 1. Install dependencies: `]add JSON, DataStructures, UUIDs, Dates, ...`
2. Create a `yiemAgent` with `loadTools("src/tools")` 2. Create a `yiemAgent` with `loadTools("src/tools")`
3. Call `run_agent(agent, "message")` then `take_response(agent)` 3. Call `runAgent(agent, "message")` then `takeResponse(agent)`
## Architecture ## Architecture
@@ -16,7 +16,7 @@ src/
├── type.jl # Core types (messages, tools, agent state) ├── type.jl # Core types (messages, tools, agent state)
├── utils.jl # Message formatting, validation ├── utils.jl # Message formatting, validation
├── agentCore.jl # Agent loop, tool execution pipeline ├── agentCore.jl # Agent loop, tool execution pipeline
├── api.jl # Public API (run_agent, take_response, etc.) ├── api.jl # Public API (runAgent, takeResponse, etc.)
└── tools/ └── tools/
├── registry.jl # Tool registry (loadTools, registerTool, listTools) ├── registry.jl # Tool registry (loadTools, registerTool, listTools)
├── getWeather.jl # Weather lookup tool ├── getWeather.jl # Weather lookup tool
+41 -38
View File
@@ -52,20 +52,21 @@ result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x)
### Step 2: Load — `loadTools()` ### Step 2: Load — `loadTools()`
Load all tool modules from a directory into a `ToolStore`. Each `.jl` file must define `getTool()::agentTool`. Load all tool modules from a directory into a `toolStore`. Each `.jl` file must define `getTool()::agentTool`. `listTool` is auto-registered so the LLM can discover available tools.
```julia ```julia
using YiemAgent, YiemAgent.toolRegistry using YiemAgent, YiemAgent.toolRegistry
store = ToolStore(name="myAgent") store = toolStore(name="myAgent")
tools = loadTools(store, "src/tools") tools = loadTools(store, "src/tools")
# Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools # Scans src/tools/ for .jl files, wraps each in a submodule, calls getTool(), registers in store.tools
# Also auto-registers listTools for runtime discovery
``` ```
**Result extraction:** **Result extraction:**
```julia ```julia
all_tools = getTools(store) # OrderedDict{String, agentTool} all_tools = getTools(store) # OrderedDict{String, agentTool}
# Keys: "getTime", "getWeather", "writeTool" # Keys: "getTime", "getWeather", "writeTool", "listTools"
getTime_tool = all_tools["getTime"] getTime_tool = all_tools["getTime"]
# Manual registration (alternative to loadTools) # Manual registration (alternative to loadTools)
@@ -73,7 +74,7 @@ registerTool(store, my_tool)
clearTools(store) # Clear all tools from store clearTools(store) # Clear all tools from store
``` ```
**Source:** `toolRegistry.jl:113-172` **Source:** `toolRegistry.jl:126-178`
--- ---
@@ -84,8 +85,8 @@ Wire the loaded tools into a new `yiemAgent` instance. The `tools` parameter is
```julia ```julia
using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry
# 1. Set up ToolStore and load tools # 1. Set up toolStore and load tools (auto-registers listTools)
store = ToolStore(name="myAgent") store = toolStore(name="myAgent")
loadTools(store, "src/tools") loadTools(store, "src/tools")
# 2. Create agent — pass tools + _tool_store # 2. Create agent — pass tools + _tool_store
@@ -102,9 +103,10 @@ agent = yiemAgent(
**Manual registration** (without `loadTools`): **Manual registration** (without `loadTools`):
```julia ```julia
store = ToolStore(name="myAgent") store = toolStore(name="myAgent")
registerTool(store, getTime_tool) registerTool(store, getTime_tool)
registerTool(store, getWeather_tool) registerTool(store, getWeather_tool)
registerTool(store, listTool(store)) # needed for manual registration
agent = yiemAgent( agent = yiemAgent(
tools = getTools(store), tools = getTools(store),
@@ -124,7 +126,7 @@ agent = yiemAgent(
| `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history | | `messages` | `Vector{agentMessage}` | No (default: empty) | Initial conversation history |
| `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM | | `llmCall` | `Function` | **Yes** | `(messages::Dict) -> assistantMessage` — invokes the LLM |
| `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events | | `agentEventSink` | `Function` | **Yes** | `(event) -> nothing` — receives tool lifecycle events |
| `_tool_store` | `ToolStore` | No | Runtime tool registry for `registerTool()` | | `_tool_store` | `toolStore` | No | Runtime tool registry for `registerTool()` |
Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`. Optional hooks: `prepareContext`, `formatMsgForLLM`, `beforeToolCall`, `afterToolCall`, `sessionId`, `maxRetryDelayMs`, `parallelToolExecute`.
@@ -149,7 +151,7 @@ result = getTime_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)
**Via agent loop (production):** **Via agent loop (production):**
``` ```
user message → run_agent(agent, Dict("role"=>"user", "content"=>...)) user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
→ _agent_loop detects message → @spawn _process_message(agent) → _agent_loop detects message → @spawn _process_message(agent)
→ prepareContext → formatMsgForLLM → llmCall → prepareContext → formatMsgForLLM → llmCall
→ LLM returns tool_calls → LLM returns tool_calls
@@ -278,12 +280,12 @@ The `terminate` flag is checked at the batch level. See [Section 10](#10-tool-ca
**Source:** `toolRegistry.jl` **Source:** `toolRegistry.jl`
### How `ToolStore` Works ### How `toolStore` Works
The registry uses **per-agent isolated storage** via the `ToolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set. The registry uses **per-agent isolated storage** via the `toolStore` struct. Each agent gets its own store, so tool registration is independent — `registerTool(store, tool)` only affects that agent's tool set.
```julia ```julia
struct ToolStore struct toolStore
tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration tools::OrderedDict{String, agentTool} # keyed by name for O(1) lookup + ordered iteration
name::String # identifier for debugging/logs name::String # identifier for debugging/logs
end end
@@ -294,10 +296,10 @@ end
### How `loadTools(store, dir)` Works ### How `loadTools(store, dir)` Works
```julia ```julia
function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
``` ```
**Source:** `toolRegistry.jl:113-172` **Source:** `toolRegistry.jl:126-178`
1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name) 1. **Scans** `dir` for `.jl` files (excluding files matching `registry` in name)
2. **Sorts** filenames alphabetically for deterministic registration order 2. **Sorts** filenames alphabetically for deterministic registration order
@@ -312,7 +314,8 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool
``` ```
4. **Evaluates** `getTool()` within the submodule scope using `Core.eval(mod, :(getTool()))` — this avoids world-age issues 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 5. **Validates** the return value is an `agentTool` instance
6. **Registers** the tool in `store.tools` and returns an `OrderedDict{String, agentTool}` 6. **Registers** the tool in `store.tools`
7. **Auto-registers** `listTool(store)` so the LLM can discover available tools at runtime
### Why Submodules? ### Why Submodules?
@@ -325,10 +328,10 @@ Each tool file is loaded into its own **namespaced submodule**. This means:
```julia ```julia
# Create per-agent stores # Create per-agent stores
store1 = ToolStore(name="agent1") store1 = toolStore(name="agent1")
store2 = ToolStore(name="agent2") store2 = toolStore(name="agent2")
# Load tools into specific stores # Load tools into specific stores (auto-registers listTools)
tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only tools1 = loadTools(store1, "src/tools/weather_tools") # agent1 only
tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only tools2 = loadTools(store2, "src/tools/wine_tools") # agent2 only
@@ -350,11 +353,11 @@ clearTools(store1) # only clears store1
### Per-Agent Isolation ### Per-Agent Isolation
Each `ToolStore` is completely independent — tools registered in one store do not appear in another: Each `toolStore` is completely independent — tools registered in one store do not appear in another:
```julia ```julia
storeA = ToolStore(name="A") storeA = toolStore(name="A")
storeB = ToolStore(name="B") storeB = toolStore(name="B")
registerTool(storeA, getTime_tool) registerTool(storeA, getTime_tool)
registerTool(storeB, getWeather_tool) registerTool(storeB, getWeather_tool)
@@ -379,10 +382,10 @@ The `_agent_loop()` function runs as a background `@spawn` task, created when `y
``` ```
yiemAgent struct contains: yiemAgent struct contains:
- inputChannel (Channel, capacity 16) ← user sends messages here via run_agent() - inputChannel (Channel, capacity 16) ← user sends messages here via runAgent()
- followUpChannel (Channel, capacity 32) ← user sends follow-ups here via follow_up() - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
- outputChannel (Channel, capacity 16) ← agent sends responses here via take_response() - outputChannel (Channel, capacity 16) ← agent sends responses here via takeResponse()
- _tool_store (ToolStore) ← per-agent isolated tool registry - _tool_store (toolStore) ← per-agent isolated tool registry
``` ```
### Loop States ### Loop States
@@ -630,7 +633,7 @@ function prepareToolCall(
- On failure: throws `ArgumentError(error_string)`, caught by the try-catch below - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below
4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` 4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing`
- Passes `assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context)` and `signal` - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal`
- Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject)
- If `signal.aborted == true``immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `signal.aborted == true``immediateOutcome(createErrorToolResult("Operation aborted"), true)`
- If `before.block == true``immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` - If `before.block == true``immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)`
@@ -719,10 +722,10 @@ function finalizeExecutedToolCall(
``` ```
2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`:
- Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal`
- Hook can mutate the result: - Hook can mutate the result:
```julia ```julia
after = config.afterToolCall(afterCtx(...)) after = config.afterToolCall(afterToolCallContext(...))
if after !== nothing if after !== nothing
result = merge(result, dict( result = merge(result, dict(
:content => get(after, :content, result.content), :content => get(after, :content, result.content),
@@ -1143,8 +1146,8 @@ The `agentEventSink` function is a user-provided callback that receives all even
| `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `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 | | `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format |
| `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API |
| `beforeToolCall` | `(msgCtx::assistantMsgCtx, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | | `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort |
| `afterToolCall` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` |
| `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring |
### `beforeToolCall` Hook ### `beforeToolCall` Hook
@@ -1154,7 +1157,7 @@ The `agentEventSink` function is a user-provided callback that receives all even
```julia ```julia
if config.beforeToolCall !== nothing if config.beforeToolCall !== nothing
before = config.beforeToolCall( before = config.beforeToolCall(
assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal
) )
if signal !== nothing && signal.aborted if signal !== nothing && signal.aborted
return immediateOutcome(createErrorToolResult("Operation aborted"), true) return immediateOutcome(createErrorToolResult("Operation aborted"), true)
@@ -1179,7 +1182,7 @@ end
if config.afterToolCall !== nothing if config.afterToolCall !== nothing
try try
after = config.afterToolCall( after = config.afterToolCall(
afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
) )
if after !== nothing if after !== nothing
result = merge(result, dict( result = merge(result, dict(
@@ -1282,9 +1285,9 @@ The framework supports tools that modify the tool system itself at runtime.
### `listTool` — Discover Available Tools ### `listTool` — Discover Available Tools
**Source:** `toolRegistry.jl:54-82` **Source:** `toolRegistry.jl:55-82`
Each `ToolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. Primarily useful for **collision detection** before creating a new tool via `writeTool`. Each `toolStore` gets its own `listTool` instance bound to that store via `listTool(store)`, so each agent sees only its own tools. `loadTools` auto-registers one, so the LLM can discover available tools at runtime. Also useful for **collision detection** before creating a new tool via `writeTool`.
### Self-Tooling Workflow ### Self-Tooling Workflow
@@ -1324,7 +1327,7 @@ Each `ToolStore` gets its own `listTool` instance bound to that store via `listT
``` ```
USER SENDS MESSAGE USER SENDS MESSAGE
└─> run_agent(agent, "What's the weather in Tokyo?") └─> runAgent(agent, "What's the weather in Tokyo?")
└─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...])) └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))
@@ -1427,7 +1430,7 @@ LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
AGENT LOOP: SEND RESPONSE TO USER AGENT LOOP: SEND RESPONSE TO USER
└─> put!(agent.outputChannel, final_response) └─> put!(agent.outputChannel, final_response)
└─> take_response(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.") └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.")
``` ```
--- ---
@@ -1603,8 +1606,8 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli
| `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `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 (tools, beforeToolCall, afterToolCall, toolExecution) |
| `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) |
| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | | `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) |
| `afterCtx` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | | `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) |
### Event Types ### Event Types
+77 -2
View File
@@ -1,2 +1,77 @@
# ── executeToolCalls() Julia pseudo code ────────────────────────── i am not sure that's the case. see my NATS message log:
# Full call stack from runLoop → executeToolCalls → prepare → execute → finalize → emit <NATS debug message>
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "new user msg"
Info: debug
payload = "_process_message 3"
Info: debug
payload = "_process_message 5"
Info: debug
payload = "_process_message 6"
Info: debug
payload = "_process_message 7"
</NATS debug message>
my NATS receiver report the following for a long time
Info: debug
payload = "new user msg"
untill I Ctrl + d so shutdown the process then i got the following report
Info: debug
payload = "_process_message 3"
Info: debug
payload = "_process_message 5"
Info: debug
payload = "_process_message 6"
Info: debug
payload = "_process_message 7"
my point is if _process_message() actually run then this code in _process_message()
"raw_msg = take!(agent.inputChannel)"
should take the new msg message out of agent.inputChannel and there should be only one debug message showing
Info: debug
payload = "new user msg"
before reaching error("debug marker")
+203 -94
View File
@@ -1,14 +1,142 @@
module agentCore module agentCore
export _agent_loop, OpenAiToUserMessage export yiemAgent, _agent_loop, OpenAiToUserMessage
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames, Base.Threads DataFrames, Base.Threads
using GeneralUtils using GeneralUtils
using ..type, ..utils using ..type, ..utils, ..toolRegistry
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
"""
docstring
"""
mutable struct yiemAgent <: agent # High-level agent wrapper
_state::agentState # Current state (prompt, model, messages, tools, etc.)
# user sends prompt message to agent. if agent is idle, it process user message right away.
# if agent is running, it process user message after the current tool call finished.
inputChannel::Channel
# Buffers messages the user sends while the agent is busy. Processed after all inputChannel
# messages are handled and the agent is idle (not using a tool call).
followUpChannel::Channel
# agent sends response message to user after processing all user messages in inputChannel
# and all followUp messages.
outputChannel::Channel
_agent_loop::Union{Task, Nothing} # agent loop running in the background
# Preprocess/transform messages and context (modify, filter, prune, inject context from memory,
# reorder, ...) for a single LLM call in _process_message()'s loop.
# returns new Vector{agentMessage}
prepareContext::Union{Function, Nothing}
# Convert prepareContext()'s new Vector{agentMessage} to LLM message format
formatMsgForLLM::Function
# 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(
toolsFolderPath::String,
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 from toolsFolderPath
toolStore1 = toolStore(name="myagent")
loadTools(toolStore1, toolsFolderPath)
# Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent(
agentState(systemPrompt, model, getTools(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._agent_loop = @spawn _agent_loop(agent)
return agent
end
""" """
Private agent loop. Runs in a background `@spawn` task. Private agent loop. Runs in a background `@spawn` task.
@@ -81,7 +209,8 @@ function _agent_loop(agent::yiemAgent)
if isready(agent.inputChannel) if isready(agent.inputChannel)
# message will be taken in _process_message() # message will be taken in _process_message()
msg = fetch!(agent.inputChannel) msg = fetch(agent.inputChannel)
agent.agentEventSink("new user msg")
else else
yield() yield()
end end
@@ -107,9 +236,11 @@ function _agent_loop(agent::yiemAgent)
# start _process_message loop # start _process_message loop
if agent._state.activeRun == false if agent._state.activeRun == false
agent.agentEventSink("_agent_loop 2")
# Dispatch message through the processing pipeline # Dispatch message through the processing pipeline
processingTask = Threads.@spawn _process_message(agent) processingTask = Threads.@spawn _process_message(agent)
agent._state.activeRun = true agent._state.activeRun = true
agent.agentEventSink("_agent_loop 3")
end end
# during agent runs, check followUp message after _process_message() is done # during agent runs, check followUp message after _process_message() is done
@@ -173,6 +304,7 @@ julia> # Currently returns a placeholder echo response
``` ```
""" """
function _process_message(agent::yiemAgent)::assistantMessage function _process_message(agent::yiemAgent)::assistantMessage
agent.agentEventSink("_process_message 1")
# loop until llmCall() response didn't use tool calls # loop until llmCall() response didn't use tool calls
final_response = nothing final_response = nothing
while true while true
@@ -185,21 +317,26 @@ function _process_message(agent::yiemAgent)::assistantMessage
Dict( Dict(
"type" => "image_url", "type" => "image_url",
"image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string") "image_url" => Dict("url" => "data:mime_type;base64,image2_base64_string")
) ),
] ]
), )
""" """
# Drain inputChannel and convert OpenAI-format messages to userMessage type # Drain inputChannel and convert OpenAI-format messages to userMessage type
while isready(agent.inputChannel) while isready(agent.inputChannel)
agent.agentEventSink("_process_message 2")
raw_msg = take!(agent.inputChannel) raw_msg = take!(agent.inputChannel)
agent.agentEventSink("_process_message 3")
if raw_msg === :shutdown if raw_msg === :shutdown
agent.agentEventSink("_process_message 4")
# Re-emit shutdown signal for the loop to handle # Re-emit shutdown signal for the loop to handle
put!(agent.inputChannel, :shutdown) put!(agent.inputChannel, :shutdown)
break break
end end
agent.agentEventSink("_process_message 5")
user_msg = OpenAiToUserMessage(raw_msg) user_msg = OpenAiToUserMessage(raw_msg)
push!(agent._state.messages, user_msg) push!(agent._state.messages, user_msg)
agent.agentEventSink("_process_message 6")
end end
# call agent.prepareContext() # call agent.prepareContext()
@@ -208,10 +345,11 @@ function _process_message(agent::yiemAgent)::assistantMessage
# Call agent.formatMsgForLLM(agent._state) to format for LLM # Call agent.formatMsgForLLM(agent._state) to format for LLM
formatted_messages = agent.formatMsgForLLM(preparedContext) formatted_messages = agent.formatMsgForLLM(preparedContext)
agent.agentEventSink("_process_message 7")
# Call llmCall() (blocking — the task waits here) # Call llmCall() (blocking — the task waits here)
error("debug marker")
response = agent.llmCall(formatted_messages) response = agent.llmCall(formatted_messages)
agent.agentEventSink("_process_message 8")
error(5555555)
#WORKING Check if LLM used tool calls (inspect content for tool_call blocks) #WORKING Check if LLM used tool calls (inspect content for tool_call blocks)
has_tool_calls = false has_tool_calls = false
@@ -509,41 +647,42 @@ prepareToolCall(context, msg, tc, config, abortedSignal)
``` ```
""" """
function prepareToolCall( function prepareToolCall(
context::agentContext, context::agentContext,
assistantMsg::assistantMessage, assistantMsg::assistantMessage,
toolCall::agentToolCall, toolCall::agentToolCall,
config::agentLoopConfig, config::agentLoopConfig,
signal::Union{Nothing, abortSignal}, signal::Union{Nothing, abortSignal},
)::Union{preparedToolCall,immediateOutcome} )::Union{preparedToolCall,immediateOutcome}
tool = get(context.tools, toolCall.name, nothing) tool = get(context.tools, toolCall.name, nothing)
if tool === nothing if tool === nothing
return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true)
end
try
# 1. prepare arguments (tool-specific transform)
prepared = prepareToolCallArguments(tool, toolCall)
validatedArgs = validateToolArguments(tool, prepared)
# 2. beforeToolCall hook — can block
if config.beforeToolCall !== nothing
before = config.beforeToolCall(
beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context),
signal
)
if signal !== nothing && signal.aborted
return immediateOutcome(createErrorToolResult("Operation aborted"), true)
end
if before !== nothing && before.block
return immediateOutcome(
createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true)
end
end end
try return preparedToolCall(tool, toolCall, validatedArgs)
# 1. prepare arguments (tool-specific transform) catch err
prepared = prepareToolCallArguments(tool, toolCall) return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true)
validatedArgs = validateToolArguments(tool, prepared) end
# 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
end end
# ── per-call execution ────────────────────────────────────────── # ── per-call execution ──────────────────────────────────────────
@@ -681,61 +820,28 @@ function finalizeExecutedToolCall(
signal::Union{Nothing,abortSignal}, signal::Union{Nothing,abortSignal},
)::finalizedOutcome )::finalizedOutcome
result = executed.result result = executed.result
isError = executed.isError isError = executed.isError
if config.afterToolCall !== nothing if config.afterToolCall !== nothing
try try
after = config.afterToolCall( after = config.afterToolCall(
afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
) )
if after !== nothing if after !== nothing
result = merge(result, dict(:content=>get(after,:content,result.content), result = merge(result, dict(:content=>get(after,:content,result.content),
:details=>get(after,:details,result.details), :details=>get(after,:details,result.details),
:usage=>get(after,:usage,result.usage), :usage=>get(after,:usage,result.usage),
:terminate=>get(after,:terminate,result.terminate))) :terminate=>get(after,:terminate,result.terminate)))
isError = get(after, :isError, isError) isError = get(after, :isError, isError)
end end
catch err catch err
result = createErrorToolResult(sprint(showerror, err)) result = createErrorToolResult(sprint(showerror, err))
isError = true isError = true
end
end end
end
return finalizedOutcome(prep.toolCall, result, isError) 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 end
# ── sequential execution ──────────────────────────────────────── # ── sequential execution ────────────────────────────────────────
@@ -816,7 +922,8 @@ function executeToolCallsSequential(
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
end end
emitToolExecutionEnd(finalized, emit) emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
push!(messages, createToolResultMessage(finalized)) push!(messages, createToolResultMessage(finalized))
push!(finalizedCalls, finalized) push!(finalizedCalls, finalized)
@@ -903,13 +1010,15 @@ function executeToolCallsParallel(
if prep isa immediateOutcome if prep isa immediateOutcome
finalized = finalizedOutcome(tc, prep.result, prep.isError) finalized = finalizedOutcome(tc, prep.result, prep.isError)
emitToolExecutionEnd(finalized, emit) emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
push!(entries, finalized) push!(entries, finalized)
else else
task = task() do task = task() do
executed = executePreparedToolCall(prep, signal, emit) executed = executePreparedToolCall(prep, signal, emit)
finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
emitToolExecutionEnd(finalized, emit) emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name,
finalized.result, finalized.isError))
return finalized return finalized
end end
schedule(task) schedule(task)
+14 -15
View File
@@ -5,12 +5,11 @@ export prompt
using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization,
DataFrames DataFrames
using GeneralUtils using GeneralUtils
using ..type, ..utils using ..type, ..utils, ..agentCore, ..toolRegistry
# ---------------------------------------------- 100 --------------------------------------------- # # ---------------------------------------------- 100 --------------------------------------------- #
""" """
Send a message to the agent's input channel. Send a message to the agent's input channel.
@@ -25,16 +24,16 @@ The agent processes messages from `inputChannel` in the background task.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `take_response(agent)` to receive the agent's response after sending a message. - Use `takeResponse(agent)` to receive the agent's response after sending a message.
- Use `follow_up(agent, msg)` to send messages while the agent is still processing. - Use `followUp(agent, msg)` to send messages while the agent is still processing.
# Examples # Examples
```jldoctest ```jldoctest
julia> run_agent(agent, "Hello!") julia> runAgent(agent, "Hello!")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function run_agent(agent::yiemAgent, msg) function runAgent(agent::yiemAgent, msg)
put!(agent.inputChannel, msg) put!(agent.inputChannel, msg)
return agent return agent
end end
@@ -51,15 +50,15 @@ Blocks until the agent sends a response.
- An `assistantMessage` instance representing the agent's response - An `assistantMessage` instance representing the agent's response
# Notes # Notes
- Use `run_agent(agent, msg)` to send a message before calling this function. - Use `runAgent(agent, msg)` to send a message before calling this function.
# Examples # Examples
```jldoctest ```jldoctest
julia> response = take_response(agent) julia> response = takeResponse(agent)
assistantMessage(...) assistantMessage(...)
``` ```
""" """
function take_response(agent::yiemAgent) function takeResponse(agent::yiemAgent)
return take!(agent.outputChannel) return take!(agent.outputChannel)
end end
@@ -77,17 +76,17 @@ and before any tool call results are sent.
- The same `agent` instance for chaining - The same `agent` instance for chaining
# Notes # Notes
- Use `run_agent(agent, msg)` for the primary message and `follow_up(agent, msg)` for additional - Use `runAgent(agent, msg)` for the primary message and `followUp(agent, msg)` for additional
messages while the agent is processing. messages while the agent is processing.
- Follow-up messages are buffered in a separate channel (capacity 32 by default). - Follow-up messages are buffered in a separate channel (capacity 32 by default).
# Examples # Examples
```jldoctest ```jldoctest
julia> follow_up(agent, "Also consider red wines") julia> followUp(agent, "Also consider red wines")
yiemAgent(...) yiemAgent(...)
``` ```
""" """
function follow_up(agent::yiemAgent, msg) function followUp(agent::yiemAgent, msg)
put!(agent.followUpChannel, msg) put!(agent.followUpChannel, msg)
return agent return agent
end end
@@ -105,16 +104,16 @@ then closes all channels (`inputChannel`, `outputChannel`, `followUpChannel`).
- `nothing` - `nothing`
# Notes # Notes
- After calling `stop_agent`, the agent is no longer usable. A new agent must be created - After calling `stopAgent`, the agent is no longer usable. A new agent must be created
for further interaction. for further interaction.
- If the background task throws a `TaskFailedException`, it is rethrown. - If the background task throws a `TaskFailedException`, it is rethrown.
# Examples # Examples
```jldoctest ```jldoctest
julia> stop_agent(agent) julia> stopAgent(agent)
``` ```
""" """
function stop_agent(agent::yiemAgent) function stopAgent(agent::yiemAgent)
put!(agent.inputChannel, :shutdown) put!(agent.inputChannel, :shutdown)
try try
fetch(agent._agent_loop) fetch(agent._agent_loop)
+84 -56
View File
@@ -1,6 +1,6 @@
module toolRegistry module toolRegistry
export ToolStore, loadTools, registerTool, getTools, clearTools, listTool export toolStore, loadTools, registerTool, getTools, clearTools, listTool
using Dates using Dates
using JSON, DataStructures using JSON, DataStructures
@@ -9,47 +9,65 @@ using ..type
""" """
Per-agent isolated tool storage. Per-agent isolated tool storage.
Each agent gets its own `ToolStore` so tool registration is independent — Each agent gets its own `toolStore` so tool registration is independent —
`registerTool(store, tool)` only affects that agent's tool set. `registerTool(store, tool)` only affects that agent's tool set.
# Fields # Fields
- `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration - `tools::OrderedDict{String, agentTool}` — keyed by name for O(1) lookup + ordered iteration
- `name::String` — identifier for debugging/logs - `name::String` — identifier for debugging/logs
""" """
struct ToolStore struct toolStore
tools::OrderedDict{String, agentTool} tools::OrderedDict{String, agentTool}
name::String name::String
end end
""" """
Create a new isolated tool store. toolStore(; name="default") -> toolStore
Create a new empty tool store.
# Keyword Arguments # Keyword Arguments
- `name::String`: Identifier for this store (default: "default") - `name::String`: Display name for logging (default: `"default"`)
# Examples # Example
```julia ```julia
store = ToolStore(name="agent1") julia> store = toolStore(name="agent1")
tools = loadTools(store, "src/tools") toolStore(OrderedDict{String, agentTool}(), "agent1")
registerTool(store, my_tool)
agent = yiemAgent(tools=getTools(store), llmCall=..., _tool_store=store)
``` ```
""" """
function ToolStore(; name::String="default")::ToolStore function toolStore(; name::String="default")::toolStore
ToolStore(OrderedDict{String, agentTool}(), name) toolStore(OrderedDict{String, agentTool}(), name)
end end
""" """
List tool definition — lets the agent query available tools for collision detection listTool(store::toolStore) -> agentTool
when creating new tools via writeTool.
Return an `agentTool` definition for listing registered tools.
Each call produces a **new** tool object that captures (closes over)
`store`. `loadTools` auto-registers one so the LLM can discover tools
at runtime.
# Arguments # Arguments
- `store::ToolStore`: The tool store to list from - `store`: The tool store whose tools will be listed when the tool runs
Each `ToolStore` gets its own `listTool` instance bound to that store, # Example
so each agent sees only its own tools. ```julia
julia> store = toolStore(name="agent1");
julia> loadTools(store, "src/tools") # auto-registers listTools
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Registered tool: listTools
julia> tools = getTools(store)
OrderedDict{String, agentTool} with 4 entries:
"getWeather" => agentTool(...)
"getTime" => agentTool(...)
"writeTool" => agentTool(...)
"listTools" => agentTool(...)
```
""" """
function listTool(store::ToolStore)::agentTool function listTool(store::toolStore)::agentTool
return agentTool( return agentTool(
name = "listTools", name = "listTools",
label = "List Tools", label = "List Tools",
@@ -80,37 +98,38 @@ function listTool(store::ToolStore)::agentTool
end end
""" """
Load all tool modules from a directory into a specific ToolStore. Load `.jl` tool files from `dir` into `store`, then auto-register
`listTool` so the LLM can discover available tools at runtime.
Scans `dir` for `.jl` files. Each file must define a function named Each `.jl` file must define `function getTool()::agentTool ... end`.
`getTool()::agentTool`. Files are sorted alphabetically so tool Files are sorted alphabetically for deterministic registration order.
registration order is deterministic. Each file is loaded into its own Julia submodule to avoid name collisions.
Each `.jl` file is loaded into its own **submodule** so that all functions
defined in the file (`validateRequiredArgs`, `prepareArguments`, `executeTool`,
and any helper functions) are namespaced and never collide with other tools.
# Arguments # Arguments
- `store::ToolStore`: The tool store to register tools into - `store`: Tool store to populate
- `dir::String`: Directory path to scan for `.jl` tool files - `dir`: Directory containing `.jl` tool files
# Returns # Returns
- `OrderedDict{String, agentTool}`: All loaded tools keyed by name - The same `store.tools` dict (modified in place)
# Errors # Errors
- Throws `ArgumentError` if a tool file does not define a `getTool` function - Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()`
# Examples # Example
```julia ```julia
julia> store = ToolStore(name="agent1") julia> store = toolStore(name="agent1");
julia> tools = loadTools(store, "src/tools")
julia> loadTools(store, "src/tools")
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
[toolRegistry:agent1] Loaded tool: getTime (Time Lookup)
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 3 entries: OrderedDict{String, agentTool} with 3 entries:
"getWeather" => agentTool(...) "getWeather" => agentTool(...)
"getTime" => agentTool(...) "getTime" => agentTool(...)
"listTools" => agentTool(...) "listTools" => agentTool(...)
``` ```
""" """
function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool} function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
if !isdir(dir) if !isdir(dir)
throw(ArgumentError("Tool directory does not exist: $dir")) throw(ArgumentError("Tool directory does not exist: $dir"))
end end
@@ -168,73 +187,82 @@ function loadTools(store::ToolStore, dir::String)::OrderedDict{String, agentTool
end end
end end
registerTool(store, listTool(store))
return store.tools return store.tools
end end
""" """
Register a single agentTool into a specific ToolStore. registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
Add `tool` to `store`, overwriting any existing tool with the same name.
# Arguments # Arguments
- `store::ToolStore`: The tool store to register into - `store`: Tool store to modify
- `tool::agentTool`: The tool to register - `tool`: The `agentTool` to register
# Returns # Returns
- `OrderedDict{String, agentTool}`: Updated tool dict for this store - The same `store.tools` dict (modified in place)
# Examples # Example
```julia ```julia
julia> store = ToolStore(name="agent1") julia> store = toolStore(name="agent1");
julia> registerTool(store, my_tool)
[toolRegistry:agent1] Registered tool: my_tool julia> registerTool(store, listTool(store))
[toolRegistry:agent1] Registered tool: listTools
OrderedDict{String, agentTool} with 1 entry:
"listTools" => agentTool(...)
``` ```
""" """
function registerTool(store::ToolStore, tool::agentTool)::OrderedDict{String, agentTool} function registerTool(store::toolStore, tool::agentTool)::OrderedDict{String, agentTool}
store.tools[tool.name] = tool store.tools[tool.name] = tool
println("[$(store.name)] Registered tool: $(tool.name)") println("[$(store.name)] Registered tool: $(tool.name)")
return store.tools return store.tools
end end
""" """
Get the registered tools from a specific ToolStore. Return the tools registered in `store`.
Returns the internal `OrderedDict` directly — O(1) lookup by name, The returned dict is the **same object** stored inside `store` — mutations
ordered iteration preserving registration order. to it (e.g. via `registerTool`) are visible through subsequent calls.
# Arguments # Arguments
- `store::ToolStore`: The tool store to query - `store`: Tool store to query
# Returns # Returns
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order - `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
# Examples # Example
```julia ```julia
julia> getTools(store) julia> tools = getTools(store)
OrderedDict{String, agentTool} with 3 entries: OrderedDict{String, agentTool} with 2 entries:
"listTools" => agentTool(...)
"getWeather" => agentTool(...) "getWeather" => agentTool(...)
"getTime" => agentTool(...) "getTime" => agentTool(...)
``` ```
""" """
function getTools(store::ToolStore)::OrderedDict{String, agentTool} function getTools(store::toolStore)::OrderedDict{String, agentTool}
return store.tools return store.tools
end end
""" """
Clear all registered tools from a specific ToolStore. Remove all tools from `store`.
# Arguments # Arguments
- `store::ToolStore`: The tool store to clear - `store`: Tool store to clear
# Returns # Returns
- `nothing` - `nothing`
# Examples # Example
```julia ```julia
julia> clearTools(store) julia> clearTools(store)
[toolRegistry:agent1] Registry cleared [toolRegistry:agent1] Registry cleared
nothing
julia> getTools(store)
OrderedDict{String, agentTool} with 0 entries
``` ```
""" """
function clearTools(store::ToolStore)::Nothing function clearTools(store::toolStore)::Nothing
empty!(store.tools) empty!(store.tools)
println("[$(store.name)] Registry cleared") println("[$(store.name)] Registry cleared")
return nothing return nothing
+2 -1
View File
@@ -43,7 +43,8 @@ Execute the getTime tool.
Returns mock time data for the given timezone or city. Returns mock time data for the given timezone or city.
""" """
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
tz = get(args, "timezone", nothing) tz = get(args, "timezone", nothing)
city = get(args, "city", "") city = get(args, "city", "")
if tz !== nothing if tz !== nothing
+2 -1
View File
@@ -3,7 +3,8 @@ Execute the getWeather tool.
Returns mock weather data for the given city and temperature units. Returns mock weather data for the given city and temperature units.
""" """
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
onPartialResult::Function)::agentToolResult
city = get(args, "city", "") city = get(args, "city", "")
units = get(args, "units", "celsius") units = get(args, "units", "celsius")
temp = units == "fahrenheit" ? "72" : "22" temp = units == "fahrenheit" ? "72" : "22"
+26 -158
View File
@@ -13,8 +13,8 @@
# Context types # Context types
agentContext, agentState, agentToolCall, prepareNextTurnContext, agentContext, agentState, agentToolCall, prepareNextTurnContext,
# Loop & execution types # Loop & execution types
agentLoopConfig, abortSignal, agentToolResult, agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext,
assistantMsgCtx, afterCtx, beforeToolCallResult, afterToolCallContext,
# Event types # Event types
toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent,
# Agent # Agent
@@ -23,7 +23,7 @@
preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome, preparedToolCall, immediateOutcome, executedOutcome, finalizedOutcome,
agentToolCallBatch, agentToolCallBatch,
# Functions (defined elsewhere) # Functions (defined elsewhere)
run_agent, take_response, follow_up, stop_agent runAgent, takeResponse, followUp, stopAgent
using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads using Dates, UUIDs, DataStructures, JSON, NATS, Base.Threads
@@ -144,7 +144,7 @@ assistantMessage("assistant", [textContent("Hello!")], "", "", "gpt-4", ..., "en
``` ```
""" """
function assistantMessage(; role="assistant", content=Vector{messageContent}(), function assistantMessage(; role="assistant", content=Vector{messageContent}(),
api="", provider="", model="", usage=llmUsage(0, 0), stopReason="end_turn", api="", provider="", model=nothing, usage=llmUsage(0, 0), stopReason="end_turn",
errorMessage=nothing, timestamp=now()) errorMessage=nothing, timestamp=now())
return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp) return assistantMessage(role, content, api, provider, model, usage, stopReason, errorMessage, timestamp)
end end
@@ -309,7 +309,7 @@ end
mutable struct agentState # Mutable runtime state of an agent mutable struct agentState # Mutable runtime state of an agent
systemPrompt::String # System prompt for the agent systemPrompt::String # System prompt for the agent
model::llmModel # LLM model to use model::Union{llmModel, Nothing} # LLM model to use
tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt # messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
@@ -341,21 +341,21 @@ julia> state = agentState(systemPrompt="You are a helpful assistant")
agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing) agentState("You are a helpful assistant", OrderedDict{String, agentTool}(), agentMessage[], String[], nothing)
""" """
function agentState( function agentState(
systemPrompt::String="", systemPrompt::String="",
model::llmModel=llmModel{String}("", "", "unknown", "unknown", "", false, String[], model=llmModel("model_1", "unknown", "unknown", "", false, String[],
modelCost(0.0, 0.0, 0.0, 0.0), 0, 0), modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[], messages::Vector{agentMessage}=agentMessage[],
) )
agentState( agentState(
systemPrompt, systemPrompt,
model, model,
deepcopy(tools), deepcopy(tools),
deepcopy(messages), deepcopy(messages),
Vector{String}(), Vector{String}(),
false, false,
nothing, nothing,
) )
end end
@@ -442,13 +442,18 @@ Context passed to the `beforeToolCall` hook.
- `args::Dict{String,Any}`: Validated tool arguments - `args::Dict{String,Any}`: Validated tool arguments
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct assistantMsgCtx struct beforeToolCallContext
message::assistantMessage message::assistantMessage
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::Dict{String,Any}
context::agentContext context::agentContext
end end
struct beforeToolCallResult
block::Bool
reason::String
end
""" """
Context passed to the `afterToolCall` hook. Context passed to the `afterToolCall` hook.
@@ -460,7 +465,7 @@ Context passed to the `afterToolCall` hook.
- `isError::Bool`: Whether execution resulted in an error - `isError::Bool`: Whether execution resulted in an error
- `context::agentContext`: Current conversation context - `context::agentContext`: Current conversation context
""" """
struct afterCtx struct afterToolCallContext
message::assistantMessage message::assistantMessage
toolCall::agentToolCall toolCall::agentToolCall
args::Dict{String,Any} args::Dict{String,Any}
@@ -521,143 +526,6 @@ end
abstract type agent end abstract type agent end
"""
docstring
"""
mutable struct yiemAgent <: agent # High-level agent wrapper
_state::agentState # Current state (prompt, model, messages, tools, etc.)
# user sends prompt message to agent. if agent is idle, it process user message right away.
# if agent is running, it process user message after the current tool call finished.
inputChannel::Channel
# Buffers messages the user sends while the agent is busy. Processed after all inputChannel
# messages are handled and the agent is idle (not using a tool call).
followUpChannel::Channel
# agent sends response message to user after processing all user messages in inputChannel
# and all followUp messages.
outputChannel::Channel
_agent_loop::Union{Task, Nothing} # agent loop running in the background
# Preprocess/transform messages and context (modify, filter, prune, inject context from memory,
# reorder, ...) for a single LLM call in _process_message()'s loop.
# returns new Vector{agentMessage}
prepareContext ::Union{Function, Nothing}
# Convert prepareContext()'s new Vector{agentMessage} to LLM message format
formatMsgForLLM::Function
# Actually invoke the LLM to get a completion response. The LLM response comes back as an
# assistantMessage whose content is an array of content blocks.
# Each block has a type — "text", "thinking", or "toolCall".
# The code filters for type === "toolCall" blocks, then passes them to executeToolCalls().
llmCall::Function
# Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..)
beforeToolCall::Union{Function, Nothing}
executeToolCalls::Function # execute tool calls ()
# Callback invoked after executing a tool call to sanitize tools output so the output is ready
# to be converted into toolResults message
afterToolCall::Union{Function, Nothing}
# prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn
# prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context
sessionId::Union{String, Nothing} # Optional session identifier
maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms)
parallelToolExecute::Bool # Default: false
agentEventSink::Function # agent emits its status via this function
_tool_store::Any # Reference to the ToolStore for runtime registration
end
"""
Create a new yiemAgent instance with a background loop task.
Spawns a background `@spawn` task that runs the agent loop, listening
on `inputChannel` and `followUpChannel` channels concurrently.
# Keyword Arguments
- `systemPrompt::String`: System prompt for the agent
- `model`: LLM model to use
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty)
- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty)
- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`)
- `llmCall::Function`: Function to invoke the LLM (required)
- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`)
- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`)
- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`)
- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`)
- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`)
- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`)
- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`)
- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`)
- `agentEventSink::Function`: Callback to receive agent events
- `tool_store::Union{Any, Nothing}`: ToolStore for runtime tool registration (default: `nothing`)
# Returns
- A new `yiemAgent` instance with an active background task
# Examples
```julia
julia> store = ToolStore(name="agent1")
julia> tools = loadTools(store, "src/tools")
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store)
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store)
"""
function yiemAgent(
; systemPrompt::String="You are helpful assistant.",
model=nothing,
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
messages::Vector{agentMessage}=agentMessage[],
prepareContext::Union{Function, Nothing}=nothing,
formatMsgForLLM::Function=defaultformatMsgForLLM,
llmCall::Function,
beforeToolCall::Union{Function, Nothing}=nothing,
afterToolCall::Union{Function, Nothing}=nothing,
# prepareNextTurn::Union{Function, Nothing}=nothing,
# prepareNextTurnWithContext::Union{Function, Nothing}=nothing,
sessionId::Union{String, Nothing}=nothing,
maxRetryDelayMs::Union{Int64, Nothing}=nothing,
parallelToolExecute::Bool=false,
agentEventSink::Function,
tool_store::Union{Any, Nothing}=nothing,
)
# Create channels: input (user -> agent), followUp (async queue), output (agent -> user)
inputChannel = Channel(16)
followUp = Channel(32)
outputChannel = Channel(16)
# Create struct with a placeholder task, then spawn and replace it
agent = yiemAgent(
agentState(systemPrompt, model, tools, messages),
inputChannel,
followUp,
outputChannel,
nothing, # placeholder — replaced below
prepareContext,
formatMsgForLLM,
llmCall,
beforeToolCall,
afterToolCall,
# prepareNextTurn,
# prepareNextTurnWithContext,
sessionId,
maxRetryDelayMs,
parallelToolExecute,
agentEventSink,
tool_store,
)
# Spawn the background loop and attach it
agent._agent_loop = @spawn _agent_loop(agent)
return agent
end
""" """
preparedToolCall(tool, toolCall, args) preparedToolCall(tool, toolCall, args)
+32 -2
View File
@@ -1,7 +1,9 @@
module utils module utils
export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks validateToolArguments, _userMessageToOpenAI,
_assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks,
beforeToolCall, afterToolCall, agentEventSink
using UUIDs, Dates, DataStructures, HTTP, JSON using UUIDs, Dates, DataStructures, HTTP, JSON
using GeneralUtils using GeneralUtils
@@ -218,6 +220,34 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any}
return Dict("messages" => messages) return Dict("messages" => messages)
end end
#TODO
function beforeToolCall(context::beforeToolCallContext, signal::abortSignal
)::beforeToolCallResult
# final context check
# seek user approval via UI
# other check
return beforeToolCallResult(false, "N/A")
end
#TODO
function afterToolCall(context::beforeToolCallContext, signal::abortSignal
)::Union{agentToolResult, Nothing}
# modify context.result if needed and return agentToolResult
return nothing
end
#TODO
function agentEventSink(x)
end
""" """
Convert a userMessage to OpenAI message format. Convert a userMessage to OpenAI message format.
-401
View File
@@ -1,401 +0,0 @@
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")
+48 -9
View File
@@ -6,12 +6,12 @@ using YiemAgent.type
# Path to the real tools directory # Path to the real tools directory
TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools") TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@testset "loadTools with ToolStore" begin @testset "loadTools with toolStore" begin
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 1. loadTools throws on non-existent directory # # 1. loadTools throws on non-existent directory #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
store = ToolStore(name="test1") store = toolStore(name="test1")
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist") @test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -26,24 +26,25 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3. loadTools loads actual tool files from src/tools/ # # 3. loadTools loads actual tool files from src/tools/ #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
store2 = ToolStore(name="test2") store2 = toolStore(name="test2")
loaded = loadTools(store2, TOOLS_DIR) loaded = loadTools(store2, TOOLS_DIR)
@test !isempty(loaded) @test !isempty(loaded)
@test length(loaded) == 3 @test length(loaded) == 4 # 3 files + auto-registered listTools
names = [k for k in keys(loaded)] names = [k for k in keys(loaded)]
@test "getTime" in names @test "getTime" in names
@test "getWeather" in names @test "getWeather" in names
@test "writeTool" in names @test "writeTool" in names
@test "listTools" in names
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 4. loadTools returns tools sorted alphabetically by filename # # 4. loadTools returns tools sorted alphabetically by filename #
# (getTime.jl < getWeather.jl < writeTool.jl) # # (getTime.jl < getWeather.jl < writeTool.jl) + listTools at end #
# because 'T' < 'W' in ASCII #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@test collect(keys(loaded))[1] == "getTime" @test collect(keys(loaded))[1] == "getTime"
@test collect(keys(loaded))[2] == "getWeather" @test collect(keys(loaded))[2] == "getWeather"
@test collect(keys(loaded))[3] == "writeTool" @test collect(keys(loaded))[3] == "writeTool"
@test collect(keys(loaded))[4] == "listTools"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Verify loaded tool fields are correct # # 5. Verify loaded tool fields are correct #
@@ -102,7 +103,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 7. getTools / registerTool / clearTools (per-store isolation) # # 7. getTools / registerTool / clearTools (per-store isolation) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
store3 = ToolStore(name="test3") store3 = toolStore(name="test3")
registry_tools = getTools(store3) registry_tools = getTools(store3)
@test isempty(registry_tools) @test isempty(registry_tools)
@@ -153,8 +154,8 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 9. Per-store isolation — two stores don't share tools # # 9. Per-store isolation — two stores don't share tools #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
storeA = ToolStore(name="isolationA") storeA = toolStore(name="isolationA")
storeB = ToolStore(name="isolationB") storeB = toolStore(name="isolationB")
registerTool(storeA, loaded["getTime"]) registerTool(storeA, loaded["getTime"])
registerTool(storeB, loaded["getWeather"]) registerTool(storeB, loaded["getWeather"])
@@ -171,3 +172,41 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
@test isempty(getTools(storeA)) @test isempty(getTools(storeA))
@test !isempty(getTools(storeB)) # storeB unaffected @test !isempty(getTools(storeB)) # storeB unaffected
end end
@testset "listTool" begin
store = toolStore(name="test_list")
loaded = loadTools(store, TOOLS_DIR) # auto-registers getWeather, getTime, writeTool + listTools
# loadTools auto-registers listTool
@test "listTools" in keys(loaded)
# listTool returns an agentTool, not a string or array
list_t = listTool(store)
@test list_t isa agentTool
@test list_t.name == "listTools"
@test list_t.label == "List Tools"
@test isempty(list_t.inputSchema["required"])
# Verify all tools appear (3 loaded + listTools = 4)
result = list_t.execute("call-1", Dict{String,Any}(), nothing, x -> x)
@test result isa agentToolResult
@test result.content[1] isa textContent
@test occursin("listTools", result.content[1].text)
@test occursin("getWeather", result.content[1].text)
@test occursin("getTime", result.content[1].text)
@test occursin("writeTool", result.content[1].text)
@test result.details["count"] == 4
# Each listTool call creates an independent closure
storeB = toolStore(name="test_listB")
registerTool(storeB, loaded["getWeather"])
list_tB = listTool(storeB)
resultA = list_t.execute("call-3", Dict{String,Any}(), nothing, x -> x)
resultB = list_tB.execute("call-4", Dict{String,Any}(), nothing, x -> x)
@test occursin("getWeather", resultA.content[1].text)
@test occursin("getWeather", resultB.content[1].text)
@test occursin("getTime", resultA.content[1].text)
@test occursin("getTime", resultB.content[1].text) == false # storeB only has getWeather
end