@librechat/agents 3.3.4 → 3.3.5
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.
- package/dist/cjs/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +456 -7
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +268 -3
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +17 -3
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/format.cjs +72 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +3 -0
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +28 -7
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +3 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +455 -6
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +270 -6
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +8 -5
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/format.mjs +72 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +3 -0
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +27 -8
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +3 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +138 -1
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +7 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +0 -25
- package/package.json +1 -1
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +568 -3
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
- package/src/llm/bedrock/utils/message_inputs.ts +14 -11
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.ts +417 -8
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/format.ts +91 -0
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +36 -8
- package/src/tools/ToolNode.ts +3 -31
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +0 -41
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { TPayload } from '@/types';
|
|
3
|
+
import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
|
|
3
4
|
import { ContentTypes, Constants, Providers } from '@/common';
|
|
4
5
|
import { formatAgentMessages } from './format';
|
|
5
6
|
|
|
@@ -324,3 +325,269 @@ describe('formatAgentMessages steer replay', () => {
|
|
|
324
325
|
]);
|
|
325
326
|
});
|
|
326
327
|
});
|
|
328
|
+
|
|
329
|
+
describe('formatAgentMessages trailing-steer anchor', () => {
|
|
330
|
+
const steerPart = (text: string): Record<string, unknown> =>
|
|
331
|
+
({
|
|
332
|
+
type: ContentTypes.STEER,
|
|
333
|
+
[ContentTypes.STEER]: text,
|
|
334
|
+
}) as unknown as Record<string, unknown>;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A steer that ends an assistant message leaves the replay on a
|
|
338
|
+
* HumanMessage. Followed by the next turn's user message, that reaches the
|
|
339
|
+
* provider as two adjacent user turns — a hard error on Bedrock and
|
|
340
|
+
* Mistral. Reachable today through abort, not only through preemption.
|
|
341
|
+
*/
|
|
342
|
+
it('anchors a trailing steer when another turn follows', () => {
|
|
343
|
+
const payload: TPayload = [
|
|
344
|
+
{ role: 'user', content: 'Original request' },
|
|
345
|
+
{
|
|
346
|
+
role: 'assistant',
|
|
347
|
+
content: [
|
|
348
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
|
|
349
|
+
steerPart('Actually, stop and do the other thing.'),
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
{ role: 'user', content: 'Next turn' },
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const { messages } = formatAgentMessages(payload);
|
|
356
|
+
|
|
357
|
+
const steerIndex = messages.findIndex(
|
|
358
|
+
(message) =>
|
|
359
|
+
message instanceof HumanMessage &&
|
|
360
|
+
message.additional_kwargs.source === 'steer'
|
|
361
|
+
);
|
|
362
|
+
expect(steerIndex).toBeGreaterThan(0);
|
|
363
|
+
|
|
364
|
+
const anchor = messages[steerIndex + 1];
|
|
365
|
+
expect(anchor).toBeInstanceOf(AIMessage);
|
|
366
|
+
expect(anchor.content).not.toBe('');
|
|
367
|
+
|
|
368
|
+
const following = messages[steerIndex + 2];
|
|
369
|
+
expect(following).toBeInstanceOf(HumanMessage);
|
|
370
|
+
expect(following.content).toEqual([{ type: 'text', text: 'Next turn' }]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `messagesStateReducer` treats a repeated id as replace-in-place, so an
|
|
375
|
+
* anchor stamped with the shared `sourceMessageId` would overwrite the very
|
|
376
|
+
* steer it exists to protect. It must reach the reducer unstamped.
|
|
377
|
+
*/
|
|
378
|
+
it('does not stamp the anchor with the shared source message id', () => {
|
|
379
|
+
const payload: TPayload = [
|
|
380
|
+
{ role: 'user', content: 'Original request' },
|
|
381
|
+
{
|
|
382
|
+
messageId: 'assistant_1',
|
|
383
|
+
role: 'assistant',
|
|
384
|
+
content: [
|
|
385
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
|
|
386
|
+
steerPart('Change of plan.'),
|
|
387
|
+
],
|
|
388
|
+
} as unknown as TPayload[number],
|
|
389
|
+
{ role: 'user', content: 'Next turn' },
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
const { messages } = formatAgentMessages(payload);
|
|
393
|
+
|
|
394
|
+
const steerIndex = messages.findIndex(
|
|
395
|
+
(message) => message.additional_kwargs.source === 'steer'
|
|
396
|
+
);
|
|
397
|
+
expect(steerIndex).toBeGreaterThan(0);
|
|
398
|
+
expect(messages[steerIndex].id).toBe('assistant_1');
|
|
399
|
+
expect(messages[steerIndex - 1].id).toBe('assistant_1');
|
|
400
|
+
|
|
401
|
+
const anchor = messages[steerIndex + 1];
|
|
402
|
+
expect(anchor).toBeInstanceOf(AIMessage);
|
|
403
|
+
expect(anchor.content).not.toBe('');
|
|
404
|
+
expect(anchor.id).not.toBe('assistant_1');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The anchor exists to keep the sequence provider-valid, so the assertion
|
|
409
|
+
* that matters is what a provider actually receives. An empty-string
|
|
410
|
+
* assistant turn with no tool calls survives the Anthropic converter
|
|
411
|
+
* verbatim — the empty-text repair covers only array content and tool-call
|
|
412
|
+
* turns — so an empty anchor would trade adjacent user turns for an
|
|
413
|
+
* empty-content 400.
|
|
414
|
+
*/
|
|
415
|
+
it('survives the Anthropic converter with non-empty assistant content', () => {
|
|
416
|
+
const payload: TPayload = [
|
|
417
|
+
{ role: 'user', content: 'Original request' },
|
|
418
|
+
{
|
|
419
|
+
role: 'assistant',
|
|
420
|
+
content: [
|
|
421
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
|
|
422
|
+
steerPart('Change of plan.'),
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
{ role: 'user', content: 'Next turn' },
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
const { messages } = formatAgentMessages(payload);
|
|
429
|
+
const { messages: anthropicMessages } =
|
|
430
|
+
_convertMessagesToAnthropicPayload(messages);
|
|
431
|
+
|
|
432
|
+
for (const message of anthropicMessages) {
|
|
433
|
+
if (message.role !== 'assistant') {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (typeof message.content === 'string') {
|
|
437
|
+
expect(message.content.trim()).not.toBe('');
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
expect(message.content.length).toBeGreaterThan(0);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const roles = anthropicMessages.map((message) => message.role);
|
|
444
|
+
for (let i = 1; i < roles.length; i++) {
|
|
445
|
+
expect(roles[i] === 'user' && roles[i - 1] === 'user').toBe(false);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The old lookahead only proved a later ENTRY existed, not that it emitted.
|
|
451
|
+
* An entry with empty content is skipped silently, so a trailing steer
|
|
452
|
+
* followed only by such entries would leave the anchor as the final turn —
|
|
453
|
+
* an assistant prefill with no request after it.
|
|
454
|
+
*/
|
|
455
|
+
it('omits the anchor when no later payload entry emits a message', () => {
|
|
456
|
+
const payload: TPayload = [
|
|
457
|
+
{ role: 'user', content: 'Original request' },
|
|
458
|
+
{
|
|
459
|
+
role: 'assistant',
|
|
460
|
+
content: [
|
|
461
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
|
|
462
|
+
steerPart('Change of plan.'),
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
{ role: 'user', content: [] } as unknown as TPayload[number],
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
const { messages } = formatAgentMessages(payload);
|
|
469
|
+
const last = messages[messages.length - 1];
|
|
470
|
+
expect(last.additional_kwargs.source).toBe('steer');
|
|
471
|
+
expect(
|
|
472
|
+
messages.some(
|
|
473
|
+
(m) => m instanceof AIMessage && m.content === '_'
|
|
474
|
+
)
|
|
475
|
+
).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('leaves the user turn last when the steer ends the payload', () => {
|
|
479
|
+
const payload: TPayload = [
|
|
480
|
+
{ role: 'user', content: 'Original request' },
|
|
481
|
+
{
|
|
482
|
+
role: 'assistant',
|
|
483
|
+
content: [
|
|
484
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
|
|
485
|
+
steerPart('Actually, stop and do the other thing.'),
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
|
|
490
|
+
const { messages } = formatAgentMessages(payload);
|
|
491
|
+
|
|
492
|
+
const last = messages[messages.length - 1];
|
|
493
|
+
expect(last).toBeInstanceOf(HumanMessage);
|
|
494
|
+
expect(last.additional_kwargs.source).toBe('steer');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('does not anchor when assistant content follows the steer', () => {
|
|
498
|
+
const payload: TPayload = [
|
|
499
|
+
{ role: 'user', content: 'Original request' },
|
|
500
|
+
{
|
|
501
|
+
role: 'assistant',
|
|
502
|
+
content: [
|
|
503
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
|
|
504
|
+
steerPart('Focus on item two.'),
|
|
505
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Focusing.' },
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
{ role: 'user', content: 'Next turn' },
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
const { messages } = formatAgentMessages(payload);
|
|
512
|
+
|
|
513
|
+
const emptyAssistants = messages.filter(
|
|
514
|
+
(message) => message instanceof AIMessage && message.content === ''
|
|
515
|
+
);
|
|
516
|
+
expect(emptyAssistants).toHaveLength(0);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A trailing steer followed by another assistant ENTRY needs no anchor —
|
|
521
|
+
* that entry's own assistant turn IS the separation. Emitting the
|
|
522
|
+
* placeholder anyway would put two assistant turns back to back, which
|
|
523
|
+
* strict-alternation providers can reject and nothing downstream merges
|
|
524
|
+
* (`coalesceAdjacentUserTurns` merges user turns only).
|
|
525
|
+
*/
|
|
526
|
+
it('suppresses the anchor when the next payload entry is an assistant turn', () => {
|
|
527
|
+
const payload: TPayload = [
|
|
528
|
+
{ role: 'user', content: 'Original request' },
|
|
529
|
+
{
|
|
530
|
+
role: 'assistant',
|
|
531
|
+
content: [
|
|
532
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Working on it.' },
|
|
533
|
+
steerPart('Actually, do the other thing.'),
|
|
534
|
+
],
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
role: 'assistant',
|
|
538
|
+
content: [
|
|
539
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Doing the other thing.' },
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
const { messages } = formatAgentMessages(payload);
|
|
545
|
+
|
|
546
|
+
expect(messages.map((message) => message.constructor.name)).toEqual([
|
|
547
|
+
'HumanMessage',
|
|
548
|
+
'AIMessage',
|
|
549
|
+
'HumanMessage',
|
|
550
|
+
'AIMessage',
|
|
551
|
+
]);
|
|
552
|
+
expect(messages[2].additional_kwargs.source).toBe('steer');
|
|
553
|
+
expect(messages[3].content).toEqual([
|
|
554
|
+
{ type: 'text', text: 'Doing the other thing.' },
|
|
555
|
+
]);
|
|
556
|
+
expect(
|
|
557
|
+
messages.some(
|
|
558
|
+
(message) => message instanceof AIMessage && message.content === '_'
|
|
559
|
+
)
|
|
560
|
+
).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('never emits two adjacent user turns around a trailing steer', () => {
|
|
564
|
+
const payload: TPayload = [
|
|
565
|
+
{ role: 'user', content: 'Original request' },
|
|
566
|
+
{
|
|
567
|
+
role: 'assistant',
|
|
568
|
+
content: [
|
|
569
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Partial answer.' },
|
|
570
|
+
steerPart('Change of plan.'),
|
|
571
|
+
],
|
|
572
|
+
},
|
|
573
|
+
{ role: 'user', content: 'Next turn' },
|
|
574
|
+
{
|
|
575
|
+
role: 'assistant',
|
|
576
|
+
content: [
|
|
577
|
+
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second answer.' },
|
|
578
|
+
steerPart('Change again.'),
|
|
579
|
+
],
|
|
580
|
+
},
|
|
581
|
+
{ role: 'user', content: 'Third turn' },
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
const { messages } = formatAgentMessages(payload);
|
|
585
|
+
|
|
586
|
+
for (let i = 1; i < messages.length; i++) {
|
|
587
|
+
const adjacentHumans =
|
|
588
|
+
messages[i] instanceof HumanMessage &&
|
|
589
|
+
messages[i - 1] instanceof HumanMessage;
|
|
590
|
+
expect(adjacentHumans).toBe(false);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import {
|
|
4
|
+
appendPredecessorHandoffCue,
|
|
5
|
+
removePredecessorHandoffCue,
|
|
6
|
+
PREDECESSOR_HANDOFF_CUE,
|
|
7
|
+
} from './handoffCue';
|
|
8
|
+
|
|
9
|
+
const runAi = new AIMessage({ content: 'predecessor output', id: 'run-ai-1' });
|
|
10
|
+
const producedIds = new Set(['run-ai-1']);
|
|
11
|
+
const isRunProduced = (message: BaseMessage): boolean =>
|
|
12
|
+
message.id != null && producedIds.has(message.id);
|
|
13
|
+
|
|
14
|
+
describe('appendPredecessorHandoffCue', () => {
|
|
15
|
+
it('appends the cue when the payload ends with a run-produced assistant turn', () => {
|
|
16
|
+
const payload = [new HumanMessage('ask'), runAi];
|
|
17
|
+
const result = appendPredecessorHandoffCue(payload, isRunProduced);
|
|
18
|
+
expect(result).toHaveLength(3);
|
|
19
|
+
expect(result[2].content).toBe(PREDECESSOR_HANDOFF_CUE);
|
|
20
|
+
expect(result[2].additional_kwargs).toEqual({
|
|
21
|
+
role: 'user',
|
|
22
|
+
isMeta: true,
|
|
23
|
+
source: 'handoff',
|
|
24
|
+
});
|
|
25
|
+
/** Non-mutating: the input array is untouched. */
|
|
26
|
+
expect(payload).toHaveLength(2);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('matches a transformed clone of the run tail by id', () => {
|
|
30
|
+
const clone = new AIMessage({ content: 'projected copy', id: 'run-ai-1' });
|
|
31
|
+
expect(appendPredecessorHandoffCue([clone], isRunProduced)).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Host-supplied trailing assistant turns are deliberate prefill (continue
|
|
36
|
+
* generation flows). The run has not produced them, so provenance never
|
|
37
|
+
* matches and the cue must stay off.
|
|
38
|
+
*/
|
|
39
|
+
it('leaves host-history prefill payloads alone', () => {
|
|
40
|
+
const hostAi = new AIMessage({ content: 'host prefill', id: 'host-ai-9' });
|
|
41
|
+
expect(appendPredecessorHandoffCue([hostAi], isRunProduced)).toHaveLength(
|
|
42
|
+
1
|
|
43
|
+
);
|
|
44
|
+
expect(appendPredecessorHandoffCue([hostAi], undefined)).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is off when the payload does not end on an assistant turn', () => {
|
|
48
|
+
const tool = new ToolMessage({ content: 'r', tool_call_id: 't1' });
|
|
49
|
+
expect(
|
|
50
|
+
appendPredecessorHandoffCue([runAi, tool], isRunProduced)
|
|
51
|
+
).toHaveLength(2);
|
|
52
|
+
expect(
|
|
53
|
+
appendPredecessorHandoffCue([new HumanMessage('hi')], isRunProduced)
|
|
54
|
+
).toHaveLength(1);
|
|
55
|
+
expect(appendPredecessorHandoffCue([], isRunProduced)).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('fails safe when the tail carries no id', () => {
|
|
59
|
+
const noId = new AIMessage({ content: 'no id' });
|
|
60
|
+
expect(appendPredecessorHandoffCue([noId], isRunProduced)).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('removePredecessorHandoffCue', () => {
|
|
65
|
+
const cue = new HumanMessage({
|
|
66
|
+
content: PREDECESSOR_HANDOFF_CUE,
|
|
67
|
+
additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('strips a trailing cue and only a trailing cue', () => {
|
|
71
|
+
const stripped = removePredecessorHandoffCue([runAi, cue]);
|
|
72
|
+
expect(stripped).toHaveLength(1);
|
|
73
|
+
expect(stripped[0]).toBe(runAi);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('is identity when no cue trails', () => {
|
|
77
|
+
const messages = [runAi];
|
|
78
|
+
expect(removePredecessorHandoffCue(messages)).toBe(messages);
|
|
79
|
+
const userTail = [runAi, new HumanMessage('real user turn')];
|
|
80
|
+
expect(removePredecessorHandoffCue(userTail)).toBe(userTail);
|
|
81
|
+
const empty: BaseMessage[] = [];
|
|
82
|
+
expect(removePredecessorHandoffCue(empty)).toBe(empty);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not strip a user turn that merely repeats the cue text', () => {
|
|
86
|
+
const impostor = new HumanMessage({ content: PREDECESSOR_HANDOFF_CUE });
|
|
87
|
+
const messages = [runAi, impostor];
|
|
88
|
+
expect(removePredecessorHandoffCue(messages)).toBe(messages);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('round-trips with append', () => {
|
|
92
|
+
const payload = [new HumanMessage('ask'), runAi];
|
|
93
|
+
const appended = appendPredecessorHandoffCue(payload, isRunProduced);
|
|
94
|
+
expect(removePredecessorHandoffCue(appended)).toEqual(payload);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/messages/handoffCue.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bracketed-meta convention, like the handoff path's
|
|
7
|
+
* `[Processed tool result and transferring to …]` bridge. The wording makes
|
|
8
|
+
* two things unambiguous to the model: the assistant turn above is FINISHED,
|
|
9
|
+
* and it belongs to a previous stage — so the successor answers as itself
|
|
10
|
+
* instead of continuing someone else's sentence.
|
|
11
|
+
*/
|
|
12
|
+
export const PREDECESSOR_HANDOFF_CUE =
|
|
13
|
+
'[The assistant message above is the completed output of a previous ' +
|
|
14
|
+
'agent. Respond now according to your own role and instructions.]';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Appends a user-turn handoff cue when a payload ends with an assistant turn
|
|
18
|
+
* that THIS RUN produced — which only happens when a different agent in a
|
|
19
|
+
* multi-agent workflow produced it (an agent's own self-loops always re-enter
|
|
20
|
+
* on a tool result or an injected user turn).
|
|
21
|
+
*
|
|
22
|
+
* Why: providers with prefill semantics (Anthropic, Bedrock-Claude) treat a
|
|
23
|
+
* trailing assistant message as a prefill and CONTINUE it. A bare direct-edge
|
|
24
|
+
* successor therefore speaks in its predecessor's voice — or, when the
|
|
25
|
+
* trailing turn reads complete (a preemption steer's short resume, say),
|
|
26
|
+
* returns empty content (danny-avila/agents#345, reproduced live 3/3).
|
|
27
|
+
* Handoff edges with instructions and prompt-instruction edges already break
|
|
28
|
+
* the prefill with a user turn; this closes the same gap for bare edges.
|
|
29
|
+
*
|
|
30
|
+
* Fail-safe OFF by provenance: the trailing payload message must be one the
|
|
31
|
+
* run itself produced (`isRunProduced`, backed by the graph's run-produced id
|
|
32
|
+
* set — immune to summarization compaction, which rewrites the live array
|
|
33
|
+
* and stales index-based boundaries). Host-supplied trailing assistant
|
|
34
|
+
* turns (deliberate prefill flows) never match — the run has not produced
|
|
35
|
+
* them — so single-agent prefill behavior is untouched. Wire-only: the cue is
|
|
36
|
+
* appended to the provider projection, never to graph state or host history.
|
|
37
|
+
*/
|
|
38
|
+
export function appendPredecessorHandoffCue(
|
|
39
|
+
messages: BaseMessage[],
|
|
40
|
+
isRunProduced: ((message: BaseMessage) => boolean) | undefined
|
|
41
|
+
): BaseMessage[] {
|
|
42
|
+
const last = messages.at(-1);
|
|
43
|
+
if (last == null || last.getType() !== 'ai') {
|
|
44
|
+
return messages;
|
|
45
|
+
}
|
|
46
|
+
if (isRunProduced == null || !isRunProduced(last)) {
|
|
47
|
+
return messages;
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
...messages,
|
|
51
|
+
new HumanMessage({
|
|
52
|
+
content: PREDECESSOR_HANDOFF_CUE,
|
|
53
|
+
additional_kwargs: { role: 'user', isMeta: true, source: 'handoff' },
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strips a trailing handoff cue. The counterpart for the serving-provider
|
|
60
|
+
* funnel: an Anthropic-like PRIMARY bakes the cue into its measured payload,
|
|
61
|
+
* and a tolerant fallback (OpenAI, Mistral, Bedrock-Nova) re-sending that
|
|
62
|
+
* payload must not ship the Claude-only synthetic turn. Identity on the
|
|
63
|
+
* no-op path.
|
|
64
|
+
*/
|
|
65
|
+
export function removePredecessorHandoffCue(
|
|
66
|
+
messages: BaseMessage[]
|
|
67
|
+
): BaseMessage[] {
|
|
68
|
+
const last = messages.at(-1);
|
|
69
|
+
if (
|
|
70
|
+
last == null ||
|
|
71
|
+
last.getType() !== 'human' ||
|
|
72
|
+
last.additional_kwargs.source !== 'handoff' ||
|
|
73
|
+
last.content !== PREDECESSOR_HANDOFF_CUE
|
|
74
|
+
) {
|
|
75
|
+
return messages;
|
|
76
|
+
}
|
|
77
|
+
return messages.slice(0, -1);
|
|
78
|
+
}
|
package/src/messages/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ export * from './cache';
|
|
|
7
7
|
export * from './anthropicToolCache';
|
|
8
8
|
export * from './content';
|
|
9
9
|
export * from './tools';
|
|
10
|
+
export * from './injected';
|
|
11
|
+
export * from './alternation';
|
|
12
|
+
export * from './handoffCue';
|
|
10
13
|
export * from './contextPruning';
|
|
11
14
|
export * from './contextPruningSettings';
|
|
12
15
|
export * from './reducer';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { InjectedMessage } from '@/types/tools';
|
|
3
|
+
import { convertInjectedMessages } from './injected';
|
|
4
|
+
|
|
5
|
+
describe('convertInjectedMessages', () => {
|
|
6
|
+
it('returns an empty array for no entries', () => {
|
|
7
|
+
expect(convertInjectedMessages([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('emits one HumanMessage per entry, in order', () => {
|
|
11
|
+
const messages: InjectedMessage[] = [
|
|
12
|
+
{ role: 'user', content: 'first' },
|
|
13
|
+
{ role: 'user', content: 'second' },
|
|
14
|
+
];
|
|
15
|
+
const converted = convertInjectedMessages(messages);
|
|
16
|
+
expect(converted).toHaveLength(2);
|
|
17
|
+
expect(converted[0]).toBeInstanceOf(HumanMessage);
|
|
18
|
+
expect(converted[0].content).toBe('first');
|
|
19
|
+
expect(converted[1].content).toBe('second');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Both 'user' and 'system' become `HumanMessage` — Anthropic and Google
|
|
24
|
+
* reject a non-leading SystemMessage — with the original role preserved for
|
|
25
|
+
* downstream consumers.
|
|
26
|
+
*/
|
|
27
|
+
it('converts a system role to a HumanMessage carrying the original role', () => {
|
|
28
|
+
const [converted] = convertInjectedMessages([
|
|
29
|
+
{ role: 'system', content: 'convention reminder' },
|
|
30
|
+
]);
|
|
31
|
+
expect(converted).toBeInstanceOf(HumanMessage);
|
|
32
|
+
expect(converted.additional_kwargs.role).toBe('system');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('carries isMeta, source and skillName only when set', () => {
|
|
36
|
+
const [bare] = convertInjectedMessages([{ role: 'user', content: 'x' }]);
|
|
37
|
+
expect(bare.additional_kwargs).toEqual({ role: 'user' });
|
|
38
|
+
|
|
39
|
+
const [full] = convertInjectedMessages([
|
|
40
|
+
{
|
|
41
|
+
role: 'user',
|
|
42
|
+
content: 'x',
|
|
43
|
+
isMeta: true,
|
|
44
|
+
source: 'steer',
|
|
45
|
+
skillName: 'writing',
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
expect(full.additional_kwargs).toEqual({
|
|
49
|
+
role: 'user',
|
|
50
|
+
isMeta: true,
|
|
51
|
+
source: 'steer',
|
|
52
|
+
skillName: 'writing',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('passes multimodal content through as a content array', () => {
|
|
57
|
+
const [converted] = convertInjectedMessages([
|
|
58
|
+
{
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: [
|
|
61
|
+
{ type: 'text', text: 'look at this' },
|
|
62
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
expect(Array.isArray(converted.content)).toBe(true);
|
|
67
|
+
expect(converted.content).toHaveLength(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The provider-safety argument for sealing a stream mid-generation rests on
|
|
72
|
+
* the preempt boundary emitting exactly what the already-shipped tool
|
|
73
|
+
* boundary emits. Both call this function, so shape parity is structural —
|
|
74
|
+
* this pins it against a future divergence.
|
|
75
|
+
*/
|
|
76
|
+
it('produces identical shapes for the same entry across calls', () => {
|
|
77
|
+
const entry: InjectedMessage = {
|
|
78
|
+
role: 'user',
|
|
79
|
+
content: 'Skip phase two.',
|
|
80
|
+
source: 'steer',
|
|
81
|
+
};
|
|
82
|
+
const [atToolBoundary] = convertInjectedMessages([entry]);
|
|
83
|
+
const [atPreemptBoundary] = convertInjectedMessages([entry]);
|
|
84
|
+
expect(atPreemptBoundary.content).toEqual(atToolBoundary.content);
|
|
85
|
+
expect(atPreemptBoundary.additional_kwargs).toEqual(
|
|
86
|
+
atToolBoundary.additional_kwargs
|
|
87
|
+
);
|
|
88
|
+
expect(atPreemptBoundary.getType()).toBe(atToolBoundary.getType());
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// src/messages/injected.ts
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
|
+
import type { InjectedMessage } from '@/types/tools';
|
|
5
|
+
import { ContentTypes } from '@/common';
|
|
6
|
+
import { toLangChainContent } from './langchain';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts `InjectedMessage` instances to LangChain `HumanMessage` objects.
|
|
10
|
+
* Both 'user' and 'system' roles become `HumanMessage` to avoid provider
|
|
11
|
+
* rejections (Anthropic/Google reject non-leading SystemMessages). The
|
|
12
|
+
* original role is preserved in `additional_kwargs` for downstream consumers.
|
|
13
|
+
*
|
|
14
|
+
* Shared by both injection boundaries — `ToolNode`'s tool-batch dispatch and
|
|
15
|
+
* `StandardGraph`'s preempt-boundary dispatch. Keeping one implementation is
|
|
16
|
+
* load-bearing rather than tidy: the provider-safety argument for sealing a
|
|
17
|
+
* stream mid-generation rests on the injected turn having byte-identical
|
|
18
|
+
* shape to the one the already-shipped tool boundary emits, so the two sites
|
|
19
|
+
* must not be able to drift.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* True when an entry carries nothing a provider will accept as a turn.
|
|
23
|
+
*
|
|
24
|
+
* The public `InjectedMessage` type admits `content: ''` and `content: []`,
|
|
25
|
+
* and a host hook is free to return one. Converting it anyway produces a
|
|
26
|
+
* `HumanMessage` that passes the caller's `length > 0` test, so the graph
|
|
27
|
+
* resumes and sends a trailing EMPTY user turn — which Anthropic and other
|
|
28
|
+
* strict providers reject outright, turning a cooperative seal into a failed
|
|
29
|
+
* run. Permissive providers merely burn a model call.
|
|
30
|
+
*
|
|
31
|
+
* Whitespace counts as empty, matching the standard `canSealPreempt` applies
|
|
32
|
+
* to the assistant side of the same pair. Non-text blocks count as content: a
|
|
33
|
+
* media-only steer is a real turn and must survive.
|
|
34
|
+
*/
|
|
35
|
+
function isEmptyInjectedContent(content: InjectedMessage['content']): boolean {
|
|
36
|
+
if (typeof content === 'string') {
|
|
37
|
+
return content.trim() === '';
|
|
38
|
+
}
|
|
39
|
+
if (content.length === 0) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return content.every((block) => {
|
|
43
|
+
if (block.type !== ContentTypes.TEXT) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const text = block[ContentTypes.TEXT];
|
|
47
|
+
return typeof text !== 'string' || text.trim() === '';
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function convertInjectedMessages(
|
|
52
|
+
messages: InjectedMessage[]
|
|
53
|
+
): BaseMessage[] {
|
|
54
|
+
const converted: BaseMessage[] = [];
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
if (isEmptyInjectedContent(msg.content)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const additional_kwargs: Record<string, unknown> = {
|
|
60
|
+
role: msg.role,
|
|
61
|
+
};
|
|
62
|
+
if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
|
|
63
|
+
if (msg.source != null) additional_kwargs.source = msg.source;
|
|
64
|
+
if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
|
|
65
|
+
|
|
66
|
+
converted.push(
|
|
67
|
+
new HumanMessage({
|
|
68
|
+
content: toLangChainContent(msg.content),
|
|
69
|
+
additional_kwargs,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return converted;
|
|
74
|
+
}
|