@librechat/agents 3.3.5 → 3.3.7

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.
Files changed (127) hide show
  1. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  2. package/dist/cjs/llm/openai/index.cjs +51 -6
  3. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  4. package/dist/cjs/main.cjs +16 -0
  5. package/dist/cjs/session/JsonlSessionStore.cjs +1 -1
  6. package/dist/cjs/stream.cjs +6 -1
  7. package/dist/cjs/stream.cjs.map +1 -1
  8. package/dist/cjs/tools/BashExecutor.cjs +3 -1
  9. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  10. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -0
  11. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  12. package/dist/cjs/tools/CodeExecutor.cjs +19 -16
  13. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  14. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +2 -0
  15. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  16. package/dist/cjs/tools/ReadFile.cjs +8 -4
  17. package/dist/cjs/tools/ReadFile.cjs.map +1 -1
  18. package/dist/cjs/tools/SkillTool.cjs +2 -0
  19. package/dist/cjs/tools/SkillTool.cjs.map +1 -1
  20. package/dist/cjs/tools/SubagentTool.cjs +3 -0
  21. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  22. package/dist/cjs/tools/ToolNode.cjs +88 -27
  23. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  24. package/dist/cjs/tools/ToolSearch.cjs +5 -1
  25. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  26. package/dist/cjs/tools/intentArg.cjs +256 -0
  27. package/dist/cjs/tools/intentArg.cjs.map +1 -0
  28. package/dist/cjs/tools/local/CompileCheckTool.cjs +3 -2
  29. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -1
  30. package/dist/cjs/tools/local/LocalCodingTools.cjs +13 -12
  31. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -1
  32. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +2 -2
  33. package/dist/cjs/tools/ptcTimeout.cjs.map +1 -1
  34. package/dist/cjs/tools/search/schema.cjs +2 -0
  35. package/dist/cjs/tools/search/schema.cjs.map +1 -1
  36. package/dist/cjs/tools/search/tool.cjs +31 -1
  37. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  38. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  39. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  40. package/dist/cjs/tools/toolOutputReferences.cjs +41 -7
  41. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  42. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  43. package/dist/esm/llm/openai/index.mjs +51 -6
  44. package/dist/esm/llm/openai/index.mjs.map +1 -1
  45. package/dist/esm/main.mjs +4 -3
  46. package/dist/esm/session/JsonlSessionStore.mjs +1 -1
  47. package/dist/esm/stream.mjs +6 -1
  48. package/dist/esm/stream.mjs.map +1 -1
  49. package/dist/esm/tools/BashExecutor.mjs +3 -1
  50. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  51. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +2 -0
  52. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  53. package/dist/esm/tools/CodeExecutor.mjs +19 -16
  54. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  55. package/dist/esm/tools/ProgrammaticToolCalling.mjs +2 -0
  56. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  57. package/dist/esm/tools/ReadFile.mjs +8 -4
  58. package/dist/esm/tools/ReadFile.mjs.map +1 -1
  59. package/dist/esm/tools/SkillTool.mjs +2 -0
  60. package/dist/esm/tools/SkillTool.mjs.map +1 -1
  61. package/dist/esm/tools/SubagentTool.mjs +3 -0
  62. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +88 -28
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/ToolSearch.mjs +5 -1
  66. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  67. package/dist/esm/tools/intentArg.mjs +244 -0
  68. package/dist/esm/tools/intentArg.mjs.map +1 -0
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +3 -2
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -1
  71. package/dist/esm/tools/local/LocalCodingTools.mjs +13 -12
  72. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -1
  73. package/dist/esm/tools/local/LocalExecutionEngine.mjs +2 -2
  74. package/dist/esm/tools/ptcTimeout.mjs.map +1 -1
  75. package/dist/esm/tools/search/schema.mjs +2 -0
  76. package/dist/esm/tools/search/schema.mjs.map +1 -1
  77. package/dist/esm/tools/search/tool.mjs +31 -2
  78. package/dist/esm/tools/search/tool.mjs.map +1 -1
  79. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  80. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  81. package/dist/esm/tools/toolOutputReferences.mjs +41 -7
  82. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  83. package/dist/types/index.d.ts +1 -0
  84. package/dist/types/llm/openai/index.d.ts +0 -11
  85. package/dist/types/tools/BashExecutor.d.ts +18 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +18 -0
  87. package/dist/types/tools/ReadFile.d.ts +18 -0
  88. package/dist/types/tools/SkillTool.d.ts +18 -0
  89. package/dist/types/tools/SubagentTool.d.ts +9 -0
  90. package/dist/types/tools/ToolNode.d.ts +37 -5
  91. package/dist/types/tools/ToolSearch.d.ts +18 -0
  92. package/dist/types/tools/intentArg.d.ts +181 -0
  93. package/dist/types/tools/ptcTimeout.d.ts +2 -0
  94. package/dist/types/tools/search/schema.d.ts +18 -0
  95. package/dist/types/tools/search/tool.d.ts +18 -0
  96. package/dist/types/tools/toolOutputReferences.d.ts +12 -2
  97. package/dist/types/types/stream.d.ts +18 -0
  98. package/dist/types/types/tools.d.ts +27 -2
  99. package/package.json +1 -1
  100. package/src/index.ts +1 -0
  101. package/src/llm/openai/index.ts +74 -10
  102. package/src/llm/openai/llm.spec.ts +105 -0
  103. package/src/stream.ts +12 -1
  104. package/src/tools/BashExecutor.ts +6 -0
  105. package/src/tools/BashProgrammaticToolCalling.ts +2 -0
  106. package/src/tools/CodeExecutor.ts +6 -0
  107. package/src/tools/ProgrammaticToolCalling.ts +2 -0
  108. package/src/tools/ReadFile.ts +2 -0
  109. package/src/tools/SkillTool.ts +2 -0
  110. package/src/tools/SubagentTool.ts +3 -0
  111. package/src/tools/ToolNode.ts +190 -43
  112. package/src/tools/ToolSearch.ts +3 -0
  113. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +174 -0
  114. package/src/tools/__tests__/intentArg.test.ts +357 -0
  115. package/src/tools/__tests__/intentCoverage.test.ts +139 -0
  116. package/src/tools/__tests__/toolOutputReferences.test.ts +83 -0
  117. package/src/tools/intentArg.ts +357 -0
  118. package/src/tools/local/CompileCheckTool.ts +4 -3
  119. package/src/tools/local/LocalCodingTools.ts +14 -13
  120. package/src/tools/ptcTimeout.ts +2 -0
  121. package/src/tools/search/outcome.test.ts +90 -0
  122. package/src/tools/search/schema.ts +3 -0
  123. package/src/tools/search/tool.ts +47 -1
  124. package/src/tools/subagent/SubagentExecutor.ts +2 -1
  125. package/src/tools/toolOutputReferences.ts +77 -9
  126. package/src/types/stream.ts +18 -0
  127. package/src/types/tools.ts +24 -2
@@ -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,357 @@
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
+ INTENT_LABEL_MARKER,
8
+ withoutIntent,
9
+ withIntent,
10
+ readIntent,
11
+ stripIntent,
12
+ applyOutcome,
13
+ readOutcomeFields,
14
+ resolveToolOutcome,
15
+ } from '../intentArg';
16
+ import { ReadFileToolSchema } from '../ReadFile';
17
+
18
+ describe('withIntent', () => {
19
+ const base: JsonSchemaType = {
20
+ type: 'object',
21
+ properties: {
22
+ query: { type: 'string', description: 'Search query' },
23
+ limit: { type: 'number' },
24
+ },
25
+ required: ['query'],
26
+ };
27
+
28
+ it('prepends intent as the FIRST property key', () => {
29
+ const next = withIntent(base);
30
+ expect(Object.keys(next.properties ?? {})).toEqual(['intent', 'query', 'limit']);
31
+ expect(next.properties?.[INTENT_ARG]).toEqual(INTENT_PROPERTY);
32
+ /**
33
+ * Must be a copy, not the frozen canonical instance: LangChain's
34
+ * JSON-schema validator stamps `__absolute_uri__` onto subschemas,
35
+ * which throws on a frozen object.
36
+ */
37
+ expect(next.properties?.[INTENT_ARG]).not.toBe(INTENT_PROPERTY);
38
+ expect(Object.isFrozen(next.properties?.[INTENT_ARG])).toBe(false);
39
+ });
40
+
41
+ it('never mutates the input schema', () => {
42
+ const frozen = Object.freeze<JsonSchemaType>({
43
+ type: 'object',
44
+ properties: Object.freeze({ query: { type: 'string' } }),
45
+ }) as JsonSchemaType;
46
+ const next = withIntent(frozen);
47
+ expect(next).not.toBe(frozen);
48
+ expect(Object.keys(frozen.properties ?? {})).toEqual(['query']);
49
+ expect(Object.keys(next.properties ?? {})).toEqual(['intent', 'query']);
50
+ });
51
+
52
+ it('does not add intent to required', () => {
53
+ const next = withIntent(base);
54
+ expect(next.required).toEqual(['query']);
55
+ });
56
+
57
+ it('is idempotent when intent already exists (position preserved)', () => {
58
+ const once = withIntent(base);
59
+ const twice = withIntent(once);
60
+ expect(twice).toBe(once);
61
+ expect(Object.keys(twice.properties ?? {})[0]).toBe('intent');
62
+ });
63
+
64
+ it('handles a schema with no properties', () => {
65
+ const next = withIntent({ type: 'object', properties: {} });
66
+ expect(Object.keys(next.properties ?? {})).toEqual(['intent']);
67
+ });
68
+
69
+ it('handles an undefined schema', () => {
70
+ const next = withIntent(undefined);
71
+ expect(next.type).toBe('object');
72
+ expect(Object.keys(next.properties ?? {})).toEqual(['intent']);
73
+ });
74
+
75
+ it('carries the model-facing instruction', () => {
76
+ expect(INTENT_PROPERTY.description).toBe(INTENT_DESCRIPTION);
77
+ expect(INTENT_DESCRIPTION).toContain('FIRST');
78
+ /** Sibling differentiation is the headline case; models emit identical
79
+ * labels for parallel calls without it. */
80
+ expect(INTENT_DESCRIPTION).toContain('Sibling calls to one tool must differ');
81
+ });
82
+
83
+ it('opens with the exported marker so host strip passes can key on it', () => {
84
+ expect(INTENT_DESCRIPTION.startsWith(INTENT_LABEL_MARKER)).toBe(true);
85
+ });
86
+
87
+ it('stays terse — it is repeated per tool, per request', () => {
88
+ expect(INTENT_DESCRIPTION.length).toBeLessThanOrEqual(300);
89
+ });
90
+ });
91
+
92
+ describe('withoutIntent', () => {
93
+ it('removes the injected label — the opt-out for embedders that render none', () => {
94
+ const withLabel = withIntent({
95
+ type: 'object',
96
+ properties: { query: { type: 'string' } },
97
+ required: ['query'],
98
+ });
99
+ const stripped = withoutIntent(withLabel);
100
+ expect(Object.keys(stripped?.properties ?? {})).toEqual(['query']);
101
+ expect(stripped?.required).toEqual(['query']);
102
+ });
103
+
104
+ it('never removes a tool-owned business `intent` parameter', () => {
105
+ const business: JsonSchemaType = {
106
+ type: 'object',
107
+ properties: { intent: { type: 'string', description: 'CRM intent category' } },
108
+ required: ['intent'],
109
+ };
110
+ expect(withoutIntent(business)).toBe(business);
111
+ });
112
+
113
+ it('is a no-op on schemas without the label', () => {
114
+ const plain: JsonSchemaType = { type: 'object', properties: { q: { type: 'string' } } };
115
+ expect(withoutIntent(plain)).toBe(plain);
116
+ expect(withoutIntent(undefined)).toBeUndefined();
117
+ });
118
+
119
+ it('prunes intent from `required` too, so the schema stays valid', () => {
120
+ /** Strict-mode normalization lists every property in `required`; leaving
121
+ * a dangling entry yields invalid JSON Schema the provider rejects. */
122
+ const strict: JsonSchemaType = {
123
+ type: 'object',
124
+ properties: { intent: { ...INTENT_PROPERTY }, path: { type: 'string' } },
125
+ required: ['intent', 'path'],
126
+ };
127
+ const stripped = withoutIntent(strict);
128
+ expect(Object.keys(stripped?.properties ?? {})).toEqual(['path']);
129
+ expect(stripped?.required).toEqual(['path']);
130
+ });
131
+
132
+ it('drops `required` entirely when intent was its only entry', () => {
133
+ const onlyIntent: JsonSchemaType = {
134
+ type: 'object',
135
+ properties: { intent: { ...INTENT_PROPERTY } },
136
+ required: ['intent'],
137
+ };
138
+ const stripped = withoutIntent(onlyIntent);
139
+ expect(stripped?.required).toBeUndefined();
140
+ expect(Object.keys(stripped?.properties ?? {})).toEqual([]);
141
+ });
142
+
143
+ it('accepts a readonly `as const` native schema without a cast', () => {
144
+ /** ReadFileToolSchema is declared `as const`, so `required` is a readonly
145
+ * tuple — this call is the compile-time assertion that the advertised
146
+ * opt-out is usable on the schemas it exists for. */
147
+ const stripped = withoutIntent(ReadFileToolSchema);
148
+ expect(Object.keys(stripped?.properties ?? {})).toEqual(['path']);
149
+ expect(stripped?.required).toEqual(['path']);
150
+ });
151
+
152
+ it('round-trips with withIntent', () => {
153
+ const base: JsonSchemaType = { type: 'object', properties: { q: { type: 'string' } } };
154
+ expect(withoutIntent(withIntent(base))).toEqual(base);
155
+ });
156
+ });
157
+
158
+ describe('readIntent', () => {
159
+ it('reads from object args', () => {
160
+ expect(readIntent({ intent: 'Searching for OAuth handling' })).toBe(
161
+ 'Searching for OAuth handling'
162
+ );
163
+ });
164
+
165
+ it('reads from stringified JSON args', () => {
166
+ expect(readIntent('{"intent":"Searching for OAuth handling","query":"oauth"}')).toBe(
167
+ 'Searching for OAuth handling'
168
+ );
169
+ });
170
+
171
+ it('returns undefined for absent, empty, or non-string intents', () => {
172
+ expect(readIntent({ query: 'oauth' })).toBeUndefined();
173
+ expect(readIntent({ intent: '' })).toBeUndefined();
174
+ expect(readIntent({ intent: ' ' })).toBeUndefined();
175
+ expect(readIntent({ intent: 42 })).toBeUndefined();
176
+ expect(readIntent(undefined)).toBeUndefined();
177
+ expect(readIntent('not json')).toBeUndefined();
178
+ expect(readIntent('{"intent": broken')).toBeUndefined();
179
+ });
180
+ });
181
+
182
+ describe('stripIntent', () => {
183
+ it('removes the key from object args', () => {
184
+ expect(stripIntent({ intent: 'Searching', query: 'oauth' })).toEqual({ query: 'oauth' });
185
+ });
186
+
187
+ it('parses and strips stringified args', () => {
188
+ expect(stripIntent('{"intent":"Searching","query":"oauth"}')).toEqual({ query: 'oauth' });
189
+ });
190
+
191
+ it('returns args unchanged when the key is absent', () => {
192
+ const args = { query: 'oauth' };
193
+ expect(stripIntent(args)).toBe(args);
194
+ expect(stripIntent('plain string')).toBe('plain string');
195
+ expect(stripIntent(undefined)).toBeUndefined();
196
+ });
197
+ });
198
+
199
+ describe('applyOutcome', () => {
200
+ const intent = 'Searching for OAuth handling';
201
+
202
+ it('prefers a tool-supplied outcome (full replacement)', () => {
203
+ expect(
204
+ applyOutcome(intent, {
205
+ outcome: 'Found 12 results for OAuth handling',
206
+ outcome_patch: { from: 'Searching', to: 'Searched' },
207
+ })
208
+ ).toBe('Found 12 results for OAuth handling');
209
+ });
210
+
211
+ it('ignores a blank outcome', () => {
212
+ expect(applyOutcome(intent, { outcome: ' ' })).toBe(intent);
213
+ });
214
+
215
+ it('applies outcome_patch to the first occurrence only, case-sensitive', () => {
216
+ expect(
217
+ applyOutcome('Searching for Searching patterns', {
218
+ outcome_patch: { from: 'Searching', to: 'Searched' },
219
+ })
220
+ ).toBe('Searched for Searching patterns');
221
+ expect(
222
+ applyOutcome(intent, { outcome_patch: { from: 'searching', to: 'searched' } })
223
+ ).toBe(intent);
224
+ });
225
+
226
+ it('ignores a patch whose from is empty or absent from the intent', () => {
227
+ expect(applyOutcome(intent, { outcome_patch: { from: '', to: 'x' } })).toBe(intent);
228
+ expect(applyOutcome(intent, { outcome_patch: { from: 'Grepping', to: 'Grepped' } })).toBe(
229
+ intent
230
+ );
231
+ });
232
+
233
+ /**
234
+ * There is no mechanical tense rewrite: a closed English verb list would
235
+ * never fire for non-English labels, and would fire for some siblings but
236
+ * not others inside one group. Completion is a UI state, not a tense.
237
+ */
238
+ it('returns the intent unchanged when the tool authored no outcome', () => {
239
+ for (const label of [
240
+ 'Reading the callback router',
241
+ 'Recording the OAuth callback location',
242
+ 'searching for OAuth handling',
243
+ 'Buscando el manejo de OAuth',
244
+ 'Searching',
245
+ ]) {
246
+ expect(applyOutcome(label)).toBe(label);
247
+ }
248
+ });
249
+
250
+ it('returns undefined with neither intent nor outcome', () => {
251
+ expect(applyOutcome(undefined)).toBeUndefined();
252
+ expect(applyOutcome('')).toBeUndefined();
253
+ expect(applyOutcome(undefined, { outcome_patch: { from: 'a', to: 'b' } })).toBeUndefined();
254
+ });
255
+
256
+ it('returns the outcome even without an intent', () => {
257
+ expect(applyOutcome(undefined, { outcome: 'Found 12 results' })).toBe('Found 12 results');
258
+ });
259
+ });
260
+
261
+ describe('resolveToolOutcome', () => {
262
+ const args = { intent: 'Searching for OAuth handling', query: 'oauth' };
263
+
264
+ it('returns undefined when the tool authored no outcome fields', () => {
265
+ expect(resolveToolOutcome(args)).toBeUndefined();
266
+ expect(resolveToolOutcome(args, {})).toBeUndefined();
267
+ expect(resolveToolOutcome(args, null)).toBeUndefined();
268
+ });
269
+
270
+ it('resolves a tool-supplied outcome', () => {
271
+ expect(resolveToolOutcome(args, { outcome: 'Found 12 results' })).toBe('Found 12 results');
272
+ });
273
+
274
+ it('resolves a patch against the intent read from args', () => {
275
+ expect(
276
+ resolveToolOutcome(args, { outcome_patch: { from: 'Searching', to: 'Searched' } })
277
+ ).toBe('Searched for OAuth handling');
278
+ });
279
+
280
+ it('returns undefined for a patch with no intent in the args', () => {
281
+ expect(
282
+ resolveToolOutcome({ query: 'oauth' }, { outcome_patch: { from: 'a', to: 'b' } })
283
+ ).toBeUndefined();
284
+ });
285
+
286
+ it('collapses the label to a bounded single line', () => {
287
+ expect(
288
+ resolveToolOutcome(args, { outcome: 'Found 12 results\n for "OAuth handling"' })
289
+ ).toBe('Found 12 results for "OAuth handling"');
290
+ const oversized = resolveToolOutcome(args, { outcome: `Found ${'x'.repeat(500)}` });
291
+ expect(oversized?.length).toBe(256);
292
+ expect(oversized?.endsWith('…')).toBe(true);
293
+ expect(resolveToolOutcome(args, { outcome: ' \n \t ' })).toBe(
294
+ 'Searching for OAuth handling'
295
+ );
296
+ });
297
+
298
+ it('keeps $-token replacement text literal', () => {
299
+ expect(
300
+ resolveToolOutcome(args, {
301
+ outcome_patch: { from: 'Searching', to: 'Found $& via $\' and $$' },
302
+ })
303
+ ).toBe('Found $& via $\' and $$ for OAuth handling');
304
+ });
305
+
306
+ it('labels failed calls only with tool-authored text', () => {
307
+ expect(resolveToolOutcome(args, { outcome: 'Search failed for OAuth' }, { isError: true })).toBe(
308
+ 'Search failed for OAuth'
309
+ );
310
+ expect(
311
+ resolveToolOutcome(
312
+ args,
313
+ { outcome_patch: { from: 'Searching', to: 'Search failed' } },
314
+ { isError: true }
315
+ )
316
+ ).toBe('Search failed for OAuth handling');
317
+ });
318
+
319
+ it('never reuses the in-flight intent as a failed call\'s settled label', () => {
320
+ expect(
321
+ resolveToolOutcome(
322
+ args,
323
+ { outcome_patch: { from: 'searching', to: 'searched' } },
324
+ { isError: true }
325
+ )
326
+ ).toBeUndefined();
327
+ expect(
328
+ resolveToolOutcome(
329
+ { query: 'oauth' },
330
+ { outcome_patch: { from: 'Searching', to: 'Searched' } },
331
+ { isError: true }
332
+ )
333
+ ).toBeUndefined();
334
+ });
335
+ });
336
+
337
+ describe('readOutcomeFields', () => {
338
+ it('reads valid fields from an artifact-shaped object', () => {
339
+ expect(readOutcomeFields({ outcome: 'Found 12 results', other: 1 })).toEqual({
340
+ outcome: 'Found 12 results',
341
+ outcome_patch: undefined,
342
+ });
343
+ expect(readOutcomeFields({ outcome_patch: { from: 'a', to: 'b' } })).toEqual({
344
+ outcome: undefined,
345
+ outcome_patch: { from: 'a', to: 'b' },
346
+ });
347
+ });
348
+
349
+ it('rejects malformed fields', () => {
350
+ expect(readOutcomeFields(undefined)).toBeUndefined();
351
+ expect(readOutcomeFields('outcome')).toBeUndefined();
352
+ expect(readOutcomeFields({ outcome: 42 })).toBeUndefined();
353
+ expect(readOutcomeFields({ outcome: ' ' })).toBeUndefined();
354
+ expect(readOutcomeFields({ outcome_patch: { from: 'a' } })).toBeUndefined();
355
+ expect(readOutcomeFields({ outcome_patch: 'Searched' })).toBeUndefined();
356
+ });
357
+ });
@@ -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
+ });