Skip to content
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
28a5605
move logging utils
mikasenghaas Mar 7, 2026
602177b
update agent start cmd
mikasenghaas Mar 7, 2026
4b94f8a
adjust wording + remove task management prompt
mikasenghaas Mar 7, 2026
996362b
fix event loop blocking: offload serialization to thread, skip log st…
mikasenghaas Mar 7, 2026
6116061
fix: offload rollout serialization to thread to unblock event loop
mikasenghaas Mar 7, 2026
003744e
compact info logs
mikasenghaas Mar 8, 2026
46bb77f
ruff
mikasenghaas Mar 8, 2026
17be841
less verbose start log
mikasenghaas Mar 8, 2026
c73e3c8
use constant timeout
mikasenghaas Mar 8, 2026
69882bf
30s timeout
mikasenghaas Mar 8, 2026
f6469ba
bring back task management prompt
mikasenghaas Mar 8, 2026
c67d4b2
do not redundantly log model abort error + streaming error
mikasenghaas Mar 8, 2026
3dde6a9
minor logging improvements
mikasenghaas Mar 8, 2026
e5f439e
raise agent error
mikasenghaas Mar 9, 2026
db39779
raise sandbox error if agent bg job fails
mikasenghaas Mar 9, 2026
0f46882
Add EventLoopBlockingDetector to capture stack traces when event loop…
mikasenghaas Mar 9, 2026
c717430
Revert "Add EventLoopBlockingDetector to capture stack traces when ev…
mikasenghaas Mar 9, 2026
a6c5bed
add task sys prompt
mikasenghaas Mar 9, 2026
a41609e
handle bg job polling errors
mikasenghaas Mar 9, 2026
a88a216
30min default timeouts
mikasenghaas Mar 9, 2026
617ad93
pipe agent failure
mikasenghaas Mar 9, 2026
ac201f9
http prob for tunnel liveness
mikasenghaas Mar 9, 2026
ffa2f9c
ruff
mikasenghaas Mar 9, 2026
fab96b5
Merge branch 'main' into opencode-envs
mikasenghaas Mar 11, 2026
60a2194
dont re-raise agent error, but log error
mikasenghaas Mar 11, 2026
7542021
update hybrid math rubric constructor
mikasenghaas Mar 11, 2026
be2253b
allow offline difficulty filtering
mikasenghaas Mar 11, 2026
00c1a0f
fix default model
mikasenghaas Mar 11, 2026
c7857f4
collect agent logs
mikasenghaas Mar 11, 2026
b8c1791
use warning log
mikasenghaas Mar 11, 2026
7c9b59b
agent per-request timeout 30min->1h
mikasenghaas Mar 12, 2026
8347df4
remove http health probe again
mikasenghaas Mar 12, 2026
d729f1d
default timeouts to 1h
mikasenghaas Mar 12, 2026
67ea4bf
remove tunnel lock (not needed bc no concurrent health monitor)
mikasenghaas Mar 12, 2026
53c831b
simplify poll_job
mikasenghaas Mar 12, 2026
b149c96
use cls logger
mikasenghaas Mar 12, 2026
5dbf8a2
oops bring back tunnel lock
mikasenghaas Mar 12, 2026
cfb150e
add file upload helpers
mikasenghaas Mar 12, 2026
556b164
allow rubric cleanup
mikasenghaas Mar 12, 2026
bed4ed0
sandbox scoring in hybrid math rubric
mikasenghaas Mar 12, 2026
ee1cc99
do not upload bundle
mikasenghaas Mar 12, 2026
bb4b86c
align local and remote math verify
mikasenghaas Mar 12, 2026
e0a6713
add read_file
mikasenghaas Mar 12, 2026
7996d8b
remote math rubric
mikasenghaas Mar 12, 2026
724383d
remove script
mikasenghaas Mar 12, 2026
4af0ca3
handle answer not found
mikasenghaas Mar 12, 2026
e0c5b1b
add teardown to rubric
mikasenghaas Mar 13, 2026
a9767ce
use utils for decorator discovery
mikasenghaas Mar 13, 2026
c5ba1c4
deregister + registere flow from env -> rubric
mikasenghaas Mar 13, 2026
4ea97f2
revert zmq env server changes
mikasenghaas Mar 17, 2026
3fbbced
Merge branch 'main' into opencode-envs
mikasenghaas Mar 18, 2026
a0d18a5
fix ruff
mikasenghaas Mar 18, 2026
a3f85e5
fix: catch CancelledError from response_future in stream handler
mikasenghaas Mar 18, 2026
3b099b2
fix: preserve existing logger in SandboxMixin.init_sandbox_client
mikasenghaas Mar 18, 2026
c37da47
fix: use self.logger for tunnel debug level check in CliAgentEnv
mikasenghaas Mar 18, 2026
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
155 changes: 87 additions & 68 deletions verifiers/envs/experimental/cli_agent_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import math
import time
import uuid
from collections import Counter
from typing import Any, cast

from prime_sandboxes import (
Expand All @@ -17,24 +18,31 @@
from verifiers.clients import Client
from verifiers.envs.experimental.sandbox_mixin import SandboxMixin, SandboxMonitorRubric
from verifiers.types import (
AssistantMessage,
Messages,
MessageType,
Response,
SamplingArgs,
State,
Tool,
ToolCall,
)
from verifiers.utils.interception_utils import (
InterceptionServer,
deliver_response,
synthesize_stream,
)
from verifiers.utils.logging_utils import print_time, truncate
from verifiers.utils.message_utils import normalize_messages
from verifiers.utils.worker_utils import get_free_port

logger = logging.getLogger(__name__)


class AgentError(vf.InfraError):
"""Raised when the agent process fails or exits unexpectedly."""


class CliAgentMonitorRubric(vf.Rubric):
"""Monitor rubric that tracks CLI agent execution state."""

Expand Down Expand Up @@ -90,6 +98,7 @@ def __init__(
sandbox_client_max_connections: int = 100,
sandbox_client_max_keepalive_connections: int = 50,
sandbox_wait_for_creation_max_attempts: int = 120,
keep_sandbox_for_scoring: bool = False,
**kwargs,
):
super().__init__(max_turns=max_turns, message_type="chat", **kwargs)
Expand All @@ -104,6 +113,7 @@ def __init__(
sandbox_client_max_keepalive_connections=sandbox_client_max_keepalive_connections,
sandbox_wait_for_creation_max_attempts=sandbox_wait_for_creation_max_attempts,
)
self.keep_sandbox_for_scoring = keep_sandbox_for_scoring
self.run_command = run_command
self.poll_interval = poll_interval
self.timeout_seconds = timeout_seconds
Expand Down Expand Up @@ -135,7 +145,6 @@ def init_interception(
self.interception_url = interception_url
self._tunnel: Tunnel | None = None
self._tunnel_lock = asyncio.Lock()
self._tunnel_monitor_task: asyncio.Task | None = None
self._interception_server = InterceptionServer(port=interception_port)

def _require_interception_server(self) -> InterceptionServer:
Expand All @@ -148,7 +157,7 @@ async def get_tunnel_url(self) -> str:
async with self._tunnel_lock:
if self._tunnel is not None and not self._tunnel.is_running:
frpc_output = "\n".join(self._tunnel.recent_output)
logger.warning(
self.logger.warning(
f"Tunnel process died, recreating. frpc output:\n{frpc_output}"
)
self._tunnel.sync_stop()
Expand All @@ -165,48 +174,12 @@ async def get_tunnel_url(self) -> str:
else:
self._tunnel = Tunnel(local_port=port)
url = await self._tunnel.start()
logger.debug(f"Prime Tunnel started: {url}")

Comment thread
cursor[bot] marked this conversation as resolved.
# Lazily start health monitor on first tunnel creation
if (
self._tunnel_monitor_task is None
or self._tunnel_monitor_task.done()
):
self._tunnel_monitor_task = asyncio.create_task(
self._tunnel_health_monitor()
)

self.logger.debug(f"Prime Tunnel started: {url}")
return url
else:
assert self._tunnel.url is not None, "Tunnel started but URL is None"
return self._tunnel.url

async def _tunnel_health_monitor(self, interval: float = 30.0) -> None:
"""Background task that checks tunnel liveness and restarts a dead tunnel."""
try:
while True:
await asyncio.sleep(interval)
async with self._tunnel_lock:
if self._tunnel is not None and not self._tunnel.is_running:
frpc_output = "\n".join(self._tunnel.recent_output)
logger.warning(
f"Health monitor: tunnel dead. frpc output:\n{frpc_output}"
)
self._tunnel.sync_stop()
interception_server = self._require_interception_server()
port = interception_server.port
if logger.isEnabledFor(logging.DEBUG):
self._tunnel = Tunnel(
local_port=port,
log_level="debug",
)
else:
self._tunnel = Tunnel(local_port=port)
url = await self._tunnel.start()
logger.info(f"Health monitor: restarted tunnel url={url}")
except asyncio.CancelledError:
return

async def setup_state(self, state: State) -> State:
"""Setup sandbox + interception for this rollout"""
state = await super().setup_state(state)
Expand Down Expand Up @@ -242,7 +215,7 @@ async def setup_state(self, state: State) -> State:
advanced_configs=self.advanced_configs,
labels=self.labels if self.labels else [],
)
logger.debug(
self.logger.debug(
f"Creating sandbox with OPENAI_BASE_URL={env_vars.get('OPENAI_BASE_URL')} "
f"docker_image={docker_image}"
)
Expand All @@ -255,6 +228,12 @@ async def setup_state(self, state: State) -> State:

await self.start_agent(state)

parts = [
f"Started rollout_id={state['rollout_id']}",
f"example_id={state['example_id']}",
]
self.logger.info(" | ".join(parts))

return state

async def get_docker_image(self, state: State) -> str:
Expand All @@ -265,9 +244,9 @@ async def build_env_vars(self, state: State) -> dict[str, str]:
"""Build environment variables for the sandbox. Override to add custom vars."""
env_vars = dict(self.environment_vars) if self.environment_vars else {}
env_vars["OPENAI_BASE_URL"] = state["interception_base_url"]
env_vars.setdefault("OPENAI_TIMEOUT", "600")
env_vars.setdefault("OPENAI_REQUEST_TIMEOUT", "600")
env_vars.setdefault("HTTPX_TIMEOUT", "600")
env_vars.setdefault("OPENAI_TIMEOUT", "3600")
env_vars.setdefault("OPENAI_REQUEST_TIMEOUT", "3600")
env_vars.setdefault("HTTPX_TIMEOUT", "3600")
model = state.get("model")
if model:
env_vars["OPENAI_MODEL"] = model
Expand All @@ -281,10 +260,16 @@ async def start_agent(self, state: State) -> None:
"""Start the agent command using background job."""
sandbox_id = state["sandbox_id"]

background_job: BackgroundJob = await self.sandbox_client.start_background_job(
sandbox_id,
self.run_command,
)
self.logger.debug(f"Starting agent in sandbox {sandbox_id}")
try:
background_job: BackgroundJob = (
await self.sandbox_client.start_background_job(
sandbox_id,
self.run_command,
)
)
except Exception as e:
raise vf.SandboxError(f"Failed to start agent: {e}") from e
state["background_job"] = background_job
state["agent_start_time"] = time.time()

Expand All @@ -307,13 +292,15 @@ async def wait_for_completion(self, state: State) -> None:
timeout=self.timeout_seconds,
)
except asyncio.TimeoutError:
logger.warning(f"Agent timed out after {self.timeout_seconds}s")
self.logger.warning(f"Agent timed out after {self.timeout_seconds}s")
state["agent_timed_out"] = True
except asyncio.CancelledError:
logger.debug("Completion wait task cancelled")
self.logger.debug("Completion wait task cancelled")
raise
except Exception as e:
logger.debug(f"Completion wait ended: {e}")
error = AgentError(f"Agent polling failed: {e}")
state["error"] = error
self.logger.error(f"Agent polling failed: {e}")
finally:
state["agent_completed"] = True

Expand All @@ -330,11 +317,11 @@ async def poll_job_completion(
state["agent_stdout"] = status.stdout
state["agent_stderr"] = status.stderr
if status.exit_code == 0:
logger.debug(
self.logger.debug(
f"Agent completed successfully (exit_code={status.exit_code})"
)
else:
logger.warning(
self.logger.warning(
f"Agent failed (exit_code={status.exit_code}) stdout={status.stdout}, stderr={status.stderr}"
)
return
Expand Down Expand Up @@ -525,24 +512,13 @@ async def add_model_response(
@vf.teardown
async def teardown_resources(self):
"""Stop Prime Tunnel and HTTP interception server."""
if (
self._tunnel_monitor_task is not None
and not self._tunnel_monitor_task.done()
):
self._tunnel_monitor_task.cancel()
try:
await self._tunnel_monitor_task
except asyncio.CancelledError:
pass
self._tunnel_monitor_task = None

async with self._tunnel_lock:
if self._tunnel is not None:
try:
self._tunnel.sync_stop()
logger.debug("Prime Tunnel stopped")
self.logger.debug("Prime Tunnel stopped")
except Exception as e:
logger.warning(f"Error stopping Prime Tunnel: {e}")
self.logger.warning(f"Error stopping Prime Tunnel: {e}")
finally:
self._tunnel = None
if self._interception_server is not None:
Expand Down Expand Up @@ -582,15 +558,58 @@ async def post_rollout(self, state: State):
Override for custom post-rollout logic. For example, if sandbox state is needed for reward functions,
run computation here and cache the result in state before sandbox is destroyed.
"""
pass
tool_counts: Counter[str] = Counter()
for step in state.get("trajectory", []):
for msg in step.get("completion", []):
if isinstance(msg, AssistantMessage) and isinstance(
msg.tool_calls, list
):
for tc in msg.tool_calls:
if isinstance(tc, ToolCall):
tool_counts[tc.name] += 1

example_id = state.get("example_id")
num_turns = len(state.get("trajectory", []))
stop_condition = state.get("stop_condition", "unknown")
error = state.get("error")
error_info = (
f"{type(error).__name__}: {truncate(str(error), 80)}" if error else None
)
exit_code = state.get("agent_exit_code")
timed_out = state.get("agent_timed_out", False)
duration_s = state["timing"].get("total_ms", 0) / 1000
tools_str = ",".join(f"{k}:{v}" for k, v in tool_counts.most_common())
parts = [
f"Finished rollout_id={state.get('rollout_id')}",
f"example_id={example_id}",
f"turns={num_turns}",
f"tools=[{tools_str}]",
f"stop={stop_condition}",
f"exit_code={exit_code}",
f"duration={print_time(duration_s)}",
]
if timed_out:
parts.append("timed_out=True")
if error_info:
parts.append(f"error={error_info}")
self.logger.info(" | ".join(parts))

@vf.cleanup
async def destroy_sandbox(self, state: State):
"""Cleanup sandbox after rollout."""
"""Cleanup sandbox after rollout.

When `keep_sandbox_for_scoring` is True, sandbox deletion is deferred
(e.g. when the rubric needs sandbox access during scoring).
The sandbox is still deregistered from active tracking so the
environment teardown does not attempt a redundant bulk-delete.
"""
await self.post_rollout(state)
sandbox_id = state.get("sandbox_id")
if sandbox_id:
await self.delete_sandbox(sandbox_id)
if self.keep_sandbox_for_scoring:
self.deregister_sandbox(sandbox_id)
else:
await self.delete_sandbox(sandbox_id)

async def env_response(
self, messages: Messages, state: State, **kwargs
Expand Down
Loading
Loading