@langchain/langgraph
Version:
417 lines (416 loc) • 15 kB
JavaScript
const require_stream_channel = require("../stream-channel.cjs");
const require_mux = require("../mux.cjs");
//#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 (require_mux.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 = require_stream_channel.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(require_mux.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 = require_mux.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(require_mux.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(require_mux.nsKey(ns)) ?? deriveToolCallCause(ns);
const emit = (ns, status, extras) => {
const key = require_mux.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 = require_mux.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 = require_mux.nsKey(completion.namespace);
const rec = namespaces.get(key);
if (rec == null || rec.status !== "started") continue;
emit(completion.namespace, "completed");
}
};
const enqueueCompletion = (completion) => {
const key = require_mux.nsKey(completion.namespace);
const rec = namespaces.get(key);
if (rec == null || rec.status !== "started") return;
if (pendingCompletions.some((pending) => require_mux.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 (require_mux.nsKey(pending.source.parent) !== require_mux.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 = require_mux.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 (!require_mux.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(require_mux.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(require_mux.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
exports.createLifecycleTransformer = createLifecycleTransformer;
exports.filterLifecycleEntries = filterLifecycleEntries;
//# sourceMappingURL=lifecycle.cjs.map