Skip to content

Commit 72711d8

Browse files
committed
fix(inference): reject flattened Anthropic tool calls
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent f5198b8 commit 72711d8

6 files changed

Lines changed: 297 additions & 12 deletions

File tree

src/lib/adapters/http/probe.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,75 @@ describe("runAnthropicStreamingEventProbe", () => {
10311031
expect(result.missingEvents).toEqual([]);
10321032
expect(result.duplicateEvents).toEqual([]);
10331033
expect(result.sequenceErrors).toEqual([]);
1034+
expect(result.toolCallErrors).toEqual([]);
1035+
});
1036+
1037+
it("passes when the stream emits the required native tool call", () => {
1038+
const nativeToolStream = [
1039+
"event: message_start",
1040+
'data: {"type":"message_start","message":{"id":"msg_1"}}',
1041+
"",
1042+
"event: content_block_start",
1043+
'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"emit_ok","input":{}}}',
1044+
"",
1045+
"event: content_block_delta",
1046+
'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"value\\":\\"OK\\"}"}}',
1047+
"",
1048+
"event: content_block_stop",
1049+
'data: {"type":"content_block_stop","index":0}',
1050+
"",
1051+
"event: message_delta",
1052+
'data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}',
1053+
"",
1054+
"event: message_stop",
1055+
'data: {"type":"message_stop"}',
1056+
"",
1057+
].join("\n");
1058+
1059+
const result = runAnthropicStreamingEventProbe(
1060+
["-sS", "--max-time", "15", "https://example.test/v1/messages"],
1061+
{ spawnSyncImpl: mockStreaming(nativeToolStream) },
1062+
{ expectedToolName: "emit_ok" },
1063+
);
1064+
1065+
expect(result.ok).toBe(true);
1066+
expect(result.toolCallErrors).toEqual([]);
1067+
});
1068+
1069+
it("rejects JSON-shaped assistant text instead of a native tool call (#7967)", () => {
1070+
const flattenedToolStream = [
1071+
"event: message_start",
1072+
'data: {"type":"message_start","message":{"id":"msg_1"}}',
1073+
"",
1074+
"event: content_block_start",
1075+
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
1076+
"",
1077+
"event: content_block_delta",
1078+
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{\\"name\\":\\"emit_ok\\",\\"arguments\\":{\\"value\\":\\"OK\\"}}"}}',
1079+
"",
1080+
"event: content_block_stop",
1081+
'data: {"type":"content_block_stop","index":0}',
1082+
"",
1083+
"event: message_delta",
1084+
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}',
1085+
"",
1086+
"event: message_stop",
1087+
'data: {"type":"message_stop"}',
1088+
"",
1089+
].join("\n");
1090+
1091+
const result = runAnthropicStreamingEventProbe(
1092+
["-sS", "--max-time", "15", "https://example.test/v1/messages"],
1093+
{ spawnSyncImpl: mockStreaming(flattenedToolStream) },
1094+
{ expectedToolName: "emit_ok" },
1095+
);
1096+
1097+
expect(result.ok).toBe(false);
1098+
expect(result.toolCallErrors).toEqual([
1099+
"missing-expected-tool-use",
1100+
"missing-tool-use-stop-reason",
1101+
]);
1102+
expect(result.message).toContain("required structured tool_use content block");
10341103
});
10351104

10361105
it("rejects a non-2xx response even when its body looks like valid SSE", () => {

src/lib/adapters/http/probe.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,8 @@ interface SseEventCaptureResult {
560560
eventCounts: Map<string, number>;
561561
/** SSE `event:` types in stream order, for sequence validation. */
562562
eventSequence: string[];
563+
/** Captured response body, retained only for protocol-specific validation. */
564+
body: string;
563565
}
564566

565567
/**
@@ -614,6 +616,7 @@ function captureSseEventCounts(
614616
detail,
615617
eventCounts: new Map(),
616618
eventSequence: [],
619+
body: "",
617620
};
618621
}
619622

@@ -632,6 +635,7 @@ function captureSseEventCounts(
632635
),
633636
eventCounts: new Map(),
634637
eventSequence: [],
638+
body: "",
635639
};
636640
}
637641

@@ -654,6 +658,7 @@ function captureSseEventCounts(
654658
detail: "",
655659
eventCounts,
656660
eventSequence,
661+
body,
657662
};
658663
} finally {
659664
cleanupTempDir(bodyFile, tempPrefix);
@@ -753,9 +758,20 @@ export interface AnthropicStreamingProbeResult {
753758
duplicateEvents: string[];
754759
/** Order violations, e.g. content deltas before message_start or after message_stop. */
755760
sequenceErrors: string[];
761+
/** Structured tool-call contract violations when a named tool is required. */
762+
toolCallErrors: AnthropicStreamingToolCallError[];
756763
message: string;
757764
}
758765

766+
export type AnthropicStreamingToolCallError =
767+
| "missing-expected-tool-use"
768+
| "missing-tool-use-stop-reason";
769+
770+
export interface AnthropicStreamingExpectations {
771+
/** Require one structured `tool_use` content block for this exact tool name. */
772+
expectedToolName?: string;
773+
}
774+
759775
/**
760776
* Known Anthropic Messages payload events that must sit between
761777
* `message_start` and `message_stop` in a well-formed stream.
@@ -791,6 +807,51 @@ function anthropicSequenceErrors(eventSequence: string[]): string[] {
791807
return errors;
792808
}
793809

810+
function anthropicToolCallErrors(
811+
body: string,
812+
expectedToolName: string | undefined,
813+
): AnthropicStreamingToolCallError[] {
814+
if (!expectedToolName) return [];
815+
let hasExpectedToolUse = false;
816+
let hasToolUseStopReason = false;
817+
818+
for (const eventBlock of body.split(/\r?\n\r?\n/)) {
819+
const data = eventBlock
820+
.split(/\r?\n/)
821+
.flatMap((line) => {
822+
const match = /^data:\s?(.*)$/i.exec(line);
823+
return match ? [match[1]] : [];
824+
})
825+
.join("\n");
826+
if (!data || data === "[DONE]") continue;
827+
try {
828+
const payload = JSON.parse(data) as {
829+
type?: unknown;
830+
content_block?: { type?: unknown; name?: unknown };
831+
delta?: { stop_reason?: unknown };
832+
};
833+
if (
834+
payload.type === "content_block_start" &&
835+
payload.content_block?.type === "tool_use" &&
836+
payload.content_block.name === expectedToolName
837+
) {
838+
hasExpectedToolUse = true;
839+
}
840+
if (payload.type === "message_delta" && payload.delta?.stop_reason === "tool_use") {
841+
hasToolUseStopReason = true;
842+
}
843+
} catch {
844+
// Malformed data remains covered by the required structured observations
845+
// below; never interpret JSON-shaped assistant text as a tool call.
846+
}
847+
}
848+
849+
return [
850+
...(hasExpectedToolUse ? [] : (["missing-expected-tool-use"] as const)),
851+
...(hasToolUseStopReason ? [] : (["missing-tool-use-stop-reason"] as const)),
852+
];
853+
}
854+
794855
/**
795856
* Send a streaming request to an Anthropic-compatible `/v1/messages`
796857
* endpoint and verify the SSE event stream is well formed: the required
@@ -805,17 +866,19 @@ function anthropicSequenceErrors(eventSequence: string[]): string[] {
805866
export function runAnthropicStreamingEventProbe(
806867
argv: string[],
807868
opts: CurlProbeOptions = {},
869+
expectations: AnthropicStreamingExpectations = {},
808870
): AnthropicStreamingProbeResult {
809871
return withTraceSpan(
810872
"nemoclaw.inference.curl_anthropic_streaming_probe",
811873
getCurlProbeTraceAttributes(argv, opts),
812-
() => runAnthropicStreamingEventProbeImpl(argv, opts),
874+
() => runAnthropicStreamingEventProbeImpl(argv, opts, expectations),
813875
);
814876
}
815877

816878
function runAnthropicStreamingEventProbeImpl(
817879
argv: string[],
818880
opts: CurlProbeOptions = {},
881+
expectations: AnthropicStreamingExpectations = {},
819882
): AnthropicStreamingProbeResult {
820883
try {
821884
const capture = captureSseEventCounts(argv, opts, "nemoclaw-anthropic-streaming-probe", true);
@@ -835,6 +898,7 @@ function runAnthropicStreamingEventProbeImpl(
835898
missingEvents: REQUIRED_ANTHROPIC_STREAMING_EVENTS,
836899
duplicateEvents: [],
837900
sequenceErrors: [],
901+
toolCallErrors: [],
838902
message: `Streaming probe failed: ${compactText(capture.detail).slice(0, 200)}`,
839903
};
840904
}
@@ -847,7 +911,16 @@ function runAnthropicStreamingEventProbeImpl(
847911
);
848912
const sequenceErrors =
849913
missing.length === 0 ? anthropicSequenceErrors(capture.eventSequence) : [];
850-
if (missing.length > 0 || duplicates.length > 0 || sequenceErrors.length > 0) {
914+
const toolCallErrors =
915+
missing.length === 0
916+
? anthropicToolCallErrors(capture.body, expectations.expectedToolName)
917+
: [];
918+
if (
919+
missing.length > 0 ||
920+
duplicates.length > 0 ||
921+
sequenceErrors.length > 0 ||
922+
toolCallErrors.length > 0
923+
) {
851924
const problems: string[] = [];
852925
if (duplicates.length > 0) {
853926
const detail = duplicates
@@ -861,12 +934,19 @@ function runAnthropicStreamingEventProbeImpl(
861934
if (sequenceErrors.length > 0) {
862935
problems.push(`emits events out of order (${sequenceErrors.join("; ")})`);
863936
}
937+
if (toolCallErrors.includes("missing-expected-tool-use")) {
938+
problems.push("does not emit the required structured tool_use content block");
939+
}
940+
if (toolCallErrors.includes("missing-tool-use-stop-reason")) {
941+
problems.push("does not finish the tool request with stop_reason tool_use");
942+
}
864943
emitCurlResultTraceEvent({
865944
ok: false,
866945
http_status: capture.httpStatus,
867946
missing_events_count: missing.length,
868947
duplicate_events_count: duplicates.length,
869948
sequence_errors_count: sequenceErrors.length,
949+
tool_call_errors_count: toolCallErrors.length,
870950
curl_status: capture.curlStatus,
871951
});
872952
return {
@@ -876,9 +956,10 @@ function runAnthropicStreamingEventProbeImpl(
876956
missingEvents: missing,
877957
duplicateEvents: duplicates,
878958
sequenceErrors,
959+
toolCallErrors,
879960
message:
880961
`Anthropic Messages streaming on this endpoint ${problems.join(" and ")}. ` +
881-
"Agent runs use the streaming path and would fail with an empty final response.",
962+
"Agent runs use the streaming path and require native protocol events.",
882963
};
883964
}
884965

@@ -888,6 +969,7 @@ function runAnthropicStreamingEventProbeImpl(
888969
missing_events_count: 0,
889970
duplicate_events_count: 0,
890971
sequence_errors_count: 0,
972+
tool_call_errors_count: 0,
891973
curl_status: capture.curlStatus,
892974
});
893975
return {
@@ -897,6 +979,7 @@ function runAnthropicStreamingEventProbeImpl(
897979
missingEvents: [],
898980
duplicateEvents: [],
899981
sequenceErrors: [],
982+
toolCallErrors: [],
900983
message: "",
901984
};
902985
} catch (error) {
@@ -918,6 +1001,7 @@ function runAnthropicStreamingEventProbeImpl(
9181001
missingEvents: REQUIRED_ANTHROPIC_STREAMING_EVENTS,
9191002
duplicateEvents: [],
9201003
sequenceErrors: [],
1004+
toolCallErrors: [],
9211005
message: `Streaming probe error: ${detail}`,
9221006
};
9231007
}

0 commit comments

Comments
 (0)