@librechat/agents 3.2.61 → 3.2.63
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/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +6 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +32 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +6 -0
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +62 -1
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +266 -43
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +274 -7
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
- package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
- package/dist/cjs/llm/openrouter/index.cjs +5 -6
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -1
- package/dist/cjs/messages/format.cjs +61 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -19
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/stream.cjs +13 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +76 -11
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +2 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +111 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +6 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +32 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +6 -0
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +62 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +266 -43
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +274 -7
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
- package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
- package/dist/esm/llm/openrouter/index.mjs +5 -6
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/format.mjs +61 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -19
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/stream.mjs +13 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +76 -11
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +2 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +108 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +3 -1
- package/dist/types/graphs/Graph.d.ts +3 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -0
- package/dist/types/hooks/index.d.ts +7 -0
- package/dist/types/hooks/types.d.ts +18 -1
- package/dist/types/llm/bedrock/index.d.ts +5 -0
- package/dist/types/llm/openai/index.d.ts +19 -0
- package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
- package/dist/types/messages/prune.d.ts +4 -3
- package/dist/types/tools/ToolNode.d.ts +1 -0
- package/dist/types/types/graph.d.ts +10 -0
- package/dist/types/types/llm.d.ts +12 -2
- package/dist/types/types/tools.d.ts +1 -1
- package/dist/types/utils/tokens.d.ts +30 -0
- package/package.json +6 -6
- package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +12 -4
- package/src/hooks/HookRegistry.ts +45 -0
- package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
- package/src/hooks/__tests__/executeHooks.test.ts +85 -2
- package/src/hooks/executeHooks.ts +15 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/types.ts +18 -1
- package/src/langfuse.ts +134 -1
- package/src/llm/bedrock/index.ts +434 -83
- package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
- package/src/llm/custom-chat-models.smoke.test.ts +7 -0
- package/src/llm/openai/index.ts +604 -6
- package/src/llm/openai/managedRequests.test.ts +182 -0
- package/src/llm/openai/streamMetadata.spec.ts +86 -0
- package/src/llm/openai/streamMetadata.ts +95 -0
- package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
- package/src/llm/openrouter/index.ts +9 -5
- package/src/messages/format.ts +96 -3
- package/src/messages/formatAgentMessages.steer.test.ts +326 -0
- package/src/messages/labelContentByAgent.test.ts +75 -0
- package/src/messages/prune.ts +56 -30
- package/src/specs/anthropic.simple.test.ts +4 -2
- package/src/specs/cache.simple.test.ts +17 -4
- package/src/specs/langfuse-callbacks.test.ts +61 -0
- package/src/specs/openai.simple.test.ts +4 -2
- package/src/specs/spec.utils.ts +12 -0
- package/src/specs/summarization.test.ts +9 -13
- package/src/specs/token-accounting-pipeline.test.ts +130 -4
- package/src/specs/tokens.test.ts +214 -0
- package/src/stream.ts +22 -3
- package/src/tools/ToolNode.ts +112 -13
- package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
- package/src/tools/__tests__/hitl.test.ts +112 -0
- package/src/tools/__tests__/subagentHooks.test.ts +124 -0
- package/src/tools/subagent/SubagentExecutor.ts +3 -0
- package/src/types/graph.ts +10 -0
- package/src/types/llm.ts +12 -2
- package/src/types/tools.ts +1 -1
- package/src/utils/tokens.ts +181 -3
|
@@ -1566,6 +1566,118 @@ describe('ToolNode HITL — PostToolBatch hook', () => {
|
|
|
1566
1566
|
expect(injected).toBeDefined();
|
|
1567
1567
|
expect(String(injected!.content)).toContain('format the response as JSON');
|
|
1568
1568
|
});
|
|
1569
|
+
|
|
1570
|
+
it('PostToolBatch injectedMessages land as individual HumanMessages after the consolidated context', async () => {
|
|
1571
|
+
mockEventDispatch([
|
|
1572
|
+
{ toolCallId: 'call_1', content: 'ok', status: 'success' },
|
|
1573
|
+
]);
|
|
1574
|
+
|
|
1575
|
+
const registry = new HookRegistry();
|
|
1576
|
+
registry.register('PostToolBatch', {
|
|
1577
|
+
hooks: [
|
|
1578
|
+
async (): Promise<PostToolBatchHookOutput> => ({
|
|
1579
|
+
additionalContext: 'batch convention',
|
|
1580
|
+
injectedMessages: [
|
|
1581
|
+
{ role: 'user', content: 'steer one', source: 'steer' },
|
|
1582
|
+
{ role: 'user', content: 'steer two', source: 'steer' },
|
|
1583
|
+
],
|
|
1584
|
+
}),
|
|
1585
|
+
],
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
const node = new ToolNode({
|
|
1589
|
+
tools: [createSchemaStub('echo')],
|
|
1590
|
+
eventDrivenMode: true,
|
|
1591
|
+
agentId: 'agent-x',
|
|
1592
|
+
toolCallStepIds: new Map([['call_1', 'step_1']]),
|
|
1593
|
+
hookRegistry: registry,
|
|
1594
|
+
humanInTheLoop: { enabled: false },
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
const graph = buildHITLGraph(node, [
|
|
1598
|
+
{ id: 'call_1', name: 'echo', args: { command: 'a' } },
|
|
1599
|
+
]);
|
|
1600
|
+
const result = (await graph.invoke(
|
|
1601
|
+
{ messages: [] },
|
|
1602
|
+
{ configurable: { thread_id: 'batch-steer-thread' } }
|
|
1603
|
+
)) as { messages: BaseMessage[] };
|
|
1604
|
+
|
|
1605
|
+
type KwargMessage = {
|
|
1606
|
+
additional_kwargs?: { source?: string; role?: string };
|
|
1607
|
+
};
|
|
1608
|
+
const humanMessages = result.messages.filter(
|
|
1609
|
+
(m) => m._getType() === 'human'
|
|
1610
|
+
);
|
|
1611
|
+
const contextIndex = humanMessages.findIndex(
|
|
1612
|
+
(m) => (m as KwargMessage).additional_kwargs?.source === 'hook'
|
|
1613
|
+
);
|
|
1614
|
+
const steerMessages = humanMessages.filter(
|
|
1615
|
+
(m) => (m as KwargMessage).additional_kwargs?.source === 'steer'
|
|
1616
|
+
);
|
|
1617
|
+
|
|
1618
|
+
expect(contextIndex).toBeGreaterThanOrEqual(0);
|
|
1619
|
+
expect(steerMessages).toHaveLength(2);
|
|
1620
|
+
expect(String(steerMessages[0].content)).toBe('steer one');
|
|
1621
|
+
expect(String(steerMessages[1].content)).toBe('steer two');
|
|
1622
|
+
for (const steer of steerMessages) {
|
|
1623
|
+
expect((steer as KwargMessage).additional_kwargs?.role).toBe('user');
|
|
1624
|
+
expect(humanMessages.indexOf(steer)).toBeGreaterThan(contextIndex);
|
|
1625
|
+
}
|
|
1626
|
+
const toolIndex = result.messages.findIndex((m) => m._getType() === 'tool');
|
|
1627
|
+
const firstSteerIndex = result.messages.indexOf(steerMessages[0]);
|
|
1628
|
+
expect(firstSteerIndex).toBeGreaterThan(toolIndex);
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
it('PostToolBatch injectedMessages work without additionalContext', async () => {
|
|
1632
|
+
mockEventDispatch([
|
|
1633
|
+
{ toolCallId: 'call_1', content: 'ok', status: 'success' },
|
|
1634
|
+
]);
|
|
1635
|
+
|
|
1636
|
+
const registry = new HookRegistry();
|
|
1637
|
+
registry.register('PostToolBatch', {
|
|
1638
|
+
hooks: [
|
|
1639
|
+
async (): Promise<PostToolBatchHookOutput> => ({
|
|
1640
|
+
injectedMessages: [
|
|
1641
|
+
{ role: 'user', content: 'solo steer', source: 'steer' },
|
|
1642
|
+
],
|
|
1643
|
+
}),
|
|
1644
|
+
],
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
const node = new ToolNode({
|
|
1648
|
+
tools: [createSchemaStub('echo')],
|
|
1649
|
+
eventDrivenMode: true,
|
|
1650
|
+
agentId: 'agent-x',
|
|
1651
|
+
toolCallStepIds: new Map([['call_1', 'step_1']]),
|
|
1652
|
+
hookRegistry: registry,
|
|
1653
|
+
humanInTheLoop: { enabled: false },
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
const graph = buildHITLGraph(node, [
|
|
1657
|
+
{ id: 'call_1', name: 'echo', args: { command: 'a' } },
|
|
1658
|
+
]);
|
|
1659
|
+
const result = (await graph.invoke(
|
|
1660
|
+
{ messages: [] },
|
|
1661
|
+
{ configurable: { thread_id: 'solo-steer-thread' } }
|
|
1662
|
+
)) as { messages: BaseMessage[] };
|
|
1663
|
+
|
|
1664
|
+
type KwargMessage = {
|
|
1665
|
+
additional_kwargs?: { source?: string; role?: string };
|
|
1666
|
+
};
|
|
1667
|
+
const consolidated = result.messages.find(
|
|
1668
|
+
(m) =>
|
|
1669
|
+
m._getType() === 'human' &&
|
|
1670
|
+
(m as KwargMessage).additional_kwargs?.source === 'hook'
|
|
1671
|
+
);
|
|
1672
|
+
const steer = result.messages.find(
|
|
1673
|
+
(m) =>
|
|
1674
|
+
m._getType() === 'human' &&
|
|
1675
|
+
(m as KwargMessage).additional_kwargs?.source === 'steer'
|
|
1676
|
+
);
|
|
1677
|
+
expect(consolidated).toBeUndefined();
|
|
1678
|
+
expect(steer).toBeDefined();
|
|
1679
|
+
expect(String(steer!.content)).toBe('solo steer');
|
|
1680
|
+
});
|
|
1569
1681
|
});
|
|
1570
1682
|
|
|
1571
1683
|
describe('ToolNode HITL — per-hook allowedDecisions override', () => {
|
|
@@ -4,6 +4,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
|
|
|
4
4
|
import type {
|
|
5
5
|
HookCallback,
|
|
6
6
|
PermissionDeniedHookOutput,
|
|
7
|
+
PostToolBatchHookOutput,
|
|
7
8
|
PostToolUseHookOutput,
|
|
8
9
|
PreToolUseHookOutput,
|
|
9
10
|
SubagentStartHookInput,
|
|
@@ -365,6 +366,129 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
|
|
|
365
366
|
expect(postExecEvents).toContain('researcher-child:calculator');
|
|
366
367
|
});
|
|
367
368
|
|
|
369
|
+
it('top-level event-driven dispatches leave agentId unset (subagent-scope marker)', async () => {
|
|
370
|
+
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
371
|
+
if (provider === Providers.OPENAI) {
|
|
372
|
+
return class extends FakeChatModel {
|
|
373
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
374
|
+
constructor(_options: any) {
|
|
375
|
+
super({
|
|
376
|
+
responses: ['Calculating.', 'All done.'],
|
|
377
|
+
sleep: 1,
|
|
378
|
+
toolCalls: [createCalculatorToolCall()],
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
|
|
382
|
+
const config = {
|
|
383
|
+
tools,
|
|
384
|
+
} as Parameters<FakeChatModel['withConfig']>[0];
|
|
385
|
+
return this.withConfig(config);
|
|
386
|
+
}
|
|
387
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
388
|
+
} as any;
|
|
389
|
+
}
|
|
390
|
+
return originalGetChatModelClass(provider);
|
|
391
|
+
}) as typeof providers.getChatModelClass);
|
|
392
|
+
|
|
393
|
+
const registry = new HookRegistry();
|
|
394
|
+
const scopeEvents: string[] = [];
|
|
395
|
+
|
|
396
|
+
const preHook: HookCallback<'PreToolUse'> = async (
|
|
397
|
+
input
|
|
398
|
+
): Promise<PreToolUseHookOutput> => {
|
|
399
|
+
scopeEvents.push(
|
|
400
|
+
`pre:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
|
|
401
|
+
);
|
|
402
|
+
return { decision: 'allow' };
|
|
403
|
+
};
|
|
404
|
+
registry.register('PreToolUse', { hooks: [preHook] });
|
|
405
|
+
|
|
406
|
+
const postHook: HookCallback<'PostToolUse'> = async (
|
|
407
|
+
input
|
|
408
|
+
): Promise<PostToolUseHookOutput> => {
|
|
409
|
+
scopeEvents.push(
|
|
410
|
+
`post:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
|
|
411
|
+
);
|
|
412
|
+
return {};
|
|
413
|
+
};
|
|
414
|
+
registry.register('PostToolUse', { hooks: [postHook] });
|
|
415
|
+
|
|
416
|
+
const batchHook: HookCallback<'PostToolBatch'> = async (
|
|
417
|
+
input
|
|
418
|
+
): Promise<PostToolBatchHookOutput> => {
|
|
419
|
+
scopeEvents.push(
|
|
420
|
+
`batch:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
|
|
421
|
+
);
|
|
422
|
+
return {};
|
|
423
|
+
};
|
|
424
|
+
registry.register('PostToolBatch', { hooks: [batchHook] });
|
|
425
|
+
|
|
426
|
+
const dispatchAgentIds: Array<string | undefined> = [];
|
|
427
|
+
const customHandlers: Record<string, t.EventHandler> = {
|
|
428
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
429
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
430
|
+
[GraphEvents.ON_TOOL_EXECUTE]: {
|
|
431
|
+
handle: (_event, rawData): void => {
|
|
432
|
+
const request = rawData as t.ToolExecuteBatchRequest;
|
|
433
|
+
dispatchAgentIds.push(request.agentId);
|
|
434
|
+
const results: t.ToolExecuteResult[] = request.toolCalls.map(
|
|
435
|
+
(call) => ({
|
|
436
|
+
toolCallId: call.id,
|
|
437
|
+
status: 'success',
|
|
438
|
+
content: '42',
|
|
439
|
+
})
|
|
440
|
+
);
|
|
441
|
+
request.resolve(results);
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The LibreChat shape: a TOP-LEVEL agent whose tools ride
|
|
448
|
+
* `toolDefinitions` (event-driven ToolNode, host executes via
|
|
449
|
+
* ON_TOOL_EXECUTE). Hook inputs here must NOT carry the subagent-scope
|
|
450
|
+
* marker — a host hook keying on `agentId != null` (e.g. a steering
|
|
451
|
+
* drain that must never inject into child state) would otherwise skip
|
|
452
|
+
* every top-level batch. The DISPATCH payload is the opposite: hosts
|
|
453
|
+
* key tool/credential lookup on `request.agentId`, so it must keep
|
|
454
|
+
* identifying the owning agent.
|
|
455
|
+
*/
|
|
456
|
+
const run = await Run.create<t.IState>({
|
|
457
|
+
runId: `toplevel-event-hook-${Date.now()}`,
|
|
458
|
+
graphConfig: {
|
|
459
|
+
type: 'standard',
|
|
460
|
+
agents: [
|
|
461
|
+
{
|
|
462
|
+
agentId: 'hook-parent',
|
|
463
|
+
provider: Providers.OPENAI,
|
|
464
|
+
clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
|
|
465
|
+
instructions: 'Use the calculator.',
|
|
466
|
+
maxContextTokens: 8000,
|
|
467
|
+
toolDefinitions: [calculatorDef],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
returnContent: true,
|
|
472
|
+
skipCleanup: true,
|
|
473
|
+
customHandlers,
|
|
474
|
+
hooks: registry,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
run.Graph!.overrideTestModel(['Calculating.', 'All done.'], 5, [
|
|
478
|
+
createCalculatorToolCall(),
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
await run.processStream(
|
|
482
|
+
{ messages: [new HumanMessage('what is 21 * 2?')] },
|
|
483
|
+
callerConfig
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
expect(scopeEvents).toContain('pre:-:hook-parent');
|
|
487
|
+
expect(scopeEvents).toContain('post:-:hook-parent');
|
|
488
|
+
expect(scopeEvents).toContain('batch:-:hook-parent');
|
|
489
|
+
expect(dispatchAgentIds).toEqual(['hook-parent']);
|
|
490
|
+
});
|
|
491
|
+
|
|
368
492
|
it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
|
|
369
493
|
getChatModelClassSpy.mockImplementation(((provider: Providers) => {
|
|
370
494
|
if (provider === Providers.OPENAI) {
|
|
@@ -6,6 +6,7 @@ import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';
|
|
|
6
6
|
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
|
7
7
|
import type {
|
|
8
8
|
AgentInputs,
|
|
9
|
+
InjectedMessage,
|
|
9
10
|
MessageDeltaEvent,
|
|
10
11
|
ProcessedToolCall,
|
|
11
12
|
ReasoningDeltaEvent,
|
|
@@ -36,6 +37,7 @@ const MAX_PENDING_SUBAGENT_UPDATES = 64;
|
|
|
36
37
|
|
|
37
38
|
const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
|
|
38
39
|
additionalContexts: [] as string[],
|
|
40
|
+
injectedMessages: [] as InjectedMessage[],
|
|
39
41
|
errors: [] as string[],
|
|
40
42
|
});
|
|
41
43
|
|
|
@@ -371,6 +373,7 @@ export class SubagentExecutor {
|
|
|
371
373
|
agents: [childInputs],
|
|
372
374
|
langfuse: this.langfuse,
|
|
373
375
|
tokenCounter: this.tokenCounter,
|
|
376
|
+
subagentScope: true,
|
|
374
377
|
/**
|
|
375
378
|
* Forwarded so the child graph's own `SubagentExecutor` (created in
|
|
376
379
|
* its `createAgentNode` when `allowNested` keeps subagentConfigs)
|
package/src/types/graph.ts
CHANGED
|
@@ -336,6 +336,16 @@ export type StandardGraphInput = {
|
|
|
336
336
|
* they already flow through the registry's `CHAT_MODEL_END` handler.
|
|
337
337
|
*/
|
|
338
338
|
subagentUsageSink?: SubagentUsageSink;
|
|
339
|
+
/**
|
|
340
|
+
* True when this graph IS a subagent child run (set by `SubagentExecutor`
|
|
341
|
+
* when it constructs the child graph). Drives the hook-input `agentId`
|
|
342
|
+
* subagent-scope marker: hook dispatches from this graph's tool nodes
|
|
343
|
+
* carry `agentId` so run-scoped host hooks — which fire for child scopes
|
|
344
|
+
* too, because children inherit the parent's `run_id` — can tell child
|
|
345
|
+
* scope from the top level. Top-level graphs leave this unset and their
|
|
346
|
+
* hook inputs carry only `executingAgentId`.
|
|
347
|
+
*/
|
|
348
|
+
subagentScope?: boolean;
|
|
339
349
|
};
|
|
340
350
|
|
|
341
351
|
export type GraphEdge = {
|
package/src/types/llm.ts
CHANGED
|
@@ -45,7 +45,7 @@ export type AzureClientOptions = Partial<OpenAIChatInput> &
|
|
|
45
45
|
deploymentName?: string;
|
|
46
46
|
} & BaseChatModelParams & {
|
|
47
47
|
configuration?: OAIClientOptions;
|
|
48
|
-
};
|
|
48
|
+
} & ManagedRequestOptions;
|
|
49
49
|
/**
|
|
50
50
|
* Controls whether Claude's reasoning content is returned in adaptive
|
|
51
51
|
* thinking responses. Added for Claude Opus 4.7, which omits thinking by
|
|
@@ -73,7 +73,13 @@ export type GoogleThinkingConfig = {
|
|
|
73
73
|
includeThoughts?: boolean;
|
|
74
74
|
thinkingLevel?: 'THINKING_LEVEL_UNSPECIFIED' | 'LOW' | 'MEDIUM' | 'HIGH';
|
|
75
75
|
};
|
|
76
|
-
|
|
76
|
+
/** GPT-5.6 managed-request passthrough fields, shared by the OpenAI and
|
|
77
|
+
* Azure wrappers that both read them. */
|
|
78
|
+
export type ManagedRequestOptions = {
|
|
79
|
+
promptCacheExplicit?: boolean;
|
|
80
|
+
safety_identifier?: string;
|
|
81
|
+
};
|
|
82
|
+
export type OpenAIClientOptions = ChatOpenAIFields & ManagedRequestOptions;
|
|
77
83
|
export type AnthropicClientOptions = Omit<AnthropicInput, 'thinking'> & {
|
|
78
84
|
thinking?: ThinkingConfig;
|
|
79
85
|
promptCache?: boolean;
|
|
@@ -100,6 +106,10 @@ export type BedrockAnthropicInput = ChatBedrockConverseInput & {
|
|
|
100
106
|
* server-side, so the default is safe to leave on.
|
|
101
107
|
*/
|
|
102
108
|
promptCacheTtl?: PromptCacheTtl;
|
|
109
|
+
/**
|
|
110
|
+
* Minimum delay in milliseconds between visible streamed content deltas.
|
|
111
|
+
*/
|
|
112
|
+
_lc_stream_delay?: number;
|
|
103
113
|
};
|
|
104
114
|
export type BedrockConverseClientOptions = BedrockAnthropicInput;
|
|
105
115
|
export type BedrockAnthropicClientOptions = BedrockAnthropicInput;
|
package/src/types/tools.ts
CHANGED
|
@@ -516,7 +516,7 @@ export type InjectedMessage = {
|
|
|
516
516
|
/** When true, the message is framework-internal: not shown in UI, not counted as a user turn */
|
|
517
517
|
isMeta?: boolean;
|
|
518
518
|
/** Origin tag for downstream consumers (UI, pruner, compaction) */
|
|
519
|
-
source?: 'skill' | 'hook' | 'system';
|
|
519
|
+
source?: 'skill' | 'hook' | 'system' | 'steer';
|
|
520
520
|
/** Only set when source is 'skill', for compaction preservation */
|
|
521
521
|
skillName?: string;
|
|
522
522
|
};
|
package/src/utils/tokens.ts
CHANGED
|
@@ -17,7 +17,7 @@ const OPENAI_IMAGE_TOKENS_PER_TILE = 170;
|
|
|
17
17
|
/** Google Gemini fixed per-image cost. */
|
|
18
18
|
const _GEMINI_IMAGE_TOKENS = 258;
|
|
19
19
|
/** Safety margin for image and document token estimates (5% overestimate). */
|
|
20
|
-
const IMAGE_TOKEN_SAFETY_MARGIN = 1.05;
|
|
20
|
+
export const IMAGE_TOKEN_SAFETY_MARGIN = 1.05;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Anthropic PDF: each page costs image tokens + text tokens.
|
|
@@ -33,6 +33,33 @@ const BASE64_BYTES_PER_PDF_PAGE = 75_000;
|
|
|
33
33
|
/** Fallback token cost for URL-referenced documents without local data. */
|
|
34
34
|
const URL_DOCUMENT_FALLBACK_TOKENS = 2000;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Timed media (video/audio) is priced by DURATION, not size, and the content
|
|
38
|
+
* carries no duration — so duration is estimated from encoded size at
|
|
39
|
+
* representative bitrates and priced at Gemini's rates (the dominant provider
|
|
40
|
+
* for native video/audio). Deliberately rough; superseded by real provider
|
|
41
|
+
* usage after the first turn.
|
|
42
|
+
*/
|
|
43
|
+
/** Gemini video ≈ 258 tokens/frame (1 fps) + 32 tokens/s audio ≈ 300 tokens/s. */
|
|
44
|
+
const VIDEO_TOKENS_PER_SECOND = 300;
|
|
45
|
+
/** Gemini audio: 32 tokens/s (single channel). */
|
|
46
|
+
const AUDIO_TOKENS_PER_SECOND = 32;
|
|
47
|
+
/** Representative encoded byte rates for duration-from-size estimation. */
|
|
48
|
+
const VIDEO_BYTES_PER_SECOND = 250_000; // ~2 Mbps
|
|
49
|
+
const AUDIO_BYTES_PER_SECOND = 16_000; // ~128 kbps
|
|
50
|
+
/** Flat fallback when only a URL is present (no size to estimate from) — ~30s. */
|
|
51
|
+
const VIDEO_URL_FALLBACK_TOKENS = 9000;
|
|
52
|
+
const AUDIO_URL_FALLBACK_TOKENS = 960;
|
|
53
|
+
/** Content block types that can carry timed media (video/audio). `media` is
|
|
54
|
+
* generic (Google) so it is classified by MIME; the rest are unambiguous. */
|
|
55
|
+
const TIMED_MEDIA_TYPES = new Set([
|
|
56
|
+
'media',
|
|
57
|
+
'video',
|
|
58
|
+
'audio',
|
|
59
|
+
'video_url',
|
|
60
|
+
'input_audio',
|
|
61
|
+
]);
|
|
62
|
+
|
|
36
63
|
/**
|
|
37
64
|
* Extracts image dimensions from the first bytes of a base64-encoded
|
|
38
65
|
* PNG, JPEG, GIF, or WebP without decoding the full image.
|
|
@@ -135,7 +162,7 @@ export function estimateOpenAIImageTokens(
|
|
|
135
162
|
* Extracts dimensions from base64 header when available.
|
|
136
163
|
* Falls back to Anthropic minimum (1024) when dimensions can't be determined.
|
|
137
164
|
*/
|
|
138
|
-
function estimateImageBlockTokens(
|
|
165
|
+
export function estimateImageBlockTokens(
|
|
139
166
|
block: Record<string, unknown>,
|
|
140
167
|
encoding: EncodingName
|
|
141
168
|
): number {
|
|
@@ -180,7 +207,7 @@ function estimateImageBlockTokens(
|
|
|
180
207
|
* - Base64 PDF: page count estimated from base64 length × per-page cost.
|
|
181
208
|
* - URL reference: conservative flat estimate.
|
|
182
209
|
*/
|
|
183
|
-
function estimateDocumentBlockTokens(
|
|
210
|
+
export function estimateDocumentBlockTokens(
|
|
184
211
|
block: Record<string, unknown>,
|
|
185
212
|
encoding: EncodingName,
|
|
186
213
|
getTokenCount: (text: string) => number
|
|
@@ -287,6 +314,150 @@ function estimateDocumentBlockTokens(
|
|
|
287
314
|
return URL_DOCUMENT_FALLBACK_TOKENS;
|
|
288
315
|
}
|
|
289
316
|
|
|
317
|
+
/** Decoded byte length of base64 or a `data:` URL; 0 for any remote URI (http,
|
|
318
|
+
* gs, s3, file, …) or empty. Base64 never contains `:`, so any scheme prefix
|
|
319
|
+
* marks a remote reference with no local size. */
|
|
320
|
+
function base64ByteLength(value: string | undefined): number {
|
|
321
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (value.startsWith('data:')) {
|
|
325
|
+
const comma = value.indexOf(',');
|
|
326
|
+
return comma < 0 ? 0 : Math.floor(((value.length - comma - 1) * 3) / 4);
|
|
327
|
+
}
|
|
328
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
return Math.floor((value.length * 3) / 4);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function timedMediaTokens(
|
|
335
|
+
bytes: number,
|
|
336
|
+
bytesPerSecond: number,
|
|
337
|
+
tokensPerSecond: number,
|
|
338
|
+
urlFallback: number
|
|
339
|
+
): number {
|
|
340
|
+
if (bytes <= 0) {
|
|
341
|
+
return urlFallback;
|
|
342
|
+
}
|
|
343
|
+
return Math.max(
|
|
344
|
+
tokensPerSecond,
|
|
345
|
+
Math.ceil((bytes / bytesPerSecond) * tokensPerSecond)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Decoded byte length of a media block payload — top-level `data` (base64
|
|
350
|
+
* string or `Uint8Array`) or base64 `url`, or the nested `video|audio.{data,
|
|
351
|
+
* url, source.bytes}` shapes (`formatMessage` media arrays / native Bedrock);
|
|
352
|
+
* 0 for a bare remote URL, `fileId`/`fileUri`, S3 location, or empty. */
|
|
353
|
+
function mediaBlockByteLength(block: Record<string, unknown>): number {
|
|
354
|
+
const data = block.data;
|
|
355
|
+
if (typeof data === 'string') {
|
|
356
|
+
return base64ByteLength(data);
|
|
357
|
+
}
|
|
358
|
+
if (data instanceof Uint8Array) {
|
|
359
|
+
return data.length;
|
|
360
|
+
}
|
|
361
|
+
if (typeof block.url === 'string') {
|
|
362
|
+
return base64ByteLength(block.url);
|
|
363
|
+
}
|
|
364
|
+
const nested = (block.video ?? block.audio) as
|
|
365
|
+
| { data?: unknown; url?: unknown; source?: { bytes?: unknown } }
|
|
366
|
+
| undefined;
|
|
367
|
+
if (nested != null) {
|
|
368
|
+
if (typeof nested.data === 'string') {
|
|
369
|
+
return base64ByteLength(nested.data);
|
|
370
|
+
}
|
|
371
|
+
if (typeof nested.url === 'string') {
|
|
372
|
+
return base64ByteLength(nested.url);
|
|
373
|
+
}
|
|
374
|
+
if (nested.source?.bytes instanceof Uint8Array) {
|
|
375
|
+
return nested.source.bytes.length;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Classifies a block as timed media, or null when it is not video/audio — e.g.
|
|
382
|
+
* a generic Google `media` block carrying an image/document MIME, which must
|
|
383
|
+
* NOT be priced as video. Also handles the Google shape where `type` IS the
|
|
384
|
+
* MIME string (e.g. `{ type: 'audio/wav', data }`). */
|
|
385
|
+
function timedMediaKind(
|
|
386
|
+
block: Record<string, unknown>
|
|
387
|
+
): 'video' | 'audio' | null {
|
|
388
|
+
const type = typeof block.type === 'string' ? block.type : '';
|
|
389
|
+
if (type === 'input_audio' || type === 'audio') {
|
|
390
|
+
return 'audio';
|
|
391
|
+
}
|
|
392
|
+
if (type === 'video_url' || type === 'video') {
|
|
393
|
+
return 'video';
|
|
394
|
+
}
|
|
395
|
+
const mime =
|
|
396
|
+
type === 'media' && typeof block.mimeType === 'string' ? block.mimeType : type;
|
|
397
|
+
if (mime.startsWith('audio/')) {
|
|
398
|
+
return 'audio';
|
|
399
|
+
}
|
|
400
|
+
if (mime.startsWith('video/')) {
|
|
401
|
+
return 'video';
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Whether a content-block `type` can carry timed media — the fixed set plus the
|
|
407
|
+
* Google MIME-as-type shape (`audio/*` / `video/*`). Gates callers before they
|
|
408
|
+
* hand the block to {@link estimateTimedMediaBlockTokens}. */
|
|
409
|
+
function isTimedMediaType(type: string): boolean {
|
|
410
|
+
return (
|
|
411
|
+
TIMED_MEDIA_TYPES.has(type) ||
|
|
412
|
+
type.startsWith('audio/') ||
|
|
413
|
+
type.startsWith('video/')
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Estimates token cost for a timed-media block (video/audio). Handles Google
|
|
419
|
+
* `{ type: 'media', mimeType, data|url|fileUri }`, OpenRouter
|
|
420
|
+
* `{ type: 'video_url' }` / `{ type: 'input_audio' }`, and standard
|
|
421
|
+
* `{ type: 'video' }` / `{ type: 'audio' }` blocks (payload as `data` base64 or
|
|
422
|
+
* `Uint8Array`, base64 `url`, or `fileId`). Duration is inferred from encoded
|
|
423
|
+
* size (providers price by duration, which the block does not carry) at Gemini's
|
|
424
|
+
* rates; a payload with no size (bare URL / file id) falls back to a ~30s
|
|
425
|
+
* estimate. Returns 0 for non-timed media (e.g. an image/document `media` block)
|
|
426
|
+
* so it is never mispriced as video.
|
|
427
|
+
*/
|
|
428
|
+
export function estimateTimedMediaBlockTokens(
|
|
429
|
+
block: Record<string, unknown>
|
|
430
|
+
): number {
|
|
431
|
+
const kind = timedMediaKind(block);
|
|
432
|
+
if (kind == null) {
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
let bytes: number;
|
|
436
|
+
if (block.type === 'input_audio') {
|
|
437
|
+
const audio = block.input_audio as { data?: string } | undefined;
|
|
438
|
+
bytes = base64ByteLength(audio?.data);
|
|
439
|
+
} else if (block.type === 'video_url') {
|
|
440
|
+
const video = block.video_url as string | { url?: string } | undefined;
|
|
441
|
+
bytes = base64ByteLength(typeof video === 'string' ? video : video?.url);
|
|
442
|
+
} else {
|
|
443
|
+
bytes = mediaBlockByteLength(block);
|
|
444
|
+
}
|
|
445
|
+
if (kind === 'audio') {
|
|
446
|
+
return timedMediaTokens(
|
|
447
|
+
bytes,
|
|
448
|
+
AUDIO_BYTES_PER_SECOND,
|
|
449
|
+
AUDIO_TOKENS_PER_SECOND,
|
|
450
|
+
AUDIO_URL_FALLBACK_TOKENS
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return timedMediaTokens(
|
|
454
|
+
bytes,
|
|
455
|
+
VIDEO_BYTES_PER_SECOND,
|
|
456
|
+
VIDEO_TOKENS_PER_SECOND,
|
|
457
|
+
VIDEO_URL_FALLBACK_TOKENS
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
290
461
|
const tokenizers: Partial<Record<EncodingName, Tokenizer>> = {};
|
|
291
462
|
|
|
292
463
|
async function getTokenizer(
|
|
@@ -358,6 +529,13 @@ export function getTokenCountForMessage(
|
|
|
358
529
|
continue;
|
|
359
530
|
}
|
|
360
531
|
|
|
532
|
+
if (isTimedMediaType(item.type)) {
|
|
533
|
+
numTokens += Math.ceil(
|
|
534
|
+
estimateTimedMediaBlockTokens(item) * IMAGE_TOKEN_SAFETY_MARGIN
|
|
535
|
+
);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
361
539
|
if (item.type === ContentTypes.TOOL_CALL && item.tool_call != null) {
|
|
362
540
|
const toolName = item.tool_call.name;
|
|
363
541
|
if (typeof toolName === 'string' && toolName.length > 0) {
|