langchain
Version:
Typescript bindings for langchain
185 lines (184 loc) • 6.45 kB
JavaScript
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