Skip to content

Bedrock Converse: fallback sticking, countTokens, review fixes, live matrix (#272, part 2/2) - #284

Open
dougcain wants to merge 7 commits into
ortus-boxlang:developmentfrom
dougcain:feature/bedrock-converse-hardening-272
Open

Bedrock Converse: fallback sticking, countTokens, review fixes, live matrix (#272, part 2/2)#284
dougcain wants to merge 7 commits into
ortus-boxlang:developmentfrom
dougcain:feature/bedrock-converse-hardening-272

Conversation

@dougcain

@dougcain dougcain commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 2 of 2 of the former #278. Closes #272 with #283. Stacked on #283; review only the top commit. Descends from the former #278 head (1871695) plus the folded review fixes and two follow-up commits; re-verified live 11/11 on f5459e4. #279 (stream-callback propagation) is rebased on top of this.

Fallback and API decision

  • Converse → InvokeModel fallback sticks per request (stickBedrockApi / stuckBedrockApi) in AiChatRequest.runtimeOptions, never written into caller-owned providerOptions. Refused after the tool loop has started, after a stream has delivered a chunk, and for feature-level 400s (toolChoice, toolResult, images…) where AWS's message is surfaced verbatim; throughput / profile / auth / throttling never trigger it. Model-id regex no longer trips on inference profiles; error headers parsed.
  • countTokens() counts whichever body the request would actually send (resolveBedrockApi( honourRuntimeDecision=false )) instead of a previous call's stick; 400 hint about the bare-Anthropic-id restriction.
  • Suspended batches resume in the dialect they were built in (bedrockApi / toolDialect in the suspension payload, ResumeLedgerMismatch otherwise).

Review fixes (xhigh pass, 15 findings)

  • Request transform coercions: scalar image_url, array system, numeric max_tokens, data-URI mime types, finishReason vocabulary, tool status sniffing, max_tokens vs max_completion_tokens for OpenAI-shaped families. Guardrail / performance options accepted under camelCase or X-Amzn-Bedrock-* header spelling. String event-stream replay independent of key order. Streamed Claude/OpenAI assistant turns keep text and thinking blocks alongside tool blocks.
  • Middleware parity: wrapLLMCall receives the same context as beforeLLMCall; body serialized inside the transport closure so in-place edits reach the wire; caller stream-callback exceptions announced on onAIError and propagated. Tool loops route through BaseService::resolveToolArgs.
  • PromptSecurity readTextBlocks / writeTextBlocks shared by the Converse and Claude shapes (all text blocks, not only the first).

Live matrix

BedrockConverseLiveTest (11 cases) behind a new ./gradlew testBedrockLive task, excluded from test; BedrockLiveTestBase shared with BedrockTest; BedrockReviewRegressionTest (5, HttpServer stub) pins the review findings.

Tests

BedrockServiceTest → 163, BedrockReviewRegressionTest 5, SuspendResumeIntegrationTest +3 Converse variants, BedrockTest 3. Live (eu-west-2, 2026-09-07, on the top of the stack): testBedrockLive 11/11 — Claude haiku 4.5 chat/stream/tool loop/thinking/invoke opt-out, Nova micro, Mistral 7B, gpt-oss stream + forced-toolChoice structured output, CountTokens, plus (re-run on f5459e4) an object tool_choice mapping to Converse toolChoice.tool with "none" omitting toolConfig, and the streaming beforeLLMCall context carrying modelId + operation: "converse-stream". Note observed live: Converse toolChoice.tool makes the tool required, not exclusive — Claude may call sibling tools too and re-sends the forced call every turn, so set maxInteractions when forcing.

Size after review fixes: main +1296/−428 in 4 files, tests +3249.

Known leftovers (unchanged from #278)

Tool exceptions still propagate out of chat() (module-wide); serviceTier / outputConfig.textFormat not wired; ApplyGuardrail out of scope; structured output on Converse not visible to OutputGuardMiddleware; a real Converse ValidationException for an unsupported model was never observed live, so CONVERSE_UNSUPPORTED_PATTERN is inferred.

Review fixes folded in (xhigh code review of the stack, 2026-09-07)

Commits 761cb9d (review fixes), 64af32c (object tool_choice guard, modelId/operation in the stream middleware context), f5459e4 (two live cases). Each item also has a changelog bullet linking this PR.

  • Bedrock streaming failures are always announced
  • The bedrockHeaders shorthand goes through the Converse guardrail filter
  • Cohere, Titan, Llama and Mistral streams now report a finish reason
  • Streaming structured output on an OpenAI-shaped model over InvokeModel is refused before the call
  • countTokens() documents the scope of its count

…r toolArgs, streaming-resume guard (ortus-boxlang#248)

Cross-provider half of ortus-boxlang#248, independent of the Cohere and Bedrock streaming tool loops
that build on it:

- BaseService::resolveToolArgs / sameToolArgs: single resolver for middleware-edited tool
  arguments (normalized slot first, native-slot divergence detected, non-struct edits fall
  back to native args; canonical-JSON comparison because struct equals() is false for JSON
  null). Claude and OpenAI route both passes through it; OpenAI previously never read
  ctx.toolArgs at all.
- HumanInTheLoopMiddleware edit decisions write both ctx.toolArgs and the native slot so an
  edit survives an earlier middleware rewrite. IAiMiddleware afterToolBatch doc: toolArgs
  frozen after beforeToolCall.
- Resume ledger entries carry the resolved toolArgs (Claude, OpenAI); resume executes them
  instead of re-deriving from the raw tool call. Legacy ledgers without the field still work.
- IAiService.supportsStreamingToolResume (structural default in BaseService, Gemini false);
  AiAgent.resumeStream throws StreamingResumeUnsupported instead of silently replaying the LLM.
  resume()/resumeStream() validate decisions before clearing the checkpoint.
- ClosureTool.doInvoke shallow-copies args and injects _chatRequest only when declared, so the
  caller's struct is not mutated into message history.

Tests: ClaudeTest beforeToolCall arg-rewrite cases, AiAgentResumeStreamGuardTest,
SuspendResumeIntegrationTest ledger round trips (Claude, OpenAI, legacy fallback).
…us-boxlang#248)

CohereService.chatStream() forwarded only parsedChunk.text and hardcoded toolCalls: [], so
tools bound to a streamed Cohere call were sent to the model and then silently ignored. The
stream now accumulates Cohere v1 tool events (tool-calls-chunk deltas, authoritative
tool-calls-generation, stream-end fallback) into the same normalized tool calls the blocking
path produces and runs the full tool loop: beforeToolCall/wrapToolCall/afterToolCall, the
decide-then-execute split, afterToolBatch/defer with a combined suspension carrying
assistantMessage + resumeLedger, and a tool_results follow-up turn honouring interactionCount.
chatStream() reads _resumeContext and finishes a suspended batch via resumeToolBatchStream()
with no LLM replay. SSE transport goes through wrapLLMCall (stream: true, emitSSEChunk).

Fixes on the blocking path found while wiring the stream: only the trailing tool exchange is
folded into tool_results; truncated streamed arguments abort instead of running with defaults;
follow-ups that ask for more tools keep the exchange; non-single returnFormat resume no longer
hangs; nameless tool calls are kept; unbound tool names are reported to the model; Pass 2 uses
BaseService::resolveToolArgs and the ledger carries resolved toolArgs.

Builds on feature/tool-arg-resolution-248 (shared resolveToolArgs / ledger / resume guard).

Tests: CohereStreamingToolsTest, SuspendResumeIntegrationTest Cohere stream + ledger cases,
AiAgentResumeStreamGuardTest cohere support assertion.
…del families (ortus-boxlang#248)

BedrockService.chatStream() hardcoded toolCalls: [] and its stream transform dropped tool_use
blocks, so streamed Bedrock calls never ran a tool. The event-stream reader now accumulates
tool_use / input_json_delta blocks into normalized tool calls and runs the same tool loop as
chat(): decide-then-execute split, afterToolBatch/defer with a combined suspension carrying
assistantMessage + resumeLedger, resumeToolBatchStream() to finish a suspended batch without
replaying the LLM, and follow-up turns honouring interactionCount. Structured output works on
the streaming path via the forced-tool pattern.

Model families: OpenAI-shaped Bedrock families (gpt-oss, DeepSeek, Qwen, Jamba, ...) get tools
and response_format; Cohere Command R/R+ on Bedrock gets a real request transform; Nova/Titan/
Llama/Mistral families are detected; cache_control survives on Claude-on-Bedrock system
prompts; stream exceptions surface as errors instead of a truncated answer. Pass 2 routes
through BaseService::resolveToolArgs and the ledger carries resolved toolArgs.

Builds on feature/cohere-streaming-tools-248 (which builds on the shared
feature/tool-arg-resolution-248). Tree after this commit equals the former ortus-boxlang#277 head.

Tests: BedrockServiceTest (families, stream tool loop, structured output),
SuspendResumeIntegrationTest Bedrock stream suspend/resume/reject/cancel/edit cases.
…rtus-boxlang#272)

BedrockService hand-built each vendor's native InvokeModel body. Bedrock's model-agnostic
Converse / ConverseStream operations are now the default: one request transform serves every
family (typed content blocks incl. image/document/toolUse/toolResult/reasoningContent/
cachePoint, system[], inferenceConfig, toolConfig + toolChoice, guardrailConfig,
performanceConfig, additionalModelRequestFields), one response transform maps output/usage/
stopReason onto the OpenAI envelope, and the ConverseStream event reader feeds the same
streaming tool loop as InvokeModel. Structured output is the forced structured_output tool
for all families.

API selection: providerOptions.bedrockApi > BOXLANG_MODULES_BXAI_BEDROCK_API > application
scope > service setting > "converse". Automatic one-shot fallback to InvokeModel for
text-completion-only models, rawBody requests, and Converse ValidationException "unsupported"
answers. countTokens() exposes Bedrock CountTokens. PromptSecurity reads/writes the Converse
output.message.content[] shape so OutputGuard sees the answer on the default path.

Second half of this feature (fallback sticking, review fixes, countTokens body selection,
live matrix) follows in feature/bedrock-converse-hardening-272. Builds on
feature/bedrock-streaming-tools-248.

Tests: BedrockServiceTest Converse transforms/stream/fallback, PromptSecurityRedactTest,
OutputGuardMiddlewareTest Converse case, SuspendResumeIntegrationTest pins bedrockApi:"invoke"
on the InvokeModel cases.
… review fixes, live matrix (ortus-boxlang#272)

Hardening of the Converse path from the code review and live runs against eu-west-2:

- Converse -> InvokeModel fallback sticks per request (stickBedrockApi/stuckBedrockApi) so the
  tool loop and follow-up turns stay on one API; resolveBedrockApi(honourRuntimeDecision) lets
  countTokens count whichever body the request would actually send instead of a stale decision.
  Fallback refuses when the conversation is already in Converse dialect or mid-stream; the
  model-id regex no longer trips on inference profiles; error headers are parsed for the
  ValidationException detection.
- Suspended batches resume in the dialect they were built in (bedrockApi/toolDialect carried in
  the suspension payload; ResumeLedgerMismatch otherwise). Streamed Claude/OpenAI assistant turns
  keep text and thinking blocks alongside tool blocks.
- Request transform coercions: scalar image_url, array system, numeric max_tokens, data-URI
  mime types, finishReason vocabulary, tool status sniffing, max_tokens vs max_completion_tokens
  for OpenAI-shaped families. Guardrail/performance options accepted under either the camelCase
  or the X-Amzn-Bedrock-* header spelling. String event-stream replay no longer depends on key order.
- Middleware parity: wrapLLMCall receives the same context as beforeLLMCall and the body is
  serialized inside the transport closure so in-place wrap edits reach the wire. Caller
  stream-callback exceptions are announced via onAIError and propagate instead of being swallowed.
  Tool loops route through BaseService::resolveToolArgs; AiChatRequest.runtimeOptions carries the
  per-request API decision.
- PromptSecurity getResponseText/setResponseText share readTextBlocks/writeTextBlocks across the
  Converse and Claude shapes (all text blocks, not just the first).
- Live matrix: BedrockConverseLiveTest (9 cases; Claude/Nova/gpt-oss over Converse+stream,
  fallback, countTokens) behind a new `testBedrockLive` gradle task, excluded from `test`.
  BedrockReviewRegressionTest pins the review findings with an HttpServer stub.

Builds on feature/bedrock-converse-core-272. Tree after this commit equals the former ortus-boxlang#278 head.
@dougcain
dougcain force-pushed the feature/bedrock-converse-hardening-272 branch from a371ab8 to 761cb9d Compare September 7, 2026 15:30
…e context

Two BedrockConverseLiveTest cases covering 64af32c against eu-west-2: an object
tool_choice maps to Converse toolChoice.tool (and "none" omits toolConfig), and the
streaming beforeLLMCall context carries modelId + operation "converse-stream".
Live matrix now 11 cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bedrock: adopt Converse / ConverseStream API with InvokeModel fallback

1 participant