Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/pollux/interaction/continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions src/pollux/interaction/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions tests/interaction/test_continuation_compat.py
Original file line number Diff line number Diff line change
@@ -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"
Loading