From 5004899d0c8a2acceec86f1a52e82720b0ec0e78 Mon Sep 17 00:00:00 2001 From: Sean Brar Date: Mon, 15 Jun 2026 22:45:22 -0700 Subject: [PATCH] feat(core): enforce continuation provider compatibility at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the public Continuation primitive (slice 5). The type, its version/provider-stamped to_jsonable/from_jsonable round-trip, and the api reference were already in place; the missing piece was the runtime half of the "Continuation compatibility is a Pollux contract" requirement. validate_interaction now rejects reusing a continuation produced by a different provider before dispatch. A live continuation records its producing provider, and its provider_state (response ids, provider-specific replay blocks such as Anthropic's signed thinking blocks) is not portable, so replaying it under another provider would corrupt the turn. Continuations without a provider marker (hand-built, or derived from plain history) are left alone. This mirrors the serialized-side check in Continuation.from_jsonable(expected_provider=...). Scope: provider-match is the load-bearing, clearly-correct compatibility check. A broader environment/tools/sources fingerprint is deliberately not built — reusing a continuation against a changed environment is not inherently broken. --- src/pollux/interaction/continuation.py | 13 ++- src/pollux/interaction/validate.py | 31 ++++++++ tests/interaction/test_continuation_compat.py | 79 +++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/interaction/test_continuation_compat.py diff --git a/src/pollux/interaction/continuation.py b/src/pollux/interaction/continuation.py index 42f9b887..0fe66ce3 100644 --- a/src/pollux/interaction/continuation.py +++ b/src/pollux/interaction/continuation.py @@ -74,7 +74,18 @@ def from_jsonable(cls, data: Mapping[str, Any]) -> Message: @dataclass(frozen=True, slots=True) class Continuation: - """Serializable state for continuing a provider-correct interaction.""" + """Serializable state for continuing a provider-correct interaction. + + Read it from ``output.continuation`` and pass it back as + ``Input(continuation=...)`` to take the next turn. Persist it across processes + with :meth:`to_jsonable` / :meth:`from_jsonable`, which stamp and verify a + schema version (and, optionally, the producing provider). + + A continuation is bound to the provider that produced it — its + ``provider_state`` (response ids, provider-specific replay blocks) is not + portable. Reusing one under a different provider is rejected before dispatch. + It is not memory: Pollux does not summarize, rank, or compact it. + """ SCHEMA_VERSION: ClassVar[int] = SCHEMA_VERSION diff --git a/src/pollux/interaction/validate.py b/src/pollux/interaction/validate.py index 84c48222..f91dc14e 100644 --- a/src/pollux/interaction/validate.py +++ b/src/pollux/interaction/validate.py @@ -29,6 +29,35 @@ def _wants_conversation(inputs: Sequence[Input]) -> bool: ) +def _reject_incompatible_continuations( + inputs: Sequence[Input], snapshot: EnvironmentSnapshot +) -> None: + """Reject reusing a continuation produced by a different provider. + + A live :class:`Continuation` records the provider that produced it. Its + ``provider_state`` — response ids and provider-specific replay blocks such as + Anthropic's signed thinking blocks — is not portable, so replaying it under + another provider would corrupt the turn. This is the runtime half of the + continuation-compatibility contract; the serialized half lives in + ``Continuation.from_jsonable(expected_provider=...)``. Continuations without a + provider marker (hand-built, or derived from plain ``history``) are left alone. + """ + active = snapshot.provider + if active is None: + return + for inp in inputs: + continuation = inp.continuation + if continuation is None or continuation.provider is None: + continue + if continuation.provider != active: + raise ConfigurationError( + f"Continuation was produced by provider {continuation.provider!r}, " + f"but the active provider is {active!r}", + hint="Reuse a continuation only with the provider that produced " + "it, or start a new interaction.", + ) + + def validate_interaction( requirements: OutputRequirements, inputs: Sequence[Input], @@ -83,6 +112,8 @@ def validate_interaction( hint="Continue from a single input when passing continuation/history.", ) + _reject_incompatible_continuations(inputs, snapshot) + if cache_requested and not caps.persistent_cache: raise ConfigurationError( "Provider does not support persistent caching", diff --git a/tests/interaction/test_continuation_compat.py b/tests/interaction/test_continuation_compat.py new file mode 100644 index 00000000..16fa061a --- /dev/null +++ b/tests/interaction/test_continuation_compat.py @@ -0,0 +1,79 @@ +"""Integration tests: the runtime continuation-compatibility contract. + +A live ``Continuation`` records the provider that produced it. Reusing it under a +different provider is rejected before dispatch, because its ``provider_state`` +(response ids, provider-specific replay blocks) is not portable. +""" + +from __future__ import annotations + +import pytest + +from pollux.config import Config +from pollux.errors import ConfigurationError +from pollux.interaction.continuation import Continuation, Message +from pollux.interaction.environment import Environment +from pollux.interaction.execute import execute_interaction +from pollux.interaction.input import Input +from pollux.interaction.requirements import OutputRequirements +from pollux.providers.base import ProviderCapabilities +from tests.conftest import ANTHROPIC_MODEL, FakeProvider + +pytestmark = pytest.mark.integration + + +def _conversational_provider() -> FakeProvider: + return FakeProvider( + _capabilities=ProviderCapabilities( + persistent_cache=True, uploads=True, conversation=True + ) + ) + + +def _cfg() -> Config: + return Config(provider="anthropic", model=ANTHROPIC_MODEL, use_mock=True) + + +def _continuation(provider: str | None) -> Continuation: + return Continuation( + messages=(Message(role="user", content="earlier"),), + provider=provider, + ) + + +@pytest.mark.asyncio +async def test_rejects_continuation_from_a_different_provider() -> None: + with pytest.raises(ConfigurationError, match="active provider"): + await execute_interaction( + Environment(), + Input(content="next", continuation=_continuation("openai")), + OutputRequirements(), + _cfg(), + _conversational_provider(), + ) + + +@pytest.mark.asyncio +async def test_accepts_continuation_from_the_matching_provider() -> None: + out = await execute_interaction( + Environment(), + Input(content="next", continuation=_continuation("anthropic")), + OutputRequirements(), + _cfg(), + _conversational_provider(), + ) + assert out.text == "ok:next" + + +@pytest.mark.asyncio +async def test_accepts_continuation_without_a_provider_marker() -> None: + # Hand-built or history-derived continuations carry no provider marker and + # are left alone by the compatibility check. + out = await execute_interaction( + Environment(), + Input(content="next", continuation=_continuation(None)), + OutputRequirements(), + _cfg(), + _conversational_provider(), + ) + assert out.text == "ok:next"