Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e592173a6 | |||
| 0cacb5c94a | |||
| 6c96409969 | |||
| 06d51c1ee9 | |||
| 2ad3d1df38 | |||
| 83c7770877 | |||
| bad14fbe7f | |||
| 578e8f55bd | |||
| ae3e432b02 | |||
| 7c14390400 | |||
| 89885c1583 | |||
| 5a27630ccf | |||
| ed91260468 | |||
| c13aeb3a74 | |||
| 287704778f | |||
| a9fa23f01b | |||
| c5cb18f0f1 | |||
| c78f4b023d | |||
| 1b69f69c7d | |||
| 3891099eaa | |||
| 268d340e2f | |||
| 189bc2efcf | |||
| 92b3e4081f | |||
| 750eff483b |
@@ -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
|
||||||
|
|||||||
+1625
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ module YiemAgent
|
|||||||
include("utils.jl")
|
include("utils.jl")
|
||||||
using .utils
|
using .utils
|
||||||
|
|
||||||
include("tools/registry.jl")
|
include("toolRegistry.jl")
|
||||||
using .toolRegistry
|
using .toolRegistry
|
||||||
|
|
||||||
# include("llmfunction.jl")
|
# include("llmfunction.jl")
|
||||||
|
|||||||
+205
-100
@@ -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 = find(t -> t.name == toolCall.name, context.tools)
|
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)
|
||||||
@@ -996,13 +1105,9 @@ function executeToolCalls(
|
|||||||
|
|
||||||
hasSequential = false
|
hasSequential = false
|
||||||
for tc in toolCalls
|
for tc in toolCalls
|
||||||
for t in context.tools
|
t = get(context.tools, tc.name, nothing)
|
||||||
if t.name == tc.name && !t.parallelToolExecute
|
if t !== nothing && !t.parallelToolExecute
|
||||||
hasSequential = true
|
hasSequential = true
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if hasSequential
|
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+14
-15
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
module toolRegistry
|
||||||
|
|
||||||
|
export toolStore, loadTools, registerTool, getTools, clearTools, listTool
|
||||||
|
|
||||||
|
using Dates
|
||||||
|
using JSON, DataStructures
|
||||||
|
using ..type
|
||||||
|
|
||||||
|
"""
|
||||||
|
Per-agent isolated tool storage.
|
||||||
|
|
||||||
|
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
|
||||||
|
tools::OrderedDict{String, agentTool}
|
||||||
|
name::String
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
toolStore(; name="default") -> toolStore
|
||||||
|
|
||||||
|
Create a new empty tool store.
|
||||||
|
|
||||||
|
# Keyword Arguments
|
||||||
|
- `name::String`: Display name for logging (default: `"default"`)
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
julia> store = toolStore(name="agent1")
|
||||||
|
toolStore(OrderedDict{String, agentTool}(), "agent1")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
function toolStore(; name::String="default")::toolStore
|
||||||
|
toolStore(OrderedDict{String, agentTool}(), name)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
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`: The tool store whose tools will be listed when the tool runs
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
julia> store = toolStore(name="agent1");
|
||||||
|
|
||||||
|
julia> loadTools(store, "src/tools") # auto-registers listTools
|
||||||
|
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
|
||||||
|
[toolRegistry:agent1] Registered tool: listTools
|
||||||
|
|
||||||
|
julia> tools = getTools(store)
|
||||||
|
OrderedDict{String, agentTool} with 4 entries:
|
||||||
|
"getWeather" => agentTool(...)
|
||||||
|
"getTime" => agentTool(...)
|
||||||
|
"writeTool" => agentTool(...)
|
||||||
|
"listTools" => agentTool(...)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
function listTool(store::toolStore)::agentTool
|
||||||
|
return agentTool(
|
||||||
|
name = "listTools",
|
||||||
|
label = "List Tools",
|
||||||
|
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
|
||||||
|
inputSchema = Dict{String,Any}(
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Dict{String,Any}(),
|
||||||
|
"required" => Any[]
|
||||||
|
),
|
||||||
|
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
||||||
|
tools = getTools(store)
|
||||||
|
if isempty(tools)
|
||||||
|
result_text = "No tools registered."
|
||||||
|
else
|
||||||
|
lines = String["- $(t.name): $(t.label) — $(t.description)" for (k, t) in tools]
|
||||||
|
result_text = "Available tools:\n" * join(lines, "\n")
|
||||||
|
end
|
||||||
|
return agentToolResult(
|
||||||
|
[textContent(result_text)],
|
||||||
|
Dict{Any,Any}("count" => length(tools)),
|
||||||
|
nothing, false
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
prepareArguments = nothing,
|
||||||
|
validateRequiredArgs = nothing,
|
||||||
|
parallelToolExecute = false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
Load `.jl` tool files from `dir` into `store`, then auto-register
|
||||||
|
`listTool` so the LLM can discover available tools at runtime.
|
||||||
|
|
||||||
|
Each `.jl` file must define `function getTool()::agentTool ... end`.
|
||||||
|
Files are sorted alphabetically for deterministic registration order.
|
||||||
|
Each file is loaded into its own Julia submodule to avoid name collisions.
|
||||||
|
|
||||||
|
# Arguments
|
||||||
|
- `store`: Tool store to populate
|
||||||
|
- `dir`: Directory containing `.jl` tool files
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
- The same `store.tools` dict (modified in place)
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
- Throws `ArgumentError` if `dir` does not exist or a file lacks `getTool()`
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
julia> store = toolStore(name="agent1");
|
||||||
|
|
||||||
|
julia> loadTools(store, "src/tools")
|
||||||
|
[toolRegistry:agent1] Loaded tool: getWeather (Weather Lookup)
|
||||||
|
[toolRegistry:agent1] Loaded tool: getTime (Time Lookup)
|
||||||
|
[toolRegistry:agent1] Registered tool: listTools
|
||||||
|
OrderedDict{String, agentTool} with 3 entries:
|
||||||
|
"getWeather" => agentTool(...)
|
||||||
|
"getTime" => agentTool(...)
|
||||||
|
"listTools" => agentTool(...)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
function loadTools(store::toolStore, dir::String)::OrderedDict{String, agentTool}
|
||||||
|
if !isdir(dir)
|
||||||
|
throw(ArgumentError("Tool directory does not exist: $dir"))
|
||||||
|
end
|
||||||
|
|
||||||
|
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
|
||||||
|
sort!(jl_files)
|
||||||
|
|
||||||
|
for filename in jl_files
|
||||||
|
filepath = joinpath(dir, filename)
|
||||||
|
|
||||||
|
# Derive a unique module name from the filename only (not full path).
|
||||||
|
# e.g. "getWeather.jl" -> "_tool_getWeather"
|
||||||
|
mod_name = Symbol("_tool_", replace(rstrip(filename, '.'), ".jl" => ""))
|
||||||
|
|
||||||
|
# Build the complete module as a string and eval the parsed code.
|
||||||
|
# Julia does not allow `module ... end` inside eval(quote ...),
|
||||||
|
# and constructing the module AST by hand is fragile.
|
||||||
|
# Instead, we generate the full module source as a string,
|
||||||
|
# parse it, and eval the resulting expression.
|
||||||
|
# Each tool file declares its own dependencies via `using` statements
|
||||||
|
# at the top of the file — the registry only injects `using ..type`
|
||||||
|
# to make core types (agentTool, textContent, etc.) available.
|
||||||
|
file_content = read(filepath, String)
|
||||||
|
module_code = """
|
||||||
|
module $(mod_name)
|
||||||
|
using ..type
|
||||||
|
$(file_content)
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
mod = eval(Meta.parse(module_code))
|
||||||
|
|
||||||
|
# Call getTool() via Core.eval in the submodule's scope.
|
||||||
|
# This evaluates getTool() entirely within the new module's world,
|
||||||
|
# completely avoiding world-age issues — no invokelatest needed.
|
||||||
|
# Note: all uses of `tool` must be inside the `try` block because
|
||||||
|
# Julia 1.12's SSA form doesn't track `tool` as definitely assigned
|
||||||
|
# after a `try-catch` where it's only assigned inside `try`.
|
||||||
|
try
|
||||||
|
tool = Core.eval(mod, :(getTool()))
|
||||||
|
if !(tool isa agentTool)
|
||||||
|
throw(ArgumentError(
|
||||||
|
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
|
||||||
|
))
|
||||||
|
end
|
||||||
|
store.tools[tool.name] = tool
|
||||||
|
println("[$(store.name)] Loaded tool: $(tool.name) ($(tool.label))")
|
||||||
|
catch e
|
||||||
|
if e isa UndefVarError || occursin("getTool", sprint(showerror, e))
|
||||||
|
throw(ArgumentError(
|
||||||
|
"Tool file $(filepath) does not define a `getTool()` function in module $(mod_name). " *
|
||||||
|
"Each tool file must define: function getTool()::agentTool ... end"
|
||||||
|
))
|
||||||
|
end
|
||||||
|
rethrow(e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
registerTool(store, listTool(store))
|
||||||
|
return store.tools
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
registerTool(store::toolStore, tool::agentTool) -> OrderedDict{String, agentTool}
|
||||||
|
|
||||||
|
Add `tool` to `store`, overwriting any existing tool with the same name.
|
||||||
|
|
||||||
|
# Arguments
|
||||||
|
- `store`: Tool store to modify
|
||||||
|
- `tool`: The `agentTool` to register
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
- The same `store.tools` dict (modified in place)
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
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}
|
||||||
|
store.tools[tool.name] = tool
|
||||||
|
println("[$(store.name)] Registered tool: $(tool.name)")
|
||||||
|
return store.tools
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
Return the tools registered in `store`.
|
||||||
|
|
||||||
|
The returned dict is the **same object** stored inside `store` — mutations
|
||||||
|
to it (e.g. via `registerTool`) are visible through subsequent calls.
|
||||||
|
|
||||||
|
# Arguments
|
||||||
|
- `store`: Tool store to query
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
- `OrderedDict{String, agentTool}`: Tools keyed by name, in registration order
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
julia> tools = getTools(store)
|
||||||
|
OrderedDict{String, agentTool} with 2 entries:
|
||||||
|
"getWeather" => agentTool(...)
|
||||||
|
"getTime" => agentTool(...)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
function getTools(store::toolStore)::OrderedDict{String, agentTool}
|
||||||
|
return store.tools
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
Remove all tools from `store`.
|
||||||
|
|
||||||
|
# Arguments
|
||||||
|
- `store`: Tool store to clear
|
||||||
|
|
||||||
|
# Returns
|
||||||
|
- `nothing`
|
||||||
|
|
||||||
|
# Example
|
||||||
|
```julia
|
||||||
|
julia> clearTools(store)
|
||||||
|
[toolRegistry:agent1] Registry cleared
|
||||||
|
nothing
|
||||||
|
|
||||||
|
julia> getTools(store)
|
||||||
|
OrderedDict{String, agentTool} with 0 entries
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
function clearTools(store::toolStore)::Nothing
|
||||||
|
empty!(store.tools)
|
||||||
|
println("[$(store.name)] Registry cleared")
|
||||||
|
return nothing
|
||||||
|
end
|
||||||
|
|
||||||
|
end # module
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
# Tools
|
|
||||||
|
|
||||||
Tools allow the agent to perform actions and fetch data. Each tool defines a **schema** (what arguments it accepts) and an **execution function** (what it does).
|
|
||||||
|
|
||||||
## Tool Anatomy
|
|
||||||
|
|
||||||
Each tool has 3 main parts:
|
|
||||||
|
|
||||||
### 1. Schema (`inputSchema`)
|
|
||||||
|
|
||||||
JSON Schema (MCP format) describing the tool's arguments. The `"required"` array lists mandatory fields:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
inputSchema = Dict{String,Any}(
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Dict(
|
|
||||||
"city" => Dict("type" => "string", "description" => "City name"),
|
|
||||||
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
|
|
||||||
),
|
|
||||||
"required" => ["city"]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Execution Function (`execute`)
|
|
||||||
|
|
||||||
A function with the signature:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
execute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`toolCallId`** — unique ID for this invocation (from the LLM's tool call)
|
|
||||||
- **`args`** — validated arguments provided by the LLM
|
|
||||||
- **`signal`** — abort signal for cancellable operations
|
|
||||||
- **`onPartialResult`** — callback for streaming progress updates
|
|
||||||
- **Returns** — `agentToolResult` with content, details, usage, and termination flag
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
|
||||||
# Optional: stream progress updates
|
|
||||||
onPartialResult(Dict("status" => "Fetching data..."))
|
|
||||||
|
|
||||||
# Do work
|
|
||||||
result = "Weather in $(args["city"]): Sunny, 22°C"
|
|
||||||
|
|
||||||
# Return result
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent(result)],
|
|
||||||
Dict{Any,Any}(), # details
|
|
||||||
nothing, # usage
|
|
||||||
false # terminate (true to stop agent loop)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Tool Definition (`getTool()`)
|
|
||||||
|
|
||||||
Returns an `agentTool` struct:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `name` | `String` | Unique identifier (e.g. `"getWeather"`) |
|
|
||||||
| `label` | `String` | Human-readable name (e.g. `"Weather Lookup"`) |
|
|
||||||
| `description` | `String` | What the tool does (shown to the LLM) |
|
|
||||||
| `inputSchema` | `Any` | JSON Schema (MCP format) |
|
|
||||||
| `execute` | `Function` | The execution function |
|
|
||||||
| `prepareArguments` | `Union{Function,Nothing}` | Optional argument transform before validation |
|
|
||||||
| `validateRequiredArgs` | `Union{Function,Nothing}` | Optional custom validation |
|
|
||||||
| `parallelToolExecute` | `Bool` | Run this tool in parallel with others |
|
|
||||||
|
|
||||||
## Argument Validation
|
|
||||||
|
|
||||||
Validation happens **before** tool execution, in the `prepareToolCall` phase. Invalid calls return an error immediately without invoking `execute`, `beforeToolCall`, or logging `toolExecutionStart`.
|
|
||||||
|
|
||||||
### Default: JSON Schema Required Fields
|
|
||||||
|
|
||||||
Set `validateRequiredArgs = nothing` to use the default validator, which checks that all fields in `inputSchema["required"]` are present:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# src/tools/getWeather.jl — uses default validation
|
|
||||||
function getTool()::agentTool
|
|
||||||
return agentTool(
|
|
||||||
name = "getWeather",
|
|
||||||
# ...
|
|
||||||
validateRequiredArgs = nothing, # uses default
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Validation Hook
|
|
||||||
|
|
||||||
Override `validateRequiredArgs` when you need:
|
|
||||||
- **Cross-field constraints** (e.g. "at least one of X or Y")
|
|
||||||
- **Format validation** (e.g. regex patterns, date parsing)
|
|
||||||
- **Domain rules** (e.g. value ranges, business logic)
|
|
||||||
|
|
||||||
The hook signature takes only `args`:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
|
||||||
tz = get(args, "timezone", nothing)
|
|
||||||
city = get(args, "city", "")
|
|
||||||
|
|
||||||
if !haskey(args, "timezone") && isempty(city)
|
|
||||||
return "Missing required argument: provide at least one of 'timezone' or 'city'"
|
|
||||||
end
|
|
||||||
|
|
||||||
if tz !== nothing
|
|
||||||
tz_str = string(tz)
|
|
||||||
if !occursin(r"^[A-Za-z]+\/[A-Za-z]+(/[A-Za-z]+)*$", tz_str)
|
|
||||||
return "Invalid timezone format: '$tz_str'. Use IANA format, e.g. 'America/New_York'"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return nothing
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Return `nothing` to pass, or an error `String` to fail. The error is fed back to the LLM so it can retry with corrected arguments.
|
|
||||||
|
|
||||||
## Tool Discovery and Lifecycle
|
|
||||||
|
|
||||||
The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective:
|
|
||||||
|
|
||||||
### The Agent Loop
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# agentCore.jl:175 - _process_message()
|
|
||||||
while true
|
|
||||||
# 1. Drain messages from inputChannel
|
|
||||||
while isready(agent.inputChannel)
|
|
||||||
raw_msg = take!(agent.inputChannel)
|
|
||||||
user_msg = OpenAiToUserMessage(raw_msg)
|
|
||||||
push!(agent._state.messages, user_msg)
|
|
||||||
end
|
|
||||||
|
|
||||||
# 2. Format messages for LLM
|
|
||||||
ctx = agent.prepareContext(agent._state)
|
|
||||||
formatted = agent.formatMsgForLLM(ctx)
|
|
||||||
|
|
||||||
# 3. Call LLM
|
|
||||||
response = agent.llmCall(formatted)
|
|
||||||
|
|
||||||
# 4. Check if LLM used tool calls
|
|
||||||
if has_tool_calls(response.content)
|
|
||||||
# 5. Execute tools, feed results back to LLM, loop
|
|
||||||
else
|
|
||||||
# 6. No tool calls — return final response
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 1: Tool Discovery
|
|
||||||
|
|
||||||
Tools are discovered from `agent._state.tools`, which is a `Vector{agentTool}` populated during agent creation:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# Loading tools
|
|
||||||
tools = loadTools("src/tools") # returns Vector{agentTool}
|
|
||||||
|
|
||||||
# Passing to agent
|
|
||||||
agent = yiemAgent(
|
|
||||||
systemPrompt = "...",
|
|
||||||
tools = tools, # ← tools stored in agent._state.tools
|
|
||||||
llmCall = my_llm_call,
|
|
||||||
agentEventSink = my_event_sink,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
When the LLM response contains tool calls, the agent builds an `agentContext` with those tools:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
context = agentContext(
|
|
||||||
agent._state.systemPrompt,
|
|
||||||
agent._state.messages,
|
|
||||||
agent._state.tools, # ← tools available for discovery
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Extract Tool Calls from LLM Response
|
|
||||||
|
|
||||||
The agent inspects the `response.content` blocks for `tool_calls`:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# agentCore.jl:217-245
|
|
||||||
tool_call_list = agentToolCall[]
|
|
||||||
|
|
||||||
for content_block in response.content
|
|
||||||
if content_block isa Dict
|
|
||||||
if get(content_block, :type, "") == "tool_calls"
|
|
||||||
# OpenAI format: {"type": "tool_calls", "tool_calls": [...]}
|
|
||||||
for tc_data in get(content_block, :tool_calls, [])
|
|
||||||
tc = agentToolCall(
|
|
||||||
type = "function",
|
|
||||||
id = get(tc_data, :id, string(uuid4())),
|
|
||||||
name = get(tc_data, :function, Dict())[:name],
|
|
||||||
arguments = get(tc_data, :function, Dict())[:arguments],
|
|
||||||
)
|
|
||||||
push!(tool_call_list, tc)
|
|
||||||
end
|
|
||||||
elseif get(content_block, :type, "") == "tool_call"
|
|
||||||
# Alternative format: single tool_call block
|
|
||||||
tc = agentToolCall(
|
|
||||||
type = "function",
|
|
||||||
id = get(content_block, :id, string(uuid4())),
|
|
||||||
name = get(content_block, :name, ""),
|
|
||||||
arguments = get(content_block, :arguments, Dict()),
|
|
||||||
)
|
|
||||||
push!(tool_call_list, tc)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Execute Each Tool Call
|
|
||||||
|
|
||||||
For each tool call, the agent runs through the **prepare → execute → finalize** pipeline:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# agentCore.jl:247-302
|
|
||||||
if has_tool_calls && length(tool_call_list) > 0
|
|
||||||
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
|
|
||||||
config = agentLoopConfig(agent._state.tools, agent.beforeToolCall, agent.afterToolCall, execution_mode)
|
|
||||||
signal = nothing
|
|
||||||
emit = agent.agentEventSink
|
|
||||||
|
|
||||||
# Execute all tool calls (sequential or parallel)
|
|
||||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
|
||||||
|
|
||||||
# Save results to conversation history
|
|
||||||
for tool_result in batch.messages
|
|
||||||
push!(agent._state.messages, tool_result)
|
|
||||||
end
|
|
||||||
|
|
||||||
# If any tool requested termination, break the loop
|
|
||||||
if batch.terminate
|
|
||||||
final_response = build_final_response(batch)
|
|
||||||
break
|
|
||||||
end
|
|
||||||
# Otherwise, loop back to step 2 (format + call LLM again)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: The Per-Call Pipeline
|
|
||||||
|
|
||||||
Each tool call goes through three phases:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ PREPARE → prepareToolCall() │
|
|
||||||
│ │
|
|
||||||
│ 1. Find tool by name in context.tools │
|
|
||||||
│ 2. Transform args via tool.prepareArguments (if defined) │
|
|
||||||
│ 3. Validate via tool.validateRequiredArgs (or default) │
|
|
||||||
│ 4. Run beforeToolCall hook (if defined) │
|
|
||||||
│ └── on any failure → return immediateOutcome (skip execution) │
|
|
||||||
│ └── success → return preparedToolCall │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ EXECUTE → executePreparedToolCall() │
|
|
||||||
│ │
|
|
||||||
│ 1. emit toolExecutionStart event │
|
|
||||||
│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│
|
|
||||||
│ 3. wait for all pending update events │
|
|
||||||
│ └── on error → return executedOutcome(isError=true) │
|
|
||||||
│ └── success → return executedOutcome(isError=false) │
|
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
|
||||||
│ FINALIZE → finalizeExecutedToolCall() │
|
|
||||||
│ │
|
|
||||||
│ 1. Run afterToolCall hook (if defined) │
|
|
||||||
│ - can mutate content, details, usage, terminate, isError │
|
|
||||||
│ 2. emit toolExecutionEnd event │
|
|
||||||
│ 3. createToolResultMessage → adds to conversation history │
|
|
||||||
│ └── return finalizedOutcome │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Feed Results Back to LLM
|
|
||||||
|
|
||||||
Tool results are added to `agent._state.messages` as `toolResultMessage` objects. On the next loop iteration, `formatMsgForLLM()` converts them to OpenAI format and the LLM receives the results:
|
|
||||||
|
|
||||||
```
|
|
||||||
Conversation history after tool execution:
|
|
||||||
[system] "You are a helpful assistant."
|
|
||||||
[user] "What's the weather in Tokyo?"
|
|
||||||
[assistant] (tool_calls: getWeather(city="Tokyo"))
|
|
||||||
[tool] tool_call_id="call_1", tool_name="getWeather", content="Weather in Tokyo: Sunny, 22°C"
|
|
||||||
```
|
|
||||||
|
|
||||||
The LLM then decides: call another tool, or return a final text answer.
|
|
||||||
|
|
||||||
## Execution Modes
|
|
||||||
|
|
||||||
### Sequential
|
|
||||||
|
|
||||||
Tools execute one at a time in order. Required when:
|
|
||||||
- Tools have implicit dependencies
|
|
||||||
- Tools share state (e.g. writing to the same file)
|
|
||||||
- Tools have `parallelToolExecute = false`
|
|
||||||
|
|
||||||
Set globally via `agentLoopConfig.toolExecution = "sequential"`, or per-tool via `parallelToolExecute = false`.
|
|
||||||
|
|
||||||
### Parallel
|
|
||||||
|
|
||||||
Tools execute concurrently when all are independent. Reduces wall-clock time. Set `parallelToolExecute = true` on individual tools, or set `agentLoopConfig.toolExecution = "parallel"`.
|
|
||||||
|
|
||||||
## Streaming Partial Results
|
|
||||||
|
|
||||||
For long-running tools (API calls, file uploads, training), use `onPartialResult` to stream progress:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
|
||||||
onPartialResult(Dict("status" => "Step 1: Fetching data..."))
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
onPartialResult(Dict("status" => "Step 2: Processing..."))
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent("Done!")],
|
|
||||||
Dict{Any,Any}(), nothing, false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
UI listeners and the TUI consume these events in real time via `toolExecutionUpdate`.
|
|
||||||
|
|
||||||
## Loading Tools
|
|
||||||
|
|
||||||
### Auto-load from Directory
|
|
||||||
|
|
||||||
```julia
|
|
||||||
using .toolRegistry
|
|
||||||
|
|
||||||
tools = loadTools("src/tools") # scans for *.jl files with getTool()
|
|
||||||
```
|
|
||||||
|
|
||||||
Files are loaded alphabetically for deterministic registration order.
|
|
||||||
|
|
||||||
### Manual Registration
|
|
||||||
|
|
||||||
```julia
|
|
||||||
tool = getTool() # from your tool module
|
|
||||||
registerTool(tool)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete Lifecycle Example
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# ─── USER SENDS MESSAGE ───────────────────────────────────────────
|
|
||||||
run_agent(agent, "What's the weather in Tokyo?")
|
|
||||||
|
|
||||||
# ─── LOOP ITERATION 1 ─────────────────────────────────────────────
|
|
||||||
# Agent formats messages and calls LLM
|
|
||||||
formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state))
|
|
||||||
response = agent.llmCall(formatted)
|
|
||||||
# LLM returns: {"content": [{"type": "tool_calls", "tool_calls": [{"name": "getWeather", "arguments": {"city": "Tokyo"}}]}]}
|
|
||||||
|
|
||||||
# Agent extracts tool call, builds context
|
|
||||||
context = agentContext(systemPrompt, messages, agent._state.tools)
|
|
||||||
tool_call_list = [agentToolCall("call_1", "getWeather", Dict("city" => "Tokyo"))]
|
|
||||||
|
|
||||||
# PREPARE: find tool, validate args
|
|
||||||
tool = find(t -> t.name == "getWeather", context.tools) # found!
|
|
||||||
validateRequiredArgs(Dict("city" => "Tokyo"), tool.inputSchema) # passes
|
|
||||||
beforeToolCall_hook(agentMsgCtx, nothing) # nil, skipped
|
|
||||||
|
|
||||||
# EXECUTE: call tool.execute()
|
|
||||||
result = tool.execute("call_1", Dict("city" => "Tokyo"), nothing, onPartialResult)
|
|
||||||
# Returns: agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], Dict(), nothing, false)
|
|
||||||
|
|
||||||
# FINALIZE: afterToolCall hook, emit events
|
|
||||||
finalized = finalizedOutcome(tc, result, false)
|
|
||||||
emit(toolExecEndEvent("call_1", "getWeather", result, false))
|
|
||||||
msg = createToolResultMessage(finalized) # toolResultMessage for conversation history
|
|
||||||
|
|
||||||
# Add result to conversation
|
|
||||||
push!(agent._state.messages, msg)
|
|
||||||
# Messages now: [user: "What's the weather?", assistant: {tool_calls: getWeather}, tool: "Sunny, 22°C"]
|
|
||||||
|
|
||||||
# ─── LOOP ITERATION 2 ─────────────────────────────────────────────
|
|
||||||
# LLM called again with tool result included
|
|
||||||
formatted = agent.formatMsgForLLM(agent.prepareContext(agent._state))
|
|
||||||
response = agent.llmCall(formatted)
|
|
||||||
# LLM returns: {"content": [{"type": "text", "text": "The weather in Tokyo is sunny, 22°C."}]}
|
|
||||||
|
|
||||||
# No tool calls detected → break loop, return final response
|
|
||||||
return assistantMessage(content=[textContent("The weather in Tokyo is sunny, 22°C.")], ...)
|
|
||||||
|
|
||||||
# ─── USER RECEIVES RESPONSE ───────────────────────────────────────
|
|
||||||
response = take_response(agent)
|
|
||||||
println(response.content)
|
|
||||||
# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-Modifying Tools
|
|
||||||
|
|
||||||
The framework includes tools that allow the agent to create new tools at runtime.
|
|
||||||
|
|
||||||
### `writeTool` — Create New Tool Files
|
|
||||||
|
|
||||||
`writeTool` is a **file writer**, not a code generator. The LLM provides the tool logic as `executeCode` (the actual Julia code), and `writeTool` wraps it in the required boilerplate.
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
|
|
||||||
The LLM constructs `writeTool` with:
|
|
||||||
- **`executeCode`** — the actual tool logic (Julia code body, NOT wrapped in a function)
|
|
||||||
- **`name`, `label`, `description`** — tool metadata
|
|
||||||
- **`inputSchema`** — parameter schema in MCP format
|
|
||||||
- **`validateCode`, `prepareCode`** (optional) — custom validation/preparation logic
|
|
||||||
|
|
||||||
`writeTool` produces `src/tools/<name>.jl` by:
|
|
||||||
1. Converting the `inputSchema` Dict into a Julia `Dict{String,Any}(...)` string literal
|
|
||||||
2. Indenting `executeCode` with 4 spaces
|
|
||||||
3. Wrapping it inside a `function executeTool(...)::agentToolResult ... end` template
|
|
||||||
4. Appending the `getTool()` definition that returns an `agentTool` struct
|
|
||||||
5. Writing the combined string to disk
|
|
||||||
|
|
||||||
**Workflow:**
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM decides: "Need a searchWine tool. I'll provide the logic."
|
|
||||||
|
|
||||||
LLM calls writeTool:
|
|
||||||
name: "searchWine"
|
|
||||||
executeCode: "query = args[\"query\"]\nresult = search(query)\nreturn ..."
|
|
||||||
|
|
||||||
writeTool wraps it → src/tools/searchWine.jl:
|
|
||||||
function executeTool(...)::agentToolResult
|
|
||||||
query = args["query"] ← LLM code (indented 4 spaces)
|
|
||||||
result = search(query)
|
|
||||||
return agentToolResult(...)
|
|
||||||
end
|
|
||||||
|
|
||||||
function getTool()::agentTool
|
|
||||||
return agentTool(name="searchWine", ...)
|
|
||||||
end
|
|
||||||
|
|
||||||
Restart → loadTools("src/tools") loads searchWine.jl
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example specification:**
|
|
||||||
|
|
||||||
```julia
|
|
||||||
Dict(
|
|
||||||
"name" => "searchWine",
|
|
||||||
"label" => "Wine Search",
|
|
||||||
"description" => "Search a wine database by name, region, or variety",
|
|
||||||
"inputSchema" => Dict(
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Dict(
|
|
||||||
"query" => Dict("type" => "string", "description" => "Search query"),
|
|
||||||
"maxResults" => Dict("type" => "integer", "default" => 10)
|
|
||||||
),
|
|
||||||
"required" => ["query"]
|
|
||||||
),
|
|
||||||
"executeCode" => """
|
|
||||||
query = args["query"]
|
|
||||||
max_results = get(args, "maxResults", 10)
|
|
||||||
# Perform search logic here
|
|
||||||
result = "Found 3 wines matching: $query"
|
|
||||||
return agentToolResult([textContent(result)], Dict{Any,Any}(), nothing, false)
|
|
||||||
""",
|
|
||||||
"parallel" => false
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Optional hooks:**
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|---|---|
|
|
||||||
| `validateCode` | Custom validation Julia code (runs before execute). Return `nothing` to pass, or an error `String` to fail. |
|
|
||||||
| `prepareCode` | Argument preparation code (runs before validation). Return modified args dict. |
|
|
||||||
|
|
||||||
### `listTools` — Discover Available Tools
|
|
||||||
|
|
||||||
Returns all registered tools. Primarily useful for **collision detection** before creating a new tool via `writeTool` — the LLM checks existing names before picking a unique one.
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# Result from listTools:
|
|
||||||
# Available tools:
|
|
||||||
# - getWeather: Weather Lookup — Fetch current weather and forecast for a given city.
|
|
||||||
# - getTime: Time Lookup — Get current local time for a timezone or city.
|
|
||||||
# - writeTool: Create Tool — Generate new tool files...
|
|
||||||
# - listTools: List Tools — List all available tools with their names and labels...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Complete Self-Tooling Example
|
|
||||||
|
|
||||||
```
|
|
||||||
User: "I need to search for wines. Do you have a tool for that?"
|
|
||||||
|
|
||||||
# ─── LOOP: Agent realizes no wine search tool exists ─────────────────
|
|
||||||
|
|
||||||
# LLM generates the tool logic and calls writeTool to write it to disk
|
|
||||||
|
|
||||||
[Tool Call] writeTool(name="searchWine", label="Wine Search",
|
|
||||||
description="Search a wine database by name, region, or variety",
|
|
||||||
inputSchema={...},
|
|
||||||
executeCode="query = args[\"query\"]\nresult = \"Found wines...\"\nreturn agentToolResult([textContent(result)], ...)")
|
|
||||||
|
|
||||||
# writeTool generates src/tools/searchWine.jl
|
|
||||||
|
|
||||||
# ─── SYSTEM RESTARTS ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# loadTools("src/tools") loads searchWine.jl alongside all other tools
|
|
||||||
|
|
||||||
# ─── Agent calls the new tool ─────────────────────────────────────────
|
|
||||||
|
|
||||||
[Tool Call] searchWine(query="cabernet", maxResults=5)
|
|
||||||
# Result: "Found 5 cabernet wines..."
|
|
||||||
|
|
||||||
# ─── Final response ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
"The search found 5 cabernet wines: ..."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
| Tool | Description | Validation |
|
|
||||||
|---|---|---|
|
|
||||||
| `getWeather` | Fetch weather for a city | Default (JSON Schema required) |
|
|
||||||
| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) |
|
|
||||||
| `writeTool` | Create a new Julia tool module at runtime | Built-in (name + schema validation) |
|
|
||||||
| `listTools` | List all available tools with descriptions | None (no arguments) |
|
|
||||||
+23
-13
@@ -1,3 +1,5 @@
|
|||||||
|
using Dates
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Validate required arguments for the getTime tool.
|
Validate required arguments for the getTime tool.
|
||||||
|
|
||||||
@@ -36,6 +38,26 @@ function validateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
|
|||||||
return nothing
|
return nothing
|
||||||
end
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
tz = get(args, "timezone", nothing)
|
||||||
|
city = get(args, "city", "")
|
||||||
|
if tz !== nothing
|
||||||
|
result = "Current time in $(tz): $(now())"
|
||||||
|
else
|
||||||
|
result = "Current time in $(city): $(now())"
|
||||||
|
end
|
||||||
|
return agentToolResult(
|
||||||
|
[textContent(result)],
|
||||||
|
Dict{Any,Any}(), nothing, false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Define and return the getTime agentTool.
|
Define and return the getTime agentTool.
|
||||||
"""
|
"""
|
||||||
@@ -52,19 +74,7 @@ function getTool()::agentTool
|
|||||||
),
|
),
|
||||||
"required" => []
|
"required" => []
|
||||||
),
|
),
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
execute = executeTool,
|
||||||
tz = get(args, "timezone", nothing)
|
|
||||||
city = get(args, "city", "")
|
|
||||||
if tz !== nothing
|
|
||||||
result = "Current time in $(tz): $(now())"
|
|
||||||
else
|
|
||||||
result = "Current time in $(city): $(now())"
|
|
||||||
end
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent(result)],
|
|
||||||
Dict{Any,Any}(), nothing, false
|
|
||||||
)
|
|
||||||
end,
|
|
||||||
prepareArguments = nothing,
|
prepareArguments = nothing,
|
||||||
validateRequiredArgs = validateRequiredArgs,
|
validateRequiredArgs = validateRequiredArgs,
|
||||||
parallelToolExecute = false
|
parallelToolExecute = false
|
||||||
|
|||||||
+18
-10
@@ -1,3 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Execute the getWeather tool.
|
||||||
|
|
||||||
|
Returns mock weather data for the given city and temperature units.
|
||||||
|
"""
|
||||||
|
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal},
|
||||||
|
onPartialResult::Function)::agentToolResult
|
||||||
|
city = get(args, "city", "")
|
||||||
|
units = get(args, "units", "celsius")
|
||||||
|
temp = units == "fahrenheit" ? "72" : "22"
|
||||||
|
unit_symbol = units == "celsius" ? "°C" : "°F"
|
||||||
|
return agentToolResult(
|
||||||
|
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
|
||||||
|
Dict{Any,Any}(), nothing, false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Define and return the getWeather agentTool.
|
Define and return the getWeather agentTool.
|
||||||
"""
|
"""
|
||||||
@@ -14,16 +31,7 @@ function getTool()::agentTool
|
|||||||
),
|
),
|
||||||
"required" => ["city"]
|
"required" => ["city"]
|
||||||
),
|
),
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
execute = executeTool,
|
||||||
city = get(args, "city", "")
|
|
||||||
units = get(args, "units", "celsius")
|
|
||||||
temp = units == "fahrenheit" ? "72" : "22"
|
|
||||||
unit_symbol = units == "celsius" ? "°C" : "°F"
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent("Weather in $(city): Sunny, $(temp)$(unit_symbol)")],
|
|
||||||
Dict{Any,Any}(), nothing, false
|
|
||||||
)
|
|
||||||
end,
|
|
||||||
prepareArguments = nothing,
|
prepareArguments = nothing,
|
||||||
validateRequiredArgs = nothing,
|
validateRequiredArgs = nothing,
|
||||||
parallelToolExecute = false
|
parallelToolExecute = false
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
module toolRegistry
|
|
||||||
|
|
||||||
export loadTools, registerTool, getTools, clearTools
|
|
||||||
|
|
||||||
using Dates
|
|
||||||
using JSON
|
|
||||||
using ..type
|
|
||||||
|
|
||||||
# Global registry — populated at runtime by loadTools() or registerTool()
|
|
||||||
const _registry = Vector{agentTool}()
|
|
||||||
|
|
||||||
# Auto-register the built-in listTools tool
|
|
||||||
function __init__()
|
|
||||||
registerTool(_listTool())
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
List tool definition — lets the agent query available tools for collision detection
|
|
||||||
when creating new tools via writeTool.
|
|
||||||
"""
|
|
||||||
function _listTool()::agentTool
|
|
||||||
return agentTool(
|
|
||||||
name = "listTools",
|
|
||||||
label = "List Tools",
|
|
||||||
description = "List all available tools with their names, labels, and descriptions. Use this before creating a new tool to check for name collisions.",
|
|
||||||
inputSchema = Dict{String,Any}(
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Dict{String,Any}(),
|
|
||||||
"required" => Any[]
|
|
||||||
),
|
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
|
||||||
tools = getTools()
|
|
||||||
if isempty(tools)
|
|
||||||
result_text = "No tools registered."
|
|
||||||
else
|
|
||||||
lines = String["- $(t.name): $(t.label) — $(t.description)" for t in tools]
|
|
||||||
result_text = "Available tools:\n" * join(lines, "\n")
|
|
||||||
end
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent(result_text)],
|
|
||||||
Dict{Any,Any}("count" => length(tools)),
|
|
||||||
nothing, false
|
|
||||||
)
|
|
||||||
end,
|
|
||||||
prepareArguments = nothing,
|
|
||||||
validateRequiredArgs = nothing,
|
|
||||||
parallelToolExecute = false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Load all tool modules from a directory.
|
|
||||||
|
|
||||||
Scans `dir` for `.jl` files. Each file must define a function named
|
|
||||||
`getTool()::agentTool`. Files are sorted alphabetically so tool
|
|
||||||
registration order is deterministic.
|
|
||||||
|
|
||||||
# Tool file format
|
|
||||||
Each `.jl` file defines one function `getTool()` that returns an `agentTool`:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# src/tools/getWeather.jl
|
|
||||||
function getTool()::agentTool
|
|
||||||
return agentTool(
|
|
||||||
name = "getWeather",
|
|
||||||
label = "Weather Lookup",
|
|
||||||
description = "Fetch current weather and forecast for a given city.",
|
|
||||||
inputSchema = Dict{String,Any}(
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Dict(
|
|
||||||
"city" => Dict("type" => "string", "description" => "City and country"),
|
|
||||||
"units" => Dict("type" => "string", "enum" => ["celsius", "fahrenheit"], "default" => "celsius")
|
|
||||||
),
|
|
||||||
"required" => ["city"]
|
|
||||||
),
|
|
||||||
execute = (toolCallId, args, signal, onPartialResult) -> begin
|
|
||||||
city = args["city"]
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent("Sunny, 22C in Bangkok")],
|
|
||||||
Dict{Any,Any}(), nothing, false
|
|
||||||
)
|
|
||||||
end,
|
|
||||||
prepareArguments = nothing,
|
|
||||||
parallelToolExecute = false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
# Arguments
|
|
||||||
- `dir::String`: Directory path to scan for `.jl` tool files
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- `Vector{agentTool}`: All loaded tools
|
|
||||||
|
|
||||||
# Errors
|
|
||||||
- Throws `ArgumentError` if a tool file does not define a `getTool` function
|
|
||||||
"""
|
|
||||||
function loadTools(dir::String)::Vector{agentTool}
|
|
||||||
if !isdir(dir)
|
|
||||||
throw(ArgumentError("Tool directory does not exist: $dir"))
|
|
||||||
end
|
|
||||||
|
|
||||||
tools = agentTool[]
|
|
||||||
jl_files = filter(f -> endswith(f, ".jl") && !occursin(r"(?i)registry", f), readdir(dir))
|
|
||||||
sort!(jl_files)
|
|
||||||
|
|
||||||
for filename in jl_files
|
|
||||||
filepath = joinpath(dir, filename)
|
|
||||||
println("[toolRegistry] Loading tool from: $filepath")
|
|
||||||
|
|
||||||
# Include the file in the current module scope so all types resolve
|
|
||||||
# (agentTool, textContent, agentToolResult, etc. are all available)
|
|
||||||
include(filepath)
|
|
||||||
|
|
||||||
# Validate that getTool was defined (include() places it in current module scope)
|
|
||||||
if !isdefined(@__MODULE__, :getTool)
|
|
||||||
throw(ArgumentError(
|
|
||||||
"Tool file $(filepath) does not define a `getTool()` function. " *
|
|
||||||
"Each tool file must define: function getTool()::agentTool ... end"
|
|
||||||
))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Call getTool() — it runs in current scope where types are visible
|
|
||||||
# Use invokelatest to handle world-age semantics after include()
|
|
||||||
tool = invokelatest(getTool)
|
|
||||||
if !(tool isa agentTool)
|
|
||||||
throw(ArgumentError(
|
|
||||||
"getTool() in $(filepath) did not return an agentTool instance, got: $(typeof(tool))"
|
|
||||||
))
|
|
||||||
end
|
|
||||||
|
|
||||||
push!(_registry, tool)
|
|
||||||
push!(tools, tool)
|
|
||||||
println("[toolRegistry] Loaded tool: $(tool.name) — $(tool.label)")
|
|
||||||
end
|
|
||||||
|
|
||||||
return tools
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Register a single agentTool into the global registry.
|
|
||||||
|
|
||||||
# Arguments
|
|
||||||
- `tool::agentTool`: The tool to register
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- `Vector{agentTool}`: Updated registry
|
|
||||||
"""
|
|
||||||
function registerTool(tool::agentTool)::Vector{agentTool}
|
|
||||||
push!(_registry, tool)
|
|
||||||
println("[toolRegistry] Registered tool: $(tool.name)")
|
|
||||||
return _registry
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Get all registered tools.
|
|
||||||
|
|
||||||
# Returns
|
|
||||||
- `Vector{agentTool}`: Copy of the registry
|
|
||||||
"""
|
|
||||||
function getTools()::Vector{agentTool}
|
|
||||||
return deepcopy(_registry)
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
Clear all registered tools from the global registry.
|
|
||||||
"""
|
|
||||||
function clearTools()::Nothing
|
|
||||||
empty!(_registry)
|
|
||||||
println("[toolRegistry] Registry cleared")
|
|
||||||
return nothing
|
|
||||||
end
|
|
||||||
|
|
||||||
end # module
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using JSON
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Tool that writes new Julia tool module files to disk.
|
Tool that writes new Julia tool module files to disk.
|
||||||
|
|
||||||
@@ -5,7 +7,7 @@ The agent can use this tool when it encounters a task that no existing tool
|
|||||||
can handle. Provide the tool's name, label, description, inputSchema, and
|
can handle. Provide the tool's name, label, description, inputSchema, and
|
||||||
execute logic as Julia code. The tool is written to `src/tools/<name>.jl`.
|
execute logic as Julia code. The tool is written to `src/tools/<name>.jl`.
|
||||||
|
|
||||||
After calling this tool, restart the agent so `loadTools("src/tools")` picks
|
After calling this tool, restart the agent so `loadTools(agent._tool_store, "src/tools")` picks
|
||||||
up the new file. The new tool is immediately available.
|
up the new file. The new tool is immediately available.
|
||||||
|
|
||||||
# Example
|
# Example
|
||||||
@@ -248,13 +250,13 @@ function getTool()::agentTool
|
|||||||
|
|
||||||
tool_code = join(parts)
|
tool_code = join(parts)
|
||||||
|
|
||||||
# Write the file — tool is loaded on next agent restart via loadTools()
|
# Write the file — tool is loaded on next agent restart via loadTools(store, "src/tools")
|
||||||
write(filepath, tool_code)
|
write(filepath, tool_code)
|
||||||
|
|
||||||
onPartialResult(Dict("status" => "Done"))
|
onPartialResult(Dict("status" => "Done"))
|
||||||
|
|
||||||
return agentToolResult(
|
return agentToolResult(
|
||||||
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools() picks it up, then call listTools to verify.")],
|
[textContent("Tool '$(tool_name)' written to $filepath. Restart the agent so loadTools(agent._tool_store, \"src/tools\") picks it up, then call listTools to verify.")],
|
||||||
Dict{Any,Any}(
|
Dict{Any,Any}(
|
||||||
"file" => filepath,
|
"file" => filepath,
|
||||||
"name" => tool_name,
|
"name" => tool_name,
|
||||||
|
|||||||
+34
-161
@@ -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
|
||||||
@@ -291,7 +291,7 @@ Snapshot of the agent's conversation context.
|
|||||||
# Arguments
|
# Arguments
|
||||||
- `systemPrompt::String`: System prompt for the agent
|
- `systemPrompt::String`: System prompt for the agent
|
||||||
- `messages::Vector{agentMessage}`: Conversation messages
|
- `messages::Vector{agentMessage}`: Conversation messages
|
||||||
- `tools::Union{Vector{agentTool}, Nothing}`: Available tools
|
- `tools::Union{Dict{String, agentTool}, Nothing}`: Available tools keyed by name for O(1) lookup
|
||||||
|
|
||||||
# Returns
|
# Returns
|
||||||
- A new `agentContext` instance
|
- A new `agentContext` instance
|
||||||
@@ -299,7 +299,7 @@ Snapshot of the agent's conversation context.
|
|||||||
struct agentContext # Snapshot of the agent's conversation context
|
struct agentContext # Snapshot of the agent's conversation context
|
||||||
systemPrompt::String # System prompt for the agent
|
systemPrompt::String # System prompt for the agent
|
||||||
messages::Vector{agentMessage} # Conversation messages
|
messages::Vector{agentMessage} # Conversation messages
|
||||||
tools::Union{Vector{agentTool}, Nothing} # Available tools
|
tools::Union{Dict{String, agentTool}, Nothing} # Available tools keyed by name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
@@ -308,9 +308,9 @@ end
|
|||||||
# ------------------------------------------------------------------------------------------------ #
|
# ------------------------------------------------------------------------------------------------ #
|
||||||
|
|
||||||
mutable struct agentState # Mutable runtime state of an agent
|
mutable struct agentState # Mutable runtime state of an agent
|
||||||
systemPrompt::String # System prompt text
|
systemPrompt::String # System prompt for the agent
|
||||||
model::llmModel # LLM model to use
|
model::Union{llmModel, Nothing} # LLM model to use
|
||||||
tools::Vector{agentTool} # Available tools
|
tools::OrderedDict{String, agentTool} # Available tools keyed by name, insertion-ordered
|
||||||
|
|
||||||
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
|
# messages history includes userMessage, assistantMessage, toolResultMessage. NO system prompt
|
||||||
messages::Vector{agentMessage}
|
messages::Vector{agentMessage}
|
||||||
@@ -329,7 +329,7 @@ new state from external references.
|
|||||||
# Arguments
|
# Arguments
|
||||||
- `systemPrompt::String`: System prompt text
|
- `systemPrompt::String`: System prompt text
|
||||||
- `model::llmModel`: LLM model to use (defaults to an unknown model)
|
- `model::llmModel`: LLM model to use (defaults to an unknown model)
|
||||||
- `tools::Vector{agentTool}`: Available tools (deep copied)
|
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (deep copied)
|
||||||
- `messages::Vector{agentMessage}`: Conversation messages (deep copied)
|
- `messages::Vector{agentMessage}`: Conversation messages (deep copied)
|
||||||
|
|
||||||
# Returns
|
# Returns
|
||||||
@@ -338,24 +338,24 @@ new state from external references.
|
|||||||
# Examples
|
# Examples
|
||||||
```julia
|
```julia
|
||||||
julia> state = agentState(systemPrompt="You are a helpful assistant")
|
julia> state = agentState(systemPrompt="You are a helpful assistant")
|
||||||
agentState("You are a helpful assistant", ..., 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[], modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
|
model=llmModel("model_1", "unknown", "unknown", "", false, String[],
|
||||||
tools::Vector{agentTool}=agentTool[],
|
modelCost(0.0, 0.0, 0.0, 0.0), 0, 0),
|
||||||
messages::Vector{agentMessage}=agentMessage[],
|
tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(),
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -395,13 +395,13 @@ end
|
|||||||
Configuration for the agent tool execution loop.
|
Configuration for the agent tool execution loop.
|
||||||
|
|
||||||
# Arguments
|
# Arguments
|
||||||
- `tools::Vector{agentTool}`: Available tools
|
- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name
|
||||||
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
- `beforeToolCall::Union{Function, Nothing}`: Callback before tool execution
|
||||||
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
- `afterToolCall::Union{Function, Nothing}`: Callback after tool execution
|
||||||
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
- `toolExecution::String`: Execution mode — "sequential" or "parallel"
|
||||||
"""
|
"""
|
||||||
struct agentLoopConfig
|
struct agentLoopConfig
|
||||||
tools::Vector{agentTool}
|
tools::OrderedDict{String, agentTool}
|
||||||
beforeToolCall::Union{Function, Nothing}
|
beforeToolCall::Union{Function, Nothing}
|
||||||
afterToolCall::Union{Function, Nothing}
|
afterToolCall::Union{Function, Nothing}
|
||||||
toolExecution::String
|
toolExecution::String
|
||||||
@@ -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,138 +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
|
|
||||||
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::Vector{agentTool}`: Available tools (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
|
|
||||||
|
|
||||||
# Examples
|
|
||||||
```julia
|
|
||||||
julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model)
|
|
||||||
yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., ...)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
function yiemAgent(
|
|
||||||
; systemPrompt::String="You are helpful assistant.",
|
|
||||||
model=nothing,
|
|
||||||
tools::Vector{agentTool}=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,
|
|
||||||
)
|
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
@@ -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
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ 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" begin
|
@testset "loadTools with toolStore" begin
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 1. loadTools throws on non-existent directory #
|
# 1. loadTools throws on non-existent directory #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@test_throws ArgumentError loadTools("/nonexistent/dir/that/does/not/exist")
|
store = toolStore(name="test1")
|
||||||
|
@test_throws ArgumentError loadTools(store, "/nonexistent/dir/that/does/not/exist")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 2. loadTools throws if a .jl file does not define getTool() #
|
# 2. loadTools throws if a .jl file does not define getTool() #
|
||||||
@@ -20,34 +21,36 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
bad_dir = mktempdir()
|
bad_dir = mktempdir()
|
||||||
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
|
write(joinpath(bad_dir, "noTool.jl"), "x = 42\n")
|
||||||
@test_throws ArgumentError loadTools(bad_dir)
|
@test_throws ArgumentError loadTools(store, bad_dir)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 3. loadTools loads actual tool files from src/tools/ #
|
# 3. loadTools loads actual tool files from src/tools/ #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
loaded = loadTools(TOOLS_DIR)
|
store2 = toolStore(name="test2")
|
||||||
|
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 = [t.name for t in 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 loaded[1].name == "getTime"
|
@test collect(keys(loaded))[1] == "getTime"
|
||||||
@test loaded[2].name == "getWeather"
|
@test collect(keys(loaded))[2] == "getWeather"
|
||||||
@test loaded[3].name == "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 #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# getTime
|
# getTime
|
||||||
time_tool = loaded[1]
|
time_tool = loaded["getTime"]
|
||||||
@test time_tool.name == "getTime"
|
@test time_tool.name == "getTime"
|
||||||
@test time_tool.label == "Time Lookup"
|
@test time_tool.label == "Time Lookup"
|
||||||
@test time_tool.validateRequiredArgs !== nothing
|
@test time_tool.validateRequiredArgs !== nothing
|
||||||
@@ -55,7 +58,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
|||||||
@test time_tool.inputSchema["required"] == Any[]
|
@test time_tool.inputSchema["required"] == Any[]
|
||||||
|
|
||||||
# getWeather
|
# getWeather
|
||||||
weather = loaded[2]
|
weather = loaded["getWeather"]
|
||||||
@test weather.name == "getWeather"
|
@test weather.name == "getWeather"
|
||||||
@test weather.label == "Weather Lookup"
|
@test weather.label == "Weather Lookup"
|
||||||
@test weather.execute !== nothing
|
@test weather.execute !== nothing
|
||||||
@@ -63,7 +66,7 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
|||||||
@test weather.inputSchema["required"] == ["city"]
|
@test weather.inputSchema["required"] == ["city"]
|
||||||
|
|
||||||
# writeTool
|
# writeTool
|
||||||
wt = loaded[3]
|
wt = loaded["writeTool"]
|
||||||
@test wt.name == "writeTool"
|
@test wt.name == "writeTool"
|
||||||
@test wt.label == "Create Tool"
|
@test wt.label == "Create Tool"
|
||||||
@test wt.execute !== nothing
|
@test wt.execute !== nothing
|
||||||
@@ -98,15 +101,29 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
|||||||
@test occursin("72°F", result_w2.content[1].text)
|
@test occursin("72°F", result_w2.content[1].text)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 7. getTools / registerTool / clearTools #
|
# 7. getTools / registerTool / clearTools (per-store isolation) #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
registry_tools = getTools()
|
store3 = toolStore(name="test3")
|
||||||
@test !isempty(registry_tools)
|
registry_tools = getTools(store3)
|
||||||
@test any(t -> t.name == "getTime", registry_tools)
|
@test isempty(registry_tools)
|
||||||
@test any(t -> t.name == "getWeather", registry_tools)
|
|
||||||
|
|
||||||
clearTools()
|
# listTool is not auto-registered anymore — each store starts empty
|
||||||
@test isempty(getTools())
|
# Register tools manually
|
||||||
|
registerTool(store3, loaded["getTime"])
|
||||||
|
registerTool(store3, loaded["getWeather"])
|
||||||
|
registerTool(store3, loaded["writeTool"])
|
||||||
|
|
||||||
|
reg = getTools(store3)
|
||||||
|
@test !isempty(reg)
|
||||||
|
@test "getTime" in keys(reg)
|
||||||
|
@test "getWeather" in keys(reg)
|
||||||
|
@test "writeTool" in keys(reg)
|
||||||
|
@test collect(keys(reg))[1] == "getTime"
|
||||||
|
@test collect(keys(reg))[2] == "getWeather"
|
||||||
|
@test collect(keys(reg))[3] == "writeTool"
|
||||||
|
|
||||||
|
clearTools(store3)
|
||||||
|
@test isempty(getTools(store3))
|
||||||
|
|
||||||
test_tool = agentTool(
|
test_tool = agentTool(
|
||||||
name = "manualTool",
|
name = "manualTool",
|
||||||
@@ -119,18 +136,77 @@ TOOLS_DIR = joinpath(@__DIR__, "..", "src", "tools")
|
|||||||
validateRequiredArgs = nothing,
|
validateRequiredArgs = nothing,
|
||||||
parallelToolExecute = true
|
parallelToolExecute = true
|
||||||
)
|
)
|
||||||
registerTool(test_tool)
|
registerTool(store3, test_tool)
|
||||||
reg = getTools()
|
reg = getTools(store3)
|
||||||
@test any(t -> t.name == "manualTool", reg)
|
@test haskey(reg, "manualTool")
|
||||||
@test count(t -> t.name == "manualTool", reg) == 1
|
@test length(reg) == 1
|
||||||
@test reg[1].parallelToolExecute == true
|
@test reg["manualTool"].parallelToolExecute == true
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 8. getTools returns deep copy (mutations don't affect registry) #
|
# 8. getTools returns direct reference (mutations affect registry) #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
copy1 = getTools()
|
copy1 = getTools(store3)
|
||||||
copy2 = getTools()
|
copy2 = getTools(store3)
|
||||||
@test copy1 !== copy2
|
@test copy1 === copy2 # same reference, not a deep copy
|
||||||
empty!(copy1)
|
empty!(copy1)
|
||||||
@test !isempty(getTools())
|
@test isempty(getTools(store3)) # mutation propagates
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 9. Per-store isolation — two stores don't share tools #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
storeA = toolStore(name="isolationA")
|
||||||
|
storeB = toolStore(name="isolationB")
|
||||||
|
|
||||||
|
registerTool(storeA, loaded["getTime"])
|
||||||
|
registerTool(storeB, loaded["getWeather"])
|
||||||
|
|
||||||
|
regA = getTools(storeA)
|
||||||
|
regB = getTools(storeB)
|
||||||
|
|
||||||
|
@test "getTime" in keys(regA)
|
||||||
|
@test "getWeather" ∉ keys(regA)
|
||||||
|
@test "getWeather" in keys(regB)
|
||||||
|
@test "getTime" ∉ keys(regB)
|
||||||
|
|
||||||
|
clearTools(storeA)
|
||||||
|
@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
|
end
|
||||||
Reference in New Issue
Block a user