@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
190 lines (189 loc) • 7.27 kB
JavaScript
import { StreamStore } from "../store.js";
import { isInternalWorkNamespace, isRootNamespace, namespaceKey } from "../namespace.js";
//#region src/stream/discovery/subgraphs.ts
/** Stable empty maps — reused on {@link SubgraphDiscovery.reset}. */
const EMPTY_SUBGRAPH_MAP = /* @__PURE__ */ new Map();
const EMPTY_SUBGRAPH_BY_NODE_MAP = /* @__PURE__ */ new Map();
/**
* LangGraph namespaces a node invocation as `<node_name>:<uuid>`
* (parallel fan-outs share `<node_name>` as a prefix but each get a
* fresh uuid). Extract the node-name half so callers can key
* discovery lookups on names they wrote in `addNode(...)`.
*/
function parseNodeName(segment) {
const colon = segment.indexOf(":");
return colon === -1 ? segment : segment.slice(0, colon);
}
var SubgraphDiscovery = class {
store = new StreamStore(/* @__PURE__ */ new Map());
byNodeStore = new StreamStore(/* @__PURE__ */ new Map());
/**
* Latest known status for every namespaced lifecycle event we have
* ever observed. A shadow entry is NOT necessarily a subgraph —
* it is only projected into the committed stores once the same
* namespace also appears in {@link #promoted}.
*/
#shadow = /* @__PURE__ */ new Map();
/**
* Namespaces that have been observed as a strict prefix of a
* deeper namespace and are therefore confirmed subgraph hosts.
* Insertion order is preserved and becomes the iteration order
* of the committed snapshot maps.
*/
#promoted = /* @__PURE__ */ new Set();
/** Feed a single root event. Non-discovery events are ignored. */
push(event) {
if (event.method === "values") {
this.#onValuesEvent(event);
return;
}
if (event.method !== "lifecycle") return;
const lifecycle = event;
const namespace = lifecycle.params.namespace;
if (isRootNamespace(namespace)) return;
const id = namespaceKey(namespace);
const data = lifecycle.params.data;
const nodeName = parseNodeName(namespace[namespace.length - 1] ?? "");
let touched = false;
for (let depth = 1; depth < namespace.length; depth += 1) {
const ancestorId = namespaceKey(namespace.slice(0, depth));
if (!this.#promoted.has(ancestorId)) {
this.#promoted.add(ancestorId);
if (this.#shadow.has(ancestorId)) touched = true;
}
}
if (data.event === "started") {
if (!this.#shadow.has(id)) {
this.#shadow.set(id, {
id,
namespace: [...namespace],
nodeName,
status: "running",
startedAt: /* @__PURE__ */ new Date(),
completedAt: null
});
if (this.#promoted.has(id)) touched = true;
}
} else if (data.event === "completed" || data.event === "interrupted" || data.event === "failed") {
const entry = this.#ensureShadow(id, namespace, nodeName);
if (data.event === "failed") entry.status = "error";
else entry.status = "complete";
entry.completedAt = /* @__PURE__ */ new Date();
if (this.#promoted.has(id)) touched = true;
}
if (touched) this.#commit();
}
/**
* Promote subgraph host namespaces from namespaced `values` snapshots.
*
* Older protocol streams exposed subgraph structure primarily through
* nested `lifecycle` events: a host namespace such as
* `["research:<uuid>"]` was promoted once a deeper namespace like
* `["research:<uuid>", "inner:<uuid>"]` appeared. Some runtimes now
* emit the useful subgraph signal as `values` snapshots scoped directly
* to the host namespace instead, without forwarding inner lifecycle
* events to the client. In that shape, the namespace on the `values`
* event is already the selector target that `useMessages(stream,
* subgraph)` should subscribe to, so we create/promote the shadow
* subgraph entry from that namespace.
*
* Root `values` events are ignored because they represent the parent
* thread state, not a subgraph. Tool/subagent namespaces are also
* ignored because deep-agent task tools emit their own namespaced
* `values` snapshots under `tools:*` / `task:*`; those are discovered
* by `SubagentDiscovery` and must not be duplicated as subgraphs.
*
* A `values` event does not carry lifecycle terminal status, so the
* entry remains marked `running` until a matching lifecycle terminal
* event arrives. If no terminal arrives, the discovery map still
* contains the host namespace, which is the important invariant for
* scoped selectors.
*/
#onValuesEvent(event) {
const namespace = event.params.namespace;
if (isRootNamespace(namespace)) return;
if (isInternalWorkNamespace(namespace)) return;
const data = event.params.data;
if (data == null || typeof data !== "object" || Array.isArray(data)) return;
const id = namespaceKey(namespace);
const nodeName = parseNodeName(namespace[namespace.length - 1] ?? "");
const entry = this.#ensureShadow(id, namespace, nodeName);
if (entry.status !== "complete" && entry.status !== "error") entry.status = "running";
if (!this.#promoted.has(id)) this.#promoted.add(id);
this.#commit();
}
/**
* Seed subgraph hosts from checkpoint history (see
* `namespace-from-history.collectSubgraphHostNamespaces`) so subgraph
* cards render on thread refresh without waiting for the depth-1 SSE
* replay. Supplies the host/promotion decision from history instead
* of the live strict-prefix heuristic, then reuses the same
* `#ensureShadow` / `#promoted` / `#commit` path. Idempotent: never
* downgrades an entry that already reached a terminal state.
*/
seedFromHistory(hosts) {
if (hosts.length === 0) return;
for (const host of hosts) {
if (isRootNamespace(host.namespace)) continue;
const id = namespaceKey(host.namespace);
const lastSegment = host.namespace[host.namespace.length - 1] ?? "";
const entry = this.#ensureShadow(id, host.namespace, parseNodeName(lastSegment));
if (host.status !== "running" && entry.status !== "complete" && entry.status !== "error") {
entry.status = host.status;
entry.completedAt = /* @__PURE__ */ new Date();
}
this.#promoted.add(id);
}
this.#commit();
}
get snapshot() {
return this.store.getSnapshot();
}
get byNodeSnapshot() {
return this.byNodeStore.getSnapshot();
}
/**
* Drop all discovery state. Called on thread rebind / dispose so a
* new thread's subgraphs cannot bleed into the previous UI.
*/
reset() {
this.#shadow.clear();
this.#promoted.clear();
this.store.setValue(EMPTY_SUBGRAPH_MAP);
this.byNodeStore.setValue(EMPTY_SUBGRAPH_BY_NODE_MAP);
}
#ensureShadow(id, namespace, nodeName) {
let entry = this.#shadow.get(id);
if (entry == null) {
entry = {
id,
namespace: [...namespace],
nodeName,
status: "running",
startedAt: /* @__PURE__ */ new Date(),
completedAt: null
};
this.#shadow.set(id, entry);
}
return entry;
}
#commit() {
const snapshots = [];
for (const id of this.#promoted) {
const entry = this.#shadow.get(id);
if (entry == null) continue;
snapshots.push({ ...entry });
}
this.store.setValue(new Map(snapshots.map((s) => [s.id, s])));
const byNode = /* @__PURE__ */ new Map();
for (const snap of snapshots) {
const bucket = byNode.get(snap.nodeName);
if (bucket == null) byNode.set(snap.nodeName, [snap]);
else bucket.push(snap);
}
this.byNodeStore.setValue(byNode);
}
};
//#endregion
export { SubgraphDiscovery };
//# sourceMappingURL=subgraphs.js.map