Skip to content

Commit 93c8bf7

Browse files
committed
Add 3-hour agent timeout, retry policy, and spend cap for enhanced experiment robustness
- Extend `AGENT_TIMEOUT_SEC` to 3 hours for long-running tasks, with per-run spend capped at $30 to maintain budget control. - Define a default retry policy (`max_retries=2`) to handle transient API errors while excluding genuine timeouts. - Update `run_agent.sh` with Anthropic API key and base URL support. - Ensure user-supplied configurations override defaults for retry and spend cap. - Expand unit tests to validate new retry logic, cost cap enforcement, and timeout adjustments.
1 parent c0460fe commit 93c8bf7

5 files changed

Lines changed: 117 additions & 1 deletion

File tree

‎scripts/run_agent.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ set -a
88
set +a
99
export OPENAI_API_KEY="$LITELLM_API_KEY"
1010
export OPENAI_BASE_URL="$LITELLM_BASE_URL"
11+
export ANTHROPIC_API_KEY="$LITELLM_API_KEY"
12+
export ANTHROPIC_BASE_URL="$LITELLM_BASE_URL/anthropic"
1113

1214
LITELLM_BASE_HOST="${LITELLM_BASE_URL#*://}"
1315
LITELLM_BASE_HOST="${LITELLM_BASE_HOST%%/*}"

‎src/kotlinai/experiment.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@
4444
# in one job that is a confound, not just noise.
4545
AGENT_VERSION_PACKAGES = {"codex": "@openai/codex"}
4646

47+
# Default retry policy for the generated Harbor job. Transient agent API errors
48+
# (e.g. UnknownApiError, "API Error: Failed to parse JSON") are one-off proxy
49+
# hiccups that a clean re-run of the trial almost always clears, so retrying
50+
# recovers otherwise-lost trials. We rely on Harbor's default
51+
# ``RetryConfig.exclude_exceptions`` (which already contains AgentTimeoutError,
52+
# VerifierTimeoutError, ApiUsageLimitError, ...) so genuine timeouts/limits stay
53+
# non-retried. A retry re-runs the whole trial from scratch, so it is capped low.
54+
DEFAULT_RETRY = {"max_retries": 2}
55+
56+
# Default per-run model spend cap for claude-code (``--max-budget-usd``). The
57+
# agent timeout is now 3 hours (see ``kotlinai.harbor.AGENT_TIMEOUT_SEC``); this
58+
# bounds spend under that larger time budget. A retry re-runs the whole trial,
59+
# so this cap is per agent run, not per trial. Passed as a string, matching how
60+
# claude-code's CLI flag is resolved from kwargs.
61+
DEFAULT_AGENT_MAX_BUDGET_USD = "30"
62+
4763
# Two-sided 95% Student's t critical values indexed by degrees of freedom (entry
4864
# 0 is unused). Beyond the table the value has converged on the normal quantile.
4965
# fmt: off
@@ -77,6 +93,21 @@ def pin_agent_version(agent: dict[str, Any], resolver: Callable[[str], str]) ->
7793
kwargs["version"] = resolver(package)
7894

7995

96+
def apply_agent_cost_limit(agent: dict[str, Any]) -> None:
97+
"""Cap claude-code's per-run model spend unless the caller set a limit.
98+
99+
Harbor passes ``kwargs.max_budget_usd`` to claude-code as ``--max-budget-usd``.
100+
Now that the agent timeout is 3 hours (see ``AGENT_TIMEOUT_SEC``), this keeps
101+
a single agent run's spend bounded. The flag is claude-code specific, so this
102+
is a no-op for other agents, and an explicit ``agent.kwargs.max_budget_usd``
103+
in the config still wins.
104+
"""
105+
if agent["name"].strip().lower() != "claude-code":
106+
return
107+
kwargs = agent.setdefault("kwargs", {})
108+
kwargs.setdefault("max_budget_usd", DEFAULT_AGENT_MAX_BUDGET_USD)
109+
110+
80111
@dataclass(frozen=True)
81112
class ComparisonSettings:
82113
languages: tuple[str, str]
@@ -162,6 +193,8 @@ def validate_comparison_config(config: dict[str, Any]) -> ComparisonSettings:
162193
conflicting_fields = sorted(set(harbor) & RESERVED_HARBOR_FIELDS)
163194
if conflicting_fields:
164195
raise ComparisonError(f"harbor contains generated field(s): {conflicting_fields}")
196+
if "retry" in harbor and not isinstance(harbor["retry"], dict):
197+
raise ComparisonError("harbor.retry must be an object")
165198
return ComparisonSettings(
166199
languages=languages,
167200
parallelism=parallelism,
@@ -194,6 +227,9 @@ def build_comparison_job_config(
194227
an IndexError partway through. Datasets keep that registry populated.
195228
"""
196229
config = copy.deepcopy(settings.harbor)
230+
# A default retry only when the user has not set one, so an explicit
231+
# ``harbor.retry`` in the config still fully overrides it.
232+
config.setdefault("retry", copy.deepcopy(DEFAULT_RETRY))
197233
config["job_name"] = job_name
198234
config["jobs_dir"] = str(jobs_dir.resolve())
199235
config["n_concurrent_trials"] = settings.parallelism
@@ -253,6 +289,8 @@ def run_comparison_experiment(
253289
# CLI is the confound this run exists to avoid. Pin explicitly in the config
254290
# to run without registry access.
255291
pin_agent_version(settings.agent, version_resolver)
292+
# Bound claude-code's per-run spend now that the agent timeout is 3 hours.
293+
apply_agent_cost_limit(settings.agent)
256294
output_dir.mkdir(parents=True)
257295
jobs_dir = output_dir / "jobs"
258296
repo_root = runner.parent.parent

‎src/kotlinai/harbor.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
COMPILE_TIMEOUT_SEC = 900.0
3939
BRANCH_TIMEOUT_SEC = 3600.0
4040
BUILD_TIMEOUT_SEC = 1800.0
41-
AGENT_TIMEOUT_SEC = 3600.0
41+
# 3 hours. These reverse-engineering tasks need more than the earlier 1-hour cap
42+
# to finish; a per-run cost limit (see experiment.py) keeps the larger budget
43+
# bounded. AgentTimeoutError stays in Harbor's exclude_exceptions (not retried).
44+
AGENT_TIMEOUT_SEC = 10800.0
4245
KOTLIN_VERSION = "2.4.10"
4346
KOTLIN_COMPILER_SHA256 = "473dd66c7a3ef4b182065b3da670466c1bf2773a9dbb0ed8b33a39fe9d4f876d"
4447
# The agent phase reaches the model API and nothing else: fetching upstream

‎tests/test_experiment.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import yaml
1414

1515
from kotlinai.experiment import (
16+
DEFAULT_AGENT_MAX_BUDGET_USD,
1617
ComparisonError,
18+
apply_agent_cost_limit,
1719
build_comparison_job_config,
1820
build_comparison_report,
1921
render_markdown_report,
@@ -65,6 +67,67 @@ def test_comparison_job_config_contains_both_languages_and_shared_parameters(tmp
6567
]
6668

6769

70+
def test_comparison_job_config_injects_default_retry_when_unset(tmp_path):
71+
base = _base_config()
72+
base["harbor"].pop("retry")
73+
settings = validate_comparison_config(base)
74+
config = build_comparison_job_config(
75+
settings,
76+
task_dirs={
77+
"java": tmp_path / "tasks" / "java",
78+
"kotlin": tmp_path / "tasks" / "kotlin",
79+
},
80+
jobs_dir=tmp_path / "jobs",
81+
job_name="comparison",
82+
)
83+
84+
# Transient agent API errors are retried; genuine timeouts/limits stay
85+
# non-retried via Harbor's default exclude_exceptions.
86+
assert config["retry"] == {"max_retries": 2}
87+
88+
89+
def test_comparison_job_config_keeps_user_supplied_retry(tmp_path):
90+
base = _base_config()
91+
base["harbor"]["retry"] = {"max_retries": 5}
92+
settings = validate_comparison_config(base)
93+
config = build_comparison_job_config(
94+
settings,
95+
task_dirs={
96+
"java": tmp_path / "tasks" / "java",
97+
"kotlin": tmp_path / "tasks" / "kotlin",
98+
},
99+
jobs_dir=tmp_path / "jobs",
100+
job_name="comparison",
101+
)
102+
103+
assert config["retry"] == {"max_retries": 5}
104+
105+
106+
def test_validate_comparison_config_rejects_non_object_retry():
107+
config = _base_config()
108+
config["harbor"]["retry"] = 3
109+
with pytest.raises(ComparisonError, match="harbor.retry"):
110+
validate_comparison_config(config)
111+
112+
113+
def test_apply_agent_cost_limit_defaults_claude_code_budget():
114+
agent = {"name": "claude-code", "model_name": "claude-sonnet-5"}
115+
apply_agent_cost_limit(agent)
116+
assert agent["kwargs"]["max_budget_usd"] == DEFAULT_AGENT_MAX_BUDGET_USD
117+
118+
119+
def test_apply_agent_cost_limit_keeps_user_supplied_budget():
120+
agent = {"name": "claude-code", "model_name": "claude-sonnet-5", "kwargs": {"max_budget_usd": "50"}}
121+
apply_agent_cost_limit(agent)
122+
assert agent["kwargs"]["max_budget_usd"] == "50"
123+
124+
125+
def test_apply_agent_cost_limit_is_noop_for_other_agents():
126+
agent = {"name": "codex", "model_name": "openai/test-model"}
127+
apply_agent_cost_limit(agent)
128+
assert "max_budget_usd" not in agent.get("kwargs", {})
129+
130+
68131
@pytest.mark.parametrize("languages", [[], ["Java"], ["Java", "Kotlin", "Go"], ["Java", "java"], ["", "Kotlin"]])
69132
def test_validate_comparison_config_requires_two_distinct_languages(languages):
70133
config = _base_config()

‎tests/test_harbor.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ def test_convert_instance_task_toml_enforces_programbench_policy(tmp_path):
156156
assert "rm -f ./executable" in (harbor.HARBOR_DATA / "test.sh").read_text()
157157

158158

159+
def test_convert_instance_bakes_three_hour_agent_timeout(tmp_path):
160+
out = convert_instance(_calc_instance(), tmp_path, target_language="Kotlin")
161+
cfg = tomllib.loads((out / "task.toml").read_text())
162+
163+
# The agent gets a 3-hour budget so long reverse-engineering tasks finish;
164+
# a per-run cost cap (experiment.py) keeps spend bounded under that budget.
165+
assert harbor.AGENT_TIMEOUT_SEC == 10800.0
166+
assert cfg["agent"]["timeout_sec"] == harbor.AGENT_TIMEOUT_SEC
167+
168+
159169
def test_agent_image_ships_inspection_tools_and_drops_sudo(tmp_path):
160170
out = convert_instance(_calc_instance(), tmp_path, target_language="Kotlin")
161171
dockerfile = (out / "environment" / "Dockerfile").read_text()

0 commit comments

Comments
 (0)