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.
Files changed (36) hide show
  1. package/dist/src/adapters/providers/deepseek.js +3 -1
  2. package/dist/src/adapters/providers/deepseek.js.map +1 -1
  3. package/dist/src/adapters/providers/shared.js +3 -2
  4. package/dist/src/adapters/providers/shared.js.map +1 -1
  5. package/dist/src/backends/harbor/backend.js +21 -5
  6. package/dist/src/backends/harbor/backend.js.map +1 -1
  7. package/dist/src/backends/harbor/verifier-config.js +11 -0
  8. package/dist/src/backends/harbor/verifier-config.js.map +1 -0
  9. package/dist/src/cli/arguments.js +14 -0
  10. package/dist/src/cli/arguments.js.map +1 -1
  11. package/dist/src/cli/output.js +4 -1
  12. package/dist/src/cli/output.js.map +1 -1
  13. package/dist/src/evals/harbor-bridge-error.js +65 -0
  14. package/dist/src/evals/harbor-bridge-error.js.map +1 -0
  15. package/dist/src/evals/index.js +2 -2
  16. package/dist/src/evals/index.js.map +1 -1
  17. package/dist/src/evals/infrastructure-retry.js +181 -0
  18. package/dist/src/evals/infrastructure-retry.js.map +1 -0
  19. package/dist/src/evals/progress.js +3 -0
  20. package/dist/src/evals/progress.js.map +1 -1
  21. package/dist/src/evals/request.js +14 -1
  22. package/dist/src/evals/request.js.map +1 -1
  23. package/dist/src/evals/rerun-slots.js +103 -0
  24. package/dist/src/evals/rerun-slots.js.map +1 -0
  25. package/dist/src/evals/rerun.js +167 -148
  26. package/dist/src/evals/rerun.js.map +1 -1
  27. package/dist/src/evals/service.js +196 -84
  28. package/dist/src/evals/service.js.map +1 -1
  29. package/dist/src/evals/trial-import.js +52 -36
  30. package/dist/src/evals/trial-import.js.map +1 -1
  31. package/dist/src/evals/verifier-diagnostics.js +235 -0
  32. package/dist/src/evals/verifier-diagnostics.js.map +1 -0
  33. package/docs/schemas/eval-request.schema.json +2 -0
  34. package/integrations/harbor/hitch_harbor_agent.py +389 -40
  35. package/integrations/harbor/hitch_harbor_verifier.py +314 -0
  36. 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.4",
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",