diff --git a/src/agentCore.jl b/src/agentCore.jl index b854d18..41fd969 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -509,41 +509,42 @@ prepareToolCall(context, msg, tc, config, abortedSignal) ``` """ function prepareToolCall( - context::agentContext, - assistantMsg::assistantMessage, - toolCall::agentToolCall, - config::agentLoopConfig, - signal::Union{Nothing, abortSignal}, + context::agentContext, + assistantMsg::assistantMessage, + toolCall::agentToolCall, + config::agentLoopConfig, + signal::Union{Nothing, abortSignal}, )::Union{preparedToolCall,immediateOutcome} - tool = get(context.tools, toolCall.name, nothing) - if tool === nothing - return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + tool = get(context.tools, toolCall.name, nothing) + if tool === nothing + return immediateOutcome(createErrorToolResult("Tool $toolCall.name not found"), true) + end + + try + # 1. prepare arguments (tool-specific transform) + prepared = prepareToolCallArguments(tool, toolCall) + validatedArgs = validateToolArguments(tool, prepared) + + #WORKING 2. beforeToolCall hook — can block + if config.beforeToolCall !== nothing + before = config.beforeToolCall( + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), + signal + ) + if signal !== nothing && signal.aborted + return immediateOutcome(createErrorToolResult("Operation aborted"), true) + end + if before !== nothing && before.block + return immediateOutcome( + createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) + end end - try - # 1. prepare arguments (tool-specific transform) - prepared = prepareToolCallArguments(tool, toolCall) - validatedArgs = validateToolArguments(tool, prepared) - - # 2. beforeToolCall hook — can block - if config.beforeToolCall !== nothing - before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal - ) - if signal !== nothing && signal.aborted - return immediateOutcome(createErrorToolResult("Operation aborted"), true) - end - if before !== nothing && before.block - return immediateOutcome( - createErrorToolResult(get(before, :reason, "Tool execution was blocked")), true) - end - end - - return preparedToolCall(tool, toolCall, validatedArgs) - catch err - return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) - end + return preparedToolCall(tool, toolCall, validatedArgs) + catch err + return immediateOutcome(createErrorToolResult(sprint(showerror, err)), true) + end end # ── per-call execution ────────────────────────────────────────── @@ -687,7 +688,7 @@ function finalizeExecutedToolCall( if config.afterToolCall !== nothing try after = config.afterToolCall( - afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing result = merge(result, dict(:content=>get(after,:content,result.content), diff --git a/src/api.jl b/src/api.jl index b126b19..deddf01 100644 --- a/src/api.jl +++ b/src/api.jl @@ -5,11 +5,152 @@ export prompt using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, DataFrames using GeneralUtils -using ..type, ..utils +using ..type, ..utils, ..toolRegistry # ---------------------------------------------- 100 --------------------------------------------- # +""" +docstring +""" +mutable struct yiemAgent <: agent # High-level agent wrapper + _state::agentState # Current state (prompt, model, messages, tools, etc.) + + # user sends prompt message to agent. if agent is idle, it process user message right away. + # if agent is running, it process user message after the current tool call finished. + inputChannel::Channel + + # Buffers messages the user sends while the agent is busy. Processed after all inputChannel + # messages are handled and the agent is idle (not using a tool call). + followUpChannel::Channel + + # agent sends response message to user after processing all user messages in inputChannel + # and all followUp messages. + outputChannel::Channel + + _agent_loop::Union{Task, Nothing} # agent loop running in the background + + # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, + # reorder, ...) for a single LLM call in _process_message()'s loop. + # returns new Vector{agentMessage} + prepareContext::Union{Function, Nothing} + + # Convert prepareContext()'s new Vector{agentMessage} to LLM message format + formatMsgForLLM::Function + + # Actually invoke the LLM to get a completion response. The LLM response comes back as an + # assistantMessage whose content is an array of content blocks. + # Each block has a type — "text", "thinking", or "toolCall". + # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). + llmCall::Function + + # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) + beforeToolCall::Union{Function, Nothing} + + executeToolCalls::Function # execute tool calls () + + # Callback invoked after executing a tool call to sanitize tools output so the output is ready + # to be converted into toolResults message + afterToolCall::Union{Function, Nothing} + # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn + # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context + sessionId::Union{String, Nothing} # Optional session identifier + maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) + parallelToolExecute::Bool # Default: false + agentEventSink::Function # agent emits its status via this function + _tool_store::Any # Reference to the toolStore for runtime registration + end + +""" +Create a new yiemAgent instance with a background loop task. + +Spawns a background `@spawn` task that runs the agent loop, listening +on `inputChannel` and `followUpChannel` channels concurrently. + +# Keyword Arguments +- `systemPrompt::String`: System prompt for the agent +- `model`: LLM model to use +- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) +- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) +- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) +- `llmCall::Function`: Function to invoke the LLM (required) +- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) +- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) +- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) +- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) +- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) +- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) +- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) +- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) +- `agentEventSink::Function`: Callback to receive agent events +- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) + +# Returns +- A new `yiemAgent` instance with an active background task + +# Examples +```julia +julia> store = toolStore(name="agent1") +julia> tools = loadTools(store, "src/tools") +julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) +yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) +""" +function yiemAgent( + toolsFolderPath::String, + llmCall::Function, + ; + systemPrompt::String="You are helpful assistant.", + model=nothing, + messages::Vector{agentMessage}=agentMessage[], + prepareContext::Function=prepareContext, + formatMsgForLLM::Function=formatMsgForLLM, + beforeToolCall::Function, + afterToolCall::Function, + # prepareNextTurn::Union{Function, Nothing}=nothing, + # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, + sessionId::Union{String, Nothing}=nothing, + maxRetryDelayMs::Union{Int64, Nothing}=nothing, + parallelToolExecute::Bool=false, + agentEventSink::Function, + tool_store::Union{Any, Nothing}=nothing, + ) + # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) + inputChannel = Channel(16) + followUp = Channel(32) + outputChannel = Channel(16) + + # load tools from toolsFolderPath + toolStore = YiemAgent.toolStore(name="myagent") + loadTools(toolStore, toolsFolderPath) + + # Create struct with a placeholder task, then spawn and replace it + agent = yiemAgent( + agentState(systemPrompt, model, getTools(toolStore), messages), + inputChannel, + followUp, + outputChannel, + nothing, # placeholder — replaced below + prepareContext, + formatMsgForLLM, + llmCall, + beforeToolCall, + afterToolCall, + # prepareNextTurn, + # prepareNextTurnWithContext, + sessionId, + maxRetryDelayMs, + parallelToolExecute, + agentEventSink, + tool_store, + ) + + # Spawn the background loop and attach it + agent._agent_loop = @spawn _agent_loop(agent) + + return agent +end + + """ Send a message to the agent's input channel. diff --git a/src/tools/README.md b/src/tools/README.md index 3404b54..5062bed 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -633,7 +633,7 @@ function prepareToolCall( - On failure: throws `ArgumentError(error_string)`, caught by the try-catch below 4. **Run `beforeToolCall` hook** — if `config.beforeToolCall !== nothing` - - Passes `assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context)` and `signal` + - Passes `beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context)` and `signal` - Hook can return `nothing` (proceed), or `Dict(:block => true, :reason => "...")` (reject) - If `signal.aborted == true` → `immediateOutcome(createErrorToolResult("Operation aborted"), true)` - If `before.block == true` → `immediateOutcome(createErrorToolResult(get(before, :reason, "blocked")), true)` @@ -722,10 +722,10 @@ function finalizeExecutedToolCall( ``` 2. **Run `afterToolCall` hook** — if `config.afterToolCall !== nothing`: - - Passes `afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` + - Passes `afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context)` and `signal` - Hook can mutate the result: ```julia - after = config.afterToolCall(afterCtx(...)) + after = config.afterToolCall(afterToolCallContext(...)) if after !== nothing result = merge(result, dict( :content => get(after, :content, result.content), @@ -1146,8 +1146,8 @@ The `agentEventSink` function is a user-provided callback that receives all even | `prepareContext` | `(state::agentState) -> agentContext` | Before each LLM call | Filter tools, inject context, modify system prompt | | `formatMsgForLLM` | `(ctx::agentContext) -> Dict` | After `prepareContext` | Convert to LLM-specific format | | `llmCall` | `(messages::Dict) -> assistantMessage` | After formatting | Actually invoke the LLM API | -| `beforeToolCall` | `(msgCtx::assistantMsgCtx, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | -| `afterToolCall` | `(afterCtx::afterCtx, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | +| `beforeToolCall` | `(msgCtx::beforeToolCallContext, signal) -> Union{Nothing, Dict}` | In `prepareToolCall` | Ask for user permission, block execution, abort | +| `afterToolCall` | `(afterToolCallContext::afterToolCallContext, signal) -> Union{Nothing, Dict}` | In `finalizeExecutedToolCall` | Mutate result, mask data, flip `terminate` | | `agentEventSink` | `(event) -> nothing` | Throughout lifecycle | Emit events for TUI, logging, monitoring | ### `beforeToolCall` Hook @@ -1157,7 +1157,7 @@ The `agentEventSink` function is a user-provided callback that receives all even ```julia if config.beforeToolCall !== nothing before = config.beforeToolCall( - assistantMsgCtx(assistantMsg, toolCall, validatedArgs, context), signal + beforeToolCallContext(assistantMsg, toolCall, validatedArgs, context), signal ) if signal !== nothing && signal.aborted return immediateOutcome(createErrorToolResult("Operation aborted"), true) @@ -1182,7 +1182,7 @@ end if config.afterToolCall !== nothing try after = config.afterToolCall( - afterCtx(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal + afterToolCallContext(assistantMsg, prep.toolCall, prep.args, result, isError, context), signal ) if after !== nothing result = merge(result, dict( @@ -1606,8 +1606,8 @@ All functions in the file are scoped under `_tool_myTool`, preventing name colli | `agentState` | `type.jl:310` | Mutable runtime state (systemPrompt, model, tools, messages, pendingToolCalls, activeRun, errorMessage) | | `agentLoopConfig` | `type.jl:403` | Loop config (tools, beforeToolCall, afterToolCall, toolExecution) | | `abortSignal` | `type.jl:416` | Abort flag (`aborted::Bool`) | -| `assistantMsgCtx` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | -| `afterCtx` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | +| `beforeToolCallContext` | `type.jl:445` | Context for beforeToolCall (message, toolCall, args, context) | +| `afterToolCallContext` | `type.jl:463` | Context for afterToolCall (message, toolCall, args, result, isError, context) | ### Event Types diff --git a/src/type.jl b/src/type.jl index f01b623..efd9ad1 100644 --- a/src/type.jl +++ b/src/type.jl @@ -13,8 +13,8 @@ # Context types agentContext, agentState, agentToolCall, prepareNextTurnContext, # Loop & execution types - agentLoopConfig, abortSignal, agentToolResult, - assistantMsgCtx, afterCtx, + agentLoopConfig, abortSignal, agentToolResult,beforeToolCallContext, + beforeToolCallResult, afterToolCallContext, # Event types toolExecStartEvent, toolExecUpdateEvent, toolExecEndEvent, # Agent @@ -442,13 +442,18 @@ Context passed to the `beforeToolCall` hook. - `args::Dict{String,Any}`: Validated tool arguments - `context::agentContext`: Current conversation context """ -struct assistantMsgCtx +struct beforeToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} context::agentContext end +struct beforeToolCallResult + block::Bool + reason::String +end + """ Context passed to the `afterToolCall` hook. @@ -460,7 +465,7 @@ Context passed to the `afterToolCall` hook. - `isError::Bool`: Whether execution resulted in an error - `context::agentContext`: Current conversation context """ -struct afterCtx +struct afterToolCallContext message::assistantMessage toolCall::agentToolCall args::Dict{String,Any} @@ -521,143 +526,6 @@ end abstract type agent end -""" -docstring -""" -mutable struct yiemAgent <: agent # High-level agent wrapper - _state::agentState # Current state (prompt, model, messages, tools, etc.) - - # user sends prompt message to agent. if agent is idle, it process user message right away. - # if agent is running, it process user message after the current tool call finished. - inputChannel::Channel - - # Buffers messages the user sends while the agent is busy. Processed after all inputChannel - # messages are handled and the agent is idle (not using a tool call). - followUpChannel::Channel - - # agent sends response message to user after processing all user messages in inputChannel - # and all followUp messages. - outputChannel::Channel - - _agent_loop::Union{Task, Nothing} # agent loop running in the background - - # Preprocess/transform messages and context (modify, filter, prune, inject context from memory, - # reorder, ...) for a single LLM call in _process_message()'s loop. - # returns new Vector{agentMessage} - prepareContext ::Union{Function, Nothing} - - # Convert prepareContext()'s new Vector{agentMessage} to LLM message format - formatMsgForLLM::Function - - # Actually invoke the LLM to get a completion response. The LLM response comes back as an - # assistantMessage whose content is an array of content blocks. - # Each block has a type — "text", "thinking", or "toolCall". - # The code filters for type === "toolCall" blocks, then passes them to executeToolCalls(). - llmCall::Function - - # Callback invoked before executing a tool call (ask for user permission/confirmation/abort, etc..) - beforeToolCall::Union{Function, Nothing} - - executeToolCalls::Function # execute tool calls () - - # Callback invoked after executing a tool call to sanitize tools output so the output is ready - # to be converted into toolResults message - afterToolCall::Union{Function, Nothing} - # prepareNextTurn::Union{Function, Nothing} # Callback to prepare the next conversation turn - # prepareNextTurnWithContext::Union{Function, Nothing} # Same but receives context - sessionId::Union{String, Nothing} # Optional session identifier - maxRetryDelayMs::Union{Int64, Nothing} # Maximum delay between retries (ms) - parallelToolExecute::Bool # Default: false - agentEventSink::Function # agent emits its status via this function - _tool_store::Any # Reference to the toolStore for runtime registration - end - -""" -Create a new yiemAgent instance with a background loop task. - -Spawns a background `@spawn` task that runs the agent loop, listening -on `inputChannel` and `followUpChannel` channels concurrently. - -# Keyword Arguments -- `systemPrompt::String`: System prompt for the agent -- `model`: LLM model to use -- `tools::OrderedDict{String, agentTool}`: Available tools keyed by name (default: empty) -- `messages::Vector{agentMessage}`: Initial conversation messages (default: empty) -- `formatMsgForLLM::Function`: Convert agent messages to LLM message format (default: `defaultformatMsgForLLM`) -- `llmCall::Function`: Function to invoke the LLM (required) -- `prepareContext::Union{Function, Nothing}`: Preprocess/transform messages before sending to LLM (default: `nothing`) -- `beforeToolCall::Union{Function, Nothing}`: Callback invoked before executing a tool call (default: `nothing`) -- `afterToolCall::Union{Function, Nothing}`: Callback invoked after executing a tool call (default: `nothing`) -- `prepareNextTurn::Union{Function, Nothing}`: Callback to prepare the next conversation turn (default: `nothing`) -- `prepareNextTurnWithContext::Union{Function, Nothing}`: Same but receives context (default: `nothing`) -- `sessionId::Union{String, Nothing}`: Optional session identifier (default: `nothing`) -- `maxRetryDelayMs::Union{Int64, Nothing}`: Maximum delay between retries in milliseconds (default: `nothing`) -- `parallelToolExecute::Bool`: Run tool calls in parallel (default: `false`) -- `agentEventSink::Function`: Callback to receive agent events -- `tool_store::Union{Any, Nothing}`: toolStore for runtime tool registration (default: `nothing`) - -# Returns -- A new `yiemAgent` instance with an active background task - -# Examples -```julia -julia> store = toolStore(name="agent1") -julia> tools = loadTools(store, "src/tools") -julia> agent = yiemAgent(systemPrompt="You are a helpful assistant", model=my_model, tools=tools, llmCall=..., tool_store=store) -yiemAgent(agentState(...), Channel(...), Channel(...), Channel(...), ..., store) -""" -function yiemAgent( - ; systemPrompt::String="You are helpful assistant.", - model=nothing, - tools::OrderedDict{String, agentTool}=OrderedDict{String, agentTool}(), - messages::Vector{agentMessage}=agentMessage[], - prepareContext::Union{Function, Nothing}=nothing, - formatMsgForLLM::Function=defaultformatMsgForLLM, - llmCall::Function, - beforeToolCall::Union{Function, Nothing}=nothing, - afterToolCall::Union{Function, Nothing}=nothing, - # prepareNextTurn::Union{Function, Nothing}=nothing, - # prepareNextTurnWithContext::Union{Function, Nothing}=nothing, - sessionId::Union{String, Nothing}=nothing, - maxRetryDelayMs::Union{Int64, Nothing}=nothing, - parallelToolExecute::Bool=false, - agentEventSink::Function, - tool_store::Union{Any, Nothing}=nothing, - ) - # Create channels: input (user -> agent), followUp (async queue), output (agent -> user) - inputChannel = Channel(16) - followUp = Channel(32) - outputChannel = Channel(16) - - # Create struct with a placeholder task, then spawn and replace it - agent = yiemAgent( - agentState(systemPrompt, model, tools, messages), - inputChannel, - followUp, - outputChannel, - nothing, # placeholder — replaced below - prepareContext, - formatMsgForLLM, - llmCall, - beforeToolCall, - afterToolCall, - # prepareNextTurn, - # prepareNextTurnWithContext, - sessionId, - maxRetryDelayMs, - parallelToolExecute, - agentEventSink, - tool_store, - ) - - # Spawn the background loop and attach it - agent._agent_loop = @spawn _agent_loop(agent) - - return agent -end - - - """ preparedToolCall(tool, toolCall, args) diff --git a/src/utils.jl b/src/utils.jl index fb75d77..4300cb0 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,7 +1,8 @@ module utils -export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, validateToolArguments, _userMessageToOpenAI, - _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks +export clearhistory, availableWineToText, prepareContext, formatMsgForLLM, validateRequiredArgs, + validateToolArguments, _userMessageToOpenAI, + _assistantMessageToOpenAI, _toolResultMessageToOpenAI, _messageContentToBlocks using UUIDs, Dates, DataStructures, HTTP, JSON using GeneralUtils @@ -219,6 +220,16 @@ function formatMsgForLLM(ctx::agentContext)::Dict{String, Any} end +function beforeToolCall(context::beforeToolCallContext, signal::abortSignal)::beforeToolCallResult + # final context check + + # seek user approval via UI + + # other check + + return beforeToolCallResult(false, "N/A") +end + """ Convert a userMessage to OpenAI message format. """