@threadplane/langgraph 0.0.59 → 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() {
@@ -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;
@@ -377,6 +393,27 @@ class SubagentTracker {
377
393
  });
378
394
  this.onSubagentChange?.();
379
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
+ }
380
417
  /**
381
418
  * Register a plain-subgraph child stream on its first namespaced event.
382
419
  *
@@ -432,8 +469,12 @@ class SubagentTracker {
432
469
  addMessageToSubagent(namespaceId, message) {
433
470
  const toolCallId = this.resolveToolCallId(namespaceId);
434
471
  const subagent = this.subagents.get(toolCallId);
435
- 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]));
436
476
  return;
477
+ }
437
478
  this.subagents.set(toolCallId, {
438
479
  ...subagent,
439
480
  status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
@@ -558,7 +599,7 @@ function mergeMessages$1(existing, incoming) {
558
599
  const id = getMessageId(msg);
559
600
  const idx = id ? merged.findIndex(m => getMessageId(m) === id) : -1;
560
601
  if (idx >= 0) {
561
- merged[idx] = msg;
602
+ merged[idx] = accumulateChunk(merged[idx], msg);
562
603
  }
563
604
  else {
564
605
  merged.push(msg);
@@ -566,6 +607,57 @@ function mergeMessages$1(existing, incoming) {
566
607
  }
567
608
  return merged;
568
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
+ }
569
661
  function getMessageId(message) {
570
662
  return message.id;
571
663
  }
@@ -1208,6 +1300,10 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1208
1300
  if (child.kind === 'subgraph') {
1209
1301
  subagentManager.ensureSubgraphStream(child.key, child.name);
1210
1302
  }
1303
+ else {
1304
+ // Claim the stream for its tool call before its tokens arrive.
1305
+ subagentManager.ensureToolStreamAttribution(child.key);
1306
+ }
1211
1307
  for (const msg of normalized) {
1212
1308
  subagentManager.addMessageToSubagent(child.key, msg);
1213
1309
  }
@@ -1410,14 +1506,16 @@ function createStreamManagerBridge({ options, subjects, threadId$, destroy$ }) {
1410
1506
  if (!child)
1411
1507
  return;
1412
1508
  if (child.kind === 'tool') {
1413
- // Attribution ladder applies to tool children only: their namespace id
1414
- // may need mapping onto a registered tool call.
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.
1415
1512
  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
- }
1513
+ const first = Array.isArray(messages) && messages.length > 0 ? messages[0] : undefined;
1514
+ if (isRecord$1(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') {
1515
+ subagentManager.matchSubgraphToSubagent(child.key, first['content']);
1516
+ }
1517
+ else {
1518
+ subagentManager.ensureToolStreamAttribution(child.key);
1421
1519
  }
1422
1520
  }
1423
1521
  else {