Skip to content

Commit 86f9174

Browse files
committed
fix: treat empty-string optional integer tool params as unset
Optional positive-integer tool params (e.g. Telegram replyTo/threadId) threw ToolInputError when a tool-calling model populated them with an empty-string or whitespace-only default. Those defaults carry no value, so readPositiveIntegerParam/readNonNegativeIntegerParam now treat a blank string as unset (undefined) instead of throwing, while still rejecting genuinely invalid present values (0, "42.5", "-3"). This prevents silent message-delivery failures when models emit empty routing-param defaults. Adds unit tests covering blank vs invalid.
1 parent 5af24d1 commit 86f9174

2 files changed

Lines changed: 54 additions & 4 deletions

File tree

src/agents/tools/common.params.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,37 @@ describe("readNumberParam", () => {
132132
).toThrow("deleteDays must be an integer from 0 to 7");
133133
});
134134

135+
it("treats empty or whitespace-only strings as unset for optional positive integer params", () => {
136+
// Tool-calling models routinely emit empty-string defaults for optional
137+
// params (e.g. Telegram replyTo/threadId) they are not actually setting.
138+
// An empty/whitespace string carries no value and must not throw.
139+
expect(readPositiveIntegerParam({ replyTo: "" }, "replyTo")).toBeUndefined();
140+
expect(readPositiveIntegerParam({ threadId: " " }, "threadId")).toBeUndefined();
141+
expect(readPositiveIntegerParam({ replyTo: "\t\n" }, "replyTo")).toBeUndefined();
142+
// Genuinely invalid present values must still throw.
143+
expect(() => readPositiveIntegerParam({ replyTo: "0" }, "replyTo")).toThrow(
144+
"replyTo must be a positive integer",
145+
);
146+
expect(() => readPositiveIntegerParam({ replyTo: 0 }, "replyTo")).toThrow(
147+
"replyTo must be a positive integer",
148+
);
149+
expect(() => readPositiveIntegerParam({ replyTo: "-3" }, "replyTo")).toThrow(
150+
"replyTo must be a positive integer",
151+
);
152+
});
153+
154+
it("treats empty or whitespace-only strings as unset for optional non-negative integer params", () => {
155+
expect(readNonNegativeIntegerParam({ position: "" }, "position")).toBeUndefined();
156+
expect(readNonNegativeIntegerParam({ position: " " }, "position")).toBeUndefined();
157+
// A present, valid zero is still a real value.
158+
expect(readNonNegativeIntegerParam({ position: "0" }, "position")).toBe(0);
159+
expect(readNonNegativeIntegerParam({ position: 0 }, "position")).toBe(0);
160+
// Genuinely invalid present values must still throw.
161+
expect(() => readNonNegativeIntegerParam({ position: "4.5" }, "position")).toThrow(
162+
"position must be a non-negative integer",
163+
);
164+
});
165+
135166
it("throws for invalid present bounded finite number params", () => {
136167
expect(readFiniteNumberParam({ quality: "0.75" }, "quality")).toBe(0.75);
137168
expect(readFiniteNumberParam({ quality: null }, "quality")).toBeUndefined();

src/agents/tools/common.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ function readParamRaw(params: Record<string, unknown>, key: string): unknown {
112112
return readSnakeCaseParamRaw(params, key);
113113
}
114114

115+
/**
116+
* True when a raw tool-param value should be treated as "not provided".
117+
*
118+
* Tool-calling models frequently populate every optional field with an empty
119+
* or whitespace-only string default even when the caller did not set it. Such
120+
* a value carries no information and must not be treated as an invalid present
121+
* value. This mirrors how {@link readStringParam} / {@link readNumberParam}
122+
* already ignore empty/whitespace strings.
123+
*/
124+
function isBlankParamValue(raw: unknown): boolean {
125+
return typeof raw === "string" && raw.trim() === "";
126+
}
127+
115128
export function readStringParam(
116129
params: Record<string, unknown>,
117130
key: string,
@@ -244,8 +257,11 @@ export function readPositiveIntegerParam(
244257
positiveInteger: true,
245258
strict: true,
246259
});
247-
if (value === undefined && readParamRaw(params, key) != null) {
248-
throw new ToolInputError(options.message ?? `${key} must be a positive integer`);
260+
if (value === undefined) {
261+
const raw = readParamRaw(params, key);
262+
if (raw != null && !isBlankParamValue(raw)) {
263+
throw new ToolInputError(options.message ?? `${key} must be a positive integer`);
264+
}
249265
}
250266
if (value !== undefined && options.max !== undefined && value > options.max) {
251267
throw new ToolInputError(options.message ?? `${key} must be a positive integer`);
@@ -265,8 +281,11 @@ export function readNonNegativeIntegerParam(
265281
nonNegativeInteger: true,
266282
strict: true,
267283
});
268-
if (value === undefined && readParamRaw(params, key) != null) {
269-
throw new ToolInputError(options.message ?? `${key} must be a non-negative integer`);
284+
if (value === undefined) {
285+
const raw = readParamRaw(params, key);
286+
if (raw != null && !isBlankParamValue(raw)) {
287+
throw new ToolInputError(options.message ?? `${key} must be a non-negative integer`);
288+
}
270289
}
271290
if (value !== undefined && options.max !== undefined && value > options.max) {
272291
throw new ToolInputError(options.message ?? `${key} must be a non-negative integer`);

0 commit comments

Comments
 (0)