02: Execute Tool Call
Validate, gate, dispatch, and record one tool call from the provider.
File: src/utils/chat/toolLoop.ts:237-475
Mission
Section titled “Mission”Given a function_call stream result, run the named tool and return a
discriminated outcome that tells the outer loop whether to restart (context was
enriched), abort (fatal condition), or append a history entry and continue.
The stage enforces the deliberate-tool-mode allowlist before reaching the
registry, retains the affordance window on success, emits a hidden trigger
notice when a deliberate-mode trigger matched, and delegates context-enrichment
restarts to
stage 03 — handleEnhancedContextRestart.
params: ToolLoopParams— full loop context.streamResult: StreamResult— thefunction_callresult from stage 01;streamResult.datacarries the raw function-call payload.iteration: number— current loop iteration index (used to setshowKillHintinToolContextonce the soft-warn threshold is reached).
Output
Section titled “Output”A discriminated union:
| { kind: "restart" }| { kind: "abort"; status: GenerationTurnResult["status"] }| { kind: "history"; functionName: string; success: boolean; endTurn: boolean; stickerSelection?: Sticker | null; historyEntry: ToolHistoryEntry; }"restart"—handleEnhancedContextRestartconsumed the result; the outer loop doescontinuewithout pushing tofunctionHistory."abort"— a fatal condition was hit (malformed call, stop request, consecutive error cap); outer loop callsbuildResult(status)immediately."history"— normal outcome; outer loop pusheshistoryEntrytofunctionHistoryand checksendTurn/shouldEndAfterPreToolText.
Side effects
Section titled “Side effects”Steps in execution order:
-
Validate function-call data — if
streamResult.datais missing or has noname, returns{kind: "abort", status: "error"}and logs the malformed result. -
Stop-request check — a regular stop returns
{kind: "abort", status: "stopped_by_user"}before any tool runs. A stale follow-up interrupt is different: the follow-up is already queued, so the interrupt is cleared and the active tool chain continues. The same distinction is checked again after tool execution. -
Build
ToolContext— assembles the context object passed to every tool. Key fields derived fromChatTurnContext:channel,client,message,userId,guildIdtomoriState,locale,provider(provider name string)streamContext(the liveStreamingContext)webhook,personaUsername,personaAvatarUrl(fromresponseTarget)activePersonaId,isUserImpersonation,impersonatedUserIdsuppressProgressNotices— set whenshouldSurfaceUserErrorsis falsecontextItems,messageIdMap— live references (tools may read these)showKillHint— true onceiteration >= SOFT_WARN_ITERATION_THRESHOLDabortSignal— the turn-levelAbortSignalfromgetChannelTurnAbortSignal. Tools that forward this to theirfetchcalls get true HTTP-level cancellation when/bot killfires.
-
Deliberate-tool-mode allowlist gate — if
context.deliberateToolModeActiveis true anddeliberateToolAllowedNamesis set and the requested tool is not in the allowed set, the tool is not dispatched. A synthetic failure response is produced instead:{ status: "blocked_by_deliberate_tool_mode", functionName, allowedToolNames }This is model-visible (returned as a tool response) so the model can adapt its next turn without a user-facing error.
-
ToolRegistry.executeToolwith timeout + kill race — actual dispatch, wrapped in aPromise.raceagainst two cancellation promises:- Timeout promise — resolves after
TOOL_EXECUTION_TIMEOUT_MS(default 5 min) with a synthetic{ success: false, error: "timed out" }result. The timer is fresh per tool call, so a chain of fast tools is unaffected. - Kill promise — resolves immediately if the turn-level
AbortSignalfires (i.e.,/bot killwas used while the tool was running).
After the race, if
StreamOrchestrator.hasStopRequestis true (kill was requested), the stage returns{kind: "abort", status: "stopped_by_user"}immediately — the failed result is never fed back to the model. For a plain timeout (no kill), the{ success: false }result is returned normally so the model can handle it gracefully.Returns
ToolResult:{ success: boolean; data?: unknown; error?: string;message?: string; endTurn?: boolean; imageMetadata?: … } - Timeout promise — resolves after
-
retainSuccessfulToolAffordance— on success, extends the deliberate- tool-mode affordance window for this channel so short follow-up turns (“do it again”) keep the tool exposed fordeliberateToolContextTurnsadditional turns. No-op when deliberate-tool-mode is inactive. -
Deliberate-trigger hidden notice — if
deliberateToolTriggerMatchByToolNamehas an entry for this tool and mode is active, sends a hidden embed viarouteHiddenToolNotice(thought-log only; not shown to users) describing which trigger phrase caused deliberate mode to expose the tool. -
Enhanced-context restart check — calls
handleEnhancedContextRestart(toolLoop.ts:537-568) withtoolResult.data. If it returnstrue, returns{kind: "restart"}. -
Reactivate one-shot STM guard — a successful
update_short_term_memorysetsstreamingContext.disableShortTermMemoryUpdate = true, so the tool’s own availability and execution guards reject a second update in this turn. -
Capture sticker selection —
select_sticker_for_responsemaps a successfulsticker_idthrough the guild sticker cache and returns it asstickerSelection. Any other result from that tool returnsnull, so the latest sticker call wins and a miss clears an earlier selection. -
Build function response — wraps
toolResult.data(success) or a standardized error object (failure) into thefunctionResponseshape:
{ functionResponse: { name, response: { result: … } } }-
Preserve pre-tool text —
buildPreToolCallTextPartsconvertsstreamResult.accumulatedText(the visible text this stream iteration already delivered to Discord before the function call) intopreToolCallTextPartson the history entry. Whitespace-only text yieldsundefined. Provider adapters merge these parts into the synthetic assistant tool-call turn on the follow-up call, so the model knows it already said that text and does not repeat it. Each iteration’s entry carries only that iteration’s text (stream state is fresh perstreamOncecall). After the history entry is accepted, the outer loop clears the same text fromaccumulatedModelParts; otherwise OpenAI-style providers would also append it as a trailing assistant prefill. -
Return
{kind: "history"}withhistoryEntry(the pairedfunctionCall+functionResponse+ optionalimageMetadata+ optionalpreToolCallTextParts).
Invariants
Section titled “Invariants”After this stage runs:
- A tool was dispatched at most once per call (the deliberate-mode synthetic
failure short-circuits before
executeTool). - If
kind === "restart"is returned,functionHistorywill not receive an entry for this tool call — the restart mechanism replaces the tool response with enriched context. consecutiveToolErrorsin the outer loop is reset to0onsuccess === trueorkind === "restart".- If
/bot killfired during tool execution,kind === "abort"is returned immediately — no history entry is added and the model never sees the failed tool result. - A tool timeout (no
/bot kill) returnskind === "history"withsuccess: false— the model is informed and can decide how to proceed. - A queued follow-up never converts an in-progress tool chain into
stopped_by_user; only a genuine stop does. - After a successful STM update, the live streaming context prevents another STM update in the same turn.
- Tool execution duration is logged at
INFOlevel ("Function call completed: ${name} (${ms}ms)").
Extension points
Section titled “Extension points”| Surface | Plugin-relevance |
|---|---|
ToolRegistry.executeTool |
The tool registration contract is the seam — A plugin adding a new tool registers it with the ToolRegistry. → plugin plan candidate |
Deliberate-tool-mode allowlist (deliberateToolAllowedNames) |
Internal — controlled by turnPlanner; tool plugins declare their trigger patterns, not the gating logic |
ToolContext.abortSignal |
Tools that forward this to their fetch calls gain free cancellation on /bot kill. New tools should always thread it through. |
ToolContext shape |
The context contract — tools depend on its fields; adding a field here widens the contract for all tools |
retainSuccessfulToolAffordance |
Internal — deliberate-tool-mode retention window; operational parameter, not plugin-relevant |
handleEnhancedContextRestart |
See stage 03 — the context_restart_* namespace is the seam |
Configuration
Section titled “Configuration”| Env var | Default | Minimum | Purpose |
|---|---|---|---|
TOOL_EXECUTION_TIMEOUT_MS |
300000 (5 min) |
10000 (10 s) |
Per-tool execution timeout; resets fresh for every tool call in a chain |
Related docs
Section titled “Related docs”- Tool registry: →
README.md - Enhanced-context restart: → stage 03 —
handleEnhancedContextRestart - Deliberate tool mode: →
src/utils/tools/deliberateToolMode.ts - Tool-loop coordinator: →
README.md