diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index 7974a5992..cd67f0c94 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -173,7 +173,7 @@ }, { "name": "updateState", - "signature": "updateState(threadId: string, values: Record, _signal: AbortSignal)", + "signature": "updateState(threadId: string, values: Record, _signal: AbortSignal, options: object)", "description": "Update server-side thread state, e.g. to remove messages for regenerate rollback.", "params": [ { @@ -193,6 +193,12 @@ "type": "AbortSignal", "description": "", "optional": false + }, + { + "name": "options", + "type": "object", + "description": "", + "optional": true } ] } diff --git a/libs/a2ui/package.json b/libs/a2ui/package.json index 31e230662..bbbf1c403 100644 --- a/libs/a2ui/package.json +++ b/libs/a2ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/a2ui", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index 64b5f7949..a10552f95 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ag-ui", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/chat/package.json b/libs/chat/package.json index bb9b96d5a..aecf0b931 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.28", + "version": "0.0.29", "exports": { ".": { "types": "./index.d.ts", @@ -13,7 +13,7 @@ "./chat.css": "./chat.css" }, "dependencies": { - "@cacheplane/partial-json": "^0.1.1", + "@cacheplane/partial-json": ">=0.1.1 <0.3.0", "@cacheplane/partial-markdown": "^0.3.0" }, "peerDependencies": { diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json index cd39d37a5..877ad5a4c 100644 --- a/libs/cockpit-docs/package.json +++ b/libs/cockpit-docs/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-docs", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-registry/package.json b/libs/cockpit-registry/package.json index fad777c8d..09822ed8a 100644 --- a/libs/cockpit-registry/package.json +++ b/libs/cockpit-registry/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-registry", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index 97d7642d9..3d4ef53e8 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-shell", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json index 0e63ff33f..9a6137bbe 100644 --- a/libs/cockpit-testing/package.json +++ b/libs/cockpit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-testing", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json index 0126022f7..4adb275c4 100644 --- a/libs/cockpit-ui/package.json +++ b/libs/cockpit-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/cockpit-ui", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/db/package.json b/libs/db/package.json index bedfe3045..625e5298e 100644 --- a/libs/db/package.json +++ b/libs/db/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/db", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index 4d5a6a17d..fdfd1fb22 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/example-layouts/package.json b/libs/example-layouts/package.json index 6e6c4bd9c..db1bb6675 100644 --- a/libs/example-layouts/package.json +++ b/libs/example-layouts/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/example-layouts", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0" diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index a7a6ef5d4..1c3c7d7bb 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index da35764b3..f7d20e801 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -747,6 +747,60 @@ describe('agent', () => { expect(seen[1]?.payload).toBeNull(); }); + it("forwards asNode: '__start__' to transport.updateState so the next submit(null) can resume", async () => { + // After the original run the LangGraph thread sits at __end__ with + // `next: []`. submit(null) is a no-op in that position. The regenerate + // flow has to reposition the thread via `as_node='__start__'` before + // re-running, so the transport must receive that option. + const updateCalls: Array<{ + threadId: string; + values: Record; + options?: { asNode?: string }; + }> = []; + const transport = new MockAgentTransport(); + // Augment the mock with an updateState capture. + (transport as unknown as { + updateState: ( + threadId: string, + values: Record, + signal: AbortSignal, + options?: { asNode?: string }, + ) => Promise; + }).updateState = async (threadId, values, _signal, options) => { + updateCalls.push({ threadId, values, options }); + }; + + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', threadId: 't-1', transport, throttle: false }) + ); + + ref.submit({ message: 'hello' }); + transport.emit([{ + type: 'messages', + messages: [ + { id: 'u-1', type: 'human', content: 'hello' }, + { id: 'a-1', type: 'ai', content: 'hi there' }, + ], + }]); + transport.close(); + await new Promise(r => setTimeout(r, 30)); + + const regeneratePromise = ref.regenerate(1); + transport.close(); + await regeneratePromise; + + // updateState invoked exactly once — with the RemoveMessage payload + // AND asNode set to '__start__'. Without asNode, regenerate would + // submit(null) against a terminal thread and produce no new assistant. + expect(updateCalls).toHaveLength(1); + expect(updateCalls[0]?.threadId).toBe('t-1'); + expect(updateCalls[0]?.options?.asNode).toBe('__start__'); + const removeMessages = (updateCalls[0]?.values?.['messages'] as Array>) ?? []; + expect(removeMessages).toHaveLength(1); + expect(removeMessages[0]?.['type']).toBe('remove'); + expect(removeMessages[0]?.['id']).toBe('a-1'); + }); + it('throws when target index is not an assistant message', async () => { const transport = new MockAgentTransport(); const ref = withInjectionContext(() => diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index b173cb447..cd466f76f 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -310,15 +310,26 @@ export function agent< }) .filter((rm): rm is RemoveMessageInstruction => rm !== null); - if (removeList.length > 0) { - // updateState is a no-op when the transport doesn't support it - // (e.g. mock transport in unit tests) — safe to call unconditionally. - await manager.updateState({ messages: removeList }); - } - - // Re-run the graph with no new input so LangGraph executes from the - // current (trimmed) thread state. The trailing user message becomes the - // active prompt without being re-appended. + // RemoveMessage rollback + reposition the graph to the entry node + // via `as_node: '__start__'`. After the original run, the thread is + // at `__end__` with `next: []` — submitting `null` would be a no-op + // because there is nothing pending to execute. Setting `asNode` to + // the start node tells LangGraph to treat the update as if `__start__` + // had just produced the values, so the next pull resumes at the entry + // node and runs `generate` against the rolled-back state. + // + // We always pass `asNode` even when removeList is empty (rare, but + // possible if the assistant message had no id) so the regenerate + // submit below still runs the graph. + await manager.updateState( + { messages: removeList }, + { asNode: '__start__' }, + ); + + // Re-run the graph with no new input. With the thread now repositioned + // at `__start__`, this resumes at the entry node and produces a fresh + // assistant message — the trailing user message becomes the active + // prompt without being re-appended. await manager.submit(null, undefined); }, diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 6d1c02412..c45a605f8 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -212,11 +212,18 @@ export interface AgentTransport { * Optional: update server-side thread state (e.g. to emit RemoveMessage * entries for regenerate rollback). Forwards to the LangGraph * `threads.updateState` API. + * + * `options.asNode` corresponds to LangGraph's `as_node` parameter — the + * server treats the update as if that node had just produced the values, + * which determines what the next pull resumes. `regenerate()` passes + * `asNode: '__start__'` so the next `submit(null)` resumes at the entry + * node and re-runs `generate` against the rolled-back state. */ updateState?( threadId: string, values: Record, signal: AbortSignal, + options?: { asNode?: string }, ): Promise; } @@ -312,11 +319,13 @@ export interface LangGraphAgent void; /** - * Truncate the thread at the given assistant-message index and re-submit the - * preceding user message. Used by ``'s regenerate action and any - * custom UI that wants the same semantics. Rejects if the agent is currently - * loading, if the index does not point at an assistant message, or if no - * preceding user message exists. + * Discards the assistant message at the given index AND all messages after + * it, then re-runs the agent against the trimmed conversation tail. The + * preceding user message (at index - 1) is preserved and re-submitted as + * the agent's input. No new user message is added to the history. + * + * Throws if the message at `index` is not 'assistant' role, or if the + * agent is currently loading another response. */ regenerate: (assistantMessageIndex: number) => Promise; diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index f79d37417..5ec8a9674 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -55,7 +55,7 @@ export interface StreamManagerBridge { resubmitLast: () => Promise; getReasoningDurationMs:(id: string) => number | undefined; /** Update server-side thread state (e.g. RemoveMessage for regenerate rollback). */ - updateState: (values: Record) => Promise; + updateState: (values: Record, opts?: { asNode?: string }) => Promise; /** The current thread ID tracked by the bridge (null if not yet known). */ readonly currentThreadId: string | null; } @@ -665,13 +665,21 @@ export function createStreamManagerBridge): Promise => { + updateState: async ( + values: Record, + opts?: { asNode?: string }, + ): Promise => { // No-op when there is no thread yet or the transport doesn't support // updateState (e.g. MockAgentTransport in unit tests without a threadId). if (!currentThreadId || !transport.updateState) { return; } - await transport.updateState(currentThreadId, values, new AbortController().signal); + await transport.updateState( + currentThreadId, + values, + new AbortController().signal, + opts, + ); }, get currentThreadId(): string | null { diff --git a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts index 369c697cf..506701849 100644 --- a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts +++ b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts @@ -115,8 +115,17 @@ export class FetchStreamTransport implements AgentTransport { } /** Update server-side thread state, e.g. to remove messages for regenerate rollback. */ - async updateState(threadId: string, values: Record, _signal: AbortSignal): Promise { - await this.client.threads.updateState(threadId, { values }); + async updateState( + threadId: string, + values: Record, + _signal: AbortSignal, + options?: { asNode?: string }, + ): Promise { + const body: { values: Record; asNode?: string } = { values }; + if (options?.asNode !== undefined) { + body.asNode = options.asNode; + } + await this.client.threads.updateState(threadId, body); } } diff --git a/libs/licensing/package.json b/libs/licensing/package.json index 0ff1f7436..f8cc97b81 100644 --- a/libs/licensing/package.json +++ b/libs/licensing/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/licensing", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git", diff --git a/libs/partial-json/package.json b/libs/partial-json/package.json index c92d32fbb..79a4e3468 100644 --- a/libs/partial-json/package.json +++ b/libs/partial-json/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/partial-json", - "version": "0.0.28", + "version": "0.0.29", "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package.", "license": "MIT", "repository": { diff --git a/libs/render/package.json b/libs/render/package.json index f0f6a1b9d..3a54da47a 100644 --- a/libs/render/package.json +++ b/libs/render/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/render", - "version": "0.0.28", + "version": "0.0.29", "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/ui-react/package.json b/libs/ui-react/package.json index 047438adb..4ad87381e 100644 --- a/libs/ui-react/package.json +++ b/libs/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/ui-react", - "version": "0.0.28", + "version": "0.0.29", "license": "MIT", "repository": { "type": "git",