@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
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
//
|
|
1414
|
-
//
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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 {
|