@threadplane/langgraph 0.0.58 → 0.0.60

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.
@@ -243,6 +243,18 @@ class SubagentTracker {
243
243
  onSubagentChange;
244
244
  subagents = new Map();
245
245
  namespaceToToolCallId = new Map();
246
+ /**
247
+ * Child messages received under a namespace that is not yet attributed to a
248
+ * registered subagent. LangGraph's `tools:<id>` namespace carries an internal
249
+ * run UUID, not the parent's tool-call id, and the two are only reconciled
250
+ * once a `values` event arrives carrying the child's first human message. Any
251
+ * chunk streamed before that point would otherwise be dropped — which is what
252
+ * made subagent cards render "0 message(s)" despite a full child transcript.
253
+ *
254
+ * Merged by id like a real transcript, so this stays bounded by the child's
255
+ * distinct message count rather than by chunk volume.
256
+ */
257
+ unattributedMessages = new Map();
246
258
  pendingMatches = new Map();
247
259
  constructor(options = {}) {
248
260
  this.subagentToolNames = new Set(options.subagentToolNames ?? DEFAULT_SUBAGENT_TOOL_NAMES);
@@ -252,6 +264,7 @@ class SubagentTracker {
252
264
  this.subagents.clear();
253
265
  this.namespaceToToolCallId.clear();
254
266
  this.pendingMatches.clear();
267
+ this.unattributedMessages.clear();
255
268
  this.onSubagentChange?.();
256
269
  }
257
270
  getSubagents() {
@@ -278,6 +291,7 @@ class SubagentTracker {
278
291
  this.subagents.set(id, {
279
292
  id,
280
293
  generation: existing?.generation ?? createSubagentGeneration(),
294
+ kind: 'tool',
281
295
  status: existing?.status ?? 'pending',
282
296
  toolCall: {
283
297
  id,
@@ -319,23 +333,26 @@ class SubagentTracker {
319
333
  this.namespaceToToolCallId.set(namespaceId, toolCallId);
320
334
  const subagent = this.subagents.get(toolCallId);
321
335
  if (subagent) {
336
+ const buffered = this.unattributedMessages.get(namespaceId);
322
337
  this.subagents.set(toolCallId, {
323
338
  ...subagent,
324
339
  status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
340
+ messages: buffered ? mergeMessages$1(subagent.messages, buffered) : subagent.messages,
325
341
  });
342
+ this.unattributedMessages.delete(namespaceId);
326
343
  }
327
344
  this.onSubagentChange?.();
328
345
  return toolCallId;
329
346
  };
330
347
  for (const [toolCallId, subagent] of this.subagents) {
331
- if (mapped.has(toolCallId))
348
+ if (subagent.kind !== 'tool' || mapped.has(toolCallId))
332
349
  continue;
333
350
  if (subagent.toolCall.args['description'] === description) {
334
351
  return establish(toolCallId);
335
352
  }
336
353
  }
337
354
  for (const [toolCallId, subagent] of this.subagents) {
338
- if (mapped.has(toolCallId))
355
+ if (subagent.kind !== 'tool' || mapped.has(toolCallId))
339
356
  continue;
340
357
  const subagentDescription = subagent.toolCall.args['description'];
341
358
  if (typeof subagentDescription !== 'string' || !subagentDescription)
@@ -344,7 +361,11 @@ class SubagentTracker {
344
361
  return establish(toolCallId);
345
362
  }
346
363
  }
364
+ // Last-resort fallback — tool children only. A subgraph child is keyed by
365
+ // its own namespace and must never absorb an unrelated child's events.
347
366
  for (const [toolCallId, subagent] of this.subagents) {
367
+ if (subagent.kind !== 'tool')
368
+ continue;
348
369
  if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
349
370
  return establish(toolCallId);
350
371
  }
@@ -372,6 +393,67 @@ class SubagentTracker {
372
393
  });
373
394
  this.onSubagentChange?.();
374
395
  }
396
+ /**
397
+ * Attribute a `tools:` child stream to its parent tool call as soon as the
398
+ * child is seen, without requiring a description to match on.
399
+ *
400
+ * The description ladder needs two things this repo's own graphs don't
401
+ * reliably provide: a delegation tool that names its argument `description`,
402
+ * and a child whose first message is the human task. `cockpit/chat/subagents`
403
+ * has neither — it uses `task_description`, and its child's messages begin
404
+ * with the AI reply. Attribution therefore never ran, so the child's
405
+ * transcript was never claimed and every card rendered "0 message(s)".
406
+ *
407
+ * Calling the ladder with no description skips both description rungs and
408
+ * lands on the positional fallback (first unmapped pending/running tool
409
+ * child), which is correct for sequential dispatch and is the same heuristic
410
+ * the ladder already relied on in practice.
411
+ */
412
+ ensureToolStreamAttribution(namespaceId) {
413
+ if (this.namespaceToToolCallId.has(namespaceId))
414
+ return;
415
+ this.matchSubgraphToSubagent(namespaceId, '');
416
+ }
417
+ /**
418
+ * Register a plain-subgraph child stream on its first namespaced event.
419
+ *
420
+ * Unlike tool children — announced ahead of time by the parent's tool call —
421
+ * a compiled child added as a plain node has no announcement: its existence
422
+ * is learned from the first event carrying its namespace. It starts
423
+ * 'running' because by the time we see an event, it is.
424
+ */
425
+ ensureSubgraphStream(key, name) {
426
+ if (this.subagents.has(key))
427
+ return;
428
+ this.subagents.set(key, {
429
+ id: key,
430
+ generation: createSubagentGeneration(),
431
+ kind: 'subgraph',
432
+ status: 'running',
433
+ toolCall: { id: key, name, args: {} },
434
+ values: {},
435
+ messages: [],
436
+ });
437
+ this.onSubagentChange?.();
438
+ }
439
+ /**
440
+ * Settle still-running subgraph children when the run reaches a terminal
441
+ * outcome. Tool children settle through their tool result
442
+ * (`processToolMessage`); subgraph children have no result message, so the
443
+ * run's own settle is their completion signal. Paused/interrupted runs must
444
+ * NOT call this — a child can resume with the thread.
445
+ */
446
+ settleRunningSubgraphs(outcome) {
447
+ let changed = false;
448
+ for (const [key, subagent] of this.subagents) {
449
+ if (subagent.kind !== 'subgraph' || subagent.status !== 'running')
450
+ continue;
451
+ this.subagents.set(key, { ...subagent, status: outcome });
452
+ changed = true;
453
+ }
454
+ if (changed)
455
+ this.onSubagentChange?.();
456
+ }
375
457
  updateSubagentValues(namespaceId, values) {
376
458
  const toolCallId = this.resolveToolCallId(namespaceId);
377
459
  const subagent = this.subagents.get(toolCallId);
@@ -387,8 +469,12 @@ class SubagentTracker {
387
469
  addMessageToSubagent(namespaceId, message) {
388
470
  const toolCallId = this.resolveToolCallId(namespaceId);
389
471
  const subagent = this.subagents.get(toolCallId);
390
- if (!subagent)
472
+ if (!subagent) {
473
+ // Not attributed yet — hold it rather than drop it. `establish()` will
474
+ // replay the buffer the moment this namespace is matched to a tool call.
475
+ this.unattributedMessages.set(namespaceId, mergeMessages$1(this.unattributedMessages.get(namespaceId) ?? [], [message]));
391
476
  return;
477
+ }
392
478
  this.subagents.set(toolCallId, {
393
479
  ...subagent,
394
480
  status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
@@ -437,12 +523,40 @@ class SubagentTracker {
437
523
  return this.namespaceToToolCallId.get(namespaceId) ?? namespaceId;
438
524
  }
439
525
  }
440
- function isSubagentNamespace(namespace) {
526
+ /**
527
+ * True when a stream event belongs to a child graph rather than the parent —
528
+ * i.e. it carries any namespace at all. This is the single classification
529
+ * question; which child owns the event is a separate (attribution) question.
530
+ *
531
+ * Kept consistent with the terminal-evidence guard, which has always refused
532
+ * ANY namespaced event as proof the parent run finished.
533
+ */
534
+ function isChildNamespace(namespace) {
441
535
  if (!namespace)
442
536
  return false;
443
537
  if (typeof namespace === 'string')
444
- return namespace.includes('tools:');
445
- return namespace.some(segment => segment.startsWith('tools:'));
538
+ return namespace.length > 0;
539
+ return namespace.length > 0;
540
+ }
541
+ /**
542
+ * Derive a child stream's identity from an event namespace.
543
+ *
544
+ * `tools:<id>` segments identify a tool-dispatched child by its tool-call id.
545
+ * Any other segment (e.g. `research:<uuid>` from a compiled graph added with
546
+ * `add_node`) identifies a plain subgraph child: the full segment is the key
547
+ * (unique per invocation) and the part before the first ':' is the node name.
548
+ */
549
+ function childStreamRefFromNamespace(namespace) {
550
+ for (const segment of namespace) {
551
+ if (segment.startsWith('tools:')) {
552
+ return { key: segment.slice(6), name: '', kind: 'tool' };
553
+ }
554
+ }
555
+ const first = namespace[0];
556
+ if (!first)
557
+ return undefined;
558
+ const colon = first.indexOf(':');
559
+ return { key: first, name: colon > 0 ? first.slice(0, colon) : first, kind: 'subgraph' };
446
560
  }
447
561
  function extractToolCallIdFromNamespace(namespace) {
448
562
  if (!namespace)
@@ -485,7 +599,7 @@ function mergeMessages$1(existing, incoming) {
485
599
  const id = getMessageId(msg);
486
600
  const idx = id ? merged.findIndex(m => getMessageId(m) === id) : -1;
487
601
  if (idx >= 0) {
488
- merged[idx] = msg;
602
+ merged[idx] = accumulateChunk(merged[idx], msg);
489
603
  }
490
604
  else {
491
605
  merged.push(msg);
@@ -493,6 +607,57 @@ function mergeMessages$1(existing, incoming) {
493
607
  }
494
608
  return merged;
495
609
  }
610
+ /**
611
+ * Fold a streamed chunk into the message it belongs to.
612
+ *
613
+ * A child graph streams `AIMessageChunk`s that are *deltas* — a handful of
614
+ * characters each, hundreds per message. Replacing by id (the previous
615
+ * behavior) therefore kept only the final delta, so a fully attributed
616
+ * subagent still rendered a near-empty message. Snapshots, which carry the
617
+ * message-so-far, still replace.
618
+ *
619
+ * This mirrors the parent transcript's delta handling: append unconditionally
620
+ * rather than comparing text, because a prefix-style "dedupe" silently eats
621
+ * legitimate tokens that happen to repeat the accumulated prefix.
622
+ */
623
+ function accumulateChunk(existing, incoming) {
624
+ if (!isChunkMessage(incoming))
625
+ return incoming;
626
+ const previousText = extractText$1(existing['content']);
627
+ const incomingText = extractText$1(incoming['content']);
628
+ if (!incomingText)
629
+ return existing;
630
+ if (!previousText)
631
+ return incoming;
632
+ return { ...incoming, content: previousText + incomingText };
633
+ }
634
+ function isChunkMessage(message) {
635
+ const type = message['type'];
636
+ return typeof type === 'string' && type.endsWith('Chunk');
637
+ }
638
+ function extractText$1(content) {
639
+ if (typeof content === 'string')
640
+ return content;
641
+ if (!Array.isArray(content))
642
+ return '';
643
+ let out = '';
644
+ for (const block of content) {
645
+ if (typeof block === 'string') {
646
+ out += block;
647
+ continue;
648
+ }
649
+ if (block == null || typeof block !== 'object')
650
+ continue;
651
+ const record = block;
652
+ const blockType = record['type'];
653
+ if (blockType === 'text' || blockType === 'output_text' || blockType === undefined) {
654
+ const text = record['text'];
655
+ if (typeof text === 'string')
656
+ out += text;
657
+ }
658
+ }
659
+ return out;
660
+ }
496
661
  function getMessageId(message) {
497
662
  return message.id;
498
663
  }
@@ -649,6 +814,13 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
649
814
  continue;
650
815
  finalizeMessage(attempt, id, outcome);
651
816
  }
817
+ // Subgraph children have no tool result to settle them; the run's own
818
+ // terminal outcome is their completion signal. Paused/interrupted runs
819
+ // are excluded — a child can resume with the thread.
820
+ if (outcome === 'success' || outcome === 'error' || outcome === 'aborted') {
821
+ subagentManager.settleRunningSubgraphs(outcome === 'success' ? 'complete' : 'error');
822
+ publishSubagents();
823
+ }
652
824
  }
653
825
  function finishOutcome(attempt) {
654
826
  return attempt.terminalOutcome
@@ -1118,17 +1290,27 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1118
1290
  const normalized = options.toMessage
1119
1291
  ? msgs.map(options.toMessage)
1120
1292
  : msgs;
1121
- if (isSubagentNamespace(namespace)) {
1122
- const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1123
- if (namespaceId) {
1293
+ // Any namespaced message event is child content. It feeds the child's
1294
+ // stream and never merges into the parent transcript — the parent
1295
+ // transcript is what the parent graph says. Shared-state children still
1296
+ // surface at settle through the authoritative top-level `values` sync.
1297
+ if (isChildNamespace(namespace)) {
1298
+ const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
1299
+ if (child) {
1300
+ if (child.kind === 'subgraph') {
1301
+ subagentManager.ensureSubgraphStream(child.key, child.name);
1302
+ }
1303
+ else {
1304
+ // Claim the stream for its tool call before its tokens arrive.
1305
+ subagentManager.ensureToolStreamAttribution(child.key);
1306
+ }
1124
1307
  for (const msg of normalized) {
1125
- subagentManager.addMessageToSubagent(namespaceId, msg);
1308
+ subagentManager.addMessageToSubagent(child.key, msg);
1126
1309
  }
1127
1310
  publishSubagents();
1128
1311
  }
1129
- if (options.filterSubagentMessages) {
1130
- return;
1131
- }
1312
+ storeMessageMetadata(normalized, event);
1313
+ return;
1132
1314
  }
1133
1315
  // Partial and message-tuple events are incremental. Merge them by id
1134
1316
  // so optimistic human messages and earlier tool messages are preserved.
@@ -1143,7 +1325,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1143
1325
  const merged = mergeMessages(subjects.messages$.value, normalized, reasoningTimingMap, mode, canonicalMessageIds, affectedMessageIds, activeAttempt?.currentAssistantMessageId !== undefined
1144
1326
  && activeAttempt.currentStepHasTerminalEvidence !== true);
1145
1327
  subjects.messages$.next(merged);
1146
- if (!isSubagentNamespace(namespace)) {
1328
+ {
1147
1329
  trackAssistantMessages(merged.filter(message => {
1148
1330
  const id = message['id'];
1149
1331
  return typeof id === 'string' && affectedMessageIds.has(id);
@@ -1166,7 +1348,7 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1166
1348
  const affectedMessageIds = new Set();
1167
1349
  const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds);
1168
1350
  subjects.messages$.next(preserved);
1169
- if (!isSubagentNamespace(namespace)) {
1351
+ {
1170
1352
  trackAssistantMessages(preserved.filter(message => {
1171
1353
  const id = message['id'];
1172
1354
  return typeof id === 'string' && affectedMessageIds.has(id);
@@ -1185,8 +1367,11 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1185
1367
  switch (baseType) {
1186
1368
  case 'values': {
1187
1369
  const vals = extractEventData(event);
1188
- if (isSubagentNamespace(namespace) && isRecord$1(vals)) {
1189
- updateSubagentValues(namespace, vals);
1370
+ if (isChildNamespace(namespace)) {
1371
+ // A child's state must not clobber the parent's `values$` — route it
1372
+ // to the child stream and stop.
1373
+ if (isRecord$1(vals))
1374
+ updateSubagentValues(namespace, vals);
1190
1375
  break;
1191
1376
  }
1192
1377
  if ((namespace?.length ?? 0) === 0) {
@@ -1252,7 +1437,9 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1252
1437
  }
1253
1438
  case 'updates': {
1254
1439
  const upd = extractEventData(event);
1255
- if (isSubagentNamespace(namespace)) {
1440
+ if (isChildNamespace(namespace)) {
1441
+ // A child's updates must not spread-merge into the parent's
1442
+ // `values$` — they only mark the child stream running.
1256
1443
  markSubagentRunning(namespace);
1257
1444
  break;
1258
1445
  }
@@ -1315,24 +1502,36 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1315
1502
  publishSubagents();
1316
1503
  }
1317
1504
  function updateSubagentValues(namespace, values) {
1318
- const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1319
- if (!namespaceId)
1505
+ const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
1506
+ if (!child)
1320
1507
  return;
1321
- const messages = values['messages'];
1322
- if (Array.isArray(messages) && messages.length > 0) {
1323
- const first = messages[0];
1508
+ if (child.kind === 'tool') {
1509
+ // Prefer the precise description match when the child's first message is
1510
+ // the human task; otherwise claim the stream positionally so a graph that
1511
+ // doesn't fit that shape still gets attributed.
1512
+ const messages = values['messages'];
1513
+ const first = Array.isArray(messages) && messages.length > 0 ? messages[0] : undefined;
1324
1514
  if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1325
- subagentManager.matchSubgraphToSubagent(namespaceId, first['content']);
1515
+ subagentManager.matchSubgraphToSubagent(child.key, first['content']);
1516
+ }
1517
+ else {
1518
+ subagentManager.ensureToolStreamAttribution(child.key);
1326
1519
  }
1327
1520
  }
1328
- subagentManager.updateSubagentValues(namespaceId, values);
1521
+ else {
1522
+ subagentManager.ensureSubgraphStream(child.key, child.name);
1523
+ }
1524
+ subagentManager.updateSubagentValues(child.key, values);
1329
1525
  publishSubagents();
1330
1526
  }
1331
1527
  function markSubagentRunning(namespace) {
1332
- const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined;
1333
- if (!namespaceId)
1528
+ const child = namespace ? childStreamRefFromNamespace(namespace) : undefined;
1529
+ if (!child)
1334
1530
  return;
1335
- subagentManager.markRunningFromNamespace(namespaceId, namespace);
1531
+ if (child.kind === 'subgraph') {
1532
+ subagentManager.ensureSubgraphStream(child.key, child.name);
1533
+ }
1534
+ subagentManager.markRunningFromNamespace(child.key, namespace);
1336
1535
  publishSubagents();
1337
1536
  }
1338
1537
  function publishSubagents() {
@@ -2130,9 +2329,13 @@ function toSubagentRefs(subagents) {
2130
2329
  subagents.forEach((subagent, key) => {
2131
2330
  refs.set(key, {
2132
2331
  toolCallId: subagent.id,
2332
+ // Tool children are named by their `subagent_type` arg; subgraph
2333
+ // children by their node name (stored as the synthetic toolCall name).
2133
2334
  name: typeof subagent.toolCall.args['subagent_type'] === 'string'
2134
2335
  ? subagent.toolCall.args['subagent_type']
2135
- : undefined,
2336
+ : subagent.kind === 'subgraph'
2337
+ ? subagent.toolCall.name
2338
+ : undefined,
2136
2339
  status: signal(subagent.status),
2137
2340
  values: signal(subagent.values),
2138
2341
  messages: signal(subagent.messages),
@@ -3424,7 +3627,6 @@ function agentFactory() {
3424
3627
  ...(config.transport !== undefined ? { transport: config.transport } : {}),
3425
3628
  ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}),
3426
3629
  ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}),
3427
- ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}),
3428
3630
  ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}),
3429
3631
  ...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}),
3430
3632
  });