UNPKG

langchain

Version:
185 lines (184 loc) 6.45 kB
import { ToolMessage } from "@langchain/core/messages"; import { StreamChannel } from "@langchain/langgraph"; //#region src/agents/transformers/tool-call.ts /** * Returns true when `ns` belongs to the agent's own graph — i.e. it * starts with `path` and is at most one level deeper (the agent's * internal nodes like `tools`, `model_request`, etc.). * * Events from subagent subgraphs (two or more levels deeper) are * excluded, so `run.toolCalls` / `run.middleware` only show events * from the agent itself, not from its subagents. */ function isOwnEvent(ns, path) { if (ns.length < path.length || ns.length > path.length + 1) return false; for (let i = 0; i < path.length; i += 1) if (ns[i] !== path[i]) return false; return true; } /** * Detects when a `tool-error` payload is actually a graph interrupt rather * than a genuine tool failure. * * A tool that calls `interrupt()` throws a `GraphInterrupt`, whose message is * the JSON-serialized `Interrupt[]` array. Each entry has the LangGraph * `Interrupt` shape `{ id, value }`: a stable `id` (a hash of the checkpoint * namespace, generated by `interrupt()` and always present during graph * execution) plus the `value` passed to `interrupt(...)`. We require BOTH a * string `id` and a `value` on every entry — a bare `value` is not a reliable * discriminator, since a genuine tool error message can also be a JSON array * of `{ value }` records (e.g. a validator emitting * `[{ "value": "bad input", "message": "invalid" }]`). Keying off the * interrupt `id` keeps real tool failures on the error path. * * An interrupt is control flow that *suspends* the run (the tool re-runs on * resume); it is not an error, so the tool call must stay pending rather than * have its `output` promise rejected. Any interrupt qualifies regardless of * its `value` shape: HITL middleware interrupts (`value.type === "tool"`) and * raw `interrupt(...)` calls from inside a tool are treated identically — * raising an interrupt in a tool must work whether or not * `humanInTheLoopMiddleware` is involved. */ function isToolInterrupt(message) { let parsed; try { parsed = JSON.parse(message); } catch { return false; } if (!Array.isArray(parsed) || parsed.length === 0) return false; return parsed.every((entry) => { if (entry == null || typeof entry !== "object") return false; const record = entry; return typeof record.id === "string" && "value" in record; }); } /** * Detects serialized LangChain `ToolMessage` values that can appear on * `tool-finished.output` after crossing a protocol or serialization boundary. * * @example * ```ts * { * lc: 1, * type: "constructor", * id: ["langchain_core", "messages", "ToolMessage"], * kwargs: { content: "raw tool result", tool_call_id: "call_1" } * } * ``` */ function isSerializedToolMessage(value) { if (value == null || typeof value !== "object") return false; const record = value; if (record.type !== "constructor" || !Array.isArray(record.id)) return false; return record.id[record.id.length - 1] === "ToolMessage"; } function normalizeToolOutput(output) { if (ToolMessage.isInstance(output)) return output.content; if (isSerializedToolMessage(output)) return output.kwargs?.content; return output; } /** * Creates a native transformer that correlates `tools` channel events * into per-call {@link ToolCallStream} objects. * * Marked `__native: true` — projection keys land directly on the * `GraphRunStream` instance as `run.toolCalls`. */ function createToolCallTransformer(path) { return () => { const toolCallsLog = StreamChannel.local(); const pendingCalls = /* @__PURE__ */ new Map(); function createToolCallEntry(callId, name, rawInput) { if (pendingCalls.has(callId)) return; const input = typeof rawInput === "string" ? JSON.parse(rawInput) : rawInput; let resolveOutput; let rejectOutput; let resolveStatus; let resolveError; const output = new Promise((res, rej) => { resolveOutput = res; rejectOutput = rej; }); const status = new Promise((res) => { resolveStatus = res; }); const error = new Promise((res) => { resolveError = res; }); pendingCalls.set(callId, { resolveOutput, rejectOutput, resolveStatus, resolveError }); toolCallsLog.push({ name, callId, input, output, status, error }); } return { __native: true, init: () => ({ toolCalls: toolCallsLog }), process(event) { /** * Only process events that are at the same depth as the agent's graph. */ if (!isOwnEvent(event.params.namespace, path)) return true; if (event.method === "messages") { const data = event.params.data; if (data.event === "content-block-finish") { const cb = data.contentBlock ?? data.content_block; if (cb?.type === "tool_call") createToolCallEntry(String(cb.id ?? ""), String(cb.name ?? ""), cb.args ?? cb.input); } } if (event.method === "tools") { const data = event.params.data; const toolCallId = data.tool_call_id; if (data.event === "tool-started") createToolCallEntry(toolCallId, data.tool_name ?? "unknown", data.input); const pending = toolCallId ? pendingCalls.get(toolCallId) : void 0; if (pending) { if (data.event === "tool-finished") { pending.resolveOutput(normalizeToolOutput(data.output)); pending.resolveStatus("finished"); pending.resolveError(void 0); pendingCalls.delete(toolCallId); } else if (data.event === "tool-error") { const message = data.message ?? "unknown error"; if (isToolInterrupt(message)) return true; pending.rejectOutput(new Error(message)); pending.resolveStatus("error"); pending.resolveError(message); pendingCalls.delete(toolCallId); } } } return true; }, finalize() { for (const pending of pendingCalls.values()) { pending.resolveStatus("finished"); pending.resolveError(void 0); pending.resolveOutput(void 0); } pendingCalls.clear(); toolCallsLog.close(); }, fail(err) { for (const pending of pendingCalls.values()) { pending.resolveStatus("error"); pending.resolveError(err instanceof Error ? err.message : String(err)); pending.rejectOutput(err); } pendingCalls.clear(); toolCallsLog.fail(err); } }; }; } //#endregion export { createToolCallTransformer }; //# sourceMappingURL=tool-call.js.map