@threadplane/langgraph 0.0.46
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.
|
@@ -0,0 +1,2928 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, signal, Injectable, inject, DestroyRef, isSignal, computed, effect } from '@angular/core';
|
|
3
|
+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { takeUntil, Subject, BehaviorSubject, of, throttleTime, asyncScheduler } from 'rxjs';
|
|
5
|
+
import { takeUntil as takeUntil$1 } from 'rxjs/operators';
|
|
6
|
+
import { Client } from '@langchain/langgraph-sdk';
|
|
7
|
+
import { getToolCallsWithResults } from '@langchain/langgraph-sdk/utils';
|
|
8
|
+
|
|
9
|
+
// SPDX-License-Identifier: MIT
|
|
10
|
+
const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG');
|
|
11
|
+
/**
|
|
12
|
+
* Angular provider factory that registers global defaults for all
|
|
13
|
+
* agent instances in the application.
|
|
14
|
+
*/
|
|
15
|
+
function provideAgent(config) {
|
|
16
|
+
return { provide: AGENT_CONFIG, useValue: config };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// SPDX-License-Identifier: MIT
|
|
20
|
+
/**
|
|
21
|
+
* Optional registry that collects per-instance agent lifecycles within
|
|
22
|
+
* an Angular injection context. External instrumentation packages
|
|
23
|
+
* (e.g. cockpit-telemetry) provide this token and read from it.
|
|
24
|
+
*
|
|
25
|
+
* `@threadplane/langgraph` does NOT provide this itself — `agent()` writes to
|
|
26
|
+
* the registry only when an external consumer has provided it.
|
|
27
|
+
*/
|
|
28
|
+
class AgentLifecycleRegistry {
|
|
29
|
+
_lifecycles = signal([], ...(ngDevMode ? [{ debugName: "_lifecycles" }] : []));
|
|
30
|
+
/** Reactive list of registered lifecycles. */
|
|
31
|
+
lifecycles = this._lifecycles.asReadonly();
|
|
32
|
+
register(lifecycle) {
|
|
33
|
+
this._lifecycles.update((curr) => [...curr, lifecycle]);
|
|
34
|
+
}
|
|
35
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
36
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry });
|
|
37
|
+
}
|
|
38
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: AgentLifecycleRegistry, decorators: [{
|
|
39
|
+
type: Injectable
|
|
40
|
+
}] });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runtime constant mirroring Angular's ResourceStatus string-union type.
|
|
44
|
+
* Angular 21 ships ResourceStatus as a pure string-union type (no runtime value),
|
|
45
|
+
* so we provide a const-object shim for code that needs runtime comparisons.
|
|
46
|
+
*/
|
|
47
|
+
const ResourceStatus = {
|
|
48
|
+
Idle: 'idle',
|
|
49
|
+
Loading: 'loading',
|
|
50
|
+
Reloading: 'reloading',
|
|
51
|
+
Resolved: 'resolved',
|
|
52
|
+
Error: 'error',
|
|
53
|
+
Local: 'local',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// SPDX-License-Identifier: MIT
|
|
57
|
+
/**
|
|
58
|
+
* Construct a LangGraph SDK Client that accepts both absolute URLs
|
|
59
|
+
* (`http://localhost:2024`) and relative `/api`-style paths that get
|
|
60
|
+
* proxied by middleware in production. The SDK itself rejects
|
|
61
|
+
* relative URLs, so this helper rewrites them against
|
|
62
|
+
* `window.location.origin` when running in the browser.
|
|
63
|
+
*
|
|
64
|
+
* Single source of truth for the absolute-URL rewrite — the streaming
|
|
65
|
+
* transport (`fetch-stream.transport.ts`) and the threads adapter
|
|
66
|
+
* (`LangGraphThreadsAdapter`) both go through here.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* const client = createLangGraphClient(environment.langGraphApiUrl);
|
|
71
|
+
* const threads = await client.threads.search({ limit: 50 });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
function createLangGraphClient(apiUrl) {
|
|
75
|
+
return new Client({ apiUrl: toAbsoluteApiUrl(apiUrl) });
|
|
76
|
+
}
|
|
77
|
+
/** Exported separately so non-Client callers (e.g. raw fetch) can
|
|
78
|
+
* share the same normalization logic. */
|
|
79
|
+
function toAbsoluteApiUrl(apiUrl) {
|
|
80
|
+
if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))
|
|
81
|
+
return apiUrl;
|
|
82
|
+
return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Production transport that connects to a LangGraph Platform API via HTTP and SSE.
|
|
87
|
+
*
|
|
88
|
+
* Creates threads automatically if no threadId is provided, and streams events
|
|
89
|
+
* using the LangGraph SDK client.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* const transport = new FetchStreamTransport(
|
|
94
|
+
* 'http://localhost:2024',
|
|
95
|
+
* (id) => console.log('New thread:', id),
|
|
96
|
+
* );
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
class FetchStreamTransport {
|
|
100
|
+
client;
|
|
101
|
+
onThreadId;
|
|
102
|
+
/**
|
|
103
|
+
* @param apiUrl - Base URL of the LangGraph Platform API
|
|
104
|
+
* @param onThreadId - Optional callback invoked when a new thread is created
|
|
105
|
+
*/
|
|
106
|
+
constructor(apiUrl, onThreadId) {
|
|
107
|
+
// createLangGraphClient handles the absolute-URL normalization
|
|
108
|
+
// required by the SDK when `apiUrl` is a relative `/api`-style
|
|
109
|
+
// path proxied by middleware in production.
|
|
110
|
+
this.client = createLangGraphClient(apiUrl);
|
|
111
|
+
this.onThreadId = onThreadId;
|
|
112
|
+
}
|
|
113
|
+
/** Open a streaming connection, creating a thread if needed. */
|
|
114
|
+
async *stream(assistantId, threadId, payload, signal, options) {
|
|
115
|
+
let thread = threadId;
|
|
116
|
+
if (!thread) {
|
|
117
|
+
const t = await this.client.threads.create();
|
|
118
|
+
thread = t.thread_id;
|
|
119
|
+
this.onThreadId?.(thread);
|
|
120
|
+
}
|
|
121
|
+
const run = this.client.runs.stream(thread, assistantId, buildRunPayload(payload, signal, options));
|
|
122
|
+
for await (const event of run) {
|
|
123
|
+
yield normalizeSdkEvent(event.event, event.data);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Join an already-started run without creating a new thread. */
|
|
127
|
+
async *joinStream(threadId, runId, lastEventId, signal) {
|
|
128
|
+
// SDK joinStream: joins an already-started run without creating a new one.
|
|
129
|
+
const run = this.client.runs.joinStream(threadId, runId, {
|
|
130
|
+
signal,
|
|
131
|
+
...(lastEventId !== undefined ? { lastEventId } : {}),
|
|
132
|
+
});
|
|
133
|
+
for await (const event of run) {
|
|
134
|
+
yield normalizeSdkEvent(event.event, event.data);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Create a pending server-side run using LangGraph's enqueue strategy. */
|
|
138
|
+
async createQueuedRun(assistantId, threadId, payload, signal, options) {
|
|
139
|
+
const run = await this.client.runs.create(threadId, assistantId, {
|
|
140
|
+
...buildRunPayload(payload, signal, options),
|
|
141
|
+
multitaskStrategy: 'enqueue',
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
id: run.run_id,
|
|
145
|
+
threadId: run.thread_id ?? threadId,
|
|
146
|
+
values: payload,
|
|
147
|
+
options: { multitaskStrategy: 'enqueue', signal },
|
|
148
|
+
createdAt: run.created_at ? new Date(run.created_at) : new Date(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Cancel a server-side run. */
|
|
152
|
+
async cancelRun(threadId, runId, signal) {
|
|
153
|
+
await this.client.runs.cancel(threadId, runId, false, 'interrupt', { signal });
|
|
154
|
+
}
|
|
155
|
+
/** Load persisted checkpoint history for a thread. */
|
|
156
|
+
async getHistory(threadId, signal) {
|
|
157
|
+
return this.client.threads.getHistory(threadId, { signal });
|
|
158
|
+
}
|
|
159
|
+
/** Update server-side thread state, e.g. to remove messages for regenerate rollback. */
|
|
160
|
+
async updateState(threadId, values, _signal, options) {
|
|
161
|
+
const body = { values };
|
|
162
|
+
if (options?.asNode !== undefined) {
|
|
163
|
+
body.asNode = options.asNode;
|
|
164
|
+
}
|
|
165
|
+
await this.client.threads.updateState(threadId, body);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function buildRunPayload(input, signal, options) {
|
|
169
|
+
const runOptions = { ...(options ?? {}) };
|
|
170
|
+
const hasCheckpoint = Object.prototype.hasOwnProperty.call(runOptions, 'checkpoint');
|
|
171
|
+
const checkpoint = runOptions.checkpoint;
|
|
172
|
+
const streamMode = runOptions.streamMode;
|
|
173
|
+
const streamSubgraphs = runOptions.streamSubgraphs;
|
|
174
|
+
delete runOptions.signal;
|
|
175
|
+
delete runOptions.resume;
|
|
176
|
+
delete runOptions.checkpoint;
|
|
177
|
+
delete runOptions.streamMode;
|
|
178
|
+
delete runOptions.streamSubgraphs;
|
|
179
|
+
return {
|
|
180
|
+
...runOptions,
|
|
181
|
+
...(hasCheckpoint ? { checkpoint } : {}),
|
|
182
|
+
input: input,
|
|
183
|
+
streamMode: streamMode ?? defaultStreamMode(),
|
|
184
|
+
streamSubgraphs: streamSubgraphs ?? true,
|
|
185
|
+
signal,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function defaultStreamMode() {
|
|
189
|
+
// 'tools' is intentionally omitted: not supported by langgraph_api < 0.9.x
|
|
190
|
+
// Servers reject the entire request with HTTP 422 if any stream_mode in
|
|
191
|
+
// the array is unknown to them. Tool-call data is still derivable from
|
|
192
|
+
// the messages stream.
|
|
193
|
+
return ['values', 'messages-tuple', 'updates', 'custom'];
|
|
194
|
+
}
|
|
195
|
+
function normalizeSdkEvent(type, data) {
|
|
196
|
+
const namespace = extractNamespace(type);
|
|
197
|
+
const baseType = getBaseEventType$1(type);
|
|
198
|
+
if (baseType === 'messages' && Array.isArray(data) && data.length === 2 && isRecord$2(data[1])) {
|
|
199
|
+
return { type, ...(namespace ? { namespace } : {}), messages: [data[0]], messageMetadata: data[1], data };
|
|
200
|
+
}
|
|
201
|
+
if (isMessagesEvent$1(type) && Array.isArray(data)) {
|
|
202
|
+
return { type, ...(namespace ? { namespace } : {}), messages: data, data };
|
|
203
|
+
}
|
|
204
|
+
if (isRecord$2(data)) {
|
|
205
|
+
return { type, ...(namespace ? { namespace } : {}), ...data, data };
|
|
206
|
+
}
|
|
207
|
+
return { type, ...(namespace ? { namespace } : {}), data };
|
|
208
|
+
}
|
|
209
|
+
function isMessagesEvent$1(type) {
|
|
210
|
+
const baseType = getBaseEventType$1(type);
|
|
211
|
+
return baseType === 'messages' || baseType.startsWith('messages/');
|
|
212
|
+
}
|
|
213
|
+
function getBaseEventType$1(type) {
|
|
214
|
+
return String(type).split('|')[0];
|
|
215
|
+
}
|
|
216
|
+
function extractNamespace(type) {
|
|
217
|
+
const parts = String(type).split('|');
|
|
218
|
+
return parts.length > 1 ? parts.slice(1) : undefined;
|
|
219
|
+
}
|
|
220
|
+
function isRecord$2(value) {
|
|
221
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const DEFAULT_SUBAGENT_TOOL_NAMES = ['task'];
|
|
225
|
+
/**
|
|
226
|
+
* Lightweight Angular adapter for LangGraph subagent stream state.
|
|
227
|
+
*
|
|
228
|
+
* This intentionally mirrors only the SDK behavior this package exposes. Using
|
|
229
|
+
* the SDK UI barrel at runtime pulls StreamManager/client utilities into every
|
|
230
|
+
* Angular bundle, which breaks cockpit production budgets.
|
|
231
|
+
*/
|
|
232
|
+
class SubagentTracker {
|
|
233
|
+
subagentToolNames;
|
|
234
|
+
onSubagentChange;
|
|
235
|
+
subagents = new Map();
|
|
236
|
+
namespaceToToolCallId = new Map();
|
|
237
|
+
pendingMatches = new Map();
|
|
238
|
+
constructor(options = {}) {
|
|
239
|
+
this.subagentToolNames = new Set(options.subagentToolNames ?? DEFAULT_SUBAGENT_TOOL_NAMES);
|
|
240
|
+
this.onSubagentChange = options.onSubagentChange;
|
|
241
|
+
}
|
|
242
|
+
clear() {
|
|
243
|
+
this.subagents.clear();
|
|
244
|
+
this.namespaceToToolCallId.clear();
|
|
245
|
+
this.pendingMatches.clear();
|
|
246
|
+
this.onSubagentChange?.();
|
|
247
|
+
}
|
|
248
|
+
getSubagents() {
|
|
249
|
+
const visible = new Map();
|
|
250
|
+
for (const [id, subagent] of this.subagents) {
|
|
251
|
+
if (subagent.status !== 'pending') {
|
|
252
|
+
visible.set(id, subagent);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return visible;
|
|
256
|
+
}
|
|
257
|
+
registerFromToolCalls(toolCalls, aiMessageId) {
|
|
258
|
+
let changed = false;
|
|
259
|
+
for (const toolCall of toolCalls) {
|
|
260
|
+
if (!this.subagentToolNames.has(toolCall.name))
|
|
261
|
+
continue;
|
|
262
|
+
const id = toolCall.id;
|
|
263
|
+
if (!id)
|
|
264
|
+
continue;
|
|
265
|
+
const args = parseToolCallArgs(toolCall.args);
|
|
266
|
+
if (!isValidSubagentType(args['subagent_type']))
|
|
267
|
+
continue;
|
|
268
|
+
const existing = this.subagents.get(id);
|
|
269
|
+
this.subagents.set(id, {
|
|
270
|
+
id,
|
|
271
|
+
status: existing?.status ?? 'pending',
|
|
272
|
+
toolCall: {
|
|
273
|
+
id,
|
|
274
|
+
name: toolCall.name,
|
|
275
|
+
args: {
|
|
276
|
+
...args,
|
|
277
|
+
...(aiMessageId ? { _aiMessageId: aiMessageId } : {}),
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
values: existing?.values ?? {},
|
|
281
|
+
messages: existing?.messages ?? [],
|
|
282
|
+
});
|
|
283
|
+
changed = true;
|
|
284
|
+
}
|
|
285
|
+
if (changed) {
|
|
286
|
+
this.retryPendingMatches();
|
|
287
|
+
this.onSubagentChange?.();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
reconstructFromMessages(messages, options = {}) {
|
|
291
|
+
if (options.skipIfPopulated && this.subagents.size > 0)
|
|
292
|
+
return;
|
|
293
|
+
for (const message of messages) {
|
|
294
|
+
const raw = message;
|
|
295
|
+
if (isAiMessageWithToolCalls$1(raw)) {
|
|
296
|
+
this.registerFromToolCalls(raw['tool_calls'], typeof raw['id'] === 'string' ? raw['id'] : null);
|
|
297
|
+
}
|
|
298
|
+
else if (isToolMessage$1(raw)) {
|
|
299
|
+
this.processToolMessage(raw['tool_call_id'], raw['content'], raw['status'] === 'error' ? 'error' : 'success');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
matchSubgraphToSubagent(namespaceId, description) {
|
|
304
|
+
if (this.namespaceToToolCallId.has(namespaceId)) {
|
|
305
|
+
return this.namespaceToToolCallId.get(namespaceId);
|
|
306
|
+
}
|
|
307
|
+
const mapped = new Set(this.namespaceToToolCallId.values());
|
|
308
|
+
const establish = (toolCallId) => {
|
|
309
|
+
this.namespaceToToolCallId.set(namespaceId, toolCallId);
|
|
310
|
+
const subagent = this.subagents.get(toolCallId);
|
|
311
|
+
if (subagent) {
|
|
312
|
+
this.subagents.set(toolCallId, {
|
|
313
|
+
...subagent,
|
|
314
|
+
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
this.onSubagentChange?.();
|
|
318
|
+
return toolCallId;
|
|
319
|
+
};
|
|
320
|
+
for (const [toolCallId, subagent] of this.subagents) {
|
|
321
|
+
if (mapped.has(toolCallId))
|
|
322
|
+
continue;
|
|
323
|
+
if (subagent.toolCall.args['description'] === description) {
|
|
324
|
+
return establish(toolCallId);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const [toolCallId, subagent] of this.subagents) {
|
|
328
|
+
if (mapped.has(toolCallId))
|
|
329
|
+
continue;
|
|
330
|
+
const subagentDescription = subagent.toolCall.args['description'];
|
|
331
|
+
if (typeof subagentDescription !== 'string' || !subagentDescription)
|
|
332
|
+
continue;
|
|
333
|
+
if (description.includes(subagentDescription) || subagentDescription.includes(description)) {
|
|
334
|
+
return establish(toolCallId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const [toolCallId, subagent] of this.subagents) {
|
|
338
|
+
if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
|
|
339
|
+
return establish(toolCallId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (description) {
|
|
343
|
+
this.pendingMatches.set(namespaceId, description);
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
markRunningFromNamespace(namespaceId, namespace) {
|
|
348
|
+
const toolCallId = this.resolveToolCallId(namespaceId);
|
|
349
|
+
const subagent = this.subagents.get(toolCallId);
|
|
350
|
+
if (!subagent)
|
|
351
|
+
return;
|
|
352
|
+
if (!this.namespaceToToolCallId.has(namespaceId)) {
|
|
353
|
+
this.namespaceToToolCallId.set(namespaceId, toolCallId);
|
|
354
|
+
}
|
|
355
|
+
this.subagents.set(toolCallId, {
|
|
356
|
+
...subagent,
|
|
357
|
+
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
|
|
358
|
+
values: {
|
|
359
|
+
...subagent.values,
|
|
360
|
+
...(namespace ? { namespace } : {}),
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
this.onSubagentChange?.();
|
|
364
|
+
}
|
|
365
|
+
updateSubagentValues(namespaceId, values) {
|
|
366
|
+
const toolCallId = this.resolveToolCallId(namespaceId);
|
|
367
|
+
const subagent = this.subagents.get(toolCallId);
|
|
368
|
+
if (!subagent)
|
|
369
|
+
return;
|
|
370
|
+
this.subagents.set(toolCallId, {
|
|
371
|
+
...subagent,
|
|
372
|
+
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
|
|
373
|
+
values,
|
|
374
|
+
});
|
|
375
|
+
this.onSubagentChange?.();
|
|
376
|
+
}
|
|
377
|
+
addMessageToSubagent(namespaceId, message) {
|
|
378
|
+
const toolCallId = this.resolveToolCallId(namespaceId);
|
|
379
|
+
const subagent = this.subagents.get(toolCallId);
|
|
380
|
+
if (!subagent)
|
|
381
|
+
return;
|
|
382
|
+
this.subagents.set(toolCallId, {
|
|
383
|
+
...subagent,
|
|
384
|
+
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
|
|
385
|
+
messages: mergeMessages$1(subagent.messages, [message]),
|
|
386
|
+
});
|
|
387
|
+
this.onSubagentChange?.();
|
|
388
|
+
}
|
|
389
|
+
processToolMessage(toolCallId, content, status) {
|
|
390
|
+
const subagent = this.subagents.get(toolCallId);
|
|
391
|
+
if (!subagent)
|
|
392
|
+
return;
|
|
393
|
+
this.subagents.set(toolCallId, {
|
|
394
|
+
...subagent,
|
|
395
|
+
status: status === 'error' ? 'error' : 'complete',
|
|
396
|
+
values: {
|
|
397
|
+
...subagent.values,
|
|
398
|
+
result: content,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
this.onSubagentChange?.();
|
|
402
|
+
}
|
|
403
|
+
retryPendingMatches() {
|
|
404
|
+
for (const [namespaceId, description] of this.pendingMatches) {
|
|
405
|
+
if (this.matchSubgraphToSubagent(namespaceId, description)) {
|
|
406
|
+
this.pendingMatches.delete(namespaceId);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
resolveToolCallId(namespaceId) {
|
|
411
|
+
return this.namespaceToToolCallId.get(namespaceId) ?? namespaceId;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function isSubagentNamespace(namespace) {
|
|
415
|
+
if (!namespace)
|
|
416
|
+
return false;
|
|
417
|
+
if (typeof namespace === 'string')
|
|
418
|
+
return namespace.includes('tools:');
|
|
419
|
+
return namespace.some(segment => segment.startsWith('tools:'));
|
|
420
|
+
}
|
|
421
|
+
function extractToolCallIdFromNamespace(namespace) {
|
|
422
|
+
if (!namespace)
|
|
423
|
+
return undefined;
|
|
424
|
+
for (const segment of namespace) {
|
|
425
|
+
if (segment.startsWith('tools:'))
|
|
426
|
+
return segment.slice(6);
|
|
427
|
+
}
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
function parseToolCallArgs(args) {
|
|
431
|
+
if (typeof args !== 'string')
|
|
432
|
+
return args;
|
|
433
|
+
try {
|
|
434
|
+
const parsed = JSON.parse(args);
|
|
435
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
436
|
+
? parsed
|
|
437
|
+
: {};
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function isValidSubagentType(value) {
|
|
444
|
+
return typeof value === 'string'
|
|
445
|
+
&& value.length >= 3
|
|
446
|
+
&& value.length <= 50
|
|
447
|
+
&& /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value);
|
|
448
|
+
}
|
|
449
|
+
function isAiMessageWithToolCalls$1(value) {
|
|
450
|
+
return (value['type'] === 'ai' || value['type'] === 'assistant')
|
|
451
|
+
&& Array.isArray(value['tool_calls']);
|
|
452
|
+
}
|
|
453
|
+
function isToolMessage$1(value) {
|
|
454
|
+
return value['type'] === 'tool' && typeof value['tool_call_id'] === 'string';
|
|
455
|
+
}
|
|
456
|
+
function mergeMessages$1(existing, incoming) {
|
|
457
|
+
const merged = [...existing];
|
|
458
|
+
for (const msg of incoming) {
|
|
459
|
+
const id = getMessageId(msg);
|
|
460
|
+
const idx = id ? merged.findIndex(m => getMessageId(m) === id) : -1;
|
|
461
|
+
if (idx >= 0) {
|
|
462
|
+
merged[idx] = msg;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
merged.push(msg);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return merged;
|
|
469
|
+
}
|
|
470
|
+
function getMessageId(message) {
|
|
471
|
+
return message.id;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// SPDX-License-Identifier: MIT
|
|
475
|
+
// Local copy of the trace harness — same gating as @threadplane/chat's trace.ts.
|
|
476
|
+
// Duplicated here to avoid an @threadplane/chat dep on the langgraph internals path.
|
|
477
|
+
function isLgTraceEnabled() {
|
|
478
|
+
if (typeof globalThis === 'undefined')
|
|
479
|
+
return false;
|
|
480
|
+
const win = globalThis.window;
|
|
481
|
+
if (!win)
|
|
482
|
+
return false;
|
|
483
|
+
if (win.__threadplaneChatTrace === true)
|
|
484
|
+
return true;
|
|
485
|
+
try {
|
|
486
|
+
return win.localStorage?.getItem('THREADPLANE_CHAT_STREAM_TRACE') === '1';
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function lgTrace(...args) {
|
|
493
|
+
if (isLgTraceEnabled()) {
|
|
494
|
+
// eslint-disable-next-line no-console
|
|
495
|
+
console.debug('[ngaf-chat-stream]', ...args);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function captureAgentRuntimeTelemetry(sink, event, properties) {
|
|
499
|
+
if (!sink)
|
|
500
|
+
return;
|
|
501
|
+
try {
|
|
502
|
+
void Promise.resolve(sink({ event, properties })).catch(() => undefined);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// Keep telemetry side effects isolated from stream control flow.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function agentRuntimeTelemetryErrorClass(error) {
|
|
509
|
+
if (error instanceof Error)
|
|
510
|
+
return error.name || error.constructor.name || 'Error';
|
|
511
|
+
if (error
|
|
512
|
+
&& typeof error === 'object'
|
|
513
|
+
&& 'name' in error
|
|
514
|
+
&& typeof error.name === 'string'
|
|
515
|
+
&& error.name.length > 0) {
|
|
516
|
+
return error.name;
|
|
517
|
+
}
|
|
518
|
+
return 'UnknownError';
|
|
519
|
+
}
|
|
520
|
+
function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
|
|
521
|
+
// Intercept onThreadId to update currentThreadId when the transport
|
|
522
|
+
// auto-creates a thread. Without this, each submit() creates a new thread
|
|
523
|
+
// because currentThreadId stays null.
|
|
524
|
+
const userOnThreadId = options.onThreadId;
|
|
525
|
+
const wrappedOnThreadId = (id) => {
|
|
526
|
+
currentThreadId = id;
|
|
527
|
+
userOnThreadId?.(id);
|
|
528
|
+
};
|
|
529
|
+
const transport = options.transport ?? new FetchStreamTransport(options.apiUrl, wrappedOnThreadId);
|
|
530
|
+
let currentThreadId = null;
|
|
531
|
+
let lastPayload = null;
|
|
532
|
+
let lastOptions;
|
|
533
|
+
let abortController = null;
|
|
534
|
+
let historyAbortController = null;
|
|
535
|
+
let hasSeenThreadId = false;
|
|
536
|
+
const toolProgressMap = new Map();
|
|
537
|
+
const queuedRuns = [];
|
|
538
|
+
let drainingQueue = false;
|
|
539
|
+
const subagentManager = new SubagentTracker({
|
|
540
|
+
subagentToolNames: options.subagentToolNames,
|
|
541
|
+
onSubagentChange: publishSubagents,
|
|
542
|
+
});
|
|
543
|
+
const telemetryProperties = { transport: 'langgraph', surface: 'agent' };
|
|
544
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_instance_created', telemetryProperties);
|
|
545
|
+
function captureRuntimeRequestTelemetry(requestType) {
|
|
546
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_request_created', {
|
|
547
|
+
...telemetryProperties,
|
|
548
|
+
requestType,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Tracks reasoning timing per message id. Keys are message ids; values
|
|
553
|
+
* record when reasoning content first arrived and when response text
|
|
554
|
+
* first appeared (or the canonical message arrived). Cleared on
|
|
555
|
+
* resetThreadState() and on bridge teardown.
|
|
556
|
+
*/
|
|
557
|
+
const reasoningTimingMap = new Map();
|
|
558
|
+
function resetThreadState() {
|
|
559
|
+
historyAbortController?.abort();
|
|
560
|
+
subjects.values$.next({});
|
|
561
|
+
subjects.messages$.next([]);
|
|
562
|
+
subjects.history$.next([]);
|
|
563
|
+
subjects.interrupt$.next(undefined);
|
|
564
|
+
subjects.interrupts$.next([]);
|
|
565
|
+
subjects.toolProgress$.next([]);
|
|
566
|
+
subjects.toolCalls$.next([]);
|
|
567
|
+
subjects.messageMetadata$.next(new Map());
|
|
568
|
+
subjects.subagents$.next(new Map());
|
|
569
|
+
void cancelQueueEntries(takeQueuedRuns()).catch(err => subjects.error$.next(err));
|
|
570
|
+
publishQueue();
|
|
571
|
+
subjects.custom$.next([]);
|
|
572
|
+
subjects.isThreadLoading$.next(false);
|
|
573
|
+
toolProgressMap.clear();
|
|
574
|
+
subagentManager.clear();
|
|
575
|
+
reasoningTimingMap.clear();
|
|
576
|
+
}
|
|
577
|
+
function setThreadId(id, resetState) {
|
|
578
|
+
if (resetState) {
|
|
579
|
+
abortController?.abort();
|
|
580
|
+
}
|
|
581
|
+
currentThreadId = id;
|
|
582
|
+
if (resetState) {
|
|
583
|
+
resetThreadState();
|
|
584
|
+
}
|
|
585
|
+
void refreshHistory();
|
|
586
|
+
}
|
|
587
|
+
// Track threadId changes
|
|
588
|
+
threadId$.pipe(takeUntil(destroy$)).subscribe(id => {
|
|
589
|
+
const shouldReset = hasSeenThreadId && currentThreadId !== id;
|
|
590
|
+
hasSeenThreadId = true;
|
|
591
|
+
setThreadId(id, shouldReset);
|
|
592
|
+
});
|
|
593
|
+
destroy$.subscribe(() => {
|
|
594
|
+
abortController?.abort();
|
|
595
|
+
historyAbortController?.abort();
|
|
596
|
+
reasoningTimingMap.clear();
|
|
597
|
+
});
|
|
598
|
+
async function refreshHistory(force = false) {
|
|
599
|
+
const getHistory = transport.getHistory?.bind(transport);
|
|
600
|
+
if (!currentThreadId || !getHistory)
|
|
601
|
+
return;
|
|
602
|
+
historyAbortController?.abort();
|
|
603
|
+
const controller = new AbortController();
|
|
604
|
+
historyAbortController = controller;
|
|
605
|
+
const threadId = currentThreadId;
|
|
606
|
+
subjects.isThreadLoading$.next(true);
|
|
607
|
+
try {
|
|
608
|
+
const history = await getHistory(threadId, controller.signal);
|
|
609
|
+
if (!controller.signal.aborted && currentThreadId === threadId) {
|
|
610
|
+
subjects.history$.next(history);
|
|
611
|
+
// Project the latest checkpoint into messages$ + values$:
|
|
612
|
+
// - On first connect (`force=false`): only when messages$ is
|
|
613
|
+
// empty, so optimistic local state isn't clobbered.
|
|
614
|
+
// - At run completion (`force=true`): always — server state is
|
|
615
|
+
// authoritative for node-level mutations (e.g. RemoveMessage
|
|
616
|
+
// or id-match content replacement performed by post-process
|
|
617
|
+
// nodes), and the streaming SDK doesn't always restream those.
|
|
618
|
+
const latest = history[0];
|
|
619
|
+
const shouldProject = latest?.values
|
|
620
|
+
&& (force || subjects.messages$.value.length === 0);
|
|
621
|
+
if (shouldProject) {
|
|
622
|
+
const restoredMessages = latest.values?.messages ?? [];
|
|
623
|
+
const restoredValues = { ...latest.values };
|
|
624
|
+
// Strip the `messages` field from values — messages$ is the
|
|
625
|
+
// canonical surface for them; keeping a duplicate in values$
|
|
626
|
+
// would confuse downstream consumers reading both subjects.
|
|
627
|
+
delete restoredValues.messages;
|
|
628
|
+
subjects.messages$.next(restoredMessages);
|
|
629
|
+
subjects.values$.next(restoredValues);
|
|
630
|
+
// Rebuild derived subjects from the new authoritative messages$.
|
|
631
|
+
// Tool-call results displayed by chat-tool-calls come from
|
|
632
|
+
// toolCalls$, which is built from messages$; without this, the
|
|
633
|
+
// panel keeps showing the streamed pre-mutation content.
|
|
634
|
+
syncToolCallsFromMessages();
|
|
635
|
+
}
|
|
636
|
+
// Hydrate pending interrupts from the latest checkpoint. When a
|
|
637
|
+
// thread is reloaded mid-pause (paused at an interrupt), the
|
|
638
|
+
// streaming events won't replay — interrupts live on the
|
|
639
|
+
// checkpoint's tasks[i].interrupts and must be projected manually
|
|
640
|
+
// so the interrupt panel re-renders on page reload.
|
|
641
|
+
hydrateInterruptsFromHistory(history, subjects);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
if (!controller.signal.aborted && err?.name !== 'AbortError') {
|
|
646
|
+
subjects.error$.next(err);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
if (historyAbortController === controller) {
|
|
651
|
+
historyAbortController = null;
|
|
652
|
+
subjects.isThreadLoading$.next(false);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function publishQueue() {
|
|
657
|
+
subjects.queue$.next(createQueueSnapshot());
|
|
658
|
+
}
|
|
659
|
+
function createQueueSnapshot() {
|
|
660
|
+
return {
|
|
661
|
+
entries: [...queuedRuns],
|
|
662
|
+
size: queuedRuns.length,
|
|
663
|
+
cancel: cancelQueuedRun,
|
|
664
|
+
clear: clearQueue,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
async function enqueueRun(payload, opts) {
|
|
668
|
+
if (!currentThreadId) {
|
|
669
|
+
throw new Error('Cannot enqueue a run before a LangGraph thread exists.');
|
|
670
|
+
}
|
|
671
|
+
if (!transport.createQueuedRun) {
|
|
672
|
+
throw new Error('The configured LangGraph transport does not support server-side queueing.');
|
|
673
|
+
}
|
|
674
|
+
captureRuntimeRequestTelemetry('enqueue');
|
|
675
|
+
const controller = new AbortController();
|
|
676
|
+
const entry = await transport.createQueuedRun(options.assistantId, currentThreadId, payload, opts?.signal ?? controller.signal, opts);
|
|
677
|
+
queuedRuns.push({
|
|
678
|
+
...entry,
|
|
679
|
+
values: payload,
|
|
680
|
+
options: { ...opts, multitaskStrategy: 'enqueue' },
|
|
681
|
+
createdAt: entry.createdAt ?? new Date(),
|
|
682
|
+
});
|
|
683
|
+
publishQueue();
|
|
684
|
+
}
|
|
685
|
+
async function cancelQueuedRun(id) {
|
|
686
|
+
const index = queuedRuns.findIndex(entry => entry.id === id);
|
|
687
|
+
if (index === -1)
|
|
688
|
+
return false;
|
|
689
|
+
const [entry] = queuedRuns.splice(index, 1);
|
|
690
|
+
publishQueue();
|
|
691
|
+
if (!entry || !transport.cancelRun)
|
|
692
|
+
return false;
|
|
693
|
+
await cancelQueueEntries([entry]);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
async function clearQueue() {
|
|
697
|
+
const entries = takeQueuedRuns();
|
|
698
|
+
publishQueue();
|
|
699
|
+
await cancelQueueEntries(entries);
|
|
700
|
+
}
|
|
701
|
+
function takeQueuedRuns() {
|
|
702
|
+
return queuedRuns.splice(0, queuedRuns.length);
|
|
703
|
+
}
|
|
704
|
+
async function cancelQueueEntries(entries) {
|
|
705
|
+
const cancelRun = transport.cancelRun?.bind(transport);
|
|
706
|
+
if (!cancelRun)
|
|
707
|
+
return;
|
|
708
|
+
await Promise.all(entries.map(entry => cancelRun(entry.threadId, entry.id, new AbortController().signal)));
|
|
709
|
+
}
|
|
710
|
+
async function drainQueue() {
|
|
711
|
+
if (drainingQueue || queuedRuns.length === 0)
|
|
712
|
+
return;
|
|
713
|
+
drainingQueue = true;
|
|
714
|
+
try {
|
|
715
|
+
while (queuedRuns.length > 0) {
|
|
716
|
+
const entry = queuedRuns.shift();
|
|
717
|
+
publishQueue();
|
|
718
|
+
if (!entry || !transport.joinStream)
|
|
719
|
+
continue;
|
|
720
|
+
await joinQueuedRun(entry);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
finally {
|
|
724
|
+
drainingQueue = false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function joinQueuedRun(entry) {
|
|
728
|
+
abortController = new AbortController();
|
|
729
|
+
const startedAt = Date.now();
|
|
730
|
+
captureRuntimeRequestTelemetry('join_queued');
|
|
731
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
|
|
732
|
+
subjects.custom$.next([]);
|
|
733
|
+
subjects.toolProgress$.next([]);
|
|
734
|
+
toolProgressMap.clear();
|
|
735
|
+
subjects.status$.next(ResourceStatus.Loading);
|
|
736
|
+
try {
|
|
737
|
+
const iter = transport.joinStream
|
|
738
|
+
? transport.joinStream(entry.threadId, entry.id, undefined, abortController.signal)
|
|
739
|
+
: [];
|
|
740
|
+
for await (const event of iter) {
|
|
741
|
+
if (abortController.signal.aborted)
|
|
742
|
+
break;
|
|
743
|
+
processEvent(event);
|
|
744
|
+
}
|
|
745
|
+
if (!abortController.signal.aborted) {
|
|
746
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
747
|
+
// force=true: rehydrate from server-authoritative state so any
|
|
748
|
+
// post-process node mutations (RemoveMessage, id-match content
|
|
749
|
+
// replacement) reflected on the server are picked up client-side.
|
|
750
|
+
await refreshHistory(true);
|
|
751
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
|
|
752
|
+
...telemetryProperties,
|
|
753
|
+
durationMs: Date.now() - startedAt,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
subjects.error$.next(err);
|
|
759
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
760
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
|
|
761
|
+
...telemetryProperties,
|
|
762
|
+
durationMs: Date.now() - startedAt,
|
|
763
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async function runStream(payload, opts, requestType = 'submit') {
|
|
768
|
+
abortController?.abort();
|
|
769
|
+
abortController = new AbortController();
|
|
770
|
+
const startedAt = Date.now();
|
|
771
|
+
captureRuntimeRequestTelemetry(requestType);
|
|
772
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
|
|
773
|
+
subjects.status$.next(ResourceStatus.Loading);
|
|
774
|
+
subjects.error$.next(undefined);
|
|
775
|
+
subjects.custom$.next([]);
|
|
776
|
+
subjects.toolProgress$.next([]);
|
|
777
|
+
toolProgressMap.clear();
|
|
778
|
+
lastPayload = payload;
|
|
779
|
+
lastOptions = opts;
|
|
780
|
+
// Optimistically inject human messages so they appear immediately
|
|
781
|
+
// without waiting for the server to echo them back. Assign a stable id
|
|
782
|
+
// when missing — track-by-id in the chat-message-list relies on stable
|
|
783
|
+
// ids across re-emissions, otherwise the optimistic message gets torn
|
|
784
|
+
// down + recreated on every messages$.next() during streaming, which
|
|
785
|
+
// restarts caret/typing animations and causes visible flicker.
|
|
786
|
+
const inputMessages = payload?.['messages'];
|
|
787
|
+
if (Array.isArray(inputMessages) && inputMessages.length > 0) {
|
|
788
|
+
const stamped = inputMessages.map((m) => {
|
|
789
|
+
const raw = m;
|
|
790
|
+
if (typeof raw['id'] === 'string' && raw['id'])
|
|
791
|
+
return m;
|
|
792
|
+
const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
793
|
+
return { ...m, id };
|
|
794
|
+
});
|
|
795
|
+
const existing = subjects.messages$.value;
|
|
796
|
+
subjects.messages$.next([...existing, ...stamped]);
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
const iter = transport.stream(options.assistantId, currentThreadId, payload, opts?.signal ?? abortController.signal, opts);
|
|
800
|
+
for await (const event of iter) {
|
|
801
|
+
if (abortController.signal.aborted)
|
|
802
|
+
break;
|
|
803
|
+
processEvent(event);
|
|
804
|
+
}
|
|
805
|
+
if (!abortController.signal.aborted) {
|
|
806
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
807
|
+
// force=true: see refreshHistory comment — server state is
|
|
808
|
+
// authoritative after run completion.
|
|
809
|
+
await refreshHistory(true);
|
|
810
|
+
await drainQueue();
|
|
811
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
|
|
812
|
+
...telemetryProperties,
|
|
813
|
+
durationMs: Date.now() - startedAt,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
if (err?.name === 'AbortError') {
|
|
819
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
subjects.error$.next(err);
|
|
823
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
824
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
|
|
825
|
+
...telemetryProperties,
|
|
826
|
+
durationMs: Date.now() - startedAt,
|
|
827
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function processEvent(event) {
|
|
833
|
+
const baseType = getBaseEventType(event.type);
|
|
834
|
+
const namespace = getEventNamespace(event);
|
|
835
|
+
if (isMessagesEvent(event.type)) {
|
|
836
|
+
const msgs = normalizeMessages(event);
|
|
837
|
+
if (!msgs)
|
|
838
|
+
return;
|
|
839
|
+
const normalized = options.toMessage
|
|
840
|
+
? msgs.map(options.toMessage)
|
|
841
|
+
: msgs;
|
|
842
|
+
if (isSubagentNamespace(namespace)) {
|
|
843
|
+
const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
|
|
844
|
+
if (namespaceId) {
|
|
845
|
+
for (const msg of normalized) {
|
|
846
|
+
subagentManager.addMessageToSubagent(namespaceId, msg);
|
|
847
|
+
}
|
|
848
|
+
publishSubagents();
|
|
849
|
+
}
|
|
850
|
+
if (options.filterSubagentMessages) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Partial and message-tuple events are incremental. Merge them by id
|
|
855
|
+
// so optimistic human messages and earlier tool messages are preserved.
|
|
856
|
+
if (event.type === 'messages/partial' || event.messageMetadata) {
|
|
857
|
+
subjects.messages$.next(mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap));
|
|
858
|
+
if (isLgTraceEnabled()) {
|
|
859
|
+
const msgs = subjects.messages$.value;
|
|
860
|
+
const last = msgs[msgs.length - 1];
|
|
861
|
+
lgTrace('bridge.messages-tuple', { id: last?.['id'], count: msgs.length });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
else if (normalized.length === 0) {
|
|
865
|
+
// Defensive: skip empty replacements during streaming. An empty
|
|
866
|
+
// batch shouldn't tear down the entire UI (causes message DOM
|
|
867
|
+
// teardown + streaming renderer reset = visible jank).
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
// Preserve existing ids by content so the final-id swap doesn't
|
|
871
|
+
// tear down the chat-message DOM (and its streaming-md renderer).
|
|
872
|
+
subjects.messages$.next(preserveIds(subjects.messages$.value, normalized));
|
|
873
|
+
}
|
|
874
|
+
storeMessageMetadata(normalized, event);
|
|
875
|
+
syncSubagentsFromMessages(normalized);
|
|
876
|
+
syncToolCallsFromMessages();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
// normalizeSdkEvent spreads event data directly into the event object,
|
|
880
|
+
// so the values/updates payload is at event['data'] (the original data object),
|
|
881
|
+
// NOT at event['values'] or event['updates'].
|
|
882
|
+
switch (baseType) {
|
|
883
|
+
case 'values': {
|
|
884
|
+
const vals = extractEventData(event);
|
|
885
|
+
if (isSubagentNamespace(namespace) && isRecord$1(vals)) {
|
|
886
|
+
updateSubagentValues(namespace, vals);
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
if (vals != null) {
|
|
890
|
+
extractInterrupts(vals, subjects);
|
|
891
|
+
subjects.values$.next(vals);
|
|
892
|
+
// Also sync messages$ from the values state so the full message
|
|
893
|
+
// history (including human messages) is available to consumers.
|
|
894
|
+
const stateMessages = vals['messages'];
|
|
895
|
+
if (Array.isArray(stateMessages) && stateMessages.length > 0) {
|
|
896
|
+
// Defensive: only sync when state carries messages. An empty
|
|
897
|
+
// values payload shouldn't wipe the UI mid-stream.
|
|
898
|
+
const projected = options.toMessage
|
|
899
|
+
? stateMessages.map(options.toMessage)
|
|
900
|
+
: stateMessages;
|
|
901
|
+
// Drop empty-content AI placeholders before merging. LangGraph
|
|
902
|
+
// emits intermediate `values` events whose `state.messages`
|
|
903
|
+
// includes an unfilled assistant turn at the tail. Keeping it
|
|
904
|
+
// would create a phantom slot that competes with the chunk-
|
|
905
|
+
// streamed AIMessageChunk arriving via messages-tuple — they'd
|
|
906
|
+
// never merge (different ids; non-overlapping content fragments)
|
|
907
|
+
// and the user sees two assistant bubbles.
|
|
908
|
+
const filtered = projected.filter((m, i) => {
|
|
909
|
+
if (i !== projected.length - 1)
|
|
910
|
+
return true;
|
|
911
|
+
const t = normalizeMessageType(typeof m._getType === 'function' ? m._getType() : m['type']);
|
|
912
|
+
if (t !== 'ai')
|
|
913
|
+
return true;
|
|
914
|
+
const text = extractText(m.content);
|
|
915
|
+
return text.length > 0;
|
|
916
|
+
});
|
|
917
|
+
// Preserve existing ids by content match (server echo / final-id swap).
|
|
918
|
+
const remapped = preserveIds(subjects.messages$.value, filtered);
|
|
919
|
+
// ALWAYS merge values-derived messages into existing rather
|
|
920
|
+
// than replacing. LangGraph emits intermediate values events
|
|
921
|
+
// during streaming where state.messages can lag behind what
|
|
922
|
+
// we've already seen via messages-tuple — replacing would
|
|
923
|
+
// drop the partial AI (or even the optimistic human) and
|
|
924
|
+
// tear down their DOM mid-stream. Merge by id keeps both,
|
|
925
|
+
// updates content where ids match, preserves the rest.
|
|
926
|
+
subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped, reasoningTimingMap));
|
|
927
|
+
if (isLgTraceEnabled()) {
|
|
928
|
+
lgTrace('bridge.values-sync', {
|
|
929
|
+
incomingLength: stateMessages.length,
|
|
930
|
+
mergedLength: subjects.messages$.value.length,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
syncSubagentsFromMessages(stateMessages);
|
|
934
|
+
subagentManager.reconstructFromMessages(stateMessages, { skipIfPopulated: true });
|
|
935
|
+
publishSubagents();
|
|
936
|
+
syncToolCallsFromMessages();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
case 'updates': {
|
|
942
|
+
const upd = extractEventData(event);
|
|
943
|
+
if (isSubagentNamespace(namespace)) {
|
|
944
|
+
markSubagentRunning(namespace);
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
if (upd != null) {
|
|
948
|
+
extractInterrupts(upd, subjects);
|
|
949
|
+
subjects.values$.next({
|
|
950
|
+
...subjects.values$.value,
|
|
951
|
+
...upd,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
case 'error':
|
|
957
|
+
subjects.error$.next(event['error']);
|
|
958
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
959
|
+
break;
|
|
960
|
+
case 'interrupt':
|
|
961
|
+
subjects.interrupt$.next(event['interrupt']);
|
|
962
|
+
break;
|
|
963
|
+
case 'interrupts':
|
|
964
|
+
subjects.interrupts$.next(event['interrupts']);
|
|
965
|
+
break;
|
|
966
|
+
case 'custom': {
|
|
967
|
+
const eventData = event['data'];
|
|
968
|
+
const name = (event['name'] ?? eventData?.['name'] ?? '');
|
|
969
|
+
const data = eventData?.['data'] ?? eventData;
|
|
970
|
+
const current = subjects.custom$.value;
|
|
971
|
+
subjects.custom$.next([...current, { name, data }]);
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
case 'tools':
|
|
975
|
+
updateToolProgress(event);
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function syncToolCallsFromMessages() {
|
|
980
|
+
const toolCalls = getToolCallsWithResults(subjects.messages$.value);
|
|
981
|
+
subjects.toolCalls$.next(toolCalls);
|
|
982
|
+
}
|
|
983
|
+
function syncSubagentsFromMessages(messages) {
|
|
984
|
+
for (const message of messages) {
|
|
985
|
+
const raw = message;
|
|
986
|
+
if (isAiMessageWithToolCalls(raw)) {
|
|
987
|
+
subagentManager.registerFromToolCalls(raw['tool_calls'], typeof raw['id'] === 'string' ? raw['id'] : null);
|
|
988
|
+
}
|
|
989
|
+
if (isToolMessage(raw)) {
|
|
990
|
+
const content = typeof raw['content'] === 'string'
|
|
991
|
+
? raw['content']
|
|
992
|
+
: JSON.stringify(raw['content']);
|
|
993
|
+
const status = raw['status'] === 'error' ? 'error' : 'success';
|
|
994
|
+
subagentManager.processToolMessage(raw['tool_call_id'], content, status);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
publishSubagents();
|
|
998
|
+
}
|
|
999
|
+
function updateSubagentValues(namespace, values) {
|
|
1000
|
+
const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
|
|
1001
|
+
if (!namespaceId)
|
|
1002
|
+
return;
|
|
1003
|
+
const messages = values['messages'];
|
|
1004
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
1005
|
+
const first = messages[0];
|
|
1006
|
+
if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
|
|
1007
|
+
subagentManager.matchSubgraphToSubagent(namespaceId, first['content']);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
subagentManager.updateSubagentValues(namespaceId, values);
|
|
1011
|
+
publishSubagents();
|
|
1012
|
+
}
|
|
1013
|
+
function markSubagentRunning(namespace) {
|
|
1014
|
+
const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
|
|
1015
|
+
if (!namespaceId)
|
|
1016
|
+
return;
|
|
1017
|
+
subagentManager.markRunningFromNamespace(namespaceId, namespace);
|
|
1018
|
+
publishSubagents();
|
|
1019
|
+
}
|
|
1020
|
+
function publishSubagents() {
|
|
1021
|
+
subjects.subagents$.next(toSubagentRefs(subagentManager.getSubagents()));
|
|
1022
|
+
}
|
|
1023
|
+
function storeMessageMetadata(messages, event) {
|
|
1024
|
+
if (!event.messageMetadata)
|
|
1025
|
+
return;
|
|
1026
|
+
const next = new Map(subjects.messageMetadata$.value);
|
|
1027
|
+
messages.forEach((message, index) => {
|
|
1028
|
+
const id = message['id'];
|
|
1029
|
+
const messageId = String(id ?? index);
|
|
1030
|
+
next.set(messageId, {
|
|
1031
|
+
messageId,
|
|
1032
|
+
firstSeenState: undefined,
|
|
1033
|
+
branch: undefined,
|
|
1034
|
+
branchOptions: undefined,
|
|
1035
|
+
streamMetadata: event.messageMetadata,
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
subjects.messageMetadata$.next(next);
|
|
1039
|
+
}
|
|
1040
|
+
function updateToolProgress(event) {
|
|
1041
|
+
const data = extractEventData(event);
|
|
1042
|
+
if (!isRecord$1(data))
|
|
1043
|
+
return;
|
|
1044
|
+
const toolEvent = data['event'];
|
|
1045
|
+
const name = data['name'];
|
|
1046
|
+
if (typeof toolEvent !== 'string' || typeof name !== 'string')
|
|
1047
|
+
return;
|
|
1048
|
+
const toolCallId = typeof data['toolCallId'] === 'string' ? data['toolCallId'] : undefined;
|
|
1049
|
+
const key = toolCallId ?? name;
|
|
1050
|
+
const existing = toolProgressMap.get(key);
|
|
1051
|
+
switch (toolEvent) {
|
|
1052
|
+
case 'on_tool_start':
|
|
1053
|
+
toolProgressMap.set(key, {
|
|
1054
|
+
toolCallId,
|
|
1055
|
+
name,
|
|
1056
|
+
state: 'starting',
|
|
1057
|
+
input: data['input'],
|
|
1058
|
+
});
|
|
1059
|
+
break;
|
|
1060
|
+
case 'on_tool_event':
|
|
1061
|
+
toolProgressMap.set(key, {
|
|
1062
|
+
toolCallId,
|
|
1063
|
+
name,
|
|
1064
|
+
...existing,
|
|
1065
|
+
state: 'running',
|
|
1066
|
+
data: data['data'],
|
|
1067
|
+
});
|
|
1068
|
+
break;
|
|
1069
|
+
case 'on_tool_end':
|
|
1070
|
+
toolProgressMap.set(key, {
|
|
1071
|
+
toolCallId,
|
|
1072
|
+
name,
|
|
1073
|
+
...existing,
|
|
1074
|
+
state: 'completed',
|
|
1075
|
+
result: data['output'],
|
|
1076
|
+
});
|
|
1077
|
+
break;
|
|
1078
|
+
case 'on_tool_error':
|
|
1079
|
+
toolProgressMap.set(key, {
|
|
1080
|
+
toolCallId,
|
|
1081
|
+
name,
|
|
1082
|
+
...existing,
|
|
1083
|
+
state: 'error',
|
|
1084
|
+
error: data['error'],
|
|
1085
|
+
});
|
|
1086
|
+
break;
|
|
1087
|
+
default:
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
subjects.toolProgress$.next([...toolProgressMap.values()]);
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
submit: async (payload, opts) => {
|
|
1094
|
+
if (opts?.multitaskStrategy === 'enqueue' && subjects.status$.value === ResourceStatus.Loading) {
|
|
1095
|
+
await enqueueRun(payload, opts);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
await runStream(payload, opts);
|
|
1099
|
+
},
|
|
1100
|
+
stop: async () => {
|
|
1101
|
+
abortController?.abort();
|
|
1102
|
+
await clearQueue();
|
|
1103
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1104
|
+
},
|
|
1105
|
+
switchThread: (id) => {
|
|
1106
|
+
setThreadId(id, true);
|
|
1107
|
+
},
|
|
1108
|
+
joinStream: async (runId, lastEventId) => {
|
|
1109
|
+
if (!currentThreadId)
|
|
1110
|
+
return;
|
|
1111
|
+
abortController?.abort();
|
|
1112
|
+
abortController = new AbortController();
|
|
1113
|
+
const startedAt = Date.now();
|
|
1114
|
+
captureRuntimeRequestTelemetry('join');
|
|
1115
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
|
|
1116
|
+
subjects.custom$.next([]);
|
|
1117
|
+
subjects.toolProgress$.next([]);
|
|
1118
|
+
toolProgressMap.clear();
|
|
1119
|
+
subjects.status$.next(ResourceStatus.Loading);
|
|
1120
|
+
try {
|
|
1121
|
+
const iter = transport.joinStream
|
|
1122
|
+
? transport.joinStream(currentThreadId, runId, lastEventId, abortController.signal)
|
|
1123
|
+
: [];
|
|
1124
|
+
for await (const event of iter) {
|
|
1125
|
+
processEvent(event);
|
|
1126
|
+
}
|
|
1127
|
+
subjects.status$.next(ResourceStatus.Resolved);
|
|
1128
|
+
await refreshHistory();
|
|
1129
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
|
|
1130
|
+
...telemetryProperties,
|
|
1131
|
+
durationMs: Date.now() - startedAt,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
catch (err) {
|
|
1135
|
+
subjects.error$.next(err);
|
|
1136
|
+
subjects.status$.next(ResourceStatus.Error);
|
|
1137
|
+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
|
|
1138
|
+
...telemetryProperties,
|
|
1139
|
+
durationMs: Date.now() - startedAt,
|
|
1140
|
+
errorClass: agentRuntimeTelemetryErrorClass(err),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
resubmitLast: async () => {
|
|
1145
|
+
if (lastPayload !== null) {
|
|
1146
|
+
await runStream(lastPayload, lastOptions, 'resubmit');
|
|
1147
|
+
}
|
|
1148
|
+
},
|
|
1149
|
+
getReasoningDurationMs: (id) => {
|
|
1150
|
+
const entry = reasoningTimingMap.get(id);
|
|
1151
|
+
if (!entry)
|
|
1152
|
+
return undefined;
|
|
1153
|
+
if (entry.endedAt === undefined)
|
|
1154
|
+
return undefined;
|
|
1155
|
+
return entry.endedAt - entry.startedAt;
|
|
1156
|
+
},
|
|
1157
|
+
updateState: async (values, opts) => {
|
|
1158
|
+
// No-op when there is no thread yet or the transport doesn't support
|
|
1159
|
+
// updateState (e.g. MockAgentTransport in unit tests without a threadId).
|
|
1160
|
+
if (!currentThreadId || !transport.updateState) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
await transport.updateState(currentThreadId, values, new AbortController().signal, opts);
|
|
1164
|
+
},
|
|
1165
|
+
get currentThreadId() {
|
|
1166
|
+
return currentThreadId;
|
|
1167
|
+
},
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Extracts the payload data from a normalized SDK event.
|
|
1172
|
+
*
|
|
1173
|
+
* Handles two formats:
|
|
1174
|
+
* 1. SDK events (via normalizeSdkEvent): data at event['data'] (record) + spread into event
|
|
1175
|
+
* 2. Mock/test events: data at event[event.type] (e.g., event['values'], event['updates'])
|
|
1176
|
+
*/
|
|
1177
|
+
/**
|
|
1178
|
+
* LangGraph emits interrupts as part of `updates`/`values` events under the
|
|
1179
|
+
* special `__interrupt__` key, not as standalone events. When such a payload
|
|
1180
|
+
* appears, mirror it onto `interrupt$` (latest) and `interrupts$` (full list)
|
|
1181
|
+
* so consumers can react via `agent.interrupt()` / `agent.interrupts()`.
|
|
1182
|
+
*/
|
|
1183
|
+
function extractInterrupts(payload, subjects) {
|
|
1184
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1185
|
+
return;
|
|
1186
|
+
const raw = payload['__interrupt__'];
|
|
1187
|
+
// Cast through unknown — Interrupt$ is parameterized over Bag's InterruptType,
|
|
1188
|
+
// and the SDK delivers raw Interrupt payloads here.
|
|
1189
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
1190
|
+
const list = raw;
|
|
1191
|
+
subjects.interrupts$.next(list);
|
|
1192
|
+
subjects.interrupt$.next(list[list.length - 1]);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
// Payload has no `__interrupt__` key. Clear any stale interrupt so the UI
|
|
1196
|
+
// dismisses the panel after a resume completes (LangGraph does not emit a
|
|
1197
|
+
// separate "cleared" event — the absence of `__interrupt__` in subsequent
|
|
1198
|
+
// values/updates is the signal). No-op if interrupt$ was already empty.
|
|
1199
|
+
if (subjects.interrupt$.value !== undefined) {
|
|
1200
|
+
subjects.interrupt$.next(undefined);
|
|
1201
|
+
subjects.interrupts$.next([]);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Projects pending interrupts from the latest history checkpoint onto the
|
|
1206
|
+
* interrupt$ / interrupts$ subjects. ThreadState exposes interrupts under
|
|
1207
|
+
* `tasks[i].interrupts` (per the LangGraph SDK schema). When the latest
|
|
1208
|
+
* checkpoint contains any pending interrupts, mirror them so consumers can
|
|
1209
|
+
* react via `agent.interrupt()` on thread reload without needing a fresh
|
|
1210
|
+
* stream event.
|
|
1211
|
+
*
|
|
1212
|
+
* If no interrupts are present, this is a no-op so existing streamed state
|
|
1213
|
+
* (mid-run) isn't clobbered by a stale history refresh.
|
|
1214
|
+
*/
|
|
1215
|
+
function hydrateInterruptsFromHistory(history, subjects) {
|
|
1216
|
+
const latest = history[0];
|
|
1217
|
+
if (!latest || !Array.isArray(latest.tasks))
|
|
1218
|
+
return;
|
|
1219
|
+
const collected = [];
|
|
1220
|
+
for (const task of latest.tasks) {
|
|
1221
|
+
if (task && Array.isArray(task.interrupts) && task.interrupts.length > 0) {
|
|
1222
|
+
for (const ix of task.interrupts) {
|
|
1223
|
+
collected.push(ix);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
if (collected.length > 0) {
|
|
1228
|
+
subjects.interrupts$.next(collected);
|
|
1229
|
+
subjects.interrupt$.next(collected[collected.length - 1]);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function extractEventData(event) {
|
|
1233
|
+
// Try event['data'] first (SDK format from normalizeSdkEvent)
|
|
1234
|
+
const d = event['data'];
|
|
1235
|
+
if (d != null && typeof d === 'object' && !Array.isArray(d)) {
|
|
1236
|
+
return d;
|
|
1237
|
+
}
|
|
1238
|
+
// Try event[event.type] (mock/test format: { type: 'values', values: {...} })
|
|
1239
|
+
const named = event[event.type];
|
|
1240
|
+
if (named != null && typeof named === 'object' && !Array.isArray(named)) {
|
|
1241
|
+
return named;
|
|
1242
|
+
}
|
|
1243
|
+
// Fallback: reconstruct from remaining keys
|
|
1244
|
+
const rest = Object.fromEntries(Object.entries(event).filter(([key]) => key !== 'type' && key !== 'data'));
|
|
1245
|
+
return Object.keys(rest).length > 0 ? rest : d;
|
|
1246
|
+
}
|
|
1247
|
+
function isMessagesEvent(type) {
|
|
1248
|
+
const baseType = getBaseEventType(type);
|
|
1249
|
+
return baseType === 'messages' || baseType.startsWith('messages/');
|
|
1250
|
+
}
|
|
1251
|
+
function getBaseEventType(type) {
|
|
1252
|
+
return String(type).split('|')[0];
|
|
1253
|
+
}
|
|
1254
|
+
function getEventNamespace(event) {
|
|
1255
|
+
if (Array.isArray(event.namespace))
|
|
1256
|
+
return event.namespace;
|
|
1257
|
+
const parts = String(event.type).split('|');
|
|
1258
|
+
return parts.length > 1 ? parts.slice(1) : undefined;
|
|
1259
|
+
}
|
|
1260
|
+
function normalizeMessages(event) {
|
|
1261
|
+
const directMessages = event['messages'];
|
|
1262
|
+
if (Array.isArray(directMessages)) {
|
|
1263
|
+
// Filter out non-message metadata objects (e.g. { langgraph_node, langgraph_triggers })
|
|
1264
|
+
// that the LangGraph SDK includes alongside real messages in messages/* events.
|
|
1265
|
+
const filtered = directMessages.filter(isMessageLike);
|
|
1266
|
+
return filtered.length > 0 ? filtered : null;
|
|
1267
|
+
}
|
|
1268
|
+
const data = event['data'];
|
|
1269
|
+
if (Array.isArray(data)) {
|
|
1270
|
+
if (data.every(isMessageLike)) {
|
|
1271
|
+
return data;
|
|
1272
|
+
}
|
|
1273
|
+
if (isMessageLike(data[0])) {
|
|
1274
|
+
return [data[0]];
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
const indexedValues = Object.keys(event)
|
|
1278
|
+
.filter(key => /^\d+$/.test(key))
|
|
1279
|
+
.sort((left, right) => Number(left) - Number(right))
|
|
1280
|
+
.map(key => event[key]);
|
|
1281
|
+
if (indexedValues.every(isMessageLike)) {
|
|
1282
|
+
return indexedValues;
|
|
1283
|
+
}
|
|
1284
|
+
if (isMessageLike(indexedValues[0])) {
|
|
1285
|
+
return [indexedValues[0]];
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Collapse adjacent AI messages where one's text is a prefix of the other.
|
|
1291
|
+
*
|
|
1292
|
+
* When complex-content streaming is in play, the same conceptual assistant
|
|
1293
|
+
* message can land in two slots: the canonical AI from values-sync (id
|
|
1294
|
+
* `resp_…` or run id) and the chunk-streamed AIMessageChunk from
|
|
1295
|
+
* messages-tuple (id `lc_run--…`). Both slots fill in parallel; once both
|
|
1296
|
+
* carry the full text we collapse them, keeping the older slot's id so
|
|
1297
|
+
* track-by-id stays stable in the chat list.
|
|
1298
|
+
*/
|
|
1299
|
+
function collapseAdjacentAi(messages) {
|
|
1300
|
+
if (messages.length < 2)
|
|
1301
|
+
return messages;
|
|
1302
|
+
const out = [];
|
|
1303
|
+
for (const msg of messages) {
|
|
1304
|
+
const last = out[out.length - 1];
|
|
1305
|
+
if (!last) {
|
|
1306
|
+
out.push(msg);
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const lastType = normalizeMessageType(typeof last._getType === 'function' ? last._getType() : last['type']);
|
|
1310
|
+
const msgType = normalizeMessageType(typeof msg._getType === 'function' ? msg._getType() : msg['type']);
|
|
1311
|
+
if (lastType === 'ai' && msgType === 'ai') {
|
|
1312
|
+
const lastText = extractText(last.content);
|
|
1313
|
+
const msgText = extractText(msg.content);
|
|
1314
|
+
if (lastText.length === 0
|
|
1315
|
+
|| msgText.length === 0
|
|
1316
|
+
|| lastText === msgText
|
|
1317
|
+
|| lastText.startsWith(msgText)
|
|
1318
|
+
|| msgText.startsWith(lastText)) {
|
|
1319
|
+
// Keep the longer content; preserve last (older) id and metadata.
|
|
1320
|
+
const longerText = msgText.length >= lastText.length ? msgText : lastText;
|
|
1321
|
+
out[out.length - 1] = { ...last, content: longerText };
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
out.push(msg);
|
|
1326
|
+
}
|
|
1327
|
+
return out;
|
|
1328
|
+
}
|
|
1329
|
+
function mergeMessages(existing, incoming, reasoningTimingMap) {
|
|
1330
|
+
const merged = [...existing];
|
|
1331
|
+
for (const msg of incoming) {
|
|
1332
|
+
const rawIn = msg;
|
|
1333
|
+
const id = rawIn['id'];
|
|
1334
|
+
let idx = id ? merged.findIndex(m => m['id'] === id) : -1;
|
|
1335
|
+
// Fallback: match by (role, content) when ids differ. This is the path
|
|
1336
|
+
// that fires when the server echoes back our optimistic human message
|
|
1337
|
+
// with a server-assigned id, or when partial AI tokens carry a chunk
|
|
1338
|
+
// id but the final canonical message has a run id. Preserving the
|
|
1339
|
+
// existing id here keeps track-by-id stable in the chat list and
|
|
1340
|
+
// prevents DOM teardown + animation restarts mid-stream.
|
|
1341
|
+
if (idx < 0) {
|
|
1342
|
+
idx = findContentMatch(merged, msg);
|
|
1343
|
+
}
|
|
1344
|
+
// When an AIMessageChunk arrives without an id-match or content-prefix
|
|
1345
|
+
// match, treat the trailing AI message as its accumulator. The
|
|
1346
|
+
// OpenAI Responses API emits per-chunk events whose ids identify the
|
|
1347
|
+
// *event*, not the message, so consecutive chunks land here. Without
|
|
1348
|
+
// this we'd append every chunk as a separate bubble.
|
|
1349
|
+
if (idx < 0) {
|
|
1350
|
+
const inType = normalizeMessageType(rawIn['type']);
|
|
1351
|
+
if (inType === 'ai') {
|
|
1352
|
+
for (let i = merged.length - 1; i >= 0; i--) {
|
|
1353
|
+
const t = normalizeMessageType(typeof merged[i]._getType === 'function'
|
|
1354
|
+
? merged[i]._getType()
|
|
1355
|
+
: merged[i]['type']);
|
|
1356
|
+
if (t === 'ai') {
|
|
1357
|
+
idx = i;
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
if (t === 'human' || t === 'tool' || t === 'system')
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (idx >= 0) {
|
|
1366
|
+
const existing = merged[idx];
|
|
1367
|
+
const existingId = existing['id'];
|
|
1368
|
+
const incomingRaw = msg;
|
|
1369
|
+
// Keep the *existing* id so downstream track-by-id sees stable identity.
|
|
1370
|
+
// For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the
|
|
1371
|
+
// SDK emits per-chunk *delta* arrays — not accumulated arrays — so a
|
|
1372
|
+
// straight replacement collapses the rendered bubble to just the
|
|
1373
|
+
// latest token. Accumulate text-bearing content across chunks here
|
|
1374
|
+
// and hand a string to consumers; downstream code already handles
|
|
1375
|
+
// string content uniformly.
|
|
1376
|
+
const accumulatedContent = accumulateContent(existing.content, incomingRaw['content']);
|
|
1377
|
+
// Only accumulate reasoning when the incoming message explicitly carries
|
|
1378
|
+
// a `reasoning` field or complex-content array blocks with
|
|
1379
|
+
// type='reasoning'/'thinking'. Never use a plain string content value
|
|
1380
|
+
// as reasoning source — that would wrongly treat every assistant
|
|
1381
|
+
// message text as reasoning content.
|
|
1382
|
+
const incomingReasoningSource = 'reasoning' in incomingRaw
|
|
1383
|
+
? incomingRaw['reasoning']
|
|
1384
|
+
: (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined);
|
|
1385
|
+
const accumulatedReasoning = accumulateReasoning(existing['reasoning'], incomingReasoningSource);
|
|
1386
|
+
const idForTiming = existingId ?? incomingRaw['id'];
|
|
1387
|
+
if (idForTiming && reasoningTimingMap) {
|
|
1388
|
+
const hasReasoning = accumulatedReasoning.length > 0;
|
|
1389
|
+
const hasText = (typeof accumulatedContent === 'string' ? accumulatedContent : '').length > 0;
|
|
1390
|
+
if (hasReasoning) {
|
|
1391
|
+
const entry = reasoningTimingMap.get(idForTiming) ?? { startedAt: Date.now() };
|
|
1392
|
+
if (hasText && entry.endedAt === undefined)
|
|
1393
|
+
entry.endedAt = Date.now();
|
|
1394
|
+
reasoningTimingMap.set(idForTiming, entry);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const next = { ...msg, content: accumulatedContent };
|
|
1398
|
+
next['reasoning'] = accumulatedReasoning;
|
|
1399
|
+
if (existingId) {
|
|
1400
|
+
next['id'] = existingId;
|
|
1401
|
+
}
|
|
1402
|
+
merged[idx] = next;
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
const incomingRaw = msg;
|
|
1406
|
+
const initialReasoningSource = 'reasoning' in incomingRaw
|
|
1407
|
+
? incomingRaw['reasoning']
|
|
1408
|
+
: (Array.isArray(incomingRaw['content']) ? incomingRaw['content'] : undefined);
|
|
1409
|
+
const initialReasoning = accumulateReasoning(undefined, initialReasoningSource);
|
|
1410
|
+
if (initialReasoning.length > 0 && reasoningTimingMap) {
|
|
1411
|
+
const msgId = incomingRaw['id'];
|
|
1412
|
+
if (msgId && !reasoningTimingMap.has(msgId)) {
|
|
1413
|
+
reasoningTimingMap.set(msgId, { startedAt: Date.now() });
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const next = { ...msg };
|
|
1417
|
+
next['reasoning'] = initialReasoning;
|
|
1418
|
+
merged.push(next);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return collapseAdjacentAi(merged);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Merge an incoming chunk's content into prior accumulated content for the
|
|
1425
|
+
* same message id.
|
|
1426
|
+
*
|
|
1427
|
+
* - string + string → concat (delta append)
|
|
1428
|
+
* - array + array → concat extracted text from existing + incoming blocks
|
|
1429
|
+
* - array + string → use the string (server final-id swap)
|
|
1430
|
+
* - empty existing → use incoming as-is
|
|
1431
|
+
*
|
|
1432
|
+
* We deliberately collapse complex content arrays to a string at this layer.
|
|
1433
|
+
* The langgraph-sdk client does not accumulate complex-content arrays the
|
|
1434
|
+
* way it accumulates strings, and per-chunk arrays carry only the latest
|
|
1435
|
+
* delta. Concatenating extracted text gives consumers the same uniform
|
|
1436
|
+
* string they get for non-reasoning models.
|
|
1437
|
+
*/
|
|
1438
|
+
/**
|
|
1439
|
+
* Heuristic: does this content look like a "final canonical" array
|
|
1440
|
+
* carrying both reasoning and visible text blocks? OpenAI's Responses
|
|
1441
|
+
* API ships the final assistant message in this shape after the
|
|
1442
|
+
* streaming token chunks complete. Detection is narrow (requires BOTH
|
|
1443
|
+
* a reasoning-shape block AND a text-shape block in the same array)
|
|
1444
|
+
* so it doesn't trip on routine streaming chunks.
|
|
1445
|
+
*/
|
|
1446
|
+
function isFinalCanonicalReasoningContent(content) {
|
|
1447
|
+
if (!Array.isArray(content))
|
|
1448
|
+
return false;
|
|
1449
|
+
let hasReasoning = false;
|
|
1450
|
+
let hasText = false;
|
|
1451
|
+
for (const block of content) {
|
|
1452
|
+
if (block == null || typeof block !== 'object')
|
|
1453
|
+
continue;
|
|
1454
|
+
const t = block['type'];
|
|
1455
|
+
if (t === 'reasoning' || t === 'thinking')
|
|
1456
|
+
hasReasoning = true;
|
|
1457
|
+
else if (t === 'text' || t === 'output_text')
|
|
1458
|
+
hasText = true;
|
|
1459
|
+
}
|
|
1460
|
+
return hasReasoning && hasText;
|
|
1461
|
+
}
|
|
1462
|
+
function accumulateContent(existing, incoming) {
|
|
1463
|
+
const existingText = extractText(existing);
|
|
1464
|
+
const incomingText = extractText(incoming);
|
|
1465
|
+
// Always return a string. We never want array content escaping the bridge:
|
|
1466
|
+
// (a) downstream consumers expect string content, and (b) findContentMatch
|
|
1467
|
+
// stringifies arrays, which would prevent the canonical-message id-swap
|
|
1468
|
+
// dedupe from matching the streamed-chunk message after a partial chunk.
|
|
1469
|
+
if (existingText.length === 0)
|
|
1470
|
+
return incomingText;
|
|
1471
|
+
if (incomingText.length === 0)
|
|
1472
|
+
return existingText;
|
|
1473
|
+
// Incoming is a strict-superset of accumulated (final-id swap with full content).
|
|
1474
|
+
if (incomingText.startsWith(existingText))
|
|
1475
|
+
return incomingText;
|
|
1476
|
+
// Existing already a strict-superset — chunk arrived after the canonical
|
|
1477
|
+
// message merged in via values-sync. Keep what we have.
|
|
1478
|
+
if (existingText.startsWith(incomingText))
|
|
1479
|
+
return existingText;
|
|
1480
|
+
// Final-canonical detection: when incoming is the "reasoning + text"
|
|
1481
|
+
// array shape that ships the authoritative final message after a
|
|
1482
|
+
// streaming run, replace the partial streamed accumulator with the
|
|
1483
|
+
// canonical text instead of appending. Without this branch a small
|
|
1484
|
+
// formatting difference between the streamed accumulator and the
|
|
1485
|
+
// canonical text breaks the prefix checks above and visible content
|
|
1486
|
+
// is duplicated (`existingText + incomingText`).
|
|
1487
|
+
if (isFinalCanonicalReasoningContent(incoming))
|
|
1488
|
+
return incomingText;
|
|
1489
|
+
// Otherwise treat incoming as a delta and append.
|
|
1490
|
+
return existingText + incomingText;
|
|
1491
|
+
}
|
|
1492
|
+
function extractText(content) {
|
|
1493
|
+
if (typeof content === 'string')
|
|
1494
|
+
return content;
|
|
1495
|
+
if (!Array.isArray(content))
|
|
1496
|
+
return '';
|
|
1497
|
+
let out = '';
|
|
1498
|
+
for (const block of content) {
|
|
1499
|
+
if (typeof block === 'string') {
|
|
1500
|
+
out += block;
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
if (block == null || typeof block !== 'object')
|
|
1504
|
+
continue;
|
|
1505
|
+
const rec = block;
|
|
1506
|
+
const t = rec['type'];
|
|
1507
|
+
if (t === 'text' || t === 'output_text' || t === undefined) {
|
|
1508
|
+
const text = rec['text'];
|
|
1509
|
+
if (typeof text === 'string')
|
|
1510
|
+
out += text;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return out;
|
|
1514
|
+
}
|
|
1515
|
+
function extractReasoning(content) {
|
|
1516
|
+
if (typeof content === 'string')
|
|
1517
|
+
return '';
|
|
1518
|
+
if (!Array.isArray(content))
|
|
1519
|
+
return '';
|
|
1520
|
+
let out = '';
|
|
1521
|
+
for (const block of content) {
|
|
1522
|
+
if (block == null || typeof block !== 'object')
|
|
1523
|
+
continue;
|
|
1524
|
+
const rec = block;
|
|
1525
|
+
const t = rec['type'];
|
|
1526
|
+
if (t === 'reasoning' || t === 'thinking') {
|
|
1527
|
+
// Direct text field — Anthropic-style "thinking" blocks and
|
|
1528
|
+
// some LangChain-shaped reasoning blocks land here.
|
|
1529
|
+
const text = rec['text'];
|
|
1530
|
+
if (typeof text === 'string')
|
|
1531
|
+
out += text;
|
|
1532
|
+
// OpenAI Responses API: when `reasoning.summary='auto'` was
|
|
1533
|
+
// requested, reasoning blocks carry a `summary` array of
|
|
1534
|
+
// `{type: 'summary_text', text: '...'}` items. Concatenate
|
|
1535
|
+
// their texts in order.
|
|
1536
|
+
const summary = rec['summary'];
|
|
1537
|
+
if (Array.isArray(summary)) {
|
|
1538
|
+
for (const item of summary) {
|
|
1539
|
+
if (item == null || typeof item !== 'object')
|
|
1540
|
+
continue;
|
|
1541
|
+
const itemText = item['text'];
|
|
1542
|
+
if (typeof itemText === 'string')
|
|
1543
|
+
out += itemText;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return out;
|
|
1549
|
+
}
|
|
1550
|
+
function accumulateReasoning(existing, incoming) {
|
|
1551
|
+
const existingText = typeof existing === 'string' ? existing : extractReasoning(existing);
|
|
1552
|
+
const incomingText = typeof incoming === 'string' ? incoming : extractReasoning(incoming);
|
|
1553
|
+
if (existingText.length === 0)
|
|
1554
|
+
return incomingText;
|
|
1555
|
+
if (incomingText.length === 0)
|
|
1556
|
+
return existingText;
|
|
1557
|
+
if (incomingText.startsWith(existingText))
|
|
1558
|
+
return incomingText;
|
|
1559
|
+
if (existingText.startsWith(incomingText))
|
|
1560
|
+
return existingText;
|
|
1561
|
+
return existingText + incomingText;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Replace the incoming messages' ids with the existing array's ids whenever
|
|
1565
|
+
* (role, content) matches positionally and the existing id differs. Keeps
|
|
1566
|
+
* track-by-id stable across server echoes and final-id swaps.
|
|
1567
|
+
*/
|
|
1568
|
+
function preserveIds(existing, incoming) {
|
|
1569
|
+
if (existing.length === 0)
|
|
1570
|
+
return collapseAdjacentAi(incoming);
|
|
1571
|
+
const usedExisting = new Set();
|
|
1572
|
+
const remapped = incoming.map((msg, i) => {
|
|
1573
|
+
const inRaw = msg;
|
|
1574
|
+
const inId = inRaw['id'];
|
|
1575
|
+
// First try same-position match (the dominant case).
|
|
1576
|
+
let matchIdx = -1;
|
|
1577
|
+
if (i < existing.length && !usedExisting.has(i) && sameRoleAndContent(existing[i], msg)) {
|
|
1578
|
+
matchIdx = i;
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
// Fallback: any unused existing message with matching role+content.
|
|
1582
|
+
matchIdx = existing.findIndex((m, j) => !usedExisting.has(j) && sameRoleAndContent(m, msg));
|
|
1583
|
+
}
|
|
1584
|
+
if (matchIdx < 0)
|
|
1585
|
+
return msg;
|
|
1586
|
+
usedExisting.add(matchIdx);
|
|
1587
|
+
const existingId = existing[matchIdx]['id'];
|
|
1588
|
+
if (!existingId || existingId === inId)
|
|
1589
|
+
return msg;
|
|
1590
|
+
return { ...msg, id: existingId };
|
|
1591
|
+
});
|
|
1592
|
+
return collapseAdjacentAi(remapped);
|
|
1593
|
+
}
|
|
1594
|
+
function sameRoleAndContent(a, b) {
|
|
1595
|
+
const aType = normalizeMessageType(typeof a._getType === 'function' ? a._getType() : a['type']);
|
|
1596
|
+
const bType = normalizeMessageType(typeof b._getType === 'function' ? b._getType() : b['type']);
|
|
1597
|
+
if (aType !== bType)
|
|
1598
|
+
return false;
|
|
1599
|
+
const aContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content);
|
|
1600
|
+
const bContent = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
1601
|
+
if (aContent === bContent)
|
|
1602
|
+
return true;
|
|
1603
|
+
// For AI messages we accept prefix relationships (streaming → final).
|
|
1604
|
+
if (aType === 'ai' && typeof aContent === 'string' && typeof bContent === 'string') {
|
|
1605
|
+
return aContent.length > 0 && (bContent.startsWith(aContent) || aContent.startsWith(bContent));
|
|
1606
|
+
}
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
function findContentMatch(merged, incoming) {
|
|
1610
|
+
const inRaw = incoming;
|
|
1611
|
+
const inType = normalizeMessageType(typeof incoming._getType === 'function' ? incoming._getType() : inRaw['type']);
|
|
1612
|
+
const inContent = typeof incoming.content === 'string' ? incoming.content : JSON.stringify(incoming.content);
|
|
1613
|
+
// Only worth matching for human messages (where the optimistic→echo
|
|
1614
|
+
// mismatch happens) and for AI messages where content is a strict prefix
|
|
1615
|
+
// of the existing (token-streaming + final-id swap pattern).
|
|
1616
|
+
for (let i = merged.length - 1; i >= 0; i--) {
|
|
1617
|
+
const m = merged[i];
|
|
1618
|
+
const mType = normalizeMessageType(typeof merged[i]._getType === 'function'
|
|
1619
|
+
? merged[i]._getType()
|
|
1620
|
+
: m['type']);
|
|
1621
|
+
if (mType !== inType)
|
|
1622
|
+
continue;
|
|
1623
|
+
const mContent = typeof merged[i].content === 'string'
|
|
1624
|
+
? merged[i].content
|
|
1625
|
+
: JSON.stringify(merged[i].content);
|
|
1626
|
+
if (inType === 'human' && mContent === inContent)
|
|
1627
|
+
return i;
|
|
1628
|
+
if (inType === 'ai') {
|
|
1629
|
+
// Skip empty placeholders. We don't want a pre-existing empty AI
|
|
1630
|
+
// (created by an early values-sync emission with `state.messages`
|
|
1631
|
+
// including an unfilled assistant turn) to absorb the first chunk
|
|
1632
|
+
// arriving via messages-tuple — that strands subsequent chunks in a
|
|
1633
|
+
// separate slot whose content no longer prefix-matches the canonical.
|
|
1634
|
+
const aSafe = typeof mContent === 'string' ? mContent : '';
|
|
1635
|
+
const bSafe = typeof inContent === 'string' ? inContent : '';
|
|
1636
|
+
if (aSafe.length === 0 || bSafe.length === 0)
|
|
1637
|
+
continue;
|
|
1638
|
+
if (mContent === inContent || aSafe.startsWith(bSafe) || bSafe.startsWith(aSafe))
|
|
1639
|
+
return i;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return -1;
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Normalize message type so AIMessage and AIMessageChunk compare equal.
|
|
1646
|
+
* The LangGraph SDK emits type='AIMessageChunk' on the messages-tuple
|
|
1647
|
+
* streaming path and type='ai' on the values-sync path for the same
|
|
1648
|
+
* canonical assistant message — distinguishing them prevents the
|
|
1649
|
+
* content-prefix dedupe from collapsing the duplicate bubbles.
|
|
1650
|
+
*/
|
|
1651
|
+
function normalizeMessageType(t) {
|
|
1652
|
+
if (!t)
|
|
1653
|
+
return t;
|
|
1654
|
+
if (t === 'AIMessageChunk' || t === 'AIMessage' || t === 'assistant')
|
|
1655
|
+
return 'ai';
|
|
1656
|
+
if (t === 'HumanMessage' || t === 'HumanMessageChunk' || t === 'user')
|
|
1657
|
+
return 'human';
|
|
1658
|
+
if (t === 'ToolMessage')
|
|
1659
|
+
return 'tool';
|
|
1660
|
+
if (t === 'SystemMessage')
|
|
1661
|
+
return 'system';
|
|
1662
|
+
return t;
|
|
1663
|
+
}
|
|
1664
|
+
function toSubagentRefs(subagents) {
|
|
1665
|
+
const refs = new Map();
|
|
1666
|
+
subagents.forEach((subagent, key) => {
|
|
1667
|
+
refs.set(key, {
|
|
1668
|
+
toolCallId: subagent.id,
|
|
1669
|
+
name: typeof subagent.toolCall.args['subagent_type'] === 'string'
|
|
1670
|
+
? subagent.toolCall.args['subagent_type']
|
|
1671
|
+
: undefined,
|
|
1672
|
+
status: signal(subagent.status),
|
|
1673
|
+
values: signal(subagent.values),
|
|
1674
|
+
messages: signal(subagent.messages),
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
return refs;
|
|
1678
|
+
}
|
|
1679
|
+
function isAiMessageWithToolCalls(value) {
|
|
1680
|
+
return (value['type'] === 'ai' || value['type'] === 'assistant')
|
|
1681
|
+
&& Array.isArray(value['tool_calls']);
|
|
1682
|
+
}
|
|
1683
|
+
function isToolMessage(value) {
|
|
1684
|
+
return value['type'] === 'tool' && typeof value['tool_call_id'] === 'string';
|
|
1685
|
+
}
|
|
1686
|
+
function isMessageLike(value) {
|
|
1687
|
+
return typeof value === 'object'
|
|
1688
|
+
&& value !== null
|
|
1689
|
+
&& ('content' in value
|
|
1690
|
+
|| 'type' in value
|
|
1691
|
+
|| 'id' in value);
|
|
1692
|
+
}
|
|
1693
|
+
function isRecord$1(value) {
|
|
1694
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1695
|
+
}
|
|
1696
|
+
const _internalsForTesting = {
|
|
1697
|
+
extractText,
|
|
1698
|
+
extractReasoning,
|
|
1699
|
+
accumulateContent,
|
|
1700
|
+
accumulateReasoning,
|
|
1701
|
+
collapseAdjacentAi,
|
|
1702
|
+
mergeMessages,
|
|
1703
|
+
preserveIds,
|
|
1704
|
+
normalizeMessageType,
|
|
1705
|
+
isFinalCanonicalReasoningContent,
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
const ROOT_ID = '$';
|
|
1709
|
+
/**
|
|
1710
|
+
* Builds a branch-aware checkpoint tree from LangGraph thread history.
|
|
1711
|
+
*
|
|
1712
|
+
* This mirrors the small SDK UI branching data shape without importing the
|
|
1713
|
+
* SDK UI runtime helper, keeping Angular bundles independent of React UI code.
|
|
1714
|
+
*/
|
|
1715
|
+
function buildBranchTree(history = []) {
|
|
1716
|
+
if (history.length <= 1) {
|
|
1717
|
+
return {
|
|
1718
|
+
type: 'sequence',
|
|
1719
|
+
items: history.map(value => ({ type: 'node', value, path: [] })),
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
const nodeIds = new Set();
|
|
1723
|
+
const childrenMap = {};
|
|
1724
|
+
for (const state of history) {
|
|
1725
|
+
const parentId = state.parent_checkpoint?.checkpoint_id ?? ROOT_ID;
|
|
1726
|
+
childrenMap[parentId] ??= [];
|
|
1727
|
+
childrenMap[parentId].push(state);
|
|
1728
|
+
const checkpointId = state.checkpoint?.checkpoint_id;
|
|
1729
|
+
if (checkpointId != null) {
|
|
1730
|
+
nodeIds.add(checkpointId);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
const orphanRoot = findLatestOrphanRoot(childrenMap, nodeIds);
|
|
1734
|
+
if (orphanRoot != null) {
|
|
1735
|
+
childrenMap[ROOT_ID] = childrenMap[orphanRoot];
|
|
1736
|
+
}
|
|
1737
|
+
const rootSequence = { type: 'sequence', items: [] };
|
|
1738
|
+
const queue = [
|
|
1739
|
+
{ id: ROOT_ID, sequence: rootSequence, path: [] },
|
|
1740
|
+
];
|
|
1741
|
+
const visited = new Set();
|
|
1742
|
+
while (queue.length > 0) {
|
|
1743
|
+
const task = queue.shift();
|
|
1744
|
+
if (!task || visited.has(task.id))
|
|
1745
|
+
continue;
|
|
1746
|
+
visited.add(task.id);
|
|
1747
|
+
const children = childrenMap[task.id];
|
|
1748
|
+
if (!children?.length)
|
|
1749
|
+
continue;
|
|
1750
|
+
let fork;
|
|
1751
|
+
if (children.length > 1) {
|
|
1752
|
+
fork = { type: 'fork', items: [] };
|
|
1753
|
+
task.sequence.items.push(fork);
|
|
1754
|
+
}
|
|
1755
|
+
for (const value of children) {
|
|
1756
|
+
const id = value.checkpoint?.checkpoint_id;
|
|
1757
|
+
if (id == null)
|
|
1758
|
+
continue;
|
|
1759
|
+
let sequence = task.sequence;
|
|
1760
|
+
let path = task.path;
|
|
1761
|
+
if (fork != null) {
|
|
1762
|
+
sequence = { type: 'sequence', items: [] };
|
|
1763
|
+
fork.items.unshift(sequence);
|
|
1764
|
+
path = [...task.path, id];
|
|
1765
|
+
}
|
|
1766
|
+
sequence.items.push({ type: 'node', value, path });
|
|
1767
|
+
queue.push({ id, sequence, path });
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return rootSequence;
|
|
1771
|
+
}
|
|
1772
|
+
function findLatestOrphanRoot(childrenMap, nodeIds) {
|
|
1773
|
+
if (childrenMap[ROOT_ID] != null)
|
|
1774
|
+
return undefined;
|
|
1775
|
+
return Object.keys(childrenMap)
|
|
1776
|
+
.filter(parentId => !nodeIds.has(parentId))
|
|
1777
|
+
.map(parentId => ({
|
|
1778
|
+
parentId,
|
|
1779
|
+
lastId: findLatestDescendantId(parentId, childrenMap),
|
|
1780
|
+
}))
|
|
1781
|
+
.sort((left, right) => left.lastId.localeCompare(right.lastId))
|
|
1782
|
+
.at(-1)?.parentId;
|
|
1783
|
+
}
|
|
1784
|
+
function findLatestDescendantId(parentId, childrenMap) {
|
|
1785
|
+
const queue = [parentId];
|
|
1786
|
+
const seen = new Set();
|
|
1787
|
+
let latestId = parentId;
|
|
1788
|
+
while (queue.length > 0) {
|
|
1789
|
+
const current = queue.shift();
|
|
1790
|
+
if (!current || seen.has(current))
|
|
1791
|
+
continue;
|
|
1792
|
+
seen.add(current);
|
|
1793
|
+
for (const child of childrenMap[current] ?? []) {
|
|
1794
|
+
const childId = child.checkpoint?.checkpoint_id;
|
|
1795
|
+
if (childId == null)
|
|
1796
|
+
continue;
|
|
1797
|
+
if (childId.localeCompare(latestId) > 0) {
|
|
1798
|
+
latestId = childId;
|
|
1799
|
+
}
|
|
1800
|
+
queue.push(childId);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return latestId;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function extractCitations(msg) {
|
|
1807
|
+
const raw = msg.additional_kwargs?.['citations'] ?? msg.additional_kwargs?.['sources'];
|
|
1808
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
1809
|
+
return undefined;
|
|
1810
|
+
return raw.map((entry, i) => normalizeCitation(entry, i + 1));
|
|
1811
|
+
}
|
|
1812
|
+
function normalizeCitation(entry, fallbackIndex) {
|
|
1813
|
+
if (typeof entry === 'string') {
|
|
1814
|
+
return { id: `c${fallbackIndex}`, index: fallbackIndex, url: entry };
|
|
1815
|
+
}
|
|
1816
|
+
const e = (entry ?? {});
|
|
1817
|
+
const str = (key) => typeof e[key] === 'string' ? e[key] : undefined;
|
|
1818
|
+
const firstStr = (...keys) => {
|
|
1819
|
+
for (const k of keys) {
|
|
1820
|
+
const v = str(k);
|
|
1821
|
+
if (v !== undefined)
|
|
1822
|
+
return v;
|
|
1823
|
+
}
|
|
1824
|
+
return undefined;
|
|
1825
|
+
};
|
|
1826
|
+
return {
|
|
1827
|
+
id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
|
|
1828
|
+
index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
|
|
1829
|
+
title: firstStr('title', 'name'),
|
|
1830
|
+
url: firstStr('url', 'href', 'source'),
|
|
1831
|
+
snippet: firstStr('snippet', 'content', 'excerpt'),
|
|
1832
|
+
extra: typeof e['extra'] === 'object' && e['extra'] !== null
|
|
1833
|
+
? e['extra']
|
|
1834
|
+
: undefined,
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// SPDX-License-Identifier: MIT
|
|
1839
|
+
/**
|
|
1840
|
+
* Walk LangGraph history (newest-first) and pair each AIMessage id with
|
|
1841
|
+
* the most recent checkpoint that contains it as the tail message in
|
|
1842
|
+
* `values.messages`.
|
|
1843
|
+
*
|
|
1844
|
+
* Implementation: iterate oldest → newest (i.e. reverse the input array)
|
|
1845
|
+
* so later writes overwrite earlier ones; the final map has each
|
|
1846
|
+
* AIMessage paired with the newest containing checkpoint where it is
|
|
1847
|
+
* still the tail. Checkpoints with no AIMessage in scope are skipped.
|
|
1848
|
+
* Checkpoints with no checkpoint_id are skipped.
|
|
1849
|
+
*/
|
|
1850
|
+
function computeMessageCheckpoints(history) {
|
|
1851
|
+
const out = new Map();
|
|
1852
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
1853
|
+
const state = history[i];
|
|
1854
|
+
const cpId = state.checkpoint?.checkpoint_id;
|
|
1855
|
+
if (typeof cpId !== 'string' || cpId.length === 0)
|
|
1856
|
+
continue;
|
|
1857
|
+
const values = state.values;
|
|
1858
|
+
const msgs = Array.isArray(values?.messages) ? values.messages : [];
|
|
1859
|
+
for (let j = msgs.length - 1; j >= 0; j--) {
|
|
1860
|
+
const m = msgs[j];
|
|
1861
|
+
const type = typeof m._getType === 'function' ? m._getType() : m.type;
|
|
1862
|
+
if (type === 'ai' && typeof m.id === 'string') {
|
|
1863
|
+
out.set(m.id, cpId);
|
|
1864
|
+
break;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Creates a LangGraph-backed Angular agent.
|
|
1872
|
+
*
|
|
1873
|
+
* Must be called within an Angular injection context (component constructor,
|
|
1874
|
+
* field initializer, or `runInInjectionContext`). Returns a unified
|
|
1875
|
+
* {@link LangGraphAgent} whose properties are Angular Signals that update
|
|
1876
|
+
* in real time as LangGraph streams messages, values, tool calls, interrupts,
|
|
1877
|
+
* subagent state, and checkpoint history.
|
|
1878
|
+
*
|
|
1879
|
+
* @typeParam T - The state shape returned by the agent
|
|
1880
|
+
* @typeParam Bag - Optional bag template for typed interrupts and submit payloads
|
|
1881
|
+
* @param options - Configuration for the LangGraph agent
|
|
1882
|
+
* @returns A {@link LangGraphAgent} with reactive signals and action methods
|
|
1883
|
+
*
|
|
1884
|
+
* @example
|
|
1885
|
+
* ```typescript
|
|
1886
|
+
* // In a component field initializer
|
|
1887
|
+
* const chat = agent({
|
|
1888
|
+
* assistantId: 'chat_agent',
|
|
1889
|
+
* apiUrl: 'http://localhost:2024',
|
|
1890
|
+
* threadId: signal(this.savedThreadId),
|
|
1891
|
+
* onThreadId: (id) => localStorage.setItem('threadId', id),
|
|
1892
|
+
* });
|
|
1893
|
+
*
|
|
1894
|
+
* // Access signals in template
|
|
1895
|
+
* // chat.messages(), chat.status(), chat.error()
|
|
1896
|
+
* ```
|
|
1897
|
+
*/
|
|
1898
|
+
function agent(options) {
|
|
1899
|
+
// Injection context required
|
|
1900
|
+
const destroyRef = inject(DestroyRef);
|
|
1901
|
+
const globalConfig = inject(AGENT_CONFIG, { optional: true });
|
|
1902
|
+
const destroy$ = new Subject();
|
|
1903
|
+
destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); });
|
|
1904
|
+
// Merge: call-site options take precedence over global provider config
|
|
1905
|
+
const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? '';
|
|
1906
|
+
const transport = options.transport ?? globalConfig?.transport;
|
|
1907
|
+
const init = (options.initialValues ?? {});
|
|
1908
|
+
// All subjects created before the bridge
|
|
1909
|
+
const status$ = new BehaviorSubject(ResourceStatus.Idle);
|
|
1910
|
+
const values$ = new BehaviorSubject(init);
|
|
1911
|
+
const messages$ = new BehaviorSubject([]);
|
|
1912
|
+
const error$ = new BehaviorSubject(undefined);
|
|
1913
|
+
const interrupt$ = new BehaviorSubject(undefined);
|
|
1914
|
+
const interrupts$ = new BehaviorSubject([]);
|
|
1915
|
+
const branch$ = new BehaviorSubject('');
|
|
1916
|
+
const history$ = new BehaviorSubject([]);
|
|
1917
|
+
const isThreadLoading$ = new BehaviorSubject(false);
|
|
1918
|
+
const toolProgress$ = new BehaviorSubject([]);
|
|
1919
|
+
const toolCalls$ = new BehaviorSubject([]);
|
|
1920
|
+
const messageMetadata$ = new BehaviorSubject(new Map());
|
|
1921
|
+
const subagents$ = new BehaviorSubject(new Map());
|
|
1922
|
+
const queue$ = new BehaviorSubject({
|
|
1923
|
+
entries: [],
|
|
1924
|
+
size: 0,
|
|
1925
|
+
cancel: async () => false,
|
|
1926
|
+
clear: async () => undefined,
|
|
1927
|
+
});
|
|
1928
|
+
const custom$ = new BehaviorSubject([]);
|
|
1929
|
+
const hasValue$ = new BehaviorSubject(false);
|
|
1930
|
+
function resetDerivedThreadState() {
|
|
1931
|
+
status$.next(ResourceStatus.Idle);
|
|
1932
|
+
error$.next(undefined);
|
|
1933
|
+
hasValue$.next(false);
|
|
1934
|
+
}
|
|
1935
|
+
// Track hasValue — becomes true once values or messages arrive
|
|
1936
|
+
values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
|
|
1937
|
+
if (v != null && Object.keys(v).length > 0)
|
|
1938
|
+
hasValue$.next(true);
|
|
1939
|
+
});
|
|
1940
|
+
messages$.pipe(takeUntil$1(destroy$)).subscribe(m => { if (m.length > 0)
|
|
1941
|
+
hasValue$.next(true); });
|
|
1942
|
+
const subjects = {
|
|
1943
|
+
status$, values$, messages$, error$,
|
|
1944
|
+
interrupt$, interrupts$, branch$, history$,
|
|
1945
|
+
isThreadLoading$, toolProgress$, toolCalls$, messageMetadata$, subagents$, queue$, custom$,
|
|
1946
|
+
};
|
|
1947
|
+
// threadId$ — resolved before bridge creation (injection context required for toObservable)
|
|
1948
|
+
const threadId$ = isSignal(options.threadId)
|
|
1949
|
+
? toObservable(options.threadId)
|
|
1950
|
+
: of(options.threadId ?? null);
|
|
1951
|
+
let hasSeenThreadId = false;
|
|
1952
|
+
let lastThreadId = null;
|
|
1953
|
+
threadId$.pipe(takeUntil$1(destroy$)).subscribe((id) => {
|
|
1954
|
+
if (hasSeenThreadId && lastThreadId !== id) {
|
|
1955
|
+
resetDerivedThreadState();
|
|
1956
|
+
}
|
|
1957
|
+
hasSeenThreadId = true;
|
|
1958
|
+
lastThreadId = id;
|
|
1959
|
+
});
|
|
1960
|
+
// ── Lifecycle instrumentation ─────────────────────────────────────────────
|
|
1961
|
+
// Eight signals tracking key transitions for telemetry/observability.
|
|
1962
|
+
// All reset together via resetLifecycle(); see switchThread() below.
|
|
1963
|
+
const lcStreamStartedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcStreamStartedAt" }] : []));
|
|
1964
|
+
const lcStreamErrorAt = signal(null, ...(ngDevMode ? [{ debugName: "lcStreamErrorAt" }] : []));
|
|
1965
|
+
const lcInterruptReceivedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcInterruptReceivedAt" }] : []));
|
|
1966
|
+
const lcInterruptResolvedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcInterruptResolvedAt" }] : []));
|
|
1967
|
+
const lcThreadCreatedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcThreadCreatedAt" }] : []));
|
|
1968
|
+
const lcThreadPersistedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcThreadPersistedAt" }] : []));
|
|
1969
|
+
const lcToolCallStartedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcToolCallStartedAt" }] : []));
|
|
1970
|
+
const lcToolCallCompletedAt = signal(null, ...(ngDevMode ? [{ debugName: "lcToolCallCompletedAt" }] : []));
|
|
1971
|
+
const lifecycle = {
|
|
1972
|
+
streamStartedAt: lcStreamStartedAt,
|
|
1973
|
+
streamErrorAt: lcStreamErrorAt,
|
|
1974
|
+
interruptReceivedAt: lcInterruptReceivedAt,
|
|
1975
|
+
interruptResolvedAt: lcInterruptResolvedAt,
|
|
1976
|
+
threadCreatedAt: lcThreadCreatedAt,
|
|
1977
|
+
threadPersistedAt: lcThreadPersistedAt,
|
|
1978
|
+
toolCallStartedAt: lcToolCallStartedAt,
|
|
1979
|
+
toolCallCompletedAt: lcToolCallCompletedAt,
|
|
1980
|
+
};
|
|
1981
|
+
// Register with optional lifecycle registry. External instrumentation
|
|
1982
|
+
// (e.g. cockpit-telemetry) provides AgentLifecycleRegistry to receive
|
|
1983
|
+
// per-agent lifecycles created within this injection context.
|
|
1984
|
+
const lifecycleRegistry = inject(AgentLifecycleRegistry, { optional: true });
|
|
1985
|
+
lifecycleRegistry?.register(lifecycle);
|
|
1986
|
+
function resetLifecycle() {
|
|
1987
|
+
lcStreamStartedAt.set(null);
|
|
1988
|
+
lcStreamErrorAt.set(null);
|
|
1989
|
+
lcInterruptReceivedAt.set(null);
|
|
1990
|
+
lcInterruptResolvedAt.set(null);
|
|
1991
|
+
lcThreadCreatedAt.set(null);
|
|
1992
|
+
lcThreadPersistedAt.set(null);
|
|
1993
|
+
lcToolCallStartedAt.set(null);
|
|
1994
|
+
lcToolCallCompletedAt.set(null);
|
|
1995
|
+
}
|
|
1996
|
+
// First chunk: first values$ or messages$ emission with content.
|
|
1997
|
+
values$.pipe(takeUntil$1(destroy$)).subscribe(v => {
|
|
1998
|
+
if (lcStreamStartedAt() === null && v != null && Object.keys(v).length > 0) {
|
|
1999
|
+
lcStreamStartedAt.set(Date.now());
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
messages$.pipe(takeUntil$1(destroy$)).subscribe(m => {
|
|
2003
|
+
if (lcStreamStartedAt() === null && m.length > 0)
|
|
2004
|
+
lcStreamStartedAt.set(Date.now());
|
|
2005
|
+
});
|
|
2006
|
+
// Stream error: capture timestamp + classification (Error name or 'unknown').
|
|
2007
|
+
error$.pipe(takeUntil$1(destroy$)).subscribe(e => {
|
|
2008
|
+
if (e == null)
|
|
2009
|
+
return;
|
|
2010
|
+
const classification = e instanceof Error ? e.name : typeof e === 'string' ? 'string' : 'unknown';
|
|
2011
|
+
lcStreamErrorAt.set({ at: Date.now(), classification });
|
|
2012
|
+
});
|
|
2013
|
+
// First non-null interrupt within this thread.
|
|
2014
|
+
interrupt$.pipe(takeUntil$1(destroy$)).subscribe(ix => {
|
|
2015
|
+
if (ix != null && lcInterruptReceivedAt() === null)
|
|
2016
|
+
lcInterruptReceivedAt.set(Date.now());
|
|
2017
|
+
});
|
|
2018
|
+
// First tool call append; first completed/error result transition.
|
|
2019
|
+
const seenToolCallStates = new Map();
|
|
2020
|
+
toolCalls$.pipe(takeUntil$1(destroy$)).subscribe(tcs => {
|
|
2021
|
+
if (tcs.length > 0 && lcToolCallStartedAt() === null)
|
|
2022
|
+
lcToolCallStartedAt.set(Date.now());
|
|
2023
|
+
if (lcToolCallCompletedAt() !== null)
|
|
2024
|
+
return;
|
|
2025
|
+
for (const tc of tcs) {
|
|
2026
|
+
const prev = seenToolCallStates.get(tc.id);
|
|
2027
|
+
if (prev !== tc.state && (tc.state === 'completed' || tc.state === 'error')) {
|
|
2028
|
+
lcToolCallCompletedAt.set(Date.now());
|
|
2029
|
+
seenToolCallStates.set(tc.id, tc.state);
|
|
2030
|
+
break;
|
|
2031
|
+
}
|
|
2032
|
+
seenToolCallStates.set(tc.id, tc.state);
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
// Thread restored from server: history$ populates with content for a
|
|
2036
|
+
// pre-existing threadId.
|
|
2037
|
+
history$.pipe(takeUntil$1(destroy$)).subscribe(h => {
|
|
2038
|
+
if (h.length > 0 && lcThreadPersistedAt() === null)
|
|
2039
|
+
lcThreadPersistedAt.set(Date.now());
|
|
2040
|
+
});
|
|
2041
|
+
const manager = createStreamManagerBridge({
|
|
2042
|
+
options: { ...options, apiUrl, transport },
|
|
2043
|
+
subjects,
|
|
2044
|
+
threadId$,
|
|
2045
|
+
destroy$: destroy$.asObservable(),
|
|
2046
|
+
});
|
|
2047
|
+
// Throttle helper — default 16ms (~60fps) to batch SSE token updates into
|
|
2048
|
+
// at most one signal update per frame, preventing change detection storms.
|
|
2049
|
+
const ms = typeof options.throttle === 'number' ? options.throttle : 16;
|
|
2050
|
+
const maybeThrottle = (obs) => ms > 0
|
|
2051
|
+
? obs.pipe(throttleTime(ms, asyncScheduler, { leading: true, trailing: true }))
|
|
2052
|
+
: obs.asObservable();
|
|
2053
|
+
// Convert to Angular Signals (must happen in injection context)
|
|
2054
|
+
const value = toSignal(maybeThrottle(values$), { initialValue: init });
|
|
2055
|
+
// No throttle on messages$: we need every token emission to propagate to
|
|
2056
|
+
// Angular so streaming markdown actually streams. The bridge already
|
|
2057
|
+
// batches per-tuple at the SDK level; further throttling at the signal
|
|
2058
|
+
// boundary collapses tokens together and breaks visible token-by-token
|
|
2059
|
+
// rendering. Same-frame multiple emissions are coalesced by Angular's
|
|
2060
|
+
// CD anyway.
|
|
2061
|
+
const rawMessages = toSignal(messages$, { initialValue: [] });
|
|
2062
|
+
const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle });
|
|
2063
|
+
const errorSig = toSignal(error$, { initialValue: undefined });
|
|
2064
|
+
const hasValueSig = toSignal(hasValue$, { initialValue: false });
|
|
2065
|
+
const interruptSig = toSignal(interrupt$, { initialValue: undefined });
|
|
2066
|
+
const interruptsSig = toSignal(interrupts$, { initialValue: [] });
|
|
2067
|
+
const branchSig = toSignal(branch$, { initialValue: '' });
|
|
2068
|
+
const historySig = toSignal(history$, { initialValue: [] });
|
|
2069
|
+
const threadLoadSig = toSignal(isThreadLoading$, { initialValue: false });
|
|
2070
|
+
const toolProgSig = toSignal(toolProgress$, { initialValue: [] });
|
|
2071
|
+
const rawToolCalls = toSignal(toolCalls$, { initialValue: [] });
|
|
2072
|
+
const subagentsSig = toSignal(subagents$, { initialValue: new Map() });
|
|
2073
|
+
const queueSig = toSignal(queue$, { initialValue: queue$.value });
|
|
2074
|
+
const customSig = toSignal(custom$, { initialValue: [] });
|
|
2075
|
+
const isLoading = computed(() => statusSig() === ResourceStatus.Loading, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
|
|
2076
|
+
const activeSubagents = computed(() => [...subagentsSig().values()].filter(s => s.status() === 'running'), ...(ngDevMode ? [{ debugName: "activeSubagents" }] : []));
|
|
2077
|
+
// ── Runtime-neutral projections ───────────────────────────────────────────
|
|
2078
|
+
// Project BaseMessage → Message on every recompute. We deliberately do
|
|
2079
|
+
// NOT cache: the LangGraph SDK mutates the same AIMessage instance in
|
|
2080
|
+
// place during token streaming (appends content to the same object), so
|
|
2081
|
+
// any identity-based cache returns stale projections and Angular's
|
|
2082
|
+
// `@let content = messageContent(message)` short-circuits — DOM never
|
|
2083
|
+
// updates per token. DOM stability is provided by `track message.id`
|
|
2084
|
+
// in chat-message-list, not by Message identity.
|
|
2085
|
+
const messagesNeutral = computed(() => rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), ...(ngDevMode ? [{ debugName: "messagesNeutral" }] : []));
|
|
2086
|
+
const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall), ...(ngDevMode ? [{ debugName: "toolCallsNeutral" }] : []));
|
|
2087
|
+
const statusNeutral = computed(() => mapStatus(statusSig()), ...(ngDevMode ? [{ debugName: "statusNeutral" }] : []));
|
|
2088
|
+
const stateNeutral = computed(() => {
|
|
2089
|
+
const v = value();
|
|
2090
|
+
return v && typeof v === 'object' ? v : {};
|
|
2091
|
+
}, ...(ngDevMode ? [{ debugName: "stateNeutral" }] : []));
|
|
2092
|
+
const interruptNeutral = computed(() => {
|
|
2093
|
+
const ix = interruptSig();
|
|
2094
|
+
return ix ? toInterrupt(ix) : undefined;
|
|
2095
|
+
}, ...(ngDevMode ? [{ debugName: "interruptNeutral" }] : []));
|
|
2096
|
+
const subagentsNeutral = computed(() => {
|
|
2097
|
+
const out = new Map();
|
|
2098
|
+
subagentsSig().forEach((sa, key) => out.set(key, toSubagent(sa)));
|
|
2099
|
+
return out;
|
|
2100
|
+
}, ...(ngDevMode ? [{ debugName: "subagentsNeutral" }] : []));
|
|
2101
|
+
const historyNeutral = computed(() => historySig().map(toCheckpoint), ...(ngDevMode ? [{ debugName: "historyNeutral" }] : []));
|
|
2102
|
+
const messageCheckpointsSig = computed(() => computeMessageCheckpoints(historySig()), ...(ngDevMode ? [{ debugName: "messageCheckpointsSig" }] : []));
|
|
2103
|
+
const experimentalBranchTree = computed(() => buildBranchTree(historySig()), ...(ngDevMode ? [{ debugName: "experimentalBranchTree" }] : []));
|
|
2104
|
+
const events$ = buildEvents$(customSig);
|
|
2105
|
+
return {
|
|
2106
|
+
// ── Runtime-neutral surface (AgentWithHistory) ────────────────────────
|
|
2107
|
+
messages: messagesNeutral,
|
|
2108
|
+
status: statusNeutral,
|
|
2109
|
+
isLoading,
|
|
2110
|
+
error: errorSig,
|
|
2111
|
+
toolCalls: toolCallsNeutral,
|
|
2112
|
+
state: stateNeutral,
|
|
2113
|
+
interrupt: interruptNeutral,
|
|
2114
|
+
subagents: subagentsNeutral,
|
|
2115
|
+
events$,
|
|
2116
|
+
history: historyNeutral,
|
|
2117
|
+
messageCheckpoints: messageCheckpointsSig,
|
|
2118
|
+
submit: (input, opts) => {
|
|
2119
|
+
// Lifecycle: first submit with no existing threadId → thread create.
|
|
2120
|
+
if (lcThreadCreatedAt() === null && lastThreadId == null) {
|
|
2121
|
+
lcThreadCreatedAt.set(Date.now());
|
|
2122
|
+
}
|
|
2123
|
+
// Lifecycle: any resume submit marks an interrupt resolution.
|
|
2124
|
+
if (input?.resume !== undefined || opts?.resume !== undefined) {
|
|
2125
|
+
lcInterruptResolvedAt.set(Date.now());
|
|
2126
|
+
}
|
|
2127
|
+
const request = buildSubmitRequest(input, opts);
|
|
2128
|
+
return manager.submit(request.payload, request.options);
|
|
2129
|
+
},
|
|
2130
|
+
stop: () => manager.stop(),
|
|
2131
|
+
regenerate: async (assistantMessageIndex) => {
|
|
2132
|
+
if (isLoading()) {
|
|
2133
|
+
throw new Error('Cannot regenerate while agent is loading another response');
|
|
2134
|
+
}
|
|
2135
|
+
const msgs = messagesNeutral();
|
|
2136
|
+
const target = msgs[assistantMessageIndex];
|
|
2137
|
+
if (!target || target.role !== 'assistant') {
|
|
2138
|
+
throw new Error(`Message at index ${assistantMessageIndex} is not an assistant message`);
|
|
2139
|
+
}
|
|
2140
|
+
// Find the user message immediately preceding the target assistant message.
|
|
2141
|
+
const userIdx = msgs
|
|
2142
|
+
.slice(0, assistantMessageIndex)
|
|
2143
|
+
.map((m, i) => ({ m, i }))
|
|
2144
|
+
.reverse()
|
|
2145
|
+
.find(({ m }) => m.role === 'user')?.i;
|
|
2146
|
+
if (userIdx === undefined) {
|
|
2147
|
+
throw new Error('No user message found before the target assistant message');
|
|
2148
|
+
}
|
|
2149
|
+
// Snapshot the raw BaseMessages that will be REMOVED (everything after userIdx).
|
|
2150
|
+
const rawToRemove = messages$.value.slice(userIdx + 1);
|
|
2151
|
+
// Truncate local buffer INCLUSIVE of the user message. The computed
|
|
2152
|
+
// messagesNeutral signal immediately reflects this — user message is
|
|
2153
|
+
// preserved in the UI while the new response streams in.
|
|
2154
|
+
messages$.next(messages$.value.slice(0, userIdx + 1));
|
|
2155
|
+
// Build RemoveMessage wire-shape instructions for server-side rollback.
|
|
2156
|
+
// LangGraph's add_messages reducer recognises `{ type: 'remove', id }`
|
|
2157
|
+
// and removes those entries from the thread state — ensuring the
|
|
2158
|
+
// runtime re-runs against the same trimmed state rather than appending
|
|
2159
|
+
// new messages on top.
|
|
2160
|
+
const removeList = rawToRemove
|
|
2161
|
+
.map(m => {
|
|
2162
|
+
const raw = m;
|
|
2163
|
+
const id = typeof raw['id'] === 'string' ? raw['id'] : undefined;
|
|
2164
|
+
return id
|
|
2165
|
+
? { type: 'remove', role: 'remove', id, content: '' }
|
|
2166
|
+
: null;
|
|
2167
|
+
})
|
|
2168
|
+
.filter((rm) => rm !== null);
|
|
2169
|
+
// RemoveMessage rollback + reposition the graph to the entry node
|
|
2170
|
+
// via `as_node: '__start__'`. After the original run, the thread is
|
|
2171
|
+
// at `__end__` with `next: []` — submitting `null` would be a no-op
|
|
2172
|
+
// because there is nothing pending to execute. Setting `asNode` to
|
|
2173
|
+
// the start node tells LangGraph to treat the update as if `__start__`
|
|
2174
|
+
// had just produced the values, so the next pull resumes at the entry
|
|
2175
|
+
// node and runs `generate` against the rolled-back state.
|
|
2176
|
+
//
|
|
2177
|
+
// We always pass `asNode` even when removeList is empty (rare, but
|
|
2178
|
+
// possible if the assistant message had no id) so the regenerate
|
|
2179
|
+
// submit below still runs the graph.
|
|
2180
|
+
await manager.updateState({ messages: removeList }, { asNode: '__start__' });
|
|
2181
|
+
// Re-run the graph with no new input. With the thread now repositioned
|
|
2182
|
+
// at `__start__`, this resumes at the entry node and produces a fresh
|
|
2183
|
+
// assistant message — the trailing user message becomes the active
|
|
2184
|
+
// prompt without being re-appended.
|
|
2185
|
+
await manager.submit(null, undefined);
|
|
2186
|
+
},
|
|
2187
|
+
// ── Raw LangGraph signals ─────────────────────────────────────────────
|
|
2188
|
+
langGraphMessages: rawMessages,
|
|
2189
|
+
langGraphInterrupts: interruptsSig,
|
|
2190
|
+
langGraphToolCalls: rawToolCalls,
|
|
2191
|
+
langGraphHistory: historySig,
|
|
2192
|
+
experimentalBranchTree,
|
|
2193
|
+
// ── Other LangGraph-specific fields ──────────────────────────────────
|
|
2194
|
+
value: value,
|
|
2195
|
+
hasValue: hasValueSig,
|
|
2196
|
+
reload: () => manager.resubmitLast(),
|
|
2197
|
+
toolProgress: toolProgSig,
|
|
2198
|
+
queue: queueSig,
|
|
2199
|
+
activeSubagents,
|
|
2200
|
+
getSubagent: (toolCallId) => subagentsSig().get(toolCallId),
|
|
2201
|
+
getSubagentsByType: (type) => [...subagentsSig().values()].filter(sa => sa.name === type),
|
|
2202
|
+
getSubagentsByMessage: (msg) => {
|
|
2203
|
+
const ids = getToolCallIds(msg);
|
|
2204
|
+
const subagents = subagentsSig();
|
|
2205
|
+
return ids
|
|
2206
|
+
.map(id => subagents.get(id))
|
|
2207
|
+
.filter((subagent) => subagent != null);
|
|
2208
|
+
},
|
|
2209
|
+
customEvents: customSig,
|
|
2210
|
+
branch: branchSig,
|
|
2211
|
+
setBranch: (b) => branch$.next(b),
|
|
2212
|
+
isThreadLoading: threadLoadSig,
|
|
2213
|
+
switchThread: (id) => {
|
|
2214
|
+
resetDerivedThreadState();
|
|
2215
|
+
resetLifecycle();
|
|
2216
|
+
seenToolCallStates.clear();
|
|
2217
|
+
manager.switchThread(id);
|
|
2218
|
+
},
|
|
2219
|
+
lifecycle,
|
|
2220
|
+
joinStream: (id, last) => manager.joinStream(id, last),
|
|
2221
|
+
getMessagesMetadata: (msg, idx) => {
|
|
2222
|
+
const id = msg['id'];
|
|
2223
|
+
const key = id != null ? String(id) : idx != null ? String(idx) : undefined;
|
|
2224
|
+
return key ? messageMetadata$.value.get(key) : undefined;
|
|
2225
|
+
},
|
|
2226
|
+
getToolCalls: (msg) => {
|
|
2227
|
+
const id = msg['id'];
|
|
2228
|
+
return id == null
|
|
2229
|
+
? []
|
|
2230
|
+
: toolCalls$.value.filter(tc => tc.aiMessage['id'] === id);
|
|
2231
|
+
},
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
// ── Private translation helpers (moved from to-agent.ts) ─────────────────────
|
|
2235
|
+
/**
|
|
2236
|
+
* Build an Observable<AgentEvent> that bridges LangGraph's
|
|
2237
|
+
* `Signal<CustomStreamEvent[]>` (append-only array) into a stream of newly
|
|
2238
|
+
* emitted events. Each effect firing compares against a cursor tracking the
|
|
2239
|
+
* previously-seen length and emits only the tail slice.
|
|
2240
|
+
*/
|
|
2241
|
+
function buildEvents$(customSig) {
|
|
2242
|
+
const subject = new Subject();
|
|
2243
|
+
let seen = 0;
|
|
2244
|
+
effect(() => {
|
|
2245
|
+
const all = customSig();
|
|
2246
|
+
if (all.length < seen) {
|
|
2247
|
+
// Stream reset (new session, thread switch, etc.). Rewind cursor.
|
|
2248
|
+
seen = 0;
|
|
2249
|
+
}
|
|
2250
|
+
for (let i = seen; i < all.length; i++) {
|
|
2251
|
+
subject.next(toAgentEvent(all[i]));
|
|
2252
|
+
}
|
|
2253
|
+
seen = all.length;
|
|
2254
|
+
});
|
|
2255
|
+
return subject.asObservable();
|
|
2256
|
+
}
|
|
2257
|
+
function toAgentEvent(e) {
|
|
2258
|
+
if (e.name === 'state_update' && isRecord(e.data)) {
|
|
2259
|
+
return { type: 'state_update', data: e.data };
|
|
2260
|
+
}
|
|
2261
|
+
return { type: 'custom', name: e.name, data: e.data };
|
|
2262
|
+
}
|
|
2263
|
+
function mapStatus(s) {
|
|
2264
|
+
switch (s) {
|
|
2265
|
+
case ResourceStatus.Error: return 'error';
|
|
2266
|
+
case ResourceStatus.Loading:
|
|
2267
|
+
case ResourceStatus.Reloading:
|
|
2268
|
+
return 'running';
|
|
2269
|
+
default:
|
|
2270
|
+
return 'idle';
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
function toMessage(m, getReasoningDurationMs) {
|
|
2274
|
+
const raw = m;
|
|
2275
|
+
const typeVal = typeof m._getType === 'function'
|
|
2276
|
+
? m._getType()
|
|
2277
|
+
: raw['type'] ?? 'ai';
|
|
2278
|
+
const role = typeVal === 'human' ? 'user' :
|
|
2279
|
+
typeVal === 'tool' ? 'tool' :
|
|
2280
|
+
typeVal === 'system' ? 'system' :
|
|
2281
|
+
'assistant';
|
|
2282
|
+
const id = m.id ?? raw['id'] ?? randomId();
|
|
2283
|
+
const reasoning = typeof raw['reasoning'] === 'string' && raw['reasoning'].length > 0
|
|
2284
|
+
? raw['reasoning']
|
|
2285
|
+
: undefined;
|
|
2286
|
+
const reasoningDurationMs = reasoning && getReasoningDurationMs
|
|
2287
|
+
? getReasoningDurationMs(id)
|
|
2288
|
+
: undefined;
|
|
2289
|
+
const result = {
|
|
2290
|
+
id,
|
|
2291
|
+
role,
|
|
2292
|
+
content: extractTextContent(m.content),
|
|
2293
|
+
toolCallId: raw['tool_call_id'],
|
|
2294
|
+
name: raw['name'],
|
|
2295
|
+
reasoning,
|
|
2296
|
+
reasoningDurationMs,
|
|
2297
|
+
extra: raw,
|
|
2298
|
+
};
|
|
2299
|
+
const citations = extractCitations(raw);
|
|
2300
|
+
if (citations)
|
|
2301
|
+
result.citations = citations;
|
|
2302
|
+
if (role === 'assistant') {
|
|
2303
|
+
const tcIds = getToolCallIds(m);
|
|
2304
|
+
if (tcIds.length > 0)
|
|
2305
|
+
result.toolCallIds = tcIds;
|
|
2306
|
+
}
|
|
2307
|
+
return result;
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Extract user-visible text from a `BaseMessage.content` value.
|
|
2311
|
+
*
|
|
2312
|
+
* LangChain's `BaseMessage.content` is `string | MessageContentComplex[]`.
|
|
2313
|
+
* Reasoning-capable models (OpenAI gpt-5/o-series, Anthropic) emit complex
|
|
2314
|
+
* arrays of typed blocks: `{type: 'text', text}`, `{type: 'reasoning', ...}`,
|
|
2315
|
+
* tool-use blocks, etc. We render only the visible text portions and skip
|
|
2316
|
+
* anything else. JSON-stringifying the whole array (the previous behaviour)
|
|
2317
|
+
* would dump raw `[{"type":"text",...}]` into the chat bubble.
|
|
2318
|
+
*/
|
|
2319
|
+
function extractTextContent(content) {
|
|
2320
|
+
if (typeof content === 'string')
|
|
2321
|
+
return content;
|
|
2322
|
+
if (!Array.isArray(content))
|
|
2323
|
+
return '';
|
|
2324
|
+
let out = '';
|
|
2325
|
+
for (const block of content) {
|
|
2326
|
+
if (typeof block === 'string') {
|
|
2327
|
+
out += block;
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
if (!isRecord(block))
|
|
2331
|
+
continue;
|
|
2332
|
+
const t = block['type'];
|
|
2333
|
+
// Common text-bearing block shapes across providers.
|
|
2334
|
+
if (t === 'text' || t === 'output_text' || t === undefined) {
|
|
2335
|
+
const text = block['text'];
|
|
2336
|
+
if (typeof text === 'string')
|
|
2337
|
+
out += text;
|
|
2338
|
+
}
|
|
2339
|
+
// Skip reasoning, tool_use, image, etc. — not chat-bubble content.
|
|
2340
|
+
}
|
|
2341
|
+
return out;
|
|
2342
|
+
}
|
|
2343
|
+
function toToolCall(tc) {
|
|
2344
|
+
const stateMap = {
|
|
2345
|
+
pending: 'pending',
|
|
2346
|
+
completed: 'complete',
|
|
2347
|
+
error: 'error',
|
|
2348
|
+
};
|
|
2349
|
+
const status = stateMap[tc.state] ?? 'running';
|
|
2350
|
+
const result = tc.result;
|
|
2351
|
+
return {
|
|
2352
|
+
id: tc.id,
|
|
2353
|
+
name: tc.call.name,
|
|
2354
|
+
args: tc.call.args,
|
|
2355
|
+
status,
|
|
2356
|
+
result: result?.['content'],
|
|
2357
|
+
error: tc.state === 'error' ? result?.['content'] : undefined,
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
function toInterrupt(ix) {
|
|
2361
|
+
const raw = ix;
|
|
2362
|
+
return {
|
|
2363
|
+
id: raw['id'] ?? randomId(),
|
|
2364
|
+
value: raw['value'] ?? ix,
|
|
2365
|
+
resumable: true,
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
function toSubagent(sa) {
|
|
2369
|
+
return {
|
|
2370
|
+
toolCallId: sa.toolCallId,
|
|
2371
|
+
name: sa.name,
|
|
2372
|
+
status: sa.status,
|
|
2373
|
+
messages: computed(() => sa.messages().map((m) => toMessage(m))),
|
|
2374
|
+
state: sa.values,
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function getToolCallIds(msg) {
|
|
2378
|
+
const raw = msg;
|
|
2379
|
+
const toolCalls = raw['tool_calls'];
|
|
2380
|
+
if (!Array.isArray(toolCalls))
|
|
2381
|
+
return [];
|
|
2382
|
+
return toolCalls
|
|
2383
|
+
.map(toolCall => isRecord(toolCall) && typeof toolCall['id'] === 'string' ? toolCall['id'] : undefined)
|
|
2384
|
+
.filter((id) => id != null);
|
|
2385
|
+
}
|
|
2386
|
+
function buildSubmitRequest(input, opts) {
|
|
2387
|
+
return {
|
|
2388
|
+
payload: buildSubmitPayload(input),
|
|
2389
|
+
options: normalizeSubmitOptions(input, opts),
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
function buildSubmitPayload(input) {
|
|
2393
|
+
if (input == null)
|
|
2394
|
+
return null;
|
|
2395
|
+
if (input.resume !== undefined)
|
|
2396
|
+
return null;
|
|
2397
|
+
return buildSubmitUpdate(input) ?? {};
|
|
2398
|
+
}
|
|
2399
|
+
function normalizeSubmitOptions(input, opts) {
|
|
2400
|
+
const inputResume = input?.resume;
|
|
2401
|
+
const optionResume = opts?.resume;
|
|
2402
|
+
const resume = inputResume !== undefined ? inputResume : optionResume;
|
|
2403
|
+
if (resume === undefined)
|
|
2404
|
+
return opts;
|
|
2405
|
+
const next = { ...(opts ?? {}) };
|
|
2406
|
+
delete next.resume;
|
|
2407
|
+
const command = next.command;
|
|
2408
|
+
const update = buildSubmitUpdate(input);
|
|
2409
|
+
const commandUpdate = mergeCommandUpdate(command?.update, update);
|
|
2410
|
+
return {
|
|
2411
|
+
...next,
|
|
2412
|
+
command: {
|
|
2413
|
+
...command,
|
|
2414
|
+
resume,
|
|
2415
|
+
...(commandUpdate === undefined ? {} : { update: commandUpdate }),
|
|
2416
|
+
},
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
function buildSubmitUpdate(input) {
|
|
2420
|
+
if (input == null)
|
|
2421
|
+
return undefined;
|
|
2422
|
+
if (input.message !== undefined) {
|
|
2423
|
+
const content = typeof input.message === 'string'
|
|
2424
|
+
? input.message
|
|
2425
|
+
: input.message.map((b) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
|
|
2426
|
+
// `type: 'human'` is what `toMessage()` reads via `_getType` || raw['type'];
|
|
2427
|
+
// `role: 'human'` is what the LangGraph server expects in submit payloads.
|
|
2428
|
+
// Include both so the optimistic local copy projects as a 'user' bubble
|
|
2429
|
+
// (otherwise toMessage falls through to the 'ai' default and renders the
|
|
2430
|
+
// user's question as an assistant message).
|
|
2431
|
+
return { messages: [{ type: 'human', role: 'human', content }], ...(input.state ?? {}) };
|
|
2432
|
+
}
|
|
2433
|
+
return input.state;
|
|
2434
|
+
}
|
|
2435
|
+
function mergeCommandUpdate(existing, update) {
|
|
2436
|
+
if (update === undefined)
|
|
2437
|
+
return existing;
|
|
2438
|
+
if (existing == null)
|
|
2439
|
+
return update;
|
|
2440
|
+
if (isRecord(existing))
|
|
2441
|
+
return { ...existing, ...update };
|
|
2442
|
+
if (Array.isArray(existing))
|
|
2443
|
+
return [...existing, ...Object.entries(update)];
|
|
2444
|
+
return update;
|
|
2445
|
+
}
|
|
2446
|
+
function randomId() {
|
|
2447
|
+
return Math.random().toString(36).slice(2);
|
|
2448
|
+
}
|
|
2449
|
+
function toCheckpoint(state) {
|
|
2450
|
+
return {
|
|
2451
|
+
id: state.checkpoint?.checkpoint_id ?? undefined,
|
|
2452
|
+
label: state.next?.[0] ?? undefined,
|
|
2453
|
+
values: isRecord(state.values) ? state.values : {},
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
function isRecord(v) {
|
|
2457
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// SPDX-License-Identifier: MIT
|
|
2461
|
+
const AGENT_LIFECYCLE = new InjectionToken('AGENT_LIFECYCLE');
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* Test transport for deterministic agent testing without a real LangGraph server.
|
|
2465
|
+
*
|
|
2466
|
+
* Script event batches upfront, then emit them manually or step through them
|
|
2467
|
+
* in your test specs. Supports error injection and close control.
|
|
2468
|
+
*
|
|
2469
|
+
* @example
|
|
2470
|
+
* ```typescript
|
|
2471
|
+
* const transport = new MockAgentTransport([
|
|
2472
|
+
* [{ type: 'values', messages: [aiMsg('Hello')] }],
|
|
2473
|
+
* [{ type: 'values', messages: [aiMsg('Done')] }],
|
|
2474
|
+
* ]);
|
|
2475
|
+
* ```
|
|
2476
|
+
*/
|
|
2477
|
+
class MockAgentTransport {
|
|
2478
|
+
history = [];
|
|
2479
|
+
historyCalls = [];
|
|
2480
|
+
streams = [];
|
|
2481
|
+
createdQueuedRuns = [];
|
|
2482
|
+
cancelledRuns = [];
|
|
2483
|
+
joinedRuns = [];
|
|
2484
|
+
script;
|
|
2485
|
+
scriptIndex = 0;
|
|
2486
|
+
streaming = false;
|
|
2487
|
+
eventQueue = [];
|
|
2488
|
+
// Each resolver simply wakes the stream loop to re-check state.
|
|
2489
|
+
resolvers = [];
|
|
2490
|
+
closed = false;
|
|
2491
|
+
pendingError = null;
|
|
2492
|
+
/** @param script - Array of event batches. Each batch is emitted as a group. */
|
|
2493
|
+
constructor(script = []) {
|
|
2494
|
+
this.script = script;
|
|
2495
|
+
}
|
|
2496
|
+
/** Advance to the next scripted batch. Pass the returned events to `emit()`. */
|
|
2497
|
+
nextBatch() {
|
|
2498
|
+
if (this.scriptIndex >= this.script.length)
|
|
2499
|
+
return [];
|
|
2500
|
+
return this.script[this.scriptIndex++];
|
|
2501
|
+
}
|
|
2502
|
+
/** Manually emit events into the stream. */
|
|
2503
|
+
emit(events) {
|
|
2504
|
+
this.eventQueue.push(...events);
|
|
2505
|
+
this.flush();
|
|
2506
|
+
}
|
|
2507
|
+
/** Inject an error into the stream. */
|
|
2508
|
+
emitError(err) {
|
|
2509
|
+
this.pendingError = err;
|
|
2510
|
+
this.flush();
|
|
2511
|
+
}
|
|
2512
|
+
/** Close the stream. Remaining queued events are drained before completion. */
|
|
2513
|
+
close() {
|
|
2514
|
+
this.closed = true;
|
|
2515
|
+
this.flush();
|
|
2516
|
+
}
|
|
2517
|
+
/** Returns true if a stream is currently active. */
|
|
2518
|
+
isStreaming() {
|
|
2519
|
+
return this.streaming;
|
|
2520
|
+
}
|
|
2521
|
+
async *stream(_assistantId, _threadId, _payload, signal, options) {
|
|
2522
|
+
this.streams.push({ threadId: _threadId, payload: _payload, options });
|
|
2523
|
+
this.streaming = true;
|
|
2524
|
+
try {
|
|
2525
|
+
while (!this.closed && !signal.aborted) {
|
|
2526
|
+
if (this.pendingError)
|
|
2527
|
+
throw this.pendingError;
|
|
2528
|
+
if (this.eventQueue.length > 0) {
|
|
2529
|
+
const event = this.eventQueue.shift();
|
|
2530
|
+
if (event)
|
|
2531
|
+
yield event;
|
|
2532
|
+
}
|
|
2533
|
+
else {
|
|
2534
|
+
// Wait until flush() wakes us, then loop again to check state.
|
|
2535
|
+
await new Promise((resolve) => {
|
|
2536
|
+
if (signal.aborted) {
|
|
2537
|
+
resolve();
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
this.resolvers.push(resolve);
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
if (signal.aborted)
|
|
2545
|
+
return;
|
|
2546
|
+
// Drain remaining events after close()
|
|
2547
|
+
while (this.eventQueue.length > 0) {
|
|
2548
|
+
const event = this.eventQueue.shift();
|
|
2549
|
+
if (event)
|
|
2550
|
+
yield event;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
finally {
|
|
2554
|
+
this.streaming = false;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
async createQueuedRun(_assistantId, threadId, payload, signal, options) {
|
|
2558
|
+
void signal;
|
|
2559
|
+
const entry = {
|
|
2560
|
+
id: `queued-run-${this.createdQueuedRuns.length + 1}`,
|
|
2561
|
+
threadId,
|
|
2562
|
+
values: payload,
|
|
2563
|
+
options: { ...options, multitaskStrategy: 'enqueue' },
|
|
2564
|
+
createdAt: new Date(),
|
|
2565
|
+
};
|
|
2566
|
+
this.createdQueuedRuns.push(entry);
|
|
2567
|
+
return entry;
|
|
2568
|
+
}
|
|
2569
|
+
async cancelRun(threadId, runId, signal) {
|
|
2570
|
+
void signal;
|
|
2571
|
+
this.cancelledRuns.push({ threadId, runId });
|
|
2572
|
+
}
|
|
2573
|
+
async getHistory(threadId, signal) {
|
|
2574
|
+
void signal;
|
|
2575
|
+
this.historyCalls.push(threadId);
|
|
2576
|
+
return this.history;
|
|
2577
|
+
}
|
|
2578
|
+
async *joinStream(threadId, runId, lastEventId, signal) {
|
|
2579
|
+
void lastEventId;
|
|
2580
|
+
void signal;
|
|
2581
|
+
this.joinedRuns.push({ threadId, runId });
|
|
2582
|
+
yield { type: 'values', values: { queued: true } };
|
|
2583
|
+
}
|
|
2584
|
+
flush() {
|
|
2585
|
+
const resolve = this.resolvers.shift();
|
|
2586
|
+
if (resolve)
|
|
2587
|
+
resolve();
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// SPDX-License-Identifier: MIT
|
|
2592
|
+
/**
|
|
2593
|
+
* Creates a mock LangGraphAgent with writable signals for testing.
|
|
2594
|
+
* Control state by writing to the returned writable signals directly.
|
|
2595
|
+
*/
|
|
2596
|
+
function mockLangGraphAgent(initial = {}) {
|
|
2597
|
+
const messages$ = signal(initial.messages ?? [], ...(ngDevMode ? [{ debugName: "messages$" }] : []));
|
|
2598
|
+
const langGraphMessages$ = signal(initial.langGraphMessages ?? [], ...(ngDevMode ? [{ debugName: "langGraphMessages$" }] : []));
|
|
2599
|
+
const status$ = signal(initial.status ?? 'idle', ...(ngDevMode ? [{ debugName: "status$" }] : []));
|
|
2600
|
+
const isLoading$ = signal(initial.isLoading ?? false, ...(ngDevMode ? [{ debugName: "isLoading$" }] : []));
|
|
2601
|
+
const error$ = signal(initial.error ?? null, ...(ngDevMode ? [{ debugName: "error$" }] : []));
|
|
2602
|
+
const hasValue$ = signal(initial.hasValue ?? false, ...(ngDevMode ? [{ debugName: "hasValue$" }] : []));
|
|
2603
|
+
const value$ = signal(null, ...(ngDevMode ? [{ debugName: "value$" }] : []));
|
|
2604
|
+
const interrupt$ = signal(undefined, ...(ngDevMode ? [{ debugName: "interrupt$" }] : []));
|
|
2605
|
+
const langGraphInterrupts$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphInterrupts$" }] : []));
|
|
2606
|
+
const toolCalls$ = signal([], ...(ngDevMode ? [{ debugName: "toolCalls$" }] : []));
|
|
2607
|
+
const langGraphToolCalls$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphToolCalls$" }] : []));
|
|
2608
|
+
const toolProgress$ = signal([], ...(ngDevMode ? [{ debugName: "toolProgress$" }] : []));
|
|
2609
|
+
const queue$ = signal({
|
|
2610
|
+
entries: [],
|
|
2611
|
+
size: 0,
|
|
2612
|
+
cancel: async () => false,
|
|
2613
|
+
clear: async () => undefined,
|
|
2614
|
+
}, ...(ngDevMode ? [{ debugName: "queue$" }] : []));
|
|
2615
|
+
const branch$ = signal('', ...(ngDevMode ? [{ debugName: "branch$" }] : []));
|
|
2616
|
+
const history$ = signal([], ...(ngDevMode ? [{ debugName: "history$" }] : []));
|
|
2617
|
+
const langGraphHistory$ = signal([], ...(ngDevMode ? [{ debugName: "langGraphHistory$" }] : []));
|
|
2618
|
+
const experimentalBranchTree$ = signal({ type: 'sequence', items: [] }, ...(ngDevMode ? [{ debugName: "experimentalBranchTree$" }] : []));
|
|
2619
|
+
const isThreadLoading$ = signal(initial.isThreadLoading ?? false, ...(ngDevMode ? [{ debugName: "isThreadLoading$" }] : []));
|
|
2620
|
+
const subagents$ = signal(new Map(), ...(ngDevMode ? [{ debugName: "subagents$" }] : []));
|
|
2621
|
+
const activeSubagents$ = signal([], ...(ngDevMode ? [{ debugName: "activeSubagents$" }] : []));
|
|
2622
|
+
const customEvents$ = signal([], ...(ngDevMode ? [{ debugName: "customEvents$" }] : []));
|
|
2623
|
+
const state$ = computed(() => {
|
|
2624
|
+
const v = value$();
|
|
2625
|
+
return v && typeof v === 'object' ? v : {};
|
|
2626
|
+
}, ...(ngDevMode ? [{ debugName: "state$" }] : []));
|
|
2627
|
+
const eventsSubject = new Subject();
|
|
2628
|
+
const mock = {
|
|
2629
|
+
// ── AgentWithHistory (runtime-neutral surface) ────────────────────────
|
|
2630
|
+
messages: messages$,
|
|
2631
|
+
status: status$,
|
|
2632
|
+
isLoading: isLoading$,
|
|
2633
|
+
error: error$,
|
|
2634
|
+
toolCalls: toolCalls$,
|
|
2635
|
+
state: state$,
|
|
2636
|
+
interrupt: interrupt$,
|
|
2637
|
+
subagents: subagents$,
|
|
2638
|
+
events$: eventsSubject.asObservable(),
|
|
2639
|
+
history: history$,
|
|
2640
|
+
submit: (_input, _opts) => Promise.resolve(),
|
|
2641
|
+
stop: () => Promise.resolve(),
|
|
2642
|
+
regenerate: (_assistantMessageIndex) => Promise.resolve(),
|
|
2643
|
+
// ── Raw LangGraph signals ─────────────────────────────────────────────
|
|
2644
|
+
langGraphMessages: langGraphMessages$,
|
|
2645
|
+
langGraphInterrupts: langGraphInterrupts$,
|
|
2646
|
+
langGraphToolCalls: langGraphToolCalls$,
|
|
2647
|
+
langGraphHistory: langGraphHistory$,
|
|
2648
|
+
experimentalBranchTree: experimentalBranchTree$,
|
|
2649
|
+
// ── Other AgentRef fields preserved ──────────────────────────────────
|
|
2650
|
+
value: value$,
|
|
2651
|
+
hasValue: hasValue$,
|
|
2652
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
2653
|
+
reload: () => { },
|
|
2654
|
+
toolProgress: toolProgress$,
|
|
2655
|
+
queue: queue$,
|
|
2656
|
+
activeSubagents: activeSubagents$,
|
|
2657
|
+
getSubagent: (toolCallId) => activeSubagents$().find(subagent => subagent.toolCallId === toolCallId),
|
|
2658
|
+
getSubagentsByType: (type) => activeSubagents$().filter(subagent => subagent.name === type),
|
|
2659
|
+
getSubagentsByMessage: (msg) => {
|
|
2660
|
+
const toolCalls = msg['tool_calls'];
|
|
2661
|
+
if (!Array.isArray(toolCalls))
|
|
2662
|
+
return [];
|
|
2663
|
+
const ids = toolCalls
|
|
2664
|
+
.map(toolCall => {
|
|
2665
|
+
if (toolCall == null || typeof toolCall !== 'object' || Array.isArray(toolCall))
|
|
2666
|
+
return undefined;
|
|
2667
|
+
const id = toolCall['id'];
|
|
2668
|
+
return typeof id === 'string' ? id : undefined;
|
|
2669
|
+
})
|
|
2670
|
+
.filter((id) => id != null);
|
|
2671
|
+
return activeSubagents$().filter(subagent => ids.includes(subagent.toolCallId));
|
|
2672
|
+
},
|
|
2673
|
+
customEvents: customEvents$,
|
|
2674
|
+
branch: branch$,
|
|
2675
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
2676
|
+
setBranch: (_branch) => { },
|
|
2677
|
+
isThreadLoading: isThreadLoading$,
|
|
2678
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
2679
|
+
switchThread: (_threadId) => { },
|
|
2680
|
+
joinStream: (_runId, _lastEventId) => Promise.resolve(),
|
|
2681
|
+
getMessagesMetadata: (_msg, _idx) => undefined,
|
|
2682
|
+
getToolCalls: (_msg) => [],
|
|
2683
|
+
lifecycle: {
|
|
2684
|
+
streamStartedAt: signal(null),
|
|
2685
|
+
streamErrorAt: signal(null),
|
|
2686
|
+
interruptReceivedAt: signal(null),
|
|
2687
|
+
interruptResolvedAt: signal(null),
|
|
2688
|
+
threadCreatedAt: signal(null),
|
|
2689
|
+
threadPersistedAt: signal(null),
|
|
2690
|
+
toolCallStartedAt: signal(null),
|
|
2691
|
+
toolCallCompletedAt: signal(null),
|
|
2692
|
+
},
|
|
2693
|
+
};
|
|
2694
|
+
return mock;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// SPDX-License-Identifier: MIT
|
|
2698
|
+
const LANGGRAPH_THREADS_CONFIG = new InjectionToken('LANGGRAPH_THREADS_CONFIG');
|
|
2699
|
+
/** Optional adapter clients can pass an explicit Client (e.g. for
|
|
2700
|
+
* testing). When omitted, the adapter constructs one via
|
|
2701
|
+
* {@link createLangGraphClient}. */
|
|
2702
|
+
const LANGGRAPH_CLIENT = new InjectionToken('LANGGRAPH_CLIENT');
|
|
2703
|
+
/**
|
|
2704
|
+
* SDK-backed thread store. Wraps `client.threads.*` and maps SDK
|
|
2705
|
+
* threads to the framework's {@link Thread} type for direct use with
|
|
2706
|
+
* `<chat-thread-list>` / `<chat-sidenav>`.
|
|
2707
|
+
*
|
|
2708
|
+
* Consumers wire the framework's `ThreadActionAdapter` to instance
|
|
2709
|
+
* methods (rename/delete/archive/pin/...) so the right-click menu
|
|
2710
|
+
* round-trips through the LangGraph SDK without per-app boilerplate.
|
|
2711
|
+
*
|
|
2712
|
+
* @example
|
|
2713
|
+
* ```ts
|
|
2714
|
+
* const svc = inject(LangGraphThreadsAdapter);
|
|
2715
|
+
* const actions: ThreadActionAdapter = {
|
|
2716
|
+
* rename: (id, t) => svc.rename(id, t),
|
|
2717
|
+
* delete: (id) => svc.delete(id),
|
|
2718
|
+
* };
|
|
2719
|
+
* ```
|
|
2720
|
+
*/
|
|
2721
|
+
class LangGraphThreadsAdapter {
|
|
2722
|
+
config = inject(LANGGRAPH_THREADS_CONFIG);
|
|
2723
|
+
client = inject(LANGGRAPH_CLIENT, { optional: true })
|
|
2724
|
+
?? createLangGraphClient(this.config.apiUrl);
|
|
2725
|
+
fallback = this.config.titleFallback ?? 'Untitled';
|
|
2726
|
+
_threads = signal([], ...(ngDevMode ? [{ debugName: "_threads" }] : []));
|
|
2727
|
+
_archived = signal([], ...(ngDevMode ? [{ debugName: "_archived" }] : []));
|
|
2728
|
+
/** Active (non-archived) threads, sorted with pinned first. */
|
|
2729
|
+
threads = this._threads.asReadonly();
|
|
2730
|
+
/** Threads whose `metadata.archived === true`. */
|
|
2731
|
+
archivedThreads = this._archived.asReadonly();
|
|
2732
|
+
/** Fetch the latest thread list from the server. Failures are
|
|
2733
|
+
* logged via `console.error` (not swallowed silently — silent
|
|
2734
|
+
* catches have masked prod issues in the past).
|
|
2735
|
+
*
|
|
2736
|
+
* Invocation and resolution are logged at `console.debug` so prod
|
|
2737
|
+
* inspection can distinguish "never called" from "called but
|
|
2738
|
+
* resolved empty" from "called and threw." This was prompted by a
|
|
2739
|
+
* demo.threadplane.ai cold-load bug where the sidenav stayed empty
|
|
2740
|
+
* with no visible signal. Tighten the log volume if it becomes
|
|
2741
|
+
* noisy. */
|
|
2742
|
+
async refresh() {
|
|
2743
|
+
console.debug('[LangGraphThreadsAdapter.refresh] invoked');
|
|
2744
|
+
try {
|
|
2745
|
+
const list = await this.client.threads.search({ limit: 50 });
|
|
2746
|
+
console.debug('[LangGraphThreadsAdapter.refresh] resolved', list.length);
|
|
2747
|
+
const mapped = list.map((t) => this.toThread(t));
|
|
2748
|
+
this._threads.set(mapped
|
|
2749
|
+
.filter((t) => t.status !== 'archived')
|
|
2750
|
+
.sort((a, b) => {
|
|
2751
|
+
const aP = a.pinned === true;
|
|
2752
|
+
const bP = b.pinned === true;
|
|
2753
|
+
if (aP !== bP)
|
|
2754
|
+
return Number(bP) - Number(aP);
|
|
2755
|
+
if (aP && bP) {
|
|
2756
|
+
const aO = typeof a['pinnedOrder'] === 'number' ? a['pinnedOrder'] : Infinity;
|
|
2757
|
+
const bO = typeof b['pinnedOrder'] === 'number' ? b['pinnedOrder'] : Infinity;
|
|
2758
|
+
return aO - bO;
|
|
2759
|
+
}
|
|
2760
|
+
return 0;
|
|
2761
|
+
}));
|
|
2762
|
+
this._archived.set(mapped.filter((t) => t.status === 'archived'));
|
|
2763
|
+
}
|
|
2764
|
+
catch (e) {
|
|
2765
|
+
console.error('[LangGraphThreadsAdapter.refresh] failed:', e);
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
/** Fetch a single thread by id. Returns `null` when the server
|
|
2769
|
+
* returns 404 (thread doesn't exist) so callers can distinguish
|
|
2770
|
+
* "missing" from "couldn't reach the server" — genuine network
|
|
2771
|
+
* errors rethrow. Used by URL-based thread routing to validate a
|
|
2772
|
+
* pasted/shared thread id before activating it. */
|
|
2773
|
+
async getThread(threadId) {
|
|
2774
|
+
try {
|
|
2775
|
+
const t = await this.client.threads.get(threadId);
|
|
2776
|
+
return this.toThread(t);
|
|
2777
|
+
}
|
|
2778
|
+
catch (e) {
|
|
2779
|
+
// SDK throws HTTPError-like objects without a typed error class;
|
|
2780
|
+
// sniff status on the error or its nested response. Treat both
|
|
2781
|
+
// 404 (server says "no such thread") and 422 (server says "id
|
|
2782
|
+
// isn't even a valid UUID") as "missing" — both warrant the
|
|
2783
|
+
// same caller behavior (redirect to a fresh chat).
|
|
2784
|
+
const status = e.status ??
|
|
2785
|
+
e.response?.status;
|
|
2786
|
+
if (status === 404 || status === 422)
|
|
2787
|
+
return null;
|
|
2788
|
+
throw e;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
async create(metadata = {}) {
|
|
2792
|
+
try {
|
|
2793
|
+
const t = await this.client.threads.create({ metadata });
|
|
2794
|
+
await this.refresh();
|
|
2795
|
+
return t.thread_id;
|
|
2796
|
+
}
|
|
2797
|
+
catch (e) {
|
|
2798
|
+
console.error('[LangGraphThreadsAdapter.create] failed:', e);
|
|
2799
|
+
return null;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
async delete(threadId) {
|
|
2803
|
+
await this.client.threads.delete(threadId);
|
|
2804
|
+
await this.refresh();
|
|
2805
|
+
}
|
|
2806
|
+
async rename(threadId, newTitle) {
|
|
2807
|
+
await this.client.threads.update(threadId, { metadata: { title: newTitle } });
|
|
2808
|
+
await this.refresh();
|
|
2809
|
+
}
|
|
2810
|
+
async archive(threadId) {
|
|
2811
|
+
await this.client.threads.update(threadId, { metadata: { archived: true } });
|
|
2812
|
+
await this.refresh();
|
|
2813
|
+
}
|
|
2814
|
+
async unarchive(threadId) {
|
|
2815
|
+
await this.client.threads.update(threadId, { metadata: { archived: false } });
|
|
2816
|
+
await this.refresh();
|
|
2817
|
+
}
|
|
2818
|
+
async pin(threadId) {
|
|
2819
|
+
await this.client.threads.update(threadId, { metadata: { pinned: true } });
|
|
2820
|
+
await this.refresh();
|
|
2821
|
+
}
|
|
2822
|
+
async unpin(threadId) {
|
|
2823
|
+
await this.client.threads.update(threadId, { metadata: { pinned: false } });
|
|
2824
|
+
await this.refresh();
|
|
2825
|
+
}
|
|
2826
|
+
async moveToProject(threadId, projectId) {
|
|
2827
|
+
await this.client.threads.update(threadId, { metadata: { projectId } });
|
|
2828
|
+
await this.refresh();
|
|
2829
|
+
}
|
|
2830
|
+
/** Re-stamp `metadata.pinnedOrder = 0,1,2,...` for the pinned slice
|
|
2831
|
+
* to reflect the new ordering. */
|
|
2832
|
+
async reorderPinned(threadId, beforeId) {
|
|
2833
|
+
const current = this._threads().filter((t) => t.pinned === true);
|
|
2834
|
+
const moved = current.find((t) => t.id === threadId);
|
|
2835
|
+
if (!moved)
|
|
2836
|
+
return;
|
|
2837
|
+
const rest = current.filter((t) => t.id !== threadId);
|
|
2838
|
+
const next = [];
|
|
2839
|
+
for (const t of rest) {
|
|
2840
|
+
if (t.id === beforeId)
|
|
2841
|
+
next.push(moved);
|
|
2842
|
+
next.push(t);
|
|
2843
|
+
}
|
|
2844
|
+
if (beforeId === null)
|
|
2845
|
+
next.push(moved);
|
|
2846
|
+
await Promise.all(next.map((t, idx) => this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } })));
|
|
2847
|
+
await this.refresh();
|
|
2848
|
+
}
|
|
2849
|
+
toThread(t) {
|
|
2850
|
+
const meta = (t.metadata ?? {});
|
|
2851
|
+
const rawTitle = meta['title'];
|
|
2852
|
+
const archived = meta['archived'] === true;
|
|
2853
|
+
const pinned = meta['pinned'] === true;
|
|
2854
|
+
const projectId = typeof meta['projectId'] === 'string' && meta['projectId'].length > 0
|
|
2855
|
+
? meta['projectId']
|
|
2856
|
+
: null;
|
|
2857
|
+
const pinnedOrder = typeof meta['pinnedOrder'] === 'number' ? meta['pinnedOrder'] : undefined;
|
|
2858
|
+
return {
|
|
2859
|
+
id: t.thread_id,
|
|
2860
|
+
title: typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : this.fallback,
|
|
2861
|
+
status: archived ? 'archived' : 'active',
|
|
2862
|
+
pinned,
|
|
2863
|
+
projectId,
|
|
2864
|
+
pinnedOrder,
|
|
2865
|
+
updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined,
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2869
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, providedIn: 'root' });
|
|
2870
|
+
}
|
|
2871
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.6", ngImport: i0, type: LangGraphThreadsAdapter, decorators: [{
|
|
2872
|
+
type: Injectable,
|
|
2873
|
+
args: [{ providedIn: 'root' }]
|
|
2874
|
+
}] });
|
|
2875
|
+
|
|
2876
|
+
// SPDX-License-Identifier: MIT
|
|
2877
|
+
/**
|
|
2878
|
+
* Call `fn` whenever the agent's status transitions out of `'running'`
|
|
2879
|
+
* (i.e. when a run completes — success, error, or interrupt). Useful
|
|
2880
|
+
* for refreshing thread lists, telemetry, or any other state that
|
|
2881
|
+
* lags the agent.
|
|
2882
|
+
*
|
|
2883
|
+
* Must be called within an injection context (constructor or
|
|
2884
|
+
* `runInInjectionContext`) — uses Angular's `effect` under the hood.
|
|
2885
|
+
*
|
|
2886
|
+
* @example
|
|
2887
|
+
* ```ts
|
|
2888
|
+
* constructor() {
|
|
2889
|
+
* refreshOnRunEnd(this.agent, () => this.threads.refresh());
|
|
2890
|
+
* }
|
|
2891
|
+
* ```
|
|
2892
|
+
*/
|
|
2893
|
+
function refreshOnRunEnd(agent, fn) {
|
|
2894
|
+
let lastStatus = agent.status();
|
|
2895
|
+
effect(() => {
|
|
2896
|
+
const status = agent.status();
|
|
2897
|
+
if (lastStatus === 'running' && status !== 'running') {
|
|
2898
|
+
void fn();
|
|
2899
|
+
}
|
|
2900
|
+
lastStatus = status;
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Call `fn` whenever any of the watched signals transitions from a
|
|
2905
|
+
* truthy "active" value to a non-active value. Generic version of
|
|
2906
|
+
* {@link refreshOnRunEnd} for callers tracking custom state machines.
|
|
2907
|
+
*
|
|
2908
|
+
* Must be called within an injection context.
|
|
2909
|
+
*/
|
|
2910
|
+
function refreshOnTransition(watch, isActive, fn) {
|
|
2911
|
+
let lastActive = isActive(watch());
|
|
2912
|
+
effect(() => {
|
|
2913
|
+
const active = isActive(watch());
|
|
2914
|
+
if (lastActive && !active)
|
|
2915
|
+
void fn();
|
|
2916
|
+
lastActive = active;
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// SPDX-License-Identifier: MIT
|
|
2921
|
+
// Primary function
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* Generated bundle index. Do not edit.
|
|
2925
|
+
*/
|
|
2926
|
+
|
|
2927
|
+
export { AGENT_CONFIG, AGENT_LIFECYCLE, AgentLifecycleRegistry, FetchStreamTransport, LANGGRAPH_CLIENT, LANGGRAPH_THREADS_CONFIG, LangGraphThreadsAdapter, MockAgentTransport, ResourceStatus, agent, createLangGraphClient, extractCitations, mockLangGraphAgent, provideAgent, refreshOnRunEnd, refreshOnTransition, toAbsoluteApiUrl };
|
|
2928
|
+
//# sourceMappingURL=threadplane-langgraph.mjs.map
|