import { ToolCall } from '@langchain/core/messages/tool'; 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 { BaseMessage, AIMessage } from '@langchain/core/messages'; import type { StructuredToolInterface } from '@langchain/core/tools'; import type * as t from '@/types'; import { ExecutionContext } from './approval/constants'; import { RunnableCallable } from '@/utils'; import { processToolOutput } from '@/utils/toonFormat'; import { safeDispatchCustomEvent } from '@/utils/events'; import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common'; import { executeHooks } from '@/hooks'; import type { HookRegistry, AggregatedHookResult } from '@/hooks'; import { ToolOutputReferenceRegistry, buildReferenceKey, } from '@/tools/toolOutputReferences'; import type { ResolvedArgsByCallId } from '@/tools/toolOutputReferences'; /** * Per-call batch context for `runTool`. Bundles every optional batch-scoped * value the method needs so the signature stays at three positional params * even as new context fields are added. Upstream-aligned (PR #117). */ type RunToolBatchContext = { /** 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; }; /** * Helper to check if a value is a Send object */ function isSend(value: unknown): value is Send { return value instanceof Send; } /** * Extract text content from a ToolMessage content field. * Handles both string and MessageContentComplex[] formats. * For array content (e.g., from content_and_artifact tools), extracts text from text blocks. */ function extractStringContent(content: unknown): string { // Already a string - return as is if (typeof content === 'string') { return content; } // Array of content blocks - extract text from each if (Array.isArray(content)) { const textParts: string[] = []; for (const block of content) { if (typeof block === 'string') { textParts.push(block); } else if (block != null && typeof block === 'object') { // Handle {type: 'text', text: '...'} format const obj = block as Record; if (obj.type === 'text' && typeof obj.text === 'string') { textParts.push(obj.text); } else if (typeof obj.text === 'string') { // Just has 'text' property textParts.push(obj.text); } } } if (textParts.length > 0) { return textParts.join('\n'); } } // Fallback: stringify whatever it is return JSON.stringify(content); } // eslint-disable-next-line @typescript-eslint/no-explicit-any export class ToolNode extends RunnableCallable { private toolMap: Map; private loadRuntimeTools?: t.ToolRefGenerator; handleToolErrors = true; trace = false; toolCallStepIds?: Map; errorHandler?: t.ToolNodeConstructorParams['errorHandler']; private toolUsageCount: Map; /** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */ private toolCallTurns: Map = 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; /** Agent ID for event-driven mode */ private agentId?: string; /** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */ private directToolNames?: Set; /** HITL tool approval configuration */ private toolApprovalConfig?: t.ToolApprovalConfig; /** Buffer for recovering truncated tool call arguments from streaming data */ private streamingToolCallBuffer?: import('@/tools/StreamingToolCallBuffer').StreamingToolCallBuffer; /** Hook registry threaded down from Run for tool-lifecycle events. */ private hookRegistry?: HookRegistry; /** * Tool output reference registry threaded down from Run / Graph * (upstream PR #114). When set, dispatchToolEvents resolves * `{{toolturn}}` placeholders in args before invoking each tool * and stores successful outputs under their stable reference keys for * subsequent calls in the same run to pipe through. */ private toolOutputRegistry?: ToolOutputReferenceRegistry; /** * Per-batch turn counter for the registry. Increments once per * `dispatchToolEvents` invocation so `toolturn` keys remain * unique across overlapping batches in a single run. */ private toolOutputTurn: number = 0; /** * Counter for synthesising scope ids when `run()` is called without a * `run_id` in config. Each anonymous batch gets a unique * `\0anon-` scope so concurrent invocations don't collide on the * shared `toolOutputRegistry`. */ private anonBatchCounter: number = 0; constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, streamingToolCallBuffer, toolApprovalConfig, hookRegistry, toolOutputRegistry, toolOutputReferences, maxToolResultChars: _maxToolResultChars, }: t.ToolNodeConstructorParams) { super({ name, tags, func: (input, config) => this.run(input, config) }); 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(); this.toolRegistry = toolRegistry; this.sessions = sessions; this.eventDrivenMode = eventDrivenMode ?? false; this.agentId = agentId; this.directToolNames = directToolNames; this.streamingToolCallBuffer = streamingToolCallBuffer; this.toolApprovalConfig = toolApprovalConfig; this.hookRegistry = hookRegistry; /** * Precedence: an explicitly passed `toolOutputRegistry` instance * wins (the multi-agent case where every ToolNode shares one registry * for the run); otherwise the per-ToolNode `toolOutputReferences` * config builds an instance scoped to this ToolNode. */ if (toolOutputRegistry != null) { this.toolOutputRegistry = toolOutputRegistry; } else if (toolOutputReferences?.enabled === true) { this.toolOutputRegistry = new ToolOutputReferenceRegistry({ maxOutputSize: toolOutputReferences.maxOutputSize, maxTotalSize: toolOutputReferences.maxTotalSize, }); } } /** * Test-observation accessor for the tool-output reference registry. * @internal Exposed for test observation only. Host code should rely on * `{{toolturn}}` substitution at tool-invocation time and not * mutate the registry directly. */ public _unsafeGetToolOutputRegistry(): | ToolOutputReferenceRegistry | undefined { return this.toolOutputRegistry; } /** * 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 { return new Map(this.toolUsageCount); // Return a copy } /** * Evaluates whether a tool call requires human approval. * Returns true if approval is needed, false otherwise. * Does NOT perform the actual approval flow - that's handled by requestApproval(). */ private requiresApproval( toolName: string, args: Record ): boolean { if (!this.toolApprovalConfig) { return false; } const { defaultPolicy, overrides, executionContext, agentExecutionContextOverrides, } = this.toolApprovalConfig; // Resolve the effective execution context for this agent. // Per-agent overrides take precedence — allows handoff agents to bypass HITL // while the primary agent retains interactive approval. const effectiveContext = (this.agentId && agentExecutionContextOverrides?.[this.agentId]) ?? executionContext; // Scheduled executions bypass all approval checks — no user is present if (effectiveContext === ExecutionContext.SCHEDULED) { return false; } // Handoff/delegate/workflow agents bypass HITL approval — these agents run // autonomously as part of a multi-agent pipeline. Blocking on user approval // would stall the entire graph since all agents share the same SSE stream. // The primary (user-facing) agent retains HITL; only delegated agents skip it. if (effectiveContext === ExecutionContext.HANDOFF) { return false; } // Graph-managed routing tools (handoff/transfer) bypass HITL approval — // these are internal routing mechanisms, not user-facing tool executions. // // NOTE: `directToolNames` is used for two purposes — (1) marking tools that // are loaded as full instances and don't need on-demand ON_TOOL_EXECUTE loading, // and (2) bypassing HITL. In event-driven mode ALL built-in tools (including // `ask_user`) end up in directToolNames for reason (1), so we cannot use // `directToolNames.has(toolName)` as the HITL-bypass test — it would let // `ask_user` execute without ever firing interrupt(), defeating the whole tool. // Instead, gate the bypass on the actual routing-tool name prefix. if ( this.directToolNames?.has(toolName) && toolName.startsWith(Constants.LC_TRANSFER_TO_) ) { return false; } // Determine the effective policy for this tool const toolOverride = overrides?.[toolName]; const effectivePolicy = toolOverride ?? defaultPolicy ?? 'always'; // Evaluate whether approval is required if (effectivePolicy === 'always') { return true; } else if (effectivePolicy === 'never') { return false; } else { // Custom function - evaluate with tool name and args return effectivePolicy(toolName, args); } } /** * Requests human approval for a tool call using LangGraph's native interrupt(). * * Flow: * 1. Dispatches ON_TOOL_APPROVAL_REQUIRED notification (no resolve/reject — data only) * so the host can persist the request and send UI events. * 2. Calls interrupt() which checkpoints graph state and pauses execution. * 3. When the host resumes via Command({ resume: ToolApprovalResponse }), * interrupt() returns the response synchronously. * * @param call - The tool call requiring approval * @param config - The runnable config for event dispatch * @returns The approval response from the human */ private async requestApproval( call: ToolCall, config: RunnableConfig ): Promise { const approvalRequest: t.ToolApprovalRequest = { type: 'tool_approval_required', toolCallId: call.id ?? '', toolName: call.name, toolArgs: call.args as Record, agentId: this.agentId, description: `Tool "${call.name}" wants to execute with the provided arguments.`, }; // MUST await — interrupt() throws GraphInterrupt synchronously which unwinds // the call stack. Any un-awaited dispatch Promise is abandoned before the // host's ON_TOOL_APPROVAL_REQUIRED handler runs, so the MongoDB row never // gets written and the subsequent approve-tool POST 404s with // "No pending approval found". Awaiting guarantees the handler has persisted // the request before we suspend the graph. await safeDispatchCustomEvent( GraphEvents.ON_TOOL_APPROVAL_REQUIRED, approvalRequest, config ); // interrupt() throws GraphInterrupt on first call (checkpoints state), // returns the resume value on re-execution after Command({ resume }). const response = interrupt(approvalRequest) as t.ToolApprovalResponse; return response; } /** * Runs a single tool call with error handling. * * @param batchContext Optional per-batch context (upstream PR #117). * Threaded from `run()` for tool output reference annotation. The * `batchScopeId` field carries an anonymous synthetic scope when the * caller has no `run_id`, so concurrent batches don't collide on the * shared registry. */ protected async runTool( call: ToolCall, config: RunnableConfig, batchContext: RunToolBatchContext = {} ): Promise { const { batchIndex, turn: batchTurn, batchScopeId, resolvedArgsByCallId, } = batchContext; const tool = this.toolMap.get(call.name); const registry = this.toolOutputRegistry; /** * 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 && batchTurn != null ? buildReferenceKey(batchIndex, batchTurn) : undefined; /** * Hoisted outside the try so the catch branch can stamp * `_unresolvedRefs` into error ToolMessage metadata. This lets the * lazy annotation transform surface a `[unresolved refs: …]` hint to * the LLM, helping the model self-correct when its reference key * caused the failure. */ let unresolvedRefs: string[] = []; /** * Use the caller-provided `batchScopeId` when threaded from `run()`. * Fall back to the config's `run_id` when runTool is invoked from a * context that doesn't thread the batch context — 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(`Tool "${call.name}" not found.`); } const turn = this.toolUsageCount.get(call.name) ?? 0; this.toolUsageCount.set(call.name, turn + 1); if (call.id != null && call.id !== '') { this.toolCallTurns.set(call.id, turn); } let args = call.args; const stepId = this.toolCallStepIds?.get(call.id!); // Recover truncated tool call arguments from the streaming buffer. // When max_tokens truncates a tool call mid-JSON, parsePartialJson may lose // content that was already streamed to the UI. The buffer has the raw accumulated // arg string, so we can extract field values that were dropped. if (call.id && this.streamingToolCallBuffer?.has(call.id)) { args = this.recoverTruncatedArgs(call.name, call.id, args); this.streamingToolCallBuffer.clear(call.id); } /** * Resolve `{{toolturn}}` placeholders in args against the * registry. Captures `unresolvedRefs` so error/success branches can * surface them to the LLM via `additional_kwargs._unresolvedRefs`. */ if (registry != null) { const { resolved, unresolved } = registry.resolve(runId, args); args = resolved as typeof call.args; unresolvedRefs = unresolved; if ( resolvedArgsByCallId != null && call.id != null && call.id !== '' && resolved !== call.args && typeof resolved === 'object' ) { resolvedArgsByCallId.set( call.id, resolved as Record ); } } // Build invoke params - LangChain extracts non-schema fields to config.toolCall let invokeParams: Record = { ...call, args, type: 'tool_call', stepId, turn, }; // Inject runtime data for special tools (becomes available at config.toolCall) if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING) { const { toolMap, toolDefs } = this.getProgrammaticTools(); invokeParams = { ...invokeParams, toolMap, toolDefs, }; } 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 (even without tracked files) * so the CodeExecutor can fall back to the /files endpoint for session continuity. */ if (CODE_EXECUTION_TOOLS.has(call.name)) { const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as | t.CodeSessionContext | undefined; if (codeSession != null && codeSession.session_id !== '') { /** * Always inject session_id so retries reuse the same workspace. * Also inject file refs when files exist from previous executions. */ invokeParams = { ...invokeParams, session_id: codeSession.session_id, }; if (codeSession.files != null && codeSession.files.length > 0) { /** * Convert tracked files to CodeEnvFile format for the API. * Each file uses its own session_id (set when file was created). * This supports files from multiple parallel/sequential executions. */ const fileRefs: t.CodeEnvFile[] = codeSession.files.map((file) => ({ session_id: file.session_id ?? codeSession.session_id, id: file.id, name: file.name, })); invokeParams = { ...invokeParams, _injected_files: fileRefs, }; } } } // ======================================================================== // HITL: Check if this tool requires human approval before execution. // Uses LangGraph interrupt() — checkpoints state and pauses the graph. // Resumes when host sends Command({ resume: ToolApprovalResponse }). // ======================================================================== if ( this.requiresApproval(call.name, call.args as Record) ) { const approvalResponse = await this.requestApproval(call, config); if (!approvalResponse.approved) { // Human denied the tool call - return a denial message return new ToolMessage({ status: 'error', content: `Tool call "${call.name}" was denied by the user.${approvalResponse.feedback != null && approvalResponse.feedback !== '' ? ` Reason: ${approvalResponse.feedback}` : ''} Please acknowledge the denial and proceed without executing this tool.`, name: call.name, tool_call_id: call.id ?? '', }); } // Human approved - optionally use modified args if (approvalResponse.modifiedArgs) { invokeParams = { ...invokeParams, args: approvalResponse.modifiedArgs, }; } } const output = await tool.invoke(invokeParams, config); // Handle Command outputs directly if (isCommand(output)) { return output; } // ======================================================================== // TOOL OUTPUT PROCESSING - Single point for all tools (MCP and non-MCP) // 1. Extract string content from any output format // 2. Apply TOON conversion if content contains JSON // 3. Apply truncation if still too large // 4. Return ToolMessage with processed string content // ======================================================================== // Step 1: Extract string content from the output let rawContent: string; if (isBaseMessage(output) && output._getType() === 'tool') { const toolMsg = output as ToolMessage; rawContent = extractStringContent(toolMsg.content); } else { rawContent = extractStringContent(output); } // Step 2 & 3: Apply TOON conversion and truncation // Skip TOON for content_tool — its output is line-numbered source code. // TOON corrupts embedded JSON in source files, causing edit (str_replace) failures // because the agent sees TOON-transformed content but strReplace matches original. const isContentTool = call.name === 'content_tool'; const processed = processToolOutput(rawContent, { maxLength: 100000, // 100K char limit enableToon: !isContentTool, minSizeForToon: 1000, minReductionPercent: 10, // Only apply TOON when clearly beneficial }); /** * Tool output reference metadata (upstream PR #117). Register the * raw, untruncated content so future `{{...}}` substitutions deliver * the full payload, and stamp `_refKey` / `_refScope` / * `_unresolvedRefs` into `additional_kwargs` for the lazy * annotation transform to apply at LLM request time. Persisted * `content` stays clean. * * For error ToolMessages from the underlying tool: bypass * registration but still stamp `_unresolvedRefs` so the model can * self-correct. */ if (isBaseMessage(output) && output._getType() === 'tool') { const toolMsg = output as ToolMessage; const isError = toolMsg.status === 'error'; const refMeta = isError ? unresolvedRefs.length > 0 ? { _unresolvedRefs: unresolvedRefs } : undefined : this.recordOutputReference( runId, rawContent, refKey, unresolvedRefs ); return new ToolMessage({ status: toolMsg.status, name: toolMsg.name, content: processed.content, tool_call_id: toolMsg.tool_call_id, ...(refMeta != null && { additional_kwargs: { ...toolMsg.additional_kwargs, ...refMeta, }, }), }); } else { const refMeta = this.recordOutputReference( runId, rawContent, refKey, unresolvedRefs ); return new ToolMessage({ status: 'success', name: tool.name, content: processed.content, tool_call_id: call.id!, ...(refMeta != null && { additional_kwargs: refMeta as Record, }), }); } } catch (_e: unknown) { const e = _e as Error; if (!this.handleToolErrors) { throw e; } if (isGraphInterrupt(e)) { throw e; } if (this.errorHandler) { try { await this.errorHandler( { error: e, id: call.id!, name: call.name, input: call.args, }, config.metadata ); } catch (handlerError) { // 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, }, }); } } return new ToolMessage({ status: 'error', content: `Error: ${e.message}\n Please fix your mistakes.`, name: call.name, tool_call_id: call.id ?? '', ...(unresolvedRefs.length > 0 && { additional_kwargs: { _unresolvedRefs: unresolvedRefs }, }), }); } } /** * Recover truncated tool call arguments using the raw streaming buffer. * * When parsePartialJson drops content (e.g., a large "content" field truncated * at max_tokens), this method extracts the field value from the raw accumulated * arg string and merges it back into the parsed args object. * * This is generic — it checks ALL string fields in the raw buffer, not just * content_tool fields. Any tool with a truncated string value benefits. */ /** * Recover truncated tool call arguments using the raw streaming buffer. * * Strategy: * 1. If args are completely empty → try parsePartialJson on the raw buffer * 2. Otherwise → field-level recovery: extract missing fields from raw buffer * * @param toolName - Tool name (for logging) * @param toolCallId - The tool call ID * @param args - The parsed args (potentially incomplete from parsePartialJson) * @returns The args with recovered fields merged in */ /* eslint-disable @typescript-eslint/no-explicit-any */ private recoverTruncatedArgs( toolName: string, toolCallId: string, args: any ): any { /* eslint-enable @typescript-eslint/no-explicit-any */ if (!this.streamingToolCallBuffer) return args; const rawArgs = this.streamingToolCallBuffer.getRawArgs(toolCallId); if (!rawArgs) return args; const rawLen = rawArgs.length; // If args is completely empty (JSON parsing aborted before any key), // attempt to parse the raw accumulated string if ( args == null || (typeof args === 'object' && Object.keys(args).length === 0) ) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { parsePartialJson } = require('@langchain/core/utils/json'); const recovered = parsePartialJson(rawArgs); if ( recovered != null && typeof recovered === 'object' && Object.keys(recovered).length > 0 ) { // eslint-disable-next-line no-console console.warn( `[TruncationRecovery] Tool=${toolName}, callId=${toolCallId}, rawBufferLen=${rawLen}, ` + `parsedFields=[], recoveredFields=[${Object.keys(recovered).join(',')}] (full parse)` ); return recovered; } } catch { // parsePartialJson failed — fall through to field-level extraction } } // Field-level recovery: extract missing fields from the raw buffer. // // Only attempt this when `parsedArgs` is missing top-level keys that // appear at depth 0 in the raw JSON. The previous implementation walked // EVERY `"key":` match the regex found — including keys nested inside // arrays/objects like `steps[].options[].label` — and tried to "recover" // them as top-level fields. That produced confusing log spam (false // positives like `recoveredFields=[question,label,value]` for a clean // multi-step `ask_user` call) and could inject stray top-level fields // into `args` for downstream tool execution. const parsedArgs = typeof args === 'object' ? { ...args } : {}; const recoveredFields: string[] = []; /** * Extract only TOP-LEVEL keys from the raw JSON. Walks the buffer * tracking brace/bracket depth so nested keys are skipped. */ const topLevelKeys: string[] = []; let depth = 0; let inString = false; let escaped = false; let i = 0; while (i < rawArgs.length) { const ch = rawArgs[i]; if (escaped) { escaped = false; i++; continue; } if (inString) { if (ch === '\\') escaped = true; else if (ch === '"') inString = false; i++; continue; } if (ch === '"') { // Possible key — only count if at depth 1 (inside the outer object) const start = i + 1; let j = start; while (j < rawArgs.length) { const c = rawArgs[j]; if (c === '\\') { j += 2; continue; } if (c === '"') break; j++; } if (j < rawArgs.length) { // Skip whitespace after closing quote and check for ':' let k = j + 1; while (k < rawArgs.length && /\s/.test(rawArgs[k])) k++; if (depth === 1 && rawArgs[k] === ':') { topLevelKeys.push(rawArgs.slice(start, j)); } i = j + 1; continue; } inString = true; i++; continue; } if (ch === '{' || ch === '[') depth++; else if (ch === '}' || ch === ']') depth--; i++; } for (const fieldName of topLevelKeys) { if ( parsedArgs[fieldName] == null || parsedArgs[fieldName] === '' || parsedArgs[fieldName] === undefined ) { const rawValue = this.streamingToolCallBuffer.extractFieldValue( toolCallId, fieldName ); if (rawValue != null && rawValue !== '') { parsedArgs[fieldName] = rawValue; recoveredFields.push(fieldName); } } } if (recoveredFields.length > 0) { // eslint-disable-next-line no-console console.warn( `[TruncationRecovery] Tool=${toolName}, callId=${toolCallId}, rawBufferLen=${rawLen}, ` + `parsedFields=[${Object.keys(args ?? {}).join(',')}], recoveredFields=[${recoveredFields.join(',')}]` ); } return parsedArgs; } /** * Stores the raw, untruncated tool output in the registry under `refKey` * (when registry + refKey are both present) and returns the metadata * envelope to stamp on `ToolMessage.additional_kwargs`. The lazy * annotation transform reads `_refKey` / `_refScope` / `_unresolvedRefs` * at LLM request time to produce the transient annotated copy. */ 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; // Anonymous invocations use a synthetic scope minted in run() — stamp // it onto the message so annotateMessagesForLLM can recover it later. 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 context: NonNullable = { session_id: codeSession.session_id, }; if (codeSession.files && codeSession.files.length > 0) { context.files = codeSession.files.map((file) => ({ session_id: file.session_id ?? codeSession.session_id, id: file.id, name: file.name, })); } 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. */ private storeCodeSessionFromResults( results: t.ToolExecuteResult[], requests: 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 = requests.find((r) => r.id === result.toolCallId); if ( request?.name !== Constants.EXECUTE_CODE && request?.name !== Constants.PROGRAMMATIC_TOOL_CALLING ) { continue; } const artifact = result.artifact as t.CodeExecutionArtifact | undefined; if (artifact?.session_id == null || artifact.session_id === '') { continue; } const newFiles = artifact.files ?? []; const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as | t.CodeSessionContext | undefined; const existingFiles = existingSession?.files ?? []; if (newFiles.length > 0) { const filesWithSession: t.FileRefs = newFiles.map((file) => ({ ...file, session_id: artifact.session_id, })); const newFileNames = new Set(filesWithSession.map((f) => f.name)); const filteredExisting = existingFiles.filter( (f) => !newFileNames.has(f.name) ); this.sessions.set(Constants.EXECUTE_CODE, { session_id: artifact.session_id, files: [...filteredExisting, ...filesWithSession], lastUpdated: Date.now(), }); } else { this.sessions.set(Constants.EXECUTE_CODE, { session_id: artifact.session_id, files: existingFiles, lastUpdated: Date.now(), }); } } } /** * 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. */ private handleRunToolCompletions( calls: ToolCall[], outputs: (BaseMessage | Command)[], config: RunnableConfig ): 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. Without this check, errors would be double-dispatched. if (toolMessage.status === 'error' && this.errorHandler != null) { continue; } // Store code session context from tool results if ( this.sessions && (call.name === Constants.EXECUTE_CODE || call.name === Constants.PROGRAMMATIC_TOOL_CALLING) ) { const artifact = toolMessage.artifact as | t.CodeExecutionArtifact | undefined; if (artifact?.session_id != null && artifact.session_id !== '') { const newFiles = artifact.files ?? []; const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as | t.CodeSessionContext | undefined; const existingFiles = existingSession?.files ?? []; if (newFiles.length > 0) { const filesWithSession: t.FileRefs = newFiles.map((file) => ({ ...file, session_id: artifact.session_id, })); const newFileNames = new Set(filesWithSession.map((f) => f.name)); const filteredExisting = existingFiles.filter( (f) => !newFileNames.has(f.name) ); this.sessions.set(Constants.EXECUTE_CODE, { session_id: artifact.session_id, files: [...filteredExisting, ...filesWithSession], lastUpdated: Date.now(), }); } else { this.sessions.set(Constants.EXECUTE_CODE, { session_id: artifact.session_id, files: existingFiles, lastUpdated: Date.now(), }); } } } // Dispatch ON_RUN_STEP_COMPLETED via custom event (same path as dispatchToolEvents) const stepId = this.toolCallStepIds?.get(toolCallId) ?? ''; if (!stepId) { // eslint-disable-next-line no-console console.warn( `[ToolNode.handleRunToolCompletions] missing stepId for toolCallId=${toolCallId} ` + `name=${call.name} — ON_RUN_STEP_COMPLETED skipped, output will not be persisted. ` + `If this is a resumed HITL tool call, verify Graph.resetValues preserves toolCallStepIds when keepContent=true.` ); continue; } const contentString = typeof toolMessage.content === 'string' ? toolMessage.content : JSON.stringify(toolMessage.content); const tool_call: t.ProcessedToolCall = { args: typeof call.args === 'string' ? (call.args as string) : JSON.stringify((call.args as unknown) ?? {}), name: call.name, id: toolCallId, output: contentString, progress: 1, }; safeDispatchCustomEvent( GraphEvents.ON_RUN_STEP_COMPLETED, { result: { id: stepId, index: turn, type: 'tool_call' as const, tool_call, }, }, config ); } } /** * Converts InjectedMessage entries into LangChain HumanMessages. * 'user' and 'system' both become HumanMessage to avoid provider rejections * (Anthropic/Google reject non-leading SystemMessages); the original role * and metadata are 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 = { 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: msg.content, additional_kwargs }) ); } return converted; } /** * Dispatches tool calls to the host via ON_TOOL_EXECUTE event and returns * the resulting ToolMessages along with any injected messages emitted by * the host (e.g. SkillTool body injection). Injected messages must be * placed AFTER the ToolMessages so the AIMessage tool_calls -> ToolMessage * adjacency required by some providers is preserved. */ private async dispatchToolEvents( toolCalls: ToolCall[], config: RunnableConfig ): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> { // ======================================================================== // HITL: Check approval for event-dispatched tools (browser, MCP, etc.) // before dispatching. Uses LangGraph interrupt() for each tool needing // approval — counter-based matching handles sequential interrupts. // ======================================================================== const approvedCalls: ToolCall[] = []; const denialMessages: ToolMessage[] = []; for (const call of toolCalls) { if ( this.requiresApproval(call.name, call.args as Record) ) { const approvalResponse = await this.requestApproval(call, config); if (!approvalResponse.approved) { denialMessages.push( new ToolMessage({ status: 'error', content: `Tool call "${call.name}" was denied by the user.${approvalResponse.feedback != null && approvalResponse.feedback !== '' ? ` Reason: ${approvalResponse.feedback}` : ''} Please acknowledge the denial and proceed without executing this tool.`, name: call.name, tool_call_id: call.id ?? '', }) ); continue; } // Use modified args if provided if (approvalResponse.modifiedArgs) { call.args = approvalResponse.modifiedArgs; } } approvedCalls.push(call); } // If all tools were denied, return denial messages without dispatching if (approvedCalls.length === 0) { return { toolMessages: denialMessages, injected: [] }; } const runId = config.configurable?.run_id as string | undefined; const threadId = config.configurable?.thread_id as string | undefined; /** * PreToolUse hooks may deny / ask / allow each call, and may rewrite * `toolInput` via `updatedInput`. A denied/asked call short-circuits * with a synthetic error ToolMessage and fires PermissionDenied so * the host can audit. Survivors continue to dispatch. */ const hookDenialMessages: ToolMessage[] = []; let postHookCalls = approvedCalls; if ( runId != null && this.hookRegistry?.hasHookFor('PreToolUse', runId) === true ) { const HOOK_FALLBACK: AggregatedHookResult = { additionalContexts: [], errors: [], }; const surviving: ToolCall[] = []; const preResults = await Promise.all( approvedCalls.map((call) => executeHooks({ registry: this.hookRegistry!, input: { hook_event_name: 'PreToolUse', runId, threadId, agentId: this.agentId, toolName: call.name, toolInput: call.args as Record, toolUseId: call.id ?? '', stepId: this.toolCallStepIds?.get(call.id ?? ''), turn: this.toolUsageCount.get(call.name) ?? 0, }, sessionId: runId, matchQuery: call.name, }).catch((): AggregatedHookResult => HOOK_FALLBACK) ) ); for (let i = 0; i < approvedCalls.length; i++) { const call = approvedCalls[i]; const hookResult = preResults[i]; const denied = hookResult.decision === 'deny' || hookResult.decision === 'ask'; if (denied) { const reason = hookResult.reason ?? 'Blocked by hook'; hookDenialMessages.push( new ToolMessage({ status: 'error', content: `Blocked: ${reason}`, name: call.name, tool_call_id: call.id ?? '', }) ); if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) { // Fire-and-forget — denial accounting must not block dispatch. void executeHooks({ registry: this.hookRegistry, input: { hook_event_name: 'PermissionDenied', runId, threadId, agentId: this.agentId, toolName: call.name, toolInput: call.args as Record, toolUseId: call.id ?? '', reason, }, sessionId: runId, matchQuery: call.name, }).catch(() => { /* swallow — denial is informational */ }); } continue; } if ( hookResult.updatedInput != null && typeof hookResult.updatedInput === 'object' ) { call.args = hookResult.updatedInput as Record; } surviving.push(call); } postHookCalls = surviving; if (postHookCalls.length === 0) { return { toolMessages: [...denialMessages, ...hookDenialMessages], injected: [], }; } } /** * Tool output reference resolution (upstream PR #114): walk each call's * args and substitute `{{toolturn}}` placeholders with the stored * raw output. Captured here BEFORE request shaping so the substituted * args flow through the rest of the dispatch unchanged. * * Failures are non-fatal: the registry resolver returns the original * string unchanged for unknown keys, and surfaces them via `unresolved` * which we currently log-only. Future work can route unresolved-key * complaints into a hint message for the LLM. */ const batchTurn = this.toolOutputTurn++; if (this.toolOutputRegistry != null && runId != null) { const registry = this.toolOutputRegistry; for (const call of postHookCalls) { const args = call.args as Record | undefined; if (args == null) continue; try { const { resolved } = registry.resolve(runId, args); call.args = resolved; } catch { /* leave args untouched on resolver failure */ } } } const requests: t.ToolCallRequest[] = postHookCalls.map((call) => { const turn = this.toolUsageCount.get(call.name) ?? 0; this.toolUsageCount.set(call.name, turn + 1); // Recover truncated args from streaming buffer (same as runTool path) let args = call.args as Record; if (call.id && this.streamingToolCallBuffer?.has(call.id)) { args = this.recoverTruncatedArgs(call.name, call.id, args) as Record< string, unknown >; this.streamingToolCallBuffer.clear(call.id); } const request: t.ToolCallRequest = { id: call.id!, name: call.name, args, stepId: this.toolCallStepIds?.get(call.id!), turn, }; if ( call.name === Constants.EXECUTE_CODE || call.name === Constants.PROGRAMMATIC_TOOL_CALLING ) { request.codeSessionContext = this.getCodeSessionContext(); } return request; }); const results = await new Promise( (resolve, reject) => { const request: t.ToolExecuteBatchRequest = { toolCalls: requests, userId: config.configurable?.user_id as string | undefined, agentId: this.agentId, configurable: config.configurable as | Record | undefined, metadata: config.metadata as Record | undefined, resolve, reject, }; safeDispatchCustomEvent(GraphEvents.ON_TOOL_EXECUTE, request, config); } ); this.storeCodeSessionFromResults(results, requests); const injected: BaseMessage[] = []; 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 ); } } } /** * Fire PostToolUse / PostToolUseFailure hooks after results are in. * These run in parallel and never block the return — failures are * swallowed so a hook bug can't masquerade as a tool failure. */ if (runId != null && this.hookRegistry != null) { const hasPost = this.hookRegistry.hasHookFor('PostToolUse', runId); const hasFail = this.hookRegistry.hasHookFor('PostToolUseFailure', runId); if (hasPost || hasFail) { const postPromises: Promise[] = []; for (const result of results) { const request = requests.find((r) => r.id === result.toolCallId); if (!request) continue; if (result.status === 'success' && hasPost) { postPromises.push( executeHooks({ registry: this.hookRegistry, input: { hook_event_name: 'PostToolUse', runId, threadId, agentId: this.agentId, toolName: request.name, toolInput: request.args as Record, toolOutput: result.content, toolUseId: result.toolCallId, stepId: this.toolCallStepIds?.get(result.toolCallId), turn: request.turn, }, sessionId: runId, matchQuery: request.name, }).catch(() => undefined) ); } else if (result.status === 'error' && hasFail) { postPromises.push( executeHooks({ registry: this.hookRegistry, input: { hook_event_name: 'PostToolUseFailure', runId, threadId, agentId: this.agentId, toolName: request.name, toolInput: request.args as Record, toolUseId: result.toolCallId, error: result.errorMessage ?? 'Unknown error', stepId: this.toolCallStepIds?.get(result.toolCallId), turn: request.turn, }, sessionId: runId, matchQuery: request.name, }).catch(() => undefined) ); } } if (postPromises.length > 0) { await Promise.all(postPromises); } } } const eventMessages = results.map((result, idx) => { const request = requests.find((r) => r.id === result.toolCallId); const toolName = request?.name ?? 'unknown'; const stepId = this.toolCallStepIds?.get(result.toolCallId) ?? ''; if (!stepId) { // eslint-disable-next-line no-console console.warn( `[ToolNode] toolCallStepIds missing entry for toolCallId=${result.toolCallId} (tool=${toolName}). ` + 'This indicates a race between the stream consumer and graph execution. ' + `Map size: ${this.toolCallStepIds?.size ?? 0}` ); } let toolMessage: ToolMessage; let contentString: string; if (result.status === 'error') { contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`; toolMessage = new ToolMessage({ status: 'error', content: contentString, name: toolName, tool_call_id: result.toolCallId, }); } else { contentString = typeof result.content === 'string' ? result.content : JSON.stringify(result.content); /** * Tool output reference — lazy annotation (upstream PRs #114 + #117). * Register the raw output under `toolturn` in the * registry but leave `ToolMessage.content` clean. Stamp the ref * metadata into `additional_kwargs` instead. The lazy * `annotateMessagesForLLM` transform applies the visible * `[ref: …]` / `_ref` annotation only on the transient copy sent * to the model. Persisted message stays unannotated so * conversation exports / pruning / token accounting stay clean. */ const additionalKwargs: Record = {}; if (this.toolOutputRegistry != null && runId != null) { try { const refKey = buildReferenceKey(idx, batchTurn); this.toolOutputRegistry.set(runId, refKey, contentString); const meta: t.ToolMessageRefMetadata = { _refKey: refKey, _refScope: runId, }; Object.assign(additionalKwargs, meta); } catch { /* registry store failure is non-fatal — message stays clean, no annotation */ } } toolMessage = new ToolMessage({ status: 'success', name: toolName, content: contentString, artifact: result.artifact, tool_call_id: result.toolCallId, additional_kwargs: Object.keys(additionalKwargs).length > 0 ? additionalKwargs : undefined, }); } const tool_call: t.ProcessedToolCall = { args: typeof request?.args === 'string' ? request.args : JSON.stringify(request?.args ?? {}), name: toolName, id: result.toolCallId, output: contentString, progress: 1, }; const runStepCompletedData = { result: { id: stepId, index: request?.turn ?? 0, type: 'tool_call' as const, tool_call, }, }; safeDispatchCustomEvent( GraphEvents.ON_RUN_STEP_COMPLETED, runStepCompletedData, config ); return toolMessage; }); return { toolMessages: [ ...denialMessages, ...hookDenialMessages, ...eventMessages, ], injected, }; } /** * Execute all tool calls via ON_TOOL_EXECUTE event dispatch. * Used in event-driven mode where the host handles actual tool execution. */ private async executeViaEvent( toolCalls: ToolCall[], config: RunnableConfig, // eslint-disable-next-line @typescript-eslint/no-explicit-any input: any ): Promise { const { toolMessages, injected } = await this.dispatchToolEvents( toolCalls, config ); 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 { 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: ResolvedArgsByCallId = new Map(); /** * Claim this batch's turn synchronously from the registry (or fall * back to 0 when the feature is disabled). 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. */ const incomingRunId = config.configurable?.run_id as string | undefined; const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`; const batchTurn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0; let outputs: (BaseMessage | Command)[]; if (this.isSendInput(input)) { const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name); if (this.eventDrivenMode && isDirectTool !== true) { return this.executeViaEvent([input.lg_tool_call], config, input); } outputs = [ await this.runTool(input.lg_tool_call, config, { batchIndex: 0, turn: batchTurn, batchScopeId, resolvedArgsByCallId, }), ]; this.handleRunToolCompletions([input.lg_tool_call], outputs, config); } 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 = 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.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('srvtoolu_') ?? false) ); }) ?? []; if (this.eventDrivenMode && filteredCalls.length > 0) { if (!this.directToolNames || this.directToolNames.size === 0) { return this.executeViaEvent(filteredCalls, config, input); } const directCalls = filteredCalls.filter((c) => this.directToolNames!.has(c.name) ); const eventCalls = filteredCalls.filter( (c) => !this.directToolNames!.has(c.name) ); // Run direct tools and event tools in parallel — they are independent const [directOutputs, eventDispatch] = (await Promise.all([ directCalls.length > 0 ? Promise.all( directCalls.map((call, idx) => this.runTool(call, config, { batchIndex: idx, turn: batchTurn, batchScopeId, resolvedArgsByCallId, }) ) ) : ([] as (BaseMessage | Command)[]), eventCalls.length > 0 ? this.dispatchToolEvents(eventCalls, config) : ({ toolMessages: [], injected: [] } as { toolMessages: ToolMessage[]; injected: BaseMessage[]; }), ])) as [ (BaseMessage | Command)[], { toolMessages: ToolMessage[]; injected: BaseMessage[] }, ]; if (directCalls.length > 0 && directOutputs.length > 0) { this.handleRunToolCompletions(directCalls, directOutputs, config); } // Injected messages MUST follow ToolMessages so the AIMessage // tool_calls -> ToolMessage adjacency required by some providers // is preserved. outputs = [ ...directOutputs, ...eventDispatch.toolMessages, ...eventDispatch.injected, ]; } else { outputs = await Promise.all( filteredCalls.map((call, idx) => this.runTool(call, config, { batchIndex: idx, turn: batchTurn, batchScopeId, resolvedArgsByCallId, }) ) ); this.handleRunToolCompletions(filteredCalls, outputs, config); } } 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] } ); } } /** * Dedupe handoffs targeting the same destination. * * Parent LLMs sometimes emit multiple parallel transfer_to_ tool * calls for the same child agent in one model tick (e.g. "send email 1" * and "send email 2" both routed to the Productivity Assistant). LangGraph * would otherwise create two parallel Sends into the same subgraph, which * produces duplicate handoff entries in the UI and leaves extra * tool_call_ids without a clean result when the single child run finishes. * * Merge same-destination handoffs into one Command whose update.messages * carries every original ToolMessage — the primary one has its * Instructions field rebuilt from the combined set, and the rest are kept * verbatim so the parent message history has a ToolMessage for every * tool_call_id (LangChain requires every AI tool_call to be resolved). * The child's _extractTransferContext filters all transfer ToolMessages * out of the child's view, so it only ever sees the merged instructions. */ if (handoffCommands.length > 1) { const byDestination = new Map(); for (const cmd of handoffCommands) { const goto = cmd.goto; const dest = typeof goto === 'string' ? goto : (goto as string[])[0]; const arr = byDestination.get(dest) ?? []; arr.push(cmd); byDestination.set(dest, arr); } const hasDuplicates = Array.from(byDestination.values()).some( (arr) => arr.length > 1 ); if (hasDuplicates) { const TRANSFER_INSTRUCTIONS_PATTERN = /(?:Instructions?|Context):\s*(.+)/is; const mergedHandoffs: Command[] = []; for (const [dest, cmds] of byDestination) { if (cmds.length === 1) { mergedHandoffs.push(cmds[0]); continue; } const allToolMessages: ToolMessage[] = []; const allOtherMessages: BaseMessage[] = []; for (const cmd of cmds) { const upd = cmd.update as { messages?: BaseMessage[] } | undefined; for (const m of upd?.messages ?? []) { if (m.getType() === 'tool') { allToolMessages.push(m as ToolMessage); } else { allOtherMessages.push(m); } } } if (allToolMessages.length === 0) { mergedHandoffs.push(cmds[0]); continue; } const primary = allToolMessages[0]; const primaryContent = typeof primary.content === 'string' ? primary.content : JSON.stringify(primary.content); const mergedInstructions: string[] = []; for (const tm of allToolMessages) { const c = typeof tm.content === 'string' ? tm.content : JSON.stringify(tm.content); const match = c.match(TRANSFER_INSTRUCTIONS_PATTERN); if (match?.[1]) { const instr = match[1].trim(); if (instr && !mergedInstructions.includes(instr)) { mergedInstructions.push(instr); } } } let mergedPrimaryContent = primaryContent; if (mergedInstructions.length > 1) { const header = primaryContent .replace(TRANSFER_INSTRUCTIONS_PATTERN, '') .trimEnd(); const combinedInstr = mergedInstructions .map((s, i) => `${i + 1}. ${s}`) .join('\n'); mergedPrimaryContent = `${header}\nInstructions: ${combinedInstr}`.trim(); } const mergedPrimary = new ToolMessage({ content: mergedPrimaryContent, tool_call_id: primary.tool_call_id, name: primary.name, additional_kwargs: { ...primary.additional_kwargs }, }); const mergedMessages: BaseMessage[] = [ mergedPrimary, ...allToolMessages.slice(1), ...allOtherMessages, ]; mergedHandoffs.push( new Command({ graph: Command.PARENT, goto: dest, update: { messages: mergedMessages }, }) ); } handoffCommands.length = 0; handoffCommands.push(...mergedHandoffs); } } /** * 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 ): 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( state: BaseMessage[] | typeof MessagesAnnotation.State, toolNode: T, invokedToolIds?: Set ): 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; }