@librechat/agents 3.3.4 → 3.3.6
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/createWorkspacePolicyHook.cjs +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/openai/index.cjs +51 -6
- package/dist/cjs/llm/openai/index.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 +31 -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/JsonlSessionStore.cjs +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +34 -8
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +3 -1
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +19 -16
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ReadFile.cjs +8 -4
- package/dist/cjs/tools/ReadFile.cjs.map +1 -1
- package/dist/cjs/tools/SkillTool.cjs +2 -0
- package/dist/cjs/tools/SkillTool.cjs.map +1 -1
- package/dist/cjs/tools/SubagentTool.cjs +3 -0
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +91 -50
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +5 -1
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/intentArg.cjs +230 -0
- package/dist/cjs/tools/intentArg.cjs.map +1 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
- package/dist/cjs/tools/ptcTimeout.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/schema.cjs +2 -0
- package/dist/cjs/tools/search/schema.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +40 -2
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
- package/dist/cjs/tools/toolOutputReferences.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/createWorkspacePolicyHook.mjs +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/openai/index.mjs +51 -6
- package/dist/esm/llm/openai/index.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 +11 -7
- 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/JsonlSessionStore.mjs +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +33 -9
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +3 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +19 -16
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ReadFile.mjs +8 -4
- package/dist/esm/tools/ReadFile.mjs.map +1 -1
- package/dist/esm/tools/SkillTool.mjs +2 -0
- package/dist/esm/tools/SkillTool.mjs.map +1 -1
- package/dist/esm/tools/SubagentTool.mjs +3 -0
- package/dist/esm/tools/SubagentTool.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +91 -51
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +5 -1
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/intentArg.mjs +220 -0
- package/dist/esm/tools/intentArg.mjs.map +1 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
- package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
- package/dist/esm/tools/ptcTimeout.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/schema.mjs +2 -0
- package/dist/esm/tools/search/schema.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +40 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +41 -7
- package/dist/esm/tools/toolOutputReferences.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 +2 -1
- package/dist/types/llm/invoke.d.ts +7 -0
- package/dist/types/llm/openai/index.d.ts +0 -11
- 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/BashExecutor.d.ts +18 -0
- package/dist/types/tools/CodeExecutor.d.ts +18 -0
- package/dist/types/tools/ReadFile.d.ts +18 -0
- package/dist/types/tools/SkillTool.d.ts +18 -0
- package/dist/types/tools/SubagentTool.d.ts +9 -0
- package/dist/types/tools/ToolNode.d.ts +37 -12
- package/dist/types/tools/ToolSearch.d.ts +18 -0
- package/dist/types/tools/intentArg.d.ts +119 -0
- package/dist/types/tools/ptcTimeout.d.ts +2 -0
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/schema.d.ts +18 -0
- package/dist/types/tools/search/tool.d.ts +18 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/tools/toolOutputReferences.d.ts +12 -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 +12 -25
- package/dist/types/types/tools.d.ts +27 -2
- 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 +2 -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/openai/index.ts +74 -10
- package/src/llm/openai/llm.spec.ts +105 -0
- 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 +48 -9
- package/src/tools/BashExecutor.ts +6 -0
- package/src/tools/BashProgrammaticToolCalling.ts +2 -0
- package/src/tools/CodeExecutor.ts +6 -0
- package/src/tools/ProgrammaticToolCalling.ts +2 -0
- package/src/tools/ReadFile.ts +2 -0
- package/src/tools/SkillTool.ts +2 -0
- package/src/tools/SubagentTool.ts +3 -0
- package/src/tools/ToolNode.ts +193 -74
- package/src/tools/ToolSearch.ts +3 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
- package/src/tools/__tests__/intentArg.test.ts +281 -0
- package/src/tools/__tests__/intentCoverage.test.ts +139 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
- package/src/tools/intentArg.ts +323 -0
- package/src/tools/local/CompileCheckTool.ts +4 -3
- package/src/tools/local/LocalCodingTools.ts +14 -13
- package/src/tools/ptcTimeout.ts +2 -0
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/outcome.test.ts +90 -0
- package/src/tools/search/schema.ts +3 -0
- package/src/tools/search/tool.ts +60 -3
- package/src/tools/search/types.ts +50 -3
- package/src/tools/subagent/SubagentExecutor.ts +2 -1
- package/src/tools/toolOutputReferences.ts +77 -9
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +12 -41
- package/src/types/tools.ts +24 -2
- 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
|
@@ -560,3 +560,177 @@ describe('ToolNode per-call onResult completion emission', () => {
|
|
|
560
560
|
);
|
|
561
561
|
});
|
|
562
562
|
});
|
|
563
|
+
|
|
564
|
+
describe('ToolNode returned-error completions', () => {
|
|
565
|
+
afterEach(() => {
|
|
566
|
+
jest.restoreAllMocks();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* A tool that RETURNS an error ToolMessage never enters the catch path, so
|
|
571
|
+
* the errorHandler never runs and cannot have dispatched. The output loop
|
|
572
|
+
* must therefore emit the completion — and with it the tool's authored
|
|
573
|
+
* failure label — instead of assuming the handler owned it.
|
|
574
|
+
*/
|
|
575
|
+
it('emits a completion (and authored outcome) for a RETURNED error ToolMessage', async () => {
|
|
576
|
+
const completions: Array<{
|
|
577
|
+
result: { tool_call: { id: string; outcome?: string } };
|
|
578
|
+
}> = [];
|
|
579
|
+
jest
|
|
580
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
581
|
+
.mockImplementation(async (event, data): Promise<void> => {
|
|
582
|
+
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
|
|
583
|
+
completions.push(
|
|
584
|
+
data as { result: { tool_call: { id: string; outcome?: string } } }
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const returnsError = tool(
|
|
590
|
+
async () =>
|
|
591
|
+
new ToolMessage({
|
|
592
|
+
content: 'boom',
|
|
593
|
+
tool_call_id: 'call_fail',
|
|
594
|
+
status: 'error',
|
|
595
|
+
artifact: { outcome: 'Search failed for OAuth' },
|
|
596
|
+
}),
|
|
597
|
+
{
|
|
598
|
+
name: 'failing',
|
|
599
|
+
description: 'returns an error message',
|
|
600
|
+
schema: z.object({}).passthrough(),
|
|
601
|
+
}
|
|
602
|
+
) as unknown as StructuredToolInterface;
|
|
603
|
+
|
|
604
|
+
const errorHandler = jest.fn(async () => true);
|
|
605
|
+
const toolNode = new ToolNode({
|
|
606
|
+
tools: [returnsError],
|
|
607
|
+
toolCallStepIds: new Map([['call_fail', 'step_fail']]),
|
|
608
|
+
errorHandler: errorHandler as unknown as t.ToolNodeConstructorParams['errorHandler'],
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
await toolNode.invoke({
|
|
612
|
+
messages: [
|
|
613
|
+
createAIMessageWithToolCalls([
|
|
614
|
+
{
|
|
615
|
+
id: 'call_fail',
|
|
616
|
+
name: 'failing',
|
|
617
|
+
args: { intent: 'Searching for OAuth handling' },
|
|
618
|
+
},
|
|
619
|
+
]),
|
|
620
|
+
],
|
|
621
|
+
});
|
|
622
|
+
await flushAsync();
|
|
623
|
+
|
|
624
|
+
expect(errorHandler).not.toHaveBeenCalled();
|
|
625
|
+
const completion = completions.find(
|
|
626
|
+
(c) => c.result.tool_call.id === 'call_fail'
|
|
627
|
+
);
|
|
628
|
+
expect(completion).toBeDefined();
|
|
629
|
+
expect(completion?.result.tool_call.outcome).toBe('Search failed for OAuth');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('ToolNode error-ownership scoping', () => {
|
|
634
|
+
afterEach(() => {
|
|
635
|
+
jest.restoreAllMocks();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Tool-call ids are provider-scoped and synthetic ids can repeat, so
|
|
640
|
+
* ownership markers kept on the INSTANCE cross-consume between that
|
|
641
|
+
* instance's concurrent invocations.
|
|
642
|
+
*
|
|
643
|
+
* Interleaving that exposes it: invocation 1 batches a throwing call
|
|
644
|
+
* (whose handler claims ownership) alongside a slow call that keeps the
|
|
645
|
+
* batch — and therefore its output loop — pending. While it is parked,
|
|
646
|
+
* invocation 2 reuses the same id and RETURNS an error message. With
|
|
647
|
+
* instance-scoped markers, invocation 2's output loop consumes the
|
|
648
|
+
* marker invocation 1 set and drops its only completion.
|
|
649
|
+
*/
|
|
650
|
+
it('does not let a pending invocation\'s marker suppress a concurrent call reusing the id', async () => {
|
|
651
|
+
const completions: string[] = [];
|
|
652
|
+
jest
|
|
653
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
654
|
+
.mockImplementation(async (event, data): Promise<void> => {
|
|
655
|
+
if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
|
|
656
|
+
completions.push(
|
|
657
|
+
(data as { result: { tool_call: { id: string } } }).result.tool_call
|
|
658
|
+
.id
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const shared = 'call_shared';
|
|
664
|
+
let releaseSlow: (() => void) | undefined;
|
|
665
|
+
const slowGate = new Promise<void>((resolve) => {
|
|
666
|
+
releaseSlow = resolve;
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const thrower = tool(
|
|
670
|
+
async () => {
|
|
671
|
+
throw new Error('boom');
|
|
672
|
+
},
|
|
673
|
+
{ name: 'thrower', description: 'throws', schema: z.object({}).passthrough() }
|
|
674
|
+
) as unknown as StructuredToolInterface;
|
|
675
|
+
const slow = tool(
|
|
676
|
+
async () => {
|
|
677
|
+
await slowGate;
|
|
678
|
+
return 'done';
|
|
679
|
+
},
|
|
680
|
+
{ name: 'slow', description: 'parks the batch', schema: z.object({}).passthrough() }
|
|
681
|
+
) as unknown as StructuredToolInterface;
|
|
682
|
+
const returner = tool(
|
|
683
|
+
async () =>
|
|
684
|
+
new ToolMessage({
|
|
685
|
+
content: 'failed',
|
|
686
|
+
tool_call_id: shared,
|
|
687
|
+
status: 'error',
|
|
688
|
+
}),
|
|
689
|
+
{
|
|
690
|
+
name: 'returner',
|
|
691
|
+
description: 'returns an error message',
|
|
692
|
+
schema: z.object({}).passthrough(),
|
|
693
|
+
}
|
|
694
|
+
) as unknown as StructuredToolInterface;
|
|
695
|
+
|
|
696
|
+
/** ONE instance — the shared state the finding is about. */
|
|
697
|
+
const node = new ToolNode({
|
|
698
|
+
tools: [thrower, slow, returner],
|
|
699
|
+
toolCallStepIds: new Map([
|
|
700
|
+
[shared, 'step_shared'],
|
|
701
|
+
['call_slow', 'step_slow'],
|
|
702
|
+
]),
|
|
703
|
+
errorHandler: (async () =>
|
|
704
|
+
true) as unknown as t.ToolNodeConstructorParams['errorHandler'],
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Invocation 1: throws (claiming ownership of `shared`) and parks.
|
|
708
|
+
const pending = node.invoke({
|
|
709
|
+
messages: [
|
|
710
|
+
createAIMessageWithToolCalls([
|
|
711
|
+
{ id: shared, name: 'thrower', args: {} },
|
|
712
|
+
{ id: 'call_slow', name: 'slow', args: {} },
|
|
713
|
+
]),
|
|
714
|
+
],
|
|
715
|
+
}) as Promise<unknown>;
|
|
716
|
+
await flushAsync();
|
|
717
|
+
|
|
718
|
+
// Invocation 2, while invocation 1 is still parked.
|
|
719
|
+
const before = completions.length;
|
|
720
|
+
await node.invoke({
|
|
721
|
+
messages: [
|
|
722
|
+
createAIMessageWithToolCalls([
|
|
723
|
+
{ id: shared, name: 'returner', args: {} },
|
|
724
|
+
]),
|
|
725
|
+
],
|
|
726
|
+
});
|
|
727
|
+
await flushAsync();
|
|
728
|
+
const emittedBySecond = completions.slice(before);
|
|
729
|
+
|
|
730
|
+
releaseSlow?.();
|
|
731
|
+
await pending;
|
|
732
|
+
await flushAsync();
|
|
733
|
+
|
|
734
|
+
expect(emittedBySecond).toContain(shared);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import type { JsonSchemaType } from '@/types';
|
|
3
|
+
import {
|
|
4
|
+
INTENT_ARG,
|
|
5
|
+
INTENT_PROPERTY,
|
|
6
|
+
INTENT_DESCRIPTION,
|
|
7
|
+
withIntent,
|
|
8
|
+
readIntent,
|
|
9
|
+
stripIntent,
|
|
10
|
+
applyOutcome,
|
|
11
|
+
readOutcomeFields,
|
|
12
|
+
resolveToolOutcome,
|
|
13
|
+
} from '../intentArg';
|
|
14
|
+
|
|
15
|
+
describe('withIntent', () => {
|
|
16
|
+
const base: JsonSchemaType = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
query: { type: 'string', description: 'Search query' },
|
|
20
|
+
limit: { type: 'number' },
|
|
21
|
+
},
|
|
22
|
+
required: ['query'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('prepends intent as the FIRST property key', () => {
|
|
26
|
+
const next = withIntent(base);
|
|
27
|
+
expect(Object.keys(next.properties ?? {})).toEqual(['intent', 'query', 'limit']);
|
|
28
|
+
expect(next.properties?.[INTENT_ARG]).toEqual(INTENT_PROPERTY);
|
|
29
|
+
/**
|
|
30
|
+
* Must be a copy, not the frozen canonical instance: LangChain's
|
|
31
|
+
* JSON-schema validator stamps `__absolute_uri__` onto subschemas,
|
|
32
|
+
* which throws on a frozen object.
|
|
33
|
+
*/
|
|
34
|
+
expect(next.properties?.[INTENT_ARG]).not.toBe(INTENT_PROPERTY);
|
|
35
|
+
expect(Object.isFrozen(next.properties?.[INTENT_ARG])).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never mutates the input schema', () => {
|
|
39
|
+
const frozen = Object.freeze<JsonSchemaType>({
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: Object.freeze({ query: { type: 'string' } }),
|
|
42
|
+
}) as JsonSchemaType;
|
|
43
|
+
const next = withIntent(frozen);
|
|
44
|
+
expect(next).not.toBe(frozen);
|
|
45
|
+
expect(Object.keys(frozen.properties ?? {})).toEqual(['query']);
|
|
46
|
+
expect(Object.keys(next.properties ?? {})).toEqual(['intent', 'query']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not add intent to required', () => {
|
|
50
|
+
const next = withIntent(base);
|
|
51
|
+
expect(next.required).toEqual(['query']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('is idempotent when intent already exists (position preserved)', () => {
|
|
55
|
+
const once = withIntent(base);
|
|
56
|
+
const twice = withIntent(once);
|
|
57
|
+
expect(twice).toBe(once);
|
|
58
|
+
expect(Object.keys(twice.properties ?? {})[0]).toBe('intent');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles a schema with no properties', () => {
|
|
62
|
+
const next = withIntent({ type: 'object', properties: {} });
|
|
63
|
+
expect(Object.keys(next.properties ?? {})).toEqual(['intent']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('handles an undefined schema', () => {
|
|
67
|
+
const next = withIntent(undefined);
|
|
68
|
+
expect(next.type).toBe('object');
|
|
69
|
+
expect(Object.keys(next.properties ?? {})).toEqual(['intent']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('carries the model-facing instruction', () => {
|
|
73
|
+
expect(INTENT_PROPERTY.description).toBe(INTENT_DESCRIPTION);
|
|
74
|
+
expect(INTENT_DESCRIPTION).toContain('FIRST');
|
|
75
|
+
expect(INTENT_DESCRIPTION).toContain('distinguish that call from its siblings');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('readIntent', () => {
|
|
80
|
+
it('reads from object args', () => {
|
|
81
|
+
expect(readIntent({ intent: 'Searching for OAuth handling' })).toBe(
|
|
82
|
+
'Searching for OAuth handling'
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reads from stringified JSON args', () => {
|
|
87
|
+
expect(readIntent('{"intent":"Searching for OAuth handling","query":"oauth"}')).toBe(
|
|
88
|
+
'Searching for OAuth handling'
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns undefined for absent, empty, or non-string intents', () => {
|
|
93
|
+
expect(readIntent({ query: 'oauth' })).toBeUndefined();
|
|
94
|
+
expect(readIntent({ intent: '' })).toBeUndefined();
|
|
95
|
+
expect(readIntent({ intent: ' ' })).toBeUndefined();
|
|
96
|
+
expect(readIntent({ intent: 42 })).toBeUndefined();
|
|
97
|
+
expect(readIntent(undefined)).toBeUndefined();
|
|
98
|
+
expect(readIntent('not json')).toBeUndefined();
|
|
99
|
+
expect(readIntent('{"intent": broken')).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('stripIntent', () => {
|
|
104
|
+
it('removes the key from object args', () => {
|
|
105
|
+
expect(stripIntent({ intent: 'Searching', query: 'oauth' })).toEqual({ query: 'oauth' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('parses and strips stringified args', () => {
|
|
109
|
+
expect(stripIntent('{"intent":"Searching","query":"oauth"}')).toEqual({ query: 'oauth' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns args unchanged when the key is absent', () => {
|
|
113
|
+
const args = { query: 'oauth' };
|
|
114
|
+
expect(stripIntent(args)).toBe(args);
|
|
115
|
+
expect(stripIntent('plain string')).toBe('plain string');
|
|
116
|
+
expect(stripIntent(undefined)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('applyOutcome', () => {
|
|
121
|
+
const intent = 'Searching for OAuth handling';
|
|
122
|
+
|
|
123
|
+
it('prefers a tool-supplied outcome (full replacement)', () => {
|
|
124
|
+
expect(
|
|
125
|
+
applyOutcome(intent, {
|
|
126
|
+
outcome: 'Found 12 results for OAuth handling',
|
|
127
|
+
outcome_patch: { from: 'Searching', to: 'Searched' },
|
|
128
|
+
})
|
|
129
|
+
).toBe('Found 12 results for OAuth handling');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ignores a blank outcome', () => {
|
|
133
|
+
expect(applyOutcome(intent, { outcome: ' ' })).toBe('Searched for OAuth handling');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('applies outcome_patch to the first occurrence only, case-sensitive', () => {
|
|
137
|
+
expect(
|
|
138
|
+
applyOutcome('Searching for Searching patterns', {
|
|
139
|
+
outcome_patch: { from: 'Searching', to: 'Searched' },
|
|
140
|
+
})
|
|
141
|
+
).toBe('Searched for Searching patterns');
|
|
142
|
+
expect(
|
|
143
|
+
applyOutcome(intent, { outcome_patch: { from: 'searching', to: 'searched' } })
|
|
144
|
+
).toBe('Searched for OAuth handling');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('ignores a patch whose from is empty or absent from the intent', () => {
|
|
148
|
+
expect(applyOutcome(intent, { outcome_patch: { from: '', to: 'x' } })).toBe(
|
|
149
|
+
'Searched for OAuth handling'
|
|
150
|
+
);
|
|
151
|
+
expect(applyOutcome(intent, { outcome_patch: { from: 'Grepping', to: 'Grepped' } })).toBe(
|
|
152
|
+
'Searched for OAuth handling'
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('mechanically transforms the leading verb', () => {
|
|
157
|
+
expect(applyOutcome('Reading the callback router')).toBe('Read the callback router');
|
|
158
|
+
expect(applyOutcome('Writing the zod schema')).toBe('Wrote the zod schema');
|
|
159
|
+
expect(applyOutcome('Delegating the OAuth audit')).toBe('Delegated the OAuth audit');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('preserves a lowercase leading verb', () => {
|
|
163
|
+
expect(applyOutcome('searching for OAuth handling')).toBe('searched for OAuth handling');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('leaves an unknown leading word unchanged', () => {
|
|
167
|
+
expect(applyOutcome('Refactoring the zod schema')).toBe('Refactoring the zod schema');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('handles a single-word intent', () => {
|
|
171
|
+
expect(applyOutcome('Searching')).toBe('Searched');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns undefined with neither intent nor outcome', () => {
|
|
175
|
+
expect(applyOutcome(undefined)).toBeUndefined();
|
|
176
|
+
expect(applyOutcome('')).toBeUndefined();
|
|
177
|
+
expect(applyOutcome(undefined, { outcome_patch: { from: 'a', to: 'b' } })).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns the outcome even without an intent', () => {
|
|
181
|
+
expect(applyOutcome(undefined, { outcome: 'Found 12 results' })).toBe('Found 12 results');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('resolveToolOutcome', () => {
|
|
186
|
+
const args = { intent: 'Searching for OAuth handling', query: 'oauth' };
|
|
187
|
+
|
|
188
|
+
it('returns undefined when the tool authored no outcome fields', () => {
|
|
189
|
+
expect(resolveToolOutcome(args)).toBeUndefined();
|
|
190
|
+
expect(resolveToolOutcome(args, {})).toBeUndefined();
|
|
191
|
+
expect(resolveToolOutcome(args, null)).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('resolves a tool-supplied outcome', () => {
|
|
195
|
+
expect(resolveToolOutcome(args, { outcome: 'Found 12 results' })).toBe('Found 12 results');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resolves a patch against the intent read from args', () => {
|
|
199
|
+
expect(
|
|
200
|
+
resolveToolOutcome(args, { outcome_patch: { from: 'Searching', to: 'Searched' } })
|
|
201
|
+
).toBe('Searched for OAuth handling');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('returns undefined for a patch with no intent in the args', () => {
|
|
205
|
+
expect(
|
|
206
|
+
resolveToolOutcome({ query: 'oauth' }, { outcome_patch: { from: 'a', to: 'b' } })
|
|
207
|
+
).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('collapses the label to a bounded single line', () => {
|
|
211
|
+
expect(
|
|
212
|
+
resolveToolOutcome(args, { outcome: 'Found 12 results\n for "OAuth handling"' })
|
|
213
|
+
).toBe('Found 12 results for "OAuth handling"');
|
|
214
|
+
const oversized = resolveToolOutcome(args, { outcome: `Found ${'x'.repeat(500)}` });
|
|
215
|
+
expect(oversized?.length).toBe(256);
|
|
216
|
+
expect(oversized?.endsWith('…')).toBe(true);
|
|
217
|
+
expect(resolveToolOutcome(args, { outcome: ' \n \t ' })).toBe(
|
|
218
|
+
'Searched for OAuth handling'
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps $-token replacement text literal', () => {
|
|
223
|
+
expect(
|
|
224
|
+
resolveToolOutcome(args, {
|
|
225
|
+
outcome_patch: { from: 'Searching', to: 'Found $& via $\' and $$' },
|
|
226
|
+
})
|
|
227
|
+
).toBe('Found $& via $\' and $$ for OAuth handling');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('labels failed calls only with tool-authored text', () => {
|
|
231
|
+
expect(resolveToolOutcome(args, { outcome: 'Search failed for OAuth' }, { isError: true })).toBe(
|
|
232
|
+
'Search failed for OAuth'
|
|
233
|
+
);
|
|
234
|
+
expect(
|
|
235
|
+
resolveToolOutcome(
|
|
236
|
+
args,
|
|
237
|
+
{ outcome_patch: { from: 'Searching', to: 'Search failed' } },
|
|
238
|
+
{ isError: true }
|
|
239
|
+
)
|
|
240
|
+
).toBe('Search failed for OAuth handling');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('never falls back to the mechanical transform on a failed call', () => {
|
|
244
|
+
expect(
|
|
245
|
+
resolveToolOutcome(
|
|
246
|
+
args,
|
|
247
|
+
{ outcome_patch: { from: 'searching', to: 'searched' } },
|
|
248
|
+
{ isError: true }
|
|
249
|
+
)
|
|
250
|
+
).toBeUndefined();
|
|
251
|
+
expect(
|
|
252
|
+
resolveToolOutcome(
|
|
253
|
+
{ query: 'oauth' },
|
|
254
|
+
{ outcome_patch: { from: 'Searching', to: 'Searched' } },
|
|
255
|
+
{ isError: true }
|
|
256
|
+
)
|
|
257
|
+
).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('readOutcomeFields', () => {
|
|
262
|
+
it('reads valid fields from an artifact-shaped object', () => {
|
|
263
|
+
expect(readOutcomeFields({ outcome: 'Found 12 results', other: 1 })).toEqual({
|
|
264
|
+
outcome: 'Found 12 results',
|
|
265
|
+
outcome_patch: undefined,
|
|
266
|
+
});
|
|
267
|
+
expect(readOutcomeFields({ outcome_patch: { from: 'a', to: 'b' } })).toEqual({
|
|
268
|
+
outcome: undefined,
|
|
269
|
+
outcome_patch: { from: 'a', to: 'b' },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects malformed fields', () => {
|
|
274
|
+
expect(readOutcomeFields(undefined)).toBeUndefined();
|
|
275
|
+
expect(readOutcomeFields('outcome')).toBeUndefined();
|
|
276
|
+
expect(readOutcomeFields({ outcome: 42 })).toBeUndefined();
|
|
277
|
+
expect(readOutcomeFields({ outcome: ' ' })).toBeUndefined();
|
|
278
|
+
expect(readOutcomeFields({ outcome_patch: { from: 'a' } })).toBeUndefined();
|
|
279
|
+
expect(readOutcomeFields({ outcome_patch: 'Searched' })).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import type { CloudflareSandboxRuntime } from '@/types';
|
|
3
|
+
import {
|
|
4
|
+
CLOUDFLARE_CODING_TOOL_NAMES,
|
|
5
|
+
createCloudflareCodingTools,
|
|
6
|
+
} from '../cloudflare/CloudflareSandboxTools';
|
|
7
|
+
import {
|
|
8
|
+
createLocalCodingTools,
|
|
9
|
+
createLocalCodingToolDefinitions,
|
|
10
|
+
} from '../local/LocalCodingTools';
|
|
11
|
+
import {
|
|
12
|
+
BashExecutionToolSchema,
|
|
13
|
+
buildBashExecutionToolSchema,
|
|
14
|
+
} from '../BashExecutor';
|
|
15
|
+
import {
|
|
16
|
+
CodeExecutionToolSchema,
|
|
17
|
+
buildCodeExecutionToolSchema,
|
|
18
|
+
} from '../CodeExecutor';
|
|
19
|
+
import {
|
|
20
|
+
SubagentToolSchema,
|
|
21
|
+
createSubagentToolDefinition,
|
|
22
|
+
} from '../SubagentTool';
|
|
23
|
+
import { BashProgrammaticToolCallingSchema } from '../BashProgrammaticToolCalling';
|
|
24
|
+
import { ProgrammaticToolCallingSchema } from '../ProgrammaticToolCalling';
|
|
25
|
+
import { WebSearchToolSchema } from '../search/schema';
|
|
26
|
+
import { LOCAL_CODING_BUNDLE_NAMES } from '@/common';
|
|
27
|
+
import { ReadFileToolDefinition } from '../ReadFile';
|
|
28
|
+
import { ToolSearchToolSchema } from '../ToolSearch';
|
|
29
|
+
import { SkillToolDefinition } from '../SkillTool';
|
|
30
|
+
import { createSearchTool } from '../search/tool';
|
|
31
|
+
import { INTENT_ARG } from '../intentArg';
|
|
32
|
+
|
|
33
|
+
type SchemaLike = {
|
|
34
|
+
properties?: Record<string, unknown>;
|
|
35
|
+
required?: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function firstKey(schema: SchemaLike | undefined): string | undefined {
|
|
39
|
+
return Object.keys(schema?.properties ?? {})[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expectIntentFirst(name: string, schema: SchemaLike | undefined): void {
|
|
43
|
+
expect(`${name}:${firstKey(schema)}`).toBe(`${name}:${INTENT_ARG}`);
|
|
44
|
+
expect(schema?.required ?? []).not.toContain(INTENT_ARG);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stubSandbox: CloudflareSandboxRuntime = {
|
|
48
|
+
exec: () => Promise.reject(new Error('schema-only stub')),
|
|
49
|
+
readFile: () => Promise.reject(new Error('schema-only stub')),
|
|
50
|
+
writeFile: () => Promise.reject(new Error('schema-only stub')),
|
|
51
|
+
mkdir: () => Promise.reject(new Error('schema-only stub')),
|
|
52
|
+
listFiles: () => Promise.reject(new Error('schema-only stub')),
|
|
53
|
+
deleteFile: () => Promise.reject(new Error('schema-only stub')),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Coverage pin for the tool-intent capability: every native coding tool, on
|
|
58
|
+
* every engine, must emit `intent` as the FIRST schema property (and never
|
|
59
|
+
* require it). Mirrors the `LOCAL_CODING_BUNDLE_NAMES` pin so "did we get
|
|
60
|
+
* them all" is a failing test rather than a review question.
|
|
61
|
+
*/
|
|
62
|
+
describe('intent coverage', () => {
|
|
63
|
+
it('every local bundle tool emits intent as its first schema property', () => {
|
|
64
|
+
const tools = createLocalCodingTools();
|
|
65
|
+
expect(tools.map((tool) => tool.name).sort()).toEqual(
|
|
66
|
+
[...LOCAL_CODING_BUNDLE_NAMES].sort()
|
|
67
|
+
);
|
|
68
|
+
for (const tool of tools) {
|
|
69
|
+
expectIntentFirst(tool.name, tool.schema as SchemaLike);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('every local registry definition emits intent first', () => {
|
|
74
|
+
for (const def of createLocalCodingToolDefinitions()) {
|
|
75
|
+
expectIntentFirst(def.name, def.parameters);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('every cloudflare bundle tool emits intent first', () => {
|
|
80
|
+
const tools = createCloudflareCodingTools({ sandbox: stubSandbox });
|
|
81
|
+
expect(tools.map((tool) => tool.name).sort()).toEqual(
|
|
82
|
+
[...CLOUDFLARE_CODING_TOOL_NAMES].sort()
|
|
83
|
+
);
|
|
84
|
+
for (const tool of tools) {
|
|
85
|
+
expectIntentFirst(tool.name, tool.schema as SchemaLike);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('shared execution schemas emit intent first (all engines)', () => {
|
|
90
|
+
expectIntentFirst('bash_tool', BashExecutionToolSchema);
|
|
91
|
+
expectIntentFirst('execute_code', CodeExecutionToolSchema);
|
|
92
|
+
expectIntentFirst(
|
|
93
|
+
'bash_tool:stateful',
|
|
94
|
+
buildBashExecutionToolSchema({ statefulSessions: true })
|
|
95
|
+
);
|
|
96
|
+
expectIntentFirst(
|
|
97
|
+
'execute_code:stateful',
|
|
98
|
+
buildCodeExecutionToolSchema({ statefulSessions: true })
|
|
99
|
+
);
|
|
100
|
+
expectIntentFirst(
|
|
101
|
+
'run_tools_with_code',
|
|
102
|
+
ProgrammaticToolCallingSchema
|
|
103
|
+
);
|
|
104
|
+
expectIntentFirst(
|
|
105
|
+
'run_tools_with_bash',
|
|
106
|
+
BashProgrammaticToolCallingSchema
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('read_file, skill, subagent, tool_search, and web_search emit intent first', () => {
|
|
111
|
+
expectIntentFirst('read_file', ReadFileToolDefinition.parameters);
|
|
112
|
+
expectIntentFirst('skill', SkillToolDefinition.parameters);
|
|
113
|
+
expectIntentFirst('subagent', SubagentToolSchema);
|
|
114
|
+
expectIntentFirst(
|
|
115
|
+
'subagent:runtime',
|
|
116
|
+
createSubagentToolDefinition([
|
|
117
|
+
{ type: 'researcher', name: 'Researcher', description: 'Researches' },
|
|
118
|
+
]).parameters
|
|
119
|
+
);
|
|
120
|
+
expectIntentFirst('tool_search', ToolSearchToolSchema);
|
|
121
|
+
expectIntentFirst('web_search', WebSearchToolSchema);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('the createSearchTool FACTORY emits intent first (runtime schema, not the static def)', () => {
|
|
125
|
+
const serperTool = createSearchTool({
|
|
126
|
+
searchProvider: 'serper',
|
|
127
|
+
scraperProvider: 'serper',
|
|
128
|
+
serperApiKey: 'test-key',
|
|
129
|
+
});
|
|
130
|
+
expectIntentFirst('web_search:serper', serperTool.schema as SchemaLike);
|
|
131
|
+
const searxngTool = createSearchTool({
|
|
132
|
+
searchProvider: 'searxng',
|
|
133
|
+
scraperProvider: 'serper',
|
|
134
|
+
serperApiKey: 'test-key',
|
|
135
|
+
searxngInstanceUrl: 'http://localhost:8080',
|
|
136
|
+
});
|
|
137
|
+
expectIntentFirst('web_search:searxng', searxngTool.schema as SchemaLike);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -187,6 +187,89 @@ describe('ToolOutputReferenceRegistry', () => {
|
|
|
187
187
|
expect(resolved).toBe('nothing to see here');
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
it('leaves the top-level intent arg verbatim (display label, not a data channel)', () => {
|
|
191
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
192
|
+
reg.set('r', 'tool0turn0', 'HUGE-STORED-OUTPUT');
|
|
193
|
+
const { resolved } = reg.resolve('r', {
|
|
194
|
+
intent: 'Reusing {{tool0turn0}} for the next step',
|
|
195
|
+
command: 'cat {{tool0turn0}}',
|
|
196
|
+
nested: { intent: 'not top-level {{tool0turn0}}' },
|
|
197
|
+
});
|
|
198
|
+
expect(resolved).toEqual({
|
|
199
|
+
intent: 'Reusing {{tool0turn0}} for the next step',
|
|
200
|
+
command: 'cat HUGE-STORED-OUTPUT',
|
|
201
|
+
nested: { intent: 'not top-level HUGE-STORED-OUTPUT' },
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('preserves the intent key inside STRINGIFIED object args too', () => {
|
|
206
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
207
|
+
reg.set('r', 'tool0turn0', 'HUGE-STORED-OUTPUT');
|
|
208
|
+
const { resolved } = reg.resolve(
|
|
209
|
+
'r',
|
|
210
|
+
'{"intent":"Reusing {{tool0turn0}}","command":"cat {{tool0turn0}}"}'
|
|
211
|
+
);
|
|
212
|
+
expect(JSON.parse(resolved as string)).toEqual({
|
|
213
|
+
intent: 'Reusing {{tool0turn0}}',
|
|
214
|
+
command: 'cat HUGE-STORED-OUTPUT',
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('keeps plain (non-object) string args on the raw substitution path', () => {
|
|
219
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
220
|
+
reg.set('r', 'tool0turn0', 'DATA');
|
|
221
|
+
const { resolved } = reg.resolve('r', 'echo {{tool0turn0}} intent');
|
|
222
|
+
expect(resolved).toBe('echo DATA intent');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('substitutes a BUSINESS intent arg when the caller opts in', () => {
|
|
226
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
227
|
+
reg.set('r', 'tool0turn0', 'STORED');
|
|
228
|
+
const { resolved } = reg.resolve(
|
|
229
|
+
'r',
|
|
230
|
+
{ intent: 'category {{tool0turn0}}', title: 'x {{tool0turn0}}' },
|
|
231
|
+
{ substituteIntentKey: true }
|
|
232
|
+
);
|
|
233
|
+
expect(resolved).toEqual({
|
|
234
|
+
intent: 'category STORED',
|
|
235
|
+
title: 'x STORED',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('substitutes a business intent inside stringified args when opted in', () => {
|
|
240
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
241
|
+
reg.set('r', 'tool0turn0', 'STORED');
|
|
242
|
+
const { resolved } = reg.resolve(
|
|
243
|
+
'r',
|
|
244
|
+
'{"intent":"category {{tool0turn0}}"}',
|
|
245
|
+
{ substituteIntentKey: true }
|
|
246
|
+
);
|
|
247
|
+
expect(resolved).toBe('{"intent":"category STORED"}');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('reports unresolved references from a business intent arg', () => {
|
|
251
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
252
|
+
const { unresolved } = reg.resolve(
|
|
253
|
+
'r',
|
|
254
|
+
{ intent: 'category {{tool9turn9}}' },
|
|
255
|
+
{ substituteIntentKey: true }
|
|
256
|
+
);
|
|
257
|
+
expect(unresolved).toEqual(['tool9turn9']);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('snapshot views honor the opt-in too', () => {
|
|
261
|
+
const reg = new ToolOutputReferenceRegistry();
|
|
262
|
+
reg.set('r', 'tool0turn0', 'STORED');
|
|
263
|
+
const view = reg.snapshot('r');
|
|
264
|
+
expect(view.resolve({ intent: 'x {{tool0turn0}}' }).resolved).toEqual({
|
|
265
|
+
intent: 'x {{tool0turn0}}',
|
|
266
|
+
});
|
|
267
|
+
expect(
|
|
268
|
+
view.resolve({ intent: 'x {{tool0turn0}}' }, { substituteIntentKey: true })
|
|
269
|
+
.resolved
|
|
270
|
+
).toEqual({ intent: 'x STORED' });
|
|
271
|
+
});
|
|
272
|
+
|
|
190
273
|
it('passes through primitive values untouched', () => {
|
|
191
274
|
const reg = new ToolOutputReferenceRegistry();
|
|
192
275
|
const { resolved } = reg.resolve('r', {
|