Skip to content

Move Hash-form option interpretation into one private module (OptionForms) - #167

Merged
ya-luotao merged 8 commits into
mainfrom
refactor/options-hash-normalization
Oct 6, 2026
Merged

ya-luotao merged 8 commits into
mainfrom
refactor/options-hash-normalization

Conversation

@ya-luotao

@ya-luotao ya-luotao commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Hash-form option interpretation (system_prompt, thinking, tools, output_format, task_budget, plugins, sandbox, mcp_servers, agents) was re-derived at 15 policy groups across CommandBuilder, the root extractors, SubprocessCLITransport#sandbox_requested? and Query#initialize_protocol, each with its own key-style rule. Several October fixes (b724e0f, 774081a, 2421298, fbaf269, 1685eff, e3f1b0c, c50c5ed) touched these option-reading policies across multiple consumers and the existing sandbox normalizer; that cluster is the motivation.

This PR moves the direct field lookups into one @api private module, ClaudeAgentSDK::OptionForms (lib/claude_agent_sdk/option_forms.rb): stateless per-value functions. system_prompt, thinking and plugin return small frozen records; the other functions return the selected value or a Hash projection (the caller's own Hash for non-preset tools / a bare output_format schema, a new wire-keyed Hash for sandbox and mcp_servers); agent_definition keeps the strict AgentDefinition.new conversion. OptionForms.sandbox delegates to the existing SandboxKeys.normalize, which stays where it was. CommandBuilder keeps flag assembly, path conversion and JSON encoding; the three root extractors keep their signatures and delegate; the transport delegates only the shallow any-true sandbox_requested? predicate; Query#initialize_protocol calls OptionForms.agent_definition where it built the type before.

No change to existing public-option behaviour. Every per-option policy is preserved as it was (truthy || fallback where it was, presence fetch where it was, output_schema's tag-spelling precedence, the any-true sandbox warning predicate, strict AgentDefinition.new for agents, the rejected plugin's second type read). Option storage, configure defaults merging, dup_with, inspect, sig/ and docs/ are untouched. Unifying the key policies is deliberately not done here; that is a separate opt-in / 2.0 proposal.

How it was verified

  • Commit 0 (3526021) adds spec/unit/option_forms_characterization_spec.rb (208 examples at that commit, 213 at head): exact argv for every option form in every accepted spelling, the duplicate-key and nil/false corner cases, the initialize-protocol payloads through a fake transport, the sandbox warning predicate both ways, the four build-time ArgumentErrors (class, message, raise file) and the agents error (class, message, phase, no write), raw-Hash equal? identity with rebuild-after-mutation, and a fresh-process require 'claude_agent_sdk/command_builder' building the exact default argv. It is green on main before any production change and after every commit.
  • At head: full suite 3954 examples / 0 failures (baseline 3574; +380 added, 0 removed, 21 re-pointed); RuboCop clean with no new rubocop:disable; rake rbs:validate rbs:test green (3577 examples).
  • Sampled differential: a fixed-seed corpus of 24,069 option values (fields absent / Symbol key / String key / both orders, holding nil / false / typed / wrong-typed values) run through main's lib/ and this branch's, recording argv or error class+message, a second build, the three extractors, sandbox_requested? and whether the input was left untouched; after normalizing object addresses, all 24,069 records match. This is sampled coverage, not a proof over every input: a callback-backed Hash exposed one missed second read on the plugin rejection path, fixed in 6c8b637 and pinned with a default-proc characterization (round 2 compared read logs for eleven callback-backed cases).
  • Review process (context, in the branch's gitignored .fleet/arch/): the design went through three adversarial rounds with Codex before implementation; the implementation went through two Codex code-review rounds (one blocking finding, fixed in 6c8b637, then APPROVE) and a PR-level pass whose description corrections are applied here.

Notes for review

  • OptionForms::HashForm is a nested private_constant module holding the moved Hash reads; the flat module exceeded RuboCop's 100-line module limit and the contract forbade a new disable.
  • OptionForms::DEFAULT_TOOLS is a frozen sentinel object rather than the Symbol :default, because tools: :default sends no flag on main and must keep doing so.
  • Interaction with Share one session assembly between query() and Client #169 (refactor/session-assembly): that branch deletes the two call sites of the root extractors; this branch only changes their bodies. Whichever lands second rebases.
  • Provenance: the commits were written by a Claude Opus 5.5 implementer session working from a settled spec under this orchestrating session's direction; the commit trailers name the orchestrating session.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm

ya-luotao and others added 8 commits October 6, 2026 03:21
Seven options take a typed value or the equivalent Hash, and fifteen
readers across CommandBuilder, the root extractors, the transport and
Query each interpret such a Hash with a key rule of their own (truthy
fallback, presence, tag-spelling precedence, any-true). The next commits
move that interpretation into one private module without changing any of
it, so the behaviour has to be pinned first, at the seams a caller can
observe.

option_forms_characterization_spec.rb records, on main:

- the whole command line for every form of system_prompt, tools,
  sandbox, task_budget, thinking, output_format, mcp_servers and
  plugins, including the both-spellings and nil / false corner cases
  and the collision the configured-defaults merge produces
- the class, message and raise site (command_builder.rb, file only) of
  the four build errors
- that a process requiring only claude_agent_sdk/command_builder builds
  the default command line
- the initialize request for agents and the system prompt metadata
  through a fake transport, the connect -> read_started -> error order
  of the strict agent error, and that the metadata path never checks a
  custom prompt's text
- that an option Hash is stored and read back as the caller's object
  and re-read by every build

sandbox_unavailable_warning_spec.rb gains the any-true cases of the
warning predicate. No production code changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
An option given as a Hash is interpreted in fifteen places today, each
with its own rule for the spelling of a key and for nil / false under
it. OptionForms gathers those rules behind stateless per-value
functions: a caller hands over the value it holds and gets back a small
frozen record (Prompt, Thinking, Plugin) or the value as it was given.
Each function keeps the rule its reader had (truthy fallback, presence,
tag-spelling precedence, any-true); nothing is unified, validated,
cached or hydrated through a public constructor, and agents stay strict
through AgentDefinition.new.

Every read of a Hash key sits in the nested, private HashForm; the
functions tell the forms apart by class and go there for the Hash one.
That split also keeps the module inside Metrics/ModuleLength without an
inline disable.

The tools preset is answered as an object of its own, DEFAULT_TOOLS,
rather than the Symbol :default: `tools: :default` is a value a caller
can write, and it sends no flag today.

Nothing calls the module yet. command_builder.rb requires it directly,
next to its other requires, so that a process loading that file alone
keeps building once the readers are switched. The module is @api
private and has no signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
CommandBuilder interpreted a Hash-form option itself at eleven places:
the type tag, the fields of a system prompt, thinking config, task
budget and plugin, the tools preset, the json_schema wrapper, the
sandbox section and the sdk MCP server entries. Each append stage now
asks OptionForms what the option stands for and keeps what is its own:
flag names, path conversion, CSV / JSON encoding and the four errors,
which are still raised from this file (custom prompt without text,
enabled thinking without a budget, unknown thinking config, unknown
plugin type).

#hash_type, #append_hash_system_prompt, #thinking_fields,
#sandbox_section and #output_schema are gone, with the Hash arms of
#append_tools, #append_mcp_servers, #append_plugins and #append_budget.
The Hash branches that remain in #append_tools and #append_mcp_servers
only choose an encoding.

No behaviour changes: the characterization suite passes unchanged,
including the exact command lines for the both-spellings and nil /
false cases, the raise sites, the standalone require and the rebuild
after a caller mutates its Hash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Four readers outside CommandBuilder still interpreted an option Hash
themselves, and one of them carried a comment saying it had to match
CommandBuilder's rule by hand:

- ClaudeAgentSDK.extract_sdk_mcp_servers,
  .extract_exclude_dynamic_sections and .extract_system_prompt_snapshot
  keep their signatures and their two call sites and become one-line
  delegates. The sdk entries whose live server a session registers and
  the ones whose instance the command line strips are now recognized by
  the same private helper.
- Query#initialize_protocol builds an agent Hash through
  OptionForms.agent_definition, at the same line: still strict
  AgentDefinition.new, still after the read loop started and before
  anything is written.
- SubprocessCLITransport#sandbox_requested? asks
  OptionForms.sandbox_requested?, the same shallow any-true read, so
  nothing new runs on the stderr threads.

The root entry point requires option_forms itself, since it now calls
it directly. No behaviour changes; the characterization suite and the
wiring specs (agents_hash_option, option_hash_symbol_type,
sdk_mcp_server, sandbox_unavailable_warning) pass unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
The group in sandbox_hash_keys_spec.rb that walks every field of the
three sandbox classes in every spelling (21 examples) built a whole
command line and parsed its --settings JSON to compare one section. It
now asserts on that section where it is made, OptionForms.sandbox,
under the Symbol keys the typed classes write. No example is removed.
The groups that pin the command line itself are unchanged: the same
sandbox written three ways, and everything from the SandboxKeys tables
to the end of the file (unmodeled keys, values that are not rewritten,
both spellings, nil under a known key, rejected shapes, typed nested
values, non-mutation).

No example leaves command_builder_spec.rb either: none of its Hash-form
examples repeats a row of the characterization table verbatim.

CHANGELOG: one Internal line under Unreleased. Nothing a gem user would
notice changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Before the readers moved, CommandBuilder#append_plugins read a plugin
entry in this order: the path, then the type once to recognize the
plugin, and, only for a plugin it refused, the type again for the error
message. OptionForms.plugin collapsed the two type reads into one and
carried the value in the record. For a plain Hash literal that is the
same thing; for a Hash that computes its values (a default proc) it is
not: the message named the first reading instead of the second, and the
entry was read once instead of twice. Such a Hash fits the signature of
plugins, so this was a change of behaviour in a refactor that promised
none (code review round 1, blocking 1).

Plugin#raw_type is now a method that looks the type up in the entry each
time it is asked for, through HashForm.plugin_type, and the record keeps
the entry (the #to_h of a typed SdkPluginConfig, taken once) instead of
a saved value. CommandBuilder asks for it only where it raises, so the
sequence is the original one again and an accepted plugin still has its
type read once. The raise stays in command_builder.rb.

The characterization suite gains the cases that tell the two apart, all
through CommandBuilder#build and green on main: a computing Hash that is
refused (message names the second reading; lookups are path, type,
type), the same with the String-key fallback, an accepted one (path,
type) and an accepted one without a path, plus the raise site for a
computing Hash. option_forms_spec.rb pins the lazy read and the single
#to_h at the module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Comments only; no code changes.

- SubprocessCLITransport#sandbox_requested? still said that sandbox
  Hashes "are forwarded to the CLI verbatim". That predates this branch
  and stopped being true when SandboxKeys began renaming a Hash's keys.
  The comment now says what the predicate is (the shallow any-true read
  of OptionForms.sandbox_requested?, made for the warning alone) and
  that the payload the CLI is sent is OptionForms.sandbox.
- OptionForms and its nested HashForm claimed to be the one place a key
  of an option Hash is read, with nothing else in the SDK reading them.
  HashForm holds the direct field lookups that were moved out of the
  consumers. A sandbox Hash is still read and renamed by
  SandboxKeys.normalize (types/option_values.rb), which
  OptionForms.sandbox delegates to, and an agent Hash by the strict
  AgentDefinition.new and the Type machinery, which
  OptionForms.agent_definition delegates to. Both comments now say so.
- The module comment also lists what the functions answer: a frozen
  record, a plain value, the value as given, or a new Hash. Only the
  records are frozen, never a value the caller owns.

From the PR-level review (non-blocking 2 and blocking 4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
Comment only; no code changes.

The comment added to SubprocessCLITransport#sandbox_requested? in
2736c64 said that SandboxKeys, of two `enabled` keys, sends the later
one. SandboxKeys leaves out a known key that holds nil before it
assigns it, so a later nil does not replace an earlier value:
{ enabled: true, 'enabled' => nil } sends true, and two nils send
neither. The later key wins only among the ones that are not nil
(true then false sends false, while the warning predicate still says
true). The comment now says that.

From PR review round 2 (blocking 1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
@ya-luotao
ya-luotao merged commit 7203345 into main Oct 6, 2026
28 of 30 checks passed
ya-luotao added a commit that referenced this pull request Oct 6, 2026
Internal release: OptionForms (#167), Query::RunLifecycle (#168) and
SessionAssembly (#169) each move duplicated or scattered logic into one
private module; no public API or signature change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzJcBPQ8Vq4kjkxdm2VnYm
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.

1 participant