@threadplane/langgraph 0.0.59 → 0.0.61

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() {
@@ -320,10 +333,13 @@ class SubagentTracker {
320
333
  this.namespaceToToolCallId.set(namespaceId, toolCallId);
321
334
  const subagent = this.subagents.get(toolCallId);
322
335
  if (subagent) {
336
+ const buffered = this.unattributedMessages.get(namespaceId);
323
337
  this.subagents.set(toolCallId, {
324
338
  ...subagent,
325
339
  status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
340
+ messages: buffered ? mergeMessages$1(subagent.messages, buffered) : subagent.messages,
326
341
  });
342
+ this.unattributedMessages.delete(namespaceId);
327
343
  }
328
344
  this.onSubagentChange?.();
329
345
  return toolCallId;
@@ -345,14 +361,28 @@ class SubagentTracker {
345
361
  return establish(toolCallId);
346
362
  }
347
363
  }
348
- // Last-resort fallback — tool children only. A subgraph child is keyed by
349
- // its own namespace and must never absorb an unrelated child's events.
350
- for (const [toolCallId, subagent] of this.subagents) {
351
- if (subagent.kind !== 'tool')
352
- continue;
353
- if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
354
- return establish(toolCallId);
355
- }
364
+ // Last-resort fallback — tool children only, and only when there is
365
+ // nothing to guess between.
366
+ //
367
+ // LangGraph's `tools:<uuid>` namespace is a checkpoint id assigned
368
+ // independently of the parent's `call_*` tool-call id; the two are not
369
+ // linked anywhere on the wire (verified against a live run). So when a
370
+ // delegation tool carries no matchable description, position is the only
371
+ // signal left.
372
+ //
373
+ // That is sound with exactly one outstanding child — the shape every graph
374
+ // in this repo produces, since each dispatches one tool call per assistant
375
+ // turn. With several outstanding at once (parallel fan-out) arrival order
376
+ // is NOT dispatch order, and claiming the first unmapped call cross-wires
377
+ // the children: one card renders another's output. Leaving the stream
378
+ // unattributed keeps its messages buffered instead, so an empty card is
379
+ // the worst case rather than a confidently wrong one. It can still resolve
380
+ // later: as siblings complete, the candidate set shrinks back to one.
381
+ const candidates = [...this.subagents].filter(([toolCallId, subagent]) => subagent.kind === 'tool' &&
382
+ !mapped.has(toolCallId) &&
383
+ (subagent.status === 'pending' || subagent.status === 'running'));
384
+ if (candidates.length === 1) {
385
+ return establish(candidates[0][0]);
356
386
  }
357
387
  if (description) {
358
388
  this.pendingMatches.set(namespaceId, description);
@@ -377,6 +407,27 @@ class SubagentTracker {
377
407
  });
378
408
  this.onSubagentChange?.();
379
409
  }
410
+ /**
411
+ * Attribute a `tools:` child stream to its parent tool call as soon as the
412
+ * child is seen, without requiring a description to match on.
413
+ *
414
+ * The description ladder needs two things this repo's own graphs don't
415
+ * reliably provide: a delegation tool that names its argument `description`,
416
+ * and a child whose first message is the human task. `cockpit/chat/subagents`
417
+ * has neither — it uses `task_description`, and its child's messages begin
418
+ * with the AI reply. Attribution therefore never ran, so the child's
419
+ * transcript was never claimed and every card rendered "0 message(s)".
420
+ *
421
+ * Calling the ladder with no description skips both description rungs and
422
+ * lands on the positional fallback (first unmapped pending/running tool
423
+ * child), which is correct for sequential dispatch and is the same heuristic
424
+ * the ladder already relied on in practice.
425
+ */
426
+ ensureToolStreamAttribution(namespaceId) {
427
+ if (this.namespaceToToolCallId.has(namespaceId))
428
+ return;
429
+ this.matchSubgraphToSubagent(namespaceId, '');
430
+ }
380
431
  /**
381
432
  * Register a plain-subgraph child stream on its first namespaced event.
382
433
  *
@@ -432,8 +483,12 @@ class SubagentTracker {
432
483
  addMessageToSubagent(namespaceId, message) {
433
484
  const toolCallId = this.resolveToolCallId(namespaceId);
434
485
  const subagent = this.subagents.get(toolCallId);
435
- if (!subagent)
486
+ if (!subagent) {
487
+ // Not attributed yet — hold it rather than drop it. `establish()` will
488
+ // replay the buffer the moment this namespace is matched to a tool call.
489
+ this.unattributedMessages.set(namespaceId, mergeMessages$1(this.unattributedMessages.get(namespaceId) ?? [], [message]));
436
490
  return;
491
+ }
437
492
  this.subagents.set(toolCallId, {
438
493
  ...subagent,
439
494
  status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
@@ -558,7 +613,7 @@ function mergeMessages$1(existing, incoming) {
558
613
  const id = getMessageId(msg);
559
614
  const idx = id ? merged.findIndex(m => getMessageId(m) === id) : -1;
560
615
  if (idx >= 0) {
561
- merged[idx] = msg;
616
+ merged[idx] = accumulateChunk(merged[idx], msg);
562
617
  }
563
618
  else {
564
619
  merged.push(msg);
@@ -566,6 +621,57 @@ function mergeMessages$1(existing, incoming) {
566
621
  }
567
622
  return merged;
568
623
  }
624
+ /**
625
+ * Fold a streamed chunk into the message it belongs to.
626
+ *
627
+ * A child graph streams `AIMessageChunk`s that are *deltas* — a handful of
628
+ * characters each, hundreds per message. Replacing by id (the previous
629
+ * behavior) therefore kept only the final delta, so a fully attributed
630
+ * subagent still rendered a near-empty message. Snapshots, which carry the
631
+ * message-so-far, still replace.
632
+ *
633
+ * This mirrors the parent transcript's delta handling: append unconditionally
634
+ * rather than comparing text, because a prefix-style "dedupe" silently eats
635
+ * legitimate tokens that happen to repeat the accumulated prefix.
636
+ */
637
+ function accumulateChunk(existing, incoming) {
638
+ if (!isChunkMessage(incoming))
639
+ return incoming;
640
+ const previousText = extractText$1(existing['content']);
641
+ const incomingText = extractText$1(incoming['content']);
642
+ if (!incomingText)
643
+ return existing;
644
+ if (!previousText)
645
+ return incoming;
646
+ return { ...incoming, content: previousText + incomingText };
647
+ }
648
+ function isChunkMessage(message) {
649
+ const type = message['type'];
650
+ return typeof type === 'string' && type.endsWith('Chunk');
651
+ }
652
+ function extractText$1(content) {
653
+ if (typeof content === 'string')
654
+ return content;
655
+ if (!Array.isArray(content))
656
+ return '';
657
+ let out = '';
658
+ for (const block of content) {
659
+ if (typeof block === 'string') {
660
+ out += block;
661
+ continue;
662
+ }
663
+ if (block == null || typeof block !== 'object')
664
+ continue;
665
+ const record = block;
666
+ const blockType = record['type'];
667
+ if (blockType === 'text' || blockType === 'output_text' || blockType === undefined) {
668
+ const text = record['text'];
669
+ if (typeof text === 'string')
670
+ out += text;
671
+ }
672
+ }
673
+ return out;
674
+ }
569
675
  function getMessageId(message) {
570
676
  return message.id;
571
677
  }
@@ -1208,6 +1314,10 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1208
1314
  if (child.kind === 'subgraph') {
1209
1315
  subagentManager.ensureSubgraphStream(child.key, child.name);
1210
1316
  }
1317
+ else {
1318
+ // Claim the stream for its tool call before its tokens arrive.
1319
+ subagentManager.ensureToolStreamAttribution(child.key);
1320
+ }
1211
1321
  for (const msg of normalized) {
1212
1322
  subagentManager.addMessageToSubagent(child.key, msg);
1213
1323
  }
@@ -1410,14 +1520,16 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1410
1520
  if (!child)
1411
1521
  return;
1412
1522
  if (child.kind === 'tool') {
1413
- // Attribution ladder applies to tool children only: their namespace id
1414
- // may need mapping onto a registered tool call.
1523
+ // Prefer the precise description match when the child's first message is
1524
+ // the human task; otherwise claim the stream positionally so a graph that
1525
+ // doesn't fit that shape still gets attributed.
1415
1526
  const messages = values['messages'];
1416
- if (Array.isArray(messages) && messages.length > 0) {
1417
- const first = messages[0];
1418
- if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1419
- subagentManager.matchSubgraphToSubagent(child.key, first['content']);
1420
- }
1527
+ const first = Array.isArray(messages) && messages.length > 0 ? messages[0] : undefined;
1528
+ if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1529
+ subagentManager.matchSubgraphToSubagent(child.key, first['content']);
1530
+ }
1531
+ else {
1532
+ subagentManager.ensureToolStreamAttribution(child.key);
1421
1533
  }
1422
1534
  }
1423
1535
  else {