Files
YiemAgent/README_tools.md
T
2026-08-15 16:50:28 +07:00

57 KiB

Tools — Complete End-to-End Lifecycle

This document describes the complete tool lifecycle in the YiemAgent framework, from definition through execution, for framework authors and maintainers who need a thorough understanding of the architecture.


Table of Contents

  1. Quick Start: Tool Lifecycle
  2. Overview
  3. Tool Definition — The agentTool Struct
  4. Tool Registration — Static Registration
  5. The Agent Loop — High-Level Flow
  6. Message Processing Pipeline
  7. Tool Call Extraction from LLM Response
  8. The Per-Call Pipeline — Prepare, Execute, Finalize
  9. Execution Modes — Sequential vs Parallel
  10. Tool Call Batches & Termination Logic
  11. Tool Result Message Creation
  12. Error Handling & Recovery Pattern
  13. Event System — Tool Lifecycle Events
  14. Agent Lifecycle Hooks
  15. Self-Modifying Tools
  16. Complete End-to-End Example
  17. Tool File Contract
  18. Adding New Tools
  19. Appendix: Type Reference

1. Quick Start: Tool Lifecycle

This section shows the complete lifecycle from tool registration through execution and result extraction. Each step maps to the detailed sections below.

Step 1: Discover — listTools

The agent calls the listTools tool to see available tools and detect name collisions before creating new ones.

# The listTools tool is auto-injected via listTool(store) — no manual registration needed
tool = listTool(store)  # Returns an agentTool that, when executed, lists all tools in the store

Result extraction:

result = tool.execute("call-1", Dict{String,Any}(), nothing, x->x)
# result.content[1].text => "Available tools:\n- getWeather: Weather Lookup — Fetch current weather...\n- getTime: Time Lookup — Get current local time..."

Source: toolRegistry.jl:43-98


Step 2: Register — register_all_tools()

Tools are statically defined in src/tools/ and registered at module initialization via register_all_tools(). Each tool function (e.g., getWeatherTool(), getTimeTool(), writeToolTool()) is called to create the agentTool struct. listTool is auto-registered so the LLM can discover available tools.

using YiemAgent, YiemAgent.toolRegistry

store = toolStore(name="myAgent")
tools = register_all_tools(store)
# Calls getWeatherTool(), getTimeTool(), writeToolTool() to create agentTool structs
# Also auto-registers listTools for runtime discovery

Result extraction:

all_tools = getTools(store)  # OrderedDict{String, agentTool}
# Keys: "getWeather", "getTime", "writeTool", "listTools"
getWeather_tool = all_tools["getWeather"]

# Manual registration (alternative to register_all_tools)
registerTool(store, my_tool)
clearTools(store)  # Clear all tools from store

Source: toolRegistry.jl:38-40, 127-143


Step 2.5: Create Agent with Tools

Wire the registered tools into a new yiemAgent instance. The yiemAgent constructor calls register_all_tools() automatically.

using YiemAgent, YiemAgent.type, YiemAgent.toolRegistry

# Create agent — tools are registered automatically via register_all_tools()
agent = yiemAgent(
    llmCall = my_llm_call,            # Function that calls the LLM API
    agentEventSink = my_event_sink,   # Function for TUI/logging
)

Key constructor parameters:

Parameter Type Required Purpose
llmCall Function Yes (messages::Dict) -> assistantMessage — invokes the LLM
agentEventSink Function Yes (event) -> nothing — receives tool lifecycle events
systemPrompt String No (default: "You are helpful assistant.") System prompt text
model llmModel No LLM model config
messages Vector{agentMessage} No (default: empty) Initial conversation history

Optional hooks: prepareContext, formatMsgForLLM, beforeToolCall, afterToolCall, sessionId, maxRetryDelayMs, parallelToolExecute.

Source: type.jl:609-657, toolRegistry.jl:127-143


Step 3: Use — Tool Execution

Tools can be used in two ways:

Direct execution (testing / standalone):

using YiemAgent.type

sig = nothing
op = x -> x  # no-op partial result callback

# Execute a loaded tool directly
result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), sig, op)

Via agent loop (production):

user message → runAgent(agent, Dict("role"=>"user", "content"=>...))
  → _agentLoop detects message → @spawn _processMessage(agent)
    → prepareContext → formatMsgForLLM → llmCall
      → LLM returns tool_calls
        → executeToolCalls(context, response, tool_call_list, config, signal, emit)
          → prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall
            → createToolResultMessage → batch.messages (toolResultMessage[])

Source: Direct: test/toolTest.jl:81-99 | Agent: agentCore.jl:35-311


Step 4: Extract Result

agentToolResult (raw tool output, type.jl:429-434):

result = getWeather_tool.execute("call-1", Dict("city" => "Tokyo"), nothing, x->x)

result.content[1]      # textContent("Weather in Tokyo: Sunny, 22°C")
result.content[1].text # "Weather in Tokyo: Sunny, 22°C"
result.details         # Dict{Any,Any}() — tool-specific metadata
result.usage           # nothing — llmUsage tracking (optional)
result.terminate       # false — signals loop termination

toolResultMessage (wrapped for conversation history, type.jl:152-191):

msg = batch.messages[1]  # toolResultMessage

msg.toolCallId   # "call-1"
msg.toolName     # "getWeather"
msg.content      # Vector{messageContent}
msg.isError      # false
msg.details      # tool-specific metadata
msg.timestamp    # DateTime

2. Overview

The tool system follows a three-phase pipeline per tool call:

PREPARE → EXECUTE → FINALIZE

Each phase has a single responsibility and produces an intermediate result:

Phase Function Input Output Purpose
Prepare prepareToolCall() agentContext, assistantMessage, agentToolCall, agentLoopConfig, abortSignal preparedToolCall or immediateOutcome Resolve tool, validate args, run pre-hook
Execute executePreparedToolCall() preparedToolCall, abortSignal, agentEventSink executedOutcome Call tool.execute(), stream partial results
Finalize finalizeExecutedToolCall() agentContext, assistantMessage, preparedToolCall, executedOutcome, agentLoopConfig, abortSignal finalizedOutcome Run post-hook, emit end event

The pipeline ensures that every tool call produces a result, even on failure. Errors are captured as immediateOutcome, executedOutcome, or finalizedOutcome with isError=true, then converted to toolResultMessage objects that are fed back to the LLM conversation history.


3. Tool Definition — The agentTool Struct

Source: type.jl:261-281

Every tool is an agentTool struct with the following fields:

struct agentTool
    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 for selection)
    inputSchema::Any                          # JSON Schema (MCP format) describing parameters
    execute::Function                         # Core execution: (toolCallId, args, signal, onPartialResult) -> agentToolResult
    prepareArguments::Union{Function, Nothing}  # Optional: (args) -> modified_args (before validation)
    validateRequiredArgs::Union{Function, Nothing}  # Optional: (args) -> Union{Nothing, String} error
    parallelToolExecute::Bool                 # Override: run in parallel with other tools
end

Field Details

Field Required Signature Purpose
name Yes String Unique key for tool lookup in context.tools[name]
label Yes String Human-readable name for display
description Yes String Shown to LLM for tool selection decisions
inputSchema Yes Dict{String,Any} JSON Schema (MCP format) with type, properties, required
execute Yes Function The actual tool logic (see signature below)
prepareArguments No Function Transforms args before validation; (args::Dict) -> Dict
validateRequiredArgs No Function Custom validation; (args::Dict) -> Union{Nothing, String}
parallelToolExecute No Bool Default false. When true, allows parallel execution

Execute Function Signature

execute(toolCallId::String,
        args::Dict{String,Any},
        signal::Union{Nothing,abortSignal},
        onPartialResult::Function)::agentToolResult
Parameter Description
toolCallId Unique ID from the LLM's tool call (e.g., "call_abc123")
args Validated arguments from the LLM, already passed through prepareArguments and validateRequiredArgs
signal Optional abortSignal for cancellable operations. Check signal.aborted to abort early.
onPartialResult Callback for streaming progress: onPartialResult(partial_data) emits toolExecUpdateEvent

Returns — agentToolResult

Source: type.jl:429-434

struct agentToolResult
    content::Vector{messageContent}   # Output content (textContent, etc.)
    details::Dict{Any,Any}             # Tool-specific metadata (e.g., counts, IDs)
    usage::Union{llmUsage, Nothing}    # Token usage tracking (optional)
    terminate::Bool                    # If true, signals the tool requested loop termination
end

The terminate flag is checked at the batch level. See Section 10 for details.


4. Tool Registration — Static Registration

Source: toolRegistry.jl, YiemAgent.jl

How toolStore Works

The registry uses per-agent isolated storage via the toolStore struct. Each agent gets its own store, so tool registration is independent — registerTool(store, tool) only affects that agent's tool set.

struct toolStore
    tools::OrderedDict{String, agentTool}  # keyed by name for O(1) lookup + ordered iteration
    name::String                           # identifier for debugging/logs
end

store.tools is an OrderedDict — it provides O(1) lookup by tool name and preserves insertion order for iteration. getTools(store) returns this OrderedDict directly (not a copy), so mutations on the returned value affect the store.

Static Registration — register_all_tools()

function register_all_tools(store::toolStore)::OrderedDict{String, agentTool}

Source: YiemAgent.jl:20-29

  1. Calls each tool's definition functiongetWeatherTool(), getTimeTool(), writeToolTool() — which return agentTool structs
  2. Registers each tool via registerTool(store, tool)
  3. Auto-registers listTool(store) so the LLM can discover available tools at runtime

Why Static?

Tools are statically included in YiemAgent.jl via include(). This means:

  • Tool functions live in the YiemAgent module, not in dynamically created submodules
  • No world-age issues when calling tool.execute() (Julia compiles dispatch in the same world)
  • Simpler tool definition — no need to wrap in a module ... end block
  • Better compiler optimization (inlining, type inference)

Registration API

# Create per-agent stores
store1 = toolStore(name="agent1")
store2 = toolStore(name="agent2")

# Load all tools (auto-registers listTools)
tools1 = register_all_tools(store1)  # all agents get the same tools
tools2 = register_all_tools(store2)

# Manual registration (per-store)
registerTool(store1, my_tool)

# Query (returns OrderedDict keyed by tool name, in registration order)
all_tools = getTools(store1)  # OrderedDict{String, agentTool} — O(1) lookup + deterministic order

# Clear (per-store)
clearTools(store1)  # only clears store1

getTools(store) returns the internal OrderedDict directly, giving callers:

  • O(1) lookup by tool name
  • Deterministic iteration order (registration order)
  • Consistency with agentState.tools (also OrderedDict{String, agentTool})
  • No copy overhead — mutations on the returned value affect the store

Per-Agent Isolation

Each toolStore is completely independent — tools registered in one store do not appear in another:

storeA = toolStore(name="A")
storeB = toolStore(name="B")

registerTool(storeA, getTimeTool())
registerTool(storeB, getWeatherTool())

getTools(storeA)  # only contains getTime
getTools(storeB)  # only contains getWeather

clearTools(storeA)  # storeB is unaffected

This ensures that yiemAgent instances with different tool_store references operate with completely isolated tool sets.


5. The Agent Loop — High-Level Flow

Source: agentCore.jl:35-145

The _agentLoop() function runs as a background @spawn task, created when yiemAgent is constructed.

Channel Architecture

yiemAgent struct contains:
  - inputChannel (Channel, capacity 16)   ← user sends messages here via runAgent()
  - followUpChannel (Channel, capacity 32) ← user sends follow-ups here via followUp()
  - outputChannel (Channel, capacity 16)   ← agent sends responses here via takeResponse()

Loop States

The loop tracks 6 states (documented at agentCore.jl:39-75):

State processingTask activeRun inputChannel followUpChannel Behavior
1 nothing false empty empty Idle, waiting
2 nothing false has msg empty New message → spawn _processMessage
3 running true empty empty Processing, no new input
4 running true has msg empty New message while busy → queued
5 running true empty has msg Follow-up while busy → queued
6 done false empty empty Task completed → send result, reset

Loop Logic (simplified)

function _agentLoop(agent::yiemAgent)
    while true
        # 1. Wait for message from inputChannel (blocking poll)
        msg = fetch!(agent.inputChannel)  # agentCore.jl:84

        # 2. Handle shutdown signal
        if msg === :shutdown
            drain both channels, break loop
        end

        # 3. If agent is idle, spawn _processMessage
        if agent._state.activeRun == false
            processingTask = Threads.@spawn _processMessage(agent)
            agent._state.activeRun = true
        end

        # 4. While processing: check for followUp messages
        if istaskdone(processingTask) == false && isready(agent.followUpChannel)
            drain followUpChannel  push to inputChannel
            continue  # wait for current processing to finish
        end

        # 5. When processing completes
        if istaskdone(processingTask) == true
            result = fetch(processingTask)
            put!(agent.outputChannel, result)
            agent._state.activeRun = false
            processingTask = nothing
        end
    end
end

Key design: The loop always checks inputChannel before followUpChannel. Follow-up messages are merged into inputChannel only when the current processing task is active, ensuring they are processed after the primary message completes but before new input arrives.


6. Message Processing Pipeline

Source: agentCore.jl:175-311

_processMessage(agent) is the core function that processes a batch of user messages through the LLM pipeline.

Pipeline Steps

function _processMessage(agent::yiemAgent)::assistantMessage
    final_response = nothing

    while true  # Loop until LLM returns response without tool calls
        # ── Step 1: Drain inputChannel ──────────────────────────────
        while isready(agent.inputChannel)
            raw_msg = take!(agent.inputChannel)
            if raw_msg === :shutdown
                put!(agent.inputChannel, :shutdown)
                break
            end
            user_msg = OpenAiToUserMessage(raw_msg)  # Convert Dict → userMessage
            push!(agent._state.messages, user_msg)
        end

        # ── Step 2: Prepare context ─────────────────────────────────
        state = agentState(systemPrompt, nothing, tools, messages)
        preparedContext = prepareContext(state, agentEventSink)
        # Default: deep copies systemPrompt, messages, tools from agentState → agentContext
        # Override point: filter tools, inject context, modify system prompt

        # ── Step 3: Format for LLM ──────────────────────────────────
        formattedMessages = formatMsgForLLM(preparedContext, agentEventSink)
        # Converts agentContext → Dict("messages" => [...]) in OpenAI format
        # Wraps systemPrompt as system role, converts each messageContent block

        # ── Step 4: Call LLM ────────────────────────────────────────
        response = llmCall(formattedMessages)
        # Returns assistantMessage with content::Vector{messageContent}
        # Each content block has a type: "text", "thinking", or "tool_call"

        # ── Step 5: Extract tool calls ──────────────────────────────
        hasToolCalls, toolCallList, assistant_msg = _extractToolCalls(response)
        # Inspects content blocks for "tool_calls" or "tool_call" Dict entries

        # ── Step 6: Execute tool calls or return ────────────────────
        if hasToolCalls && !isempty(toolCallList)
            # Build context and config
            context = agentContext(systemPrompt, messages, tools)
            config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
            signal = abortSignal(false)

            # Execute tool calls (sequential or parallel)
            batch = executeToolCalls(context, assistant_msg, toolCallList, config, signal, agentEventSink)

            # Save results to conversation history
            for tool_result in batch.messages
                push!(messages, tool_result)
            end

            # Check termination
            if batch.terminate
                final_response = build_final_response(batch)
                break
            end
            # Otherwise, loop back to Step 1 (drain any new input) and call LLM again
        else
            # No tool calls — this is the final response
            final_response = response
            break
        end
    end

    return final_response
end

7. Tool Call Extraction from LLM Response

Source: agentCore.jl:217-245

After the LLM call, the agent inspects response.content (a Vector{messageContent}) for tool call blocks. Two formats are supported:

Format 1: OpenAI tool_calls array

# Response content block:
Dict(
    :type => "tool_calls",
    :tool_calls => [
        Dict(:id => "call_1", :name => "getWeather", :arguments => Dict("city" => "Tokyo")),
        Dict(:id => "call_2", :name => "getTime", :arguments => Dict("timezone" => "Asia/Tokyo")),
    ]
)

Format 2: Single tool_call block

Dict(
    :type => "tool_call",
    :id => "call_1",
    :name => "getWeather",
    :arguments => Dict("city" => "Tokyo")
)

Extraction Logic

tool_call_list = agentToolCall[]

for content_block in response.content
    if content_block isa Dict
        # OpenAI format: 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())),  # fallback UUID
                    name = get(tc_data, :function, Dict())[:name],
                    arguments = get(tc_data, :function, Dict())[:arguments],
                )
                push!(tool_call_list, tc)
            end
        # Alternative format: single tool call
        elseif get(content_block, :type, "") == "tool_call"
            tc_data = content_block
            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

The agentToolCall struct is defined at type.jl:362-367:

struct agentToolCall
    type::String                              # Always "function"
    id::String                                # Unique tool call identifier
    name::String                              # Tool name (matches context.tools keys)
    arguments::Dict{String, Any}              # Parsed tool arguments
end

8. The Per-Call Pipeline — Prepare, Execute, Finalize

This is the core of the tool execution system. Each tool call (whether part of a batch or standalone) goes through exactly three phases.

8.1 Phase 1: Prepare — prepareToolCall()

Source: agentCore.jl:511-547

function prepareToolCall(
    context::agentContext,
    assistantMsg::assistantMessage,
    toolCall::agentToolCall,
    config::agentLoopConfig,
    signal::Union{Nothing, abortSignal},
)::Union{preparedToolCall, immediateOutcome}

Steps:

  1. Resolve tool by nameget(context.tools, toolCall.name, nothing)

    • If nothingimmediateOutcome(createErrorToolResult("Tool X not found"), true)
  2. Prepare argumentsprepareToolCallArguments(tool, toolCall)

    • Calls tool.prepareArguments(toolCall.arguments) if defined
    • Returns the toolCall with transformed arguments
    • If no hook or no change, returns original toolCall
  3. Validate argumentsvalidateToolArguments(tool, prepared)

    • Calls tool.validateRequiredArgs(prepared.arguments) if defined, otherwise uses default validateRequiredArgs(prepared.arguments, tool.inputSchema)
    • Default validator checks inputSchema["required"] array
    • On failure: throws ArgumentError(error_string), caught by the try-catch below
  4. Run beforeToolCall hook — if config.beforeToolCall !== nothing

    • Passes beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context) and signal
    • Hook can return nothing (proceed), or Dict(:block => true, :reason => "...") (reject)
    • If signal.aborted == trueimmediateOutcome(createErrorToolResult("Operation aborted"), true)
    • If before.block == trueimmediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)
  5. Return successpreparedToolCall(tool, toolCall, validatedArgs)

Source: type.jl:681-685preparedToolCall holds the resolved tool, original call metadata, and validated arguments together.

Key design principle: Preparation never throws. Every failure path returns an immediateOutcome with isError=true, ensuring the agent loop always has a valid result to feed back to the LLM.

8.2 Phase 2: Execute — executePreparedToolCall()

Source: agentCore.jl:589-617

function executePreparedToolCall(
    prep::preparedToolCall,
    signal::Union{Nothing, abortSignal},
    agentEventSink,
)::executedOutcome

Steps:

  1. Call tool.execute():

    result = prep.tool.execute(
        prep.toolCall.id,
        prep.args,
        signal,
        agentEventSink  # serves as onPartialResult callback
    )
    return executedOutcome(result, false)
    
  2. On error:

    catch err
        return executedOutcome(createErrorToolResult(sprint(showerror, err)), true)
    end
    

8.3 Phase 3: Finalize — finalizeExecutedToolCall()

Source: agentCore.jl:675-706

function finalizeExecutedToolCall(
    context::agentContext,
    assistantMsg::assistantMessage,
    prep::preparedToolCall,
    executed::executedOutcome,
    config::agentLoopConfig,
    signal::Union{Nothing,abortSignal},
)::finalizedOutcome

Steps:

  1. Extract execution result:

    result = executed.result
    isError = executed.isError
    
  2. Run afterToolCall hook — if config.afterToolCall !== nothing:

    • Passes afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context) and signal
    • Hook can mutate the result:
      after = config.afterToolCall(afterToolCallContext(...))
      if after !== nothing
          result = merge(result, dict(
              :content => get(after, :content, result.content),
              :details => get(after, :details, result.details),
              :usage => get(after, :usage, result.usage),
              :terminate => get(after, :terminate, result.terminate)
          ))
          isError = get(after, :isError, isError)
      end
      
    • Common use cases: mask sensitive data, normalize usage, flip terminate based on business logic
    • On error: result = createErrorToolResult(sprint(showerror, err)); isError = true
  3. Return:

    return finalizedOutcome(prep.toolCall, result, isError)
    

Source: type.jl:787-791finalizedOutcome holds the original tool call reference, final result (post-hook), and error status.


9. Execution Modes — Sequential vs Parallel

Source: agentCore.jl:795-936, 988-1011

Dispatcher — executeToolCalls()

function executeToolCalls(
    context::agentContext,
    assistantMsg::assistantMessage,
    toolCalls::Vector{agentToolCall},
    config::agentLoopConfig,
    signal::Union{Nothing, abortSignal},
    agentEventSink,
)::agentToolCallBatch

Decision logic (agentCore.jl:997-1010):

hasSequential = false
for tc in toolCalls
    t = get(context.tools, tc.name, nothing)
    if t !== nothing && !t.parallelToolExecute
        hasSequential = true
        break
    end
end

if config.toolExecution == "sequential" || hasSequential
    return executeToolCallsSequential(...)
else
    return executeToolCallsParallel(...)
end

Rule: If the global config is "sequential" OR any tool in the batch has parallelToolExecute = false, the entire batch runs sequentially. Sequential is the safe default.

Sequential Execution — executeToolCallsSequential()

Source: agentCore.jl:795-829

function executeToolCallsSequential(...)::agentToolCallBatch
    finalizedCalls = finalizedOutcome[]
    messages = toolResultMessage[]

    for tc in toolCalls
        prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)

        if prep isa immediateOutcome
            finalized = finalizedOutcome(tc, prep.result, prep.isError)
        else
            executed = executePreparedToolCall(prep, signal, agentEventSink)
            finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
        end

        push!(messages, createToolResultMessage(finalized))
        push!(finalizedCalls, finalized)

        if signal !== nothing && signal.aborted
            break  # abort: skip remaining calls
        end
    end

    return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
end

Parallel Execution — executeToolCallsParallel()

Source: agentCore.jl:888-936

function executeToolCallsParallel(...)::agentToolCallBatch
    entries = union{finalizedOutcome, task{finalizedOutcome}}[]

    for tc in toolCalls
        prep = prepareToolCall(context, assistantMsg, tc, config, signal, agentEventSink)

        if prep isa immediateOutcome
            finalized = finalizedOutcome(tc, prep.result, prep.isError)
            push!(entries, finalized)  # immediate outcome — no task
        else
            task = task() do
                executed = executePreparedToolCall(prep, signal, agentEventSink)
                finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal)
                return finalized
            end
            schedule(task)
            push!(entries, task)  # pending task
        end

        if signal !== nothing && signal.aborted
            break  # abort during preparation — skip remaining
        end
    end

    # Collect results in original order
    finalizedCalls = finalizedOutcome[]
    for entry in entries
        outcome = entry isa task ? fetch(entry) : entry
        push!(finalizedCalls, outcome)
    end

    messages = toolResultMessage[]
    for f in finalizedCalls
        push!(messages, createToolResultMessage(f))
    end

    return agentToolCallBatch(messages, shouldTerminate(finalizedCalls))
end

Key design: All tool calls are prepared sequentially (validation, hooks), then prepared calls are executed concurrently as separate tasks. Results are collected in the original call order via fetch().

Trade-off: Parallel execution reduces wall-clock time for independent tools but can overwhelm external resources (rate limits, connection pools, disk I/O).


10. Tool Call Batches & Termination Logic

Source: type.jl:793-838

agentToolCallBatch

struct agentToolCallBatch
    messages::Vector{toolResultMessage}  # Tool result messages for this batch
    terminate::Bool                      # Whether the batch should terminate the loop
end

Termination Logic — shouldTerminate()

Source: agentCore.jl:409-411

function shouldTerminate(batches::Vector{finalizedOutcome})::Bool
    return !isempty(batches) && all(b -> b.result.terminate, batches)
end

Rule: terminate is true only when every tool in the batch has result.terminate == true. This prevents a single tool that sets terminate: true (e.g., for metadata purposes) from accidentally stopping the agent when other tools did not intend to terminate.

When to Set terminate = true

From the type documentation (type.jl:803-815):

Use Case Description
Task completion A tool like deploy or submit finishes its work and signals the agent to stop
Unrecoverable error A tool hits a fatal condition (auth token expired, database connection lost)
Async handoff A tool triggers a long-running external operation; the external system will later resume via continue()

Batch Processing in _processMessage()

Source: agentCore.jl:266-307

batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)

# Save results to conversation history
for tool_result in batch.messages
    push!(agent._state.messages, tool_result)
end

if batch.terminate
    # Build final response from tool results
    final_content = [textContent("Tool execution completed.")]
    for tool_result in batch.messages
        for content_block in tool_result.content
            if content_block isa textContent
                append!(final_content, [content_block])
            elseif content_block isa Dict
                if haskey(content_block, :text)
                    push!(final_content, textContent(content_block[:text]))
                end
            end
        end
    end
    final_response = assistantMessage(
        role = "assistant",
        content = final_content,
        api = response.api,
        model = response.model,
        usage = response.usage,
        stopReason = "tool_use_terminated",
        errorMessage = if any(x -> x.isError, batch.messages)
            "One or more tool calls failed"
        else
            nothing
        end,
        timestamp = now(),
    )
    break  # exit the while loop
end
# If batch.terminate == false, loop back to call LLM again with tool results

11. Tool Result Message Creation

Source: agentCore.jl:373-379

createToolResultMessage()

function createToolResultMessage(f::finalizedOutcome)::toolResultMessage
    return toolResultMessage(
        "toolResult",                     # role
        f.toolCall.id,                    # toolCallId
        f.toolCall.name,                  # toolName
        f.result.content,                 # content (Vector{messageContent})
        f.result.details,                 # details
        f.result.usage,                   # usage
        nothing,                          # addedToolNames (for dynamic tools)
        f.isError,                        # isError
        now(),                            # timestamp
    )
end

toolResultMessage Struct

Source: type.jl:152-191

struct toolResultMessage <: agentMessage
    role::String                            # Always "tool"
    toolCallId::String                      # ID matching the original tool call
    toolName::String                        # Name of the executed tool
    content::Vector{messageContent}         # Tool output content
    details::Any                            # Additional tool-specific details
    usage::Union{llmUsage, Nothing}            # Token usage if applicable
    addedToolNames::Union{Vector{String}, Nothing}  # Tools added during execution
    isError::Bool                           # Whether the tool call resulted in an error
    timestamp::Timestamp                    # When the result was recorded
end

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"

On the next loop iteration, formatMsgForLLM() converts toolResultMessage to OpenAI format:

Dict(
    "role" => "tool",
    "tool_call_id" => "call_1",
    "content" => [Dict("type" => "text", "text" => "Weather in Tokyo: Sunny, 22°C")]
)

12. Error Handling & Recovery Pattern

The framework uses a result-based error handling pattern instead of exceptions for tool call failures. This ensures the LLM always receives a tool result message, giving it the information to recover.

Error Flow

Tool call fails at any phase
         │
         ▼
┌─────────────────────────┐
│ Phase: Prepare          │ → immediateOutcome(error_result, true)
│ Phase: Execute          │ → executedOutcome(error_result, true)
│ Phase: Finalize (hook)  │ → finalizedOutcome(tc, error_result, true)
└─────────────────────────┘
         │
         ▼
createToolResultMessage(finalized)
         │
         ▼
push!(agent._state.messages, toolResultMessage)
         │
         ▼
formatMsgForLLM() → LLM receives error as tool result
         │
         ▼
LLM can: retry with corrected args, report failure, or ask user for clarification

createErrorToolResult()

Source: agentCore.jl:339-341

function createErrorToolResult(msg::String)::agentToolResult
    return agentToolResult([textContent("text", msg)], Dict{Any,Any}())
end

Returns an agentToolResult with a single textContent block containing the error message. This is wrapped in an immediateOutcome, executedOutcome, or finalizedOutcome depending on where the error occurred, then converted to toolResultMessage for the LLM.

Key design: Returning a result instead of throwing allows the LLM to see the error and decide whether to retry, re-issue the call with different arguments, or report failure to the user.


13. Event System — Tool Lifecycle Events

Source: type.jl:480-516

Each tool call emits a three-event lifecycle:

toolExecStartEvent → [zero or more toolExecUpdateEvent] → toolExecEndEvent

Event Types

struct toolExecStartEvent
    toolCallId::String                    # ID of the tool call
    toolName::String                      # Name of the tool
    arguments::Dict{String, Any}          # Tool arguments
end

struct toolExecUpdateEvent
    toolCallId::String                    # ID of the tool call
    toolName::String                      # Name of the tool
    arguments::Dict{String, Any}          # Tool arguments
    partialResult::Any                    # The partial result data
end

struct toolExecEndEvent
    toolCallId::String                    # ID of the tool call
    toolName::String                      # Name of the tool
    result::agentToolResult               # The final tool result
    isError::Bool                         # Whether execution resulted in an error
end

Event Emission Points

Event Emitted From When
toolExecStartEvent executeToolCalls*() loop Before prepareToolCall() for each tool call
toolExecUpdateEvent executePreparedToolCall() Inside onPartialResult callback during tool.execute()
toolExecEndEvent finalizeExecutedToolCall() After finalization for each tool call

Event Sink

The agentEventSink function is passed through the entire call chain:

agentEventSink = agent.agentEventSink  # set during yiemAgent construction

The agentEventSink function is a user-provided callback that receives all events. This is typically used by:

  • TUI (Terminal UI): Display real-time progress, tool names, results
  • Logging systems: Record tool execution history
  • Monitoring: Track tool usage, execution times, error rates
  • Audit trails: Log all tool calls with arguments and results

14. Agent Lifecycle Hooks

Hook Types

Hook Signature Called Purpose
prepareContext (state::agentState, agentEventSink) -> agentContext Before each LLM call Filter tools, inject context, modify system prompt
formatMsgForLLM (ctx::agentContext, agentEventSink) -> Dict After prepareContext Convert to LLM-specific format
llmCall (messages::Dict) -> assistantMessage After formatting Actually invoke the LLM API
beforeToolCall (msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict} In prepareToolCall Ask for user permission, block execution, abort
afterToolCall (afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict} In finalizeExecutedToolCall Mutate result, mask data, flip terminate
agentEventSink (event) -> nothing Throughout lifecycle Emit events for TUI, logging, monitoring

beforeToolCall Hook

Source: agentCore.jl:530-541

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

Return values:

  • nothing — proceed with execution
  • Dict(:block => true, :reason => "...") — block execution, error fed back to LLM
  • Dict(:block => false) — proceed (explicit allow)

afterToolCall Hook

Source: agentCore.jl:687-703

if config.afterToolCall !== nothing
    try
        after = config.afterToolCall(
            afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal
        )
        if after !== nothing
            result = merge(result, dict(
                :content => get(after, :content, result.content),
                :details => get(after, :details, result.details),
                :usage => get(after, :usage, result.usage),
                :terminate => get(after, :terminate, result.terminate)
            ))
            isError = get(after, :isError, isError)
        end
    catch err
        result = createErrorToolResult(sprint(showerror, err))
        isError = true
    end
end

Common use cases:

  • Mask sensitive data from result content before the LLM sees it (e.g., removing API keys from error messages)
  • Normalize usage tracking data into a consistent format
  • Inspect the result and decide to flip terminate: true based on business logic
  • Wrap an error result in a friendlier message for the LLM to understand

prepareContext Hook

Source: utils.jl:111-125

function prepareContext(state::agentState, agentEventSink)::agentContext
    # TODO: filter tools from state.tools based on user intent
    filteredTools = state.tools

    # TODO: add filtered tools to the current system prompt / modify systemPrompt
    preparedSystemPrompt = state.systemPrompt

    # TODO: add system prompt, adjust/modify and inject additional context into messages
    preparedMessages = deepcopy(state.messages)

    return agentContext(preparedSystemPrompt, preparedMessages, filteredTools)
end

Override points for customization:

  • Filter tools based on user intent (e.g., only show "wine" tools when user asks about wine)
  • Modify the system prompt dynamically (e.g., inject current time, user preferences)
  • Inject additional context (e.g., retrieved documents, current user state)
  • Prune or reorder messages before formatting for the LLM

formatMsgForLLM Hook

Source: utils.jl:158-219

Default implementation converts agentContext to OpenAI-compatible format:

function formatMsgForLLM(ctx::agentContext, agentEventSink)::Dict{String, Any}
    messages = Vector{Dict{String, Any}}()

    # System prompt as system message
    if !isempty(ctx.systemPrompt)
        push!(messages, Dict(
            "role" => "system",
            "content" => [Dict("type" => "text", "text" => ctx.systemPrompt)]
        ))
    end

    # Conversation messages
    for msg in ctx.messages
        if msg isa userMessage
            push!(messages, _userMessageToOpenAI(msg))
        elseif msg isa assistantMessage
            push!(messages, _assistantMessageToOpenAI(msg))
        elseif msg isa toolResultMessage
            push!(messages, _toolResultMessageToOpenAI(msg))
        end
    end

    return Dict("messages" => messages)
end

Override this to produce custom LLM message formats for different APIs/providers (e.g., Anthropic, Google, Ollama).


15. Self-Modifying Tools

The framework supports tools that modify the tool system itself at runtime.

writeTool — Create New Tool Files

Source: tools/writeTool.jl

writeTool is a file writer, not a code generator. The LLM provides the tool logic as executeCode (Julia code body), and writeTool wraps it in Julia boilerplate:

  1. Converts inputSchema Dict into Dict{String,Any}(...) string literal
  2. Indents executeCode with 4 spaces
  3. Wraps it inside function executeTool(...)::agentToolResult ... end
  4. Appends writeToolTool() returning an agentTool struct
  5. Writes the combined string to src/tools/<name>.jl

listTool — Discover Available Tools

Source: toolRegistry.jl:43-98

Each toolStore gets its own listTool instance bound to that store via listTool(store), so each agent sees only its own tools. register_all_tools auto-registers one, so the LLM can discover available tools at runtime. Also useful for collision detection before creating a new tool via writeTool.

Self-Tooling Workflow

1. Agent detects no existing tool handles the user's request
2. Agent calls writeTool with:
   - name: "searchWine"
   - label: "Wine Search"
   - description: "Search a wine database..."
   - inputSchema: { ... }
   - executeCode: "query = args[\"query\"]\nresult = search(query)\n..."
   - (optional) validateCode, prepareCode
3. writeTool generates src/tools/searchWine.jl
4. Developer adds `include("tools/searchWine.jl")` to YiemAgent.jl
5. Developer adds `registerTool(store, searchWineTool())` to register_all_tools() in YiemAgent.jl
6. Developer restarts Julia — new tool is loaded
7. Agent calls searchWine(query="cabernet")
8. Result: "Found 5 cabernet wines..."

writeTool Input Schema

Field Type Required Description
name String Yes Valid Julia identifier (letters, digits, underscores)
label String Yes Human-readable tool name
description String Yes What the tool does
inputSchema Dict Yes JSON Schema in MCP format
executeCode String Yes Julia code for executeTool body (NOT wrapped in function)
validateCode String No Custom validation Julia code
prepareCode String No Argument preparation code
parallel Bool No Whether the tool can run in parallel (default: false)

16. Complete End-to-End Example

Full Lifecycle: User Message to Tool Result

USER SENDS MESSAGE
  └─> runAgent(agent, "What's the weather in Tokyo?")
        └─> put!(agent.inputChannel, Dict("role" => "user", "content" => [...]))


LOOP ITERATION 1 — LLM DECIDES TO USE A TOOL
  └─> _agentLoop: detects msg in inputChannel
  └─> @spawn _processMessage(agent)

  ── _processMessage ──────────────────────────────────────────────
  │
  │ Step 1: Drain inputChannel
  │   raw_msg = Dict("role" => "user", "content" => [...])
  │   user_msg = OpenAiToUserMessage(raw_msg)
  │   push!(agent._state.messages, user_msg)
  │
  │ Step 2: prepareContext
  │   ctx = agent.prepareContext(agent._state)
  │   → agentContext(systemPrompt, messages, tools)
  │
  │ Step 3: formatMsgForLLM
  │   formatted = agent.formatMsgForLLM(ctx)
  │   → Dict("messages" => [
  │       Dict("role" => "system", "content" => [...]),
  │       Dict("role" => "user", "content" => [...]),
  │   ])
  │
  │ Step 4: llmCall
  │   response = agent.llmCall(formatted)
  │   → assistantMessage(content = [
  │       Dict(:type => "tool_calls", :tool_calls => [
  │           Dict(:id => "call_1", :name => "getWeather",
  │                 :arguments => Dict("city" => "Tokyo"))
  │       ])
  │   ])
  │
  │ Step 5: Extract tool calls
  │   tool_call_list = [agentToolCall("function", "call_1", "getWeather", ...)]
  │
  │ Step 6: Execute tool calls
  │   context = agentContext(systemPrompt, messages, tools)
  │   config = agentLoopConfig(beforeToolCall, afterToolCall, "sequential")
  │   batch = executeToolCalls(context, response, tool_call_list, config, signal, agentEventSink)


LOOP ITERATION 1 — executeToolCallsSequential
  │
  │ For tc = agentToolCall("call_1", "getWeather", ...):
  │
  │ PREPARE:
  │   tool = context.tools["getWeather"]  → found!
  │   validatedArgs = validateToolArguments(tool, tc)
  │   → validateRequiredArgs(Dict("city" => "Tokyo"), inputSchema)  → passes
  │   beforeToolCall_hook(...) → nothing (skipped)
  │   → preparedToolCall(tool, tc, {"city" => "Tokyo"})
  │
  │ EXECUTE:
  │   result = tool.execute("call_1", {"city" => "Tokyo"}, nothing, agentEventSink)
  │   → agentToolResult([textContent("Weather in Tokyo: Sunny, 22°C")], {}, nothing, false)
  │   → executedOutcome(result, false)
  │
  │ FINALIZE:
  │   afterToolCall_hook(...) → nothing (skipped)
  │   → finalizedOutcome(tc, result, false)
  │
  │ msg = createToolResultMessage(finalized)
  │ → toolResultMessage("tool", "call_1", "getWeather", [...], {}, nothing, [], false, ts)
  │
  └─> agentToolCallBatch([msg], false)
  │
  │ Save results:
  │   for tool_result in batch.messages
  │       push!(agent._state.messages, tool_result)
  │   end
  │
  │ batch.terminate == false → loop back to Step 1
  │


LOOP ITERATION 2 — LLM RETURNS FINAL TEXT RESPONSE
  ── _processMessage (second iteration) ───────────────────────────
  │
  │ Step 1: Drain inputChannel → empty
  │
  │ Step 2-3: prepareContext → formatMsgForLLM
  │   → messages now include:
  │     [system] "You are a helpful assistant."
  │     [user]   "What's the weather in Tokyo?"
  │     [tool]   tool_call_id="call_1", content="Weather in Tokyo: Sunny, 22°C"
  │
  │ Step 4: llmCall
  │   → assistantMessage(content = [Dict(:type => "text", :text => "The weather in Tokyo is sunny, 22°C.")])
  │
  │ Step 5: Extract tool calls → none
  │
  │ Step 6: has_tool_calls == false → break, return final_response
  │
  └─> return final_response


AGENT LOOP: SEND RESPONSE TO USER
  └─> put!(agent.outputChannel, final_response)
  └─> takeResponse(agent) → assistantMessage("The weather in Tokyo is sunny, 22°C.")

17. Tool File Contract

Each .jl file in src/tools/ follows a flat, static structure:

Required Function

function <name>Tool()::agentTool
    # Must return an agentTool instance
end

Optional Functions

# Argument preparation (before validation)
function <name>PrepareArguments(args::Dict{String,Any})::Dict{String,Any}
    # Return modified args, or args unchanged
    return args
end

# Custom validation (before execution)
function <name>ValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
    # Return nothing to pass, or error string to fail
    return nothing
end

# Core execution
function <name>Execute(toolCallId::String,
                      args::Dict{String,Any},
                      signal::Union{Nothing,abortSignal},
                      onPartialResult::Function)::agentToolResult
    # Return agentToolResult with content, details, usage, terminate
    return agentToolResult([textContent("result")], Dict{Any,Any}(), nothing, false)
end

File Structure

# src/tools/myTool.jl

using .type        # ← provides agentTool, textContent, agentToolResult, etc.
using Dates        # ← tool's own dependencies

# Optional: helper functions
function helper_function(...)
    ...
end

# Optional: prepareArguments
function myToolPrepareArguments(args::Dict{String,Any})::Dict{String,Any}
    return args
end

# Optional: validateRequiredArgs
function myToolValidateRequiredArgs(args::Dict{String,Any})::Union{Nothing,String}
    return nothing
end

# Required: execute function
function myToolExecute(toolCallId::String, args::Dict{String,Any},
                      signal::Union{Nothing,abortSignal},
                      onPartialResult::Function)::agentToolResult
    ...
end

# Required: getTool function
function myToolTool()::agentTool
    return agentTool(
        name = "myTool",
        label = "My Tool",
        description = "What this tool does",
        inputSchema = Dict{String,Any}(
            "type" => "object",
            "properties" => Dict(...),
            "required" => [...]
        ),
        execute = myToolExecute,
        prepareArguments = myToolPrepareArguments,
        validateRequiredArgs = myToolValidateRequiredArgs,
        parallelToolExecute = false
    )
end

Dependencies

Each tool file declares its own dependencies via using statements:

# src/tools/getTime.jl
using .type
using Dates

function getTimeExecute(...)
    now()  # Dates.now requires `using Dates`
end
# src/tools/myApiTool.jl
using .type
using HTTP, JSON

function myApiToolExecute(...)
    response = HTTP.get("https://api.example.com")
    data = JSON.parse(String(response.body))
    ...
end

Why Flat Modules?

All tool files are statically included in YiemAgent.jl via include(). This means:

  • All functions live in the YiemAgent module, avoiding world-age issues
  • using .type makes core types (agentTool, textContent, agentToolResult, abortSignal) available
  • Functions are named with a <toolName> prefix to avoid name collisions (e.g., getWeatherExecute, getTimeExecute)
  • The ...Tool() function (e.g., getWeatherTool()) returns the agentTool struct for registration

18. Adding New Tools

To add a new tool (e.g., searchWine.jl):

Step 1: Create src/tools/searchWine.jl

using .type
# using AdditionalPkg  # add if needed

function searchWineExecute(toolCallId::String, args::Dict{String,Any},
    signal::Union{Nothing,abortSignal}, onPartialResult)
    query = get(args, "query", "")
    result = search_wine_db(query)
    return agentToolResult(
        [textContent("Found $(length(result)) wines")],
        Dict{Any,Any}("count" => length(result)),
        nothing, false
    )
end

function searchWineTool()::agentTool
    return agentTool(
        name = "searchWine",
        label = "Search Wine",
        description = "Search wine database...",
        inputSchema = Dict{String,Any}(
            "type" => "object",
            "properties" => Dict(
                "query" => Dict("type" => "string", "description" => "Search query")
            ),
            "required" => ["query"]
        ),
        execute = searchWineExecute,
        prepareArguments = nothing,
        validateRequiredArgs = nothing,
        parallelToolExecute = false
    )
end

Step 2: Include in src/YiemAgent.jl (before toolRegistry.jl)

include("tools/getWeather.jl")
include("tools/getTime.jl")
include("tools/searchWine.jl")  # ← add here
include("tools/writeTool.jl")

Step 3: Register in register_all_tools() in YiemAgent.jl

function register_all_tools(store::toolRegistry.toolStore)
    registerTool(store, getWeatherTool())
    registerTool(store, getTimeTool())
    registerTool(store, searchWineTool())  # ← add here
    registerTool(store, writeToolTool())
    registerTool(store, listTool(store))
    return store.tools
end

Step 4: Restart Julia

The module recompiles on next load. The new tool is available immediately.


19. Appendix: Type Reference

Message Types

Type Source Description
messageContent type.jl:67 Abstract base for message content
textContent type.jl:69 Plain text content (text::String)
imageContent type.jl:73 Image content (data::String, mimeType::String)
agentMessage type.jl:82 Abstract base for all messages
userMessage type.jl:84 User message (role, content, timestamp)
assistantMessage type.jl:111 LLM response (role, content, api, provider, model, usage, stopReason, errorMessage, timestamp)
toolResultMessage type.jl:152 Tool result (role, toolCallId, toolName, content, details, usage, addedToolNames, isError, timestamp)

Tool Types

Type Source Description
agentTool type.jl:261 Tool definition (name, label, description, schema, execute, hooks)
agentToolCall type.jl:362 Tool call from LLM (type, id, name, arguments)
agentToolResult type.jl:429 Tool execution result (content, details, usage, terminate)
agentToolCallBatch type.jl:835 Batch of tool results (messages, terminate)

Lifecycle Outcome Types

Type Source Description
preparedToolCall type.jl:681 After prepare: (tool, toolCall, args)
immediateOutcome type.jl:719 Failed before execution: (result, isError)
executedOutcome type.jl:753 After execute, before finalize: (result, isError)
finalizedOutcome type.jl:787 After all phases: (toolCall, result, isError)

Context & Config Types

Type Source Description
agentContext type.jl:299 Conversation snapshot (systemPrompt, messages, tools)
agentState type.jl:310 Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage)
agentLoopConfig type.jl:403 Loop config (beforeToolCall, afterToolCall, toolExecution)
abortSignal type.jl:416 Abort flag (aborted::Bool)
beforeToolCallContext type.jl:445 Context for beforeToolCall (message, toolCall, args, context)
afterToolCallContext type.jl:463 Context for afterToolCall (message, toolCall, args, result, isError, context)

Event Types

Type Source Description
toolExecStartEvent type.jl:480 (toolCallId, toolName, arguments)
toolExecUpdateEvent type.jl:495 (toolCallId, toolName, arguments, partialResult)
toolExecEndEvent type.jl:511 (toolCallId, toolName, result, isError)

Agent Types

Type Source Description
agent type.jl:522 Abstract base type
yiemAgent type.jl:527 High-level agent wrapper (state, channels, callbacks, task)