Skip to content

Promote per-model settings to a PerModelSettings capability - #831

Open
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/per-model-settings-capability
Open

Promote per-model settings to a PerModelSettings capability#831
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/per-model-settings-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

Fourth entry in the capability-conversion series (#828 SteerInjection, #829 HistoryCompaction, #830 PluginMessageTransform): the per-model ModelSettings payload — auto-derived max_tokens budget, GPT-5 reasoning effort/verbosity, Anthropic extended-thinking, GLM extra_body riders, Copilot API translation, and the yolo-mode parallel_tool_calls gate — previously rode in on the Agent(model_settings=...) constructor kwarg at both construction sites. It now travels through pydantic-ai's first-class get_model_settings capability seam as PerModelSettings in code_puppy/agents/_model_settings.py, joining the rest of the agent's behavior in the capabilities=[...] block.

Upstream precedent: pydantic-ai's own Thinking capability (capabilities/thinking.py) is exactly this shape — a @dataclass subclass returning a static ModelSettings from get_model_settings().

Feature parity

  • Identical merged payload on the standard path. pydantic-ai layers settings agent → capability → run over the model's base settings (_layer_model_settings, verified in the installed 2.31.0 source). Nothing else contributes capability-level settings and per-run settings (agent.run(model_settings=...)) still merge last, so the payload the model receives is unchanged everywhere code_puppy builds agents. Proven by a FunctionModel wire-parity test against the old kwarg wiring, plus a full three-layer merge test (model base < capability < per-run).
  • One deliberate, reviewer-flagged divergence: Agent.override(model_settings=...) replaces the agent-slot settings — previously ours, now empty — so the capability snapshot merges after it and wins conflicting keys; the built agent's model_settings attribute also reads None. No code_puppy call site uses either seam (verified: zero .override( callers, zero readers of the built agent's model_settings). The trade-off is documented in the module docstring and pinned by a dedicated regression test so it stays visible rather than accidental.
  • Identical snapshot timing. The kwarg froze settings at agent-build time; get_model_settings is re-extracted on every run (for_run re-resolution), which would silently pick up config edits mid-session. __post_init__ therefore snapshots make_model_settings() once — config changes keep applying on agent rebuild, exactly as before.
  • Builder still computes once. build_pydantic_agent constructs a single PerModelSettings shared by both construction passes, mirroring the old single make_model_settings() call. Sub-agents construct theirs inline (single pass).
  • List position is inertget_model_settings is a configuration seam, not a request hook; documented at both call sites.
  • Spec-constructible. Like Promote plugin message transform to a first-class capability #830 (and unlike refactor: promote steer injection to a first-class pydantic-ai capability #828/refactor: promote history compaction to a first-class pydantic-ai capability #829), the inherited get_serialization_name() default is kept: the only fields are plain data (model_name/max_tokens), no live agent references. The spec path is tested end-to-end via from_spec.

make_model_settings itself is untouched and stays exported from model_factory — plugins that import it directly are unaffected.

Tests

  • tests/agents/test_model_settings_capability.py: snapshot-freeze contract, make_model_settings equivalence, FunctionModel wire-parity vs the old kwarg, three-layer merge precedence, the pinned Agent.override divergence, and from_spec construction.
  • Two existing tests stubbed _builder.make_model_settings; retargeted to code_puppy.agents._model_settings.make_model_settings and their stubs now return ModelSettings() instead of an annotation-invalid None.
  • Full suite: 7494 passed, ruff clean. The lone red (test_render_version_check_current) is pre-existing on main.

Heads-up

Like #828/#829/#830, this grazes the shared capabilities=[...] blocks in _builder.py and subagent_invocation.py — whichever of the four lands last eats a trivial rebase.

Do not merge — awaiting human review.

The per-model ModelSettings payload (max_tokens budget, GPT-5 reasoning
knobs, Anthropic thinking, GLM extra_body riders, Copilot translation,
yolo-mode parallel_tool_calls) previously rode in on the
Agent(model_settings=...) constructor kwarg at both construction sites.
It now travels through the first-class get_model_settings capability
seam as PerModelSettings, joining the rest of the agent's behavior in
the capabilities=[...] block.

Feature parity:
- identical merged payload: pydantic-ai layers agent -> capability ->
  run settings over the model's base; nothing else contributes
  capability-level settings, so slot movement is a no-op on the wire
- identical snapshot timing: get_model_settings is re-extracted per
  run, so the capability snapshots make_model_settings() output in
  __post_init__ to preserve the old build-time freeze
- builder still computes settings once, shared by both construction
  passes; sub-agents construct theirs inline

Tests: seam contract (snapshot freeze, make_model_settings
equivalence), FunctionModel wire-parity against the old kwarg wiring,
and spec-constructibility pin. Existing stubs of
_builder.make_model_settings retargeted to the capability module.
Review follow-up: moving settings from the agent slot to the capability
slot changes Agent.override(model_settings=...) precedence (the override
now merges before the capability, so the snapshot wins conflicts) and
leaves the built agent's model_settings attribute None. No code_puppy
call site uses either seam (no .override callers, no readers of the
built agent's model_settings), so the divergence is documented in the
module docstring instead of papered over, and two regression tests pin
the chosen semantics:

- full three-layer merge (model base < capability < per-run settings)
  proving run-level overrides keep their historical precedence
- the override-below-capability divergence, kept visible on purpose

Also from review: spec test now drives from_spec end-to-end, and test
stubs return ModelSettings() instead of an annotation-invalid None.
Review pass 2: temperature now overlaps between the model's base
settings (0.9) and the capability snapshot (0.1), so the
capability-beats-base assertion actually detects an ordering
regression instead of passing vacuously.
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