UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

286 lines (285 loc) • 10 kB
//#region src/headless-tools.ts /** * Parses a headless-tool interrupt `value` from the graph. Accepts both * `toolCall` (LangChain JS) and `tool_call` (Python / JSON snake_case). */ function parseHeadlessToolInterruptPayload(value) { if (typeof value !== "object" || value == null) return null; const v = value; if (v.type !== "tool") return null; const rawTc = v.toolCall ?? v.tool_call; if (typeof rawTc !== "object" || rawTc == null) return null; const tc = rawTc; if (typeof tc.name !== "string") return null; return { type: "tool", toolCall: { id: typeof tc.id === "string" ? tc.id : void 0, name: tc.name, args: tc.args } }; } /** * Strip headless-tool interrupts from a user-facing interrupt list. */ function filterOutHeadlessToolInterrupts(interrupts) { return interrupts.filter((interrupt) => interrupt.value == null || !isHeadlessToolInterrupt(interrupt.value)); } function isHeadlessToolInterrupt(interrupt) { return parseHeadlessToolInterruptPayload(interrupt) != null; } function findHeadlessTool(tools, name) { return tools.find((tool) => tool.tool.name === name); } async function executeHeadlessTool(implementation, args, onTool) { const startTime = Date.now(); onTool?.({ phase: "start", name: implementation.tool.name, args }); try { const result = await implementation.execute(args); const duration = Date.now() - startTime; onTool?.({ phase: "success", name: implementation.tool.name, args, result, duration }); return { success: true, result }; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); const duration = Date.now() - startTime; onTool?.({ phase: "error", name: implementation.tool.name, args, error, duration }); return { success: false, error }; } } async function handleHeadlessToolInterrupt(interrupt, tools, onTool) { const { toolCall } = interrupt; const implementation = findHeadlessTool(tools, toolCall.name); if (!implementation) { const error = /* @__PURE__ */ new Error(`Headless tool "${toolCall.name}" is not registered. Available tools: ${tools.map((tool) => tool.tool.name).join(", ") || "none"}`); onTool?.({ phase: "error", name: toolCall.name, args: toolCall.args, error, duration: 0 }); return { toolCallId: toolCall.id, value: { error: error.message } }; } const result = await executeHeadlessTool(implementation, toolCall.args, onTool); if (result.success) return { toolCallId: toolCall.id, value: result.result }; return { toolCallId: toolCall.id, value: { error: result.error.message } }; } function headlessToolResumeCommand(result) { return { resume: result.toolCallId ? { [result.toolCallId]: result.value } : result.value, keyedByInterruptId: false }; } /** * Merge headless-tool results into one resume command. Use interrupt-id keys * whenever the stream provided them so the resume does not need to rediscover * a pending interrupt from mutable client state. */ function headlessToolsBatchResumeCommand(entries) { if (entries.length === 0) return { resume: {}, keyedByInterruptId: false }; if (!entries.every((entry) => entry.interruptId.length > 0) && entries.length === 1) { const [entry] = entries; return headlessToolResumeCommand({ toolCallId: entry.toolCallId, value: entry.value }); } const resume = {}; for (const entry of entries) { if (entry.interruptId.length === 0) continue; resume[entry.interruptId] = entry.toolCallId != null && entry.toolCallId.length > 0 ? { [entry.toolCallId]: entry.value } : entry.value; } return { resume, keyedByInterruptId: true }; } /** * Resume a headless-tool batch on the v1 commands transport. * * {@link headlessToolsBatchResumeCommand} still returns a legacy * `{ resume }` command shape for callers on the old runs/stream API. * On v1 `StreamController`, that payload must be sent through * {@link HeadlessToolResumeController.respond} / * {@link HeadlessToolResumeController.respondAll} — not `submit(null, * { command })`, which dispatches `run.start` without a resume value. */ function applyHeadlessToolResumeCommand(controller, command) { const { resume, keyedByInterruptId } = command; if (resume == null) return Promise.resolve(); if (keyedByInterruptId === true || keyedByInterruptId !== false && isInterruptIdKeyedResume(resume)) return controller.respondAll(resume); return controller.respond(resume); } /** * True when a resume payload is keyed by protocol interrupt id at the top * level (for {@link HeadlessToolResumeController.respondAll}). * * Prefer {@link HeadlessToolResumeCommand.keyedByInterruptId} from * {@link headlessToolsBatchResumeCommand} when ids are not inferable * (for example `{ "int-1": result }` without a nested tool-call map). * * When `interrupts` is provided, a single top-level key that matches a * known protocol interrupt id is treated as interrupt-keyed. */ function isInterruptIdKeyedResume(resume, interrupts) { if (resume == null || typeof resume !== "object" || Array.isArray(resume)) return false; const record = resume; const keys = Object.keys(record); if (keys.length === 0) return false; if (keys.length > 1) return true; const [key] = keys; if (interrupts?.length) { if (new Set(interrupts.map((entry) => entry.interruptId)).has(key)) return true; } return /^[0-9a-f]{32}$/i.test(key); } /** * Reads the tool-call id from a headless-tool resume command shaped as * `{ [toolCallId]: result }`. */ function extractHeadlessToolCallIdFromResumeCommand(resume) { if (resume == null || typeof resume !== "object" || Array.isArray(resume)) return; const keys = Object.keys(resume); if (keys.length !== 1) return void 0; return keys[0]; } /** * Pick the protocol interrupt that matches a headless-tool resume payload. * Falls back to the newest unresolved interrupt for non-keyed resumes. */ function resolveInterruptTargetForHeadlessResume(resume, interrupts, resolvedInterruptIds) { const toolCallId = extractHeadlessToolCallIdFromResumeCommand(resume); if (toolCallId != null) for (let i = interrupts.length - 1; i >= 0; i -= 1) { const entry = interrupts[i]; if (entry == null || resolvedInterruptIds.has(entry.interruptId)) continue; if (parseHeadlessToolInterruptPayload(entry.payload)?.toolCall.id === toolCallId) return { interruptId: entry.interruptId, namespace: [...entry.namespace] }; } for (let i = interrupts.length - 1; i >= 0; i -= 1) { const entry = interrupts[i]; if (entry == null || resolvedInterruptIds.has(entry.interruptId)) continue; return { interruptId: entry.interruptId, namespace: [...entry.namespace] }; } return null; } const coalescedHeadlessFlushes = /* @__PURE__ */ new WeakMap(); /** * Coalesce rapid headless-tool flush triggers into one microtask so parallel * `input.requested` events observed back-to-back batch into a single resume. * Vue/Svelte/Angular watchers run synchronously per event; without this, * the first interrupt can be claimed before the second arrives and resume * splits into staggered single-tool commands. */ function scheduleCoalescedHeadlessToolFlush(handledIds, run) { let state = coalescedHeadlessFlushes.get(handledIds); if (state == null) { state = { scheduled: false, run: () => {} }; coalescedHeadlessFlushes.set(handledIds, state); } state.run = run; if (state.scheduled) return; state.scheduled = true; Promise.resolve().then(() => { state.scheduled = false; state.run(); }); } /** * Execute and resume all newly seen headless-tool interrupts from a values * payload. Callers own `handledIds` and should clear it when the thread changes. */ function flushPendingHeadlessToolInterrupts(values, tools, handledIds, options) { if (!tools?.length || !values) return; const interrupts = values.__interrupt__; if (!Array.isArray(interrupts) || interrupts.length === 0) return; const defer = options.defer ?? ((run) => run()); const pending = []; const seenToolCallIds = /* @__PURE__ */ new Set(); for (const interrupt of interrupts) { const headlessInterrupt = parseHeadlessToolInterruptPayload(interrupt.value); if (!headlessInterrupt) continue; const interruptId = interrupt.id ?? headlessInterrupt.toolCall.id ?? ""; const toolCallId = headlessInterrupt.toolCall.id ?? ""; if (handledIds.has(interruptId)) continue; if (toolCallId && handledIds.has(toolCallId)) continue; if (toolCallId && seenToolCallIds.has(toolCallId)) continue; if (toolCallId) seenToolCallIds.add(toolCallId); handledIds.add(interruptId); if (toolCallId) handledIds.add(toolCallId); pending.push({ interruptId, headlessInterrupt, toolCallId }); } if (pending.length === 0) return; defer(() => { (async () => { const results = await Promise.all(pending.map(async ({ interruptId, headlessInterrupt, toolCallId }) => { const result = await handleHeadlessToolInterrupt(headlessInterrupt, tools, options.onTool); return { interruptId, toolCallId: result.toolCallId ?? toolCallId, value: result.value }; })); await Promise.resolve(options.resumeSubmit(headlessToolsBatchResumeCommand(results))); })(); }); } //#endregion exports.applyHeadlessToolResumeCommand = applyHeadlessToolResumeCommand; exports.executeHeadlessTool = executeHeadlessTool; exports.filterOutHeadlessToolInterrupts = filterOutHeadlessToolInterrupts; exports.findHeadlessTool = findHeadlessTool; exports.flushPendingHeadlessToolInterrupts = flushPendingHeadlessToolInterrupts; exports.handleHeadlessToolInterrupt = handleHeadlessToolInterrupt; exports.headlessToolResumeCommand = headlessToolResumeCommand; exports.isHeadlessToolInterrupt = isHeadlessToolInterrupt; exports.parseHeadlessToolInterruptPayload = parseHeadlessToolInterruptPayload; exports.resolveInterruptTargetForHeadlessResume = resolveInterruptTargetForHeadlessResume; exports.scheduleCoalescedHeadlessToolFlush = scheduleCoalescedHeadlessToolFlush; //# sourceMappingURL=headless-tools.cjs.map