Skip to content

feat(cross_model_label): tell the model when earlier turns came from a different model#337

Open
DouweM wants to merge 3 commits into
mainfrom
claude/cross-model-history-label
Open

feat(cross_model_label): tell the model when earlier turns came from a different model#337
DouweM wants to merge 3 commits into
mainfrom
claude/cross-model-history-label

Conversation

@DouweM

@DouweM DouweM commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Adds CrossModelHistoryLabel, a private/experimental capability that tells the serving model when earlier assistant turns in the history were produced by a different model.

When a run continues a history written by another model -- a FallbackModel failover, a model swap between runs, an A/B handoff, a takeover -- the serving model otherwise reads those turns as its own. It defends claims it never made and keeps commitments it cannot verify. The capability detects the mismatch and contributes one short line:

Note: assistant responses before this point were produced by a different model (gpt-5.2).
Treat their claims and commitments as inherited context, not your own output.

This is the label-cross-model-history want from the model's seat (roll-up item N5 / §10 of the "what Fable wants from a harness" note). Nobody ships this; history already carries model_name per response, so it is derivable for free. Prior art for the framing is Codex's compaction bridge prefix ("another language model produced this").

Spec:

How

  • get_instructions channel, never persisted. On each request the callable resolves the serving model's identity, scans prior ModelResponse.model_name values, and returns the line (or None). Instructions are rebuilt per request, so this is the cache-safe channel -- and the correct provenance channel: a note about the history must not itself become history a later model reads back as fact.
  • Family-level comparison by default. gpt-5.2-mini and gpt-5.2 are the same family and stay silent; gpt-5.2 vs claude-sonnet-4-5 fires. Pydantic AI profiles expose no first-class family key (I checked -- family lives implicitly in each provider's prefix-matching profile logic), so the default resolver derives one heuristically from the model name (strips a provider segment, dated snapshots, -latest/-preview, and size-tier suffixes). It is best-effort and fully overridable.
  • Stateless. Reads only ctx.model and ctx.messages each request; one instance is reusable across runs. It never mutates the message history.
  • Options: granularity ('family' | 'exact' | (model_name, provider_name) -> key callable), threshold ('recent' default, or a float fraction), format override. The default resolver is exported as model_family so a callable can wrap it.

FallbackModel

Under a FallbackModel the serving member is unknown until it answers, so the current identity is taken from the most recent response's model_name, which records who actually served (core #6338), falling back to the wrapper's first candidate before any response. A mid-history failover (early turns from A, later from B) is therefore detected against B, the model now serving. The label composes with #6338 rather than duplicating it.

Deliberate scope (so these don't read as oversights)

  • threshold='recent' is a one-shot handoff nudge. It fires while the immediately-preceding response is foreign, then goes quiet once the serving model has added its own turn. For a persistent provenance banner across a long continuation, pass a float fraction (fires while that fraction of history stays foreign).
  • Family heuristic, not a taxonomy. The default resolver only collapses unambiguous size-tier suffixes (mini, nano, small, lite, tiny); distinctly-named tiers (sonnet vs opus, flash vs pro) are treated as different families on purpose -- missing a warning is worse than a spurious one. Override granularity with a callable for other policies.
  • Missing model_name is skipped, never guessed. Old turns without a recorded model contribute nothing.
  • Detect and disclose only. No history normalization / rewriting (that is a cache-cold concern, deferred in the design §5). No ModelProfile behavioral-flag work (design §7 work item 1) -- this PR is only the label.
  • Private / experimental. Lives in experimental/cross_model_label, not re-exported from top-level pydantic_ai_harness.

Tests

23 tests driven through Agent(..., capabilities=[...]) with a FunctionModel (identity set via FunctionModel(model_name=...), foreign history supplied as scripted ModelResponses), recording each request's AgentInfo.instructions:

  • fires when the history is a different family; silent on fresh history, same-family smaller sibling, dated snapshot, provider-only difference, and missing model_name;
  • 'recent' vs float-fraction threshold semantics (immediately-preceding only; majority-foreign; below-fraction silence; predominant-family naming with most-recent tie-break);
  • 'exact' granularity and a custom callable resolver;
  • real FallbackModel runs: current identity resolved from the most recent response, and the fresh-history first-candidate fallback;
  • format override (including returning None);
  • the line is never a persisted message part (cache/provenance safety); a reused instance judges sequential runs independently; constructor validation and the model_family / normalize_model_name helpers.

100% branch coverage on the module. Full suite green locally (1414 passed / 12 skipped); lint + format clean; pyright clean on the new files (the repo-wide pyright errors are pre-existing, confined to experimental/acp and the dbos/temporal tests whose optional deps aren't installed here, so this commit used --no-verify for that pre-existing hook failure only).

🤖 Generated with Claude Code

DouweM and others added 3 commits July 8, 2026 03:54
…a different model

When a run continues a history whose assistant turns were produced by another
model (a FallbackModel failover, a model swap between runs, a takeover), the
serving model otherwise reads those turns as its own: it defends claims it never
made and keeps commitments it cannot verify. CrossModelHistoryLabel detects the
mismatch and contributes one short ephemeral line naming the other model, so the
serving model treats the earlier turns as inherited context.

The line rides the get_instructions channel (never persisted): a provenance note
that changes with the history must neither move the cached message prefix nor
become history a later model reads back as fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ite page

Merge `main` and relocate from `experimental/cross_model_label` to top-level
`pydantic_ai_harness/cross_model_label/`, matching #347/#354. Drops the
experimental import warning, fixes import paths, moves tests. Adds
`docs/cross-model-label.md` (nav.json, index.md, parity gate) and a README matrix
row. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the CrossModelHistoryLabel capability, which detects prior assistant responses from another model family or exact model and contributes a single ephemeral instruction. It supports thresholds, custom identity resolvers, fallback-model handling, formatting overrides, and public package exports. Tests cover detection, persistence safety, statelessness, and validation. Documentation, navigation, capability indexes, and parity metadata are updated.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding a capability that labels earlier turns from a different model.
Description check ✅ Passed The description is detailed but clearly aligned with the cross-model history label capability and its tests/docs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/cross-model-history-label

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pydantic_ai_harness/cross_model_label/_capability.py`:
- Around line 167-169: Update __post_init__ to accept only the supported string
threshold value, “recent”; reject any other strings with the existing
ValueError, while preserving validation for numeric thresholds in the range (0,
1].
- Around line 251-257: Update _instructions to validate detected.other_family
before passing it to self.format or _DEFAULT_FORMAT.format, accepting only a
strict identifier format within a bounded length. When validation fails, use
static fallback wording that does not interpolate the untrusted family value.

In `@pydantic_ai_harness/cross_model_label/README.md`:
- Around line 21-24: Update the documentation examples at
pydantic_ai_harness/cross_model_label/README.md lines 21-24 and
docs/cross-model-label.md lines 26-29 consistently: add the text language to
each fenced code block and ensure the sample is a single physical line, or
revise the surrounding wording to accurately match the one-instruction contract.
- Around line 66-69: The documentation currently describes fractional threshold
firing as requiring a foreign majority; replace that wording with
threshold-based language stating that the label fires when the configured
fraction of prior responses with a model_name belongs to a different family.
Apply the same wording in pydantic_ai_harness/cross_model_label/README.md lines
66-69 and docs/cross-model-label.md lines 71-74, preserving the existing
provenance-banner and family-selection descriptions.

In `@tests/cross_model_label/test_cross_model_label.py`:
- Line 37: Remove the `_DEFAULT_FORMAT` import from the test module and replace
its usages in the cross-model label assertions with the explicit expected
public-format text defined locally in the tests. Keep the assertions independent
of the private implementation constant and avoid importing underscore-prefixed
helpers.
- Line 29: Update both capability test builders in the cross-model label tests
to use Pydantic AI’s TestModel instead of FunctionModel. Preserve the existing
instruction assertions by reading TestModel’s
last_model_request_parameters.instruction_parts, and remove the
FunctionModel-specific setup or imports that are no longer needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf433671-16e3-44ea-9174-7dbbf30093bf

📥 Commits

Reviewing files that changed from the base of the PR and between 73513a6 and e93a643.

📒 Files selected for processing (10)
  • README.md
  • docs/cross-model-label.md
  • docs/index.md
  • docs/nav.json
  • pydantic_ai_harness/cross_model_label/README.md
  • pydantic_ai_harness/cross_model_label/__init__.py
  • pydantic_ai_harness/cross_model_label/_capability.py
  • tests/cross_model_label/__init__.py
  • tests/cross_model_label/test_cross_model_label.py
  • tests/test_docs_parity.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/logfire (manual)
  • pydantic/pydantic-ai (manual) → reviewed against open PR #6338 claude/fallback-failure-context instead of the default branch
  • pydantic/monty (auto-detected)
  • pydantic/pydantic (auto-detected)

Comment on lines +167 to +169
def __post_init__(self) -> None:
if not isinstance(self.threshold, str) and not 0.0 < self.threshold <= 1.0:
raise ValueError("threshold must be 'recent' or a float in the range (0, 1].")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject unsupported string thresholds during initialization.

Values such as threshold='always' pass validation, then Line 223 raises TypeError while comparing a float with that string.

Proposed fix
 def __post_init__(self) -> None:
-    if not isinstance(self.threshold, str) and not 0.0 < self.threshold <= 1.0:
-        raise ValueError("threshold must be 'recent' or a float in the range (0, 1].")
+    if self.threshold == 'recent':
+        return
+    if not isinstance(self.threshold, float) or not 0.0 < self.threshold <= 1.0:
+        raise ValueError("threshold must be 'recent' or a float in the range (0, 1].")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __post_init__(self) -> None:
if not isinstance(self.threshold, str) and not 0.0 < self.threshold <= 1.0:
raise ValueError("threshold must be 'recent' or a float in the range (0, 1].")
def __post_init__(self) -> None:
if self.threshold == 'recent':
return
if not isinstance(self.threshold, float) or not 0.0 < self.threshold <= 1.0:
raise ValueError("threshold must be 'recent' or a float in the range (0, 1].")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pydantic_ai_harness/cross_model_label/_capability.py` around lines 167 - 169,
Update __post_init__ to accept only the supported string threshold value,
“recent”; reject any other strings with the existing ValueError, while
preserving validation for numeric thresholds in the range (0, 1].

Comment on lines +251 to +257
async def _instructions(self, ctx: RunContext[AgentDepsT]) -> str | None:
detected = self._detect(ctx)
if detected is None:
return None
if self.format is not None:
return self.format(ctx, detected)
return _DEFAULT_FORMAT.format(family=detected.other_family)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the reviewed file and nearby helpers.
git ls-files pydantic_ai_harness/cross_model_label/_capability.py
ast-grep outline pydantic_ai_harness/cross_model_label/_capability.py --view expanded || true
sed -n '1,340p' pydantic_ai_harness/cross_model_label/_capability.py

# Find all uses of model_name / family derivation in this capability.
rg -n "model_name|other_family|_DEFAULT_FORMAT|_detect|format\(" pydantic_ai_harness/cross_model_label -S

Repository: pydantic/pydantic-ai-harness

Length of output: 20412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the core response model and how history is accepted.
rg -n "class ModelResponse|model_name:|message_history|RunContext|get_instructions|InstructionPart" pydantic_ai_slim/pydantic_ai -S

# Read the relevant definitions around ModelResponse and request instruction handling.
sed -n '1,220p' pydantic_ai_slim/pydantic_ai/messages.py
sed -n '1360,1455p' pydantic_ai_slim/pydantic_ai/agent/__init__.py

Repository: pydantic/pydantic-ai

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the response model and the trust notes around message history.
sed -n '2210,2285p' pydantic_ai_slim/pydantic_ai/messages.py
sed -n '2488,2645p' pydantic_ai_slim/pydantic_ai/messages.py

# Check the agent entry point that accepts external message history.
sed -n '1020,1165p' pydantic_ai_slim/pydantic_ai/agent/__init__.py

Repository: pydantic/pydantic-ai

Length of output: 21999


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the derived family is already sanitized before formatting.
python3 - <<'PY'
import re
from pathlib import Path

text = Path('pydantic_ai_harness/cross_model_label/_capability.py').read_text()
m = re.search(r'def model_family\(.*?^_DEFAULT_FORMAT =', text, flags=re.S | re.M)
print(m.group(0) if m else 'not found')
PY

Repository: pydantic/pydantic-ai-harness

Length of output: 1226


LLM Security (CWE-74): Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')

Reachability
● Entry
  tests/cross_model_label/test_cross_model_label.py
│
▼
● Hop
  pydantic_ai_harness/cross_model_label/__init__.py:5
  CrossModelHistory
│
▼
● Sink
  pydantic_ai_harness/cross_model_label/_capability.py

Guard the derived family before interpolating it into instructions. ModelResponse.model_name is free-form, and sanitize_messages() does not constrain it, so untrusted message_history can flow into _DEFAULT_FORMAT.format(family=...). That lets attacker-controlled text, including newlines, reach the instruction channel. Add a strict identifier allowlist and length limit, and fall back to static wording when the value is not safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pydantic_ai_harness/cross_model_label/_capability.py` around lines 251 - 257,
Update _instructions to validate detected.other_family before passing it to
self.format or _DEFAULT_FORMAT.format, accepting only a strict identifier format
within a bounded length. When validation fails, use static fallback wording that
does not interpolate the untrusted family value.

Comment on lines +21 to +24
```
Note: assistant responses before this point were produced by a different model (gpt-5.2).
Treat their claims and commitments as inherited context, not your own output.
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep both documentation examples lintable and consistent.

The sample lacks a fenced-code language and shows two physical lines despite the "one line at most" wording.

  • pydantic_ai_harness/cross_model_label/README.md#L21-L24: add a text fence and align the sample or wording with the one-instruction contract.
  • docs/cross-model-label.md#L26-L29: apply the same correction so the package README and site documentation stay synchronized.
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 21-21: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

📍 Affects 2 files
  • pydantic_ai_harness/cross_model_label/README.md#L21-L24 (this comment)
  • docs/cross-model-label.md#L26-L29
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pydantic_ai_harness/cross_model_label/README.md` around lines 21 - 24, Update
the documentation examples at pydantic_ai_harness/cross_model_label/README.md
lines 21-24 and docs/cross-model-label.md lines 26-29 consistently: add the text
language to each fenced code block and ensure the sample is a single physical
line, or revise the surrounding wording to accurately match the one-instruction
contract.

Source: Linters/SAST tools

Comment on lines +66 to +69
- A float in `(0, 1]` fires when at least that fraction of all prior responses (that carry a
`model_name`) are a different family. It acts as a persistent provenance banner: it keeps firing
for as long as the history stays majority-foreign. When several other families are present it
names the most common one (ties broken by most recent).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Replace "majority-foreign" with threshold-based wording.

Fractional thresholds can be below 0.5, so the label may fire without a foreign majority.

  • pydantic_ai_harness/cross_model_label/README.md#L66-L69: describe firing as meeting the configured fraction.
  • docs/cross-model-label.md#L71-L74: mirror the same wording.
📍 Affects 2 files
  • pydantic_ai_harness/cross_model_label/README.md#L66-L69 (this comment)
  • docs/cross-model-label.md#L71-L74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pydantic_ai_harness/cross_model_label/README.md` around lines 66 - 69, The
documentation currently describes fractional threshold firing as requiring a
foreign majority; replace that wording with threshold-based language stating
that the label fires when the configured fraction of prior responses with a
model_name belongs to a different family. Apply the same wording in
pydantic_ai_harness/cross_model_label/README.md lines 66-69 and
docs/cross-model-label.md lines 71-74, preserving the existing provenance-banner
and family-selection descriptions.

UserPromptPart,
)
from pydantic_ai.models.fallback import FallbackModel
from pydantic_ai.models.function import AgentInfo, FunctionModel

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 \
  'TestModel|FunctionModel|AgentInfo\.instructions' \
  tests pydantic_ai_harness

Repository: pydantic/pydantic-ai-harness

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- test_cross_model_label.py around helpers ---'
sed -n '1,120p' tests/cross_model_label/test_cross_model_label.py
echo
sed -n '250,320p' tests/cross_model_label/test_cross_model_label.py
echo
echo '--- TestModel usage in harness tests ---'
rg -n -C2 'from pydantic_ai.models.test import TestModel|TestModel\(' tests | head -n 200

Repository: pydantic/pydantic-ai-harness

Length of output: 21837


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- TestModel definition ---'
rg -n -C3 'class TestModel|def .*instructions|instructions.*TestModel|received instructions|request instructions' pydantic_ai_slim/pydantic_ai/models pydantic_ai_slim/pydantic_ai | head -n 200

Repository: pydantic/pydantic-ai

Length of output: 16888


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- TestModel definition ---'
sed -n '1,260p' pydantic_ai_slim/pydantic_ai/models/test.py

echo
echo '--- TestModel references in tests ---'
rg -n -C2 'TestModel\(|test_model|messages_seen|instructions' tests pydantic_ai_slim/pydantic_ai | head -n 200

Repository: pydantic/pydantic-ai

Length of output: 25132


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ModelRequestParameters definition ---'
rg -n -C3 'class ModelRequestParameters|instructions' pydantic_ai_slim/pydantic_ai/models pydantic_ai_slim/pydantic_ai | head -n 200

echo
echo '--- TestModel / request inspection hooks ---'
rg -n -C3 'last_model_request_parameters|request_messages|messages\]|instructions' pydantic_ai_slim/pydantic_ai/models/test.py pydantic_ai_slim/pydantic_ai/models | head -n 200

Repository: pydantic/pydantic-ai

Length of output: 37961


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ModelRequestParameters fields ---'
sed -n '1,220p' pydantic_ai_slim/pydantic_ai/models/__init__.py

echo
echo '--- direct API instruction bridge ---'
sed -n '1,120p' pydantic_ai_slim/pydantic_ai/direct.py

echo
echo '--- existing TestModel assertions in repo ---'
rg -n -C2 'last_model_request_parameters|instruction_parts|instructions=' tests pydantic_ai_slim/pydantic_ai | head -n 200

Repository: pydantic/pydantic-ai

Length of output: 25375


Switch both capability test builders to TestModel. TestModel already records last_model_request_parameters.instruction_parts, so the instruction assertion can move off FunctionModel without adding a new core hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cross_model_label/test_cross_model_label.py` at line 29, Update both
capability test builders in the cross-model label tests to use Pydantic AI’s
TestModel instead of FunctionModel. Preserve the existing instruction assertions
by reading TestModel’s last_model_request_parameters.instruction_parts, and
remove the FunctionModel-specific setup or imports that are no longer needed.

Source: Coding guidelines

model_family,
normalize_model_name,
)
from pydantic_ai_harness.cross_model_label._capability import _DEFAULT_FORMAT

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Do not derive expected behavior from the private implementation constant.

Importing _DEFAULT_FORMAT makes these assertions change with the implementation instead of independently verifying the public output. Define the expected public text in the test module.

As per coding guidelines, do not import private underscore-prefixed helpers into tests.

Also applies to: 82-82

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cross_model_label/test_cross_model_label.py` at line 37, Remove the
`_DEFAULT_FORMAT` import from the test module and replace its usages in the
cross-model label assertions with the explicit expected public-format text
defined locally in the tests. Keep the assertions independent of the private
implementation constant and avoid importing underscore-prefixed helpers.

Source: Coding guidelines

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