Skip to content
Closed
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
30 changes: 23 additions & 7 deletions tests/v1/fixtures/echo_user_sim_v1.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""Multi-turn echo driven by a scripted user — the single user-sim mechanism.
"""Multi-turn echo driven by a scripted user with imported conversation history.

The env's `run()` scripts the user through an interaction. The task is
prompt-less, so the first `turn(phrase)` opens the conversation; each later turn
resumes the harness onto the accreted conversation, and leaving the `async with`
closes the interaction (the trace stops as `user_closed`).
prompt-less, so a seeded assistant greeting precedes the first `turn(phrase)`;
each later turn resumes the harness onto the accreted conversation, and leaving
the `async with` closes the interaction (the trace stops as `user_closed`).
"""

import verifiers.v1 as vf

PHRASES = ["hello world", "goodbye world"]
GREETING = "Hi! How can I help you today?"
SYSTEM = "Repeat the user's message back to them exactly, with no extra words."


Expand All @@ -27,6 +28,15 @@ class EchoUserSimData(vf.TaskData):
class EchoUserSimTask(vf.Task[EchoUserSimData, vf.State, vf.TaskConfig]):
@vf.reward(weight=1.0)
async def echoed(self, trace: vf.Trace) -> float:
imported = [
node
for node in trace.nodes
if not node.sampled
and isinstance(node.message, vf.AssistantMessage)
and node.message.content == GREETING
]
if len(imported) != 1:
return 0.0
replies = [m.content for m in trace.assistant_messages]
phrases = self.data.phrases
if len(replies) < len(phrases):
Expand All @@ -39,9 +49,15 @@ class EchoUserSimEnv(vf.SingleAgentEnv):
"""Scripts the user side: opens with the first phrase, follows with the rest."""

async def run(self, task, agents):
# An interaction scripting the user: the task carries no prompt, so the
# first turn opens the conversation.
async with agents.agent.interaction(task) as interaction:
# Imported history is native context, while the first phrase remains the
# caller's live opening turn.
async with agents.agent.interaction(
task,
history=[
vf.SystemMessage(content=SYSTEM),
vf.AssistantMessage(content=GREETING),
],
) as interaction:
for phrase in task.data.phrases:
if (await interaction.turn(phrase)).terminated:
break
Expand Down
22 changes: 17 additions & 5 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,15 @@ async def test_single_turn(run_v1, harness, harness_runtime, tmp_path):
@pytest.mark.parametrize("harness_runtime", USER_RUNTIMES, indirect=True)
async def test_user(run_v1, harness_runtime, tmp_path):
"""Multi-turn, driven by a scripted user — an interaction loop in the env's
`run()` — across the harness runtime axis. The task is prompt-less, so one
run covers the whole exchange shape: the caller opens (the user speaks first),
each later turn resumes the harness onto the conversation, and leaving the loop
ends the exchange (`user_closed`). The user runs in the eval process itself, so
there is no placement axis."""
`run()` — across the harness runtime axis. Seeded system context and an
assistant greeting are imported before the caller's first live turn; each later
turn resumes the harness onto the conversation, and leaving the loop ends the
exchange (`user_closed`). The user runs in the eval process itself, so there is
no placement axis."""
from echo_user_sim_v1 import SYSTEM

import verifiers.v1 as vf

(trace,) = await run_v1(
"echo-user-sim-v1",
harness="null",
Expand All @@ -138,6 +142,14 @@ async def test_user(run_v1, harness_runtime, tmp_path):
assert trace.num_turns >= 2 # genuinely multi-turn
assert trace.stop_condition == "user_closed" # leaving the interaction ended it
assert trace.reward == 1.0
imported_system = [
node
for node in trace.nodes
if not node.sampled
and isinstance(node.message, vf.SystemMessage)
and node.message.content == SYSTEM
]
assert len(imported_system) == 1


@pytest.mark.e2e
Expand Down
26 changes: 24 additions & 2 deletions verifiers/v1/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ def _interception_for(
return self._server
return None

def _check_resume_support(self) -> None:
def _check_interaction_support(self, history: Messages | None = None) -> None:
# Multi-turn capability is a derived fact, not a flag: an exchange advances
# by resuming the harness onto the conversation, so the harness needs either
# the default relaunch (a Messages prompt) or its own native continuation.
Expand All @@ -352,6 +352,16 @@ def _check_resume_support(self) -> None:
"default relaunch-on-the-conversation, or a native resume() "
"override. Use a harness that has one (e.g. bash or null)."
)
if (
history
and type(harness).bootstrap is Harness.bootstrap
and not harness.SUPPORTS_HISTORY_IMPORT
):
raise ValueError(
f"Harness {harness.config.id!r} cannot import conversation history: "
"it needs role-preserving Messages launch support or a native "
"bootstrap() override."
)

async def run(
self,
Expand Down Expand Up @@ -431,6 +441,7 @@ async def interaction(
runtime: Runtime | None = None,
tools: Mapping[str, SharedToolServer] | None = None,
mask_prompt: bool = False,
history: Messages | None = None,
on_trace: Callable[[Trace], None] | None = None,
) -> AsyncIterator[Interaction]:
"""Interact with this agent turn-by-turn: a full rollout of `task` where
Expand All @@ -452,6 +463,13 @@ async def interaction(
they do for `run()`; an env supplies its taskset's shared tools
automatically for tasks loaded from that taskset.

`history` imports role-preserving conversation context into the new
interaction before its live opening turn. Imported messages are context,
not sampled turns: a prompted task still opens with bare `turn()`, while a
prompt-less task still opens with `turn(message)`. Stateless harnesses
relaunch from the typed transcript; stateful harnesses must implement a
native `bootstrap()`.

Everything is a real rollout — the trace (live on `interaction.trace`),
limits, `@stop`s, and scoring all apply; leaving the context ends the
exchange (`user_closed`) and finishes the rollout, hooks and scoring
Expand All @@ -461,7 +479,8 @@ async def interaction(
apply here."""
if self._closed:
raise RuntimeError("Agent is closed; create a new agent")
self._check_resume_support()
history = _as_messages(history) if history else None
self._check_interaction_support(history)
if mask_prompt and task.data.prompt is None:
raise ValueError(
"mask_prompt hides a prompt the task doesn't have; a prompt-less "
Expand All @@ -474,6 +493,7 @@ async def interaction(
task.data.model_copy(update={"prompt": None}) if mask_prompt else None
),
has_user=True,
history=history,
on_trace=on_trace,
**params,
)
Expand Down Expand Up @@ -659,6 +679,7 @@ async def interaction(
runtime: Runtime | None = None,
tools: Mapping[str, SharedToolServer] | None = None,
mask_prompt: bool = False,
history: Messages | None = None,
on_trace: Callable[[Trace], None] | None = None,
) -> AsyncIterator[Interaction]:
"""The agent's `interaction`, with every trace stamped with its standing
Expand All @@ -680,6 +701,7 @@ def remember(current: Trace) -> None:
runtime=runtime,
tools=tools if tools is not None else self._shared_for(task),
mask_prompt=mask_prompt,
history=history,
on_trace=self._watch(remember),
) as interaction:
yield interaction
Expand Down
83 changes: 77 additions & 6 deletions verifiers/v1/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from verifiers.v1.errors import HarnessError, boundary
from verifiers.v1.runtimes import ProgramResult, Runtime
from verifiers.v1.task import TaskData
from verifiers.v1.types import Messages
from verifiers.v1.types import Messages, UserMessage

if TYPE_CHECKING:
# Annotation-only: `Trace` appears in signatures only, so this module stays
Expand All @@ -32,6 +32,9 @@ class Harness(ABC, Generic[ConfigT]):
SUPPORTS_RESUME: ClassVar[bool] = False
"""Whether the default `resume()` can relaunch this harness from the
accumulated Messages transcript."""
SUPPORTS_HISTORY_IMPORT: ClassVar[bool] = False
"""Whether the default `bootstrap()` can launch this harness from typed,
role-preserving Messages history."""
EXECUTES_CODE: ClassVar[bool] = True
"""Whether the program hands the model local execution in the runtime — true for
every real harness; the tool-less chat loops (`null`) override to False. Read
Expand Down Expand Up @@ -121,13 +124,27 @@ async def run(
mcp_urls: dict[str, str],
data: TaskData,
messages: Messages | None = None,
history: Messages | None = None,
) -> None:
"""Run ONE segment of the exchange: the program from launch (or, with
`messages`, the user's next turn(s) via `resume`) until it yields — a segment
ends when the program exits. The rollout loop owns the exchange across
segments (and stamps its end); a harness only ever sees one segment."""
"""Run ONE segment of the exchange: launch on the task prompt, bootstrap a
new session from imported `history`, or resume with the user's next
`messages`. A segment ends when the program exits. The rollout loop owns the
exchange across segments (and stamps its end); a harness only ever sees one
segment."""
async with boundary(HarnessError, f"harness {self.config.id!r}"):
if messages is None:
if history:
result = await self.bootstrap(
ctx,
trace,
runtime,
endpoint,
secret,
mcp_urls,
data,
history,
messages,
)
elif messages is None:
result = await self.launch(
ctx, trace, runtime, endpoint, secret, mcp_urls, data
)
Expand All @@ -144,6 +161,60 @@ async def run(
f"harness {self.config.id!r} exited {result.exit_code}: {detail}"
)

async def bootstrap(
self,
ctx: ModelContext,
trace: Trace,
runtime: Runtime,
endpoint: str,
secret: str,
mcp_urls: dict[str, str],
data: TaskData,
history: Messages,
messages: Messages | None,
) -> ProgramResult:
"""Start a new exchange with role-preserving conversation `history`.

`history` is pre-existing context, not a sampled turn. The task prompt (for
a prompted interaction) or `messages` (when the caller opens it) is the live
user turn that follows. The default relaunches a stateless Messages-capable
harness on that conversation. A stateful harness overrides this hook to
import the history into its own new session before sending the live turn.
"""
if not self.SUPPORTS_HISTORY_IMPORT:
raise HarnessError(
f"harness {self.config.id!r} cannot import conversation history: it "
"neither overrides bootstrap() nor declares role-preserving "
"Messages launch support."
)
if messages is not None:
live = messages
elif isinstance(data.prompt, str):
live = [UserMessage(content=data.prompt)]
elif data.prompt is not None:
live = data.prompt
else:
raise HarnessError(
"history import needs a live opening turn: set task.prompt or call "
"interaction.turn(message)"
)
# `resolve_prompt` re-emits `data.system_prompt`; mirror `resume()` so an
# imported transcript cannot duplicate that system context.
imported = [
message
for message in history
if message.role != "system" or data.system_prompt is None
]
return await self.launch(
ctx,
trace,
runtime,
endpoint,
secret,
mcp_urls,
data.model_copy(update={"prompt": [*imported, *live]}),
)
Comment thread
cursor[bot] marked this conversation as resolved.

async def score(self, trace: Trace, runtime: Runtime) -> None:
"""Run this harness's `@metric` methods over the finished trace, recording
each into `trace.metrics`. Metrics declare what they need (`task`, `trace`,
Expand Down
1 change: 1 addition & 0 deletions verifiers/v1/harnesses/bash/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class BashHarness(Harness[BashHarnessConfig]):
APPENDS_SYSTEM_PROMPT = True
SUPPORTS_MCP = True
SUPPORTS_RESUME = True
SUPPORTS_HISTORY_IMPORT = True
NEEDS_CONTAINER = False

async def setup(self, runtime: Runtime) -> None:
Expand Down
1 change: 1 addition & 0 deletions verifiers/v1/harnesses/null/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class NullHarness(Harness[NullHarnessConfig]):
APPENDS_SYSTEM_PROMPT = True
SUPPORTS_MCP = True
SUPPORTS_RESUME = True
SUPPORTS_HISTORY_IMPORT = True
EXECUTES_CODE = False
NEEDS_CONTAINER = False

Expand Down
12 changes: 9 additions & 3 deletions verifiers/v1/rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def __init__(
runtime_config: RuntimeConfig,
wire_data: TaskData | None = None,
has_user: bool = False,
history: Messages | None = None,
setup_timeout: float | None = None,
harness_timeout: float | None = None,
finalize_timeout: float | None = None,
Expand All @@ -132,6 +133,7 @@ def __init__(
self.ctx = ctx
self.runtime_config = runtime_config
self._has_user = has_user
self._history = history
self._setup_timeout = setup_timeout
self._harness_time_remaining = harness_timeout
self._finalize_timeout = finalize_timeout
Expand Down Expand Up @@ -306,7 +308,9 @@ async def step(self, messages: Messages | None = None) -> bool:
"""Run ONE segment: the harness program to its exit. With `messages`, the
segment resumes the exchange with the user's turn(s) (`Harness.resume` —
for an exchange the user opens, this is also the first segment, on an
empty conversation); without, it launches on the task's own prompt.
empty conversation); without, it launches on the task's own prompt. On the
first segment only, an interaction's imported history is bootstrapped ahead
of that live turn.
Returns whether the exchange can continue — a refused turn (limit, @stop),
a timeout, a failure, or a segment that made no progress all end it."""
if not self._opened or self._closed or not self.ok:
Expand All @@ -331,9 +335,11 @@ async def step(self, messages: Messages | None = None) -> bool:
self._endpoint,
self._secret,
self._urls,
trace.task.data,
messages,
data=trace.task.data,
messages=messages,
history=self._history,
)
self._history = None
except TimeoutError as e:
# Only the rollout deadline reads as a clean truncation; a TimeoutError
# from the harness's own I/O with no expired deadline is a failure —
Expand Down
Loading