Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/website/content/docs/agent/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@
},
{
"name": "updateState",
"signature": "updateState(threadId: string, values: Record<string, unknown>, _signal: AbortSignal)",
"signature": "updateState(threadId: string, values: Record<string, unknown>, _signal: AbortSignal, options: object)",
"description": "Update server-side thread state, e.g. to remove messages for regenerate rollback.",
"params": [
{
Expand All @@ -193,6 +193,12 @@
"type": "AbortSignal",
"description": "",
"optional": false
},
{
"name": "options",
"type": "object",
"description": "",
"optional": true
}
]
}
Expand Down
2 changes: 1 addition & 1 deletion libs/a2ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/a2ui",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/ag-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/ag-ui",
"version": "0.0.28",
"version": "0.0.29",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
4 changes: 2 additions & 2 deletions libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.28",
"version": "0.0.29",
"exports": {
".": {
"types": "./index.d.ts",
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-docs",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-registry/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-registry",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-shell/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-shell",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-testing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-testing",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/cockpit-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/cockpit-ui",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/db",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/design-tokens/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/design-tokens",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/example-layouts/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion libs/langgraph/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/langgraph",
"version": "0.0.28",
"version": "0.0.29",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
54 changes: 54 additions & 0 deletions libs/langgraph/src/lib/agent.fn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
options?: { asNode?: string };
}> = [];
const transport = new MockAgentTransport();
// Augment the mock with an updateState capture.
(transport as unknown as {
updateState: (
threadId: string,
values: Record<string, unknown>,
signal: AbortSignal,
options?: { asNode?: string },
) => Promise<void>;
}).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<Record<string, unknown>>) ?? [];
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(() =>
Expand Down
29 changes: 20 additions & 9 deletions libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},

Expand Down
19 changes: 14 additions & 5 deletions libs/langgraph/src/lib/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
signal: AbortSignal,
options?: { asNode?: string },
): Promise<void>;
}

Expand Down Expand Up @@ -312,11 +319,13 @@ export interface LangGraphAgent<T = unknown, ResolvedBag extends BagTemplate = B
reload: () => void;

/**
* Truncate the thread at the given assistant-message index and re-submit the
* preceding user message. Used by `<ngaf-chat>`'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<void>;

Expand Down
14 changes: 11 additions & 3 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface StreamManagerBridge {
resubmitLast: () => Promise<void>;
getReasoningDurationMs:(id: string) => number | undefined;
/** Update server-side thread state (e.g. RemoveMessage for regenerate rollback). */
updateState: (values: Record<string, unknown>) => Promise<void>;
updateState: (values: Record<string, unknown>, opts?: { asNode?: string }) => Promise<void>;
/** The current thread ID tracked by the bridge (null if not yet known). */
readonly currentThreadId: string | null;
}
Expand Down Expand Up @@ -665,13 +665,21 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
return entry.endedAt - entry.startedAt;
},

updateState: async (values: Record<string, unknown>): Promise<void> => {
updateState: async (
values: Record<string, unknown>,
opts?: { asNode?: string },
): Promise<void> => {
// 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 {
Expand Down
13 changes: 11 additions & 2 deletions libs/langgraph/src/lib/transport/fetch-stream.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, _signal: AbortSignal): Promise<void> {
await this.client.threads.updateState(threadId, { values });
async updateState(
threadId: string,
values: Record<string, unknown>,
_signal: AbortSignal,
options?: { asNode?: string },
): Promise<void> {
const body: { values: Record<string, unknown>; asNode?: string } = { values };
if (options?.asNode !== undefined) {
body.asNode = options.asNode;
}
await this.client.threads.updateState(threadId, body);
}
}

Expand Down
2 changes: 1 addition & 1 deletion libs/licensing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/licensing",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion libs/partial-json/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion libs/render/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion libs/ui-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/ui-react",
"version": "0.0.28",
"version": "0.0.29",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
Loading