agent-hitch 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapters/providers/deepseek.js +3 -1
- package/dist/src/adapters/providers/deepseek.js.map +1 -1
- package/dist/src/adapters/providers/shared.js +3 -2
- package/dist/src/adapters/providers/shared.js.map +1 -1
- package/dist/src/backends/harbor/backend.js +21 -5
- package/dist/src/backends/harbor/backend.js.map +1 -1
- package/dist/src/backends/harbor/verifier-config.js +11 -0
- package/dist/src/backends/harbor/verifier-config.js.map +1 -0
- package/dist/src/cli/arguments.js +14 -0
- package/dist/src/cli/arguments.js.map +1 -1
- package/dist/src/cli/output.js +4 -1
- package/dist/src/cli/output.js.map +1 -1
- package/dist/src/evals/harbor-bridge-error.js +65 -0
- package/dist/src/evals/harbor-bridge-error.js.map +1 -0
- package/dist/src/evals/index.js +2 -2
- package/dist/src/evals/index.js.map +1 -1
- package/dist/src/evals/infrastructure-retry.js +181 -0
- package/dist/src/evals/infrastructure-retry.js.map +1 -0
- package/dist/src/evals/progress.js +3 -0
- package/dist/src/evals/progress.js.map +1 -1
- package/dist/src/evals/request.js +14 -1
- package/dist/src/evals/request.js.map +1 -1
- package/dist/src/evals/rerun-slots.js +103 -0
- package/dist/src/evals/rerun-slots.js.map +1 -0
- package/dist/src/evals/rerun.js +167 -148
- package/dist/src/evals/rerun.js.map +1 -1
- package/dist/src/evals/service.js +196 -84
- package/dist/src/evals/service.js.map +1 -1
- package/dist/src/evals/trial-import.js +52 -36
- package/dist/src/evals/trial-import.js.map +1 -1
- package/dist/src/evals/verifier-diagnostics.js +235 -0
- package/dist/src/evals/verifier-diagnostics.js.map +1 -0
- package/docs/schemas/eval-request.schema.json +2 -0
- package/integrations/harbor/hitch_harbor_agent.py +389 -40
- package/integrations/harbor/hitch_harbor_verifier.py +314 -0
- package/package.json +3 -2
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Harbor verifier wrapper with conservative, verifier-only infra retries.
|
|
2
|
+
|
|
3
|
+
The wrapper retries the task's test script in the same live environment. It
|
|
4
|
+
never re-instantiates or calls the candidate agent, so the candidate workspace
|
|
5
|
+
and all other container state remain unchanged between verifier attempts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from harbor.models.trial.paths import EnvironmentPaths
|
|
19
|
+
from harbor.models.verifier.result import VerifierResult
|
|
20
|
+
from harbor.verifier.verifier import Verifier
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MAX_LOG_BYTES = 256 * 1024
|
|
24
|
+
LOG_NAMES = ("test-stdout.txt", "test-stderr.txt", "stdout.txt", "stderr.txt")
|
|
25
|
+
OUTPUT_NAMES = (*LOG_NAMES, "reward.txt", "reward.json", "ctrf.json")
|
|
26
|
+
CONTROL_NAMES = ("infrastructure-error.json", "infrastructure-retry-history.json")
|
|
27
|
+
|
|
28
|
+
INFRASTRUCTURE_PATTERNS: tuple[tuple[str, tuple[re.Pattern[str], ...]], ...] = (
|
|
29
|
+
(
|
|
30
|
+
"dns_resolution_failed",
|
|
31
|
+
(
|
|
32
|
+
re.compile(r"curl:\s*\(\d+\)\s*Could not resolve host:", re.I),
|
|
33
|
+
re.compile(r"Temporary failure in name resolution", re.I),
|
|
34
|
+
re.compile(r"Name or service not known", re.I),
|
|
35
|
+
re.compile(r"getaddrinfo\s+(?:EAI_AGAIN|ENOTFOUND)", re.I),
|
|
36
|
+
re.compile(r"Could not resolve hostname", re.I),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"network_unreachable",
|
|
41
|
+
(
|
|
42
|
+
re.compile(r"Network is unreachable", re.I),
|
|
43
|
+
re.compile(
|
|
44
|
+
r"Failed to establish a new connection:[^\n]*(?:timed out|connection refused)",
|
|
45
|
+
re.I,
|
|
46
|
+
),
|
|
47
|
+
re.compile(r"Could not connect to (?:host|server)", re.I),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"package_install_failed",
|
|
52
|
+
(
|
|
53
|
+
re.compile(r"Could not find a version that satisfies the requirement", re.I),
|
|
54
|
+
re.compile(r"No matching distribution found for", re.I),
|
|
55
|
+
re.compile(r"Failed to (?:download|fetch) [^\n]*(?:package|wheel|index)", re.I),
|
|
56
|
+
re.compile(r"error:\s*failed to (?:download|fetch|install)", re.I),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"test_runner_missing",
|
|
61
|
+
(
|
|
62
|
+
re.compile(r"(?:^|\n)[^\n]*(?:uvx|pytest|pipx|tox|nox): command not found(?:\n|$)", re.I),
|
|
63
|
+
re.compile(r"No module named ['\"]?(?:pytest|unittest|tox|nox)['\"]?", re.I),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
"verifier_environment_missing",
|
|
68
|
+
(
|
|
69
|
+
re.compile(r"/(?:root|home/[^/]+)/\.local/bin/env: No such file or directory", re.I),
|
|
70
|
+
re.compile(
|
|
71
|
+
r"(?:^|\n)[^\n]*/bin/(?:python|python3|pytest|uv|uvx): No such file or directory(?:\n|$)",
|
|
72
|
+
re.I,
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
TEST_EXECUTION_EVIDENCE = (
|
|
79
|
+
re.compile(r"test session starts", re.I),
|
|
80
|
+
re.compile(r"collected\s+\d+\s+items?", re.I),
|
|
81
|
+
re.compile(r"(?:^|\n)Ran\s+\d+\s+tests?", re.I),
|
|
82
|
+
re.compile(r"(?:^|\n)TAP version\s+\d+", re.I),
|
|
83
|
+
re.compile(r"={3,}[^\n]*(?:passed|failed|errors?|skipped)[^\n]*={3,}", re.I),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class VerifierInfrastructureError(RuntimeError):
|
|
88
|
+
"""Raised after every verifier-only infrastructure retry is exhausted."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class InfrastructureObservation:
|
|
93
|
+
signals: tuple[str, ...]
|
|
94
|
+
source_files: tuple[str, ...]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HitchRetryingVerifier(Verifier):
|
|
98
|
+
"""Retry only masked verifier bootstrap failures in the live trial."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*args: Any,
|
|
103
|
+
infrastructure_retries: int = 1,
|
|
104
|
+
infrastructure_retry_backoff_ms: int = 1_000,
|
|
105
|
+
**kwargs: Any,
|
|
106
|
+
) -> None:
|
|
107
|
+
if isinstance(infrastructure_retries, bool) or not isinstance(infrastructure_retries, int) or infrastructure_retries < 0:
|
|
108
|
+
raise ValueError("infrastructure_retries must be a non-negative integer")
|
|
109
|
+
if (
|
|
110
|
+
isinstance(infrastructure_retry_backoff_ms, bool)
|
|
111
|
+
or not isinstance(infrastructure_retry_backoff_ms, int)
|
|
112
|
+
or infrastructure_retry_backoff_ms < 0
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("infrastructure_retry_backoff_ms must be a non-negative integer")
|
|
115
|
+
super().__init__(*args, **kwargs)
|
|
116
|
+
self.infrastructure_retries = infrastructure_retries
|
|
117
|
+
self.infrastructure_retry_backoff_ms = infrastructure_retry_backoff_ms
|
|
118
|
+
|
|
119
|
+
async def verify(self) -> VerifierResult:
|
|
120
|
+
# Do not trust a control file left by the candidate or a prior phase.
|
|
121
|
+
await self._remove_files(CONTROL_NAMES)
|
|
122
|
+
attempts: list[dict[str, Any]] = []
|
|
123
|
+
for attempt in range(1, self.infrastructure_retries + 2):
|
|
124
|
+
result: VerifierResult | None = None
|
|
125
|
+
caught: Exception | None = None
|
|
126
|
+
try:
|
|
127
|
+
result = await super().verify()
|
|
128
|
+
except Exception as error:
|
|
129
|
+
caught = error
|
|
130
|
+
|
|
131
|
+
reward = _primary_reward(result)
|
|
132
|
+
observation = _detect_infrastructure(
|
|
133
|
+
self.trial_paths.verifier_dir,
|
|
134
|
+
reward,
|
|
135
|
+
allow_missing_reward=caught is not None,
|
|
136
|
+
)
|
|
137
|
+
if observation is None:
|
|
138
|
+
# The control namespace belongs to this wrapper. Remove files
|
|
139
|
+
# a task script may have created before returning/propagating.
|
|
140
|
+
await self._remove_files(CONTROL_NAMES)
|
|
141
|
+
if caught is not None:
|
|
142
|
+
raise caught
|
|
143
|
+
if attempts:
|
|
144
|
+
self._write_history("recovered", attempts)
|
|
145
|
+
assert result is not None
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
attempts.append(
|
|
149
|
+
{
|
|
150
|
+
"attempt": attempt,
|
|
151
|
+
"signals": list(observation.signals),
|
|
152
|
+
"source_files": list(observation.source_files),
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
self._archive_attempt(attempt)
|
|
156
|
+
if attempt > self.infrastructure_retries:
|
|
157
|
+
diagnostic = {
|
|
158
|
+
"schema_version": "1",
|
|
159
|
+
"code": "verifier_infrastructure_failure",
|
|
160
|
+
"signals": list(observation.signals),
|
|
161
|
+
"source_files": list(observation.source_files),
|
|
162
|
+
"attempts": attempts,
|
|
163
|
+
"max_retries": self.infrastructure_retries,
|
|
164
|
+
"backoff_ms": self.infrastructure_retry_backoff_ms,
|
|
165
|
+
}
|
|
166
|
+
self._write_json("infrastructure-error.json", diagnostic)
|
|
167
|
+
self._write_history("exhausted", attempts)
|
|
168
|
+
signal_list = ", ".join(observation.signals)
|
|
169
|
+
raise VerifierInfrastructureError(
|
|
170
|
+
"verifier infrastructure retries exhausted "
|
|
171
|
+
f"after {attempt} attempt(s): {signal_list}"
|
|
172
|
+
) from caught
|
|
173
|
+
|
|
174
|
+
self._write_history("retrying", attempts)
|
|
175
|
+
await self._clear_outputs()
|
|
176
|
+
backoff_seconds = (self.infrastructure_retry_backoff_ms * attempt) / 1_000
|
|
177
|
+
if backoff_seconds > 0:
|
|
178
|
+
await asyncio.sleep(backoff_seconds)
|
|
179
|
+
|
|
180
|
+
raise AssertionError("unreachable verifier retry state")
|
|
181
|
+
|
|
182
|
+
async def _clear_outputs(self) -> None:
|
|
183
|
+
await self._remove_files(OUTPUT_NAMES)
|
|
184
|
+
|
|
185
|
+
async def _remove_files(self, names: tuple[str, ...]) -> None:
|
|
186
|
+
for name in names:
|
|
187
|
+
(self.trial_paths.verifier_dir / name).unlink(missing_ok=True)
|
|
188
|
+
env_paths = EnvironmentPaths.for_os(self.environment.os)
|
|
189
|
+
if self.environment.os.value == "windows":
|
|
190
|
+
targets = ",".join(
|
|
191
|
+
f"'{str(env_paths.verifier_dir / name)}'"
|
|
192
|
+
for name in names
|
|
193
|
+
)
|
|
194
|
+
command = (
|
|
195
|
+
"powershell -NoProfile -NonInteractive -Command "
|
|
196
|
+
f'"Remove-Item -Force -ErrorAction SilentlyContinue {targets}"'
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
targets = " ".join(
|
|
200
|
+
_shell_quote(str(env_paths.verifier_dir / name))
|
|
201
|
+
for name in names
|
|
202
|
+
)
|
|
203
|
+
command = f"rm -f -- {targets}"
|
|
204
|
+
cleared = await self.environment.exec(
|
|
205
|
+
command=command,
|
|
206
|
+
user=None if self.environment.os.value == "windows" else "root",
|
|
207
|
+
)
|
|
208
|
+
if cleared.return_code != 0:
|
|
209
|
+
raise VerifierInfrastructureError(
|
|
210
|
+
"could not clear verifier control/output files: "
|
|
211
|
+
f"exit {cleared.return_code}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _archive_attempt(self, attempt: int) -> None:
|
|
215
|
+
archive = self.trial_paths.verifier_dir / "infrastructure-attempts" / f"attempt-{attempt:04d}"
|
|
216
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
for name in OUTPUT_NAMES:
|
|
218
|
+
source = self.trial_paths.verifier_dir / name
|
|
219
|
+
if source.is_file():
|
|
220
|
+
shutil.copy2(source, archive / name)
|
|
221
|
+
|
|
222
|
+
def _write_history(self, status: str, attempts: list[dict[str, Any]]) -> None:
|
|
223
|
+
self._write_json(
|
|
224
|
+
"infrastructure-retry-history.json",
|
|
225
|
+
{
|
|
226
|
+
"schema_version": "1",
|
|
227
|
+
"code": "verifier_infrastructure_retry_history",
|
|
228
|
+
"status": status,
|
|
229
|
+
"max_retries": self.infrastructure_retries,
|
|
230
|
+
"backoff_ms": self.infrastructure_retry_backoff_ms,
|
|
231
|
+
"attempts": attempts,
|
|
232
|
+
"candidate_rerun": False,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _write_json(self, name: str, value: dict[str, Any]) -> None:
|
|
237
|
+
target = self.trial_paths.verifier_dir / name
|
|
238
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
target.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _primary_reward(result: VerifierResult | None) -> float | int | None:
|
|
243
|
+
if result is None:
|
|
244
|
+
return None
|
|
245
|
+
preferred = result.rewards.get("reward")
|
|
246
|
+
if isinstance(preferred, (int, float)) and not isinstance(preferred, bool):
|
|
247
|
+
return preferred
|
|
248
|
+
return next(
|
|
249
|
+
(
|
|
250
|
+
value
|
|
251
|
+
for value in result.rewards.values()
|
|
252
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
253
|
+
),
|
|
254
|
+
None,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _detect_infrastructure(
|
|
259
|
+
verifier_dir: Path,
|
|
260
|
+
reward: float | int | None,
|
|
261
|
+
*,
|
|
262
|
+
allow_missing_reward: bool,
|
|
263
|
+
) -> InfrastructureObservation | None:
|
|
264
|
+
if reward != 0 and not (allow_missing_reward and reward is None):
|
|
265
|
+
return None
|
|
266
|
+
ctrf = verifier_dir / "ctrf.json"
|
|
267
|
+
if ctrf.is_file() and ctrf.stat().st_size > 0:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
logs: list[str] = []
|
|
271
|
+
source_files: list[str] = []
|
|
272
|
+
for name in LOG_NAMES:
|
|
273
|
+
source = verifier_dir / name
|
|
274
|
+
value = _read_bounded(source)
|
|
275
|
+
if value is None:
|
|
276
|
+
continue
|
|
277
|
+
logs.append(value)
|
|
278
|
+
source_files.append(f"verifier/{name}")
|
|
279
|
+
if not logs:
|
|
280
|
+
return None
|
|
281
|
+
combined = "\n".join(logs)
|
|
282
|
+
if any(pattern.search(combined) for pattern in TEST_EXECUTION_EVIDENCE):
|
|
283
|
+
return None
|
|
284
|
+
signals = tuple(
|
|
285
|
+
signal
|
|
286
|
+
for signal, patterns in INFRASTRUCTURE_PATTERNS
|
|
287
|
+
if any(pattern.search(combined) for pattern in patterns)
|
|
288
|
+
)
|
|
289
|
+
if not signals:
|
|
290
|
+
return None
|
|
291
|
+
return InfrastructureObservation(signals=signals, source_files=tuple(source_files))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _read_bounded(source: Path) -> str | None:
|
|
295
|
+
try:
|
|
296
|
+
size = source.stat().st_size
|
|
297
|
+
except FileNotFoundError:
|
|
298
|
+
return None
|
|
299
|
+
with source.open("rb") as handle:
|
|
300
|
+
if size <= MAX_LOG_BYTES:
|
|
301
|
+
return handle.read().decode("utf-8", errors="replace")
|
|
302
|
+
half = MAX_LOG_BYTES // 2
|
|
303
|
+
head = handle.read(half)
|
|
304
|
+
handle.seek(max(0, size - (MAX_LOG_BYTES - half)))
|
|
305
|
+
tail = handle.read(MAX_LOG_BYTES - half)
|
|
306
|
+
return (
|
|
307
|
+
head.decode("utf-8", errors="replace")
|
|
308
|
+
+ "\n[... verifier log truncated ...]\n"
|
|
309
|
+
+ tail.decode("utf-8", errors="replace")
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _shell_quote(value: str) -> str:
|
|
314
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hitch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Content-addressed version control and evidence storage for agent harnesses",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"dist/src/",
|
|
44
44
|
"dist/scripts/",
|
|
45
45
|
"docs/schemas/",
|
|
46
|
-
"integrations/harbor/hitch_harbor_agent.py"
|
|
46
|
+
"integrations/harbor/hitch_harbor_agent.py",
|
|
47
|
+
"integrations/harbor/hitch_harbor_verifier.py"
|
|
47
48
|
],
|
|
48
49
|
"scripts": {
|
|
49
50
|
"typecheck": "tsc --noEmit",
|