Skip to content

feat: carry InvokeModel server-side context features (W1-01) - #32

Merged
samuelboland merged 4 commits into
mainfrom
dux-nativecontext-w1-01-invoke-server-features
Oct 6, 2026
Merged

samuelboland merged 4 commits into
mainfrom
dux-nativecontext-w1-01-invoke-server-features

Conversation

@staging-supernova-dx-appf-io

@staging-supernova-dx-appf-io staging-supernova-dx-appf-io Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

What this does

This PR empirically validates Anthropic's server-side context management features (context editing, threshold compaction, tool search, and thinking-binding controls) against AppFolio's Bedrock account, available only via the InvokeModel API with native Anthropic Messages bodies (not Converse, which the fork normally speaks to Bedrock).

It cherry-picks the prior spike (fork PR #30, commit b5e38127) as a base commit, fills in the gaps that spike left (parsing/replaying the new block types), turns the features on, and records real VCR-cassetted traffic against them. This PR replaces #30 — it targets main, includes the spike's commit as its own commit (not squashed), and builds on it; #30 should be closed in favor of this one.

Key changes:

  • New Protocols::InvokeAnthropic::ContentBlocks < Content::Raw — the "smallest structure" chosen to carry unmodeled reply block types (compaction, server_tool_use, tool_search_tool_result, or unknown blocks) forward by wrapping the full block list for replay, while #text/#to_s still join only text blocks.
  • New/filled-in invoke_anthropic/chat.rb and invoke_anthropic/streaming.rb: request rendering plus sync and streaming response parsing, surfacing context_management (including applied_edits), usage.iterations, input_transformations, and cache_creation ephemeral token counts via Message#provider_data. Also renders defer_loading: true for tool search, and places a mid-conversation role: "system" message as its own system-role turn rather than merging it into top-level system.
  • Message#provider_data: new public reader exposing the extra fields above.
  • RubyLLM::UnsupportedContentError: new error, raised when a message holds content blocks a protocol can't send (e.g. an InvokeModel compaction block replayed through Converse).
  • Converse's chat.rb now guards (reject_messages_api_blocks) against forwarding blocks it can't carry.
  • Bedrock#protocol_for never auto-selects :invoke_anthropic; it remains strictly opt-in via protocol: :invoke_anthropic / config.bedrock_protocol. Converse stays the default.
  • Review fix 1: Array(hash) on a single-Hash Content::Raw was corrupting it into key/value pairs instead of [hash]; fixed with a private helper matching Converse's format_raw_content rule, applied at six call sites.
  • Review fix 2: Provider#render previously returned a body different from what Bedrock#complete actually sent for the invoke protocol; fixed by extracting a shared prepare_params and adding a render override so render and complete agree, while leaving the default (Converse) render behavior unchanged.

Live-recording findings (summarized; also encoded as spec assertions):

  • Context editing (clear_tool_uses_20250919): cleared tool uses/tokens as expected, confirmed with follow-up requests on Opus 5.5 and Sonnet 5.5.
  • Context editing (clear_thinking_20251015): clearing "1 kept turn" clears every earlier turn — a surprising Anthropic server behavior, not a fork bug.
  • Threshold compaction (compact_20260112): confirmed minimum trigger value is 50,000 (49,999 is rejected with HTTP 400); at 50,000 a compaction block is returned and replaying it is accepted.
  • Tool search (defer_loading: true): deferred tools are found via search and replayed search blocks are accepted.
  • Thinking recorded via InvokeModel and replayed through Converse is accepted, not rejected, on both Opus 5.5 and Sonnet 5.5 — the answer the supernova kill-switch design depends on.
  • Production models (Sonnet 5, Opus 5): compaction and tool search pass on Sonnet 5; tool search passes on Opus 5, but Bedrock refused the ~58K-token compaction test prompt as a content-filter false positive (cyber) on Opus 5, so no clean compacted-summary observation exists for that model — flagged honestly rather than papered over.
  • Automatic prompt caching: top-level cache_control: {type: "ephemeral"} confirmed working with a cache-read on repeat requests; cache_creation fields parsed into provider_data.
  • Mid-conversation system messages render as their own system-role turn and are accepted by Bedrock.
  • Bedrock CountTokens is denied for the agent's current IAM role (403) — not pursued further, per instructions to stop on access-denied errors.
  • Anthropic's refusal stop/finish reason is not currently recognized by Message#content_filtered? — a known gap left unaddressed, flagged as a possible follow-up.

Nothing here is wired into supernova yet; that is covered by a later brief. VCR cassettes were scanned for leaked secrets at every recording stage — only <AWS_...> placeholders were found. Estimated live spend was roughly $1 at list prices, dominated by ~58K-token compaction requests.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Performance improvement

Scope check

  • I read the Contributing Guide
  • This aligns with RubyLLM's focus on LLM communication
  • This isn't application-specific logic that belongs in user code
  • This benefits most users, not just my specific use case

Required for new features

No Jira ticket exists for this work. This PR explicitly supersedes/replaces fork PR #30 (the spike this work builds on); whether a separately approved feature issue exists was not established in this session.

  • I opened an issue before writing code and received maintainer approval
  • Linked issue: #___

Quality check

  • I ran overcommit --install and all hooks pass
  • I tested my changes thoroughly
    • For provider changes: Re-recorded VCR cassettes with bundle exec rake vcr:record[provider_name]
    • All tests pass: bundle exec rspec
  • I updated documentation if needed
  • I didn't modify auto-generated files manually (models.json, aliases.json)

New VCR cassettes were recorded via live calls against AppFolio's Bedrock account (us-west-2) and scanned for leaked secrets (only <AWS_...> placeholders found), rather than via bundle exec rake vcr:record[provider_name] directly. Targeted/offline specs for all touched areas pass under CI=1 (913 examples, 0 failures, 63 pre-existing pending; offline spec/ruby_llm/protocols/ replay: 298 examples, 0 failures) and bundle exec rubocop is clean across 367 files with no new disables. The full bundle exec rspec suite was not run in this session; the author relied on targeted/offline runs and CI to cover the rest.

AI-generated code

  • I used AI tools to help write this code
  • I have reviewed and understand all generated code (required if above is checked)

API changes

  • Breaking change
  • New public methods/classes
  • Changed method signatures
  • No API changes

New: Message#provider_data, RubyLLM::UnsupportedContentError, Bedrock#render override, Protocols::InvokeAnthropic::ContentBlocks. The default (Converse) behavior of Bedrock#render is unchanged and explicitly covered by a new spec asserting render without protocol: is unaffected.

appf-supernova and others added 2 commits October 5, 2026 21:26
…ssages bodies)

Opt-in third Bedrock protocol that POSTs Anthropic Messages bodies to
/model/{id}/invoke and /invoke-with-response-stream (SigV4-signed),
reusing the Anthropic protocol's payload assembly and parsing. Converse
stays the default; protocol_for never selects it.

- Extract Converse::EventStream (eventstream POST/decoding/exception
  mapping) and share it between ConverseStream and InvokeModel streaming
- finalize_invoke_payload is the single envelope hook (anthropic_version,
  de-duplicated anthropic_beta) for later beta/context_management work
- Thinking/redacted_thinking blocks captured verbatim and replayed on
  either endpoint (Converse <-> Messages shape translation both ways)
- Bedrock#complete lifts additionalModelRequestFields to top level for
  invoke requests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Sam Boland <sam.boland@appfolio.com>
Replies that hold a block the fork does not model (compaction, server_tool_use,
tool_search_tool_result, or any unknown type) keep their full content-block
list as InvokeAnthropic::ContentBlocks, a Content::Raw that replays
block-for-block and whose #text is the reply's text. Sync and streaming both
rebuild the blocks; the streaming parser follows the recorded event formats.

Message#provider_data exposes context_management, usage.iterations and
input_transformations as plain hashes. Streamed input tokens now come from
message_delta, the count after compaction and server-tool iterations.

Two InvokeModel-only params: deferred_tools (names rendered with
defer_loading: true) and server_tools (definitions sent as given, e.g. the
regex tool-search tool). Converse rendering is unchanged, except that it now
raises UnsupportedContentError for a block it cannot carry.

Adds live VCR examples for clear_tool_uses, clear_thinking, compaction, tool
search, thinking-binding controls and Converse fallback on Opus 5.5 and
Sonnet 5.5, plus WebMock examples for the parsing and rendering paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Sam Boland <sam.boland@appfolio.com>
@staging-supernova-dx-appf-io

Copy link
Copy Markdown
Author

🤖 Supernova Code Review — View trace

🔄 Re-run this review

— via automation "Code Review Opus 5.5 Medium" (run), installed by Sam Boland (sam.boland@appfolio.com)

@staging-supernova-dx-appf-io staging-supernova-dx-appf-io Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the :invoke_anthropic server-side context features. I left two inline findings:

  1. invoke_anthropic/chat.rb: a Content::Raw holding a single Hash block gets turned into an array of key/value pairs by Array(...). Converse handles the same input, so a chat that works there breaks after switching protocols. The same pattern is at lines 86, 175, 237, 245 and 282.
  2. providers/bedrock.rb: InvokeModel param normalization only runs in complete, so Provider#render returns a different body than the one actually sent.

I also checked these areas and found nothing actionable: the EventStream refactor (Converse error/status mapping is unchanged), how streamed blocks are rebuilt into ContentBlocks, server_tool_use JSON deltas not leaking into the accumulator's tool calls (they're keyed by index), merging provider_data across stream chunks, the Converse UnsupportedContentError guard, and translating thinking blocks in both directions.

🔄 Re-run this review

— via automation "Code Review Opus 5.5 Medium" (run), installed by Sam Boland (sam.boland@appfolio.com)


def format_message_content(msg)
if msg.content.is_a?(RubyLLM::Content::Raw)
blocks = Array(msg.content.value)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array(msg.content.value) breaks a Content::Raw that holds a single Hash block. Array({ type: 'text', text: 'hi' }) returns [[:type, "text"], [:text, "hi"]], so InvokeModel gets an array of pairs and returns a 400. Converse handles the same input correctly (format_raw_content uses value.is_a?(Array) ? value : [value]). That means a chat whose Raw message works on Converse fails once it switches to :invoke_anthropic, and the PR is built around switching protocols. The same Array(...value) pattern shows up at lines 86 (system), 237/245 (tool results) and 282 (format_content). Fix: add one helper and use it in all five places:

def raw_blocks(raw)
  value = raw.value
  value.is_a?(Array) ? value : [value]
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6301383. InvokeAnthropic::Chat has a private raw_blocks(value) helper: it returns value for an Array, [] for nil, and [value] otherwise, which is the same rule as Converse's format_raw_content. All six Raw call sites use it. The Array(...) calls on params and tool lists are unchanged. Four new examples in invoke_anthropic_spec.rb each render a single-Hash Raw (assistant content, user content, tool-result content, a passed-through tool_result block) and assert the rendered block equals the Hash. They fail against the old code.

Comment thread lib/ruby_llm/providers/bedrock.rb Outdated
params = mantle_only_model?(model) ? strip_converse_only_params(params) : normalize_params(params, model:)
params = if mantle_only_model?(model)
strip_converse_only_params(params)
elsif invoke_anthropic_request?(rest[:protocol])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_invoke_params only runs in Bedrock#complete. Provider#render ("the request a completion call would send, without sending it") isn't overridden, so provider.render(..., protocol: :invoke_anthropic, params: { additionalModelRequestFields: { context_management: ... } }) returns a body with additionalModelRequestFields at the top level and context_management still nested inside it. complete lifts both, so render no longer matches what is actually sent. Fix: move the param selection into a private normalize_request_params(params, model:, protocol:) and call it from both complete and a new render override.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6301383. The params choice (mantle strip, invoke normalize, converse normalize) now lives in one private Bedrock#prepare_params(params, model:, protocol:), called from both complete and a new render override. render uses the same **rest form as complete, because copying Provider#render's full keyword list trips Metrics/ParameterLists. Two new examples in bedrock_spec.rb: with protocol: :invoke_anthropic, context_management moves to the top level and there are no model, stream or additionalModelRequestFields keys; without a protocol, the Converse body is unchanged.

appf-supernova and others added 2 commits October 5, 2026 23:05
…sation system messages through InvokeModel

Render a system message placed after the conversation starts as a role "system" turn instead of
merging it into the top-level system, expose usage.cache_creation as provider_data, assert that
top-level usage excludes the compaction iteration, and record Sonnet 5 and Opus 5 runs of
examples 3 and 4 (Opus 5 refuses the ledger prompt as `cyber`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Sam Boland <sam.boland@appfolio.com>
…plete sends them

Kernel#Array split a Raw holding one Hash block into key/value pairs; InvokeAnthropic now wraps
it the way Converse's format_raw_content does. Bedrock#render now runs the same params
preparation as #complete, so an :invoke_anthropic render lifts additionalModelRequestFields and
drops model/stream like the request that is actually sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Sam Boland <sam.boland@appfolio.com>

@samuelboland samuelboland left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: both review findings fixed with specs, :invoke_anthropic is opt-in only, the Converse guard and thinking translation act only on typed blocks (none in the last 50k supernova messages), and supernova does not call render.

@samuelboland
samuelboland merged commit a3dc133 into main Oct 6, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants