@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
276 lines (275 loc) • 9.58 kB
JavaScript
//#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
export { applyHeadlessToolResumeCommand, executeHeadlessTool, filterOutHeadlessToolInterrupts, findHeadlessTool, flushPendingHeadlessToolInterrupts, handleHeadlessToolInterrupt, headlessToolResumeCommand, isHeadlessToolInterrupt, parseHeadlessToolInterruptPayload, resolveInterruptTargetForHeadlessResume, scheduleCoalescedHeadlessToolFlush };
//# sourceMappingURL=headless-tools.js.map