V0.8.0 add tools #41
+190
-357
@@ -2,43 +2,6 @@
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Add a new tool by creating a `.jl` file in `src/tools/`. The file must define a `getTool()` function that returns an `agentTool`:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# src/tools/my_tool.jl
|
|
||||||
|
|
||||||
function executeTool(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult::Function)::agentToolResult
|
|
||||||
city = args["city"]
|
|
||||||
return agentToolResult(
|
|
||||||
[textContent("Hello from $(city)!")],
|
|
||||||
Dict{Any,Any}(), nothing, false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
function getTool()::agentTool
|
|
||||||
return agentTool(
|
|
||||||
name = "my_tool",
|
|
||||||
label = "My Tool",
|
|
||||||
description = "Says hello to a city.",
|
|
||||||
inputSchema = Dict{String,Any}(
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Dict(
|
|
||||||
"city" => Dict("type" => "string", "description" => "City name")
|
|
||||||
),
|
|
||||||
"required" => ["city"]
|
|
||||||
),
|
|
||||||
execute = executeTool,
|
|
||||||
prepareArguments = nothing,
|
|
||||||
validateRequiredArgs = nothing,
|
|
||||||
parallelToolExecute = false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
When `loadTools()` or `registerTool()` is called, the tool becomes available to the agent.
|
|
||||||
|
|
||||||
## Tool Anatomy
|
## Tool Anatomy
|
||||||
|
|
||||||
Each tool has 3 main parts:
|
Each tool has 3 main parts:
|
||||||
@@ -155,220 +118,177 @@ 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.
|
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 Lifecycle — Framework Internals
|
## Tool Discovery and Lifecycle
|
||||||
|
|
||||||
This section traces the full code path from the moment the LLM returns tool calls to the final result being fed back into the conversation. All code references are to `agentCore.jl`.
|
The agent iterates through tools via a **discover → execute → loop** cycle. Here is the complete flow from the framework author's perspective:
|
||||||
|
|
||||||
### Phase 1: Detect Tool Calls in LLM Response
|
### The Agent Loop
|
||||||
|
|
||||||
After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block:
|
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
# agentCore.jl:217-244
|
# agentCore.jl:175 - _process_message()
|
||||||
has_tool_calls = false
|
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[]
|
tool_call_list = agentToolCall[]
|
||||||
|
|
||||||
for content_block in response.content
|
for content_block in response.content
|
||||||
if content_block isa Dict
|
if content_block isa Dict
|
||||||
# OpenAI-style: type == "tool_calls" with array of tool calls
|
if get(content_block, :type, "") == "tool_calls"
|
||||||
if get(content_block, :type, "") == "tool_calls"
|
# OpenAI format: {"type": "tool_calls", "tool_calls": [...]}
|
||||||
for tc_data in get(content_block, :tool_calls, [])
|
for tc_data in get(content_block, :tool_calls, [])
|
||||||
tc = agentToolCall(
|
tc = agentToolCall(
|
||||||
type="function",
|
type = "function",
|
||||||
id=get(tc_data, :id, string(uuid4())),
|
id = get(tc_data, :id, string(uuid4())),
|
||||||
name=get(tc_data, :function, Dict{String,Any}())[:name],
|
name = get(tc_data, :function, Dict())[:name],
|
||||||
arguments=get(tc_data, :function, Dict{String,Any}())[:arguments],
|
arguments = get(tc_data, :function, Dict())[:arguments],
|
||||||
)
|
)
|
||||||
push!(tool_call_list, tc)
|
push!(tool_call_list, tc)
|
||||||
end
|
end
|
||||||
# Alternative style: type == "tool_call" single dict per block
|
elseif get(content_block, :type, "") == "tool_call"
|
||||||
elseif get(content_block, :type, "") == "tool_call"
|
# Alternative format: single tool_call block
|
||||||
tc = agentToolCall(
|
tc = agentToolCall(
|
||||||
type="function",
|
type = "function",
|
||||||
id=get(tc_data, :id, string(uuid4())),
|
id = get(content_block, :id, string(uuid4())),
|
||||||
name=get(tc_data, :name, ""),
|
name = get(content_block, :name, ""),
|
||||||
arguments=get(tc_data, :arguments, Dict{String,Any}()),
|
arguments = get(content_block, :arguments, Dict()),
|
||||||
)
|
)
|
||||||
push!(tool_call_list, tc)
|
push!(tool_call_list, tc)
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Each content block with `type == "tool_calls"` or `type == "tool_call"` extracts an `agentToolCall` (id, name, arguments dict) and collects them into a `Vector{agentToolCall}`.
|
|
||||||
|
|
||||||
### Phase 2: Dispatch to Sequential or Parallel Execution
|
|
||||||
|
|
||||||
At `agentCore.jl:247`, the framework checks if any tool calls exist and decides execution mode:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
# agentCore.jl:247-265
|
|
||||||
context = agentContext(agent._state.systemPrompt, agent._state.messages, agent._state.tools)
|
|
||||||
config = agentLoopConfig(
|
|
||||||
agent._state.tools,
|
|
||||||
agent.beforeToolCall,
|
|
||||||
agent.afterToolCall,
|
|
||||||
agent.parallelToolExecute ? "parallel" : "sequential",
|
|
||||||
)
|
|
||||||
batch = executeToolCalls(context, response, tool_call_list, config, signal, emit)
|
|
||||||
```
|
|
||||||
|
|
||||||
`executeToolCalls` (`agentCore.jl:988-1015`) checks:
|
|
||||||
- `config.toolExecution == "sequential"` → sequential mode
|
|
||||||
- Any tool has `parallelToolExecute == false` → sequential mode
|
|
||||||
- Otherwise → parallel mode
|
|
||||||
|
|
||||||
### Phase 3: Per-Call Preparation (`prepareToolCall`)
|
|
||||||
|
|
||||||
Each tool call goes through `prepareToolCall` (`agentCore.jl:511-547`):
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Look up tool by name: find(t -> t.name == tc.name, context.tools)
|
|
||||||
2. If not found → immediateOutcome("Tool X not found", true)
|
|
||||||
3. Run tool.prepareArguments (if defined) → transforms raw LLM args
|
|
||||||
4. Run validateToolArguments → validateRequiredArgs (hook or default)
|
|
||||||
→ if fails → throws ArgumentError → caught below
|
|
||||||
5. Run beforeToolCall hook (if defined) → can block execution
|
|
||||||
→ if blocked → immediateOutcome("Tool execution was blocked", true)
|
|
||||||
6. Return preparedToolCall(tool, tc, validatedArgs)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any step throws (validation, prepareArguments, beforeToolCall), the catch block at `agentCore.jl:545` converts it to an `immediateOutcome`:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
catch err
|
|
||||||
return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: Execution (`executePreparedToolCall`)
|
|
||||||
|
|
||||||
For each `preparedToolCall`, `executePreparedToolCall` (`agentCore.jl:589-617`) runs:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function executePreparedToolCall(prep::preparedToolCall, signal, emit)::executedOutcome
|
|
||||||
updateEvents = promise[]
|
|
||||||
accepting = true
|
|
||||||
|
|
||||||
try
|
|
||||||
result = prep.tool.execute(
|
|
||||||
prep.toolCall.id, prep.args, signal,
|
|
||||||
partialResult -> begin
|
|
||||||
if accepting
|
|
||||||
push!(updateEvents, emit(toolExecUpdateEvent(..., partialResult)))
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
)
|
|
||||||
accepting = false
|
|
||||||
wait.(updateEvents)
|
|
||||||
return executedOutcome(result, false)
|
|
||||||
catch err
|
|
||||||
accepting = false
|
|
||||||
wait.(updateEvents)
|
|
||||||
return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Key behaviors:
|
|
||||||
- Calls `tool.execute(id, args, signal, onPartialResult)` — your tool's `executeTool` function
|
|
||||||
- `signal` can be checked inside `executeTool` for cancellation
|
|
||||||
- `onPartialResult` is called for streaming updates, which are emitted as `toolExecutionUpdate` events
|
|
||||||
- `accepting` guard prevents emitting updates after the result is already captured
|
|
||||||
- `wait.(updateEvents)` ensures all streaming updates are delivered before returning
|
|
||||||
- Execution errors are caught and returned as `executedOutcome(isError=true)` — never thrown
|
|
||||||
|
|
||||||
### Phase 5: Finalization (`finalizeExecutedToolCall`)
|
|
||||||
|
|
||||||
After execution, `finalizeExecutedToolCall` (`agentCore.jl:675-706`) runs the `afterToolCall` hook:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)::finalizedOutcome
|
|
||||||
result = executed.result
|
|
||||||
isError = executed.isError
|
|
||||||
|
|
||||||
if config.afterToolCall !== nothing
|
|
||||||
try
|
|
||||||
after = config.afterToolCall(afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal)
|
|
||||||
if after !== nothing
|
|
||||||
# Hook can mutate: content, details, usage, terminate, isError
|
|
||||||
result = merge(result, dict(...))
|
|
||||||
isError = get(after, :isError, isError)
|
|
||||||
end
|
|
||||||
catch err
|
|
||||||
result = createErrorToolResult(sprint(showerror, err))
|
|
||||||
isError = true
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
return finalizedOutcome(prep.toolCall, result, isError)
|
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
The hook can:
|
### Step 3: Execute Each Tool Call
|
||||||
- Mask sensitive data from result content
|
|
||||||
- Normalize usage tracking
|
|
||||||
- Flip `terminate: true` based on business logic
|
|
||||||
- Wrap errors in friendlier messages for the LLM
|
|
||||||
|
|
||||||
If the hook itself throws, the error is caught and converted to an error outcome.
|
For each tool call, the agent runs through the **prepare → execute → finalize** pipeline:
|
||||||
|
|
||||||
### Phase 6: Emit Events and Create Result Message
|
|
||||||
|
|
||||||
Each call emits `toolExecutionEnd`:
|
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function)
|
# agentCore.jl:247-302
|
||||||
emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError))
|
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
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`):
|
### Step 4: The Per-Call Pipeline
|
||||||
|
|
||||||
```julia
|
Each tool call goes through three phases:
|
||||||
function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
|
|
||||||
return toolResultMessage(
|
|
||||||
"toolResult", f.toolCall.id, f.toolCall.name,
|
|
||||||
f.result.content, f.result.details, f.result.usage,
|
|
||||||
get(f.result, :addedToolNames, string[]), f.isError, nowMillis()
|
|
||||||
)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 7: Batch Assembly and Loop Control
|
|
||||||
|
|
||||||
In `executeToolCallsSequential` (`agentCore.jl:795-829`) or `executeToolCallsParallel` (`agentCore.jl:888-936`), all results are collected:
|
|
||||||
|
|
||||||
```julia
|
|
||||||
messages = toolResultMessage[]
|
|
||||||
for finalized in finalizedCalls
|
|
||||||
push!(messages, createToolResultMessage(finalized))
|
|
||||||
end
|
|
||||||
return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
|
|
||||||
```
|
|
||||||
|
|
||||||
`shouldTerminate` (`agentCore.jl:409`) returns `true` only if ALL tools in the batch set `result.terminate == true`. If `false`, the agent loop at `agentCore.jl:176-308` feeds the tool results back to the LLM for another turn.
|
|
||||||
|
|
||||||
### Data Flow Summary
|
|
||||||
|
|
||||||
```
|
```
|
||||||
response.content (Vector{Any})
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
└── phase 1: parse content blocks
|
│ PREPARE → prepareToolCall() │
|
||||||
└── tool_call_list :: Vector{agentToolCall}
|
│ │
|
||||||
└── phase 2: dispatch to sequential/parallel
|
│ 1. Find tool by name in context.tools │
|
||||||
└── phase 3: prepareToolCall
|
│ 2. Transform args via tool.prepareArguments (if defined) │
|
||||||
└── preparedToolCall or immediateOutcome
|
│ 3. Validate via tool.validateRequiredArgs (or default) │
|
||||||
└── phase 4: executePreparedToolCall
|
│ 4. Run beforeToolCall hook (if defined) │
|
||||||
└── executedOutcome
|
│ └── on any failure → return immediateOutcome (skip execution) │
|
||||||
└── phase 5: finalizeExecutedToolCall
|
│ └── success → return preparedToolCall │
|
||||||
└── finalizedOutcome
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
└── phase 6: createToolResultMessage
|
│ EXECUTE → executePreparedToolCall() │
|
||||||
└── toolResultMessage
|
│ │
|
||||||
└── phase 7: agentToolCallBatch
|
│ 1. emit toolExecutionStart event │
|
||||||
└── pushed to agent._state.messages
|
│ 2. call tool.execute(toolCallId, args, signal, onPartialResult)│
|
||||||
└── loop back to LLM
|
│ 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
|
## Execution Modes
|
||||||
|
|
||||||
### Sequential
|
### Sequential
|
||||||
@@ -424,124 +344,53 @@ tool = getTool() # from your tool module
|
|||||||
registerTool(tool)
|
registerTool(tool)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using Tools with an Agent
|
## Complete Lifecycle Example
|
||||||
|
|
||||||
Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow:
|
|
||||||
|
|
||||||
```julia
|
```julia
|
||||||
using .YiemAgent
|
# ─── USER SENDS MESSAGE ───────────────────────────────────────────
|
||||||
using .toolRegistry
|
|
||||||
|
|
||||||
# 1. Load tools from the tools directory
|
|
||||||
tools = loadTools("src/tools")
|
|
||||||
# [toolRegistry] Loading tool from: src/tools/getTime.jl
|
|
||||||
# [toolRegistry] Loaded tool: getTime — Time Lookup
|
|
||||||
# [toolRegistry] Loading tool from: src/tools/getWeather.jl
|
|
||||||
# [toolRegistry] Loaded tool: getWeather — Weather Lookup
|
|
||||||
|
|
||||||
# 2. Define your LLM call function
|
|
||||||
function my_llm_call(messages::Dict)::assistantMessage
|
|
||||||
# Call your LLM API here (OpenAI, Anthropic, local model, etc.)
|
|
||||||
# Return an assistantMessage with the response content
|
|
||||||
# If the LLM wants to call a tool, include tool_call content blocks
|
|
||||||
...
|
|
||||||
end
|
|
||||||
|
|
||||||
# 3. Define your event sink (optional, for logging/debugging)
|
|
||||||
function my_event_sink(event)
|
|
||||||
if event isa toolExecStartEvent
|
|
||||||
println("[EVENT] Tool start: $(event.toolName)")
|
|
||||||
elseif event isa toolExecEndEvent
|
|
||||||
status = event.isError ? "ERROR" : "OK"
|
|
||||||
println("[EVENT] Tool end: $(event.toolName) — $status")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# 4. Create the agent with tools
|
|
||||||
agent = yiemAgent(
|
|
||||||
systemPrompt = "You are a helpful assistant that can check weather and time.",
|
|
||||||
model = my_model,
|
|
||||||
tools = tools, # pass loaded tools
|
|
||||||
llmCall = my_llm_call, # your LLM function
|
|
||||||
agentEventSink = my_event_sink, # event handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Send a message and get a response
|
|
||||||
run_agent(agent, "What's the weather in Tokyo?")
|
run_agent(agent, "What's the weather in Tokyo?")
|
||||||
response = take_response(agent)
|
|
||||||
|
|
||||||
# response.content contains the LLM's reply (with tool results if applicable)
|
# ─── 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)
|
println(response.content)
|
||||||
|
# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]"
|
||||||
# 6. When done, stop the agent
|
|
||||||
stop_agent(agent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **User sends a message** via `run_agent(agent, "What's the weather in Tokyo?")`. The message goes into `inputChannel`.
|
|
||||||
|
|
||||||
2. **Agent loop** (`_agent_loop`) picks it up, converts it to a `userMessage`, and adds it to `agent._state.messages`.
|
|
||||||
|
|
||||||
3. **LLM is called** via `agent.llmCall(formatted_messages)`. The LLM sees the system prompt, conversation history, and the tool definitions in the prompt (via `formatMsgForLLM`).
|
|
||||||
|
|
||||||
4. **If the LLM uses a tool**, it returns a response with `tool_call` content blocks. The agent:
|
|
||||||
- Extracts each tool call (name, arguments)
|
|
||||||
- Runs validation (`validateRequiredArgs` or default)
|
|
||||||
- Executes the tool (or returns an error if validation fails)
|
|
||||||
- Feeds the result back as a `toolResultMessage` in the conversation
|
|
||||||
|
|
||||||
5. **LLM is called again** with the tool results. This repeats until the LLM returns a text response with no tool calls.
|
|
||||||
|
|
||||||
6. **Final response** is sent to `outputChannel` — retrieve it with `take_response(agent)`.
|
|
||||||
|
|
||||||
### Minimal Working Example
|
|
||||||
|
|
||||||
```julia
|
|
||||||
using .YiemAgent
|
|
||||||
using .toolRegistry
|
|
||||||
|
|
||||||
# Load tools
|
|
||||||
tools = loadTools("src/tools")
|
|
||||||
|
|
||||||
# Mock LLM that echoes back a tool call, then a text response
|
|
||||||
call_count = 0
|
|
||||||
function mock_llm_call(messages::Dict)::assistantMessage
|
|
||||||
global call_count += 1
|
|
||||||
if call_count == 1
|
|
||||||
# First call: LLM decides to use getWeather
|
|
||||||
return assistantMessage(
|
|
||||||
content=[
|
|
||||||
Dict("type" => "tool_calls",
|
|
||||||
"tool_calls" => [Dict("id" => "call_1", "name" => "getWeather",
|
|
||||||
"arguments" => Dict("city" => "Tokyo"))])
|
|
||||||
],
|
|
||||||
model = "mock",
|
|
||||||
usage = llmUsage(0, 0)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
# Second call: LLM returns text (after tool result)
|
|
||||||
return assistantMessage(
|
|
||||||
content = [textContent("The weather in Tokyo is sunny, 22°C.")],
|
|
||||||
model = "mock",
|
|
||||||
usage = llmUsage(0, 0)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create agent
|
|
||||||
agent = yiemAgent(
|
|
||||||
systemPrompt = "You are a helpful assistant.",
|
|
||||||
tools = tools,
|
|
||||||
llmCall = mock_llm_call,
|
|
||||||
agentEventSink = e -> nothing, # no events
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run
|
|
||||||
run_agent(agent, "What's the weather in Tokyo?")
|
|
||||||
response = take_response(agent)
|
|
||||||
|
|
||||||
stop_agent(agent)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
@@ -550,19 +399,3 @@ stop_agent(agent)
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `getWeather` | Fetch weather for a city | Default (JSON Schema required) |
|
| `getWeather` | Fetch weather for a city | Default (JSON Schema required) |
|
||||||
| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) |
|
| `getTime` | Get current time for a timezone or city | Custom (cross-field + format) |
|
||||||
|
|
||||||
## Example: Error Flow
|
|
||||||
|
|
||||||
When the LLM calls a tool with invalid arguments:
|
|
||||||
|
|
||||||
```
|
|
||||||
User: "What's the weather?"
|
|
||||||
└── LLM: call getWeather() with no arguments
|
|
||||||
└── prepareToolCall → validateRequiredArgs → "Missing required arguments: city"
|
|
||||||
└── immediateOutcome → error tool result
|
|
||||||
└── LLM sees: "Missing required arguments: city"
|
|
||||||
└── LLM retries: call getWeather(city="Tokyo")
|
|
||||||
└── executeTool → "Weather in Tokyo: Sunny, 22°C"
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent feeds the error back to the LLM as a tool result message, allowing it to self-correct.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user