@@ -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 = / ^ d a t a : \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[] {
805866export 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
816878function 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