From 75b2ce59788eeba6964a332a84126c4ef6701d3a Mon Sep 17 00:00:00 2001 From: narawat Date: Sat, 8 Aug 2026 12:11:53 +0700 Subject: [PATCH] update --- src/tools/README.md | 547 +++++++++++++++----------------------------- 1 file changed, 190 insertions(+), 357 deletions(-) diff --git a/src/tools/README.md b/src/tools/README.md index 7262450..0a3a8a8 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -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). -## 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 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. -## 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 - -After the LLM returns an `assistantMessage`, the loop at `agentCore.jl:220-244` inspects each `content` block: +### The Agent Loop ```julia -# agentCore.jl:217-244 -has_tool_calls = false +# 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 - # OpenAI-style: type == "tool_calls" with array of tool calls - if get(content_block, :type, "") == "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{String,Any}())[:name], - arguments=get(tc_data, :function, Dict{String,Any}())[:arguments], - ) - push!(tool_call_list, tc) - end - # Alternative style: type == "tool_call" single dict per block - elseif get(content_block, :type, "") == "tool_call" - tc = agentToolCall( - type="function", - id=get(tc_data, :id, string(uuid4())), - name=get(tc_data, :name, ""), - arguments=get(tc_data, :arguments, Dict{String,Any}()), - ) - push!(tool_call_list, tc) - end - end -end -``` - -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))) + 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 - ) - 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 - - return finalizedOutcome(prep.toolCall, result, isError) end ``` -The hook can: -- Mask sensitive data from result content -- Normalize usage tracking -- Flip `terminate: true` based on business logic -- Wrap errors in friendlier messages for the LLM +### Step 3: Execute Each Tool Call -If the hook itself throws, the error is caught and converted to an error outcome. - -### Phase 6: Emit Events and Create Result Message - -Each call emits `toolExecutionEnd`: +For each tool call, the agent runs through the **prepare → execute → finalize** pipeline: ```julia -function emitToolExecutionEnd(finalized::finalizedOutcome, emit::Function) - emit(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) +# 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 ``` -Then creates the `toolResultMessage` for conversation history (`agentCore.jl:373-379`): +### Step 4: The Per-Call Pipeline -```julia -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 +Each tool call goes through three phases: ``` -response.content (Vector{Any}) - └── phase 1: parse content blocks - └── tool_call_list :: Vector{agentToolCall} - └── phase 2: dispatch to sequential/parallel - └── phase 3: prepareToolCall - └── preparedToolCall or immediateOutcome - └── phase 4: executePreparedToolCall - └── executedOutcome - └── phase 5: finalizeExecutedToolCall - └── finalizedOutcome - └── phase 6: createToolResultMessage - └── toolResultMessage - └── phase 7: agentToolCallBatch - └── pushed to agent._state.messages - └── loop back to LLM +┌─────────────────────────────────────────────────────────────────┐ +│ 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 @@ -424,124 +344,53 @@ tool = getTool() # from your tool module registerTool(tool) ``` -## Using Tools with an Agent - -Loading tools only registers them — you must pass them to the `yiemAgent` and provide an `llmCall` function. Here is the complete flow: +## Complete Lifecycle Example ```julia -using .YiemAgent -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 +# ─── USER SENDS MESSAGE ─────────────────────────────────────────── 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) - -# 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) +# => "[textContent(\"The weather in Tokyo is sunny, 22°C.\")]" ``` ## Available Tools @@ -550,19 +399,3 @@ stop_agent(agent) |---|---|---| | `getWeather` | Fetch weather for a city | Default (JSON Schema required) | | `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.