mirror of
https://github.com/hanzochat/agents.git
synced 2026-10-30 20:55:06 +00:00
* fix: Scope the Hook agentId Marker to Subagent Child Graphs The event-driven ToolNode construction stamped `agentId` with the owning agent's id at the TOP level, violating the documented contract that `agentId` is the subagent-scope marker (set only inside child runs) while `executingAgentId` carries attribution. Host hooks keying on `agentId != null` to avoid injecting into child state — e.g. a steering drain at PostToolBatch — therefore skipped every top-level batch whenever tools rode `toolDefinitions`. - Add `StandardGraphInput.subagentScope`, set by `SubagentExecutor` when constructing child graphs (every nesting level passes through its own executor, so grandchildren are marked too) - Stamp `agentId` on tool nodes only for `subagentScope` graphs, in BOTH the event-driven and traditional branches (the traditional branch previously never set the marker, even in child scope) - Regression test: a top-level event-driven run's PreToolUse / PostToolUse / PostToolBatch inputs carry `executingAgentId` but no `agentId`; fails before this change with `agentId` stamped * fix: Keep Dispatch Attribution on ToolExecuteBatchRequest The batch dispatch payload's `agentId` is host-facing attribution (tool/credential lookup keys on the owning agent), not the hook subagent-scope marker — use `executingAgentId`, matching the eager dispatch path which sends `agentContext.agentId`. The regression test now pins the split: hook inputs carry no `agentId` at the top level while the dispatched batch request still identifies the owning agent.
3943 lines
146 KiB
TypeScript
3943 lines
146 KiB
TypeScript
import { ToolCall } from '@langchain/core/messages/tool';
|
|
import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons';
|
|
import {
|
|
ToolMessage,
|
|
HumanMessage,
|
|
isAIMessage,
|
|
isBaseMessage,
|
|
} from '@langchain/core/messages';
|
|
import {
|
|
END,
|
|
Send,
|
|
Command,
|
|
isCommand,
|
|
interrupt,
|
|
isGraphInterrupt,
|
|
MessagesAnnotation,
|
|
} from '@langchain/langgraph';
|
|
import type {
|
|
RunnableConfig,
|
|
RunnableToolLike,
|
|
} from '@langchain/core/runnables';
|
|
import type {
|
|
ToolRuntime,
|
|
StructuredToolInterface,
|
|
} from '@langchain/core/tools';
|
|
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
|
import type { LangGraphRunnableConfig } from '@langchain/langgraph';
|
|
import type {
|
|
ToolOutputResolveView,
|
|
PreResolvedArgsMap,
|
|
ResolvedArgsByCallId,
|
|
ResolveResult,
|
|
} from '@/tools/toolOutputReferences';
|
|
import type {
|
|
HookRegistry,
|
|
AggregatedHookResult,
|
|
PostToolBatchEntry,
|
|
} from '@/hooks';
|
|
import type * as t from '@/types';
|
|
import {
|
|
buildToolExecutionRequestPlan,
|
|
resolveRuntimeSessionHint,
|
|
recordArgsEqual,
|
|
} from '@/tools/eagerEventExecution';
|
|
import {
|
|
resolveLangfuseRuntimeScope,
|
|
withLangfuseRuntimeScope,
|
|
} from '@/langfuseRuntimeScope';
|
|
import {
|
|
buildReferenceKey,
|
|
ToolOutputReferenceRegistry,
|
|
} from '@/tools/toolOutputReferences';
|
|
import {
|
|
calculateMaxToolResultChars,
|
|
truncateToolResultContent,
|
|
} from '@/utils/truncation';
|
|
import {
|
|
resolveLocalToolRegistry,
|
|
resolveLocalExecutionTools,
|
|
} from '@/tools/local';
|
|
import { stripCodeSessionFileSummary } from '@/tools/CodeSessionFileSummary';
|
|
import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
|
|
import { toLangChainContent } from '@/messages/langchain';
|
|
import { safeDispatchCustomEvent } from '@/utils/events';
|
|
import { RunnableCallable } from '@/utils';
|
|
import { executeHooks } from '@/hooks';
|
|
|
|
/**
|
|
* Per-call batch context for `runTool`. Bundles every optional
|
|
* batch-scoped value the method needs so the signature stays at
|
|
* three positional parameters even as new context fields are added.
|
|
*/
|
|
type RunToolBatchContext<T = unknown> = {
|
|
/** Position of this call within the parent ToolNode batch. */
|
|
batchIndex?: number;
|
|
/** Batch turn shared across every call in the batch. */
|
|
turn?: number;
|
|
/** Registry partition scope (run id or anonymous batch id). */
|
|
batchScopeId?: string;
|
|
/** Batch-local sink for post-substitution args. */
|
|
resolvedArgsByCallId?: ResolvedArgsByCallId;
|
|
/**
|
|
* Frozen pre-batch view of the tool-output registry. When supplied,
|
|
* `runTool` resolves `{{tool…turn…}}` placeholders against this
|
|
* snapshot instead of the live registry, so a slow `PreToolUse`
|
|
* hook on one direct call cannot cause a sibling's just-registered
|
|
* output to leak into this call's args mid-batch (Codex P1 #18 —
|
|
* `Promise.all`-induced ordering would otherwise be observable).
|
|
*/
|
|
preBatchSnapshot?: ToolOutputResolveView;
|
|
/**
|
|
* Pre-incremented per-tool usage counter. Set by
|
|
* `runDirectToolWithLifecycleHooks` so PreToolUse hooks observe
|
|
* the same `turn` the tool will actually execute under (Codex P2
|
|
* #27 — without this, parallel direct calls of the same tool in
|
|
* one Promise.all batch all read `turn=N` for the hook but
|
|
* actually executed as `turn=N`, `N+1`, `N+2`). When supplied,
|
|
* `runTool` skips its own counter increment.
|
|
*/
|
|
usageCount?: number;
|
|
/**
|
|
* Per-batch sink for `additionalContext` strings returned by
|
|
* direct-path PreToolUse / PostToolUse / PostToolUseFailure hooks.
|
|
* The caller in `run()` materializes the accumulated strings as a
|
|
* `HumanMessage` appended to outputs so the next model turn sees
|
|
* them — same shape as the event-driven path's `injected[]`.
|
|
* Codex P2 #39: pre-fix the direct path called `executeHooks` and
|
|
* discarded `additionalContexts`, silently breaking the hook API
|
|
* contract for hosts relying on it for policy / recovery guidance.
|
|
*/
|
|
additionalContextsSink?: string[];
|
|
/**
|
|
* Graph state the ToolNode was invoked with, threaded from `run()`
|
|
* so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
|
|
* (the deprecation-free replacement for `getCurrentTaskInput()`,
|
|
* which relies on `node:async_hooks` and is browser-incompatible).
|
|
*/
|
|
runInput?: T;
|
|
};
|
|
|
|
const TOOL_NODE_RUN_NAME = 'tool_batch';
|
|
|
|
/**
|
|
* Per-batch context for `dispatchToolEvents` / `executeViaEvent`.
|
|
* Mirrors {@link RunToolBatchContext} for the event-driven path,
|
|
* with bulk indices and the snapshot/pre-resolved-args carriers
|
|
* used in the mixed direct+event flow.
|
|
*/
|
|
type DispatchBatchContext = {
|
|
/** Per-call batch indices, parallel to the `toolCalls` array. */
|
|
batchIndices?: number[];
|
|
/** Batch turn shared across every call in the batch. */
|
|
turn?: number;
|
|
/** Registry partition scope (run id or anonymous batch id). */
|
|
batchScopeId?: string;
|
|
/**
|
|
* Pre-resolved args keyed by `toolCallId`. Populated by the mixed
|
|
* path so event calls don't re-resolve against a registry that
|
|
* already contains same-turn direct outputs.
|
|
*/
|
|
preResolvedArgs?: PreResolvedArgsMap;
|
|
/**
|
|
* Frozen pre-batch registry view used to re-resolve placeholders
|
|
* a `PreToolUse` hook injects via `updatedInput` — preserves the
|
|
* same-turn isolation guarantee for hook-rewritten args.
|
|
*/
|
|
preBatchSnapshot?: ToolOutputResolveView;
|
|
};
|
|
|
|
/**
|
|
* Helper to check if a value is a Send object
|
|
*/
|
|
function isSend(value: unknown): value is Send {
|
|
return value instanceof Send;
|
|
}
|
|
|
|
function isHandoffToolName(name: string): boolean {
|
|
return name.startsWith(Constants.LC_TRANSFER_TO_);
|
|
}
|
|
|
|
/**
|
|
* Format a fail-closed diagnostic for malformed approval-decision
|
|
* fields. Hosts deserialize resume payloads from untyped JSON, so
|
|
* `responseText` and `updatedInput` can land here as anything; the
|
|
* blocking ToolMessage carries this string so the host can debug the
|
|
* exact wire shape that was rejected.
|
|
*/
|
|
function describeOfferedShape(value: unknown): string {
|
|
if (value === undefined) {
|
|
return '<missing>';
|
|
}
|
|
if (value === null) {
|
|
return 'null';
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return 'array';
|
|
}
|
|
return typeof value;
|
|
}
|
|
|
|
/**
|
|
* Per-entry record collected during PreToolUse hook handling for tool
|
|
* calls that need human approval. Carries everything
|
|
* `buildToolApprovalInterruptPayload` needs to assemble the interrupt
|
|
* payload, plus the per-tool decision allowlist if the hook supplied
|
|
* one. Defined at module scope so the payload-builder helper can be
|
|
* extracted out of `dispatchToolEvents` without leaking the locally-
|
|
* inferred shape.
|
|
*/
|
|
type AskEntry = {
|
|
entry: {
|
|
call: ToolCall;
|
|
args: Record<string, unknown>;
|
|
stepId: string;
|
|
};
|
|
reason?: string;
|
|
allowedDecisions?: ReadonlyArray<'approve' | 'reject' | 'edit' | 'respond'>;
|
|
};
|
|
|
|
/**
|
|
* Build the `tool_approval` interrupt payload from the set of pending
|
|
* `ask`-decision entries collected during PreToolUse hook handling.
|
|
* Pure function — doesn't touch ToolNode state — so it lives at module
|
|
* scope. The interrupt itself is raised by the caller (which still
|
|
* needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
|
|
*/
|
|
function buildToolApprovalInterruptPayload(
|
|
askEntries: ReadonlyArray<AskEntry>
|
|
): t.ToolApprovalInterruptPayload {
|
|
return {
|
|
type: 'tool_approval',
|
|
action_requests: askEntries.map(({ entry, reason }) => {
|
|
const request: t.ToolApprovalRequest = {
|
|
tool_call_id: entry.call.id!,
|
|
name: entry.call.name,
|
|
arguments: entry.args,
|
|
};
|
|
if (reason != null) {
|
|
request.description = reason;
|
|
}
|
|
return request;
|
|
}),
|
|
review_configs: askEntries.map(({ entry, allowedDecisions }) => ({
|
|
action_name: entry.call.name,
|
|
tool_call_id: entry.call.id!,
|
|
allowed_decisions: (allowedDecisions ?? [
|
|
'approve',
|
|
'reject',
|
|
'edit',
|
|
'respond',
|
|
]) as t.ToolApprovalDecisionType[],
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a `tool_call_id → ToolApprovalDecision` map from the host's
|
|
* resume value. Hosts may return decisions either as an array (one per
|
|
* action_request, in order) or as a record keyed by `tool_call_id`. Any
|
|
* unrecognized shape (or a decision missing for a given call id) is
|
|
* treated as "no decision" by callers — typically rejected so the run
|
|
* doesn't silently invoke a tool the human never approved.
|
|
*/
|
|
function normalizeApprovalDecisions(
|
|
callIds: string[],
|
|
resumeValue: t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap | undefined
|
|
): Map<string, t.ToolApprovalDecision> {
|
|
const map = new Map<string, t.ToolApprovalDecision>();
|
|
if (resumeValue == null) {
|
|
return map;
|
|
}
|
|
if (Array.isArray(resumeValue)) {
|
|
const limit = Math.min(callIds.length, resumeValue.length);
|
|
for (let i = 0; i < limit; i++) {
|
|
map.set(callIds[i], resumeValue[i]);
|
|
}
|
|
return map;
|
|
}
|
|
if (typeof resumeValue === 'object') {
|
|
for (const callId of callIds) {
|
|
const decision = (resumeValue as Partial<t.ToolApprovalDecisionMap>)[
|
|
callId
|
|
];
|
|
if (decision !== undefined) {
|
|
map.set(callId, decision);
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Merges code execution session context into the sessions map.
|
|
*
|
|
* The codeapi worker reports two distinct ids on a code-execution result:
|
|
* - `artifact.session_id` (the `sessionId` arg here) is the EXEC session
|
|
* — the sandbox VM that ran the code. It's transient and torn down
|
|
* post-execution; subsequent calls cannot reuse it as a sandbox.
|
|
* - `file.storage_session_id` on each `artifact.files[i]` is the STORAGE
|
|
* session — the file-server bucket prefix where the artifact actually
|
|
* lives and is served from.
|
|
*
|
|
* Per-file `storage_session_id` is preserved (not overwritten with the
|
|
* exec id) because `_injected_files` are looked up against the
|
|
* file-server's storage path on subsequent tool calls. Stomping the
|
|
* storage id with the exec id silently 404s every follow-up tool call
|
|
* within the same run — `cat /mnt/data/foo.txt` reports "No such file
|
|
* or directory" because the worker can't mount a file at a path the
|
|
* storage doesn't know about. Fall back to the exec id only when the
|
|
* per-file id is absent (e.g. inline `content` files have no persistent
|
|
* storage location).
|
|
*/
|
|
/**
|
|
* Builds a `CodeEnvFile` ref from an arbitrary `FileRef`-like input,
|
|
* narrowing onto the discriminated union: `kind: 'skill'` requires
|
|
* `version`, other kinds forbid it.
|
|
*
|
|
* Defaults `kind` to `'user'` when unset — most ad-hoc files are
|
|
* user-private; shared resources (skills/agents) populate their kind
|
|
* upstream. A skill ref missing `version` falls back to `'user'` so
|
|
* the upstream contract bug surfaces as a degraded sessionKey rather
|
|
* than a runtime crash; primeSkillFiles is the only writer, and it
|
|
* always sets `version` — see LC packages/api/src/agents/skillFiles.ts.
|
|
*
|
|
* `resource_id` carries the entity-that-owns-this-file's-session
|
|
* identity (skill `_id` etc.); falls back to `id` (the storage
|
|
* file_id) for inputs that haven't been updated to send the field
|
|
* explicitly. The fallback degrades sessionKey resolution on the
|
|
* codeapi side for shared kinds (it'll match the storage nanoid
|
|
* against a skill _id and 403) — but won't crash, so an unmigrated
|
|
* client still produces a diagnosable error instead of a stack
|
|
* trace.
|
|
*/
|
|
function toInjectedFileRef(
|
|
file: {
|
|
id: string;
|
|
resource_id?: string;
|
|
name: string;
|
|
storage_session_id?: string;
|
|
kind?: t.CodeEnvKind;
|
|
version?: number;
|
|
},
|
|
execSessionId: string
|
|
): t.CodeEnvFile {
|
|
const base = {
|
|
id: file.id,
|
|
resource_id: file.resource_id ?? file.id,
|
|
name: file.name,
|
|
/* Inline `content` files have no persistent storage location;
|
|
* fall back to the execution session id for those entries. */
|
|
storage_session_id: file.storage_session_id ?? execSessionId,
|
|
};
|
|
const kind = file.kind ?? 'user';
|
|
if (kind === 'skill' && file.version != null) {
|
|
return { ...base, kind: 'skill', version: file.version };
|
|
}
|
|
if (kind === 'agent') {
|
|
return { ...base, kind: 'agent' };
|
|
}
|
|
return { ...base, kind: 'user' };
|
|
}
|
|
|
|
/* Stable file identity = `(storage_session_id, id)`. Same name in
|
|
* different storage sessions are distinct files. */
|
|
function fileIdentityKey(file: {
|
|
storage_session_id?: string;
|
|
id: string;
|
|
}): string {
|
|
return `${file.storage_session_id ?? ''}\0${file.id}`;
|
|
}
|
|
|
|
function updateCodeSession(
|
|
sessions: t.ToolSessionMap,
|
|
execSessionId: string,
|
|
files: t.FileRefs | undefined
|
|
): void {
|
|
const newFiles = files ?? [];
|
|
const existingSession = sessions.get(Constants.EXECUTE_CODE) as
|
|
| t.CodeSessionContext
|
|
| undefined;
|
|
const existingFiles = existingSession?.files ?? [];
|
|
|
|
if (newFiles.length === 0) {
|
|
sessions.set(Constants.EXECUTE_CODE, {
|
|
session_id: execSessionId,
|
|
files: existingFiles,
|
|
lastUpdated: Date.now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
/* Worker echoes lack ownership identity (kind/resource_id/version) —
|
|
* sandbox doesn't re-attest; that's signed at upload. Merge by
|
|
* (storage_session_id, id) so prior identity survives the echo. */
|
|
const filesWithSession: t.FileRefs = [];
|
|
const newFileNames = new Set<string>();
|
|
const incomingByIdentity = new Map<string, number>();
|
|
for (const file of newFiles) {
|
|
const withSession = {
|
|
...file,
|
|
storage_session_id: file.storage_session_id ?? execSessionId,
|
|
};
|
|
incomingByIdentity.set(
|
|
fileIdentityKey(withSession),
|
|
filesWithSession.length
|
|
);
|
|
newFileNames.add(withSession.name);
|
|
filesWithSession.push(withSession);
|
|
}
|
|
|
|
const filteredExisting: t.FileRefs = [];
|
|
for (const e of existingFiles) {
|
|
const idx = incomingByIdentity.get(fileIdentityKey(e));
|
|
if (idx !== undefined) {
|
|
filesWithSession[idx] = { ...e, ...filesWithSession[idx] };
|
|
}
|
|
if (!newFileNames.has(e.name)) {
|
|
filteredExisting.push(e);
|
|
}
|
|
}
|
|
|
|
sessions.set(Constants.EXECUTE_CODE, {
|
|
session_id: execSessionId,
|
|
files: [...filteredExisting, ...filesWithSession],
|
|
lastUpdated: Date.now(),
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
private toolMap: Map<string, StructuredToolInterface | RunnableToolLike>;
|
|
private loadRuntimeTools?: t.ToolRefGenerator;
|
|
handleToolErrors = true;
|
|
trace = false;
|
|
private runLangfuse?: t.LangfuseConfig;
|
|
private agentLangfuse?: t.LangfuseConfig;
|
|
toolCallStepIds?: Map<string, string>;
|
|
errorHandler?: t.ToolNodeConstructorParams['errorHandler'];
|
|
/**
|
|
* Tool call ids whose `errorHandler` did NOT dispatch the error completion
|
|
* event (it returned `false` or threw). The output loop must dispatch the
|
|
* completion for these itself — skipping them there would strand the
|
|
* client's tool-call part without a terminal event.
|
|
*/
|
|
private undispatchedToolErrors: Set<string> = new Set();
|
|
private toolUsageCount: Map<string, number>;
|
|
/** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
|
|
private toolCallTurns: Map<string, number> = new Map();
|
|
/**
|
|
* `call.id → turn` map dedicated to the direct-path lifecycle so the
|
|
* turn assigned on first entry is REUSED on LangGraph resume.
|
|
* Distinct from `toolCallTurns` (which is cleared at the start of
|
|
* every `run()` to keep per-batch event-dispatch metadata fresh) —
|
|
* the direct path needs stability across re-entries triggered by
|
|
* `interrupt()` resumes (Codex P2 #30). Cleared with the rest of
|
|
* the per-Run state in `clearHeavyState`-equivalent flushes when
|
|
* the Run ends.
|
|
*/
|
|
private directPathTurns: Map<string, number> = new Map();
|
|
/** Tool registry for filtering (lazy computation of programmatic maps) */
|
|
private toolRegistry?: t.LCToolRegistry;
|
|
/** Cached programmatic tools (computed once on first PTC call) */
|
|
private programmaticCache?: t.ProgrammaticCache;
|
|
/** Reference to Graph's sessions map for automatic session injection */
|
|
private sessions?: t.ToolSessionMap;
|
|
/** When true, dispatches ON_TOOL_EXECUTE events instead of invoking tools directly */
|
|
private eventDrivenMode: boolean = false;
|
|
/** Opt-in stream-layer prestart config for event-driven tools. */
|
|
private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
|
|
/** Host tools that write to the code sandbox and share its exec session. */
|
|
private codeSessionToolNames?: ReadonlySet<string>;
|
|
/** Shared per-run prestarted tool registry populated by ChatModelStreamHandler. */
|
|
private eagerEventToolExecutions?: Map<string, t.EagerEventToolExecution>;
|
|
/** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
|
|
private eagerEventToolUsageCount?: Map<string, number>;
|
|
/** Agent ID for event-driven mode */
|
|
private agentId?: string;
|
|
/**
|
|
* ID of the agent that owns this tool node, whenever the graph knows it
|
|
* (including top-level agents in a multi-agent graph). Surfaced to hooks as
|
|
* `executingAgentId` so they can attribute a tool batch to a specific agent
|
|
* even where `agentId` (the subagent-scope marker) is undefined.
|
|
*/
|
|
private executingAgentId?: string;
|
|
/** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */
|
|
private directToolNames?: Set<string>;
|
|
/**
|
|
* Tool names whose in-process body may raise a LangGraph `interrupt()`
|
|
* mid-execution (e.g. `ask_user_question`). Used only to REORDER within
|
|
* the direct group: a tool named here that is *already* direct (a real
|
|
* in-process graphTool — the only kind whose body can reach
|
|
* `interrupt()`) is scheduled ahead of its non-interrupting direct
|
|
* siblings, so a mid-body interrupt unwinds the ToolNode before a
|
|
* non-idempotent sibling executes and LangGraph's resume-time batch
|
|
* re-execution can't double it. This set is deliberately NOT folded
|
|
* into direct classification: a name that resolves to a schema-only
|
|
* event stub (an inherited `toolDefinition` with no executable
|
|
* instance) stays event-dispatched — forcing it direct would invoke
|
|
* the stub, which throws. See {@link t.ToolNodeOptions.interruptingToolNames}.
|
|
*/
|
|
private interruptingToolNames?: Set<string>;
|
|
/**
|
|
* File checkpointer extracted from the local coding tool bundle when
|
|
* `toolExecution.local.fileCheckpointing === true`. Exposed via
|
|
* {@link getFileCheckpointer}. Undefined when checkpointing is off
|
|
* or the local coding suite isn't bound to this node.
|
|
*/
|
|
private fileCheckpointer?: t.LocalFileCheckpointer;
|
|
/** Maximum characters allowed in a single tool result before truncation. */
|
|
private maxToolResultChars: number;
|
|
/** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
|
|
private hookRegistry?: HookRegistry;
|
|
/**
|
|
* Run-scoped HITL config. When `enabled`, `ask` decisions from
|
|
* PreToolUse hooks raise a LangGraph `interrupt()` instead of being
|
|
* treated as fail-closed denies.
|
|
*/
|
|
private humanInTheLoop?: t.HumanInTheLoopConfig;
|
|
/**
|
|
* Registry of tool outputs keyed by `tool<idx>turn<turn>`.
|
|
*
|
|
* Populated only when `toolOutputReferences.enabled` is true. The
|
|
* registry owns the run-scoped state (turn counter, last-seen runId,
|
|
* warn-once memo, stored outputs), so sharing a single instance
|
|
* across multiple ToolNodes in a run lets cross-agent `{{…}}`
|
|
* references resolve — which is why multi-agent graphs pass the
|
|
* *same* instance to every ToolNode they compile rather than each
|
|
* ToolNode building its own.
|
|
*/
|
|
private toolOutputRegistry?: ToolOutputReferenceRegistry;
|
|
/** Run-scoped selection for swapping remote code tools to local executors. */
|
|
private toolExecution?: t.ToolExecutionConfig;
|
|
/**
|
|
* Monotonic counter used to mint a unique scope id for anonymous
|
|
* batches (ones invoked without a `run_id` in
|
|
* `config.configurable`). Each such batch gets its own registry
|
|
* partition so concurrent anonymous invocations can't delete each
|
|
* other's in-flight state.
|
|
*/
|
|
private anonBatchCounter: number = 0;
|
|
|
|
constructor({
|
|
tools,
|
|
toolMap,
|
|
name,
|
|
tags,
|
|
trace,
|
|
runLangfuse,
|
|
agentLangfuse,
|
|
errorHandler,
|
|
toolCallStepIds,
|
|
handleToolErrors,
|
|
loadRuntimeTools,
|
|
toolRegistry,
|
|
sessions,
|
|
eventDrivenMode,
|
|
eagerEventToolExecution,
|
|
eagerEventToolExecutions,
|
|
eagerEventToolUsageCount,
|
|
agentId,
|
|
executingAgentId,
|
|
directToolNames,
|
|
interruptingToolNames,
|
|
codeSessionToolNames,
|
|
maxContextTokens,
|
|
maxToolResultChars,
|
|
hookRegistry,
|
|
humanInTheLoop,
|
|
toolOutputReferences,
|
|
toolOutputRegistry,
|
|
toolExecution,
|
|
fileCheckpointer,
|
|
}: t.ToolNodeConstructorParams) {
|
|
super({
|
|
name: name ?? TOOL_NODE_RUN_NAME,
|
|
tags,
|
|
func: (input, config) => this.run(input, config),
|
|
});
|
|
this.trace = trace ?? this.trace;
|
|
this.runLangfuse = runLangfuse;
|
|
this.agentLangfuse = agentLangfuse;
|
|
this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
this.toolCallStepIds = toolCallStepIds;
|
|
this.handleToolErrors = handleToolErrors ?? this.handleToolErrors;
|
|
this.loadRuntimeTools = loadRuntimeTools;
|
|
this.errorHandler = errorHandler;
|
|
this.toolUsageCount = new Map<string, number>();
|
|
this.toolRegistry = resolveLocalToolRegistry({
|
|
toolRegistry,
|
|
toolExecution,
|
|
});
|
|
this.sessions = sessions;
|
|
this.eventDrivenMode = eventDrivenMode ?? false;
|
|
this.eagerEventToolExecution = eagerEventToolExecution;
|
|
this.eagerEventToolExecutions = eagerEventToolExecutions;
|
|
this.eagerEventToolUsageCount = eagerEventToolUsageCount;
|
|
this.agentId = agentId;
|
|
// Default to agentId so callers constructing ToolNode directly (who pass the
|
|
// existing agentId option) still get attribution without knowing the new option.
|
|
this.executingAgentId = executingAgentId ?? agentId;
|
|
this.directToolNames = directToolNames;
|
|
this.interruptingToolNames =
|
|
interruptingToolNames != null && interruptingToolNames.size > 0
|
|
? interruptingToolNames
|
|
: undefined;
|
|
this.codeSessionToolNames =
|
|
codeSessionToolNames != null && codeSessionToolNames.length > 0
|
|
? new Set(codeSessionToolNames)
|
|
: undefined;
|
|
this.maxToolResultChars =
|
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
this.hookRegistry = hookRegistry;
|
|
this.humanInTheLoop = humanInTheLoop;
|
|
this.toolExecution = toolExecution;
|
|
// Caller-provided checkpointer wins. Graphs use this to share a
|
|
// single per-Run instance across every ToolNode they compile so
|
|
// `Run.rewindFiles()` reaches the same snapshot store regardless
|
|
// of which agent's tool batch ran. Falls through to the bundle's
|
|
// auto-created one when undefined (direct ToolNode construction).
|
|
this.fileCheckpointer = fileCheckpointer;
|
|
this.applyToolExecutionOverrides();
|
|
/**
|
|
* Precedence: an explicitly passed `toolOutputRegistry` instance
|
|
* wins over a config object so a host (`Graph`) can share one
|
|
* registry across many ToolNodes. When only the config is
|
|
* provided (direct ToolNode usage), build a local registry so
|
|
* the feature still works without graph-level plumbing. Registry
|
|
* caps are intentionally decoupled from `maxToolResultChars`:
|
|
* the registry stores the raw untruncated output so a later
|
|
* `{{…}}` substitution pipes the full payload into the next
|
|
* tool, even when the LLM saw a truncated preview.
|
|
*/
|
|
if (toolOutputRegistry != null) {
|
|
this.toolOutputRegistry = toolOutputRegistry;
|
|
} else if (toolOutputReferences?.enabled === true) {
|
|
this.toolOutputRegistry = new ToolOutputReferenceRegistry({
|
|
maxOutputSize: toolOutputReferences.maxOutputSize,
|
|
maxTotalSize: toolOutputReferences.maxTotalSize,
|
|
});
|
|
}
|
|
}
|
|
|
|
override async invoke(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
input: any,
|
|
options?: Partial<RunnableConfig>
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
): Promise<any> {
|
|
return withLangfuseRuntimeScope(
|
|
resolveLangfuseRuntimeScope({
|
|
runLangfuse: this.runLangfuse,
|
|
langfuseOverlay: this.agentLangfuse,
|
|
}),
|
|
() => super.invoke(input, options)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the run-scoped tool output registry, or `undefined` when
|
|
* the feature is disabled.
|
|
*
|
|
* @internal Exposed for test observation only. Host code should rely
|
|
* on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
|
|
* not mutate the registry directly.
|
|
*/
|
|
public _unsafeGetToolOutputRegistry():
|
|
| ToolOutputReferenceRegistry
|
|
| undefined {
|
|
return this.toolOutputRegistry;
|
|
}
|
|
|
|
/**
|
|
* Replaces known remote Code API tools with local-process tools when
|
|
* `RunConfig.toolExecution.engine === 'local'`. In event-driven mode those
|
|
* names are also marked direct so the SDK executes them locally instead of
|
|
* dispatching the batch to a host-side remote sandbox handler. When the
|
|
* local coding suite is enabled, this also injects file/search/edit tools.
|
|
*/
|
|
private applyToolExecutionOverrides(): void {
|
|
const resolved = resolveLocalExecutionTools({
|
|
toolMap: this.toolMap,
|
|
toolExecution: this.toolExecution,
|
|
fileCheckpointer: this.fileCheckpointer,
|
|
});
|
|
|
|
this.toolMap = resolved.toolMap;
|
|
if (resolved.fileCheckpointer != null) {
|
|
this.fileCheckpointer = resolved.fileCheckpointer;
|
|
}
|
|
if (resolved.directToolNames.size === 0) {
|
|
return;
|
|
}
|
|
|
|
this.directToolNames = new Set([
|
|
...(this.directToolNames ?? new Set<string>()),
|
|
...resolved.directToolNames,
|
|
]);
|
|
this.programmaticCache = undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns the per-Run file checkpointer when
|
|
* `toolExecution.local.fileCheckpointing === true`. Hosts call
|
|
* `rewind()` on the returned object to restore captured pre-write
|
|
* file contents — the standard "undo a tool batch" pattern. Returns
|
|
* undefined when checkpointing is disabled or the local coding suite
|
|
* isn't bound. Manual review (finding E): without this getter, the
|
|
* config flag was a silent no-op outside of direct
|
|
* `createLocalCodingToolBundle()` use.
|
|
*/
|
|
getFileCheckpointer(): t.LocalFileCheckpointer | undefined {
|
|
return this.fileCheckpointer;
|
|
}
|
|
|
|
private *getRegisteredHandoffNames(): IterableIterator<string> {
|
|
if (this.directToolNames != null) {
|
|
for (const toolName of this.directToolNames) {
|
|
yield toolName;
|
|
}
|
|
}
|
|
|
|
for (const toolName of this.toolMap.keys()) {
|
|
if (this.directToolNames?.has(toolName) === true) {
|
|
continue;
|
|
}
|
|
yield toolName;
|
|
}
|
|
}
|
|
|
|
private hasRegisteredHandoffTool(): boolean {
|
|
for (const toolName of this.getRegisteredHandoffNames()) {
|
|
if (isHandoffToolName(toolName)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private getHandoffToolNameSuggestion(callName: string): string | undefined {
|
|
if (!isHandoffToolName(callName)) {
|
|
return undefined;
|
|
}
|
|
|
|
let suggestion: string | undefined;
|
|
for (const toolName of this.getRegisteredHandoffNames()) {
|
|
if (
|
|
!isHandoffToolName(toolName) ||
|
|
toolName.length >= callName.length ||
|
|
!callName.startsWith(toolName)
|
|
) {
|
|
continue;
|
|
}
|
|
if (suggestion == null || toolName.length > suggestion.length) {
|
|
suggestion = toolName;
|
|
}
|
|
}
|
|
return suggestion;
|
|
}
|
|
|
|
private shouldHandleUnknownHandoffLocally(
|
|
callName: string,
|
|
hasRegisteredHandoffTool?: boolean
|
|
): boolean {
|
|
if (!isHandoffToolName(callName) || this.toolMap.has(callName)) {
|
|
return false;
|
|
}
|
|
return hasRegisteredHandoffTool ?? this.hasRegisteredHandoffTool();
|
|
}
|
|
|
|
private getUnknownToolErrorMessage(callName: string): string {
|
|
const suggestion = this.getHandoffToolNameSuggestion(callName);
|
|
if (suggestion == null) {
|
|
return `Tool "${callName}" not found.`;
|
|
}
|
|
return (
|
|
`Tool "${callName}" not found. Did you mean "${suggestion}"? ` +
|
|
'Handoff tool names must match exactly.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Flush the per-Run direct-path turn cache. Called by the Graph at
|
|
* end-of-Run via `clearHeavyState`. The map intentionally survives
|
|
* `run()` re-entry so an interrupt + resume reuses the original
|
|
* slot (Codex P2 #30), but it would otherwise grow linearly with
|
|
* tool calls and could collide across Runs if a provider reused
|
|
* call IDs (Codex P2 #33). Hosts can also call this directly if
|
|
* they reuse a ToolNode across batches outside of a Graph.
|
|
*/
|
|
clearDirectPathTurns(): void {
|
|
this.directPathTurns.clear();
|
|
}
|
|
|
|
/**
|
|
* Returns cached programmatic tools, computing once on first access.
|
|
* Single iteration builds both toolMap and toolDefs simultaneously.
|
|
*/
|
|
private getProgrammaticTools(): { toolMap: t.ToolMap; toolDefs: t.LCTool[] } {
|
|
if (this.programmaticCache) return this.programmaticCache;
|
|
|
|
const toolMap: t.ToolMap = new Map();
|
|
const toolDefs: t.LCTool[] = [];
|
|
|
|
if (this.toolRegistry) {
|
|
for (const [name, toolDef] of this.toolRegistry) {
|
|
if (
|
|
(toolDef.allowed_callers ?? ['direct']).includes('code_execution')
|
|
) {
|
|
toolDefs.push(toolDef);
|
|
const tool = this.toolMap.get(name);
|
|
if (tool) toolMap.set(name, tool);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.programmaticCache = { toolMap, toolDefs };
|
|
return this.programmaticCache;
|
|
}
|
|
|
|
/**
|
|
* Returns a snapshot of the current tool usage counts.
|
|
* @returns A ReadonlyMap where keys are tool names and values are their usage counts.
|
|
*/
|
|
public getToolUsageCounts(): ReadonlyMap<string, number> {
|
|
return new Map(this.toolUsageCount); // Return a copy
|
|
}
|
|
|
|
private recordToolUsageTurn(
|
|
toolName: string,
|
|
turn: number,
|
|
callId?: string
|
|
): void {
|
|
this.toolUsageCount.set(
|
|
toolName,
|
|
Math.max(this.toolUsageCount.get(toolName) ?? 0, turn + 1)
|
|
);
|
|
if (callId != null && callId !== '') {
|
|
this.toolCallTurns.set(callId, turn);
|
|
}
|
|
}
|
|
|
|
private recordEventToolPlanningTurn(
|
|
toolName: string,
|
|
turn: number,
|
|
callId?: string,
|
|
runId?: string
|
|
): void {
|
|
this.recordToolUsageTurn(toolName, turn, callId);
|
|
if (this.canConsumeEagerEventExecution(runId)) {
|
|
this.eagerEventToolUsageCount?.set(
|
|
toolName,
|
|
Math.max(this.eagerEventToolUsageCount.get(toolName) ?? 0, turn + 1)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs a single tool call with error handling.
|
|
*
|
|
* `batchIndex` is the tool's position within the current ToolNode
|
|
* batch and, together with `this.currentTurn`, forms the key used to
|
|
* register the output for future `{{tool<idx>turn<turn>}}`
|
|
* substitutions. Omit when no registration should occur.
|
|
*/
|
|
protected async runTool(
|
|
call: ToolCall,
|
|
config: RunnableConfig,
|
|
batchContext: RunToolBatchContext<T> = {}
|
|
): Promise<BaseMessage | Command> {
|
|
const {
|
|
batchIndex,
|
|
turn,
|
|
batchScopeId,
|
|
resolvedArgsByCallId,
|
|
preBatchSnapshot,
|
|
runInput,
|
|
} = batchContext;
|
|
const tool = this.toolMap.get(call.name);
|
|
const registry = this.toolOutputRegistry;
|
|
/**
|
|
* Prefer the caller-provided snapshot when present — `run()`
|
|
* captures one synchronously per batch so direct-path placeholder
|
|
* resolution stays isolated from same-turn sibling outputs even
|
|
* when a slow `PreToolUse` hook lets siblings finish first.
|
|
* Falls back to the live registry for callers that didn't thread
|
|
* a snapshot (anonymous direct invokes, legacy paths).
|
|
*/
|
|
type ResolveFn = <T>(
|
|
runIdArg: string | undefined,
|
|
args: T
|
|
) => ResolveResult<T>;
|
|
let resolveFn: ResolveFn | undefined;
|
|
if (preBatchSnapshot != null) {
|
|
resolveFn = <T>(_runId: string | undefined, args: T): ResolveResult<T> =>
|
|
preBatchSnapshot.resolve(args);
|
|
} else if (registry != null) {
|
|
resolveFn = <T>(
|
|
runIdArg: string | undefined,
|
|
args: T
|
|
): ResolveResult<T> => registry.resolve(runIdArg, args);
|
|
}
|
|
/**
|
|
* Precompute the reference key once per call — captured locally
|
|
* so concurrent `invoke()` calls on the same ToolNode cannot race
|
|
* on a shared turn field.
|
|
*/
|
|
const refKey =
|
|
registry != null && batchIndex != null && turn != null
|
|
? buildReferenceKey(batchIndex, turn)
|
|
: undefined;
|
|
/**
|
|
* Hoisted outside the try so the catch branch can append
|
|
* `[unresolved refs: …]` to error messages — otherwise the LLM
|
|
* only sees a generic error when it references a bad key, losing
|
|
* the self-correction signal this feature is meant to provide.
|
|
*/
|
|
let unresolvedRefs: string[] = [];
|
|
/**
|
|
* Use the caller-provided `batchScopeId` when threaded from
|
|
* `run()` (so anonymous batches get their own unique scope).
|
|
* Fall back to the config's `run_id` when runTool is invoked
|
|
* from a context that doesn't thread it — that still preserves
|
|
* the runId-based partitioning for named runs.
|
|
*/
|
|
const runId =
|
|
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
try {
|
|
if (tool === undefined) {
|
|
throw new Error(this.getUnknownToolErrorMessage(call.name));
|
|
}
|
|
/**
|
|
* `usageCount` is the per-tool-name invocation index that
|
|
* web-search and other tools observe via `invokeParams.turn`.
|
|
* It is intentionally distinct from the outer `turn` parameter
|
|
* (the batch turn used for ref keys); the latter is captured
|
|
* before the try block when constructing `refKey`.
|
|
*
|
|
* Prefer the value `runDirectToolWithLifecycleHooks` already
|
|
* incremented (Codex P2 #27) — its hook wants the SAME turn
|
|
* the tool will execute under. When called from a path that
|
|
* doesn't pre-increment (event dispatch, the no-hooks
|
|
* shortcut), do the read+increment here.
|
|
*/
|
|
const usageCount =
|
|
batchContext.usageCount ??
|
|
((): number => {
|
|
const next = this.toolUsageCount.get(call.name) ?? 0;
|
|
this.toolUsageCount.set(call.name, next + 1);
|
|
if (call.id != null && call.id !== '') {
|
|
this.toolCallTurns.set(call.id, next);
|
|
}
|
|
return next;
|
|
})();
|
|
let args = call.args;
|
|
if (resolveFn != null) {
|
|
const { resolved, unresolved } = resolveFn(runId, args);
|
|
args = resolved;
|
|
unresolvedRefs = unresolved;
|
|
/**
|
|
* Expose the post-substitution args to downstream completion
|
|
* events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
|
|
* handlers observe what actually ran, not the `{{…}}`
|
|
* template. Only string/object args are worth recording.
|
|
*/
|
|
if (
|
|
resolvedArgsByCallId != null &&
|
|
call.id != null &&
|
|
call.id !== '' &&
|
|
resolved !== call.args &&
|
|
typeof resolved === 'object'
|
|
) {
|
|
resolvedArgsByCallId.set(
|
|
call.id,
|
|
resolved as Record<string, unknown>
|
|
);
|
|
}
|
|
}
|
|
const stepId = this.toolCallStepIds?.get(call.id!);
|
|
|
|
// Build invoke params - LangChain extracts non-schema fields to config.toolCall
|
|
// `turn` here is the per-tool usage count (matches what tools have
|
|
// observed historically via config.toolCall.turn — e.g. web search).
|
|
let invokeParams: Record<string, unknown> = {
|
|
...call,
|
|
args,
|
|
type: 'tool_call',
|
|
stepId,
|
|
turn: usageCount,
|
|
};
|
|
|
|
// Inject runtime data for special tools (becomes available at config.toolCall)
|
|
if (
|
|
call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
|
|
call.name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING
|
|
) {
|
|
const { toolMap, toolDefs } = this.getProgrammaticTools();
|
|
invokeParams = {
|
|
...invokeParams,
|
|
toolMap,
|
|
toolDefs,
|
|
// Plumb the hook context into the programmatic-tool path so
|
|
// inner tool calls made via the in-process bridge can run
|
|
// through `PreToolUse` (deny / updatedInput) before reaching
|
|
// the underlying tool. Without this, programmatic tool calls
|
|
// bypass every PreToolUse hook the host registered for the tools
|
|
// they dispatch — including HITL gates on `write_file` / `edit_file`.
|
|
hookContext: {
|
|
registry: this.hookRegistry,
|
|
runId: (config.configurable?.run_id as string | undefined) ?? '',
|
|
threadId: config.configurable?.thread_id as string | undefined,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
},
|
|
};
|
|
} else if (call.name === Constants.TOOL_SEARCH) {
|
|
invokeParams = {
|
|
...invokeParams,
|
|
toolRegistry: this.toolRegistry,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Inject session context for code execution tools when available.
|
|
* Each file uses its own session_id (supporting multi-session file tracking).
|
|
* Both session_id and _injected_files are injected directly to invokeParams
|
|
* (not inside args) so they bypass Zod schema validation and reach config.toolCall.
|
|
*
|
|
* session_id is always injected when available, but concrete file refs
|
|
* still need to travel through `_injected_files`; the legacy
|
|
* `/files/<session_id>` fallback was removed from the executors.
|
|
*/
|
|
if (this.participatesInCodeSession(call.name)) {
|
|
const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
|
|
| t.CodeSessionContext
|
|
| undefined;
|
|
const execSessionId = codeSession?.session_id;
|
|
if (execSessionId != null && execSessionId !== '') {
|
|
invokeParams = {
|
|
...invokeParams,
|
|
session_id: execSessionId,
|
|
};
|
|
|
|
if (codeSession?.files != null && codeSession.files.length > 0) {
|
|
invokeParams._injected_files = codeSession.files.map((file) =>
|
|
toInjectedFileRef(file, execSessionId)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stateful runtime session hint — orthogonal to the transient
|
|
* exec-session above, and injected independently (a first call has a
|
|
* hint but no exec session yet). Explicit host hint wins; otherwise
|
|
* fall back to the conversation's thread_id.
|
|
*/
|
|
const runtimeSessionHint = this.resolveRuntimeSessionHint(config);
|
|
if (runtimeSessionHint != null) {
|
|
invokeParams = {
|
|
...invokeParams,
|
|
_runtime_session_hint: runtimeSessionHint,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward the graph state as langgraph 1.4's `runtime.state` so
|
|
* tools can read it off their second argument instead of the
|
|
* deprecated `getCurrentTaskInput()` (which relies on
|
|
* `node:async_hooks` and is browser-incompatible). Shape mirrors
|
|
* langgraph's prebuilt ToolNode runtime exactly.
|
|
*/
|
|
const lgConfig = config as LangGraphRunnableConfig;
|
|
const runtime: ToolRuntime<T> = {
|
|
...config,
|
|
state: runInput as ToolRuntime<T>['state'],
|
|
toolCallId: call.id ?? '',
|
|
config,
|
|
context: lgConfig.context as ToolRuntime<T>['context'],
|
|
store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
|
|
writer:
|
|
lgConfig.writer ??
|
|
(config.configurable?.writer as ToolRuntime<T>['writer']) ??
|
|
null,
|
|
};
|
|
const output = await tool.invoke(invokeParams, runtime);
|
|
if (isCommand(output)) {
|
|
return output;
|
|
}
|
|
if (isBaseMessage(output) && output._getType() === 'tool') {
|
|
const toolMsg = output as ToolMessage;
|
|
const isError = toolMsg.status === 'error';
|
|
if (isError) {
|
|
/**
|
|
* Error ToolMessages bypass registration but still stamp the
|
|
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
* annotation transform surfaces it to the LLM, letting the
|
|
* model self-correct when its reference key caused the
|
|
* failure. Persisted `content` stays clean.
|
|
*/
|
|
if (unresolvedRefs.length > 0) {
|
|
toolMsg.additional_kwargs = {
|
|
...toolMsg.additional_kwargs,
|
|
_unresolvedRefs: unresolvedRefs,
|
|
};
|
|
}
|
|
return toolMsg;
|
|
}
|
|
if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
|
|
if (typeof toolMsg.content === 'string') {
|
|
const rawContent = toolMsg.content;
|
|
const registryContent = stripCodeSessionFileSummary(rawContent);
|
|
const llmContent = truncateToolResultContent(
|
|
rawContent,
|
|
this.maxToolResultChars
|
|
);
|
|
toolMsg.content = llmContent;
|
|
const refMeta = this.recordOutputReference(
|
|
runId,
|
|
registryContent,
|
|
refKey,
|
|
unresolvedRefs
|
|
);
|
|
if (refMeta != null) {
|
|
toolMsg.additional_kwargs = {
|
|
...toolMsg.additional_kwargs,
|
|
...refMeta,
|
|
};
|
|
}
|
|
} else {
|
|
/**
|
|
* Non-string content (multi-part content blocks — text +
|
|
* image). Known limitation: we cannot register under a
|
|
* reference key because there's no canonical serialized
|
|
* form. Warn once per tool per run when the caller
|
|
* intended to register. The unresolved-refs hint is still
|
|
* stamped as metadata; the lazy transform prepends a text
|
|
* block at request time so the LLM gets the self-correction
|
|
* signal.
|
|
*/
|
|
if (unresolvedRefs.length > 0) {
|
|
toolMsg.additional_kwargs = {
|
|
...toolMsg.additional_kwargs,
|
|
_unresolvedRefs: unresolvedRefs,
|
|
};
|
|
}
|
|
if (
|
|
refKey != null &&
|
|
this.toolOutputRegistry!.claimWarnOnce(runId, call.name)
|
|
) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[ToolNode] Skipping tool output reference for "${call.name}": ` +
|
|
'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return toolMsg;
|
|
}
|
|
const rawContent =
|
|
typeof output === 'string' ? output : JSON.stringify(output);
|
|
const truncated = truncateToolResultContent(
|
|
rawContent,
|
|
this.maxToolResultChars
|
|
);
|
|
const refMeta = this.recordOutputReference(
|
|
runId,
|
|
stripCodeSessionFileSummary(rawContent),
|
|
refKey,
|
|
unresolvedRefs
|
|
);
|
|
return new ToolMessage({
|
|
status: 'success',
|
|
name: tool.name,
|
|
content: truncated,
|
|
tool_call_id: call.id!,
|
|
...(refMeta != null && {
|
|
additional_kwargs: refMeta as Record<string, unknown>,
|
|
}),
|
|
});
|
|
} catch (_e: unknown) {
|
|
const e = _e as Error;
|
|
if (!this.handleToolErrors) {
|
|
throw e;
|
|
}
|
|
if (isGraphInterrupt(e)) {
|
|
throw e;
|
|
}
|
|
if (this.errorHandler) {
|
|
try {
|
|
const dispatched = await this.errorHandler(
|
|
{
|
|
error: e,
|
|
id: call.id!,
|
|
name: call.name,
|
|
input: call.args,
|
|
},
|
|
config.metadata
|
|
);
|
|
if (dispatched === false && call.id != null && call.id !== '') {
|
|
/**
|
|
* The handler could not dispatch the error completion (typically
|
|
* a resume pass, where a fast-failing tool errors before the
|
|
* step replay registers its run step). Remember the call so the
|
|
* output loop dispatches the completion itself instead of
|
|
* assuming the handler covered it.
|
|
*/
|
|
this.undispatchedToolErrors.add(call.id);
|
|
}
|
|
} catch (handlerError) {
|
|
// A THROWN handler is not proof the completion wasn't dispatched: the
|
|
// built-in session handler emits `tool.completed` BEFORE invoking a
|
|
// user ON_RUN_STEP_COMPLETED callback, so a throw from that callback
|
|
// has already dispatched. Marking it undispatched would make the
|
|
// fallback loop re-emit a duplicate completion — only an explicit
|
|
// `false` return (handled above) means "nothing dispatched"; a throw
|
|
// is just logged.
|
|
// eslint-disable-next-line no-console
|
|
console.error('Error in errorHandler:', {
|
|
toolName: call.name,
|
|
toolCallId: call.id,
|
|
toolArgs: call.args,
|
|
stepId: this.toolCallStepIds?.get(call.id!),
|
|
turn: this.toolUsageCount.get(call.name),
|
|
originalError: {
|
|
message: e.message,
|
|
stack: e.stack ?? undefined,
|
|
},
|
|
handlerError:
|
|
handlerError instanceof Error
|
|
? {
|
|
message: handlerError.message,
|
|
stack: handlerError.stack ?? undefined,
|
|
}
|
|
: {
|
|
message: String(handlerError),
|
|
stack: undefined,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
|
|
const refMeta =
|
|
unresolvedRefs.length > 0
|
|
? this.recordOutputReference(
|
|
runId,
|
|
errorContent,
|
|
undefined,
|
|
unresolvedRefs
|
|
)
|
|
: undefined;
|
|
return new ToolMessage({
|
|
status: 'error',
|
|
content: errorContent,
|
|
name: call.name,
|
|
tool_call_id: call.id ?? '',
|
|
...(refMeta != null && {
|
|
additional_kwargs: refMeta as Record<string, unknown>,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs a single in-process tool call with the same lifecycle hooks
|
|
* the event-dispatch path fires (`PreToolUse`, `PermissionDenied`,
|
|
* `PostToolUse`, `PostToolUseFailure`). Used for any tool whose
|
|
* implementation lives in the SDK process — i.e. every entry in
|
|
* `directToolNames` — so host-supplied policy hooks gate
|
|
* direct-invoked tools the same way they gate dispatched ones.
|
|
*
|
|
* Fast path: when the registry has none of the relevant events
|
|
* registered for this run, falls through to `runTool` with zero
|
|
* extra work. The hook list is also checked via
|
|
* `hasHookFor(event, runId)`, which performs the registry's own
|
|
* O(1) shortcut.
|
|
*
|
|
* Hook semantics intentionally mirror `dispatchToolEvents` for the
|
|
* single-call case:
|
|
* - `PreToolUse` returning `decision: 'deny'` synthesizes an error
|
|
* `ToolMessage` and fires `PermissionDenied` (observational).
|
|
* - `PreToolUse` returning `decision: 'ask'`:
|
|
* • When `humanInTheLoop.enabled === true`: raises a real
|
|
* `tool_approval` interrupt for this single tool call (the
|
|
* same payload shape the event path produces). On resume:
|
|
* `approve` runs the tool, `reject` blocks via
|
|
* `blockDirectCall`, `respond` returns the host-supplied
|
|
* `responseText` as a synthetic success ToolMessage,
|
|
* `edit` re-runs with edited args. LangGraph re-enters
|
|
* ToolNode.run from the start on resume; the hook fires
|
|
* again and the resume value distinguishes "first ask" from
|
|
* "second pass with decision".
|
|
* • When HITL is off: collapses to a fail-closed deny (matches
|
|
* the rest of the SDK's HITL-disabled default). One-time
|
|
* warning logged so hosts notice the gap.
|
|
* - `PreToolUse.updatedInput` is applied to the call before
|
|
* `runTool` runs; placeholder resolution inside `runTool` is
|
|
* idempotent on already-resolved args.
|
|
* - `PostToolUse.updatedOutput` replaces the returned
|
|
* `ToolMessage` content (preserving id/name/status).
|
|
* - `PostToolUseFailure` fires when `runTool` returns a
|
|
* `ToolMessage` whose `status === 'error'`. Observational only;
|
|
* the error message stays the source of truth.
|
|
*
|
|
* `PostToolBatch` aggregation across direct + dispatched outcomes is
|
|
* a separate concern: `dispatchToolEvents` accumulates batch entries
|
|
* locally and fires `PostToolBatch` at the end of its scope. Wiring
|
|
* direct-call entries into that aggregation crosses the two paths'
|
|
* scopes and is left to a follow-up.
|
|
*/
|
|
private async runDirectToolWithLifecycleHooks(
|
|
call: ToolCall,
|
|
config: RunnableConfig,
|
|
batchContext: RunToolBatchContext<T> = {}
|
|
): Promise<BaseMessage | Command> {
|
|
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
const hookRegistry = this.hookRegistry;
|
|
const hasPreHook = hookRegistry?.hasHookFor('PreToolUse', runId) === true;
|
|
const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
|
|
const hasFailureHook =
|
|
hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
|
|
|
|
if (
|
|
hookRegistry == null ||
|
|
(!hasPreHook && !hasPostHook && !hasFailureHook)
|
|
) {
|
|
return this.runTool(call, config, batchContext);
|
|
}
|
|
|
|
const threadId = config.configurable?.thread_id as string | undefined;
|
|
const registryRunId =
|
|
batchContext.batchScopeId ??
|
|
(config.configurable?.run_id as string | undefined);
|
|
// Slot reservation, synchronous, before any await:
|
|
// 1. If this call.id already has a recorded turn (from a prior
|
|
// entry that asked / interrupted), REUSE it. LangGraph
|
|
// re-runs the entire ToolNode on resume, so the same call
|
|
// can hit this code multiple times — incrementing on each
|
|
// pass would push the eventual approved execution to
|
|
// `turn=N` instead of `turn=0` (Codex P2 #30: the fix from
|
|
// P2 #27 over-incremented across re-entries).
|
|
// 2. Otherwise reserve the next slot from the counter. Done
|
|
// synchronously so concurrent same-tool calls in a single
|
|
// Promise.all batch get distinct turns (the original P2 #27
|
|
// requirement still holds).
|
|
// Net: turns are stable per call.id across interrupt/resume,
|
|
// unique per call within a batch.
|
|
let usageCount: number;
|
|
// Look in the resume-stable map first; fall back to the
|
|
// per-batch one. (`directPathTurns` is set on first entry and
|
|
// survives `run()`'s clear, so a resume sees the original
|
|
// assignment.)
|
|
const cachedTurn =
|
|
call.id != null && call.id !== ''
|
|
? (this.directPathTurns.get(call.id) ?? this.toolCallTurns.get(call.id))
|
|
: undefined;
|
|
if (cachedTurn != null) {
|
|
usageCount = cachedTurn;
|
|
} else {
|
|
usageCount = this.toolUsageCount.get(call.name) ?? 0;
|
|
this.toolUsageCount.set(call.name, usageCount + 1);
|
|
if (call.id != null && call.id !== '') {
|
|
this.toolCallTurns.set(call.id, usageCount);
|
|
// Dedicated direct-path map that SURVIVES `run()`'s
|
|
// toolCallTurns.clear() — so a re-entry triggered by
|
|
// LangGraph interrupt resume reuses this slot instead of
|
|
// re-incrementing. Codex P2 #30.
|
|
this.directPathTurns.set(call.id, usageCount);
|
|
}
|
|
}
|
|
const turn = usageCount;
|
|
const stepId = this.toolCallStepIds?.get(call.id ?? '') ?? '';
|
|
|
|
// Use the caller-threaded snapshot when available (P1 #18) so the
|
|
// value the PreToolUse hook observes matches the value the
|
|
// (later-awaited) `runTool` will actually run with — both are
|
|
// anchored to the pre-batch registry state.
|
|
let resolvedArgs = call.args as Record<string, unknown>;
|
|
if (batchContext.preBatchSnapshot != null) {
|
|
const { resolved } = batchContext.preBatchSnapshot.resolve(call.args);
|
|
resolvedArgs = resolved as Record<string, unknown>;
|
|
} else if (this.toolOutputRegistry != null) {
|
|
const { resolved } = this.toolOutputRegistry.resolve(
|
|
registryRunId,
|
|
call.args
|
|
);
|
|
resolvedArgs = resolved as Record<string, unknown>;
|
|
}
|
|
|
|
let effectiveCall = call;
|
|
if (hasPreHook) {
|
|
const preResult = await executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PreToolUse',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: call.name,
|
|
toolInput: resolvedArgs,
|
|
toolUseId: call.id ?? '',
|
|
stepId,
|
|
turn,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: call.name,
|
|
}).catch(() => undefined);
|
|
|
|
if (preResult != null) {
|
|
// Forward any additionalContext strings hooks returned into
|
|
// the per-batch sink so the caller materializes them as a
|
|
// HumanMessage for the next model turn — same shape as the
|
|
// event-driven path's `injected[]`. Codex P2 #39.
|
|
if (
|
|
batchContext.additionalContextsSink != null &&
|
|
preResult.additionalContexts.length > 0
|
|
) {
|
|
batchContext.additionalContextsSink.push(
|
|
...preResult.additionalContexts
|
|
);
|
|
}
|
|
// Apply any input rewrite first — `ask`-with-`updatedInput` is
|
|
// a valid combination (one matcher sanitises args, another asks
|
|
// for approval); the reviewer should see the sanitised args.
|
|
if (preResult.updatedInput != null) {
|
|
effectiveCall = {
|
|
...call,
|
|
args: preResult.updatedInput as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
if (preResult.decision === 'deny') {
|
|
return this.blockDirectCall({
|
|
call,
|
|
resolvedArgs,
|
|
reason: preResult.reason ?? 'Blocked by hook',
|
|
hookRegistry,
|
|
runId,
|
|
threadId,
|
|
});
|
|
}
|
|
|
|
if (preResult.decision === 'ask') {
|
|
if (this.humanInTheLoop?.enabled !== true) {
|
|
// Fail-closed: no HITL UI configured, so we can't actually
|
|
// ask. Logged once via the existing helper.
|
|
const reason = this.resolveAskDecisionForDirectTool(
|
|
preResult.reason,
|
|
call.name
|
|
);
|
|
return this.blockDirectCall({
|
|
call,
|
|
resolvedArgs,
|
|
reason,
|
|
hookRegistry,
|
|
runId,
|
|
threadId,
|
|
});
|
|
}
|
|
|
|
// Raise a single-tool tool_approval interrupt. LangGraph
|
|
// throws on the first execution (host gets the interrupt)
|
|
// and returns the resume value on re-entry. Because direct
|
|
// tools re-enter the entire ToolNode.run on resume, the
|
|
// PreToolUse hook fires AGAIN — which is fine: the hook is
|
|
// expected to be deterministic, and the resume value is what
|
|
// distinguishes "first call asking" from "second call after
|
|
// approve/reject". We anchor `interrupt()` against the
|
|
// node's RunnableConfig the same way `dispatchToolEvents`
|
|
// does (ToolNode disables LangSmith tracing, so the
|
|
// AsyncLocalStorage frame must be re-established here).
|
|
const askEntry: AskEntry = {
|
|
entry: {
|
|
call: effectiveCall,
|
|
args: effectiveCall.args as Record<string, unknown>,
|
|
stepId,
|
|
},
|
|
reason: preResult.reason,
|
|
allowedDecisions: preResult.allowedDecisions,
|
|
};
|
|
const payload = buildToolApprovalInterruptPayload([askEntry]);
|
|
const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
|
|
config,
|
|
() =>
|
|
interrupt<
|
|
t.ToolApprovalInterruptPayload,
|
|
t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
|
|
>(payload)
|
|
);
|
|
const decisionByCallId = normalizeApprovalDecisions(
|
|
[call.id!],
|
|
resumeValue
|
|
);
|
|
const decision = decisionByCallId.get(call.id!) ?? {
|
|
type: 'reject' as const,
|
|
reason: 'No decision provided for tool approval',
|
|
};
|
|
const declaredType = (decision as { type?: unknown }).type;
|
|
|
|
if (
|
|
preResult.allowedDecisions != null &&
|
|
(typeof declaredType !== 'string' ||
|
|
!preResult.allowedDecisions.includes(
|
|
declaredType as t.ToolApprovalDecisionType
|
|
))
|
|
) {
|
|
return this.blockDirectCall({
|
|
call,
|
|
resolvedArgs,
|
|
reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
|
|
hookRegistry,
|
|
runId,
|
|
threadId,
|
|
});
|
|
}
|
|
|
|
if (decision.type === 'reject') {
|
|
return this.blockDirectCall({
|
|
call,
|
|
resolvedArgs,
|
|
reason: decision.reason ?? preResult.reason ?? 'Rejected by user',
|
|
hookRegistry,
|
|
runId,
|
|
threadId,
|
|
});
|
|
}
|
|
|
|
if (decision.type === 'respond') {
|
|
const responseText = (decision as { responseText?: unknown })
|
|
.responseText;
|
|
if (typeof responseText !== 'string') {
|
|
return this.blockDirectCall({
|
|
call,
|
|
resolvedArgs,
|
|
reason:
|
|
'Approval payload `respond` was missing a string `responseText`',
|
|
hookRegistry,
|
|
runId,
|
|
threadId,
|
|
});
|
|
}
|
|
return new ToolMessage({
|
|
status: 'success',
|
|
content: responseText,
|
|
name: call.name,
|
|
tool_call_id: call.id ?? '',
|
|
});
|
|
}
|
|
|
|
if (decision.type === 'edit') {
|
|
// Mirror the event-driven path's validation
|
|
// (see `dispatchToolEvents`'s edit branch). The wire
|
|
// field is `updatedInput`, NOT `args` — hosts following
|
|
// the documented `ToolApprovalDecision` shape were
|
|
// silently ignored before, so the tool ran with the
|
|
// original (un-edited) arguments. Fail closed on
|
|
// malformed payloads instead of falling through with
|
|
// undefined args.
|
|
const updatedInput = (decision as { updatedInput?: unknown })
|
|
.updatedInput;
|
|
if (
|
|
updatedInput === null ||
|
|
typeof updatedInput !== 'object' ||
|
|
Array.isArray(updatedInput)
|
|
) {
|
|
return new ToolMessage({
|
|
status: 'error',
|
|
content:
|
|
'Decision "edit" missing object updatedInput — failing closed.',
|
|
name: call.name,
|
|
tool_call_id: call.id ?? '',
|
|
});
|
|
}
|
|
effectiveCall = {
|
|
...call,
|
|
args: updatedInput as Record<string, unknown>,
|
|
};
|
|
// fall through to executing the edited call
|
|
}
|
|
// 'approve' (or 'edit' after applying edits) → fall through
|
|
}
|
|
}
|
|
}
|
|
|
|
const output = await this.runTool(effectiveCall, config, {
|
|
...batchContext,
|
|
usageCount,
|
|
});
|
|
|
|
if (!(output instanceof ToolMessage)) {
|
|
return output;
|
|
}
|
|
|
|
if (output.status === 'error' && hasFailureHook) {
|
|
// Await the failure hook (instead of fire-and-forget) so we
|
|
// can capture additionalContexts before returning. The hook is
|
|
// still observational w.r.t. the tool result itself — we don't
|
|
// mutate `output`, just plumb the contexts. Codex P2 #39.
|
|
const failureResult = await executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PostToolUseFailure',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: call.name,
|
|
toolInput: effectiveCall.args as Record<string, unknown>,
|
|
toolUseId: call.id ?? '',
|
|
error:
|
|
typeof output.content === 'string'
|
|
? output.content
|
|
: JSON.stringify(output.content),
|
|
stepId,
|
|
turn,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: call.name,
|
|
}).catch(() => undefined);
|
|
if (
|
|
failureResult != null &&
|
|
batchContext.additionalContextsSink != null &&
|
|
failureResult.additionalContexts.length > 0
|
|
) {
|
|
batchContext.additionalContextsSink.push(
|
|
...failureResult.additionalContexts
|
|
);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
if (output.status !== 'error' && hasPostHook) {
|
|
const postResult = await executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PostToolUse',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: call.name,
|
|
toolInput: effectiveCall.args as Record<string, unknown>,
|
|
toolOutput: output.content,
|
|
toolUseId: call.id ?? '',
|
|
stepId,
|
|
turn,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: call.name,
|
|
}).catch(() => undefined);
|
|
|
|
// Forward additionalContexts from the PostToolUse hook into
|
|
// the per-batch sink (Codex P2 #39).
|
|
if (
|
|
postResult != null &&
|
|
batchContext.additionalContextsSink != null &&
|
|
postResult.additionalContexts.length > 0
|
|
) {
|
|
batchContext.additionalContextsSink.push(
|
|
...postResult.additionalContexts
|
|
);
|
|
}
|
|
|
|
if (postResult?.updatedOutput != null) {
|
|
const replaced =
|
|
typeof postResult.updatedOutput === 'string'
|
|
? postResult.updatedOutput
|
|
: JSON.stringify(postResult.updatedOutput);
|
|
// Keep the tool-output registry in sync with what the model
|
|
// actually sees. Without this, `runTool` already registered
|
|
// the PRE-hook content under `_refKey`, and a later
|
|
// `{{tool<i>turn<n>}}` substitution would deliver the stale
|
|
// pre-hook bytes while the model (and downstream tools)
|
|
// observed the post-hook replacement. Read `_refKey` /
|
|
// `_refScope` straight off the message metadata that
|
|
// `recordOutputReference` stamped — no need to re-derive
|
|
// (and we couldn't, for anonymous-batch synthetic scopes).
|
|
const refMeta = output.additional_kwargs as
|
|
| t.ToolMessageRefMetadata
|
|
| undefined;
|
|
const refKey = refMeta?._refKey;
|
|
const refScope = refMeta?._refScope;
|
|
if (this.toolOutputRegistry != null && refKey != null) {
|
|
this.toolOutputRegistry.set(refScope, refKey, replaced);
|
|
}
|
|
return new ToolMessage({
|
|
status: output.status,
|
|
name: output.name,
|
|
content: replaced,
|
|
artifact: output.artifact,
|
|
tool_call_id: output.tool_call_id,
|
|
additional_kwargs: output.additional_kwargs,
|
|
});
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* `ask` decisions on direct-path tools collapse to fail-closed deny
|
|
* only when `humanInTheLoop.enabled !== true` (i.e. there's no host
|
|
* UI configured to actually prompt the user). Logged once per process
|
|
* so the gap is visible. When HITL IS enabled, `ask` raises a real
|
|
* LangGraph `interrupt()` instead — see `runDirectToolWithLifecycleHooks`.
|
|
*/
|
|
private askDirectWarningEmitted = false;
|
|
private resolveAskDecisionForDirectTool(
|
|
reason: string | undefined,
|
|
toolName: string
|
|
): string {
|
|
if (!this.askDirectWarningEmitted) {
|
|
this.askDirectWarningEmitted = true;
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[ToolNode] PreToolUse returned 'ask' for direct-path tool "${toolName}" but ` +
|
|
'humanInTheLoop is not enabled — failing closed. Set humanInTheLoop.enabled=true ' +
|
|
'to raise a tool_approval interrupt the host can resolve.'
|
|
);
|
|
}
|
|
return reason ?? 'Blocked by hook';
|
|
}
|
|
|
|
/**
|
|
* Synthesize a Blocked ToolMessage AND fire `PermissionDenied`
|
|
* (observational) for a direct-path tool call. Centralised so the
|
|
* deny path looks identical whether the block came from `'deny'` or
|
|
* from a fail-closed/`'reject'`/policy-violation path.
|
|
*/
|
|
private blockDirectCall(args: {
|
|
call: ToolCall;
|
|
resolvedArgs: Record<string, unknown>;
|
|
reason: string;
|
|
hookRegistry: HookRegistry;
|
|
runId: string;
|
|
threadId: string | undefined;
|
|
}): ToolMessage {
|
|
const { call, resolvedArgs, reason, hookRegistry, runId, threadId } = args;
|
|
if (hookRegistry.hasHookFor('PermissionDenied', runId) === true) {
|
|
executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PermissionDenied',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: call.name,
|
|
toolInput: resolvedArgs,
|
|
toolUseId: call.id ?? '',
|
|
reason,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: call.name,
|
|
}).catch(() => {
|
|
/* observational */
|
|
});
|
|
}
|
|
return new ToolMessage({
|
|
status: 'error',
|
|
content: `Blocked: ${reason}`,
|
|
name: call.name,
|
|
tool_call_id: call.id ?? '',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Registers the full, raw output under `refKey` (when provided) and
|
|
* builds the per-message ref metadata stamped onto the resulting
|
|
* `ToolMessage.additional_kwargs`. The metadata is read at LLM-
|
|
* request time by `annotateMessagesForLLM` to produce a transient
|
|
* annotated copy of the message — the persisted `content` itself
|
|
* stays clean.
|
|
*
|
|
* @param registryContent The full, untruncated output to store in
|
|
* the registry so `{{tool<i>turn<n>}}` substitutions deliver the
|
|
* complete payload. Ignored when `refKey` is undefined.
|
|
* @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
|
|
* the output is not to be registered (errors, disabled feature,
|
|
* unavailable batch/turn).
|
|
* @param unresolved Placeholder keys that did not resolve; surfaced
|
|
* to the LLM lazily so it can self-correct.
|
|
* @returns A `ToolMessageRefMetadata` object when there is anything
|
|
* to stamp, otherwise `undefined`.
|
|
*/
|
|
private recordOutputReference(
|
|
runId: string | undefined,
|
|
registryContent: string,
|
|
refKey: string | undefined,
|
|
unresolved: string[]
|
|
): t.ToolMessageRefMetadata | undefined {
|
|
if (this.toolOutputRegistry != null && refKey != null) {
|
|
this.toolOutputRegistry.set(runId, refKey, registryContent);
|
|
}
|
|
if (refKey == null && unresolved.length === 0) return undefined;
|
|
const meta: t.ToolMessageRefMetadata = {};
|
|
if (refKey != null) {
|
|
meta._refKey = refKey;
|
|
/**
|
|
* Stamp the registry scope alongside the key so the lazy
|
|
* annotation transform can look up the right bucket. Anonymous
|
|
* invocations get a synthetic per-batch scope (`\0anon-<n>`)
|
|
* that `attemptInvoke` cannot derive from
|
|
* `config.configurable.run_id` — without this, anonymous-run
|
|
* refs would silently fail registry lookup and the LLM would
|
|
* never see `[ref: …]` markers for outputs that were registered.
|
|
*/
|
|
if (runId != null) meta._refScope = runId;
|
|
}
|
|
if (unresolved.length > 0) meta._unresolvedRefs = unresolved;
|
|
return meta;
|
|
}
|
|
|
|
/**
|
|
* Builds code session context for injection into event-driven tool calls.
|
|
* Mirrors the session injection logic in runTool() for direct execution.
|
|
*/
|
|
private getCodeSessionContext(): t.ToolCallRequest['codeSessionContext'] {
|
|
if (!this.sessions) {
|
|
return undefined;
|
|
}
|
|
|
|
const codeSession = this.sessions.get(Constants.EXECUTE_CODE) as
|
|
| t.CodeSessionContext
|
|
| undefined;
|
|
if (!codeSession) {
|
|
return undefined;
|
|
}
|
|
|
|
const execSessionId = codeSession.session_id;
|
|
const context: NonNullable<t.ToolCallRequest['codeSessionContext']> = {
|
|
session_id: execSessionId,
|
|
};
|
|
|
|
if (codeSession.files && codeSession.files.length > 0) {
|
|
context.files = codeSession.files.map((file) =>
|
|
toInjectedFileRef(file, execSessionId)
|
|
);
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Extracts code execution session context from tool results and stores in Graph.sessions.
|
|
* Mirrors the session storage logic in handleRunToolCompletions for direct execution.
|
|
*/
|
|
/**
|
|
* True when a tool's successful result should fold its returned exec
|
|
* `session_id` into the shared code session: built-in `CODE_EXECUTION_TOOLS`,
|
|
* plus host-declared sandbox-writing tools (`codeSessionToolNames`, e.g.
|
|
* create_file/edit_file). Kept name-scoped rather than a blanket artifact
|
|
* opt-in so only host-declared tools can influence the shared session.
|
|
*/
|
|
private participatesInCodeSession(name: string): boolean {
|
|
if (name === '') {
|
|
return false;
|
|
}
|
|
return (
|
|
CODE_EXECUTION_TOOLS.has(name) ||
|
|
this.codeSessionToolNames?.has(name) === true
|
|
);
|
|
}
|
|
|
|
/** Delegates to the shared resolver so the direct and event-driven planning
|
|
* paths derive the runtime session hint identically. */
|
|
private resolveRuntimeSessionHint(
|
|
config: RunnableConfig
|
|
): string | undefined {
|
|
return resolveRuntimeSessionHint(
|
|
this.toolExecution,
|
|
config.configurable?.thread_id as string | undefined
|
|
);
|
|
}
|
|
|
|
private storeCodeSessionFromResults(
|
|
results: t.ToolExecuteResult[],
|
|
requestMap: Map<string, t.ToolCallRequest>
|
|
): void {
|
|
if (!this.sessions) {
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
const result = results[i];
|
|
if (result.status !== 'success' || result.artifact == null) {
|
|
continue;
|
|
}
|
|
|
|
const request = requestMap.get(result.toolCallId);
|
|
if (
|
|
request?.name == null ||
|
|
request.name === '' ||
|
|
(!this.participatesInCodeSession(request.name) &&
|
|
request.name !== Constants.SKILL_TOOL)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const artifact = result.artifact as t.CodeExecutionArtifact | undefined;
|
|
const execSessionId = artifact?.session_id;
|
|
if (execSessionId == null || execSessionId === '') {
|
|
continue;
|
|
}
|
|
|
|
updateCodeSession(this.sessions, execSessionId, artifact?.files);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Post-processes standard runTool outputs: dispatches ON_RUN_STEP_COMPLETED
|
|
* and stores code session context. Mirrors the completion handling in
|
|
* dispatchToolEvents for the event-driven path.
|
|
*
|
|
* By handling completions here in graph context (rather than in the
|
|
* stream consumer via ToolEndHandler), the race between the stream
|
|
* consumer and graph execution is eliminated.
|
|
*
|
|
* @param resolvedArgsByCallId Per-batch resolved-args sink populated
|
|
* by `runTool`. Threaded as a local map (instead of instance state)
|
|
* so concurrent batches cannot read each other's entries.
|
|
*/
|
|
private async handleRunToolCompletions(
|
|
calls: ToolCall[],
|
|
outputs: (BaseMessage | Command)[],
|
|
config: RunnableConfig,
|
|
resolvedArgsByCallId?: ResolvedArgsByCallId
|
|
): Promise<void> {
|
|
for (let i = 0; i < calls.length; i++) {
|
|
const call = calls[i];
|
|
const output = outputs[i];
|
|
const turn = this.toolCallTurns.get(call.id!) ?? 0;
|
|
|
|
if (isCommand(output)) {
|
|
continue;
|
|
}
|
|
|
|
const toolMessage = output as ToolMessage;
|
|
const toolCallId = call.id ?? '';
|
|
|
|
// Skip error ToolMessages when errorHandler already dispatched ON_RUN_STEP_COMPLETED
|
|
// via handleToolCallErrorStatic — dispatching again here would double-dispatch.
|
|
// When the handler reported it could NOT dispatch (no run step registered yet at
|
|
// error time, e.g. a fast-failing tool on a resume pass), fall through: by now the
|
|
// step replay has usually registered the id, so this loop's dispatch is the only
|
|
// terminal event the client's tool-call part will ever get.
|
|
if (toolMessage.status === 'error' && this.errorHandler != null) {
|
|
if (this.undispatchedToolErrors.has(toolCallId)) {
|
|
// CONSUME the marker: this loop now owns the dispatch for this id.
|
|
// Leaving it set would let a later re-entry (same ToolNode instance
|
|
// re-executing the batch, where the handler CAN dispatch) fall
|
|
// through here too and double-dispatch — and the set would grow
|
|
// unbounded across a long-lived graph's fast-failing calls.
|
|
this.undispatchedToolErrors.delete(toolCallId);
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (this.sessions && this.participatesInCodeSession(call.name)) {
|
|
const artifact = toolMessage.artifact as
|
|
| t.CodeExecutionArtifact
|
|
| undefined;
|
|
const execSessionId = artifact?.session_id;
|
|
if (execSessionId != null && execSessionId !== '') {
|
|
updateCodeSession(this.sessions, execSessionId, artifact?.files);
|
|
}
|
|
}
|
|
|
|
// Dispatch ON_RUN_STEP_COMPLETED via custom event (same path as dispatchToolEvents)
|
|
const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
|
|
if (!stepId) {
|
|
continue;
|
|
}
|
|
|
|
const contentString =
|
|
typeof toolMessage.content === 'string'
|
|
? toolMessage.content
|
|
: JSON.stringify(toolMessage.content);
|
|
|
|
/**
|
|
* Prefer the post-substitution args when a `{{…}}` placeholder
|
|
* was resolved in `runTool`. This keeps
|
|
* `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
|
|
* the tool actually received rather than leaking the template.
|
|
*/
|
|
const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
|
|
const tool_call: t.ProcessedToolCall = {
|
|
args:
|
|
typeof effectiveArgs === 'string'
|
|
? (effectiveArgs as string)
|
|
: JSON.stringify((effectiveArgs as unknown) ?? {}),
|
|
name: call.name,
|
|
id: toolCallId,
|
|
output: contentString,
|
|
progress: 1,
|
|
};
|
|
|
|
await safeDispatchCustomEvent(
|
|
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
{
|
|
result: {
|
|
id: stepId,
|
|
index: turn,
|
|
type: 'tool_call' as const,
|
|
tool_call,
|
|
},
|
|
},
|
|
config
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispatches tool calls to the host via ON_TOOL_EXECUTE event and returns raw ToolMessages.
|
|
* Core logic for event-driven execution, separated from output shaping.
|
|
*
|
|
* Hook lifecycle (when `hookRegistry` is set):
|
|
* 1. **PreToolUse** fires per call in parallel before dispatch. Denied
|
|
* calls produce error ToolMessages and fire **PermissionDenied**;
|
|
* surviving calls proceed with optional `updatedInput`.
|
|
* 2. Surviving calls are dispatched to the host via `ON_TOOL_EXECUTE`.
|
|
* 3. **PostToolUse** / **PostToolUseFailure** fire per result. Post hooks
|
|
* can replace tool output via `updatedOutput`.
|
|
* 4. Injected messages from results are collected and returned alongside
|
|
* ToolMessages (appended AFTER to respect provider ordering).
|
|
*/
|
|
private async dispatchToolEvents(
|
|
toolCalls: ToolCall[],
|
|
config: RunnableConfig,
|
|
batchContext: DispatchBatchContext = {}
|
|
): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> {
|
|
const {
|
|
batchIndices,
|
|
turn,
|
|
batchScopeId,
|
|
preResolvedArgs,
|
|
preBatchSnapshot,
|
|
} = batchContext;
|
|
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
/**
|
|
* Registry-facing scope id — prefers the caller-threaded
|
|
* `batchScopeId` so anonymous batches target their own unique
|
|
* bucket and don't step on concurrent anonymous invocations.
|
|
* Hooks and event payloads keep using the empty-string coerced
|
|
* `runId` for backward compat.
|
|
*/
|
|
const registryRunId =
|
|
batchScopeId ?? (config.configurable?.run_id as string | undefined);
|
|
const threadId = config.configurable?.thread_id as string | undefined;
|
|
const registry = this.toolOutputRegistry;
|
|
const unresolvedByCallId = new Map<string, string[]>();
|
|
|
|
const preToolCalls = toolCalls.map((call, i) => {
|
|
const originalArgs = call.args as Record<string, unknown>;
|
|
let resolvedArgs = originalArgs;
|
|
/**
|
|
* When the caller provided a pre-resolved map (the mixed
|
|
* direct+event path snapshots event args synchronously before
|
|
* awaiting directs so they can't accidentally resolve
|
|
* same-turn direct outputs), use those entries verbatim instead
|
|
* of re-resolving against a registry that may have changed
|
|
* since the batch started.
|
|
*/
|
|
const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
|
|
if (pre != null) {
|
|
resolvedArgs = pre.resolved;
|
|
if (pre.unresolved.length > 0 && call.id != null) {
|
|
unresolvedByCallId.set(call.id, pre.unresolved);
|
|
}
|
|
} else if (registry != null) {
|
|
const { resolved, unresolved } = registry.resolve(
|
|
registryRunId,
|
|
originalArgs
|
|
);
|
|
resolvedArgs = resolved as Record<string, unknown>;
|
|
if (unresolved.length > 0 && call.id != null) {
|
|
unresolvedByCallId.set(call.id, unresolved);
|
|
}
|
|
}
|
|
return {
|
|
call,
|
|
stepId: this.toolCallStepIds?.get(call.id!) ?? '',
|
|
args: resolvedArgs,
|
|
batchIndex: batchIndices?.[i],
|
|
};
|
|
});
|
|
|
|
const messageByCallId = new Map<string, ToolMessage>();
|
|
const approvedEntries: typeof preToolCalls = [];
|
|
/**
|
|
* Batch-level accumulator for `additionalContext` strings returned
|
|
* by any PreToolUse / PostToolUse / PostToolUseFailure hook in this
|
|
* dispatch. We emit one consolidated `HumanMessage` after all tool
|
|
* results land so the next model turn sees the injected context
|
|
* exactly once, ordered after the ToolMessages.
|
|
*/
|
|
const batchAdditionalContexts: string[] = [];
|
|
/**
|
|
* Batch-level outcome record keyed by `tool_call_id`. Captures
|
|
* every tool call's final result (success / error from the host,
|
|
* blocked from HITL deny / reject, substituted from HITL respond)
|
|
* across the three call sites that touch it. We materialize the
|
|
* `PostToolBatch` entry array in `toolCalls` order at dispatch
|
|
* time so hooks correlating outcomes by position see exactly the
|
|
* same sequence the model emitted — independent of when each
|
|
* outcome was recorded (deny entries land synchronously in the
|
|
* hook loop, approved entries land after host execution, respond
|
|
* entries land in the resume branch).
|
|
*/
|
|
const postToolBatchEntryByCallId = new Map<string, PostToolBatchEntry>();
|
|
const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
|
|
additionalContexts: [] as string[],
|
|
injectedMessages: [] as t.InjectedMessage[],
|
|
errors: [] as string[],
|
|
});
|
|
|
|
if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
|
|
/**
|
|
* Capture as a non-null local so the inner `blockEntry` closure
|
|
* doesn't lose narrowing on `this.hookRegistry` and we don't have
|
|
* to defensively `?.` it across every reference inside.
|
|
*/
|
|
const hookRegistry = this.hookRegistry;
|
|
/**
|
|
* Pull each call's prestarted eager record BEFORE awaiting the hooks:
|
|
* an async deny must never race the eager host promise into emitting a
|
|
* successful completion (`dispatchEagerToolCompletions`' map-identity
|
|
* check skips deleted records). Allowed entries get their record
|
|
* restored in the decision loop below so consumption still works;
|
|
* denied entries stay deleted. Ask/interrupt configs never have eager
|
|
* records (HITL disables the reservation gate).
|
|
*/
|
|
const preemptedEagerRecords = new Map<
|
|
string,
|
|
t.EagerEventToolExecution
|
|
>();
|
|
if (this.eagerEventToolExecutions != null) {
|
|
for (const entry of preToolCalls) {
|
|
const callId = entry.call.id;
|
|
if (callId == null) {
|
|
continue;
|
|
}
|
|
const record = this.eagerEventToolExecutions.get(callId);
|
|
if (record != null) {
|
|
preemptedEagerRecords.set(callId, record);
|
|
this.eagerEventToolExecutions.delete(callId);
|
|
}
|
|
}
|
|
}
|
|
const preResults = await Promise.all(
|
|
preToolCalls.map((entry) =>
|
|
executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PreToolUse',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: entry.call.name,
|
|
toolInput: entry.args,
|
|
toolUseId: entry.call.id!,
|
|
stepId: entry.stepId,
|
|
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: entry.call.name,
|
|
}).catch((): AggregatedHookResult => HOOK_FALLBACK)
|
|
)
|
|
);
|
|
|
|
type PendingEntry = (typeof preToolCalls)[number];
|
|
|
|
/**
|
|
* Side effects deferred from `blockEntry` until after any pending
|
|
* `interrupt()` resolves. Without deferral, a batch that mixes a
|
|
* `deny` decision with an `ask` decision would dispatch
|
|
* `ON_RUN_STEP_COMPLETED` for the denied tool on the FIRST node
|
|
* execution (before `interrupt()` throws), then dispatch the
|
|
* same event AGAIN on the resume re-execution — hosts would
|
|
* observe two completion events for one logical denial. By
|
|
* queueing the dispatch + PermissionDenied hook here and
|
|
* flushing after the interrupt block, we ensure each side effect
|
|
* fires exactly once: never on the first pass when interrupt
|
|
* throws (the flush is unreachable), once on resume / no-ask
|
|
* passes when control reaches the flush.
|
|
*/
|
|
const deferredBlockedSideEffects: Array<{
|
|
callId: string;
|
|
toolName: string;
|
|
args: Record<string, unknown>;
|
|
contentString: string;
|
|
reason: string;
|
|
}> = [];
|
|
|
|
const blockEntry = (entry: PendingEntry, reason: string): void => {
|
|
const contentString = `Blocked: ${reason}`;
|
|
messageByCallId.set(
|
|
entry.call.id!,
|
|
new ToolMessage({
|
|
status: 'error',
|
|
content: contentString,
|
|
name: entry.call.name,
|
|
tool_call_id: entry.call.id!,
|
|
})
|
|
);
|
|
postToolBatchEntryByCallId.set(entry.call.id!, {
|
|
toolName: entry.call.name,
|
|
toolInput: entry.args,
|
|
toolUseId: entry.call.id!,
|
|
stepId: entry.stepId,
|
|
/**
|
|
* Records the pre-invocation turn count — the same value the
|
|
* executed path captures before incrementing `toolUsageCount`.
|
|
* For a blocked tool the counter is never incremented (no
|
|
* invocation happened), so this is always the count of prior
|
|
* successful invocations of this tool name in earlier batches.
|
|
* Surfaces in the `PostToolBatch` entry so batch hooks see
|
|
* a uniform shape regardless of outcome.
|
|
*/
|
|
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
status: 'error',
|
|
error: contentString,
|
|
});
|
|
deferredBlockedSideEffects.push({
|
|
callId: entry.call.id!,
|
|
toolName: entry.call.name,
|
|
args: entry.args,
|
|
contentString,
|
|
reason,
|
|
});
|
|
/**
|
|
* A prestarted eager execution for a now-blocked call must neither be
|
|
* consumed nor emit its completion — the run reports this call as
|
|
* blocked. Deleting the record makes `dispatchEagerToolCompletions`'
|
|
* map-identity check skip the pending emission (same pattern as the
|
|
* rejected-results cleanup below).
|
|
*/
|
|
this.eagerEventToolExecutions?.delete(entry.call.id!);
|
|
};
|
|
|
|
const flushDeferredBlockedSideEffects = async (): Promise<void> => {
|
|
for (const item of deferredBlockedSideEffects) {
|
|
await this.dispatchStepCompleted(
|
|
item.callId,
|
|
item.toolName,
|
|
item.args,
|
|
item.contentString,
|
|
config
|
|
);
|
|
if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
|
|
executeHooks({
|
|
registry: hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PermissionDenied',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName: item.toolName,
|
|
toolInput: item.args,
|
|
toolUseId: item.callId,
|
|
reason: item.reason,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: item.toolName,
|
|
}).catch(() => {
|
|
/* PermissionDenied is observational — swallow errors */
|
|
});
|
|
}
|
|
}
|
|
deferredBlockedSideEffects.length = 0;
|
|
};
|
|
|
|
/**
|
|
* Apply a hook-supplied or host-supplied input override to a pending
|
|
* entry, re-running the `{{tool<i>turn<n>}}` resolver so any new
|
|
* placeholders introduced by the override are substituted (and any
|
|
* formerly-unresolved refs cleared from the unresolved set).
|
|
*
|
|
* Mixed direct+event batches must use the pre-batch snapshot so a
|
|
* hook-introduced placeholder cannot accidentally resolve to a
|
|
* same-turn direct output that has just registered. Pure event
|
|
* batches don't have a snapshot and resolve against the live
|
|
* registry — safe because no event-side registrations have happened
|
|
* yet.
|
|
*/
|
|
const applyInputOverride = (
|
|
entry: PendingEntry,
|
|
nextArgs: Record<string, unknown>
|
|
): void => {
|
|
if (registry != null) {
|
|
const view: ToolOutputResolveView = preBatchSnapshot ?? {
|
|
resolve: <T>(args: T) => registry.resolve(registryRunId, args),
|
|
};
|
|
const { resolved, unresolved } = view.resolve(nextArgs);
|
|
entry.args = resolved as Record<string, unknown>;
|
|
if (entry.call.id != null) {
|
|
if (unresolved.length > 0) {
|
|
unresolvedByCallId.set(entry.call.id, unresolved);
|
|
} else {
|
|
unresolvedByCallId.delete(entry.call.id);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
entry.args = nextArgs;
|
|
};
|
|
|
|
const askEntries: Array<{
|
|
entry: PendingEntry;
|
|
reason?: string;
|
|
allowedDecisions?: ReadonlyArray<
|
|
'approve' | 'reject' | 'edit' | 'respond'
|
|
>;
|
|
}> = [];
|
|
|
|
for (let i = 0; i < preToolCalls.length; i++) {
|
|
const hookResult = preResults[i];
|
|
const entry = preToolCalls[i];
|
|
|
|
for (const ctx of hookResult.additionalContexts) {
|
|
batchAdditionalContexts.push(ctx);
|
|
}
|
|
|
|
if (hookResult.decision === 'deny') {
|
|
blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
|
|
continue;
|
|
}
|
|
|
|
if (hookResult.decision === 'ask') {
|
|
/**
|
|
* HITL is OFF by default — hosts must explicitly opt in via
|
|
* `humanInTheLoop: { enabled: true }` to engage the
|
|
* `interrupt()` path. When opted out (or omitted), `ask`
|
|
* collapses into the pre-HITL fail-closed path: a blocked
|
|
* tool with an error `ToolMessage`. The default stays
|
|
* conservative until host UIs are ready to render
|
|
* `tool_approval` interrupts; see `HumanInTheLoopConfig`
|
|
* JSDoc for the full rationale and the migration plan.
|
|
*/
|
|
if (this.humanInTheLoop?.enabled !== true) {
|
|
blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
|
|
continue;
|
|
}
|
|
/**
|
|
* Apply `updatedInput` BEFORE queuing into `askEntries` —
|
|
* a hook is allowed to return both a sanitization rewrite
|
|
* and an `ask` decision (e.g. one matcher redacts secrets,
|
|
* another matcher requires approval). Without this, the
|
|
* interrupt payload would surface the original args to the
|
|
* reviewer AND the post-approve execution would run with
|
|
* the original args, silently dropping the hook's rewrite.
|
|
*/
|
|
if (hookResult.updatedInput != null) {
|
|
applyInputOverride(entry, hookResult.updatedInput);
|
|
}
|
|
askEntries.push({
|
|
entry,
|
|
reason: hookResult.reason,
|
|
allowedDecisions: hookResult.allowedDecisions,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (hookResult.updatedInput != null) {
|
|
applyInputOverride(entry, hookResult.updatedInput);
|
|
}
|
|
if (entry.call.id != null) {
|
|
const preempted = preemptedEagerRecords.get(entry.call.id);
|
|
if (preempted != null) {
|
|
this.eagerEventToolExecutions?.set(entry.call.id, preempted);
|
|
}
|
|
}
|
|
approvedEntries.push(entry);
|
|
}
|
|
|
|
/**
|
|
* If any entries asked for approval, raise a single LangGraph
|
|
* `interrupt()` carrying every pending request together. The host
|
|
* pauses, gathers human input, and resumes the run with one
|
|
* decision per request. On resume LangGraph re-executes this node
|
|
* from the start; `interrupt()` then returns the resume value
|
|
* instead of throwing, so the loop above re-runs and the same
|
|
* `askEntries` list is rebuilt deterministically (assuming hooks
|
|
* are pure — see `humanInTheLoop` docs).
|
|
*/
|
|
if (askEntries.length > 0) {
|
|
const payload = buildToolApprovalInterruptPayload(askEntries);
|
|
|
|
/**
|
|
* `interrupt()` reads the current `RunnableConfig` from
|
|
* AsyncLocalStorage. ToolNode usually runs with tracing disabled
|
|
* (unless Langfuse explicitly enables it), so the upstream
|
|
* `runWithConfig` frame may not exist. Re-anchor here using the
|
|
* node's own `config` — Pregel hands us a config that already
|
|
* carries every checkpoint/scratchpad key `interrupt()` needs to
|
|
* suspend and resume.
|
|
*/
|
|
const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
|
|
config,
|
|
() =>
|
|
interrupt<
|
|
t.ToolApprovalInterruptPayload,
|
|
t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
|
|
>(payload)
|
|
);
|
|
|
|
const decisionByCallId = normalizeApprovalDecisions(
|
|
askEntries.map(({ entry }) => entry.call.id!),
|
|
resumeValue
|
|
);
|
|
|
|
for (const {
|
|
entry,
|
|
reason: askReason,
|
|
allowedDecisions,
|
|
} of askEntries) {
|
|
const decision = decisionByCallId.get(entry.call.id!) ?? {
|
|
type: 'reject' as const,
|
|
reason: 'No decision provided for tool approval',
|
|
};
|
|
/**
|
|
* Read `decision.type` through a widened view once: hosts
|
|
* deserialize resume payloads from untyped JSON, so the
|
|
* runtime value can be a typo, the wrong type, or missing
|
|
* entirely. Both the `allowedDecisions` enforcement
|
|
* immediately below and the unknown-type fallthrough at the
|
|
* end of this loop body share this single read so the
|
|
* fail-closed checks compare against the same source.
|
|
*/
|
|
const declaredType = (decision as { type?: unknown }).type;
|
|
|
|
/**
|
|
* Enforce the per-tool `allowedDecisions` allowlist that the
|
|
* `PreToolUse` hook surfaced in `review_configs`. The host
|
|
* UI is supposed to honor this when collecting the user's
|
|
* decision, but the wire is untrusted: a buggy or hostile
|
|
* host could submit a decision type the policy explicitly
|
|
* forbids (e.g. `'edit'` when the hook restricted to
|
|
* `['approve', 'reject']`), bypassing argument-mutation /
|
|
* response-substitution safeguards. Fail closed when the
|
|
* declared type isn't in the allowlist.
|
|
*/
|
|
if (
|
|
allowedDecisions != null &&
|
|
(typeof declaredType !== 'string' ||
|
|
!allowedDecisions.includes(
|
|
declaredType as t.ToolApprovalDecisionType
|
|
))
|
|
) {
|
|
const offered =
|
|
typeof declaredType === 'string' ? declaredType : '<missing>';
|
|
blockEntry(
|
|
entry,
|
|
`Decision "${offered}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (decision.type === 'reject') {
|
|
blockEntry(
|
|
entry,
|
|
decision.reason ?? askReason ?? 'Rejected by user'
|
|
);
|
|
continue;
|
|
}
|
|
|
|
/**
|
|
* `respond` short-circuits tool execution: the human supplies
|
|
* the result the model should see in place of running the
|
|
* tool. We emit a successful `ToolMessage` directly and skip
|
|
* dispatch — no host event fires, no real tool side effect
|
|
* occurs. Mirrors LangChain HITL middleware semantics.
|
|
*/
|
|
if (decision.type === 'respond') {
|
|
/**
|
|
* Validate the wire shape before touching it: hosts
|
|
* deserialize resume payloads from untyped JSON, so a
|
|
* malformed `{ type: 'respond' }` (no `responseText`) or
|
|
* `{ type: 'respond', responseText: 42 }` would crash
|
|
* `truncateToolResultContent` (which calls
|
|
* `content.length`) and turn a fail-closed approval path
|
|
* into a hard run failure. Route bad shapes through
|
|
* `blockEntry` like any other unusable decision.
|
|
*/
|
|
const responseText = (decision as { responseText?: unknown })
|
|
.responseText;
|
|
if (typeof responseText !== 'string') {
|
|
blockEntry(
|
|
entry,
|
|
`Decision "respond" missing string responseText (got ${describeOfferedShape(responseText)}) — failing closed`
|
|
);
|
|
continue;
|
|
}
|
|
/**
|
|
* Truncate the human-supplied text just like the success
|
|
* path does for real tool output. Without this, a user
|
|
* pasting a large document as a manual response bypasses
|
|
* `maxToolResultChars` and can blow past the model's
|
|
* context window. The PostToolBatch entry surfaces the
|
|
* truncated text too so batch hooks see what the model
|
|
* will actually see.
|
|
*/
|
|
const truncatedResponse = truncateToolResultContent(
|
|
responseText,
|
|
this.maxToolResultChars
|
|
);
|
|
messageByCallId.set(
|
|
entry.call.id!,
|
|
new ToolMessage({
|
|
status: 'success',
|
|
content: truncatedResponse,
|
|
name: entry.call.name,
|
|
tool_call_id: entry.call.id!,
|
|
})
|
|
);
|
|
postToolBatchEntryByCallId.set(entry.call.id!, {
|
|
toolName: entry.call.name,
|
|
toolInput: entry.args,
|
|
toolUseId: entry.call.id!,
|
|
stepId: entry.stepId,
|
|
turn: this.toolUsageCount.get(entry.call.name) ?? 0,
|
|
status: 'success',
|
|
toolOutput: truncatedResponse,
|
|
});
|
|
/**
|
|
* Safe to dispatch immediately — unlike `blockEntry` which
|
|
* defers, `respond` only executes inside the decision-
|
|
* processing loop, which is reachable only AFTER
|
|
* `interrupt()` has returned (the resume pass). There is
|
|
* no risk of being rolled back by a subsequent throw, so
|
|
* no risk of a duplicate `ON_RUN_STEP_COMPLETED` event.
|
|
*/
|
|
await this.dispatchStepCompleted(
|
|
entry.call.id!,
|
|
entry.call.name,
|
|
entry.args,
|
|
truncatedResponse,
|
|
config
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (decision.type === 'edit') {
|
|
/**
|
|
* Validate the wire shape before touching it: hosts
|
|
* deserialize resume payloads from untyped JSON, so a
|
|
* malformed `{ type: 'edit' }` (no `updatedInput`),
|
|
* `{ type: 'edit', updatedInput: 'string' }` (non-object),
|
|
* or `{ type: 'edit', updatedInput: [...] }` (array, not a
|
|
* plain object) would feed garbage into
|
|
* `applyInputOverride` and silently approve a tool with
|
|
* undefined / wrong-shape args. Same trust boundary as
|
|
* the `respond` validation above — fail closed via
|
|
* `blockEntry` with a diagnostic.
|
|
*/
|
|
const updatedInput = (decision as { updatedInput?: unknown })
|
|
.updatedInput;
|
|
if (
|
|
updatedInput === null ||
|
|
typeof updatedInput !== 'object' ||
|
|
Array.isArray(updatedInput)
|
|
) {
|
|
blockEntry(
|
|
entry,
|
|
`Decision "edit" missing object updatedInput (got ${describeOfferedShape(updatedInput)}) — failing closed`
|
|
);
|
|
continue;
|
|
}
|
|
applyInputOverride(entry, updatedInput as Record<string, unknown>);
|
|
approvedEntries.push(entry);
|
|
continue;
|
|
}
|
|
|
|
/**
|
|
* Defensive type widening: hosts deserialize resume payloads
|
|
* from untyped JSON, so the `decision.type` value at runtime
|
|
* is whatever string the wire sent — not necessarily one of
|
|
* the four union variants TS knows about. We compare against
|
|
* the literal `'approve'` through the widened `declaredType`
|
|
* captured at the top of this iteration, so a typo or schema
|
|
* drift (`'aproved'`, `null`, `undefined`) hits the fail-
|
|
* closed branch below instead of silently approving the
|
|
* tool. Without this widening, TS narrows the union after
|
|
* the three earlier branches and treats `=== 'approve'` as
|
|
* trivially true.
|
|
*/
|
|
if (declaredType === 'approve') {
|
|
approvedEntries.push(entry);
|
|
continue;
|
|
}
|
|
|
|
/**
|
|
* Unknown / missing decision type — fail closed. The whole
|
|
* point of an approval gate is that "no decision" or
|
|
* "garbled decision" deny by default.
|
|
*/
|
|
const unknownType =
|
|
typeof declaredType === 'string' ? declaredType : '<missing>';
|
|
blockEntry(
|
|
entry,
|
|
`Unknown approval decision type "${unknownType}" — failing closed`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush deferred denial side effects exactly once. On the FIRST
|
|
* pass through a batch that contains an `ask`, `interrupt()`
|
|
* threw above and we never reach this line — so no
|
|
* `ON_RUN_STEP_COMPLETED` / `PermissionDenied` events fire
|
|
* for blocked tools yet. On resume the node re-executes from
|
|
* scratch, `blockEntry` re-queues the same entries, and the
|
|
* flush below dispatches them once. For batches without any
|
|
* `ask` (deny-only or empty), the flush still runs here and
|
|
* dispatches in the same relative position as the pre-deferral
|
|
* code did (after hook processing, before tool execution).
|
|
*/
|
|
await flushDeferredBlockedSideEffects();
|
|
} else {
|
|
approvedEntries.push(...preToolCalls);
|
|
}
|
|
|
|
const injected: BaseMessage[] = [];
|
|
|
|
const batchIndexByCallId = new Map<string, number>();
|
|
|
|
if (approvedEntries.length > 0) {
|
|
const plan = buildToolExecutionRequestPlan({
|
|
toolCalls: approvedEntries.map((entry) => {
|
|
const codeSessionContext =
|
|
this.participatesInCodeSession(entry.call.name) ||
|
|
entry.call.name === Constants.SKILL_TOOL ||
|
|
entry.call.name === Constants.READ_FILE
|
|
? this.getCodeSessionContext()
|
|
: undefined;
|
|
const runtimeSessionHint = this.participatesInCodeSession(
|
|
entry.call.name
|
|
)
|
|
? this.resolveRuntimeSessionHint(config)
|
|
: undefined;
|
|
return {
|
|
id: entry.call.id,
|
|
name: entry.call.name,
|
|
args: entry.args,
|
|
stepId: entry.stepId,
|
|
codeSessionContext,
|
|
runtimeSessionHint,
|
|
};
|
|
}),
|
|
usageCount: this.toolUsageCount,
|
|
invalidArgsBehavior: 'error-result',
|
|
recordTurn: (toolName, reservedTurn, callId) => {
|
|
this.recordEventToolPlanningTurn(
|
|
toolName,
|
|
reservedTurn,
|
|
callId,
|
|
runId
|
|
);
|
|
},
|
|
});
|
|
if (plan == null) {
|
|
throw new Error('Unable to build event tool execution request plan');
|
|
}
|
|
const requests = plan.requests;
|
|
|
|
for (const entry of approvedEntries) {
|
|
if (entry.batchIndex != null && entry.call.id != null) {
|
|
batchIndexByCallId.set(entry.call.id, entry.batchIndex);
|
|
}
|
|
}
|
|
|
|
for (const result of plan.rejectedResults) {
|
|
this.eagerEventToolExecutions?.delete(result.toolCallId);
|
|
}
|
|
|
|
const requestMap = new Map(plan.allRequests.map((r) => [r.id, r]));
|
|
const eagerExecutions: Array<{
|
|
request: t.ToolCallRequest;
|
|
execution: t.EagerEventToolExecution;
|
|
}> = [];
|
|
const dispatchRequests: t.ToolCallRequest[] = [];
|
|
|
|
for (const request of requests) {
|
|
const eagerExecution = this.takeMatchingEagerEventExecution(request);
|
|
if (eagerExecution != null) {
|
|
eagerExecutions.push({ request, execution: eagerExecution });
|
|
} else {
|
|
dispatchRequests.push(request);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-call completion fast-path: when the host reports a result
|
|
* through `onResult` before the batch resolves, emit that call's
|
|
* completed run step immediately instead of waiting for the slowest
|
|
* call in the batch. Safe only when nothing can change the result
|
|
* after execution — post-tool hooks may rewrite output and HITL may
|
|
* deny a call, so those configurations keep batch-time emission.
|
|
* Ids are claimed synchronously before the async dispatch and
|
|
* released if the dispatch fails, letting the batch path re-emit.
|
|
*/
|
|
const canEmitEarlyCompletions =
|
|
this.hookRegistry?.hasResultAlteringHooks(runId) !== true &&
|
|
this.humanInTheLoop?.enabled !== true;
|
|
/**
|
|
* Snapshot the post-hook gates at the same instant as the early-emission
|
|
* gate: a result-altering hook registered while this batch is in flight
|
|
* applies from the NEXT batch. Evaluating these after results settle
|
|
* would let a late hook rewrite the ToolMessage AFTER an early
|
|
* completion already emitted the pre-hook output — two views of the
|
|
* same call.
|
|
*/
|
|
const hasPostHook =
|
|
this.hookRegistry?.hasHookFor('PostToolUse', runId) === true;
|
|
const hasFailureHook =
|
|
this.hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
|
|
const earlyCompletionDispatchedIds = new Set<string>();
|
|
const earlyCompletionDispatches: Array<Promise<void>> = [];
|
|
const dispatchRequestById = new Map(
|
|
dispatchRequests.map((request) => [request.id, request])
|
|
);
|
|
const onResult = (result: t.ToolExecuteResult): void => {
|
|
const request =
|
|
result.toolCallId != null
|
|
? dispatchRequestById.get(result.toolCallId)
|
|
: undefined;
|
|
if (
|
|
request == null ||
|
|
earlyCompletionDispatchedIds.has(result.toolCallId)
|
|
) {
|
|
return;
|
|
}
|
|
earlyCompletionDispatchedIds.add(result.toolCallId);
|
|
earlyCompletionDispatches.push(
|
|
this.dispatchEarlyToolCompletion(result, request, config).then(
|
|
(dispatched) => {
|
|
if (!dispatched) {
|
|
earlyCompletionDispatchedIds.delete(result.toolCallId);
|
|
}
|
|
},
|
|
() => {
|
|
earlyCompletionDispatchedIds.delete(result.toolCallId);
|
|
}
|
|
)
|
|
);
|
|
};
|
|
|
|
const dispatchPromise =
|
|
dispatchRequests.length === 0
|
|
? Promise.resolve([] as t.ToolExecuteResult[])
|
|
: new Promise<t.ToolExecuteResult[]>((resolve, reject) => {
|
|
let dispatchSettled = false;
|
|
let resultSettled = false;
|
|
let settledResults: t.ToolExecuteResult[] | undefined;
|
|
|
|
const maybeResolve = (): void => {
|
|
if (dispatchSettled && resultSettled) {
|
|
resolve(settledResults ?? []);
|
|
}
|
|
};
|
|
|
|
const batchRequest: t.ToolExecuteBatchRequest = {
|
|
toolCalls: dispatchRequests,
|
|
userId: config.configurable?.user_id as string | undefined,
|
|
// Dispatch attribution, NOT the hook subagent-scope marker:
|
|
// hosts key tool/credential lookup on the owning agent, and
|
|
// the eager path sends `agentContext.agentId` — this must
|
|
// match it at the top level too.
|
|
agentId: this.executingAgentId,
|
|
configurable: config.configurable as
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
metadata: config.metadata as
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
resolve: (results): void => {
|
|
resultSettled = true;
|
|
settledResults = results;
|
|
maybeResolve();
|
|
},
|
|
reject,
|
|
...(canEmitEarlyCompletions && { onResult }),
|
|
};
|
|
|
|
void safeDispatchCustomEvent(
|
|
GraphEvents.ON_TOOL_EXECUTE,
|
|
batchRequest,
|
|
config
|
|
)
|
|
.then(() => {
|
|
dispatchSettled = true;
|
|
maybeResolve();
|
|
})
|
|
.catch(reject);
|
|
});
|
|
|
|
const eagerResultsPromise = Promise.all(
|
|
eagerExecutions.map(async ({ request, execution }) => {
|
|
const results = await this.resolveEagerEventExecution(
|
|
request,
|
|
execution
|
|
);
|
|
return {
|
|
results,
|
|
completionDispatched:
|
|
execution.completionDispatched === true &&
|
|
execution.request.turn === request.turn,
|
|
toolCallId: request.id,
|
|
};
|
|
})
|
|
);
|
|
|
|
const [eagerResults, dispatchedResults] = await Promise.all([
|
|
eagerResultsPromise,
|
|
dispatchPromise,
|
|
]);
|
|
// Settle in-flight early completion dispatches before the batch loop
|
|
// below decides which completions still need emitting.
|
|
await Promise.allSettled(earlyCompletionDispatches);
|
|
const eagerCompletionDispatchedIds = new Set(
|
|
eagerResults
|
|
.filter((result) => result.completionDispatched)
|
|
.map((result) => result.toolCallId)
|
|
);
|
|
const flattenedEagerResults = eagerResults.flatMap(
|
|
(result) => result.results
|
|
);
|
|
const results = [
|
|
...plan.rejectedResults,
|
|
...flattenedEagerResults,
|
|
...dispatchedResults,
|
|
];
|
|
|
|
this.storeCodeSessionFromResults(results, requestMap);
|
|
|
|
for (const result of results) {
|
|
if (result.injectedMessages && result.injectedMessages.length > 0) {
|
|
try {
|
|
injected.push(
|
|
...this.convertInjectedMessages(result.injectedMessages)
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[ToolNode] Failed to convert injectedMessages for toolCallId=${result.toolCallId}:`,
|
|
e instanceof Error ? e.message : e
|
|
);
|
|
}
|
|
}
|
|
const request = requestMap.get(result.toolCallId);
|
|
const toolName = request?.name ?? 'unknown';
|
|
|
|
let contentString: string;
|
|
let toolMessage: ToolMessage;
|
|
/**
|
|
* Tracks the post-PostToolUse-hook output so the
|
|
* `PostToolBatch` entry below sees the final transformed value
|
|
* even when a hook replaced the original via `updatedOutput`.
|
|
* Lives at the loop-iteration scope so the success branch can
|
|
* mutate it; the error branch leaves it unset (and the batch
|
|
* entry uses `error` instead of `toolOutput` in that case).
|
|
*/
|
|
let finalToolOutput: unknown = result.content;
|
|
|
|
if (result.status === 'error') {
|
|
contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
|
|
/**
|
|
* Error results bypass registration but stamp the
|
|
* unresolved-refs hint into `additional_kwargs` so the lazy
|
|
* annotation transform surfaces it to the LLM at request
|
|
* time, letting the model self-correct when its reference
|
|
* key caused the failure. Persisted `content` stays clean.
|
|
*/
|
|
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
const errorRefMeta =
|
|
unresolved.length > 0
|
|
? this.recordOutputReference(
|
|
registryRunId,
|
|
contentString,
|
|
undefined,
|
|
unresolved
|
|
)
|
|
: undefined;
|
|
toolMessage = new ToolMessage({
|
|
status: 'error',
|
|
content: contentString,
|
|
name: toolName,
|
|
tool_call_id: result.toolCallId,
|
|
...(errorRefMeta != null && {
|
|
additional_kwargs: errorRefMeta as Record<string, unknown>,
|
|
}),
|
|
});
|
|
|
|
if (hasFailureHook) {
|
|
const failureHookResult = await executeHooks({
|
|
registry: this.hookRegistry!,
|
|
input: {
|
|
hook_event_name: 'PostToolUseFailure',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName,
|
|
toolInput: request?.args ?? {},
|
|
toolUseId: result.toolCallId,
|
|
error: result.errorMessage ?? 'Unknown error',
|
|
stepId: request?.stepId,
|
|
turn: request?.turn,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: toolName,
|
|
}).catch((): undefined => undefined);
|
|
/**
|
|
* Collect `additionalContext` from failure hooks too. Without
|
|
* this, recovery guidance returned on tool errors (e.g.
|
|
* "if this tool errors with X, suggest Y to the user") is
|
|
* silently dropped even though the API surface advertises
|
|
* `additionalContext` for this event. PostToolUseFailure
|
|
* remains observational for errors thrown by the hook
|
|
* itself, but a successfully-returned result is honored.
|
|
*/
|
|
if (failureHookResult != null) {
|
|
for (const ctx of failureHookResult.additionalContexts) {
|
|
batchAdditionalContexts.push(ctx);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let registryRaw =
|
|
typeof result.content === 'string'
|
|
? result.content
|
|
: JSON.stringify(result.content);
|
|
contentString = truncateToolResultContent(
|
|
registryRaw,
|
|
this.maxToolResultChars
|
|
);
|
|
|
|
if (hasPostHook) {
|
|
const hookResult = await executeHooks({
|
|
registry: this.hookRegistry!,
|
|
input: {
|
|
hook_event_name: 'PostToolUse',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
toolName,
|
|
toolInput: request?.args ?? {},
|
|
toolOutput: result.content,
|
|
toolUseId: result.toolCallId,
|
|
stepId: request?.stepId,
|
|
turn: request?.turn,
|
|
},
|
|
sessionId: runId,
|
|
matchQuery: toolName,
|
|
}).catch((): undefined => undefined);
|
|
if (hookResult != null) {
|
|
for (const ctx of hookResult.additionalContexts) {
|
|
batchAdditionalContexts.push(ctx);
|
|
}
|
|
}
|
|
if (hookResult?.updatedOutput != null) {
|
|
const replaced =
|
|
typeof hookResult.updatedOutput === 'string'
|
|
? hookResult.updatedOutput
|
|
: JSON.stringify(hookResult.updatedOutput);
|
|
registryRaw = replaced;
|
|
contentString = truncateToolResultContent(
|
|
replaced,
|
|
this.maxToolResultChars
|
|
);
|
|
finalToolOutput = hookResult.updatedOutput;
|
|
/**
|
|
* The hook ACTUALLY rewrote this output: any completion the
|
|
* stream already emitted for the consumed eager result showed
|
|
* the raw content, so un-mark it and let the dispatch below
|
|
* re-emit the corrected version. Hooks that merely observe or
|
|
* add context leave the original emission final — no
|
|
* duplicates.
|
|
*/
|
|
eagerCompletionDispatchedIds.delete(result.toolCallId);
|
|
}
|
|
}
|
|
|
|
const batchIndex = batchIndexByCallId.get(result.toolCallId);
|
|
const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
|
|
const refKey =
|
|
this.toolOutputRegistry != null &&
|
|
batchIndex != null &&
|
|
turn != null
|
|
? buildReferenceKey(batchIndex, turn)
|
|
: undefined;
|
|
const successRefMeta = this.recordOutputReference(
|
|
registryRunId,
|
|
stripCodeSessionFileSummary(registryRaw),
|
|
refKey,
|
|
unresolved
|
|
);
|
|
|
|
toolMessage = new ToolMessage({
|
|
status: 'success',
|
|
name: toolName,
|
|
content: contentString,
|
|
artifact: result.artifact,
|
|
tool_call_id: result.toolCallId,
|
|
...(successRefMeta != null && {
|
|
additional_kwargs: successRefMeta as Record<string, unknown>,
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (
|
|
!eagerCompletionDispatchedIds.has(result.toolCallId) &&
|
|
!earlyCompletionDispatchedIds.has(result.toolCallId)
|
|
) {
|
|
await this.dispatchStepCompleted(
|
|
result.toolCallId,
|
|
toolName,
|
|
request?.args ?? {},
|
|
contentString,
|
|
config,
|
|
request?.turn
|
|
);
|
|
}
|
|
|
|
postToolBatchEntryByCallId.set(result.toolCallId, {
|
|
toolName,
|
|
toolInput: request?.args ?? {},
|
|
toolUseId: result.toolCallId,
|
|
stepId: request?.stepId,
|
|
turn: request?.turn,
|
|
status: result.status === 'error' ? 'error' : 'success',
|
|
...(result.status === 'error'
|
|
? { error: result.errorMessage ?? 'Unknown error' }
|
|
: { toolOutput: finalToolOutput }),
|
|
});
|
|
|
|
messageByCallId.set(result.toolCallId, toolMessage);
|
|
}
|
|
}
|
|
|
|
const toolMessages = toolCalls
|
|
.map((call) => messageByCallId.get(call.id!))
|
|
.filter((m): m is ToolMessage => m != null);
|
|
|
|
await this.dispatchPostToolBatchAndInjectContext({
|
|
toolCalls,
|
|
entriesByCallId: postToolBatchEntryByCallId,
|
|
batchAdditionalContexts,
|
|
injected,
|
|
runId,
|
|
threadId,
|
|
});
|
|
|
|
return { toolMessages, injected };
|
|
}
|
|
|
|
/** Run-scoped so another run's session hooks can't flip this run's gate. */
|
|
private canConsumeEagerEventExecution(runId?: string): boolean {
|
|
return (
|
|
this.eventDrivenMode &&
|
|
this.eagerEventToolExecution?.enabled === true &&
|
|
this.hookRegistry?.hasResultAlteringHooks(runId) !== true &&
|
|
this.humanInTheLoop?.enabled !== true
|
|
);
|
|
}
|
|
|
|
private takeMatchingEagerEventExecution(
|
|
request: t.ToolCallRequest
|
|
): t.EagerEventToolExecution | undefined {
|
|
// Static enablement only: a stored record means the reservation-time
|
|
// gates passed and the host already dispatched the execution. Re-checking
|
|
// the dynamic hook gate here could decline consumption after a mid-run
|
|
// registration and send the same call through normal dispatch — executing
|
|
// the tool twice. PostToolUse hooks still process consumed results below.
|
|
if (
|
|
!this.eventDrivenMode ||
|
|
this.eagerEventToolExecution?.enabled !== true
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const execution = this.eagerEventToolExecutions?.get(request.id);
|
|
if (execution == null) {
|
|
return undefined;
|
|
}
|
|
|
|
this.eagerEventToolExecutions?.delete(request.id);
|
|
|
|
// Only tool identity + canonical args define side-effect identity here.
|
|
// `request.turn` is final-planning metadata; if it drifts between the
|
|
// streamed eager reservation and model-end materialization, consume the
|
|
// same-name/same-args eager result and let the final request drive refs,
|
|
// completion metadata, and PostToolBatch state.
|
|
if (
|
|
execution.toolName !== request.name ||
|
|
!recordArgsEqual(execution.args, request.args)
|
|
) {
|
|
return {
|
|
toolCallId: request.id,
|
|
toolName: request.name,
|
|
args: request.args,
|
|
request,
|
|
promise: Promise.resolve({
|
|
results: [
|
|
{
|
|
toolCallId: request.id,
|
|
status: 'error',
|
|
content: '',
|
|
errorMessage:
|
|
'Tool call changed after eager execution started; refusing to re-run the tool to avoid duplicate side effects.',
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
|
|
return execution;
|
|
}
|
|
|
|
private async resolveEagerEventExecution(
|
|
request: t.ToolCallRequest,
|
|
execution: t.EagerEventToolExecution
|
|
): Promise<t.ToolExecuteResult[]> {
|
|
const outcome = await execution.promise;
|
|
if (outcome.error != null) {
|
|
throw outcome.error;
|
|
}
|
|
|
|
const results = outcome.results.filter(
|
|
(result) => result.toolCallId === request.id
|
|
);
|
|
if (results.length > 0) {
|
|
return results;
|
|
}
|
|
|
|
return [
|
|
{
|
|
toolCallId: request.id,
|
|
status: 'error',
|
|
content: '',
|
|
errorMessage:
|
|
'Tool execution completed without a result for this tool call',
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fires the `PostToolBatch` hook (if registered) and appends the
|
|
* accumulated batch-level `additionalContext` strings to `injected`
|
|
* as a single `HumanMessage`. Entries are materialized in the
|
|
* original `toolCalls` order so hooks correlating outcomes by
|
|
* position (as the type docs promise) see exactly the sequence
|
|
* the model emitted, regardless of when each individual outcome
|
|
* was recorded into the map (deny synchronous, approved
|
|
* post-execution, respond on resume).
|
|
*
|
|
* The PostToolBatch hook's `additionalContexts` flow into the same
|
|
* batch accumulator per-tool hooks already use, so a single
|
|
* batch-level convention message can be injected through one path.
|
|
*
|
|
* Mutates `batchAdditionalContexts` (push from batch hook) and
|
|
* `injected` (push the consolidated HumanMessage). The caller owns
|
|
* those arrays and consumes them right after this returns.
|
|
*/
|
|
private async dispatchPostToolBatchAndInjectContext(args: {
|
|
toolCalls: ToolCall[];
|
|
entriesByCallId: Map<string, PostToolBatchEntry>;
|
|
batchAdditionalContexts: string[];
|
|
injected: BaseMessage[];
|
|
runId: string;
|
|
threadId: string | undefined;
|
|
}): Promise<void> {
|
|
const {
|
|
toolCalls,
|
|
entriesByCallId,
|
|
batchAdditionalContexts,
|
|
injected,
|
|
runId,
|
|
threadId,
|
|
} = args;
|
|
|
|
const orderedBatchEntries: PostToolBatchEntry[] = [];
|
|
for (const call of toolCalls) {
|
|
const callId = call.id;
|
|
if (callId == null) {
|
|
continue;
|
|
}
|
|
const entry = entriesByCallId.get(callId);
|
|
if (entry != null) {
|
|
orderedBatchEntries.push(entry);
|
|
}
|
|
}
|
|
const hookInjectedMessages: t.InjectedMessage[] = [];
|
|
if (
|
|
this.hookRegistry?.hasHookFor('PostToolBatch', runId) === true &&
|
|
orderedBatchEntries.length > 0
|
|
) {
|
|
const batchHookResult = await executeHooks({
|
|
registry: this.hookRegistry,
|
|
input: {
|
|
hook_event_name: 'PostToolBatch',
|
|
runId,
|
|
threadId,
|
|
agentId: this.agentId,
|
|
executingAgentId: this.executingAgentId,
|
|
entries: orderedBatchEntries,
|
|
},
|
|
sessionId: runId,
|
|
}).catch((): undefined => undefined);
|
|
if (batchHookResult != null) {
|
|
for (const ctx of batchHookResult.additionalContexts) {
|
|
batchAdditionalContexts.push(ctx);
|
|
}
|
|
for (const msg of batchHookResult.injectedMessages) {
|
|
hookInjectedMessages.push(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (batchAdditionalContexts.length > 0) {
|
|
/**
|
|
* `HumanMessage` carrying a metadata `role: 'system'` marker —
|
|
* see `convertInjectedMessages` for the wider rationale. Anthropic
|
|
* and Google reject mid-conversation `SystemMessage`s, so we use
|
|
* a user-role message and surface the system intent through
|
|
* `additional_kwargs` for hosts inspecting state. The model sees
|
|
* a user message; `role` is metadata only.
|
|
*/
|
|
injected.push(
|
|
new HumanMessage({
|
|
content: batchAdditionalContexts.join('\n\n'),
|
|
additional_kwargs: { role: 'system', source: 'hook' },
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hook-returned `injectedMessages` land AFTER the consolidated context
|
|
* message: one converted `HumanMessage` per entry (role/source kept in
|
|
* `additional_kwargs`), preserving per-message identity so verbatim
|
|
* user speech (e.g. steering) sits closest to the next model call.
|
|
*/
|
|
if (hookInjectedMessages.length > 0) {
|
|
try {
|
|
injected.push(...this.convertInjectedMessages(hookInjectedMessages));
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
'[ToolNode] Failed to convert PostToolBatch injectedMessages:',
|
|
e instanceof Error ? e.message : e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async dispatchStepCompleted(
|
|
toolCallId: string,
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
output: string,
|
|
config: RunnableConfig,
|
|
turn?: number
|
|
): Promise<boolean> {
|
|
const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
|
|
if (!stepId) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[ToolNode] toolCallStepIds missing entry for toolCallId=${toolCallId} (tool=${toolName}). ` +
|
|
'This indicates a race between the stream consumer and graph execution. ' +
|
|
`Map size: ${this.toolCallStepIds?.size ?? 0}`
|
|
);
|
|
}
|
|
|
|
const dispatched = await safeDispatchCustomEvent(
|
|
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
{
|
|
result: {
|
|
id: stepId,
|
|
index: turn ?? this.toolUsageCount.get(toolName) ?? 0,
|
|
type: 'tool_call' as const,
|
|
tool_call: {
|
|
args: JSON.stringify(args),
|
|
name: toolName,
|
|
id: toolCallId,
|
|
output,
|
|
progress: 1,
|
|
} as t.ProcessedToolCall,
|
|
},
|
|
},
|
|
config
|
|
);
|
|
return dispatched !== false;
|
|
}
|
|
|
|
/**
|
|
* Emits the completed run step for a single host-reported result before
|
|
* the batch resolves. Mirrors the batch loop's output formatting exactly;
|
|
* callers gate on the no-hooks/no-HITL configuration, so the raw result
|
|
* content here is also the final content. Returns whether the event was
|
|
* actually dispatched so the caller can fall back to batch-time emission.
|
|
*/
|
|
private async dispatchEarlyToolCompletion(
|
|
result: t.ToolExecuteResult,
|
|
request: t.ToolCallRequest,
|
|
config: RunnableConfig
|
|
): Promise<boolean> {
|
|
const output =
|
|
result.status === 'error'
|
|
? `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`
|
|
: truncateToolResultContent(
|
|
typeof result.content === 'string'
|
|
? result.content
|
|
: JSON.stringify(result.content),
|
|
this.maxToolResultChars
|
|
);
|
|
return this.dispatchStepCompleted(
|
|
result.toolCallId,
|
|
request.name,
|
|
request.args,
|
|
output,
|
|
config,
|
|
request.turn
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Converts InjectedMessage instances to LangChain HumanMessage objects.
|
|
* Both 'user' and 'system' roles become HumanMessage to avoid provider
|
|
* rejections (Anthropic/Google reject non-leading SystemMessages).
|
|
* The original role is preserved in additional_kwargs for downstream consumers.
|
|
*/
|
|
private convertInjectedMessages(
|
|
messages: t.InjectedMessage[]
|
|
): BaseMessage[] {
|
|
const converted: BaseMessage[] = [];
|
|
for (const msg of messages) {
|
|
const additional_kwargs: Record<string, unknown> = {
|
|
role: msg.role,
|
|
};
|
|
if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
|
|
if (msg.source != null) additional_kwargs.source = msg.source;
|
|
if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
|
|
|
|
converted.push(
|
|
new HumanMessage({
|
|
content: toLangChainContent(msg.content),
|
|
additional_kwargs,
|
|
})
|
|
);
|
|
}
|
|
return converted;
|
|
}
|
|
|
|
/**
|
|
* Execute a group of direct (in-process) tool calls with interrupt-safe
|
|
* ordering, returning outputs aligned 1:1 with `directCalls`.
|
|
*
|
|
* Fast path (the common case): when no call in the group is in
|
|
* `interruptingToolNames`, this is a single `Promise.all` — byte-for-byte
|
|
* the prior behavior, so ordinary batches are unaffected.
|
|
*
|
|
* Interrupt-safe path: when the group contains an interrupting tool (e.g.
|
|
* `ask_user_question`, whose body raises a LangGraph `interrupt()` to
|
|
* collect a human answer), the interrupting calls run as their own awaited
|
|
* group **first**; only after they all settle without interrupting do the
|
|
* remaining (potentially non-idempotent) siblings run. If an interrupting
|
|
* call throws a `GraphInterrupt`, the `await` below rejects and unwinds the
|
|
* whole ToolNode *before* any non-interrupting sibling has started — so a
|
|
* sibling with real side effects (send_email, billing) never executes on
|
|
* the first pass. On the resume pass LangGraph re-runs the batch from the
|
|
* top; the interrupting tool resolves with the host's answer instead of
|
|
* throwing, and the siblings execute for the FIRST time, exactly once.
|
|
*
|
|
* Without this ordering, a flat `Promise.all` starts every sibling
|
|
* concurrently, so a non-idempotent sibling can complete its side effect
|
|
* before the interrupt unwinds and then run a SECOND time on resume — the
|
|
* duplicate side effect this method exists to prevent. Interrupting tools
|
|
* are expected to be side-effect-free (they only suspend), so running them
|
|
* as a group and re-running them on resume is harmless.
|
|
*
|
|
* `batchIndices[i]` is `directCalls[i]`'s position within the parent
|
|
* ToolNode batch (used for `{{tool<i>turn<n>}}` registration); it is
|
|
* preserved regardless of execution order. `baseContext` carries the
|
|
* batch-scoped fields every call shares; `batchIndex` is filled in
|
|
* per-call here.
|
|
*/
|
|
private async runDirectBatchInterruptSafe(
|
|
directCalls: ToolCall[],
|
|
batchIndices: number[],
|
|
config: RunnableConfig,
|
|
baseContext: Omit<RunToolBatchContext<T>, 'batchIndex'>
|
|
): Promise<(BaseMessage | Command)[]> {
|
|
const runOne = (
|
|
call: ToolCall,
|
|
position: number
|
|
): Promise<BaseMessage | Command> =>
|
|
this.runDirectToolWithLifecycleHooks(call, config, {
|
|
...baseContext,
|
|
batchIndex: batchIndices[position],
|
|
});
|
|
|
|
const interrupting = this.interruptingToolNames;
|
|
const hasInterrupting =
|
|
interrupting != null &&
|
|
directCalls.some((call) => interrupting.has(call.name));
|
|
|
|
if (!hasInterrupting) {
|
|
return Promise.all(directCalls.map((call, i) => runOne(call, i)));
|
|
}
|
|
|
|
const outputs: (BaseMessage | Command)[] = new Array(directCalls.length);
|
|
const interruptingPositions: number[] = [];
|
|
const regularPositions: number[] = [];
|
|
for (let i = 0; i < directCalls.length; i++) {
|
|
if (interrupting.has(directCalls[i].name)) {
|
|
interruptingPositions.push(i);
|
|
} else {
|
|
regularPositions.push(i);
|
|
}
|
|
}
|
|
|
|
// Interrupting group first. A GraphInterrupt here propagates out of the
|
|
// `await` before any regular sibling is dispatched below.
|
|
const interruptingOutputs = await Promise.all(
|
|
interruptingPositions.map((i) => runOne(directCalls[i], i))
|
|
);
|
|
interruptingPositions.forEach((i, k) => {
|
|
outputs[i] = interruptingOutputs[k];
|
|
});
|
|
|
|
// No interrupting call suspended — safe to run the remaining siblings.
|
|
const regularOutputs = await Promise.all(
|
|
regularPositions.map((i) => runOne(directCalls[i], i))
|
|
);
|
|
regularPositions.forEach((i, k) => {
|
|
outputs[i] = regularOutputs[k];
|
|
});
|
|
|
|
return outputs;
|
|
}
|
|
|
|
/**
|
|
* Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
|
|
* Injected messages are placed AFTER ToolMessages to respect provider
|
|
* message ordering (AIMessage tool_calls must be immediately followed
|
|
* by their ToolMessage results).
|
|
*
|
|
* `batchIndices` mirrors `toolCalls` and carries each call's position
|
|
* within the parent batch. `turn` is the per-`run()` batch index
|
|
* captured locally by the caller. Both are threaded so concurrent
|
|
* invocations cannot race on shared mutable state.
|
|
*/
|
|
private async executeViaEvent(
|
|
toolCalls: ToolCall[],
|
|
config: RunnableConfig,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
input: any,
|
|
batchContext: DispatchBatchContext = {}
|
|
): Promise<T> {
|
|
const { toolMessages, injected } = await this.dispatchToolEvents(
|
|
toolCalls,
|
|
config,
|
|
batchContext
|
|
);
|
|
const outputs: BaseMessage[] = [...toolMessages, ...injected];
|
|
return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
protected async run(input: any, config: RunnableConfig): Promise<T> {
|
|
this.toolCallTurns.clear();
|
|
/**
|
|
* Per-batch local map for resolved (post-substitution) args.
|
|
* Lives on the stack so concurrent `run()` calls on the same
|
|
* ToolNode cannot read or wipe each other's entries.
|
|
*/
|
|
const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
|
|
/**
|
|
* Claim this batch's turn synchronously from the registry (or
|
|
* fall back to 0 when the feature is disabled). The registry is
|
|
* partitioned by scope id so overlapping batches cannot
|
|
* overwrite each other's state even under a shared registry.
|
|
*
|
|
* For anonymous callers (no `run_id` in config), mint a unique
|
|
* per-batch scope id so two concurrent anonymous invocations
|
|
* don't target the same bucket. The scope is threaded down to
|
|
* every subsequent registry call on this batch.
|
|
*/
|
|
const incomingRunId = config.configurable?.run_id as string | undefined;
|
|
const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
|
|
const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
|
|
let outputs: (BaseMessage | Command)[];
|
|
|
|
if (this.isSendInput(input)) {
|
|
const isLocalTool =
|
|
this.directToolNames?.has(input.lg_tool_call.name) === true ||
|
|
this.shouldHandleUnknownHandoffLocally(input.lg_tool_call.name);
|
|
if (this.eventDrivenMode && !isLocalTool) {
|
|
return this.executeViaEvent([input.lg_tool_call], config, input, {
|
|
batchIndices: [0],
|
|
turn,
|
|
batchScopeId,
|
|
});
|
|
}
|
|
// Same per-batch sink the message-state branches use so
|
|
// direct-path PreToolUse/PostToolUse/Failure additionalContexts
|
|
// surface here too. Codex P2 [44] — round 14 added the sink to
|
|
// both message-state branches but missed this Send-input
|
|
// branch, so direct tools dispatched via Send (a supported
|
|
// input shape) still silently dropped hook context.
|
|
const directAdditionalContexts: string[] = [];
|
|
// Mirror langgraph's prebuilt ToolNode: the Send-input state is
|
|
// the input minus the `lg_tool_call` envelope key.
|
|
const { lg_tool_call: _sendToolCall, ...sendState } = input;
|
|
const sendOutput = await this.runDirectToolWithLifecycleHooks(
|
|
input.lg_tool_call,
|
|
config,
|
|
{
|
|
batchIndex: 0,
|
|
turn,
|
|
batchScopeId,
|
|
resolvedArgsByCallId,
|
|
additionalContextsSink: directAdditionalContexts,
|
|
runInput: sendState as T,
|
|
}
|
|
);
|
|
outputs =
|
|
directAdditionalContexts.length > 0
|
|
? [
|
|
sendOutput,
|
|
new HumanMessage({
|
|
content: directAdditionalContexts.join('\n\n'),
|
|
// Match the event-driven path's marker so hosts /
|
|
// model-side annotators treat this as system intent
|
|
// rather than ordinary user text. Codex P2 [46].
|
|
additional_kwargs: { role: 'system', source: 'hook' },
|
|
}),
|
|
]
|
|
: [sendOutput];
|
|
await this.handleRunToolCompletions(
|
|
[input.lg_tool_call],
|
|
// Pass only the tool output to completion handling; the
|
|
// HumanMessage isn't a tool result.
|
|
[sendOutput],
|
|
config,
|
|
resolvedArgsByCallId
|
|
);
|
|
} else {
|
|
let messages: BaseMessage[];
|
|
if (Array.isArray(input)) {
|
|
messages = input;
|
|
} else if (this.isMessagesState(input)) {
|
|
messages = input.messages;
|
|
} else {
|
|
throw new Error(
|
|
'ToolNode only accepts BaseMessage[] or { messages: BaseMessage[] } as input.'
|
|
);
|
|
}
|
|
|
|
const toolMessageIds: Set<string> = new Set(
|
|
messages
|
|
.filter((msg) => msg._getType() === 'tool')
|
|
.map((msg) => (msg as ToolMessage).tool_call_id)
|
|
);
|
|
|
|
let aiMessage: AIMessage | undefined;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const message = messages[i];
|
|
if (isAIMessage(message)) {
|
|
aiMessage = message;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (aiMessage == null || !isAIMessage(aiMessage)) {
|
|
throw new Error('ToolNode only accepts AIMessages as input.');
|
|
}
|
|
|
|
if (this.loadRuntimeTools) {
|
|
const { tools, toolMap } = this.loadRuntimeTools(
|
|
aiMessage.tool_calls ?? []
|
|
);
|
|
this.toolMap =
|
|
toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
|
|
this.applyToolExecutionOverrides();
|
|
this.programmaticCache = undefined; // Invalidate cache on toolMap change
|
|
}
|
|
|
|
const filteredCalls =
|
|
aiMessage.tool_calls?.filter((call) => {
|
|
/**
|
|
* Filter out:
|
|
* 1. Already processed tool calls (present in toolMessageIds)
|
|
* 2. Server tool calls (e.g., web_search with IDs starting with 'srvtoolu_')
|
|
* which are executed by the provider's API and don't require invocation
|
|
*/
|
|
return (
|
|
(call.id == null || !toolMessageIds.has(call.id)) &&
|
|
!(
|
|
call.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
|
|
false
|
|
)
|
|
);
|
|
}) ?? [];
|
|
|
|
if (this.eventDrivenMode && filteredCalls.length > 0) {
|
|
const directToolNames = this.directToolNames;
|
|
const hasRegisteredHandoffTool = this.hasRegisteredHandoffTool();
|
|
|
|
const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
|
|
for (let i = 0; i < filteredCalls.length; i++) {
|
|
const call = filteredCalls[i];
|
|
const entry = { call, batchIndex: i };
|
|
if (
|
|
directToolNames?.has(call.name) === true ||
|
|
this.shouldHandleUnknownHandoffLocally(
|
|
call.name,
|
|
hasRegisteredHandoffTool
|
|
)
|
|
) {
|
|
directEntries.push(entry);
|
|
} else {
|
|
eventEntries.push(entry);
|
|
}
|
|
}
|
|
|
|
if (directEntries.length === 0) {
|
|
return this.executeViaEvent(filteredCalls, config, input, {
|
|
batchIndices: eventEntries.map((entry) => entry.batchIndex),
|
|
turn,
|
|
batchScopeId,
|
|
});
|
|
}
|
|
|
|
const directCalls = directEntries.map((e) => e.call);
|
|
const directIndices = directEntries.map((e) => e.batchIndex);
|
|
const eventCalls = eventEntries.map((e) => e.call);
|
|
const eventIndices = eventEntries.map((e) => e.batchIndex);
|
|
|
|
/**
|
|
* Snapshot the event calls' args against the *pre-batch*
|
|
* registry state synchronously, before any await runs. The
|
|
* directs are then awaited first (preserving fail-fast
|
|
* semantics — a thrown error in a direct tool, e.g. with
|
|
* `handleToolErrors=false` or a `GraphInterrupt`, aborts
|
|
* before we dispatch any event-driven tools to the host).
|
|
* Because the event args were captured pre-await, they stay
|
|
* isolated from same-turn direct outputs that register
|
|
* during the await.
|
|
*/
|
|
const preResolvedEventArgs = new Map<
|
|
string,
|
|
{ resolved: Record<string, unknown>; unresolved: string[] }
|
|
>();
|
|
/**
|
|
* Take a frozen snapshot of the registry state before any
|
|
* direct registrations land. The snapshot resolves
|
|
* placeholders against this point-in-time view, so a
|
|
* `PreToolUse` hook later rewriting event args via
|
|
* `updatedInput` can introduce placeholders that resolve
|
|
* cross-batch (against prior runs) without ever picking up
|
|
* same-turn direct outputs.
|
|
*/
|
|
const preBatchSnapshot =
|
|
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
if (preBatchSnapshot != null) {
|
|
for (const entry of eventEntries) {
|
|
if (entry.call.id != null) {
|
|
const { resolved, unresolved } = preBatchSnapshot.resolve(
|
|
entry.call.args as Record<string, unknown>
|
|
);
|
|
preResolvedEventArgs.set(entry.call.id, {
|
|
resolved: resolved as Record<string, unknown>,
|
|
unresolved,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-batch sink for direct-path hook additionalContexts
|
|
// (Codex P2 #39). Materialized as a HumanMessage at end-of-
|
|
// batch so the next model turn sees the injected context,
|
|
// matching the event path's `injected[]` shape.
|
|
const directAdditionalContexts: string[] = [];
|
|
const directOutputs: (BaseMessage | Command)[] =
|
|
directCalls.length > 0
|
|
? await this.runDirectBatchInterruptSafe(
|
|
directCalls,
|
|
directIndices,
|
|
config,
|
|
{
|
|
turn,
|
|
batchScopeId,
|
|
resolvedArgsByCallId,
|
|
preBatchSnapshot,
|
|
additionalContextsSink: directAdditionalContexts,
|
|
runInput: input as T,
|
|
}
|
|
)
|
|
: [];
|
|
|
|
if (directCalls.length > 0 && directOutputs.length > 0) {
|
|
await this.handleRunToolCompletions(
|
|
directCalls,
|
|
directOutputs,
|
|
config,
|
|
resolvedArgsByCallId
|
|
);
|
|
}
|
|
|
|
const eventResult =
|
|
eventCalls.length > 0
|
|
? await this.dispatchToolEvents(eventCalls, config, {
|
|
batchIndices: eventIndices,
|
|
turn,
|
|
batchScopeId,
|
|
preResolvedArgs: preResolvedEventArgs,
|
|
preBatchSnapshot,
|
|
})
|
|
: {
|
|
toolMessages: [] as ToolMessage[],
|
|
injected: [] as BaseMessage[],
|
|
};
|
|
|
|
const directInjected: BaseMessage[] =
|
|
directAdditionalContexts.length > 0
|
|
? [
|
|
new HumanMessage({
|
|
content: directAdditionalContexts.join('\n\n'),
|
|
// System-role metadata to match the event-driven
|
|
// path so policy/recovery guidance is treated
|
|
// consistently regardless of whether the tool ran
|
|
// direct or dispatched. Codex P2 [46].
|
|
additional_kwargs: { role: 'system', source: 'hook' },
|
|
}),
|
|
]
|
|
: [];
|
|
outputs = [
|
|
...directOutputs,
|
|
...eventResult.toolMessages,
|
|
...directInjected,
|
|
...eventResult.injected,
|
|
];
|
|
} else {
|
|
// Same per-batch pre-snapshot as the mixed path, applied to
|
|
// the all-direct case so `Promise.all`-induced ordering can't
|
|
// leak a sibling's just-registered output into a sister
|
|
// call's args mid-await (Codex P1 #18).
|
|
const preBatchSnapshot =
|
|
this.toolOutputRegistry?.snapshot(batchScopeId);
|
|
const directAdditionalContexts: string[] = [];
|
|
const toolOutputs = await this.runDirectBatchInterruptSafe(
|
|
filteredCalls,
|
|
filteredCalls.map((_call, i) => i),
|
|
config,
|
|
{
|
|
turn,
|
|
batchScopeId,
|
|
resolvedArgsByCallId,
|
|
preBatchSnapshot,
|
|
additionalContextsSink: directAdditionalContexts,
|
|
runInput: input as T,
|
|
}
|
|
);
|
|
await this.handleRunToolCompletions(
|
|
filteredCalls,
|
|
toolOutputs,
|
|
config,
|
|
resolvedArgsByCallId
|
|
);
|
|
// Append accumulated additionalContexts as a single
|
|
// HumanMessage so the next model turn sees them. Codex P2 #39.
|
|
outputs =
|
|
directAdditionalContexts.length > 0
|
|
? [
|
|
...toolOutputs,
|
|
new HumanMessage({
|
|
content: directAdditionalContexts.join('\n\n'),
|
|
// Same system-role marker the event-driven path
|
|
// uses so direct vs dispatched is invisible to
|
|
// downstream consumers. Codex P2 [46].
|
|
additional_kwargs: { role: 'system', source: 'hook' },
|
|
}),
|
|
]
|
|
: toolOutputs;
|
|
}
|
|
}
|
|
|
|
if (!outputs.some(isCommand)) {
|
|
return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
|
|
}
|
|
|
|
const combinedOutputs: (
|
|
| { messages: BaseMessage[] }
|
|
| BaseMessage[]
|
|
| Command
|
|
)[] = [];
|
|
let parentCommand: Command | null = null;
|
|
|
|
/**
|
|
* Collect handoff commands (Commands with string goto and Command.PARENT)
|
|
* for potential parallel handoff aggregation
|
|
*/
|
|
const handoffCommands: Command[] = [];
|
|
const nonCommandOutputs: BaseMessage[] = [];
|
|
|
|
for (const output of outputs) {
|
|
if (isCommand(output)) {
|
|
if (
|
|
output.graph === Command.PARENT &&
|
|
Array.isArray(output.goto) &&
|
|
output.goto.every((send): send is Send => isSend(send))
|
|
) {
|
|
/** Aggregate Send-based commands */
|
|
if (parentCommand) {
|
|
(parentCommand.goto as Send[]).push(...(output.goto as Send[]));
|
|
} else {
|
|
parentCommand = new Command({
|
|
graph: Command.PARENT,
|
|
goto: output.goto,
|
|
});
|
|
}
|
|
} else if (output.graph === Command.PARENT) {
|
|
/**
|
|
* Handoff Command with destination.
|
|
* Handle both string ('agent') and array (['agent']) formats.
|
|
* Collect for potential parallel aggregation.
|
|
*/
|
|
const goto = output.goto;
|
|
const isSingleStringDest = typeof goto === 'string';
|
|
const isSingleArrayDest =
|
|
Array.isArray(goto) &&
|
|
goto.length === 1 &&
|
|
typeof goto[0] === 'string';
|
|
|
|
if (isSingleStringDest || isSingleArrayDest) {
|
|
handoffCommands.push(output);
|
|
} else {
|
|
/** Multi-destination or other command - pass through */
|
|
combinedOutputs.push(output);
|
|
}
|
|
} else {
|
|
/** Other commands - pass through */
|
|
combinedOutputs.push(output);
|
|
}
|
|
} else {
|
|
nonCommandOutputs.push(output);
|
|
combinedOutputs.push(
|
|
Array.isArray(input) ? [output] : { messages: [output] }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle handoff commands - convert to Send objects for parallel execution
|
|
* when multiple handoffs are requested
|
|
*/
|
|
if (handoffCommands.length > 1) {
|
|
/**
|
|
* Multiple parallel handoffs - convert to Send objects.
|
|
* Each Send carries its own state with the appropriate messages.
|
|
* This enables LLM-initiated parallel execution when calling multiple
|
|
* transfer tools simultaneously.
|
|
*/
|
|
|
|
/** Collect all destinations for sibling tracking */
|
|
const allDestinations = handoffCommands.map((cmd) => {
|
|
const goto = cmd.goto;
|
|
return typeof goto === 'string' ? goto : (goto as string[])[0];
|
|
});
|
|
|
|
const sends = handoffCommands.map((cmd, idx) => {
|
|
const destination = allDestinations[idx];
|
|
/** Get siblings (other destinations, not this one) */
|
|
const siblings = allDestinations.filter((d) => d !== destination);
|
|
|
|
/** Add siblings to ToolMessage additional_kwargs */
|
|
const update = cmd.update as { messages?: BaseMessage[] } | undefined;
|
|
if (update && update.messages) {
|
|
for (const msg of update.messages) {
|
|
if (msg.getType() === 'tool') {
|
|
(msg as ToolMessage).additional_kwargs.handoff_parallel_siblings =
|
|
siblings;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Send(destination, cmd.update);
|
|
});
|
|
|
|
const parallelCommand = new Command({
|
|
graph: Command.PARENT,
|
|
goto: sends,
|
|
});
|
|
combinedOutputs.push(parallelCommand);
|
|
} else if (handoffCommands.length === 1) {
|
|
/** Single handoff - pass through as-is */
|
|
combinedOutputs.push(handoffCommands[0]);
|
|
}
|
|
|
|
if (parentCommand) {
|
|
combinedOutputs.push(parentCommand);
|
|
}
|
|
|
|
return combinedOutputs as T;
|
|
}
|
|
|
|
private isSendInput(input: unknown): input is { lg_tool_call: ToolCall } {
|
|
return (
|
|
typeof input === 'object' && input != null && 'lg_tool_call' in input
|
|
);
|
|
}
|
|
|
|
private isMessagesState(
|
|
input: unknown
|
|
): input is { messages: BaseMessage[] } {
|
|
return (
|
|
typeof input === 'object' &&
|
|
input != null &&
|
|
'messages' in input &&
|
|
Array.isArray((input as { messages: unknown }).messages) &&
|
|
(input as { messages: unknown[] }).messages.every(isBaseMessage)
|
|
);
|
|
}
|
|
}
|
|
|
|
function areToolCallsInvoked(
|
|
message: AIMessage,
|
|
invokedToolIds?: Set<string>
|
|
): boolean {
|
|
if (!invokedToolIds || invokedToolIds.size === 0) return false;
|
|
return (
|
|
message.tool_calls?.every(
|
|
(toolCall) => toolCall.id != null && invokedToolIds.has(toolCall.id)
|
|
) ?? false
|
|
);
|
|
}
|
|
|
|
export function toolsCondition<T extends string>(
|
|
state: BaseMessage[] | typeof MessagesAnnotation.State,
|
|
toolNode: T,
|
|
invokedToolIds?: Set<string>
|
|
): T | typeof END {
|
|
const messages = Array.isArray(state) ? state : state.messages;
|
|
const message = messages[messages.length - 1] as AIMessage | undefined;
|
|
|
|
if (
|
|
message &&
|
|
'tool_calls' in message &&
|
|
(message.tool_calls?.length ?? 0) > 0 &&
|
|
!areToolCallsInvoked(message, invokedToolIds)
|
|
) {
|
|
return toolNode;
|
|
}
|
|
return END;
|
|
}
|