UNPKG

@langchain/langgraph

Version:
416 lines (415 loc) 14.7 kB
import { StreamChannel } from "../stream-channel.js"; import { hasPrefix, nsKey } from "../mux.js"; //#region src/stream/transformers/lifecycle.ts /** * Filter a lifecycle {@link StreamChannel} to only the entries whose * namespace lies within the subtree rooted at {@link path}. * * Returns an `AsyncIterable` whose iterator yields every entry whose * namespace either equals {@link path} or is a descendant of it. * Iteration begins at {@link startAt}, so callers can capture the * log's current size at construction time to skip entries emitted * before the caller existed (e.g. a subgraph stream discovered * mid-run shouldn't replay the root's `started`). * * @param log - The shared lifecycle log owned by the transformer. * @param path - Namespace prefix to scope entries by (use `[]` for * the root subtree, i.e. everything). * @param startAt - Zero-based index into the log to begin from. * @returns An async iterable of matching lifecycle entries. */ function filterLifecycleEntries(log, path, startAt = 0) { return { [Symbol.asyncIterator]() { const base = log.iterate(startAt); return { async next() { while (true) { const result = await base.next(); if (result.done) return { value: void 0, done: true }; if (hasPrefix(result.value.namespace, path)) return { value: result.value, done: false }; } } }; } }; } const DEFAULT_ROOT_GRAPH_NAME = "root"; function defaultGuessGraphName(ns) { if (ns.length === 0) return DEFAULT_ROOT_GRAPH_NAME; const last = ns[ns.length - 1]; const colon = last.indexOf(":"); return colon === -1 ? last : last.slice(0, colon); } function defaultSerializeError(err) { if (err instanceof Error) return err.message; if (typeof err === "string") return err; try { return JSON.stringify(err); } catch { return String(err); } } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Extract an upstream `cause` from a `lifecycle.started` payload, if * the shape matches one of the known variants. Shape validation is * intentionally loose: any object with a string `type` is accepted, so * future protocol variants flow through unchanged. */ function extractCause(data) { if (!isRecord(data)) return void 0; if (data.event !== "started") return void 0; const cause = data.cause; if (!isRecord(cause)) return void 0; if (typeof cause.type !== "string") return void 0; return cause; } function extractTaskResultCompletion(data) { if (!isRecord(data)) return void 0; if (!("result" in data)) return void 0; if (typeof data.name !== "string") return void 0; if (typeof data.id !== "string") return void 0; if (data.name.startsWith("__")) return void 0; return { name: data.name, id: data.id }; } /** * Create the built-in lifecycle transformer. * * Marked as a {@link NativeStreamTransformer} so the run stream * factory can expose `_lifecycleLog` via a dedicated getter * (`run.lifecycle`) rather than through `run.extensions`. */ function createLifecycleTransformer(options = {}) { const rootGraphName = options.rootGraphName ?? DEFAULT_ROOT_GRAPH_NAME; const initialStatus = options.initialStatus ?? "running"; const emitRootOnRegister = options.emitRootOnRegister ?? true; const getGraphName = options.getGraphName ?? defaultGuessGraphName; const serializeError = options.serializeError ?? defaultSerializeError; const getTerminalStatusOverride = options.getTerminalStatusOverride; const log = StreamChannel.local(); const namespaces = /* @__PURE__ */ new Map(); const namespaceCause = /* @__PURE__ */ new Map(); /** * `lc_agent_name` observed at each namespace (first task event wins). A * nested run carrying an `lc_agent_name` (set by `createAgent`) is treated * as a named subagent: its `graph_name` becomes that name and a tool-call * `cause` is recovered (see {@link deriveToolCallCause}). Namespaces without * one fall back to the parsed namespace segment, preserving the prior * product-agnostic behavior for plain subgraphs. */ const lcByNs = /* @__PURE__ */ new Map(); /** * Pregel task id -> triggering LLM `tool_call_id`, harvested from a task * whose `input` is a `tool_call_with_context` dict (current shape) or a list * of tool-call dicts (legacy shape). The child subgraph's namespace segment * `node:<task_id>` shares this task id, so a named subagent recovers the tool * call that spawned it across payloads. */ const pendingToolCalls = /* @__PURE__ */ new Map(); const pendingInterruptIds = /* @__PURE__ */ new Set(); /** * Child namespaces whose parent just saw an `updates` event with a * `node` attribution. We defer the `lifecycle.completed` emission * until the *next* inbound event (or `finalize`) so the parent's * `updates` lands on the wire before its child is marked complete - * matching the previous session behavior. */ const pendingCompletions = []; let emitter; let inSelfEmit = 0; let finalized = false; const resolveGraphName = (ns) => { if (ns.length === 0) return rootGraphName; const lc = lcByNs.get(nsKey(ns)); if (typeof lc === "string" && lc.length > 0) return lc; return getGraphName(ns); }; /** * Record a namespace's `lc_agent_name` from a task-start payload (first * event wins). The presence of a name is what marks the namespace a named * subagent; the value may be `undefined` for unnamed runs (still recorded so * a later event doesn't re-evaluate it). */ const recordIdentity = (ns, data) => { const key = nsKey(ns); if (lcByNs.has(key)) return; const lc = (isRecord(data) && isRecord(data.metadata) ? data.metadata : void 0)?.lc_agent_name; lcByNs.set(key, typeof lc === "string" ? lc : void 0); }; /** * Harvest a task's triggering `tool_call_id` keyed by its task id. The * spawned subgraph's namespace segment `node:<task_id>` shares that id, so a * subagent can later recover the tool call that caused it. Two input shapes * are handled: a `tool_call_with_context` object (`input.tool_call.id`) and a * legacy list of tool-call objects (first with a string `id`). */ const recordPendingToolCalls = (data) => { if (!isRecord(data)) return; const taskId = data.id; if (typeof taskId !== "string") return; const input = data.input; let toolCallId; if (isRecord(input) && isRecord(input.tool_call)) { const candidate = input.tool_call.id; if (typeof candidate === "string") toolCallId = candidate; } else if (Array.isArray(input)) { for (const toolCall of input) if (isRecord(toolCall) && typeof toolCall.id === "string") { toolCallId = toolCall.id; break; } } if (toolCallId != null) pendingToolCalls.set(taskId, toolCallId); }; /** * Derive a `toolCall` cause for a named subagent namespace by joining the * namespace segment's task id (`node:<task_id>`) to a previously harvested * `tool_call_id`. Only fires for namespaces carrying an `lc_agent_name`, so * plain subgraphs never get a spurious cause. */ const deriveToolCallCause = (ns) => { if (ns.length === 0) return void 0; const lc = lcByNs.get(nsKey(ns)); if (typeof lc !== "string" || lc.length === 0) return void 0; const segment = ns[ns.length - 1]; const colon = segment.indexOf(":"); if (colon === -1) return void 0; const triggerCallId = segment.slice(colon + 1); if (triggerCallId.length === 0) return void 0; const toolCallId = pendingToolCalls.get(triggerCallId); if (typeof toolCallId !== "string" || toolCallId.length === 0) return; return { type: "toolCall", tool_call_id: toolCallId }; }; /** * Resolve the `cause` to attach to a namespace's `started`. An upstream * `cause` stashed from a product-specific transformer (e.g. deepagents' * SubagentTransformer) wins; otherwise a tool-call cause is recovered for * named subagents. */ const resolveStartCause = (ns) => namespaceCause.get(nsKey(ns)) ?? deriveToolCallCause(ns); const emit = (ns, status, extras) => { const key = nsKey(ns); let current = namespaces.get(key); const graphName = current?.graphName ?? resolveGraphName(ns); if (current != null && current.status === status && current.graphName === graphName && extras?.error == null) return; if (current == null) { current = { namespace: ns, graphName, status }; namespaces.set(key, current); } else current.status = status; const data = { event: status, graph_name: graphName, ...extras?.cause != null ? { cause: extras.cause } : {}, ...extras?.error != null ? { error: extras.error } : {} }; const timestamp = Date.now(); log.push({ namespace: ns, timestamp, ...data }); if (ns.length === 0 && !emitRootOnRegister) return; if (emitter == null) return; inSelfEmit += 1; try { emitter.push(ns, { type: "event", seq: 0, method: "lifecycle", params: { namespace: ns, timestamp, data } }); } finally { inSelfEmit -= 1; } }; /** * Ensures a record exists for `ns` without mutating its status. Used * by hooks that need a canonical `graphName` for lookups before emit * writes the first status. Status remains `undefined` until `emit` * fires. */ const trackNamespace = (ns) => { const key = nsKey(ns); let rec = namespaces.get(key); if (rec == null) { rec = { namespace: ns, graphName: resolveGraphName(ns), status: void 0 }; namespaces.set(key, rec); } return rec; }; const flushPendingCompletions = () => { if (pendingCompletions.length === 0) return; const toFlush = pendingCompletions.splice(0, pendingCompletions.length); for (const completion of toFlush) { const key = nsKey(completion.namespace); const rec = namespaces.get(key); if (rec == null || rec.status !== "started") continue; emit(completion.namespace, "completed"); } }; const enqueueCompletion = (completion) => { const key = nsKey(completion.namespace); const rec = namespaces.get(key); if (rec == null || rec.status !== "started") return; if (pendingCompletions.some((pending) => nsKey(pending.namespace) === key)) return; pendingCompletions.push(completion); }; const removePendingNodeCompletions = (parent, node) => { for (let index = pendingCompletions.length - 1; index >= 0; index -= 1) { const pending = pendingCompletions[index]; if (pending.source.type !== "node") continue; if (pending.source.node !== node) continue; if (nsKey(pending.source.parent) !== nsKey(parent)) continue; pendingCompletions.splice(index, 1); } }; const ensureStarted = (ns) => { for (let length = 1; length <= ns.length; length += 1) { const prefix = ns.slice(0, length); const key = nsKey(prefix); if (namespaces.has(key)) continue; trackNamespace(prefix); const cause = resolveStartCause(prefix); emit(prefix, "started", cause != null ? { cause } : void 0); } }; const defaultTerminalStatus = () => pendingInterruptIds.size > 0 ? "interrupted" : "completed"; const cascadeTerminalStatus = (status) => { for (const rec of namespaces.values()) { if (rec.namespace.length === 0) continue; if (rec.status !== "started") continue; emit(rec.namespace, status); } emit([], status); log.close(); }; const resolveTerminalStatusOverride = async () => { if (getTerminalStatusOverride == null) return defaultTerminalStatus(); try { return await getTerminalStatusOverride() ?? defaultTerminalStatus(); } catch { return defaultTerminalStatus(); } }; const findStartedChildForNode = (parentNamespace, node) => { const prefix = `${node}:`; for (const rec of namespaces.values()) { if (rec.namespace.length !== parentNamespace.length + 1) continue; if (rec.status !== "started") continue; if (!hasPrefix(rec.namespace, parentNamespace)) continue; const last = rec.namespace[rec.namespace.length - 1]; if (last === node || last.startsWith(prefix)) return rec.namespace; } }; const findStartedChildForTask = (parentNamespace, task) => { const namespace = [...parentNamespace, `${task.name}:${task.id}`]; return namespaces.get(nsKey(namespace))?.status === "started" ? namespace : void 0; }; return { __native: true, init() { return { _lifecycleLog: log, lifecycle: filterLifecycleEntries(log, [], 0) }; }, onRegister(handle) { emitter = handle; trackNamespace([]); if (emitRootOnRegister) emit([], initialStatus); }, process(event) { const ns = event.params.namespace; if (inSelfEmit > 0) return true; const taskCompletion = event.method === "tasks" ? extractTaskResultCompletion(event.params.data) : void 0; if (taskCompletion != null) removePendingNodeCompletions(ns, taskCompletion.name); else if (event.method === "tasks") { recordIdentity(ns, event.params.data); recordPendingToolCalls(event.params.data); } flushPendingCompletions(); if (event.method === "lifecycle") { const cause = extractCause(event.params.data); if (cause != null) namespaceCause.set(nsKey(ns), cause); ensureStarted(ns); return false; } ensureStarted(ns); if (event.method === "input" && isRecord(event.params.data) && event.params.data.event === "requested") { const id = event.params.data.id; if (typeof id === "string") pendingInterruptIds.add(id); } if (taskCompletion != null) { const childNamespace = findStartedChildForTask(ns, taskCompletion); if (childNamespace != null) enqueueCompletion({ namespace: childNamespace, source: { type: "task" } }); } if (event.method === "updates") { const node = event.params.node; if (typeof node === "string" && !node.startsWith("__")) { const childNamespace = findStartedChildForNode(ns, node); if (childNamespace != null) enqueueCompletion({ namespace: childNamespace, source: { type: "node", parent: ns, node } }); } } return true; }, finalize() { if (finalized) return; finalized = true; flushPendingCompletions(); if (getTerminalStatusOverride == null) { cascadeTerminalStatus(defaultTerminalStatus()); return; } return resolveTerminalStatusOverride().then(cascadeTerminalStatus).catch((err) => { log.fail(err); }); }, fail(err) { if (finalized) return; finalized = true; const errorMessage = serializeError(err); for (const rec of namespaces.values()) { if (rec.namespace.length === 0) continue; if (rec.status !== "started") continue; emit(rec.namespace, "failed"); } emit([], "failed", { error: errorMessage }); log.fail(err); } }; } //#endregion export { createLifecycleTransformer, filterLifecycleEntries }; //# sourceMappingURL=lifecycle.js.map