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