Skip to content

Promote model delivery to a ResolvedModel capability - #833

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/resolved-model-capability
Open

Promote model delivery to a ResolvedModel capability#833
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/resolved-model-capability

Conversation

@mpfaffenberger

Copy link
Copy Markdown
Owner

Summary

Sixth in the capability series (#828 SteerInjection, #829 HistoryCompaction, #830 PluginMessageTransform, #831 PerModelSettings, #832 AssembledInstructions): promotes model delivery from the Agent(model=...) constructor kwarg to a first-class pydantic-ai capability, ResolvedModel, on the dedicated get_model() seam.

Both construction sites (agents/_builder.py and tools/subagent_invocation.py) previously handed their fully resolved Model instance to the constructor kwarg while everything else about the agent's behavior already composes through capabilities=[...].

What changed

Parity (verified against pydantic-ai 2.31.0 source)

  • Static contribution, resolved once per run. A non-callable get_model() return is used directly as the run's model (Agent._evaluate_model_contribution), exactly like the agent-slot model. _check_dynamic_model_resume is a no-op for static contributions — no selector semantics introduced.
  • run(model=...) and Agent.override(model=...) stay authoritative. Both set model_is_explicit, which short-circuits capability contribution evaluation entirely — same precedence as the old kwarg. Pinned by tests.
  • Enter/exit lifecycle preserved. Agent.__aenter__ enters a static capability model's context identically (line ~3736: static_selection = capability_model), so provider HTTP clients open/close the same.
  • One observable divergence: the built agent's .model property now reads None (the model no longer occupies the agent slot). Verified: zero code_puppy readers — everything goes through BaseAgent.cur_model. One plugin reader exists: wiggum's _resolve_judges probes get_pydantic_agent().model.model_name for its default-judge fallback and now takes its own next fallback, BaseAgent.get_model_name() — the configured model name, which is what judge configs resolve against anyway (arguably more correct than the provider model id it used to get). Degrades gracefully by design (getattr(None, "model_name", None)); documented in the module docstring and pinned by test_agent_model_slot_reads_none.
  • Not spec-constructible by design: get_serialization_name() -> None because the capability holds a live, provider-configured Model instance (HTTP clients and all) — same precedent as SteerInjection/HistoryCompaction.
  • List position inert: get_model is a configuration seam, not a request hook; placed first in both blocks for readability.

Tests

7 contract tests in tests/agents/test_resolved_model_capability.py:

  1. direct seam identity (get_model() is model)
  2. spec-construction opt-out
  3. kwarg-vs-capability wire parity via FunctionModel (model-visible content compared; timestamps excluded)
  4. run(model=...) authoritative over the capability
  5. override(model=...) authoritative in scope, capability restored outside
  6. pinned .model is None divergence
  7. end-to-end build_pydantic_agent drive: built agent runs on the capability-delivered model, cur_model tracks it, agent slot empty

Full suite: 7583 passed; the 3 reds are the documented pre-existing main failures (test_render_version_check_current, plus the two full-run-order FieldInfo has no attribute 'strip' flakes noted in #832) — both subagent_invocation-adjacent ones pass in isolation on this branch.

Merge-order note

Like its five siblings, this touches the shared capabilities=[...] blocks in _builder.py + subagent_invocation.py. Whichever PRs land last eat trivial rebases.

Both agent construction paths handed their fully resolved pydantic-ai
model to the Agent(model=...) constructor kwarg. ResolvedModel moves
that delivery onto the dedicated get_model() capability seam, so the
model travels in the same capabilities=[...] block as the rest of the
agent's behavior. Sixth in the capability series (#828 SteerInjection,
#829 HistoryCompaction, #830 PluginMessageTransform, #831
PerModelSettings, #832 AssembledInstructions).

Resolution logic is untouched: load_model_with_fallback still owns the
fallback chain and warning dedup; sub-agent pin/override resolution is
unchanged. The capability only owns the last mile.

Parity (pydantic-ai 2.31.0): a static get_model() contribution is
resolved once per run exactly like the agent-slot model was;
run(model=...) and Agent.override(model=...) both set model_is_explicit
and stay authoritative; Agent.__aenter__ enters a static capability
model identically. One observable divergence: the built agent's .model
property reads None -- no code_puppy call site reads it (everything
uses BaseAgent.cur_model), and wiggum's default-judge probe degrades
to its own get_model_name() fallback. Pinned by regression test.

get_serialization_name() -> None: the capability holds a live,
provider-configured Model instance, so it opts out of spec-based
construction (SteerInjection/HistoryCompaction precedent).
Apply the three optional review findings on PR #833: pin the
Agent.__aenter__/__aexit__ lifecycle of a capability-delivered model,
prove the static contribution serves every step of a multi-step
(tool-call) run, and cover the second construction site end-to-end by
driving subagent_invocation._invoke_agent_impl with a streaming
FunctionModel delivered via the capability.
@mpfaffenberger

Copy link
Copy Markdown
Owner Author

Review trail: code-puppy clone did two passes. Pass 1: clean APPROVE with three optional test-hardening findings (pin the Agent.aenter/aexit lifecycle of the capability model, prove static delivery across a multi-step tool-call run, cover the sub-agent construction site end-to-end). All three applied in c511377. Pass 2: APPROVE, confirmed the new tests are non-vacuous (lifecycle counters, forced two-step run, subagent test fails if the capability is dropped). 10/10 contract tests green; full suite matches main's failure profile (3 documented pre-existing reds, all reproduce on clean main).

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