diff --git a/pyproject.toml b/pyproject.toml index a9c0d0f..596a410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "mcp[cli]>=1.27,<2", "anthropic", "openai", + "tenacity", ] [project.optional-dependencies] diff --git a/src/robocode/utils/apptainer_sandbox.py b/src/robocode/utils/apptainer_sandbox.py index f58a050..7f0aec6 100644 --- a/src/robocode/utils/apptainer_sandbox.py +++ b/src/robocode/utils/apptainer_sandbox.py @@ -63,6 +63,7 @@ ) from robocode.utils.docker_sandbox import ( DOCKER_PYTHON, + GENPLAN_CONTAINER_TIMEOUT_S, _filtered_repo_mounts, _find_repo_root, _get_claude_oauth_token, @@ -419,7 +420,7 @@ def run_genplan_in_apptainer( sandbox_dir: Path, completion_cfg: dict[str, Any], sif_path: Path = _DEFAULT_SIF, - timeout: float = 3600.0, + timeout: float = GENPLAN_CONTAINER_TIMEOUT_S, include_bilevel: bool = False, ) -> None: """Apptainer analog of :func:`docker_sandbox.run_genplan_in_docker`. diff --git a/src/robocode/utils/docker_sandbox.py b/src/robocode/utils/docker_sandbox.py index ac34a37..747fdc4 100644 --- a/src/robocode/utils/docker_sandbox.py +++ b/src/robocode/utils/docker_sandbox.py @@ -99,6 +99,11 @@ def container_python(blackbox_strict: bool) -> str: # Default Docker image name. _DEFAULT_IMAGE: str = "robocode-sandbox" +# A $20 Opus GenPlan run can take longer than an hour. Keep one shared limit for +# Docker and Apptainer so the selected container backend does not change whether +# the configured model budget can be exhausted. +GENPLAN_CONTAINER_TIMEOUT_S: float = 10 * 60 * 60 + def _telemetry_docker(config: SandboxConfig) -> tuple[list[str], list[str]]: """(extra volumes, env args) enabling telemetry for a whitebox docker run. @@ -397,7 +402,7 @@ def run_genplan_in_docker( sandbox_dir: Path, completion_cfg: dict[str, Any], image: str = _DEFAULT_IMAGE, - timeout: float = 3600.0, + timeout: float = GENPLAN_CONTAINER_TIMEOUT_S, include_bilevel: bool = False, ) -> None: """Run the whole LLM-GenPlan loop inside one sandbox container. @@ -445,13 +450,25 @@ def run_genplan_in_docker( ss_pybullet=ss_pybullet, ) + [DOCKER_PYTHON, "-m", "robocode.approaches.genplan_driver"] logger.info("Starting genplan Docker container %s", container_name) - subprocess.run( - docker_cmd, - env={**os.environ, **auth_env}, - stdin=subprocess.DEVNULL, - check=True, - timeout=timeout, - ) + try: + subprocess.run( + docker_cmd, + env={**os.environ, **auth_env}, + stdin=subprocess.DEVNULL, + check=True, + timeout=timeout, + ) + except BaseException: + # A timeout kills the attached Docker client, not necessarily the named + # container. Remove it explicitly so an abandoned run cannot keep + # spending model budget or writing into the sandbox directory. + subprocess.run( + ["docker", "rm", "-f", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + raise def _copy_src( diff --git a/src/robocode/utils/llm/cli_client.py b/src/robocode/utils/llm/cli_client.py index c968962..34a363a 100644 --- a/src/robocode/utils/llm/cli_client.py +++ b/src/robocode/utils/llm/cli_client.py @@ -1,6 +1,7 @@ """Claude CLI driven as a plain LLM: the default completion backend. -Runs ``claude -p`` single-shot with tools and system prompt stripped. Billing +Runs ``claude -p`` with tools and system prompt stripped, resuming matching +conversations by session ID to preserve message boundaries and cache reuse. Billing follows the authenticated CLI (no API key needed), and the same CLI serves the agentic backend, so completion and agentic runs share one auth path and one set of model ids. @@ -13,17 +14,83 @@ from __future__ import annotations import json +import logging import os import subprocess +from typing import Any from omegaconf import DictConfig +from tenacity import ( + Retrying, + before_sleep_log, + retry_if_exception, + stop_after_attempt, + wait_random_exponential, +) from robocode.utils.backends.claude import anthropic_compatible_env, get_claude_cmd from robocode.utils.llm.base import LLMResponse +from robocode.utils.rate_limit import wait_for_rate_limit_reset + +logger = logging.getLogger(__name__) + + +class ClaudeCLIError(RuntimeError): + """A failed Claude CLI invocation, including its structured error output.""" + + def __init__(self, returncode: int, stdout: str | None, stderr: str | None) -> None: + self.returncode = returncode + self.stdout = stdout or "" + self.stderr = stderr or "" + super().__init__( + f"Claude exited with status {returncode}.\n" + f"stdout: {self.stdout or '(empty)'}\n" + f"stderr: {self.stderr or '(empty)'}" + ) + + +def _is_transient_claude_error(exc: BaseException) -> bool: + """Return whether Claude reported a retryable server-side API failure.""" + data = _claude_error_data(exc) + if data is None: + return False + status = data.get("api_error_status") + # Only retry when the CLI explicitly confirms that the failed request was + # free. A missing cost is ambiguous and could duplicate a charged request. + return ( + isinstance(status, int) + and 500 <= status < 600 + and data.get("total_cost_usd") == 0 + ) + + +def _claude_error_data(exc: BaseException) -> dict[str, Any] | None: + """Parse a Claude CLI error envelope when one is available.""" + if not isinstance(exc, ClaudeCLIError): + return None + try: + data: Any = json.loads(exc.stdout) + except (json.JSONDecodeError, TypeError): + return None + return data if isinstance(data, dict) else None + + +def _session_limit_reset(exc: BaseException) -> str | None: + """Return Claude's reset message for a safe-to-repeat usage-limit failure.""" + data = _claude_error_data(exc) + if data is None or data.get("api_error_status") != 429: + return None + message = data.get("result") + if not isinstance(message, str) or data.get("total_cost_usd") != 0: + return None + normalized = message.lower() + if "limit" not in normalized or "reset" not in normalized: + return None + return message class ClaudeCLIClient: - """Single-shot completions via the Claude Code CLI (no tools).""" + """Text completions via the Claude Code CLI with per-client session reuse.""" def __init__(self, cfg: DictConfig) -> None: self._model = cfg["model"] @@ -32,10 +99,49 @@ def __init__(self, cfg: DictConfig) -> None: self._ollama_keep_alive = cfg.get("ollama_keep_alive", "") self._timeout_s = cfg.get("request_timeout_s", 1200.0) self._max_thinking_tokens = cfg.get("max_thinking_tokens", 0) + self._retry_attempts = cfg.get("retry_attempts", 3) + self._retry_wait_min_s = cfg.get("retry_wait_min_s", 2.0) + self._retry_wait_max_s = cfg.get("retry_wait_max_s", 30.0) + self._session_id: str | None = None + self._history: list[dict[str, str]] = [] def complete(self, messages: list[dict[str, str]]) -> LLMResponse: """Return the model's reply to a message list.""" - prompt = _flatten(messages) + retrying = Retrying( + retry=retry_if_exception(_is_transient_claude_error), + stop=stop_after_attempt(self._retry_attempts), + wait=wait_random_exponential( + min=self._retry_wait_min_s, max=self._retry_wait_max_s + ), + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) + while True: + try: + return retrying(self._complete_once, messages) + except ClaudeCLIError as exc: + reset_message = _session_limit_reset(exc) + if reset_message is None: + raise + wait_for_rate_limit_reset(reset_message) + + def _complete_once(self, messages: list[dict[str, str]]) -> LLMResponse: + """Make one attempt, retaining session state only for a safe reset retry.""" + # Only resume an exact continuation. Callers may reuse this client for + # independent samples or edit history, which must start fresh sessions. + resume = ( + self._session_id is not None + and len(messages) == len(self._history) + 1 + and messages[:-1] == self._history + and messages[-1]["role"] == "user" + ) + session_id = self._session_id if resume else None + previous_history = self._history + prompt = messages[-1]["content"] if resume else _flatten(messages) + # A failed request may have advanced the remote session. Do not resume + # that uncertain state if the caller retries. + self._session_id = None + self._history = [] args = [ get_claude_cmd(), "-p", @@ -54,6 +160,8 @@ def complete(self, messages: list[dict[str, str]]) -> LLMResponse: "--max-thinking-tokens", str(self._max_thinking_tokens), ] + if session_id is not None: + args += ["--resume", session_id] env = {k: v for k, v in os.environ.items() if not k.startswith("CLAUDECODE")} env.update( anthropic_compatible_env( @@ -72,15 +180,26 @@ def complete(self, messages: list[dict[str, str]]) -> LLMResponse: timeout=self._timeout_s, ) except subprocess.CalledProcessError as exc: - raise RuntimeError( - f"Claude exited with status {exc.returncode}.\n" - f"stdout: {exc.stdout or '(empty)'}\n" - f"stderr: {exc.stderr or '(empty)'}" - ) from exc + error = ClaudeCLIError(exc.returncode, exc.stdout, exc.stderr) + if _session_limit_reset(error) is not None: + self._session_id = session_id + self._history = previous_history + raise error from exc data = json.loads(result.stdout) - return LLMResponse(text=data["result"], cost_usd=data.get("total_cost_usd")) + if data.get("is_error"): + error = ClaudeCLIError(result.returncode, result.stdout, result.stderr) + if _session_limit_reset(error) is not None: + self._session_id = session_id + self._history = previous_history + raise error + response = LLMResponse(text=data["result"], cost_usd=data.get("total_cost_usd")) + self._session_id = data.get("session_id") + self._history = [dict(message) for message in messages] + [ + {"role": "assistant", "content": response.text} + ] + return response def _flatten(messages: list[dict[str, str]]) -> str: - """Collapse the multi-turn history into one prompt for the stateless CLI.""" + """Serialize initial or replaced history when starting a fresh CLI session.""" return "\n\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages) diff --git a/src/robocode/utils/rate_limit.py b/src/robocode/utils/rate_limit.py index 697243c..e4ff78a 100644 --- a/src/robocode/utils/rate_limit.py +++ b/src/robocode/utils/rate_limit.py @@ -112,6 +112,23 @@ def seconds_until_reset( return min(wait, _MAX_WAIT_SECS) +def wait_for_rate_limit_reset(reset_message: str) -> None: + """Sleep until Claude's reported usage reset, including a short grace period.""" + reset_hour, reset_minute, is_utc = parse_reset_time(reset_message) + wait_secs = seconds_until_reset(reset_hour, is_utc, reset_minute) + logger.warning( + "Rate-limited (%s). Sleeping %.1f hours for reset at %02d:%02d %s " + "(plus 5-minute grace) ...", + reset_message, + wait_secs / 3600, + reset_hour, + reset_minute, + "UTC" if is_utc else "local", + ) + time.sleep(wait_secs) + logger.info("Woke up after rate-limit sleep; retrying...") + + def _fold_retry_metrics( result: SandboxResult, aborted_tokens: int, @@ -319,22 +336,7 @@ def budget_stop_reason(latest: SandboxResult) -> str | None: if rate_limited: assert result.rate_limit_reset is not None - reset_hour, reset_minute, is_utc = parse_reset_time(result.rate_limit_reset) - wait_secs = seconds_until_reset(reset_hour, is_utc, reset_minute) - hours = wait_secs / 3600 - logger.warning( - "Rate-limited (%s). Sleeping %.1f hours for reset at %02d:%02d %s " - "(plus 5-minute grace) ...", - result.error, - hours, - reset_hour, - reset_minute, - "UTC" if is_utc else "local", - ) - time.sleep(wait_secs) - logger.info( - "Woke up after rate-limit sleep, resuming with remaining budget..." - ) + wait_for_rate_limit_reset(result.rate_limit_reset) resume_prompt = active.prompt else: output_token_retries += 1 diff --git a/tests/utils/test_apptainer_sandbox.py b/tests/utils/test_apptainer_sandbox.py index 9996848..66db412 100644 --- a/tests/utils/test_apptainer_sandbox.py +++ b/tests/utils/test_apptainer_sandbox.py @@ -16,7 +16,11 @@ _build_apptainer_cmd, run_genplan_in_apptainer, ) -from robocode.utils.docker_sandbox import DOCKER_PYTHON, _find_repo_root +from robocode.utils.docker_sandbox import ( + DOCKER_PYTHON, + GENPLAN_CONTAINER_TIMEOUT_S, + _find_repo_root, +) def test_apptainer_python_matches_docker_python() -> None: @@ -182,8 +186,11 @@ def test_genplan_cmd_adds_containall( ) calls: list[list[str]] = [] - def fake_run(cmd: list[str], **_kwargs) -> None: + timeouts: list[float] = [] + + def fake_run(cmd: list[str], **kwargs) -> None: calls.append(cmd) + timeouts.append(kwargs["timeout"]) monkeypatch.setattr("robocode.utils.apptainer_sandbox.subprocess.run", fake_run) @@ -194,6 +201,7 @@ def fake_run(cmd: list[str], **_kwargs) -> None: ) assert len(calls) == 1 + assert timeouts == [GENPLAN_CONTAINER_TIMEOUT_S] assert calls[0][:3] == ["apptainer", "exec", "--containall"] assert "--pid" in calls[0] diff --git a/tests/utils/test_llm.py b/tests/utils/test_llm.py index 1595931..2b85934 100644 --- a/tests/utils/test_llm.py +++ b/tests/utils/test_llm.py @@ -8,12 +8,17 @@ import shutil import subprocess import uuid +from contextlib import nullcontext from pathlib import Path import pytest from omegaconf import DictConfig -from robocode.utils.docker_sandbox import DOCKER_PYTHON, run_genplan_in_docker +from robocode.utils.docker_sandbox import ( + DOCKER_PYTHON, + GENPLAN_CONTAINER_TIMEOUT_S, + run_genplan_in_docker, +) from robocode.utils.llm.base import pricing_from_cfg, usage_cost from robocode.utils.llm.cli_client import ClaudeCLIClient @@ -56,6 +61,47 @@ def fake_run(args, **kwargs): assert reply.cost_usd == 0.01 +def test_genplan_docker_timeout_removes_container(tmp_path, monkeypatch): + """A timed-out Docker client does not leave a paid container running.""" + filtered_src = tmp_path / "src" + filtered_kindergarden = tmp_path / "kindergarden" + filtered_src.mkdir() + filtered_kindergarden.mkdir() + monkeypatch.setattr( + "robocode.utils.docker_sandbox._filtered_repo_mounts", + lambda **_kwargs: nullcontext( + (filtered_src, filtered_kindergarden, None, None) + ), + ) + monkeypatch.setattr( + "robocode.utils.docker_sandbox._build_docker_auth_args", + lambda _backend: nullcontext(([], {})), + ) + monkeypatch.setattr( + "robocode.utils.docker_sandbox.firewall_domains_for_provider", + lambda *_args: [], + ) + monkeypatch.setattr( + "robocode.utils.docker_sandbox._docker_run_prefix", + lambda name, *_args, **_kwargs: ["docker", "run", "--name", name], + ) + calls: list[list[str]] = [] + + def fake_run(args, **_kwargs): + calls.append(args) + if args[:2] == ["docker", "run"]: + raise subprocess.TimeoutExpired(args, GENPLAN_CONTAINER_TIMEOUT_S) + return subprocess.CompletedProcess(args, 0) + + monkeypatch.setattr("robocode.utils.docker_sandbox.subprocess.run", fake_run) + + with pytest.raises(subprocess.TimeoutExpired): + run_genplan_in_docker(tmp_path, {"provider": "cli"}) + + container_name = calls[0][calls[0].index("--name") + 1] + assert calls[1] == ["docker", "rm", "-f", container_name] + + def test_cli_failure_exposes_diagnostics(monkeypatch): """Captured CLI errors include the actual reason rather than only exit 1.""" @@ -70,6 +116,298 @@ def fail(args, **kwargs): assert "resets 4:50am" in str(error.value) +def test_cli_retries_server_error(monkeypatch): + """A transient Claude API 5xx is retried with the same completion request.""" + calls = 0 + + def fail_twice_then_succeed(args, **_kwargs): + nonlocal calls + calls += 1 + if calls < 3: + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps( + { + "is_error": True, + "api_error_status": 500, + "total_cost_usd": 0, + "result": "API Error: 500 Internal server error", + } + ), + stderr="", + ) + return subprocess.CompletedProcess( + args, 0, json.dumps({"result": "recovered"}), "" + ) + + monkeypatch.setattr(subprocess, "run", fail_twice_then_succeed) + client = ClaudeCLIClient( + DictConfig( + { + "model": "test", + "retry_wait_min_s": 0, + "retry_wait_max_s": 0, + } + ) + ) + assert client.complete([{"role": "user", "content": "hello"}]).text == "recovered" + assert calls == 3 + + +def test_cli_waits_for_session_limit_reset(monkeypatch): + """A zero-cost session limit sleeps until reset and repeats the request.""" + calls = 0 + + def rate_limit_then_succeed(args, **_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps( + { + "is_error": True, + "api_error_status": 429, + "total_cost_usd": 0, + "result": ( + "You've hit your session limit · resets 8:20am (UTC)" + ), + } + ), + stderr="", + ) + return subprocess.CompletedProcess( + args, 0, json.dumps({"result": "recovered"}), "" + ) + + waited_for: list[str] = [] + monkeypatch.setattr(subprocess, "run", rate_limit_then_succeed) + monkeypatch.setattr( + "robocode.utils.rate_limit.wait_for_rate_limit_reset", waited_for.append + ) + + client = ClaudeCLIClient(DictConfig({"model": "test"})) + assert client.complete([{"role": "user", "content": "hello"}]).text == "recovered" + assert waited_for == ["You've hit your session limit · resets 8:20am (UTC)"] + assert calls == 2 + + +def test_cli_session_limit_retry_preserves_resume(monkeypatch): + """A rejected follow-up still resumes its established Claude conversation.""" + calls: list[tuple[list[str], str]] = [] + + def rate_limit_follow_up(args, **kwargs): + calls.append((args, kwargs["input"])) + if len(calls) == 1: + return subprocess.CompletedProcess( + args, + 0, + json.dumps({"result": "first reply", "session_id": "session-1"}), + "", + ) + if len(calls) == 2: + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps( + { + "is_error": True, + "api_error_status": 429, + "total_cost_usd": 0, + "result": "Session limit reached; resets 8:20am (UTC)", + } + ), + stderr="", + ) + return subprocess.CompletedProcess( + args, 0, json.dumps({"result": "second reply"}), "" + ) + + monkeypatch.setattr(subprocess, "run", rate_limit_follow_up) + monkeypatch.setattr( + "robocode.utils.rate_limit.wait_for_rate_limit_reset", lambda _message: None + ) + client = ClaudeCLIClient(DictConfig({"model": "test"})) + first = [{"role": "user", "content": "first prompt"}] + assert client.complete(first).text == "first reply" + follow_up = first + [ + {"role": "assistant", "content": "first reply"}, + {"role": "user", "content": "next prompt"}, + ] + + assert client.complete(follow_up).text == "second reply" + for args, prompt in calls[1:]: + assert args[args.index("--resume") + 1] == "session-1" + assert prompt == "next prompt" + + +def test_cli_does_not_retry_nonserver_error(monkeypatch): + """A non-5xx CLI failure is returned immediately rather than retried.""" + calls = 0 + + def fail(args, **_kwargs): + nonlocal calls + calls += 1 + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps({"is_error": True, "api_error_status": 400}), + stderr="bad request", + ) + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(RuntimeError, match="bad request"): + ClaudeCLIClient(DictConfig({"model": "test"})).complete([]) + assert calls == 1 + + +def test_cli_does_not_retry_charged_server_error(monkeypatch): + """A failed request with reported spend is not duplicated automatically.""" + calls = 0 + + def fail(args, **_kwargs): + nonlocal calls + calls += 1 + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps( + { + "is_error": True, + "api_error_status": 500, + "total_cost_usd": 0.25, + } + ), + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(RuntimeError, match="api_error_status"): + ClaudeCLIClient(DictConfig({"model": "test"})).complete([]) + assert calls == 1 + + +def test_cli_does_not_retry_server_error_with_unknown_cost(monkeypatch): + """Missing billing data is not assumed to mean that a request was free.""" + calls = 0 + + def fail(args, **_kwargs): + nonlocal calls + calls += 1 + raise subprocess.CalledProcessError( + 1, + args, + output=json.dumps({"is_error": True, "api_error_status": 500}), + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", fail) + with pytest.raises(RuntimeError, match="api_error_status"): + ClaudeCLIClient(DictConfig({"model": "test"})).complete([]) + assert calls == 1 + + +@pytest.fixture(name="cli_calls") +def _record_cli_calls(monkeypatch): + """Record requests and return a distinct session ID for each fake call.""" + calls = [] + + def fake_run(args, **kwargs): + calls.append((args, kwargs["input"])) + return subprocess.CompletedProcess( + args, + 0, + json.dumps( + { + "result": "reply", + "session_id": f"session-{len(calls)}", + "total_cost_usd": 0.01, + } + ), + "", + ) + + monkeypatch.setattr("robocode.utils.llm.cli_client.subprocess.run", fake_run) + return calls + + +def test_cli_resumes_matching_history(cli_calls): + """Follow-ups send only the new user turn to the explicit previous session.""" + client = ClaudeCLIClient(DictConfig({"model": "test"})) + messages = [{"role": "user", "content": "hello"}] + reply = client.complete(messages) + messages += [ + {"role": "assistant", "content": reply.text}, + {"role": "user", "content": "continue"}, + ] + followup = client.complete(messages) + args, prompt = cli_calls[-1] + assert args == cli_calls[0][0] + ["--resume", "session-1"] + assert prompt == "continue" + assert followup.cost_usd == 0.01 + + +def test_cli_repeated_sample_starts_fresh(cli_calls): + """Independent samples of the same prompt must not continue each other.""" + client = ClaudeCLIClient(DictConfig({"model": "test"})) + messages = [{"role": "user", "content": "hello"}] + client.complete(messages) + client.complete(messages) + assert "--resume" not in cli_calls[-1][0] + assert cli_calls[-1][1] == "USER: hello" + + +def test_cli_edited_history_starts_fresh(cli_calls): + """Mutating the caller's history cannot silently change session identity.""" + client = ClaudeCLIClient(DictConfig({"model": "test"})) + messages = [{"role": "user", "content": "hello"}] + client.complete(messages) + messages[0]["content"] = "changed" + messages += [ + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "continue"}, + ] + client.complete(messages) + assert "--resume" not in cli_calls[-1][0] + assert cli_calls[-1][1] == "USER: changed\n\nASSISTANT: reply\n\nUSER: continue" + + +def test_cli_failed_resume_discards_session(cli_calls, monkeypatch): + """A retry rebuilds history rather than replaying a possibly accepted turn.""" + client = ClaudeCLIClient(DictConfig({"model": "test"})) + messages = [{"role": "user", "content": "hello"}] + client.complete(messages) + messages += [ + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "continue"}, + ] + with monkeypatch.context() as patch: + + def fail(*args, **kwargs): + raise subprocess.TimeoutExpired("claude", 1) + + patch.setattr("robocode.utils.llm.cli_client.subprocess.run", fail) + with pytest.raises(subprocess.TimeoutExpired): + client.complete(messages) + client.complete(messages) + assert "--resume" not in cli_calls[-1][0] + + +def test_cli_error_result_not_saved_as_session(monkeypatch): + """An error envelope is not a successful model response.""" + + def fake_run(args, **_kwargs): + return subprocess.CompletedProcess( + args, 0, json.dumps({"is_error": True, "result": "limit reached"}), "" + ) + + monkeypatch.setattr("robocode.utils.llm.cli_client.subprocess.run", fake_run) + with pytest.raises(RuntimeError, match="limit reached"): + ClaudeCLIClient(DictConfig({"model": "test"})).complete([]) + + def _create_mcp_tool(sandbox_dir: Path) -> str: """Write the Docker MCP server/config and return its secret test token.""" # A harmless tool returns a token the model cannot know without invoking it. diff --git a/uv.lock b/uv.lock index cc92846..34ad0f0 100644 --- a/uv.lock +++ b/uv.lock @@ -2812,6 +2812,7 @@ dependencies = [ { name = "pybullet-arm64" }, { name = "pyyaml" }, { name = "scipy-stubs" }, + { name = "tenacity" }, { name = "types-shapely" }, ] @@ -2883,6 +2884,7 @@ requires-dist = [ { name = "robomimic", marker = "extra == 'libero'", specifier = "==0.2.0" }, { name = "robosuite", marker = "extra == 'libero'", specifier = "==1.4.0" }, { name = "scipy-stubs" }, + { name = "tenacity" }, { name = "types-pyyaml", marker = "extra == 'develop'" }, { name = "types-shapely" }, ] @@ -3162,6 +3164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tensorboard" version = "2.20.0"