@langchain/langgraph-sdk 1.9.16 → 1.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/base.cjs +70 -4
- package/dist/client/base.cjs.map +1 -1
- package/dist/client/base.d.cts +3 -0
- package/dist/client/base.d.cts.map +1 -1
- package/dist/client/base.d.ts +3 -0
- package/dist/client/base.d.ts.map +1 -1
- package/dist/client/base.js +70 -4
- package/dist/client/base.js.map +1 -1
- package/dist/client/threads/index.cjs +4 -2
- package/dist/client/threads/index.cjs.map +1 -1
- package/dist/client/threads/index.d.cts.map +1 -1
- package/dist/client/threads/index.d.ts.map +1 -1
- package/dist/client/threads/index.js +4 -2
- package/dist/client/threads/index.js.map +1 -1
- package/dist/stream/controller.cjs +451 -32
- package/dist/stream/controller.cjs.map +1 -1
- package/dist/stream/controller.d.cts +15 -0
- package/dist/stream/controller.d.cts.map +1 -1
- package/dist/stream/controller.d.ts +15 -0
- package/dist/stream/controller.d.ts.map +1 -1
- package/dist/stream/controller.js +472 -32
- package/dist/stream/controller.js.map +1 -1
- package/dist/stream/discovery/index.cjs +2 -0
- package/dist/stream/discovery/index.js +3 -0
- package/dist/stream/discovery/namespace-from-history.cjs +207 -0
- package/dist/stream/discovery/namespace-from-history.cjs.map +1 -0
- package/dist/stream/discovery/namespace-from-history.js +204 -0
- package/dist/stream/discovery/namespace-from-history.js.map +1 -0
- package/dist/stream/discovery/subagents.cjs +56 -1
- package/dist/stream/discovery/subagents.cjs.map +1 -1
- package/dist/stream/discovery/subagents.d.cts +31 -0
- package/dist/stream/discovery/subagents.d.cts.map +1 -1
- package/dist/stream/discovery/subagents.d.ts +31 -0
- package/dist/stream/discovery/subagents.d.ts.map +1 -1
- package/dist/stream/discovery/subagents.js +56 -1
- package/dist/stream/discovery/subagents.js.map +1 -1
- package/dist/stream/discovery/subgraphs.cjs +24 -0
- package/dist/stream/discovery/subgraphs.cjs.map +1 -1
- package/dist/stream/discovery/subgraphs.d.cts +13 -0
- package/dist/stream/discovery/subgraphs.d.cts.map +1 -1
- package/dist/stream/discovery/subgraphs.d.ts +13 -0
- package/dist/stream/discovery/subgraphs.d.ts.map +1 -1
- package/dist/stream/discovery/subgraphs.js +24 -0
- package/dist/stream/discovery/subgraphs.js.map +1 -1
- package/dist/stream/index.cjs +1 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/message-coercion.cjs +101 -0
- package/dist/stream/message-coercion.cjs.map +1 -0
- package/dist/stream/message-coercion.d.ts +1 -0
- package/dist/stream/message-coercion.js +98 -0
- package/dist/stream/message-coercion.js.map +1 -0
- package/dist/stream/message-metadata-tracker.cjs +92 -0
- package/dist/stream/message-metadata-tracker.cjs.map +1 -1
- package/dist/stream/message-metadata-tracker.d.cts +23 -0
- package/dist/stream/message-metadata-tracker.d.cts.map +1 -1
- package/dist/stream/message-metadata-tracker.d.ts +23 -0
- package/dist/stream/message-metadata-tracker.d.ts.map +1 -1
- package/dist/stream/message-metadata-tracker.js +92 -0
- package/dist/stream/message-metadata-tracker.js.map +1 -1
- package/dist/stream/message-reconciliation.cjs +2 -2
- package/dist/stream/message-reconciliation.cjs.map +1 -1
- package/dist/stream/message-reconciliation.js +2 -2
- package/dist/stream/message-reconciliation.js.map +1 -1
- package/dist/stream/optimistic-input.cjs +86 -0
- package/dist/stream/optimistic-input.cjs.map +1 -0
- package/dist/stream/optimistic-input.d.ts +1 -0
- package/dist/stream/optimistic-input.js +86 -0
- package/dist/stream/optimistic-input.js.map +1 -0
- package/dist/stream/projections/channel.cjs +1 -0
- package/dist/stream/projections/channel.cjs.map +1 -1
- package/dist/stream/projections/channel.d.cts.map +1 -1
- package/dist/stream/projections/channel.d.ts.map +1 -1
- package/dist/stream/projections/channel.js +1 -0
- package/dist/stream/projections/channel.js.map +1 -1
- package/dist/stream/projections/messages.cjs +24 -14
- package/dist/stream/projections/messages.cjs.map +1 -1
- package/dist/stream/projections/messages.js +21 -11
- package/dist/stream/projections/messages.js.map +1 -1
- package/dist/stream/projections/tool-calls.cjs +22 -10
- package/dist/stream/projections/tool-calls.cjs.map +1 -1
- package/dist/stream/projections/tool-calls.js +22 -10
- package/dist/stream/projections/tool-calls.js.map +1 -1
- package/dist/stream/projections/values.cjs +2 -2
- package/dist/stream/projections/values.cjs.map +1 -1
- package/dist/stream/projections/values.js +1 -1
- package/dist/stream/projections/values.js.map +1 -1
- package/dist/stream/root-message-projection.cjs +130 -3
- package/dist/stream/root-message-projection.cjs.map +1 -1
- package/dist/stream/root-message-projection.js +130 -3
- package/dist/stream/root-message-projection.js.map +1 -1
- package/dist/stream/submit-coordinator.cjs +28 -6
- package/dist/stream/submit-coordinator.cjs.map +1 -1
- package/dist/stream/submit-coordinator.d.cts.map +1 -1
- package/dist/stream/submit-coordinator.d.ts +0 -1
- package/dist/stream/submit-coordinator.d.ts.map +1 -1
- package/dist/stream/submit-coordinator.js +28 -6
- package/dist/stream/submit-coordinator.js.map +1 -1
- package/dist/stream/tool-calls.cjs +32 -0
- package/dist/stream/tool-calls.cjs.map +1 -1
- package/dist/stream/tool-calls.js +32 -1
- package/dist/stream/tool-calls.js.map +1 -1
- package/dist/stream/types.d.cts +43 -0
- package/dist/stream/types.d.cts.map +1 -1
- package/dist/stream/types.d.ts +43 -0
- package/dist/stream/types.d.ts.map +1 -1
- package/dist/ui/index.d.cts +1 -1
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/messages.cjs +4 -50
- package/dist/ui/messages.cjs.map +1 -1
- package/dist/ui/messages.d.cts.map +1 -1
- package/dist/ui/messages.d.ts.map +1 -1
- package/dist/ui/messages.js +3 -48
- package/dist/ui/messages.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subgraphs.js","names":["#onValuesEvent","#promoted","#shadow","#ensureShadow","#commit"],"sources":["../../../src/stream/discovery/subgraphs.ts"],"sourcesContent":["/**\n * Root-scoped subgraph discovery.\n *\n * Watches namespaced `lifecycle` events on the root subscription and\n * assembles two views of the subgraph set:\n *\n * - {@link SubgraphMap}: `Map<namespaceKey, SubgraphDiscoverySnapshot>`,\n * the canonical identity-keyed view consumed by the channel\n * registry and selector hooks.\n * - {@link SubgraphByNodeMap}: `Map<nodeName, readonly\n * SubgraphDiscoverySnapshot[]>`, a convenience index so callers\n * can look up subgraphs by the graph node that produced them\n * (`addNode(\"visualizer_0\", …)`) without parsing namespaces.\n * Arrays preserve insertion order, which matters for parallel\n * fan-outs that share a node name.\n *\n * # What counts as a subgraph\n *\n * The server emits a namespaced `lifecycle` event for every node\n * invocation — a plain function node (`orchestrator`) and a subgraph\n * host (`research`) look identical on the wire. We classify a\n * namespace as a subgraph iff at least one strictly-deeper namespace\n * has been observed with it as a prefix. Concretely, given a stream\n * whose lifecycle events hit the namespaces\n *\n * `[\"orchestrator:u1\"]`\n * `[\"research:u2\"]`\n * `[\"research:u2\", \"researcher:u3\"]`\n * `[\"research:u2\", \"tools:u4\"]`\n * `[\"writer:u5\"]`\n *\n * only `[\"research:u2\"]` is promoted — it's the only namespace that\n * hosts deeper executions. `orchestrator` and `writer` are plain\n * function-node leaves; the `researcher` / `tools` entries are the\n * subgraph's internal nodes, not subgraphs in their own right.\n *\n * Promotion is monotonic (a namespace never loses subgraph status)\n * and retroactive: a namespace whose own `started` event arrived\n * before any descendant is promoted later when the first descendant\n * event lands. Latency is bounded by the gap between a parent node\n * entering and its first inner node materializing — typically tens\n * of milliseconds.\n */\nimport type { Event, LifecycleEvent, ValuesEvent } from \"@langchain/protocol\";\nimport { StreamStore } from \"../store.js\";\nimport type { SubgraphDiscoverySnapshot } from \"../types.js\";\nimport {\n isInternalWorkNamespace,\n isRootNamespace,\n namespaceKey,\n} from \"../namespace.js\";\n\nexport type SubgraphMap = ReadonlyMap<string, SubgraphDiscoverySnapshot>;\nexport type SubgraphByNodeMap = ReadonlyMap<\n string,\n readonly SubgraphDiscoverySnapshot[]\n>;\n\n/** Stable empty maps — reused on {@link SubgraphDiscovery.reset}. */\nconst EMPTY_SUBGRAPH_MAP: SubgraphMap = new Map();\nconst EMPTY_SUBGRAPH_BY_NODE_MAP: SubgraphByNodeMap = new Map();\n\ninterface MutableSubgraph {\n id: string;\n namespace: readonly string[];\n nodeName: string;\n status: \"running\" | \"complete\" | \"error\";\n startedAt: Date;\n completedAt: Date | null;\n}\n\n/**\n * LangGraph namespaces a node invocation as `<node_name>:<uuid>`\n * (parallel fan-outs share `<node_name>` as a prefix but each get a\n * fresh uuid). Extract the node-name half so callers can key\n * discovery lookups on names they wrote in `addNode(...)`.\n */\nfunction parseNodeName(segment: string): string {\n const colon = segment.indexOf(\":\");\n return colon === -1 ? segment : segment.slice(0, colon);\n}\n\nexport class SubgraphDiscovery {\n readonly store = new StreamStore<SubgraphMap>(new Map());\n readonly byNodeStore = new StreamStore<SubgraphByNodeMap>(new Map());\n\n /**\n * Latest known status for every namespaced lifecycle event we have\n * ever observed. A shadow entry is NOT necessarily a subgraph —\n * it is only projected into the committed stores once the same\n * namespace also appears in {@link #promoted}.\n */\n #shadow = new Map<string, MutableSubgraph>();\n\n /**\n * Namespaces that have been observed as a strict prefix of a\n * deeper namespace and are therefore confirmed subgraph hosts.\n * Insertion order is preserved and becomes the iteration order\n * of the committed snapshot maps.\n */\n #promoted = new Set<string>();\n\n /** Feed a single root event. Non-discovery events are ignored. */\n push(event: Event): void {\n if (event.method === \"values\") {\n this.#onValuesEvent(event as ValuesEvent);\n return;\n }\n if (event.method !== \"lifecycle\") return;\n const lifecycle = event as LifecycleEvent;\n const namespace = lifecycle.params.namespace;\n // Root lifecycle events describe the main run; subgraph discovery\n // only cares about namespaced lifecycle events.\n if (isRootNamespace(namespace)) return;\n const id = namespaceKey(namespace);\n const data = lifecycle.params.data as { event?: string };\n const lastSegment = namespace[namespace.length - 1] ?? \"\";\n const nodeName = parseNodeName(lastSegment);\n\n let touched = false;\n\n // Promote every strict ancestor the first time we see it as a\n // prefix. The ancestor may or may not yet have a shadow entry;\n // #commit() tolerates either case.\n for (let depth = 1; depth < namespace.length; depth += 1) {\n const ancestorId = namespaceKey(namespace.slice(0, depth));\n if (!this.#promoted.has(ancestorId)) {\n this.#promoted.add(ancestorId);\n if (this.#shadow.has(ancestorId)) touched = true;\n }\n }\n\n // Update shadow status for this namespace itself.\n if (data.event === \"started\") {\n if (!this.#shadow.has(id)) {\n this.#shadow.set(id, {\n id,\n namespace: [...namespace],\n nodeName,\n status: \"running\",\n startedAt: new Date(),\n completedAt: null,\n });\n if (this.#promoted.has(id)) touched = true;\n }\n } else if (\n data.event === \"completed\" ||\n data.event === \"interrupted\" ||\n data.event === \"failed\"\n ) {\n // Synthesize a shadow entry if we missed the `started` event\n // (common when a late subscription attaches to a running run).\n const entry = this.#ensureShadow(id, namespace, nodeName);\n if (data.event === \"failed\") {\n entry.status = \"error\";\n } else {\n entry.status = \"complete\";\n }\n entry.completedAt = new Date();\n if (this.#promoted.has(id)) touched = true;\n }\n\n if (touched) this.#commit();\n }\n\n /**\n * Promote subgraph host namespaces from namespaced `values` snapshots.\n *\n * Older protocol streams exposed subgraph structure primarily through\n * nested `lifecycle` events: a host namespace such as\n * `[\"research:<uuid>\"]` was promoted once a deeper namespace like\n * `[\"research:<uuid>\", \"inner:<uuid>\"]` appeared. Some runtimes now\n * emit the useful subgraph signal as `values` snapshots scoped directly\n * to the host namespace instead, without forwarding inner lifecycle\n * events to the client. In that shape, the namespace on the `values`\n * event is already the selector target that `useMessages(stream,\n * subgraph)` should subscribe to, so we create/promote the shadow\n * subgraph entry from that namespace.\n *\n * Root `values` events are ignored because they represent the parent\n * thread state, not a subgraph. Tool/subagent namespaces are also\n * ignored because deep-agent task tools emit their own namespaced\n * `values` snapshots under `tools:*` / `task:*`; those are discovered\n * by `SubagentDiscovery` and must not be duplicated as subgraphs.\n *\n * A `values` event does not carry lifecycle terminal status, so the\n * entry remains marked `running` until a matching lifecycle terminal\n * event arrives. If no terminal arrives, the discovery map still\n * contains the host namespace, which is the important invariant for\n * scoped selectors.\n */\n #onValuesEvent(event: ValuesEvent): void {\n const namespace = event.params.namespace;\n if (isRootNamespace(namespace)) return;\n if (isInternalWorkNamespace(namespace)) return;\n const data = event.params.data;\n if (data == null || typeof data !== \"object\" || Array.isArray(data)) return;\n\n const id = namespaceKey(namespace);\n const lastSegment = namespace[namespace.length - 1] ?? \"\";\n const nodeName = parseNodeName(lastSegment);\n const entry = this.#ensureShadow(id, namespace, nodeName);\n // A `values` snapshot is a discovery/promotion signal, not a status\n // transition — terminal status is owned by `lifecycle` events. The\n // content pump (which carries `values`) and the lifecycle watcher\n // (which carries `lifecycle`) are independent streams deduped through\n // `onEvent`, so a host namespace's final `values` snapshot can be\n // observed AFTER its terminal `completed`/`failed` lifecycle event.\n // Unconditionally writing \"running\" here would resurrect a finished\n // subgraph and strand it as perpetually running in the UI. New\n // entries are already created as \"running\" by `#ensureShadow`, so we\n // only need to avoid downgrading an entry that already reached a\n // terminal state.\n if (entry.status !== \"complete\" && entry.status !== \"error\") {\n entry.status = \"running\";\n }\n\n if (!this.#promoted.has(id)) {\n this.#promoted.add(id);\n }\n this.#commit();\n }\n\n get snapshot(): SubgraphMap {\n return this.store.getSnapshot();\n }\n\n get byNodeSnapshot(): SubgraphByNodeMap {\n return this.byNodeStore.getSnapshot();\n }\n\n /**\n * Drop all discovery state. Called on thread rebind / dispose so a\n * new thread's subgraphs cannot bleed into the previous UI.\n */\n reset(): void {\n this.#shadow.clear();\n this.#promoted.clear();\n this.store.setValue(EMPTY_SUBGRAPH_MAP);\n this.byNodeStore.setValue(EMPTY_SUBGRAPH_BY_NODE_MAP);\n }\n\n #ensureShadow(\n id: string,\n namespace: readonly string[],\n nodeName: string\n ): MutableSubgraph {\n let entry = this.#shadow.get(id);\n if (entry == null) {\n entry = {\n id,\n namespace: [...namespace],\n nodeName,\n status: \"running\",\n startedAt: new Date(),\n completedAt: null,\n };\n this.#shadow.set(id, entry);\n }\n return entry;\n }\n\n #commit(): void {\n const snapshots: SubgraphDiscoverySnapshot[] = [];\n for (const id of this.#promoted) {\n const entry = this.#shadow.get(id);\n // A namespace can be promoted before its own lifecycle event\n // arrives if descendant events outpace the prefix event. Skip\n // until the shadow entry lands; the next push() promoting or\n // updating this namespace will re-commit.\n if (entry == null) continue;\n snapshots.push({ ...entry });\n }\n\n this.store.setValue(new Map(snapshots.map((s) => [s.id, s])));\n\n const byNode = new Map<string, SubgraphDiscoverySnapshot[]>();\n for (const snap of snapshots) {\n const bucket = byNode.get(snap.nodeName);\n if (bucket == null) byNode.set(snap.nodeName, [snap]);\n else bucket.push(snap);\n }\n this.byNodeStore.setValue(byNode);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"subgraphs.js","names":["#onValuesEvent","#promoted","#shadow","#ensureShadow","#commit"],"sources":["../../../src/stream/discovery/subgraphs.ts"],"sourcesContent":["/**\n * Root-scoped subgraph discovery.\n *\n * Watches namespaced `lifecycle` events on the root subscription and\n * assembles two views of the subgraph set:\n *\n * - {@link SubgraphMap}: `Map<namespaceKey, SubgraphDiscoverySnapshot>`,\n * the canonical identity-keyed view consumed by the channel\n * registry and selector hooks.\n * - {@link SubgraphByNodeMap}: `Map<nodeName, readonly\n * SubgraphDiscoverySnapshot[]>`, a convenience index so callers\n * can look up subgraphs by the graph node that produced them\n * (`addNode(\"visualizer_0\", …)`) without parsing namespaces.\n * Arrays preserve insertion order, which matters for parallel\n * fan-outs that share a node name.\n *\n * # What counts as a subgraph\n *\n * The server emits a namespaced `lifecycle` event for every node\n * invocation — a plain function node (`orchestrator`) and a subgraph\n * host (`research`) look identical on the wire. We classify a\n * namespace as a subgraph iff at least one strictly-deeper namespace\n * has been observed with it as a prefix. Concretely, given a stream\n * whose lifecycle events hit the namespaces\n *\n * `[\"orchestrator:u1\"]`\n * `[\"research:u2\"]`\n * `[\"research:u2\", \"researcher:u3\"]`\n * `[\"research:u2\", \"tools:u4\"]`\n * `[\"writer:u5\"]`\n *\n * only `[\"research:u2\"]` is promoted — it's the only namespace that\n * hosts deeper executions. `orchestrator` and `writer` are plain\n * function-node leaves; the `researcher` / `tools` entries are the\n * subgraph's internal nodes, not subgraphs in their own right.\n *\n * Promotion is monotonic (a namespace never loses subgraph status)\n * and retroactive: a namespace whose own `started` event arrived\n * before any descendant is promoted later when the first descendant\n * event lands. Latency is bounded by the gap between a parent node\n * entering and its first inner node materializing — typically tens\n * of milliseconds.\n */\nimport type { Event, LifecycleEvent, ValuesEvent } from \"@langchain/protocol\";\nimport { StreamStore } from \"../store.js\";\nimport type { SubgraphDiscoverySnapshot } from \"../types.js\";\nimport {\n isInternalWorkNamespace,\n isRootNamespace,\n namespaceKey,\n} from \"../namespace.js\";\n\nexport type SubgraphMap = ReadonlyMap<string, SubgraphDiscoverySnapshot>;\nexport type SubgraphByNodeMap = ReadonlyMap<\n string,\n readonly SubgraphDiscoverySnapshot[]\n>;\n\n/** Stable empty maps — reused on {@link SubgraphDiscovery.reset}. */\nconst EMPTY_SUBGRAPH_MAP: SubgraphMap = new Map();\nconst EMPTY_SUBGRAPH_BY_NODE_MAP: SubgraphByNodeMap = new Map();\n\ninterface MutableSubgraph {\n id: string;\n namespace: readonly string[];\n nodeName: string;\n status: \"running\" | \"complete\" | \"error\";\n startedAt: Date;\n completedAt: Date | null;\n}\n\n/**\n * LangGraph namespaces a node invocation as `<node_name>:<uuid>`\n * (parallel fan-outs share `<node_name>` as a prefix but each get a\n * fresh uuid). Extract the node-name half so callers can key\n * discovery lookups on names they wrote in `addNode(...)`.\n */\nfunction parseNodeName(segment: string): string {\n const colon = segment.indexOf(\":\");\n return colon === -1 ? segment : segment.slice(0, colon);\n}\n\nexport class SubgraphDiscovery {\n readonly store = new StreamStore<SubgraphMap>(new Map());\n readonly byNodeStore = new StreamStore<SubgraphByNodeMap>(new Map());\n\n /**\n * Latest known status for every namespaced lifecycle event we have\n * ever observed. A shadow entry is NOT necessarily a subgraph —\n * it is only projected into the committed stores once the same\n * namespace also appears in {@link #promoted}.\n */\n #shadow = new Map<string, MutableSubgraph>();\n\n /**\n * Namespaces that have been observed as a strict prefix of a\n * deeper namespace and are therefore confirmed subgraph hosts.\n * Insertion order is preserved and becomes the iteration order\n * of the committed snapshot maps.\n */\n #promoted = new Set<string>();\n\n /** Feed a single root event. Non-discovery events are ignored. */\n push(event: Event): void {\n if (event.method === \"values\") {\n this.#onValuesEvent(event as ValuesEvent);\n return;\n }\n if (event.method !== \"lifecycle\") return;\n const lifecycle = event as LifecycleEvent;\n const namespace = lifecycle.params.namespace;\n // Root lifecycle events describe the main run; subgraph discovery\n // only cares about namespaced lifecycle events.\n if (isRootNamespace(namespace)) return;\n const id = namespaceKey(namespace);\n const data = lifecycle.params.data as { event?: string };\n const lastSegment = namespace[namespace.length - 1] ?? \"\";\n const nodeName = parseNodeName(lastSegment);\n\n let touched = false;\n\n // Promote every strict ancestor the first time we see it as a\n // prefix. The ancestor may or may not yet have a shadow entry;\n // #commit() tolerates either case.\n for (let depth = 1; depth < namespace.length; depth += 1) {\n const ancestorId = namespaceKey(namespace.slice(0, depth));\n if (!this.#promoted.has(ancestorId)) {\n this.#promoted.add(ancestorId);\n if (this.#shadow.has(ancestorId)) touched = true;\n }\n }\n\n // Update shadow status for this namespace itself.\n if (data.event === \"started\") {\n if (!this.#shadow.has(id)) {\n this.#shadow.set(id, {\n id,\n namespace: [...namespace],\n nodeName,\n status: \"running\",\n startedAt: new Date(),\n completedAt: null,\n });\n if (this.#promoted.has(id)) touched = true;\n }\n } else if (\n data.event === \"completed\" ||\n data.event === \"interrupted\" ||\n data.event === \"failed\"\n ) {\n // Synthesize a shadow entry if we missed the `started` event\n // (common when a late subscription attaches to a running run).\n const entry = this.#ensureShadow(id, namespace, nodeName);\n if (data.event === \"failed\") {\n entry.status = \"error\";\n } else {\n entry.status = \"complete\";\n }\n entry.completedAt = new Date();\n if (this.#promoted.has(id)) touched = true;\n }\n\n if (touched) this.#commit();\n }\n\n /**\n * Promote subgraph host namespaces from namespaced `values` snapshots.\n *\n * Older protocol streams exposed subgraph structure primarily through\n * nested `lifecycle` events: a host namespace such as\n * `[\"research:<uuid>\"]` was promoted once a deeper namespace like\n * `[\"research:<uuid>\", \"inner:<uuid>\"]` appeared. Some runtimes now\n * emit the useful subgraph signal as `values` snapshots scoped directly\n * to the host namespace instead, without forwarding inner lifecycle\n * events to the client. In that shape, the namespace on the `values`\n * event is already the selector target that `useMessages(stream,\n * subgraph)` should subscribe to, so we create/promote the shadow\n * subgraph entry from that namespace.\n *\n * Root `values` events are ignored because they represent the parent\n * thread state, not a subgraph. Tool/subagent namespaces are also\n * ignored because deep-agent task tools emit their own namespaced\n * `values` snapshots under `tools:*` / `task:*`; those are discovered\n * by `SubagentDiscovery` and must not be duplicated as subgraphs.\n *\n * A `values` event does not carry lifecycle terminal status, so the\n * entry remains marked `running` until a matching lifecycle terminal\n * event arrives. If no terminal arrives, the discovery map still\n * contains the host namespace, which is the important invariant for\n * scoped selectors.\n */\n #onValuesEvent(event: ValuesEvent): void {\n const namespace = event.params.namespace;\n if (isRootNamespace(namespace)) return;\n if (isInternalWorkNamespace(namespace)) return;\n const data = event.params.data;\n if (data == null || typeof data !== \"object\" || Array.isArray(data)) return;\n\n const id = namespaceKey(namespace);\n const lastSegment = namespace[namespace.length - 1] ?? \"\";\n const nodeName = parseNodeName(lastSegment);\n const entry = this.#ensureShadow(id, namespace, nodeName);\n // A `values` snapshot is a discovery/promotion signal, not a status\n // transition — terminal status is owned by `lifecycle` events. The\n // content pump (which carries `values`) and the lifecycle watcher\n // (which carries `lifecycle`) are independent streams deduped through\n // `onEvent`, so a host namespace's final `values` snapshot can be\n // observed AFTER its terminal `completed`/`failed` lifecycle event.\n // Unconditionally writing \"running\" here would resurrect a finished\n // subgraph and strand it as perpetually running in the UI. New\n // entries are already created as \"running\" by `#ensureShadow`, so we\n // only need to avoid downgrading an entry that already reached a\n // terminal state.\n if (entry.status !== \"complete\" && entry.status !== \"error\") {\n entry.status = \"running\";\n }\n\n if (!this.#promoted.has(id)) {\n this.#promoted.add(id);\n }\n this.#commit();\n }\n\n /**\n * Seed subgraph hosts from checkpoint history (see\n * `namespace-from-history.collectSubgraphHostNamespaces`) so subgraph\n * cards render on thread refresh without waiting for the depth-1 SSE\n * replay. Supplies the host/promotion decision from history instead\n * of the live strict-prefix heuristic, then reuses the same\n * `#ensureShadow` / `#promoted` / `#commit` path. Idempotent: never\n * downgrades an entry that already reached a terminal state.\n */\n seedFromHistory(\n hosts: Array<{\n namespace: string[];\n status: \"running\" | \"complete\" | \"error\";\n }>\n ): void {\n if (hosts.length === 0) return;\n for (const host of hosts) {\n if (isRootNamespace(host.namespace)) continue;\n const id = namespaceKey(host.namespace);\n const lastSegment = host.namespace[host.namespace.length - 1] ?? \"\";\n const entry = this.#ensureShadow(\n id,\n host.namespace,\n parseNodeName(lastSegment)\n );\n if (\n host.status !== \"running\" &&\n entry.status !== \"complete\" &&\n entry.status !== \"error\"\n ) {\n entry.status = host.status;\n entry.completedAt = new Date();\n }\n this.#promoted.add(id);\n }\n this.#commit();\n }\n\n get snapshot(): SubgraphMap {\n return this.store.getSnapshot();\n }\n\n get byNodeSnapshot(): SubgraphByNodeMap {\n return this.byNodeStore.getSnapshot();\n }\n\n /**\n * Drop all discovery state. Called on thread rebind / dispose so a\n * new thread's subgraphs cannot bleed into the previous UI.\n */\n reset(): void {\n this.#shadow.clear();\n this.#promoted.clear();\n this.store.setValue(EMPTY_SUBGRAPH_MAP);\n this.byNodeStore.setValue(EMPTY_SUBGRAPH_BY_NODE_MAP);\n }\n\n #ensureShadow(\n id: string,\n namespace: readonly string[],\n nodeName: string\n ): MutableSubgraph {\n let entry = this.#shadow.get(id);\n if (entry == null) {\n entry = {\n id,\n namespace: [...namespace],\n nodeName,\n status: \"running\",\n startedAt: new Date(),\n completedAt: null,\n };\n this.#shadow.set(id, entry);\n }\n return entry;\n }\n\n #commit(): void {\n const snapshots: SubgraphDiscoverySnapshot[] = [];\n for (const id of this.#promoted) {\n const entry = this.#shadow.get(id);\n // A namespace can be promoted before its own lifecycle event\n // arrives if descendant events outpace the prefix event. Skip\n // until the shadow entry lands; the next push() promoting or\n // updating this namespace will re-commit.\n if (entry == null) continue;\n snapshots.push({ ...entry });\n }\n\n this.store.setValue(new Map(snapshots.map((s) => [s.id, s])));\n\n const byNode = new Map<string, SubgraphDiscoverySnapshot[]>();\n for (const snap of snapshots) {\n const bucket = byNode.get(snap.nodeName);\n if (bucket == null) byNode.set(snap.nodeName, [snap]);\n else bucket.push(snap);\n }\n this.byNodeStore.setValue(byNode);\n }\n}\n"],"mappings":"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"}
|
package/dist/stream/index.cjs
CHANGED
|
@@ -6,6 +6,7 @@ const require_store = require("./store.cjs");
|
|
|
6
6
|
const require_channel_registry = require("./channel-registry.cjs");
|
|
7
7
|
const require_subagents = require("./discovery/subagents.cjs");
|
|
8
8
|
const require_subgraphs = require("./discovery/subgraphs.cjs");
|
|
9
|
+
require("./discovery/index.cjs");
|
|
9
10
|
const require_assembled_to_message = require("./assembled-to-message.cjs");
|
|
10
11
|
const require_controller = require("./controller.cjs");
|
|
11
12
|
const require_messages = require("./projections/messages.cjs");
|
package/dist/stream/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StreamStore } from "./store.js";
|
|
|
5
5
|
import { ChannelRegistry } from "./channel-registry.js";
|
|
6
6
|
import { SubagentDiscovery } from "./discovery/subagents.js";
|
|
7
7
|
import { SubgraphDiscovery } from "./discovery/subgraphs.js";
|
|
8
|
+
import "./discovery/index.js";
|
|
8
9
|
import { assembledMessageToBaseMessage, assembledToBaseMessage } from "./assembled-to-message.js";
|
|
9
10
|
import { ROOT_PUMP_CHANNELS, StreamController } from "./controller.js";
|
|
10
11
|
import { messagesProjection } from "./projections/messages.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require("../_virtual/_rolldown/runtime.cjs");
|
|
2
|
+
let _langchain_core_messages = require("@langchain/core/messages");
|
|
3
|
+
//#region src/stream/message-coercion.ts
|
|
4
|
+
/**
|
|
5
|
+
* Stream-local message coercion for serialized messages returned by
|
|
6
|
+
* `getState()`, `getHistory()`, and `values` events.
|
|
7
|
+
*
|
|
8
|
+
* LangGraph API payloads may carry v1 content blocks as snake_case
|
|
9
|
+
* `content_blocks`, while `@langchain/core` message constructors only
|
|
10
|
+
* understand camelCase `contentBlocks` (or `content`). Normalize that
|
|
11
|
+
* boundary here so stream consumers always see `BaseMessage.text`.
|
|
12
|
+
*/
|
|
13
|
+
function tryCoerceMessageLikeToMessage(message) {
|
|
14
|
+
const normalized = normalizeAIMessageToolCalls(message);
|
|
15
|
+
if (normalized.type === "human" || normalized.type === "user") return new _langchain_core_messages.HumanMessage(normalized);
|
|
16
|
+
if (normalized.type === "ai" || normalized.type === "assistant") return new _langchain_core_messages.AIMessage(normalized);
|
|
17
|
+
if (normalized.type === "system") return new _langchain_core_messages.SystemMessage(normalized);
|
|
18
|
+
if (normalized.type === "tool" && "tool_call_id" in normalized) return new _langchain_core_messages.ToolMessage({
|
|
19
|
+
...normalized,
|
|
20
|
+
tool_call_id: normalized.tool_call_id
|
|
21
|
+
});
|
|
22
|
+
if (normalized.type === "remove" && normalized.id != null) return new _langchain_core_messages.RemoveMessage({
|
|
23
|
+
...normalized,
|
|
24
|
+
id: normalized.id
|
|
25
|
+
});
|
|
26
|
+
return (0, _langchain_core_messages.coerceMessageLikeToMessage)(normalized);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Ensures all messages in an array are BaseMessage class instances.
|
|
30
|
+
* Messages that are already class instances pass through unchanged.
|
|
31
|
+
*/
|
|
32
|
+
function ensureMessageInstances(messages) {
|
|
33
|
+
return messages.map((msg) => {
|
|
34
|
+
if (typeof msg.getType === "function") return msg;
|
|
35
|
+
return tryCoerceMessageLikeToMessage(msg);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function normalizeSerializedContentBlocks(message) {
|
|
39
|
+
const record = message;
|
|
40
|
+
const contentBlocks = record.contentBlocks ?? record.content_blocks;
|
|
41
|
+
if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) return message;
|
|
42
|
+
const shouldPreferContentBlocks = isEmptyContent(record.content) || !hasTextContent(record.content) && hasTextContent(contentBlocks);
|
|
43
|
+
if (!shouldPreferContentBlocks && record.contentBlocks === contentBlocks) return message;
|
|
44
|
+
return {
|
|
45
|
+
...message,
|
|
46
|
+
content: shouldPreferContentBlocks ? contentBlocks : record.content,
|
|
47
|
+
contentBlocks
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function normalizeAIMessageToolCalls(message) {
|
|
51
|
+
const normalized = normalizeSerializedContentBlocks(message);
|
|
52
|
+
const record = normalized;
|
|
53
|
+
if (Array.isArray(record.tool_calls) && record.tool_calls.length > 0) return normalized;
|
|
54
|
+
const toolCalls = extractToolCallsFromContent(record.content);
|
|
55
|
+
if (toolCalls.length === 0) return normalized;
|
|
56
|
+
return {
|
|
57
|
+
...normalized,
|
|
58
|
+
tool_calls: toolCalls
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function extractToolCallsFromContent(content) {
|
|
62
|
+
if (!Array.isArray(content)) return [];
|
|
63
|
+
return content.flatMap((block) => {
|
|
64
|
+
if (block == null || typeof block !== "object") return [];
|
|
65
|
+
const record = block;
|
|
66
|
+
if (record.type !== "tool_call" && record.type !== "tool_use") return [];
|
|
67
|
+
return [{
|
|
68
|
+
id: record.id ?? "",
|
|
69
|
+
name: record.name ?? "",
|
|
70
|
+
args: normalizeToolCallArgs(record.args ?? record.input),
|
|
71
|
+
type: "tool_call"
|
|
72
|
+
}];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function normalizeToolCallArgs(value) {
|
|
76
|
+
if (value != null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
77
|
+
if (typeof value === "string" && value.length > 0) try {
|
|
78
|
+
const parsed = JSON.parse(value);
|
|
79
|
+
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
80
|
+
} catch {}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
function isEmptyContent(content) {
|
|
84
|
+
return content == null || content === "" || Array.isArray(content) && content.length === 0;
|
|
85
|
+
}
|
|
86
|
+
function hasTextContent(content) {
|
|
87
|
+
if (typeof content === "string") return content.length > 0;
|
|
88
|
+
if (!Array.isArray(content)) return false;
|
|
89
|
+
return content.some((block) => {
|
|
90
|
+
if (typeof block === "string") return block.length > 0;
|
|
91
|
+
if (block == null || typeof block !== "object") return false;
|
|
92
|
+
const record = block;
|
|
93
|
+
return record.type === "text" && typeof record.text === "string" && record.text.length > 0;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
exports.ensureMessageInstances = ensureMessageInstances;
|
|
98
|
+
exports.normalizeAIMessageToolCalls = normalizeAIMessageToolCalls;
|
|
99
|
+
exports.tryCoerceMessageLikeToMessage = tryCoerceMessageLikeToMessage;
|
|
100
|
+
|
|
101
|
+
//# sourceMappingURL=message-coercion.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-coercion.cjs","names":["HumanMessage","AIMessage","SystemMessage","ToolMessage","RemoveMessage"],"sources":["../../src/stream/message-coercion.ts"],"sourcesContent":["import type { BaseMessage, BaseMessageChunk } from \"@langchain/core/messages\";\nimport {\n AIMessage,\n HumanMessage,\n RemoveMessage,\n SystemMessage,\n ToolMessage,\n coerceMessageLikeToMessage,\n} from \"@langchain/core/messages\";\nimport type { Message } from \"../types.messages.js\";\n\ntype MessageLike = Omit<Message, \"type\"> & { type: string };\n\ntype ToolCallLike = {\n id?: string;\n name?: string;\n args?: unknown;\n input?: unknown;\n};\n\ntype SerializedMessageWithContentBlocks = MessageLike & {\n content?: unknown;\n contentBlocks?: unknown;\n content_blocks?: unknown;\n};\n\n/**\n * Stream-local message coercion for serialized messages returned by\n * `getState()`, `getHistory()`, and `values` events.\n *\n * LangGraph API payloads may carry v1 content blocks as snake_case\n * `content_blocks`, while `@langchain/core` message constructors only\n * understand camelCase `contentBlocks` (or `content`). Normalize that\n * boundary here so stream consumers always see `BaseMessage.text`.\n */\nexport function tryCoerceMessageLikeToMessage(\n message: MessageLike\n): BaseMessage | BaseMessageChunk {\n const normalized = normalizeAIMessageToolCalls(message);\n\n if (normalized.type === \"human\" || normalized.type === \"user\") {\n return new HumanMessage(normalized);\n }\n\n if (normalized.type === \"ai\" || normalized.type === \"assistant\") {\n return new AIMessage(normalized);\n }\n\n if (normalized.type === \"system\") {\n return new SystemMessage(normalized);\n }\n\n if (normalized.type === \"tool\" && \"tool_call_id\" in normalized) {\n return new ToolMessage({\n ...normalized,\n tool_call_id: normalized.tool_call_id as string,\n });\n }\n\n if (normalized.type === \"remove\" && normalized.id != null) {\n return new RemoveMessage({ ...normalized, id: normalized.id });\n }\n\n return coerceMessageLikeToMessage(normalized);\n}\n\n/**\n * Ensures all messages in an array are BaseMessage class instances.\n * Messages that are already class instances pass through unchanged.\n */\nexport function ensureMessageInstances(\n messages: (Message | BaseMessage)[]\n): (BaseMessage | BaseMessageChunk)[] {\n return messages.map((msg) => {\n if (typeof (msg as BaseMessage).getType === \"function\") {\n return msg as BaseMessage;\n }\n return tryCoerceMessageLikeToMessage(\n msg as Omit<Message, \"type\"> & { type: string }\n );\n });\n}\n\nfunction normalizeSerializedContentBlocks<T extends MessageLike>(\n message: T\n): T {\n const record = message as SerializedMessageWithContentBlocks;\n const contentBlocks = record.contentBlocks ?? record.content_blocks;\n if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) {\n return message;\n }\n\n const shouldPreferContentBlocks =\n isEmptyContent(record.content) ||\n (!hasTextContent(record.content) && hasTextContent(contentBlocks));\n if (!shouldPreferContentBlocks && record.contentBlocks === contentBlocks) {\n return message;\n }\n\n return {\n ...message,\n content: shouldPreferContentBlocks ? contentBlocks : record.content,\n contentBlocks,\n } as T;\n}\n\nexport function normalizeAIMessageToolCalls<T extends MessageLike>(\n message: T\n): T {\n const normalized = normalizeSerializedContentBlocks(message);\n const record = normalized as T & {\n content?: unknown;\n tool_calls?: unknown;\n };\n if (Array.isArray(record.tool_calls) && record.tool_calls.length > 0) {\n return normalized;\n }\n\n const toolCalls = extractToolCallsFromContent(record.content);\n if (toolCalls.length === 0) return normalized;\n return {\n ...normalized,\n tool_calls: toolCalls,\n };\n}\n\nfunction extractToolCallsFromContent(content: unknown) {\n if (!Array.isArray(content)) return [];\n return content.flatMap(\n (\n block\n ): Array<{\n id: string;\n name: string;\n args: Record<string, unknown>;\n type: \"tool_call\";\n }> => {\n if (block == null || typeof block !== \"object\") return [];\n const record = block as ToolCallLike & { type?: unknown };\n if (record.type !== \"tool_call\" && record.type !== \"tool_use\") return [];\n return [\n {\n id: record.id ?? \"\",\n name: record.name ?? \"\",\n args: normalizeToolCallArgs(record.args ?? record.input),\n type: \"tool_call\",\n },\n ];\n }\n );\n}\n\nfunction normalizeToolCallArgs(value: unknown): Record<string, unknown> {\n if (value != null && typeof value === \"object\" && !Array.isArray(value)) {\n return value as Record<string, unknown>;\n }\n if (typeof value === \"string\" && value.length > 0) {\n try {\n const parsed = JSON.parse(value);\n if (\n parsed != null &&\n typeof parsed === \"object\" &&\n !Array.isArray(parsed)\n ) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Streaming input fragments are expected to be invalid until finalized.\n }\n }\n return {};\n}\n\nfunction isEmptyContent(content: unknown): boolean {\n return (\n content == null ||\n content === \"\" ||\n (Array.isArray(content) && content.length === 0)\n );\n}\n\nfunction hasTextContent(content: unknown): boolean {\n if (typeof content === \"string\") return content.length > 0;\n if (!Array.isArray(content)) return false;\n return content.some((block) => {\n if (typeof block === \"string\") return block.length > 0;\n if (block == null || typeof block !== \"object\") return false;\n const record = block as { type?: unknown; text?: unknown };\n return (\n record.type === \"text\" &&\n typeof record.text === \"string\" &&\n record.text.length > 0\n );\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { BaseMessage, BaseMessageChunk } from "@langchain/core/messages";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, RemoveMessage, SystemMessage, ToolMessage, coerceMessageLikeToMessage } from "@langchain/core/messages";
|
|
2
|
+
//#region src/stream/message-coercion.ts
|
|
3
|
+
/**
|
|
4
|
+
* Stream-local message coercion for serialized messages returned by
|
|
5
|
+
* `getState()`, `getHistory()`, and `values` events.
|
|
6
|
+
*
|
|
7
|
+
* LangGraph API payloads may carry v1 content blocks as snake_case
|
|
8
|
+
* `content_blocks`, while `@langchain/core` message constructors only
|
|
9
|
+
* understand camelCase `contentBlocks` (or `content`). Normalize that
|
|
10
|
+
* boundary here so stream consumers always see `BaseMessage.text`.
|
|
11
|
+
*/
|
|
12
|
+
function tryCoerceMessageLikeToMessage(message) {
|
|
13
|
+
const normalized = normalizeAIMessageToolCalls(message);
|
|
14
|
+
if (normalized.type === "human" || normalized.type === "user") return new HumanMessage(normalized);
|
|
15
|
+
if (normalized.type === "ai" || normalized.type === "assistant") return new AIMessage(normalized);
|
|
16
|
+
if (normalized.type === "system") return new SystemMessage(normalized);
|
|
17
|
+
if (normalized.type === "tool" && "tool_call_id" in normalized) return new ToolMessage({
|
|
18
|
+
...normalized,
|
|
19
|
+
tool_call_id: normalized.tool_call_id
|
|
20
|
+
});
|
|
21
|
+
if (normalized.type === "remove" && normalized.id != null) return new RemoveMessage({
|
|
22
|
+
...normalized,
|
|
23
|
+
id: normalized.id
|
|
24
|
+
});
|
|
25
|
+
return coerceMessageLikeToMessage(normalized);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ensures all messages in an array are BaseMessage class instances.
|
|
29
|
+
* Messages that are already class instances pass through unchanged.
|
|
30
|
+
*/
|
|
31
|
+
function ensureMessageInstances(messages) {
|
|
32
|
+
return messages.map((msg) => {
|
|
33
|
+
if (typeof msg.getType === "function") return msg;
|
|
34
|
+
return tryCoerceMessageLikeToMessage(msg);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function normalizeSerializedContentBlocks(message) {
|
|
38
|
+
const record = message;
|
|
39
|
+
const contentBlocks = record.contentBlocks ?? record.content_blocks;
|
|
40
|
+
if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) return message;
|
|
41
|
+
const shouldPreferContentBlocks = isEmptyContent(record.content) || !hasTextContent(record.content) && hasTextContent(contentBlocks);
|
|
42
|
+
if (!shouldPreferContentBlocks && record.contentBlocks === contentBlocks) return message;
|
|
43
|
+
return {
|
|
44
|
+
...message,
|
|
45
|
+
content: shouldPreferContentBlocks ? contentBlocks : record.content,
|
|
46
|
+
contentBlocks
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function normalizeAIMessageToolCalls(message) {
|
|
50
|
+
const normalized = normalizeSerializedContentBlocks(message);
|
|
51
|
+
const record = normalized;
|
|
52
|
+
if (Array.isArray(record.tool_calls) && record.tool_calls.length > 0) return normalized;
|
|
53
|
+
const toolCalls = extractToolCallsFromContent(record.content);
|
|
54
|
+
if (toolCalls.length === 0) return normalized;
|
|
55
|
+
return {
|
|
56
|
+
...normalized,
|
|
57
|
+
tool_calls: toolCalls
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function extractToolCallsFromContent(content) {
|
|
61
|
+
if (!Array.isArray(content)) return [];
|
|
62
|
+
return content.flatMap((block) => {
|
|
63
|
+
if (block == null || typeof block !== "object") return [];
|
|
64
|
+
const record = block;
|
|
65
|
+
if (record.type !== "tool_call" && record.type !== "tool_use") return [];
|
|
66
|
+
return [{
|
|
67
|
+
id: record.id ?? "",
|
|
68
|
+
name: record.name ?? "",
|
|
69
|
+
args: normalizeToolCallArgs(record.args ?? record.input),
|
|
70
|
+
type: "tool_call"
|
|
71
|
+
}];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function normalizeToolCallArgs(value) {
|
|
75
|
+
if (value != null && typeof value === "object" && !Array.isArray(value)) return value;
|
|
76
|
+
if (typeof value === "string" && value.length > 0) try {
|
|
77
|
+
const parsed = JSON.parse(value);
|
|
78
|
+
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
79
|
+
} catch {}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
function isEmptyContent(content) {
|
|
83
|
+
return content == null || content === "" || Array.isArray(content) && content.length === 0;
|
|
84
|
+
}
|
|
85
|
+
function hasTextContent(content) {
|
|
86
|
+
if (typeof content === "string") return content.length > 0;
|
|
87
|
+
if (!Array.isArray(content)) return false;
|
|
88
|
+
return content.some((block) => {
|
|
89
|
+
if (typeof block === "string") return block.length > 0;
|
|
90
|
+
if (block == null || typeof block !== "object") return false;
|
|
91
|
+
const record = block;
|
|
92
|
+
return record.type === "text" && typeof record.text === "string" && record.text.length > 0;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { ensureMessageInstances, normalizeAIMessageToolCalls, tryCoerceMessageLikeToMessage };
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=message-coercion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-coercion.js","names":[],"sources":["../../src/stream/message-coercion.ts"],"sourcesContent":["import type { BaseMessage, BaseMessageChunk } from \"@langchain/core/messages\";\nimport {\n AIMessage,\n HumanMessage,\n RemoveMessage,\n SystemMessage,\n ToolMessage,\n coerceMessageLikeToMessage,\n} from \"@langchain/core/messages\";\nimport type { Message } from \"../types.messages.js\";\n\ntype MessageLike = Omit<Message, \"type\"> & { type: string };\n\ntype ToolCallLike = {\n id?: string;\n name?: string;\n args?: unknown;\n input?: unknown;\n};\n\ntype SerializedMessageWithContentBlocks = MessageLike & {\n content?: unknown;\n contentBlocks?: unknown;\n content_blocks?: unknown;\n};\n\n/**\n * Stream-local message coercion for serialized messages returned by\n * `getState()`, `getHistory()`, and `values` events.\n *\n * LangGraph API payloads may carry v1 content blocks as snake_case\n * `content_blocks`, while `@langchain/core` message constructors only\n * understand camelCase `contentBlocks` (or `content`). Normalize that\n * boundary here so stream consumers always see `BaseMessage.text`.\n */\nexport function tryCoerceMessageLikeToMessage(\n message: MessageLike\n): BaseMessage | BaseMessageChunk {\n const normalized = normalizeAIMessageToolCalls(message);\n\n if (normalized.type === \"human\" || normalized.type === \"user\") {\n return new HumanMessage(normalized);\n }\n\n if (normalized.type === \"ai\" || normalized.type === \"assistant\") {\n return new AIMessage(normalized);\n }\n\n if (normalized.type === \"system\") {\n return new SystemMessage(normalized);\n }\n\n if (normalized.type === \"tool\" && \"tool_call_id\" in normalized) {\n return new ToolMessage({\n ...normalized,\n tool_call_id: normalized.tool_call_id as string,\n });\n }\n\n if (normalized.type === \"remove\" && normalized.id != null) {\n return new RemoveMessage({ ...normalized, id: normalized.id });\n }\n\n return coerceMessageLikeToMessage(normalized);\n}\n\n/**\n * Ensures all messages in an array are BaseMessage class instances.\n * Messages that are already class instances pass through unchanged.\n */\nexport function ensureMessageInstances(\n messages: (Message | BaseMessage)[]\n): (BaseMessage | BaseMessageChunk)[] {\n return messages.map((msg) => {\n if (typeof (msg as BaseMessage).getType === \"function\") {\n return msg as BaseMessage;\n }\n return tryCoerceMessageLikeToMessage(\n msg as Omit<Message, \"type\"> & { type: string }\n );\n });\n}\n\nfunction normalizeSerializedContentBlocks<T extends MessageLike>(\n message: T\n): T {\n const record = message as SerializedMessageWithContentBlocks;\n const contentBlocks = record.contentBlocks ?? record.content_blocks;\n if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) {\n return message;\n }\n\n const shouldPreferContentBlocks =\n isEmptyContent(record.content) ||\n (!hasTextContent(record.content) && hasTextContent(contentBlocks));\n if (!shouldPreferContentBlocks && record.contentBlocks === contentBlocks) {\n return message;\n }\n\n return {\n ...message,\n content: shouldPreferContentBlocks ? contentBlocks : record.content,\n contentBlocks,\n } as T;\n}\n\nexport function normalizeAIMessageToolCalls<T extends MessageLike>(\n message: T\n): T {\n const normalized = normalizeSerializedContentBlocks(message);\n const record = normalized as T & {\n content?: unknown;\n tool_calls?: unknown;\n };\n if (Array.isArray(record.tool_calls) && record.tool_calls.length > 0) {\n return normalized;\n }\n\n const toolCalls = extractToolCallsFromContent(record.content);\n if (toolCalls.length === 0) return normalized;\n return {\n ...normalized,\n tool_calls: toolCalls,\n };\n}\n\nfunction extractToolCallsFromContent(content: unknown) {\n if (!Array.isArray(content)) return [];\n return content.flatMap(\n (\n block\n ): Array<{\n id: string;\n name: string;\n args: Record<string, unknown>;\n type: \"tool_call\";\n }> => {\n if (block == null || typeof block !== \"object\") return [];\n const record = block as ToolCallLike & { type?: unknown };\n if (record.type !== \"tool_call\" && record.type !== \"tool_use\") return [];\n return [\n {\n id: record.id ?? \"\",\n name: record.name ?? \"\",\n args: normalizeToolCallArgs(record.args ?? record.input),\n type: \"tool_call\",\n },\n ];\n }\n );\n}\n\nfunction normalizeToolCallArgs(value: unknown): Record<string, unknown> {\n if (value != null && typeof value === \"object\" && !Array.isArray(value)) {\n return value as Record<string, unknown>;\n }\n if (typeof value === \"string\" && value.length > 0) {\n try {\n const parsed = JSON.parse(value);\n if (\n parsed != null &&\n typeof parsed === \"object\" &&\n !Array.isArray(parsed)\n ) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Streaming input fragments are expected to be invalid until finalized.\n }\n }\n return {};\n}\n\nfunction isEmptyContent(content: unknown): boolean {\n return (\n content == null ||\n content === \"\" ||\n (Array.isArray(content) && content.length === 0)\n );\n}\n\nfunction hasTextContent(content: unknown): boolean {\n if (typeof content === \"string\") return content.length > 0;\n if (!Array.isArray(content)) return false;\n return content.some((block) => {\n if (typeof block === \"string\") return block.length > 0;\n if (block == null || typeof block !== \"object\") return false;\n const record = block as { type?: unknown; text?: unknown };\n return (\n record.type === \"text\" &&\n typeof record.text === \"string\" &&\n record.text.length > 0\n );\n });\n}\n"],"mappings":"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"}
|
|
@@ -82,12 +82,21 @@ var MessageMetadataTracker = class {
|
|
|
82
82
|
*/
|
|
83
83
|
#pendingCheckpointByNamespace = /* @__PURE__ */ new Map();
|
|
84
84
|
/**
|
|
85
|
+
* Ids of messages currently in the `"pending"` optimistic state.
|
|
86
|
+
* Maintained alongside the metadata map so the controller can cheaply
|
|
87
|
+
* (a) flip ids to `"sent"` when the server echoes them and (b) flip
|
|
88
|
+
* any leftover ids to `"sent"` / `"failed"` at run terminal, without
|
|
89
|
+
* scanning the whole metadata map.
|
|
90
|
+
*/
|
|
91
|
+
#pendingOptimisticIds = /* @__PURE__ */ new Set();
|
|
92
|
+
/**
|
|
85
93
|
* Drop all buffered checkpoints and reset the metadata map to the
|
|
86
94
|
* shared empty instance. Called on thread rebind / dispose so a new
|
|
87
95
|
* thread's metadata can't bleed into the old one.
|
|
88
96
|
*/
|
|
89
97
|
reset() {
|
|
90
98
|
this.#pendingCheckpointByNamespace.clear();
|
|
99
|
+
this.#pendingOptimisticIds.clear();
|
|
91
100
|
this.store.setState(() => EMPTY_METADATA_MAP);
|
|
92
101
|
}
|
|
93
102
|
/**
|
|
@@ -109,6 +118,7 @@ var MessageMetadataTracker = class {
|
|
|
109
118
|
if (data == null || typeof data.id !== "string") return;
|
|
110
119
|
const envelope = { id: data.id };
|
|
111
120
|
if (typeof data.parent_id === "string") envelope.parent_id = data.parent_id;
|
|
121
|
+
if (typeof data.step === "number") envelope.step = data.step;
|
|
112
122
|
this.#pendingCheckpointByNamespace.set(require_namespace.namespaceKey(namespace), envelope);
|
|
113
123
|
}
|
|
114
124
|
/**
|
|
@@ -158,6 +168,88 @@ var MessageMetadataTracker = class {
|
|
|
158
168
|
}
|
|
159
169
|
if (changed) this.store.setState(() => next);
|
|
160
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Mark a set of message ids as optimistically `"pending"`.
|
|
173
|
+
*
|
|
174
|
+
* Called from {@link StreamController}'s optimistic submit path right
|
|
175
|
+
* after the messages are appended to the projection, so a UI can
|
|
176
|
+
* render a "sending…" affordance via
|
|
177
|
+
* `useMessageMetadata(stream, id).optimisticStatus`.
|
|
178
|
+
*
|
|
179
|
+
* @param ids - Message ids that were just applied optimistically.
|
|
180
|
+
*/
|
|
181
|
+
markPending(ids) {
|
|
182
|
+
let changed = false;
|
|
183
|
+
const next = new Map(this.store.getSnapshot());
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
186
|
+
this.#pendingOptimisticIds.add(id);
|
|
187
|
+
const prev = next.get(id);
|
|
188
|
+
if (prev?.optimisticStatus === "pending") continue;
|
|
189
|
+
next.set(id, {
|
|
190
|
+
parentCheckpointId: prev?.parentCheckpointId,
|
|
191
|
+
...prev,
|
|
192
|
+
optimisticStatus: "pending"
|
|
193
|
+
});
|
|
194
|
+
changed = true;
|
|
195
|
+
}
|
|
196
|
+
if (changed) this.store.setState(() => next);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Transition the given ids out of `"pending"`.
|
|
200
|
+
*
|
|
201
|
+
* Only ids currently tracked as pending are affected, so passing a
|
|
202
|
+
* full server `values.messages` id list (to flip echoed messages to
|
|
203
|
+
* `"sent"`) never stamps a status onto ordinary server messages.
|
|
204
|
+
*
|
|
205
|
+
* @param ids - Candidate ids (e.g. all ids in a server snapshot,
|
|
206
|
+
* or the ids echoed by a single submit).
|
|
207
|
+
* @param status - Terminal optimistic status (`"sent"` / `"failed"`).
|
|
208
|
+
*/
|
|
209
|
+
resolvePending(ids, status) {
|
|
210
|
+
let changed = false;
|
|
211
|
+
const next = new Map(this.store.getSnapshot());
|
|
212
|
+
for (const id of ids) {
|
|
213
|
+
if (!this.#pendingOptimisticIds.has(id)) continue;
|
|
214
|
+
this.#pendingOptimisticIds.delete(id);
|
|
215
|
+
const prev = next.get(id);
|
|
216
|
+
next.set(id, {
|
|
217
|
+
parentCheckpointId: prev?.parentCheckpointId,
|
|
218
|
+
...prev,
|
|
219
|
+
optimisticStatus: status
|
|
220
|
+
});
|
|
221
|
+
changed = true;
|
|
222
|
+
}
|
|
223
|
+
if (changed) this.store.setState(() => next);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Snapshot of ids whose optimistic status is `"pending"` or
|
|
227
|
+
* `"failed"` — i.e. messages applied locally that the server has not
|
|
228
|
+
* echoed. Used by {@link StreamController.hydrate} to drop
|
|
229
|
+
* never-persisted optimistic messages so a reload converges to
|
|
230
|
+
* server truth.
|
|
231
|
+
*/
|
|
232
|
+
unpersistedOptimisticIds() {
|
|
233
|
+
const ids = new Set(this.#pendingOptimisticIds);
|
|
234
|
+
for (const [id, meta] of this.store.getSnapshot()) if (meta.optimisticStatus === "failed") ids.add(id);
|
|
235
|
+
return ids;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Drop all metadata for the given ids. Called after never-persisted
|
|
239
|
+
* optimistic messages are removed from the projection on
|
|
240
|
+
* {@link StreamController.hydrate}, so their status doesn't linger.
|
|
241
|
+
*
|
|
242
|
+
* @param ids - Message ids to forget.
|
|
243
|
+
*/
|
|
244
|
+
forget(ids) {
|
|
245
|
+
let changed = false;
|
|
246
|
+
const next = new Map(this.store.getSnapshot());
|
|
247
|
+
for (const id of ids) {
|
|
248
|
+
this.#pendingOptimisticIds.delete(id);
|
|
249
|
+
if (next.delete(id)) changed = true;
|
|
250
|
+
}
|
|
251
|
+
if (changed) this.store.setState(() => next);
|
|
252
|
+
}
|
|
161
253
|
};
|
|
162
254
|
//#endregion
|
|
163
255
|
exports.MessageMetadataTracker = MessageMetadataTracker;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-metadata-tracker.cjs","names":["StreamStore","#pendingCheckpointByNamespace","namespaceKey"],"sources":["../../src/stream/message-metadata-tracker.ts"],"sourcesContent":["/**\n * Per-message checkpoint metadata projection.\n *\n * # What this module is\n *\n * The protocol emits a `checkpoints` event immediately *before* its\n * companion `values` event for the same superstep:\n *\n * 1. `checkpoints` — `{ id, parent_id?, step?, source? }`\n * 2. `values` — `{ messages, ... }` (same namespace)\n *\n * Both events carry the same `seq` ordering but live on different\n * channels, so the controller can't atomically observe them. This\n * tracker bridges the gap by buffering each `checkpoints` envelope\n * keyed on its namespace, then consuming it when the matching values\n * payload arrives. Once paired, the consumer (typically the\n * controller) writes a {@link MessageMetadata} record under each\n * message id.\n *\n * # Why fork / edit flows need this\n *\n * Surfacing `parentCheckpointId` per-message lets UI flows like\n * \"edit a message and re-run\" call\n * `submit(input, { forkFrom: checkpointId })` without making the\n * caller juggle thread state. Each message remembers the checkpoint\n * it was first observed at, so a \"fork from this message\" UI can read\n * `useMessageMetadata(stream, msg.id)` directly.\n *\n * # Lifecycle\n *\n * - `bufferCheckpoint(ns, data)` — store the envelope until the\n * companion values event arrives.\n * - `consumeCheckpoint(ns)` — read-and-clear the envelope when\n * the values event lands. Returning `undefined` signals \"no\n * metadata to attach\" — older snapshots without a paired\n * checkpoint are still applied to the store, just without\n * `parentCheckpointId`.\n * - `recordMessages(msgs, meta)` — write metadata for the supplied\n * message ids if it differs from what's already stored.\n * - `reset()` — clear everything (called on\n * thread rebind / dispose).\n *\n * The buffer is read-and-cleared on consumption so a values event that\n * arrives without a fresh checkpoint envelope doesn't reuse stale\n * metadata from a previous superstep.\n */\nimport { StreamStore } from \"./store.js\";\nimport { namespaceKey } from \"./namespace.js\";\n\n/**\n * Metadata tracked per message id. Surfaced to applications via\n * `useMessageMetadata(stream, messageId)`.\n */\nexport interface MessageMetadata {\n /**\n * Checkpoint id the message's *parent* was at when this message was\n * observed. Drives fork / edit flows\n * (`submit(input, { forkFrom: checkpointId })`).\n *\n * `undefined` when the message was observed without a paired\n * checkpoint envelope (e.g. before checkpoints rolled out, or when\n * the caller stripped them upstream).\n */\n readonly parentCheckpointId: string | undefined;\n}\n\n/**\n * Read-only map exposed via {@link MessageMetadataTracker.store}.\n */\nexport type MessageMetadataMap = ReadonlyMap<string, MessageMetadata>;\n\n/**\n * Lightweight envelope mirroring the on-wire `checkpoints` event.\n *\n * The protocol payload may include additional fields (`step`,\n * `source`, etc.) — we only carry what the per-message metadata\n * actually needs.\n */\nexport interface CheckpointEnvelope {\n /** Checkpoint id this superstep wrote. */\n readonly id: string;\n /**\n * Parent checkpoint id, when present. Becomes\n * {@link MessageMetadata.parentCheckpointId} on the next values event.\n */\n readonly parent_id?: string;\n}\n\n/**\n * Frozen empty map used as the store's initial value. Keeping the\n * reference stable avoids spurious `setSnapshot` notifications on\n * `reset()` for consumers that haven't observed any metadata yet.\n */\nconst EMPTY_METADATA_MAP: MessageMetadataMap = new Map();\n\n/**\n * Tracks checkpoint-derived metadata for messages.\n *\n * Owns one {@link StreamStore} mapping `messageId → MessageMetadata`\n * plus a per-namespace buffer of pending checkpoint envelopes. The\n * controller wires it up via three call sites:\n *\n * 1. `controller.#onRootEvent(\"checkpoints\")`\n * → `bufferCheckpoint(namespace, data)`\n * 2. `controller.#onRootEvent(\"values\")`\n * → `consumeCheckpoint(namespace)` then\n * `recordMessages(values.messages, { parentCheckpointId })`\n * 3. `controller.#teardownThread`\n * → `reset()`\n *\n * @see useMessageMetadata - The framework hook that reads from\n * {@link MessageMetadataTracker.store}.\n */\nexport class MessageMetadataTracker {\n /** Observable map of messageId → metadata for framework consumers. */\n readonly store = new StreamStore<MessageMetadataMap>(EMPTY_METADATA_MAP);\n\n /**\n * Pending checkpoint envelopes awaiting their companion values\n * event. Keyed by `namespaceKey(namespace)` so a deeply-nested\n * checkpoint at one namespace doesn't collide with a root-level\n * checkpoint emitted in the same tick.\n */\n readonly #pendingCheckpointByNamespace = new Map<\n string,\n CheckpointEnvelope\n >();\n\n /**\n * Drop all buffered checkpoints and reset the metadata map to the\n * shared empty instance. Called on thread rebind / dispose so a new\n * thread's metadata can't bleed into the old one.\n */\n reset(): void {\n this.#pendingCheckpointByNamespace.clear();\n this.store.setState(() => EMPTY_METADATA_MAP);\n }\n\n /**\n * Buffer a `checkpoints` event for later pairing with its values\n * companion.\n *\n * Defensive against missing / malformed payloads:\n *\n * - `data == null` → no-op (some upstream nodes elide the\n * payload entirely; we keep the previous buffered envelope so\n * the next consume call still wins).\n * - `id` not a string → no-op.\n * - `parent_id` not a string → omitted from the envelope.\n *\n * @param namespace - Event namespace (used as the buffer key).\n * @param data - Raw checkpoints payload.\n */\n bufferCheckpoint(\n namespace: readonly string[],\n data: { id?: unknown; parent_id?: unknown } | null\n ): void {\n if (data == null || typeof data.id !== \"string\") return;\n const envelope: CheckpointEnvelope = { id: data.id };\n if (typeof data.parent_id === \"string\") {\n (envelope as { parent_id?: string }).parent_id = data.parent_id;\n }\n this.#pendingCheckpointByNamespace.set(namespaceKey(namespace), envelope);\n }\n\n /**\n * Read-and-clear the buffered checkpoint envelope for `namespace`.\n *\n * Always pairs with a single {@link bufferCheckpoint} call: a values\n * event without a matching buffered checkpoint returns `undefined`\n * (meaning \"no metadata to attach\"), and the next checkpoint event\n * starts fresh rather than reusing stale data.\n *\n * @param namespace - Event namespace to consume.\n * @returns The buffered envelope, or `undefined` when none was buffered.\n */\n consumeCheckpoint(\n namespace: readonly string[]\n ): CheckpointEnvelope | undefined {\n const key = namespaceKey(namespace);\n const checkpoint = this.#pendingCheckpointByNamespace.get(key);\n if (checkpoint != null) this.#pendingCheckpointByNamespace.delete(key);\n return checkpoint;\n }\n\n /**\n * Record metadata for a list of messages.\n *\n * Skips messages whose existing entry already matches `metadata`;\n * those without an `id` (or with a non-string id) are silently\n * ignored — there's nothing to key the metadata on. The store is\n * only updated when at least one entry actually changed, so\n * reapplying the same values snapshot is cheap.\n *\n * @param messages - Messages from the latest values payload.\n * @param metadata - Metadata to attach (currently just\n * `parentCheckpointId`).\n */\n recordMessages(\n messages: Array<{ id?: string }>,\n metadata: MessageMetadata\n ): void {\n const current = this.store.getSnapshot();\n let changed = false;\n const next = new Map(current);\n for (const msg of messages) {\n const id = msg?.id;\n if (typeof id !== \"string\" || id.length === 0) continue;\n const prev = next.get(id);\n if (\n prev != null &&\n prev.parentCheckpointId === metadata.parentCheckpointId\n ) {\n continue;\n }\n next.set(id, { ...prev, ...metadata });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6FA,MAAM,qCAAyC,IAAI,KAAK;;;;;;;;;;;;;;;;;;;AAoBxD,IAAa,yBAAb,MAAoC;;CAElC,QAAiB,IAAIA,cAAAA,YAAgC,mBAAmB;;;;;;;CAQxE,gDAAyC,IAAI,KAG1C;;;;;;CAOH,QAAc;AACZ,QAAA,6BAAmC,OAAO;AAC1C,OAAK,MAAM,eAAe,mBAAmB;;;;;;;;;;;;;;;;;CAkB/C,iBACE,WACA,MACM;AACN,MAAI,QAAQ,QAAQ,OAAO,KAAK,OAAO,SAAU;EACjD,MAAM,WAA+B,EAAE,IAAI,KAAK,IAAI;AACpD,MAAI,OAAO,KAAK,cAAc,SAC3B,UAAoC,YAAY,KAAK;AAExD,QAAA,6BAAmC,IAAIE,kBAAAA,aAAa,UAAU,EAAE,SAAS;;;;;;;;;;;;;CAc3E,kBACE,WACgC;EAChC,MAAM,MAAMA,kBAAAA,aAAa,UAAU;EACnC,MAAM,aAAa,MAAA,6BAAmC,IAAI,IAAI;AAC9D,MAAI,cAAc,KAAM,OAAA,6BAAmC,OAAO,IAAI;AACtE,SAAO;;;;;;;;;;;;;;;CAgBT,eACE,UACA,UACM;EACN,MAAM,UAAU,KAAK,MAAM,aAAa;EACxC,IAAI,UAAU;EACd,MAAM,OAAO,IAAI,IAAI,QAAQ;AAC7B,OAAK,MAAM,OAAO,UAAU;GAC1B,MAAM,KAAK,KAAK;AAChB,OAAI,OAAO,OAAO,YAAY,GAAG,WAAW,EAAG;GAC/C,MAAM,OAAO,KAAK,IAAI,GAAG;AACzB,OACE,QAAQ,QACR,KAAK,uBAAuB,SAAS,mBAErC;AAEF,QAAK,IAAI,IAAI;IAAE,GAAG;IAAM,GAAG;IAAU,CAAC;AACtC,aAAU;;AAEZ,MAAI,QAAS,MAAK,MAAM,eAAe,KAAK"}
|
|
1
|
+
{"version":3,"file":"message-metadata-tracker.cjs","names":["StreamStore","#pendingCheckpointByNamespace","#pendingOptimisticIds","namespaceKey"],"sources":["../../src/stream/message-metadata-tracker.ts"],"sourcesContent":["/**\n * Per-message checkpoint metadata projection.\n *\n * # What this module is\n *\n * The protocol emits a `checkpoints` event immediately *before* its\n * companion `values` event for the same superstep:\n *\n * 1. `checkpoints` — `{ id, parent_id?, step?, source? }`\n * 2. `values` — `{ messages, ... }` (same namespace)\n *\n * Both events carry the same `seq` ordering but live on different\n * channels, so the controller can't atomically observe them. This\n * tracker bridges the gap by buffering each `checkpoints` envelope\n * keyed on its namespace, then consuming it when the matching values\n * payload arrives. Once paired, the consumer (typically the\n * controller) writes a {@link MessageMetadata} record under each\n * message id.\n *\n * # Why fork / edit flows need this\n *\n * Surfacing `parentCheckpointId` per-message lets UI flows like\n * \"edit a message and re-run\" call\n * `submit(input, { forkFrom: checkpointId })` without making the\n * caller juggle thread state. Each message remembers the checkpoint\n * it was first observed at, so a \"fork from this message\" UI can read\n * `useMessageMetadata(stream, msg.id)` directly.\n *\n * # Lifecycle\n *\n * - `bufferCheckpoint(ns, data)` — store the envelope until the\n * companion values event arrives.\n * - `consumeCheckpoint(ns)` — read-and-clear the envelope when\n * the values event lands. Returning `undefined` signals \"no\n * metadata to attach\" — older snapshots without a paired\n * checkpoint are still applied to the store, just without\n * `parentCheckpointId`.\n * - `recordMessages(msgs, meta)` — write metadata for the supplied\n * message ids if it differs from what's already stored.\n * - `reset()` — clear everything (called on\n * thread rebind / dispose).\n *\n * The buffer is read-and-cleared on consumption so a values event that\n * arrives without a fresh checkpoint envelope doesn't reuse stale\n * metadata from a previous superstep.\n */\nimport { StreamStore } from \"./store.js\";\nimport { namespaceKey } from \"./namespace.js\";\n\n/**\n * Optimistic lifecycle status for a message that originated from a\n * local {@link StreamController.submit} before the server echoed it.\n *\n * - `\"pending\"` — applied optimistically; the run is in flight and\n * the server has not yet echoed this id in a `values` snapshot.\n * - `\"sent\"` — the server echoed the id (run progressed); the\n * message is now server-authoritative.\n * - `\"failed\"` — the run failed before the id was echoed. The\n * message is kept (so UIs can show it with a retry affordance) but\n * is dropped on the next {@link StreamController.hydrate} because\n * it was never persisted server-side.\n *\n * Server-originated messages (history, streamed assistant turns) never\n * carry a status — `undefined` means \"not optimistic\".\n */\nexport type OptimisticStatus = \"pending\" | \"sent\" | \"failed\";\n\n/**\n * Metadata tracked per message id. Surfaced to applications via\n * `useMessageMetadata(stream, messageId)`.\n */\nexport interface MessageMetadata {\n /**\n * Checkpoint id the message's *parent* was at when this message was\n * observed. Drives fork / edit flows\n * (`submit(input, { forkFrom: checkpointId })`).\n *\n * `undefined` when the message was observed without a paired\n * checkpoint envelope (e.g. before checkpoints rolled out, or when\n * the caller stripped them upstream).\n */\n readonly parentCheckpointId: string | undefined;\n\n /**\n * Optimistic lifecycle status, present only for messages applied\n * locally by an optimistic `submit()`. `undefined` for ordinary\n * server-originated messages. See {@link OptimisticStatus}.\n */\n readonly optimisticStatus?: OptimisticStatus;\n}\n\n/**\n * Read-only map exposed via {@link MessageMetadataTracker.store}.\n */\nexport type MessageMetadataMap = ReadonlyMap<string, MessageMetadata>;\n\n/**\n * Lightweight envelope mirroring the on-wire `checkpoints` event.\n *\n * The protocol payload may include additional fields (`step`,\n * `source`, etc.) — we only carry what the per-message metadata\n * actually needs.\n */\nexport interface CheckpointEnvelope {\n /** Checkpoint id this superstep wrote. */\n readonly id: string;\n /**\n * Parent checkpoint id, when present. Becomes\n * {@link MessageMetadata.parentCheckpointId} on the next values event.\n */\n readonly parent_id?: string;\n /**\n * Monotonic superstep counter for the checkpoint. Used by the root\n * message projection to distinguish a fresh/live `values` snapshot\n * from an older one replayed by the content pump on reconnect, so a\n * stale replay can't remove tail messages the authoritative\n * `getState()` seed already established.\n */\n readonly step?: number;\n}\n\n/**\n * Frozen empty map used as the store's initial value. Keeping the\n * reference stable avoids spurious `setSnapshot` notifications on\n * `reset()` for consumers that haven't observed any metadata yet.\n */\nconst EMPTY_METADATA_MAP: MessageMetadataMap = new Map();\n\n/**\n * Tracks checkpoint-derived metadata for messages.\n *\n * Owns one {@link StreamStore} mapping `messageId → MessageMetadata`\n * plus a per-namespace buffer of pending checkpoint envelopes. The\n * controller wires it up via three call sites:\n *\n * 1. `controller.#onRootEvent(\"checkpoints\")`\n * → `bufferCheckpoint(namespace, data)`\n * 2. `controller.#onRootEvent(\"values\")`\n * → `consumeCheckpoint(namespace)` then\n * `recordMessages(values.messages, { parentCheckpointId })`\n * 3. `controller.#teardownThread`\n * → `reset()`\n *\n * @see useMessageMetadata - The framework hook that reads from\n * {@link MessageMetadataTracker.store}.\n */\nexport class MessageMetadataTracker {\n /** Observable map of messageId → metadata for framework consumers. */\n readonly store = new StreamStore<MessageMetadataMap>(EMPTY_METADATA_MAP);\n\n /**\n * Pending checkpoint envelopes awaiting their companion values\n * event. Keyed by `namespaceKey(namespace)` so a deeply-nested\n * checkpoint at one namespace doesn't collide with a root-level\n * checkpoint emitted in the same tick.\n */\n readonly #pendingCheckpointByNamespace = new Map<\n string,\n CheckpointEnvelope\n >();\n\n /**\n * Ids of messages currently in the `\"pending\"` optimistic state.\n * Maintained alongside the metadata map so the controller can cheaply\n * (a) flip ids to `\"sent\"` when the server echoes them and (b) flip\n * any leftover ids to `\"sent\"` / `\"failed\"` at run terminal, without\n * scanning the whole metadata map.\n */\n readonly #pendingOptimisticIds = new Set<string>();\n\n /**\n * Drop all buffered checkpoints and reset the metadata map to the\n * shared empty instance. Called on thread rebind / dispose so a new\n * thread's metadata can't bleed into the old one.\n */\n reset(): void {\n this.#pendingCheckpointByNamespace.clear();\n this.#pendingOptimisticIds.clear();\n this.store.setState(() => EMPTY_METADATA_MAP);\n }\n\n /**\n * Buffer a `checkpoints` event for later pairing with its values\n * companion.\n *\n * Defensive against missing / malformed payloads:\n *\n * - `data == null` → no-op (some upstream nodes elide the\n * payload entirely; we keep the previous buffered envelope so\n * the next consume call still wins).\n * - `id` not a string → no-op.\n * - `parent_id` not a string → omitted from the envelope.\n *\n * @param namespace - Event namespace (used as the buffer key).\n * @param data - Raw checkpoints payload.\n */\n bufferCheckpoint(\n namespace: readonly string[],\n data: { id?: unknown; parent_id?: unknown; step?: unknown } | null\n ): void {\n if (data == null || typeof data.id !== \"string\") return;\n const envelope: CheckpointEnvelope = { id: data.id };\n if (typeof data.parent_id === \"string\") {\n (envelope as { parent_id?: string }).parent_id = data.parent_id;\n }\n if (typeof data.step === \"number\") {\n (envelope as { step?: number }).step = data.step;\n }\n this.#pendingCheckpointByNamespace.set(namespaceKey(namespace), envelope);\n }\n\n /**\n * Read-and-clear the buffered checkpoint envelope for `namespace`.\n *\n * Always pairs with a single {@link bufferCheckpoint} call: a values\n * event without a matching buffered checkpoint returns `undefined`\n * (meaning \"no metadata to attach\"), and the next checkpoint event\n * starts fresh rather than reusing stale data.\n *\n * @param namespace - Event namespace to consume.\n * @returns The buffered envelope, or `undefined` when none was buffered.\n */\n consumeCheckpoint(\n namespace: readonly string[]\n ): CheckpointEnvelope | undefined {\n const key = namespaceKey(namespace);\n const checkpoint = this.#pendingCheckpointByNamespace.get(key);\n if (checkpoint != null) this.#pendingCheckpointByNamespace.delete(key);\n return checkpoint;\n }\n\n /**\n * Record metadata for a list of messages.\n *\n * Skips messages whose existing entry already matches `metadata`;\n * those without an `id` (or with a non-string id) are silently\n * ignored — there's nothing to key the metadata on. The store is\n * only updated when at least one entry actually changed, so\n * reapplying the same values snapshot is cheap.\n *\n * @param messages - Messages from the latest values payload.\n * @param metadata - Metadata to attach (currently just\n * `parentCheckpointId`).\n */\n recordMessages(\n messages: Array<{ id?: string }>,\n metadata: MessageMetadata\n ): void {\n const current = this.store.getSnapshot();\n let changed = false;\n const next = new Map(current);\n for (const msg of messages) {\n const id = msg?.id;\n if (typeof id !== \"string\" || id.length === 0) continue;\n const prev = next.get(id);\n if (\n prev != null &&\n prev.parentCheckpointId === metadata.parentCheckpointId\n ) {\n continue;\n }\n next.set(id, { ...prev, ...metadata });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Mark a set of message ids as optimistically `\"pending\"`.\n *\n * Called from {@link StreamController}'s optimistic submit path right\n * after the messages are appended to the projection, so a UI can\n * render a \"sending…\" affordance via\n * `useMessageMetadata(stream, id).optimisticStatus`.\n *\n * @param ids - Message ids that were just applied optimistically.\n */\n markPending(ids: Iterable<string>): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n if (typeof id !== \"string\" || id.length === 0) continue;\n this.#pendingOptimisticIds.add(id);\n const prev = next.get(id);\n if (prev?.optimisticStatus === \"pending\") continue;\n next.set(id, {\n parentCheckpointId: prev?.parentCheckpointId,\n ...prev,\n optimisticStatus: \"pending\",\n });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Transition the given ids out of `\"pending\"`.\n *\n * Only ids currently tracked as pending are affected, so passing a\n * full server `values.messages` id list (to flip echoed messages to\n * `\"sent\"`) never stamps a status onto ordinary server messages.\n *\n * @param ids - Candidate ids (e.g. all ids in a server snapshot,\n * or the ids echoed by a single submit).\n * @param status - Terminal optimistic status (`\"sent\"` / `\"failed\"`).\n */\n resolvePending(ids: Iterable<string>, status: OptimisticStatus): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n if (!this.#pendingOptimisticIds.has(id)) continue;\n this.#pendingOptimisticIds.delete(id);\n const prev = next.get(id);\n next.set(id, {\n parentCheckpointId: prev?.parentCheckpointId,\n ...prev,\n optimisticStatus: status,\n });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Snapshot of ids whose optimistic status is `\"pending\"` or\n * `\"failed\"` — i.e. messages applied locally that the server has not\n * echoed. Used by {@link StreamController.hydrate} to drop\n * never-persisted optimistic messages so a reload converges to\n * server truth.\n */\n unpersistedOptimisticIds(): Set<string> {\n const ids = new Set<string>(this.#pendingOptimisticIds);\n for (const [id, meta] of this.store.getSnapshot()) {\n if (meta.optimisticStatus === \"failed\") ids.add(id);\n }\n return ids;\n }\n\n /**\n * Drop all metadata for the given ids. Called after never-persisted\n * optimistic messages are removed from the projection on\n * {@link StreamController.hydrate}, so their status doesn't linger.\n *\n * @param ids - Message ids to forget.\n */\n forget(ids: Iterable<string>): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n this.#pendingOptimisticIds.delete(id);\n if (next.delete(id)) changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
//#region src/stream/message-metadata-tracker.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Optimistic lifecycle status for a message that originated from a
|
|
4
|
+
* local {@link StreamController.submit} before the server echoed it.
|
|
5
|
+
*
|
|
6
|
+
* - `"pending"` — applied optimistically; the run is in flight and
|
|
7
|
+
* the server has not yet echoed this id in a `values` snapshot.
|
|
8
|
+
* - `"sent"` — the server echoed the id (run progressed); the
|
|
9
|
+
* message is now server-authoritative.
|
|
10
|
+
* - `"failed"` — the run failed before the id was echoed. The
|
|
11
|
+
* message is kept (so UIs can show it with a retry affordance) but
|
|
12
|
+
* is dropped on the next {@link StreamController.hydrate} because
|
|
13
|
+
* it was never persisted server-side.
|
|
14
|
+
*
|
|
15
|
+
* Server-originated messages (history, streamed assistant turns) never
|
|
16
|
+
* carry a status — `undefined` means "not optimistic".
|
|
17
|
+
*/
|
|
18
|
+
type OptimisticStatus = "pending" | "sent" | "failed";
|
|
2
19
|
/**
|
|
3
20
|
* Metadata tracked per message id. Surfaced to applications via
|
|
4
21
|
* `useMessageMetadata(stream, messageId)`.
|
|
@@ -14,6 +31,12 @@ interface MessageMetadata {
|
|
|
14
31
|
* the caller stripped them upstream).
|
|
15
32
|
*/
|
|
16
33
|
readonly parentCheckpointId: string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Optimistic lifecycle status, present only for messages applied
|
|
36
|
+
* locally by an optimistic `submit()`. `undefined` for ordinary
|
|
37
|
+
* server-originated messages. See {@link OptimisticStatus}.
|
|
38
|
+
*/
|
|
39
|
+
readonly optimisticStatus?: OptimisticStatus;
|
|
17
40
|
}
|
|
18
41
|
/**
|
|
19
42
|
* Read-only map exposed via {@link MessageMetadataTracker.store}.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-metadata-tracker.d.cts","names":[],"sources":["../../src/stream/message-metadata-tracker.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"message-metadata-tracker.d.cts","names":[],"sources":["../../src/stream/message-metadata-tracker.ts"],"mappings":";;;;;;;;;;;;;;;;;KAiEY,gBAAA;;;;;UAMK,eAAA;;;;;;;;;;WAUN,kBAAA;;;;;;WAOA,gBAAA,GAAmB,gBAAA;AAAA;;;;KAMlB,kBAAA,GAAqB,WAAA,SAAoB,eAAA"}
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
//#region src/stream/message-metadata-tracker.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Optimistic lifecycle status for a message that originated from a
|
|
4
|
+
* local {@link StreamController.submit} before the server echoed it.
|
|
5
|
+
*
|
|
6
|
+
* - `"pending"` — applied optimistically; the run is in flight and
|
|
7
|
+
* the server has not yet echoed this id in a `values` snapshot.
|
|
8
|
+
* - `"sent"` — the server echoed the id (run progressed); the
|
|
9
|
+
* message is now server-authoritative.
|
|
10
|
+
* - `"failed"` — the run failed before the id was echoed. The
|
|
11
|
+
* message is kept (so UIs can show it with a retry affordance) but
|
|
12
|
+
* is dropped on the next {@link StreamController.hydrate} because
|
|
13
|
+
* it was never persisted server-side.
|
|
14
|
+
*
|
|
15
|
+
* Server-originated messages (history, streamed assistant turns) never
|
|
16
|
+
* carry a status — `undefined` means "not optimistic".
|
|
17
|
+
*/
|
|
18
|
+
type OptimisticStatus = "pending" | "sent" | "failed";
|
|
2
19
|
/**
|
|
3
20
|
* Metadata tracked per message id. Surfaced to applications via
|
|
4
21
|
* `useMessageMetadata(stream, messageId)`.
|
|
@@ -14,6 +31,12 @@ interface MessageMetadata {
|
|
|
14
31
|
* the caller stripped them upstream).
|
|
15
32
|
*/
|
|
16
33
|
readonly parentCheckpointId: string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Optimistic lifecycle status, present only for messages applied
|
|
36
|
+
* locally by an optimistic `submit()`. `undefined` for ordinary
|
|
37
|
+
* server-originated messages. See {@link OptimisticStatus}.
|
|
38
|
+
*/
|
|
39
|
+
readonly optimisticStatus?: OptimisticStatus;
|
|
17
40
|
}
|
|
18
41
|
/**
|
|
19
42
|
* Read-only map exposed via {@link MessageMetadataTracker.store}.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-metadata-tracker.d.ts","names":[],"sources":["../../src/stream/message-metadata-tracker.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"message-metadata-tracker.d.ts","names":[],"sources":["../../src/stream/message-metadata-tracker.ts"],"mappings":";;;;;;;;;;;;;;;;;KAiEY,gBAAA;;;;;UAMK,eAAA;;;;;;;;;;WAUN,kBAAA;;;;;;WAOA,gBAAA,GAAmB,gBAAA;AAAA;;;;KAMlB,kBAAA,GAAqB,WAAA,SAAoB,eAAA"}
|