@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
@@ -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', {
@@ -0,0 +1,357 @@
1
+ /**
2
+ * @fileoverview Tool intent labels.
3
+ *
4
+ * Lets a tool declare, as the FIRST property of its input schema, an `intent`
5
+ * string: one model-authored sentence stating what that specific call is about
6
+ * to do ("Searching for OAuth handling in the callback router"). Because the
7
+ * property is first, it is the first key providers stream in the tool-call
8
+ * args, so a host UI can render it as the call's live status label before the
9
+ * rest of the args exist. When the call settles, {@link applyOutcome} edits
10
+ * the sentence in place into its outcome form — a tool-supplied replacement
11
+ * (`outcome`) or a tool-supplied span edit (`outcome_patch`). Absent either,
12
+ * the label is left exactly as the model wrote it: completion is a UI state
13
+ * (the shimmer stopping, the icon settling), not a tense change.
14
+ *
15
+ * The arg is always optional (never listed in `required`): the same schemas
16
+ * are callable from programmatic tool calling, where no UI renders a label
17
+ * and forcing generated code to fabricate one would be pure cost. Tool bodies
18
+ * must call {@link stripIntent} before using their args so no tool receives a
19
+ * parameter it did not declare.
20
+ */
21
+
22
+ import type { JsonSchemaType, OutcomePatch } from '@/types';
23
+
24
+ /** Argument carrying the model-authored label for a tool call. */
25
+ export const INTENT_ARG = 'intent';
26
+
27
+ /**
28
+ * Opening words of {@link INTENT_DESCRIPTION}, and the discriminator that
29
+ * tells the injected LABEL apart from a tool's own business parameter that
30
+ * merely shares the name `intent`.
31
+ *
32
+ * Exported because host applications reimplement the same strip/sanitize
33
+ * passes and would otherwise duplicate this as a string literal: if the two
34
+ * copies drift, the host silently stops recognizing SDK-native labels and
35
+ * fails OPEN (labels stay in schemas, opt-outs stop working) with no error.
36
+ * Any edit to the description must preserve this prefix verbatim.
37
+ */
38
+ export const INTENT_LABEL_MARKER = 'ALWAYS write this field FIRST';
39
+
40
+ /**
41
+ * Model-facing instruction for the injected `intent` property.
42
+ *
43
+ * Deliberately terse — it is repeated on every opted-in tool schema, on every
44
+ * request, so each sentence is paid for many times over. What remains is
45
+ * load-bearing: first-position placement (the entire streaming mechanism),
46
+ * the one-sentence present-progressive form, who reads it, and the sibling
47
+ * rule, without which models emit identical labels for parallel calls to one
48
+ * tool and defeat the feature's headline case.
49
+ */
50
+ export const INTENT_DESCRIPTION =
51
+ `${INTENT_LABEL_MARKER}, before any other argument. One present-progressive ` +
52
+ 'sentence saying what THIS call is about to do: "Searching for OAuth handling ' +
53
+ 'in the callback router". Shown to the user as this call\'s live status. ' +
54
+ 'Never name the tool. Sibling calls to one tool must differ.';
55
+
56
+ /**
57
+ * Canonical (frozen) shape of the injected property. Always embed a COPY
58
+ * (`{ ...INTENT_PROPERTY }`): LangChain's JSON-schema validator stamps a
59
+ * `__absolute_uri__` marker onto every subschema it dereferences, which
60
+ * throws on a frozen object — and a single shared instance would be stamped
61
+ * with one schema's URI while embedded in many.
62
+ */
63
+ export const INTENT_PROPERTY: JsonSchemaType = Object.freeze<JsonSchemaType>({
64
+ type: 'string',
65
+ description: INTENT_DESCRIPTION,
66
+ });
67
+
68
+ /**
69
+ * Discriminates the intent LABEL property from a tool's own business
70
+ * parameter that merely shares the name: the label contract always opens
71
+ * with the same instruction. Removal/sanitize passes must never strip a
72
+ * parameter the tool actually needs.
73
+ */
74
+ export function isIntentLabelProperty(property: unknown): boolean {
75
+ if (property == null || typeof property !== 'object') {
76
+ return false;
77
+ }
78
+ const record = property as { type?: unknown; description?: unknown };
79
+ return (
80
+ record.type === 'string' &&
81
+ typeof record.description === 'string' &&
82
+ record.description.startsWith(INTENT_LABEL_MARKER)
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Schema shape accepted by {@link withoutIntent}.
88
+ *
89
+ * `required` is widened to `readonly string[]` because the SDK's own native
90
+ * schemas are declared `as const` — their `required` is a readonly tuple, and
91
+ * a mutable `string[]` parameter would reject the very schemas this helper
92
+ * exists for (TS2345), forcing embedders to cast to use the advertised API.
93
+ */
94
+ export type IntentStrippableSchema = Omit<JsonSchemaType, 'required'> & {
95
+ required?: readonly string[];
96
+ };
97
+
98
+ /**
99
+ * Returns a copy of `parameters` without the injected intent LABEL — the
100
+ * opt-out for consumers that render no status label and should not pay for
101
+ * the property.
102
+ *
103
+ * The SDK's native schemas carry the label unconditionally, so without this
104
+ * an embedder has no lever at all: `withIntent` is applied at module scope.
105
+ * Marker-guarded, so a tool's own business parameter named `intent` is never
106
+ * removed. Returns the input unchanged when there is nothing to strip.
107
+ *
108
+ * `required` is pruned alongside the property: a schema that lists `intent`
109
+ * as required (strict-mode normalization does exactly that, since OpenAI
110
+ * strict function schemas require every property to appear in `required`)
111
+ * would otherwise be left naming a property it no longer declares, which is
112
+ * invalid JSON Schema and gets rejected by the provider instead of quietly
113
+ * opting out.
114
+ */
115
+ export function withoutIntent(parameters?: IntentStrippableSchema): JsonSchemaType | undefined {
116
+ const props = parameters?.properties;
117
+ if (parameters == null || props == null || !isIntentLabelProperty(props[INTENT_ARG])) {
118
+ return parameters as JsonSchemaType | undefined;
119
+ }
120
+ const { [INTENT_ARG]: _omit, ...rest } = props;
121
+ const next: JsonSchemaType = {
122
+ ...(parameters as JsonSchemaType),
123
+ properties: rest,
124
+ };
125
+ if (parameters.required != null) {
126
+ const required = parameters.required.filter((key) => key !== INTENT_ARG);
127
+ if (required.length > 0) {
128
+ next.required = required;
129
+ } else {
130
+ delete next.required;
131
+ }
132
+ }
133
+ return next;
134
+ }
135
+
136
+ /**
137
+ * Returns a copy of the parameters schema with `intent` prepended as the
138
+ * FIRST property (object key order is insertion order and every provider
139
+ * serializer preserves it — first key in the schema means first key in the
140
+ * streamed input). Never mutates the input; no-op when the schema already
141
+ * declares `intent`. The property is not added to `required`.
142
+ */
143
+ export function withIntent(parameters?: JsonSchemaType): JsonSchemaType {
144
+ const existingProps = parameters?.properties ?? {};
145
+ if (INTENT_ARG in existingProps) {
146
+ return parameters as JsonSchemaType;
147
+ }
148
+ return {
149
+ ...parameters,
150
+ type: 'object',
151
+ properties: { [INTENT_ARG]: { ...INTENT_PROPERTY }, ...existingProps },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Coerces tool-call args to an object, parsing a stringified JSON object
157
+ * (some providers deliver args as a string). Returns undefined otherwise.
158
+ */
159
+ function coerceArgsObject(args: unknown): Record<string, unknown> | undefined {
160
+ if (typeof args === 'object' && args !== null && !Array.isArray(args)) {
161
+ return args as Record<string, unknown>;
162
+ }
163
+ if (typeof args === 'string' && args.trim().startsWith('{')) {
164
+ try {
165
+ const parsed = JSON.parse(args) as unknown;
166
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
167
+ return parsed as Record<string, unknown>;
168
+ }
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * Reads the model-authored intent from tool-call args (handles stringified
178
+ * args). Returns undefined when absent, empty, or not a string.
179
+ */
180
+ export function readIntent(args: unknown): string | undefined {
181
+ const value = coerceArgsObject(args)?.[INTENT_ARG];
182
+ if (typeof value !== 'string') {
183
+ return undefined;
184
+ }
185
+ const trimmed = value.trim();
186
+ return trimmed === '' ? undefined : trimmed;
187
+ }
188
+
189
+ /**
190
+ * Returns the args without the `intent` key so downstream consumers that did
191
+ * not declare it never receive it. Parses stringified JSON object args;
192
+ * returns the value unchanged when the key is absent.
193
+ */
194
+ export function stripIntent(args: unknown): unknown {
195
+ const obj = coerceArgsObject(args);
196
+ if (!obj || !(INTENT_ARG in obj)) {
197
+ return args;
198
+ }
199
+ const { [INTENT_ARG]: _omit, ...rest } = obj;
200
+ return rest;
201
+ }
202
+
203
+ /**
204
+ * Resolves the settled label for a call from its model-authored `intent` and
205
+ * the tool's result fields, in precedence order:
206
+ *
207
+ * 1. `outcome` — full replacement authored by the tool.
208
+ * 2. `outcome_patch` — first occurrence of `from` in the intent replaced
209
+ * with `to` (case-sensitive); no-op when `from` is absent or empty.
210
+ * 3. Otherwise the intent is returned UNCHANGED.
211
+ *
212
+ * There is deliberately no mechanical present-progressive→past-tense rewrite.
213
+ * Such a transform can only be a closed list of English verbs, which makes it
214
+ * wrong in three ways at once: it never fires for the non-English labels this
215
+ * feature expects (the model answers in the user's language), it fires for
216
+ * some sibling calls and not others inside one group — "Searched…" beside
217
+ * "Recording…" — and it quietly enumerates a vocabulary in a feature whose
218
+ * premise is that the sentence is free-form. Completion is conveyed by UI
219
+ * state (the shimmer stopping, the icon settling), which is language-neutral
220
+ * and always consistent; a tool that wants past tense says so explicitly via
221
+ * `outcome` or `outcome_patch`.
222
+ *
223
+ * Returns undefined when there is neither an intent nor an outcome, so
224
+ * callers fall back to their default label. Pure and dependency-free — host
225
+ * UIs needing identical logic can import or mirror it.
226
+ */
227
+ export function applyOutcome(
228
+ intent: string | undefined,
229
+ result?: { outcome?: string; outcome_patch?: OutcomePatch },
230
+ ): string | undefined {
231
+ const outcome = result?.outcome;
232
+ if (typeof outcome === 'string' && outcome.trim() !== '') {
233
+ return outcome;
234
+ }
235
+ if (intent == null || intent === '') {
236
+ return undefined;
237
+ }
238
+ const patch = result?.outcome_patch;
239
+ if (patch != null && patch.from !== '' && intent.includes(patch.from)) {
240
+ /** Replacement callback keeps `to` verbatim — a direct string second
241
+ * argument would interpret `$&`/`$'`-style tokens in tool-authored
242
+ * text (e.g. labels derived from shell syntax). */
243
+ return intent.replace(patch.from, () => patch.to);
244
+ }
245
+ return intent;
246
+ }
247
+
248
+ /**
249
+ * Hard cap on an emitted outcome label. The label is a single progress line
250
+ * in UI chrome; a tool that derives it from data (or a malformed patch)
251
+ * must not be able to inflate completion events or persisted parts.
252
+ */
253
+ const MAX_OUTCOME_CHARS = 256;
254
+
255
+ function boundOutcomeLabel(label: string | undefined): string | undefined {
256
+ if (label == null) {
257
+ return undefined;
258
+ }
259
+ const singleLine = label.replace(/\s+/g, ' ').trim();
260
+ if (singleLine === '') {
261
+ return undefined;
262
+ }
263
+ if (singleLine.length <= MAX_OUTCOME_CHARS) {
264
+ return singleLine;
265
+ }
266
+ return `${singleLine.slice(0, MAX_OUTCOME_CHARS - 1)}…`;
267
+ }
268
+
269
+ /**
270
+ * Resolves the settled label to emit on a completion event: only when the
271
+ * tool actually authored `outcome`/`outcome_patch` fields. Returns undefined
272
+ * otherwise, so the wire never carries a label the host already has — a bare
273
+ * intent needs no settled form, because it is displayed unchanged and the UI
274
+ * conveys completion through its own state. Hosts must NOT rewrite it (see
275
+ * {@link applyOutcome} for why a tense transform is deliberately absent). The
276
+ * result is collapsed to a bounded single line before emission.
277
+ *
278
+ * For failed calls (`isError`), only tool-AUTHORED text may label the call:
279
+ * an explicit `outcome`, or a patch whose `from` actually matches the intent.
280
+ * An unmatched patch resolves to undefined rather than silently reusing the
281
+ * in-flight intent, so a failure is never labelled as though it succeeded.
282
+ */
283
+ export function resolveToolOutcome(
284
+ args: unknown,
285
+ fields?: { outcome?: string; outcome_patch?: OutcomePatch } | null,
286
+ options?: { isError?: boolean },
287
+ ): string | undefined {
288
+ if (fields == null || (fields.outcome == null && fields.outcome_patch == null)) {
289
+ return undefined;
290
+ }
291
+ if (options?.isError !== true) {
292
+ return boundOutcomeLabel(applyOutcome(readIntent(args), fields));
293
+ }
294
+ const outcome = fields.outcome;
295
+ if (typeof outcome === 'string' && outcome.trim() !== '') {
296
+ return boundOutcomeLabel(outcome);
297
+ }
298
+ const intent = readIntent(args);
299
+ const patch = fields.outcome_patch;
300
+ if (
301
+ intent != null &&
302
+ patch != null &&
303
+ patch.from !== '' &&
304
+ intent.includes(patch.from)
305
+ ) {
306
+ return boundOutcomeLabel(intent.replace(patch.from, () => patch.to));
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ /**
312
+ * Reads the outcome fields off a tool-execution result: the typed
313
+ * `outcome`/`outcome_patch` fields when present, else the artifact channel
314
+ * (see {@link readOutcomeFields}) — so a `content_and_artifact` tool authors
315
+ * its label the same way on the direct and event-driven paths.
316
+ */
317
+ export function outcomeFieldsFromResult(result: {
318
+ outcome?: string;
319
+ outcome_patch?: OutcomePatch;
320
+ artifact?: unknown;
321
+ }): { outcome?: string; outcome_patch?: OutcomePatch } | undefined {
322
+ if (result.outcome != null || result.outcome_patch != null) {
323
+ return result;
324
+ }
325
+ return readOutcomeFields(result.artifact);
326
+ }
327
+
328
+ /**
329
+ * Extracts validated `outcome`/`outcome_patch` fields from an arbitrary
330
+ * value — the artifact channel through which an in-process
331
+ * `content_and_artifact` tool authors its settled label. Returns undefined
332
+ * when neither field is usable.
333
+ */
334
+ export function readOutcomeFields(
335
+ source: unknown,
336
+ ): { outcome?: string; outcome_patch?: OutcomePatch } | undefined {
337
+ if (source == null || typeof source !== 'object' || Array.isArray(source)) {
338
+ return undefined;
339
+ }
340
+ const record = source as Record<string, unknown>;
341
+ const outcome =
342
+ typeof record.outcome === 'string' && record.outcome.trim() !== ''
343
+ ? record.outcome
344
+ : undefined;
345
+ let outcome_patch: OutcomePatch | undefined;
346
+ const rawPatch = record.outcome_patch;
347
+ if (rawPatch != null && typeof rawPatch === 'object' && !Array.isArray(rawPatch)) {
348
+ const patch = rawPatch as Record<string, unknown>;
349
+ if (typeof patch.from === 'string' && typeof patch.to === 'string') {
350
+ outcome_patch = { from: patch.from, to: patch.to };
351
+ }
352
+ }
353
+ if (outcome == null && outcome_patch == null) {
354
+ return undefined;
355
+ }
356
+ return { outcome, outcome_patch };
357
+ }
@@ -27,7 +27,6 @@ import { resolve } from 'path';
27
27
  import { tool } from '@langchain/core/tools';
28
28
  import type { DynamicStructuredTool } from '@langchain/core/tools';
29
29
  import type { WorkspaceFS } from './workspaceFS';
30
- import { isWorkspaceClientTimeoutError } from './workspaceFS';
31
30
  import type * as t from '@/types';
32
31
  import {
33
32
  getLocalCwd,
@@ -36,12 +35,14 @@ import {
36
35
  truncateLocalOutput,
37
36
  validateBashCommand,
38
37
  } from './LocalExecutionEngine';
38
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
39
+ import { withIntent } from '@/tools/intentArg';
39
40
  import { Constants } from '@/common';
40
41
 
41
42
  /** Back-compat alias; canonical name lives on `Constants.COMPILE_CHECK`. */
42
43
  export const CompileCheckToolName = Constants.COMPILE_CHECK;
43
44
 
44
- const CompileCheckSchema: t.JsonSchemaType = {
45
+ const CompileCheckSchema: t.JsonSchemaType = withIntent({
45
46
  type: 'object',
46
47
  properties: {
47
48
  command: {
@@ -55,7 +56,7 @@ const CompileCheckSchema: t.JsonSchemaType = {
55
56
  'Optional timeout in milliseconds. Defaults to 120000 (2 min).',
56
57
  },
57
58
  },
58
- };
59
+ });
59
60
 
60
61
  type DetectedKind =
61
62
  | 'typescript'
@@ -3,7 +3,6 @@ import { createTwoFilesPatch } from 'diff';
3
3
  import { tool } from '@langchain/core/tools';
4
4
  import type { DynamicStructuredTool } from '@langchain/core/tools';
5
5
  import type * as t from '@/types';
6
- import { isWorkspaceClientTimeoutError } from './workspaceFS';
7
6
  import {
8
7
  createLocalBashProgrammaticToolCallingTool,
9
8
  createLocalProgrammaticToolCallingTool,
@@ -25,9 +24,11 @@ import {
25
24
  } from './CompileCheckTool';
26
25
  import { classifyAttachment, imageAttachmentContent } from './attachments';
27
26
  import { createLocalFileCheckpointer } from './FileCheckpointer';
27
+ import { isWorkspaceClientTimeoutError } from './workspaceFS';
28
28
  import { applyEdit, locateEdit } from './editStrategies';
29
29
  import { decodeFile, encodeFile } from './textEncoding';
30
30
  import { runPostEditSyntaxCheck } from './syntaxCheck';
31
+ import { withIntent } from '@/tools/intentArg';
31
32
  import { Constants } from '@/common';
32
33
 
33
34
  const MAX_READ_CHARS = 256000;
@@ -47,7 +48,7 @@ export const LocalGrepSearchToolName = Constants.GREP_SEARCH;
47
48
  export const LocalGlobSearchToolName = Constants.GLOB_SEARCH;
48
49
  export const LocalListDirectoryToolName = Constants.LIST_DIRECTORY;
49
50
 
50
- export const LocalReadFileToolSchema: t.JsonSchemaType = {
51
+ export const LocalReadFileToolSchema: t.JsonSchemaType = withIntent({
51
52
  type: 'object',
52
53
  properties: {
53
54
  path: {
@@ -65,9 +66,9 @@ export const LocalReadFileToolSchema: t.JsonSchemaType = {
65
66
  },
66
67
  },
67
68
  required: ['path'],
68
- };
69
+ });
69
70
 
70
- export const LocalWriteFileToolSchema: t.JsonSchemaType = {
71
+ export const LocalWriteFileToolSchema: t.JsonSchemaType = withIntent({
71
72
  type: 'object',
72
73
  properties: {
73
74
  path: {
@@ -81,9 +82,9 @@ export const LocalWriteFileToolSchema: t.JsonSchemaType = {
81
82
  },
82
83
  },
83
84
  required: ['path', 'content'],
84
- };
85
+ });
85
86
 
86
- export const LocalEditFileToolSchema: t.JsonSchemaType = {
87
+ export const LocalEditFileToolSchema: t.JsonSchemaType = withIntent({
87
88
  type: 'object',
88
89
  properties: {
89
90
  path: {
@@ -114,9 +115,9 @@ export const LocalEditFileToolSchema: t.JsonSchemaType = {
114
115
  },
115
116
  },
116
117
  required: ['path'],
117
- };
118
+ });
118
119
 
119
- export const LocalGrepSearchToolSchema: t.JsonSchemaType = {
120
+ export const LocalGrepSearchToolSchema: t.JsonSchemaType = withIntent({
120
121
  type: 'object',
121
122
  properties: {
122
123
  pattern: {
@@ -137,9 +138,9 @@ export const LocalGrepSearchToolSchema: t.JsonSchemaType = {
137
138
  },
138
139
  },
139
140
  required: ['pattern'],
140
- };
141
+ });
141
142
 
142
- export const LocalGlobSearchToolSchema: t.JsonSchemaType = {
143
+ export const LocalGlobSearchToolSchema: t.JsonSchemaType = withIntent({
143
144
  type: 'object',
144
145
  properties: {
145
146
  pattern: {
@@ -156,9 +157,9 @@ export const LocalGlobSearchToolSchema: t.JsonSchemaType = {
156
157
  },
157
158
  },
158
159
  required: ['pattern'],
159
- };
160
+ });
160
161
 
161
- export const LocalListDirectoryToolSchema: t.JsonSchemaType = {
162
+ export const LocalListDirectoryToolSchema: t.JsonSchemaType = withIntent({
162
163
  type: 'object',
163
164
  properties: {
164
165
  path: {
@@ -166,7 +167,7 @@ export const LocalListDirectoryToolSchema: t.JsonSchemaType = {
166
167
  description: 'Directory to list. Defaults to cwd.',
167
168
  },
168
169
  },
169
- };
170
+ });
170
171
 
171
172
  function lineWindow(
172
173
  content: string,
@@ -1,3 +1,4 @@
1
+ import type { JsonSchemaType } from '@/types';
1
2
  import { EnvVar } from '@/common';
2
3
 
3
4
  export const DEFAULT_CODE_API_RUN_TIMEOUT_MS = 15_000;
@@ -15,6 +16,7 @@ type TimeoutSchema = {
15
16
  export type ProgrammaticToolCallingJsonSchema = {
16
17
  type: 'object';
17
18
  properties: {
19
+ intent: JsonSchemaType;
18
20
  code: {
19
21
  type: 'string';
20
22
  minLength: number;
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import type * as t from './types';
3
+ import { resolveSearchOutcome } from './tool';
4
+
5
+ const data = (partial: Partial<t.SearchResultData>): t.SearchResultData =>
6
+ ({ turn: 0, ...partial }) as t.SearchResultData;
7
+
8
+ describe('resolveSearchOutcome', () => {
9
+ it('authors a FAILURE label when the processor caught an error', () => {
10
+ expect(
11
+ resolveSearchOutcome(
12
+ data({ error: 'provider timeout', organic: [] }),
13
+ 'oauth handling'
14
+ )
15
+ ).toBe('Search failed for "oauth handling"');
16
+ });
17
+
18
+ it('prefers the failure label over any partial results', () => {
19
+ expect(
20
+ resolveSearchOutcome(
21
+ data({
22
+ error: 'partial failure',
23
+ organic: [{ link: 'a' }] as t.SearchResultData['organic'],
24
+ }),
25
+ 'oauth'
26
+ )
27
+ ).toBe('Search failed for "oauth"');
28
+ });
29
+
30
+ it('ignores an empty-string error', () => {
31
+ expect(
32
+ resolveSearchOutcome(
33
+ data({
34
+ error: '',
35
+ organic: [{ link: 'a' }] as t.SearchResultData['organic'],
36
+ }),
37
+ 'oauth'
38
+ )
39
+ ).toBe('Found 1 result for "oauth"');
40
+ });
41
+
42
+ it('counts every rendered collection kind', () => {
43
+ expect(
44
+ resolveSearchOutcome(
45
+ data({
46
+ organic: [
47
+ { link: 'a' },
48
+ { link: 'b' },
49
+ ] as t.SearchResultData['organic'],
50
+ topStories: [{ link: 'c' }] as t.SearchResultData['topStories'],
51
+ images: [{ imageUrl: 'd' }] as t.SearchResultData['images'],
52
+ videos: [{ link: 'e' }] as t.SearchResultData['videos'],
53
+ places: [{ name: 'f' }] as t.SearchResultData['places'],
54
+ peopleAlsoAsk: [
55
+ { question: 'g' },
56
+ ] as t.SearchResultData['peopleAlsoAsk'],
57
+ }),
58
+ 'oauth'
59
+ )
60
+ ).toBe('Found 7 results for "oauth"');
61
+ });
62
+
63
+ it('counts singleton structured results (knowledge graph / answer box)', () => {
64
+ expect(
65
+ resolveSearchOutcome(
66
+ data({
67
+ knowledgeGraph: {
68
+ title: 'OAuth',
69
+ } as t.SearchResultData['knowledgeGraph'],
70
+ }),
71
+ 'oauth'
72
+ )
73
+ ).toBe('Found 1 result for "oauth"');
74
+ expect(
75
+ resolveSearchOutcome(
76
+ data({
77
+ answerBox: { snippet: 'x' } as t.SearchResultData['answerBox'],
78
+ knowledgeGraph: {
79
+ title: 'OAuth',
80
+ } as t.SearchResultData['knowledgeGraph'],
81
+ }),
82
+ 'oauth'
83
+ )
84
+ ).toBe('Found 2 results for "oauth"');
85
+ });
86
+
87
+ it('leaves a genuine zero-result search unlabeled, so the intent stands', () => {
88
+ expect(resolveSearchOutcome(data({ organic: [] }), 'oauth')).toBeUndefined();
89
+ });
90
+ });
@@ -1,3 +1,5 @@
1
+ import { INTENT_PROPERTY } from '@/tools/intentArg';
2
+
1
3
  export enum DATE_RANGE {
2
4
  PAST_HOUR = 'h',
3
5
  PAST_24_HOURS = 'd',
@@ -71,6 +73,7 @@ export const newsSchema = {
71
73
  export const WebSearchToolSchema = {
72
74
  type: 'object',
73
75
  properties: {
76
+ intent: { ...INTENT_PROPERTY },
74
77
  query: querySchema,
75
78
  date: dateSchema,
76
79
  country: countrySchema,