@librechat/agents 3.3.6 → 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.
@@ -4,6 +4,8 @@ import {
4
4
  INTENT_ARG,
5
5
  INTENT_PROPERTY,
6
6
  INTENT_DESCRIPTION,
7
+ INTENT_LABEL_MARKER,
8
+ withoutIntent,
7
9
  withIntent,
8
10
  readIntent,
9
11
  stripIntent,
@@ -11,6 +13,7 @@ import {
11
13
  readOutcomeFields,
12
14
  resolveToolOutcome,
13
15
  } from '../intentArg';
16
+ import { ReadFileToolSchema } from '../ReadFile';
14
17
 
15
18
  describe('withIntent', () => {
16
19
  const base: JsonSchemaType = {
@@ -72,7 +75,83 @@ describe('withIntent', () => {
72
75
  it('carries the model-facing instruction', () => {
73
76
  expect(INTENT_PROPERTY.description).toBe(INTENT_DESCRIPTION);
74
77
  expect(INTENT_DESCRIPTION).toContain('FIRST');
75
- expect(INTENT_DESCRIPTION).toContain('distinguish that call from its siblings');
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);
76
155
  });
77
156
  });
78
157
 
@@ -130,7 +209,7 @@ describe('applyOutcome', () => {
130
209
  });
131
210
 
132
211
  it('ignores a blank outcome', () => {
133
- expect(applyOutcome(intent, { outcome: ' ' })).toBe('Searched for OAuth handling');
212
+ expect(applyOutcome(intent, { outcome: ' ' })).toBe(intent);
134
213
  });
135
214
 
136
215
  it('applies outcome_patch to the first occurrence only, case-sensitive', () => {
@@ -141,34 +220,31 @@ describe('applyOutcome', () => {
141
220
  ).toBe('Searched for Searching patterns');
142
221
  expect(
143
222
  applyOutcome(intent, { outcome_patch: { from: 'searching', to: 'searched' } })
144
- ).toBe('Searched for OAuth handling');
223
+ ).toBe(intent);
145
224
  });
146
225
 
147
226
  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
- );
227
+ expect(applyOutcome(intent, { outcome_patch: { from: '', to: 'x' } })).toBe(intent);
151
228
  expect(applyOutcome(intent, { outcome_patch: { from: 'Grepping', to: 'Grepped' } })).toBe(
152
- 'Searched for OAuth handling'
229
+ intent
153
230
  );
154
231
  });
155
232
 
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');
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
+ }
172
248
  });
173
249
 
174
250
  it('returns undefined with neither intent nor outcome', () => {
@@ -215,7 +291,7 @@ describe('resolveToolOutcome', () => {
215
291
  expect(oversized?.length).toBe(256);
216
292
  expect(oversized?.endsWith('…')).toBe(true);
217
293
  expect(resolveToolOutcome(args, { outcome: ' \n \t ' })).toBe(
218
- 'Searched for OAuth handling'
294
+ 'Searching for OAuth handling'
219
295
  );
220
296
  });
221
297
 
@@ -240,7 +316,7 @@ describe('resolveToolOutcome', () => {
240
316
  ).toBe('Search failed for OAuth handling');
241
317
  });
242
318
 
243
- it('never falls back to the mechanical transform on a failed call', () => {
319
+ it('never reuses the in-flight intent as a failed call\'s settled label', () => {
244
320
  expect(
245
321
  resolveToolOutcome(
246
322
  args,
@@ -8,8 +8,9 @@
8
8
  * args, so a host UI can render it as the call's live status label before the
9
9
  * rest of the args exist. When the call settles, {@link applyOutcome} edits
10
10
  * the sentence in place into its outcome form — a tool-supplied replacement
11
- * (`outcome`), a tool-supplied span edit (`outcome_patch`), or a mechanical
12
- * present-progressive→past-tense transform of the leading verb.
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.
13
14
  *
14
15
  * The arg is always optional (never listed in `required`): the same schemas
15
16
  * are callable from programmatic tool calling, where no UI renders a label
@@ -23,15 +24,34 @@ import type { JsonSchemaType, OutcomePatch } from '@/types';
23
24
  /** Argument carrying the model-authored label for a tool call. */
24
25
  export const INTENT_ARG = 'intent';
25
26
 
26
- /** Model-facing instruction for the injected `intent` property. */
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
+ */
27
50
  export const INTENT_DESCRIPTION =
28
- 'ALWAYS write this field FIRST, before any other argument. One short sentence, ' +
29
- 'present progressive, stating what this specific call is about to do: ' +
30
- '"Searching for OAuth handling in the callback router". It is shown to the user ' +
31
- 'as the live status label for this call while it runs, so write it for a human ' +
32
- 'reading a progress line. Do not restate the tool name. Do not exceed one sentence. ' +
33
- 'When you make several calls to the same tool in one turn, each intent must ' +
34
- 'distinguish that call from its siblings.';
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.';
35
55
 
36
56
  /**
37
57
  * Canonical (frozen) shape of the injected property. Always embed a COPY
@@ -59,10 +79,60 @@ export function isIntentLabelProperty(property: unknown): boolean {
59
79
  return (
60
80
  record.type === 'string' &&
61
81
  typeof record.description === 'string' &&
62
- record.description.startsWith('ALWAYS write this field FIRST')
82
+ record.description.startsWith(INTENT_LABEL_MARKER)
63
83
  );
64
84
  }
65
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
+
66
136
  /**
67
137
  * Returns a copy of the parameters schema with `intent` prepended as the
68
138
  * FIRST property (object key order is insertion order and every provider
@@ -130,53 +200,6 @@ export function stripIntent(args: unknown): unknown {
130
200
  return rest;
131
201
  }
132
202
 
133
- /**
134
- * Leading-verb map for the mechanical outcome transform, keyed by the
135
- * lowercased first word of the intent. Deliberately small: an unknown leading
136
- * word leaves the intent unchanged rather than mangling it.
137
- */
138
- const OUTCOME_VERB_MAP: ReadonlyMap<string, string> = new Map([
139
- ['searching', 'Searched'],
140
- ['reading', 'Read'],
141
- ['writing', 'Wrote'],
142
- ['editing', 'Edited'],
143
- ['running', 'Ran'],
144
- ['creating', 'Created'],
145
- ['checking', 'Checked'],
146
- ['fetching', 'Fetched'],
147
- ['listing', 'Listed'],
148
- ['looking', 'Looked'],
149
- ['building', 'Built'],
150
- ['deleting', 'Deleted'],
151
- ['updating', 'Updated'],
152
- ['adding', 'Added'],
153
- ['removing', 'Removed'],
154
- ['verifying', 'Verified'],
155
- ['analyzing', 'Analyzed'],
156
- ['generating', 'Generated'],
157
- ['delegating', 'Delegated'],
158
- ['spawning', 'Spawned'],
159
- ['compiling', 'Compiled'],
160
- ['grepping', 'Grepped'],
161
- ]);
162
-
163
- function matchLeadingCase(replacement: string, original: string): string {
164
- if (original.charAt(0) === original.charAt(0).toLowerCase()) {
165
- return replacement.charAt(0).toLowerCase() + replacement.slice(1);
166
- }
167
- return replacement;
168
- }
169
-
170
- function transformLeadingVerb(intent: string): string {
171
- const spaceIdx = intent.search(/\s/);
172
- const leading = spaceIdx === -1 ? intent : intent.slice(0, spaceIdx);
173
- const mapped = OUTCOME_VERB_MAP.get(leading.toLowerCase());
174
- if (mapped == null) {
175
- return intent;
176
- }
177
- return matchLeadingCase(mapped, leading) + intent.slice(leading.length);
178
- }
179
-
180
203
  /**
181
204
  * Resolves the settled label for a call from its model-authored `intent` and
182
205
  * the tool's result fields, in precedence order:
@@ -184,8 +207,18 @@ function transformLeadingVerb(intent: string): string {
184
207
  * 1. `outcome` — full replacement authored by the tool.
185
208
  * 2. `outcome_patch` — first occurrence of `from` in the intent replaced
186
209
  * with `to` (case-sensitive); no-op when `from` is absent or empty.
187
- * 3. Mechanical transform — the leading word mapped present-progressive →
188
- * past tense; an unknown leading word leaves the intent unchanged.
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`.
189
222
  *
190
223
  * Returns undefined when there is neither an intent nor an outcome, so
191
224
  * callers fall back to their default label. Pure and dependency-free — host
@@ -209,7 +242,7 @@ export function applyOutcome(
209
242
  * text (e.g. labels derived from shell syntax). */
210
243
  return intent.replace(patch.from, () => patch.to);
211
244
  }
212
- return transformLeadingVerb(intent);
245
+ return intent;
213
246
  }
214
247
 
215
248
  /**
@@ -236,15 +269,16 @@ function boundOutcomeLabel(label: string | undefined): string | undefined {
236
269
  /**
237
270
  * Resolves the settled label to emit on a completion event: only when the
238
271
  * tool actually authored `outcome`/`outcome_patch` fields. Returns undefined
239
- * otherwise the mechanical transform of a bare intent is left to the host
240
- * so the wire never carries a label the host can derive itself. The result
241
- * is collapsed to a bounded single line before emission.
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.
242
277
  *
243
278
  * For failed calls (`isError`), only tool-AUTHORED text may label the call:
244
- * an explicit `outcome`, or a patch whose `from` actually matches the
245
- * intent. An unmatched patch must not fall through to the mechanical
246
- * past-tense transform wording drift in a failure patch would otherwise
247
- * render a success-looking label for an error.
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.
248
282
  */
249
283
  export function resolveToolOutcome(
250
284
  args: unknown,
@@ -84,7 +84,7 @@ describe('resolveSearchOutcome', () => {
84
84
  ).toBe('Found 2 results for "oauth"');
85
85
  });
86
86
 
87
- it('leaves a genuine zero-result search to the mechanical transform', () => {
87
+ it('leaves a genuine zero-result search unlabeled, so the intent stands', () => {
88
88
  expect(resolveSearchOutcome(data({ organic: [] }), 'oauth')).toBeUndefined();
89
89
  });
90
90
  });
@@ -35,12 +35,12 @@ import { Constants } from '@/common';
35
35
  *
36
36
  * A caught provider or processing failure is reported through `data.error`
37
37
  * while the tool still returns NORMALLY, so that case must author its own
38
- * label: the `ToolMessage` carries success status, and a bare intent would
39
- * otherwise settle mechanically from "Searching…" to "Searched…" and present
40
- * a failed search as a successful one.
38
+ * label: the `ToolMessage` carries success status, so without an authored
39
+ * outcome the in-flight intent ("Searching…") would stand as the settled
40
+ * label and present a failed search as an ordinary one.
41
41
  *
42
- * Returns undefined for a genuine zero-result search, leaving the host's
43
- * mechanical past-tense transform to label it.
42
+ * Returns undefined for a genuine zero-result search, leaving the
43
+ * model-authored intent to stand unchanged as the label.
44
44
  */
45
45
  export function resolveSearchOutcome(
46
46
  data: t.SearchResultData,
@@ -145,8 +145,14 @@ export type ProcessedToolCall = {
145
145
  /**
146
146
  * Settled label for the call, resolved from the tool-supplied
147
147
  * `outcome`/`outcome_patch` result fields against the model-authored
148
- * `intent` arg. Only present when the tool authored one — hosts apply
149
- * the mechanical intent transform themselves when absent.
148
+ * `intent` arg. Present ONLY when the tool authored one.
149
+ *
150
+ * When absent, display the `intent` arg unchanged — do NOT rewrite its
151
+ * tense. A gerund→past-tense rewrite can only be a closed list of English
152
+ * verbs, so it never fires for the non-English labels this feature expects
153
+ * and fires for some sibling calls but not others within one group.
154
+ * Completion belongs to UI state (the shimmer stopping, the icon settling),
155
+ * which is language-neutral and always consistent.
150
156
  */
151
157
  outcome?: string;
152
158
  };