Skip to content

Commit e399a92

Browse files
leno23amknight
andauthored
fix(anthropic): preserve unsafe integer tool inputs (#83063)
* fix(anthropic): preserve unsafe integer tool inputs Fixes #47229 * docs: add Anthropic unsafe integer changelog * fix: narrow Anthropic partial JSON type --------- Co-authored-by: Alex Knight <aknight@atlassian.com>
1 parent 36e76ef commit e399a92

4 files changed

Lines changed: 210 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424

2525
### Fixes
2626

27+
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
2728
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
2829
- Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.
2930
- Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.

src/agents/anthropic-transport-stream.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,63 @@ describe("anthropic transport stream", () => {
416416
expect(result.errorMessage).toBe("OpenClaw transport error: malformed_streaming_fragment");
417417
});
418418

419+
it("preserves unsafe integer Anthropic tool-use input deltas", async () => {
420+
guardedFetchMock.mockResolvedValueOnce(
421+
createSseResponse([
422+
{
423+
type: "message_start",
424+
message: { id: "msg_unsafe", usage: { input_tokens: 10, output_tokens: 0 } },
425+
},
426+
{
427+
type: "content_block_start",
428+
index: 0,
429+
content_block: {
430+
type: "tool_use",
431+
id: "tool_unsafe",
432+
name: "send_message",
433+
input: {},
434+
},
435+
},
436+
{
437+
type: "content_block_delta",
438+
index: 0,
439+
delta: {
440+
type: "input_json_delta",
441+
partial_json:
442+
'{"to":1481220477346119781,"safe":42,"maxSafe":9007199254740991,"nested":{"ids":[9007199254740993,-9007199254740992]}}',
443+
},
444+
},
445+
{ type: "content_block_stop", index: 0 },
446+
{
447+
type: "message_delta",
448+
delta: { stop_reason: "tool_use" },
449+
usage: { input_tokens: 10, output_tokens: 5 },
450+
},
451+
]),
452+
);
453+
454+
const result = await runTransportStream(
455+
makeAnthropicTransportModel(),
456+
{
457+
messages: [{ role: "user", content: "message this channel" }],
458+
} as AnthropicStreamContext,
459+
{
460+
apiKey: "sk-ant-api",
461+
} as AnthropicStreamOptions,
462+
);
463+
464+
const toolCall = findRecord(
465+
result.content,
466+
(record) => record.type === "toolCall" && record.name === "send_message",
467+
);
468+
expect(toolCall.arguments).toEqual({
469+
to: "1481220477346119781",
470+
safe: 42,
471+
maxSafe: 9007199254740991,
472+
nested: { ids: ["9007199254740993", "-9007199254740992"] },
473+
});
474+
});
475+
419476
it("preserves Anthropic OAuth identity and tool-name remapping with transport overrides", async () => {
420477
guardedFetchMock.mockResolvedValueOnce(
421478
createSseResponse([

src/agents/anthropic-transport-stream.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
resolveAnthropicPayloadPolicy,
1717
} from "./anthropic-payload-policy.js";
1818
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
19+
import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js";
1920
import { resolveProviderEndpoint } from "./provider-attribution.js";
2021
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
2122
import { transformTransportMessages } from "./transport-message-transform.js";
@@ -497,6 +498,10 @@ function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
497498
return converted;
498499
}
499500

501+
function parseAnthropicToolCallArguments(inputJson: string): unknown {
502+
return parseJsonObjectPreservingUnsafeIntegers(inputJson) ?? parseStreamingJson(inputJson);
503+
}
504+
500505
function mapStopReason(reason: string | undefined): string {
501506
switch (reason) {
502507
case "end_turn":
@@ -1267,8 +1272,9 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
12671272
delta?.type === "input_json_delta" &&
12681273
typeof delta.partial_json === "string"
12691274
) {
1270-
block.partialJson += delta.partial_json;
1271-
block.arguments = parseStreamingJson(block.partialJson);
1275+
const partialJson = `${block.partialJson ?? ""}${delta.partial_json}`;
1276+
block.partialJson = partialJson;
1277+
block.arguments = parseAnthropicToolCallArguments(partialJson);
12721278
stream.push({
12731279
type: "toolcall_delta",
12741280
contentIndex: index,
@@ -1316,7 +1322,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
13161322
}
13171323
if (block.type === "toolCall") {
13181324
if (typeof block.partialJson === "string" && block.partialJson.length > 0) {
1319-
block.arguments = parseStreamingJson(block.partialJson);
1325+
block.arguments = parseAnthropicToolCallArguments(block.partialJson);
13201326
}
13211327
delete block.partialJson;
13221328
stream.push({

src/agents/json-unsafe-integers.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER);
2+
3+
function isAsciiDigit(ch: string | undefined): boolean {
4+
return ch !== undefined && ch >= "0" && ch <= "9";
5+
}
6+
7+
function parseJsonNumberToken(
8+
input: string,
9+
start: number,
10+
): { token: string; end: number; isInteger: boolean } | null {
11+
let idx = start;
12+
if (input[idx] === "-") {
13+
idx += 1;
14+
}
15+
if (idx >= input.length) {
16+
return null;
17+
}
18+
19+
if (input[idx] === "0") {
20+
idx += 1;
21+
} else if (isAsciiDigit(input[idx]) && input[idx] !== "0") {
22+
while (isAsciiDigit(input[idx])) {
23+
idx += 1;
24+
}
25+
} else {
26+
return null;
27+
}
28+
29+
let isInteger = true;
30+
if (input[idx] === ".") {
31+
isInteger = false;
32+
idx += 1;
33+
if (!isAsciiDigit(input[idx])) {
34+
return null;
35+
}
36+
while (isAsciiDigit(input[idx])) {
37+
idx += 1;
38+
}
39+
}
40+
41+
if (input[idx] === "e" || input[idx] === "E") {
42+
isInteger = false;
43+
idx += 1;
44+
if (input[idx] === "+" || input[idx] === "-") {
45+
idx += 1;
46+
}
47+
if (!isAsciiDigit(input[idx])) {
48+
return null;
49+
}
50+
while (isAsciiDigit(input[idx])) {
51+
idx += 1;
52+
}
53+
}
54+
55+
return {
56+
token: input.slice(start, idx),
57+
end: idx,
58+
isInteger,
59+
};
60+
}
61+
62+
function isUnsafeIntegerLiteral(token: string): boolean {
63+
const digits = token[0] === "-" ? token.slice(1) : token;
64+
if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) {
65+
return false;
66+
}
67+
if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) {
68+
return true;
69+
}
70+
return digits > MAX_SAFE_INTEGER_ABS_STR;
71+
}
72+
73+
export function quoteUnsafeIntegerLiterals(input: string): string {
74+
let out = "";
75+
let inString = false;
76+
let escaped = false;
77+
let idx = 0;
78+
79+
while (idx < input.length) {
80+
const ch = input[idx] ?? "";
81+
if (inString) {
82+
out += ch;
83+
if (escaped) {
84+
escaped = false;
85+
} else if (ch === "\\") {
86+
escaped = true;
87+
} else if (ch === '"') {
88+
inString = false;
89+
}
90+
idx += 1;
91+
continue;
92+
}
93+
94+
if (ch === '"') {
95+
inString = true;
96+
out += ch;
97+
idx += 1;
98+
continue;
99+
}
100+
101+
if (ch === "-" || isAsciiDigit(ch)) {
102+
const parsed = parseJsonNumberToken(input, idx);
103+
if (parsed) {
104+
if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) {
105+
out += `"${parsed.token}"`;
106+
} else {
107+
out += parsed.token;
108+
}
109+
idx = parsed.end;
110+
continue;
111+
}
112+
}
113+
114+
out += ch;
115+
idx += 1;
116+
}
117+
118+
return out;
119+
}
120+
121+
export function parseJsonPreservingUnsafeIntegers(input: string): unknown {
122+
return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown;
123+
}
124+
125+
export function parseJsonObjectPreservingUnsafeIntegers(
126+
value: unknown,
127+
): Record<string, unknown> | null {
128+
if (typeof value === "string") {
129+
try {
130+
const parsed = parseJsonPreservingUnsafeIntegers(value);
131+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
132+
return parsed as Record<string, unknown>;
133+
}
134+
} catch {
135+
return null;
136+
}
137+
return null;
138+
}
139+
if (value && typeof value === "object" && !Array.isArray(value)) {
140+
return value as Record<string, unknown>;
141+
}
142+
return null;
143+
}

0 commit comments

Comments
 (0)