Skip to content
Draft
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
46 changes: 45 additions & 1 deletion verifiers/v1/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@

from verifiers.v1.agent import Agent, AgentConfig, Agents, _EpisodeAgent
from verifiers.v1.harness import Harness, HarnessConfig
from verifiers.v1.clients import Client, ClientConfig, ModelContext, resolve_client
from verifiers.v1.clients import (
Client,
ClientConfig,
ModelContext,
TrainClient,
resolve_client,
)
from verifiers.v1.types import ID
from verifiers.v1.interception import (
ElasticInterceptionPoolConfig,
Expand Down Expand Up @@ -289,6 +295,8 @@ def __init__(self, config: ConfigT) -> None:
self._agent_clients: dict[str, Client] = {}
# Resource warnings dedupe env-wide (agents are per-episode).
self._warned_resources: set = set()
# Agents already demoted from trainable, so the warning fires once.
self._demoted_trainable: set[str] = set()

# --- the multi-agent surface (override these) ------------------------------

Expand Down Expand Up @@ -382,6 +390,41 @@ def _client_for(self, config: ClientConfig) -> Client:
self._agent_clients[key] = resolve_client(config)
return self._agent_clients[key]

def _resolve_trainable_standing(self, agents: Agents, ctx: ModelContext) -> None:
"""One model context is trainable — `ctx`. Every trainable agent must
point to it: the same `model`, no pinned `client`. On a training run
(a `TrainClient` — its tokens exist to be trained on) a divergent
trainable agent is a config error; on any other run it is demoted,
warned once — an eval must not fail over a training-only concern, but
standing filters metrics and records, so it must stay honest. Resolved
after `setup()` (which declares standing), before any tokens burn."""
for name in self._agent_specs:
agent = getattr(agents, name)
if not agent.trainable:
continue
if agent.config.model == ctx.model and agent.config.client is None:
continue
if isinstance(ctx.client, TrainClient):
raise ValueError(
f"trainable agent {name!r} doesn't use the trainable model "
f"({ctx.model!r}) — mark it untrainable in "
f"{type(self).__name__}.setup() "
f"(`agents.{name}.trainable = False`)"
)
agent.trainable = False
if name in self._demoted_trainable:
continue
self._demoted_trainable.add(name)
logger.warning(
"agent %r is declared trainable but doesn't use the trainable "
"model context (%r): its traces are marked untrainable. Declare "
"`agents.%s.trainable = False` in %s.setup() to silence this.",
name,
ctx.model,
name,
type(self).__name__,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Demotes instead of refusing

High Severity

The PR is meant to refuse a mismatched trainable agent with a ValueError on the episode, but _resolve_trainable_standing auto-demotes it and only logs a warning. Training runs then stay alive with zero trainable traces—the silent failure the design notes explicitly reject.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e605132ac5583fd3037f9148df90bac389cab97f. Configure here.


async def run_episode(
self,
task: Task,
Expand All @@ -404,6 +447,7 @@ async def run_episode(
async with asyncio.timeout(self.config.timeout.episode):
async with boundary(EnvError, f"{type(self).__name__}.setup()"):
await self.setup(agents)
self._resolve_trainable_standing(agents, ctx)
async with boundary(EnvError, f"{type(self).__name__}.run()"):
await self.run(task, agents)
if not episode.traces:
Expand Down
Loading