agent-hitch 0.2.7 → 0.2.8

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 (287) hide show
  1. package/README.md +16 -2
  2. package/README.zh-CN.md +14 -2
  3. package/dist/bin/hitch.js +25 -3
  4. package/dist/bin/hitch.js.map +1 -1
  5. package/dist/scripts/canary-benchmark.js +54 -0
  6. package/dist/scripts/canary-benchmark.js.map +1 -0
  7. package/dist/scripts/canary-eval-scheduler-throughput.js +47 -0
  8. package/dist/scripts/canary-eval-scheduler-throughput.js.map +1 -0
  9. package/dist/scripts/check-architecture.js +3 -2
  10. package/dist/scripts/check-architecture.js.map +1 -1
  11. package/dist/src/adapters/catalog.js +2 -1
  12. package/dist/src/adapters/catalog.js.map +1 -1
  13. package/dist/src/adapters/providers/codex-auth.js +29 -0
  14. package/dist/src/adapters/providers/codex-auth.js.map +1 -0
  15. package/dist/src/adapters/providers/codex.js +23 -6
  16. package/dist/src/adapters/providers/codex.js.map +1 -1
  17. package/dist/src/adapters/providers/model-call.js +26 -0
  18. package/dist/src/adapters/providers/model-call.js.map +1 -0
  19. package/dist/src/artifacts/index.js +1 -1
  20. package/dist/src/artifacts/index.js.map +1 -1
  21. package/dist/src/artifacts/preparer.js +7 -2
  22. package/dist/src/artifacts/preparer.js.map +1 -1
  23. package/dist/src/backends/harbor/agent-budget.js +31 -0
  24. package/dist/src/backends/harbor/agent-budget.js.map +1 -0
  25. package/dist/src/backends/harbor/backend.js +6 -4
  26. package/dist/src/backends/harbor/backend.js.map +1 -1
  27. package/dist/src/backends/harbor/index.js +1 -0
  28. package/dist/src/backends/harbor/index.js.map +1 -1
  29. package/dist/src/backends/harbor/regrade.js +77 -0
  30. package/dist/src/backends/harbor/regrade.js.map +1 -0
  31. package/dist/src/backends/index.js +1 -0
  32. package/dist/src/backends/index.js.map +1 -1
  33. package/dist/src/benchmarks/index.js +3 -0
  34. package/dist/src/benchmarks/index.js.map +1 -0
  35. package/dist/src/benchmarks/loader.js +271 -0
  36. package/dist/src/benchmarks/loader.js.map +1 -0
  37. package/dist/src/benchmarks/metrics.js +16 -0
  38. package/dist/src/benchmarks/metrics.js.map +1 -0
  39. package/dist/src/benchmarks/toml.js +13 -0
  40. package/dist/src/benchmarks/toml.js.map +1 -0
  41. package/dist/src/benchmarks/validation.js +242 -0
  42. package/dist/src/benchmarks/validation.js.map +1 -0
  43. package/dist/src/cli/arguments.js +6 -4
  44. package/dist/src/cli/arguments.js.map +1 -1
  45. package/dist/src/cli/commands/benchmark.js +22 -0
  46. package/dist/src/cli/commands/benchmark.js.map +1 -0
  47. package/dist/src/cli/commands/capabilities.js +18 -0
  48. package/dist/src/cli/commands/capabilities.js.map +1 -0
  49. package/dist/src/cli/commands/eval.js +15 -3
  50. package/dist/src/cli/commands/eval.js.map +1 -1
  51. package/dist/src/cli/commands/run.js +37 -15
  52. package/dist/src/cli/commands/run.js.map +1 -1
  53. package/dist/src/cli/commands/trajectory.js +96 -5
  54. package/dist/src/cli/commands/trajectory.js.map +1 -1
  55. package/dist/src/cli/commands/verifier.js +36 -0
  56. package/dist/src/cli/commands/verifier.js.map +1 -0
  57. package/dist/src/cli/main.js +6 -0
  58. package/dist/src/cli/main.js.map +1 -1
  59. package/dist/src/cli/output.js +14 -2
  60. package/dist/src/cli/output.js.map +1 -1
  61. package/dist/src/control-plane/eval-control-work.js +5 -0
  62. package/dist/src/control-plane/eval-control-work.js.map +1 -1
  63. package/dist/src/control-plane/eval-scheduler.js +4 -1
  64. package/dist/src/control-plane/eval-scheduler.js.map +1 -1
  65. package/dist/src/control-plane/index.js +1 -1
  66. package/dist/src/control-plane/index.js.map +1 -1
  67. package/dist/src/control-plane/remote-result-transport.js +3 -0
  68. package/dist/src/control-plane/remote-result-transport.js.map +1 -1
  69. package/dist/src/control-plane/remote-work-coordinator.js +1 -0
  70. package/dist/src/control-plane/remote-work-coordinator.js.map +1 -1
  71. package/dist/src/control-plane/remote-work-item.js +62 -0
  72. package/dist/src/control-plane/remote-work-item.js.map +1 -0
  73. package/dist/src/control-plane/remote-work-recovery.js +44 -5
  74. package/dist/src/control-plane/remote-work-recovery.js.map +1 -1
  75. package/dist/src/control-plane/remote-worker-protocol.js +2 -23
  76. package/dist/src/control-plane/remote-worker-protocol.js.map +1 -1
  77. package/dist/src/control-plane/rerun-scheduler.js +51 -14
  78. package/dist/src/control-plane/rerun-scheduler.js.map +1 -1
  79. package/dist/src/control-plane/rerun-submission.js +10 -4
  80. package/dist/src/control-plane/rerun-submission.js.map +1 -1
  81. package/dist/src/control-plane/work-admission.js +2 -0
  82. package/dist/src/control-plane/work-admission.js.map +1 -1
  83. package/dist/src/control-plane/work-dispatcher.js +9 -1
  84. package/dist/src/control-plane/work-dispatcher.js.map +1 -1
  85. package/dist/src/controller-runtime/hash.js +7 -2
  86. package/dist/src/controller-runtime/hash.js.map +1 -1
  87. package/dist/src/daemon/auth.js +5 -0
  88. package/dist/src/daemon/auth.js.map +1 -1
  89. package/dist/src/domain/benchmarks.js +2 -0
  90. package/dist/src/domain/benchmarks.js.map +1 -0
  91. package/dist/src/domain/index.js +4 -0
  92. package/dist/src/domain/index.js.map +1 -1
  93. package/dist/src/domain/runs.js.map +1 -1
  94. package/dist/src/domain/validation.js +11 -4
  95. package/dist/src/domain/validation.js.map +1 -1
  96. package/dist/src/domain/verifier-evidence-validation.js +214 -0
  97. package/dist/src/domain/verifier-evidence-validation.js.map +1 -0
  98. package/dist/src/domain/verifier-evidence.js +3 -0
  99. package/dist/src/domain/verifier-evidence.js.map +1 -0
  100. package/dist/src/domain/verifier-score-contract.js +232 -0
  101. package/dist/src/domain/verifier-score-contract.js.map +1 -0
  102. package/dist/src/evals/benchmark-adapter-manifest.js +219 -0
  103. package/dist/src/evals/benchmark-adapter-manifest.js.map +1 -0
  104. package/dist/src/evals/benchmark-candidate.js +36 -0
  105. package/dist/src/evals/benchmark-candidate.js.map +1 -0
  106. package/dist/src/evals/benchmark-run.js +166 -0
  107. package/dist/src/evals/benchmark-run.js.map +1 -0
  108. package/dist/src/evals/collect-only-rerun.js +2 -1
  109. package/dist/src/evals/collect-only-rerun.js.map +1 -1
  110. package/dist/src/evals/directory.js +1 -1
  111. package/dist/src/evals/directory.js.map +1 -1
  112. package/dist/src/evals/duration-estimator.js +126 -0
  113. package/dist/src/evals/duration-estimator.js.map +1 -0
  114. package/dist/src/evals/eval-lifecycle-events.js +13 -0
  115. package/dist/src/evals/eval-lifecycle-events.js.map +1 -1
  116. package/dist/src/evals/eval-logical-plan.js +51 -0
  117. package/dist/src/evals/eval-logical-plan.js.map +1 -0
  118. package/dist/src/evals/eval-result-builder.js +81 -0
  119. package/dist/src/evals/eval-result-builder.js.map +1 -0
  120. package/dist/src/evals/evolution-baseline.js +191 -0
  121. package/dist/src/evals/evolution-baseline.js.map +1 -0
  122. package/dist/src/evals/execution-plan.js +39 -3
  123. package/dist/src/evals/execution-plan.js.map +1 -1
  124. package/dist/src/evals/failure-classifier.js +61 -0
  125. package/dist/src/evals/failure-classifier.js.map +1 -0
  126. package/dist/src/evals/harbor-artifact-builder.js +6 -2
  127. package/dist/src/evals/harbor-artifact-builder.js.map +1 -1
  128. package/dist/src/evals/harbor-bridge-error.js +41 -1
  129. package/dist/src/evals/harbor-bridge-error.js.map +1 -1
  130. package/dist/src/evals/harbor-node-runtime.js +146 -0
  131. package/dist/src/evals/harbor-node-runtime.js.map +1 -0
  132. package/dist/src/evals/index.js +15 -0
  133. package/dist/src/evals/index.js.map +1 -1
  134. package/dist/src/evals/infrastructure-retry.js +109 -22
  135. package/dist/src/evals/infrastructure-retry.js.map +1 -1
  136. package/dist/src/evals/native-phase-evidence.js +440 -0
  137. package/dist/src/evals/native-phase-evidence.js.map +1 -0
  138. package/dist/src/evals/physical-retry-work.js +31 -0
  139. package/dist/src/evals/physical-retry-work.js.map +1 -0
  140. package/dist/src/evals/planned-execution-support.js +22 -10
  141. package/dist/src/evals/planned-execution-support.js.map +1 -1
  142. package/dist/src/evals/planned-execution.js +120 -112
  143. package/dist/src/evals/planned-execution.js.map +1 -1
  144. package/dist/src/evals/planned-progress-publisher.js +62 -0
  145. package/dist/src/evals/planned-progress-publisher.js.map +1 -0
  146. package/dist/src/evals/planned-retry-execution.js +174 -0
  147. package/dist/src/evals/planned-retry-execution.js.map +1 -0
  148. package/dist/src/evals/planned-retry-lifecycle.js +4 -2
  149. package/dist/src/evals/planned-retry-lifecycle.js.map +1 -1
  150. package/dist/src/evals/preparation-rerun.js +225 -0
  151. package/dist/src/evals/preparation-rerun.js.map +1 -0
  152. package/dist/src/evals/progress.js +48 -8
  153. package/dist/src/evals/progress.js.map +1 -1
  154. package/dist/src/evals/recovery.js +54 -4
  155. package/dist/src/evals/recovery.js.map +1 -1
  156. package/dist/src/evals/regrade-evidence.js +77 -0
  157. package/dist/src/evals/regrade-evidence.js.map +1 -0
  158. package/dist/src/evals/remote-infrastructure-retry.js +84 -18
  159. package/dist/src/evals/remote-infrastructure-retry.js.map +1 -1
  160. package/dist/src/evals/request.js +21 -3
  161. package/dist/src/evals/request.js.map +1 -1
  162. package/dist/src/evals/rerun-inputs.js +19 -0
  163. package/dist/src/evals/rerun-inputs.js.map +1 -1
  164. package/dist/src/evals/rerun-types.js +2 -6
  165. package/dist/src/evals/rerun-types.js.map +1 -1
  166. package/dist/src/evals/rerun.js +27 -20
  167. package/dist/src/evals/rerun.js.map +1 -1
  168. package/dist/src/evals/result-helpers.js +13 -0
  169. package/dist/src/evals/result-helpers.js.map +1 -1
  170. package/dist/src/evals/retry-backoff.js +20 -0
  171. package/dist/src/evals/retry-backoff.js.map +1 -0
  172. package/dist/src/evals/retry-state.js +195 -0
  173. package/dist/src/evals/retry-state.js.map +1 -0
  174. package/dist/src/evals/scheduler-metrics.js +95 -0
  175. package/dist/src/evals/scheduler-metrics.js.map +1 -0
  176. package/dist/src/evals/scheduler-trace-replay.js +107 -0
  177. package/dist/src/evals/scheduler-trace-replay.js.map +1 -0
  178. package/dist/src/evals/service.js +87 -138
  179. package/dist/src/evals/service.js.map +1 -1
  180. package/dist/src/evals/trial-import.js +47 -50
  181. package/dist/src/evals/trial-import.js.map +1 -1
  182. package/dist/src/evals/trial-publication-recovery.js +45 -5
  183. package/dist/src/evals/trial-publication-recovery.js.map +1 -1
  184. package/dist/src/evals/trial-reference-validation.js +49 -0
  185. package/dist/src/evals/trial-reference-validation.js.map +1 -0
  186. package/dist/src/evals/verifier-artifacts.js +317 -0
  187. package/dist/src/evals/verifier-artifacts.js.map +1 -0
  188. package/dist/src/evals/verifier-eligibility.js +25 -0
  189. package/dist/src/evals/verifier-eligibility.js.map +1 -0
  190. package/dist/src/evals/verifier-only-rerun.js +210 -0
  191. package/dist/src/evals/verifier-only-rerun.js.map +1 -0
  192. package/dist/src/evals/verifier-runtime.js +61 -0
  193. package/dist/src/evals/verifier-runtime.js.map +1 -0
  194. package/dist/src/evals/verifier-score-artifacts.js +119 -0
  195. package/dist/src/evals/verifier-score-artifacts.js.map +1 -0
  196. package/dist/src/foundation/contained-file.js +85 -0
  197. package/dist/src/foundation/contained-file.js.map +1 -0
  198. package/dist/src/foundation/credential-redaction.js +26 -1
  199. package/dist/src/foundation/credential-redaction.js.map +1 -1
  200. package/dist/src/foundation/fs.js +2 -1
  201. package/dist/src/foundation/fs.js.map +1 -1
  202. package/dist/src/foundation/index.js +2 -1
  203. package/dist/src/foundation/index.js.map +1 -1
  204. package/dist/src/runs/adapter-process.js +12 -0
  205. package/dist/src/runs/adapter-process.js.map +1 -0
  206. package/dist/src/runs/executor.js +24 -30
  207. package/dist/src/runs/executor.js.map +1 -1
  208. package/dist/src/runs/finalizer.js +17 -0
  209. package/dist/src/runs/finalizer.js.map +1 -1
  210. package/dist/src/runs/index.js +5 -0
  211. package/dist/src/runs/index.js.map +1 -1
  212. package/dist/src/runs/phase-bundle.js +86 -0
  213. package/dist/src/runs/phase-bundle.js.map +1 -0
  214. package/dist/src/runs/phase-cancellation.js +107 -0
  215. package/dist/src/runs/phase-cancellation.js.map +1 -0
  216. package/dist/src/runs/phase-group.js +113 -0
  217. package/dist/src/runs/phase-group.js.map +1 -0
  218. package/dist/src/runs/query.js +1 -1
  219. package/dist/src/runs/query.js.map +1 -1
  220. package/dist/src/runs/records.js +3 -0
  221. package/dist/src/runs/records.js.map +1 -1
  222. package/dist/src/runs/request.js +4 -2
  223. package/dist/src/runs/request.js.map +1 -1
  224. package/dist/src/runs/verifier-evidence-redaction.js +61 -0
  225. package/dist/src/runs/verifier-evidence-redaction.js.map +1 -0
  226. package/dist/src/runs/verifier-evidence.js +459 -0
  227. package/dist/src/runs/verifier-evidence.js.map +1 -0
  228. package/dist/src/runs/verifier-structured-evidence.js +83 -0
  229. package/dist/src/runs/verifier-structured-evidence.js.map +1 -0
  230. package/dist/src/trajectories/analysis.js +288 -0
  231. package/dist/src/trajectories/analysis.js.map +1 -0
  232. package/dist/src/trajectories/chunk-projection.js +148 -0
  233. package/dist/src/trajectories/chunk-projection.js.map +1 -0
  234. package/dist/src/trajectories/content-projection.js +389 -0
  235. package/dist/src/trajectories/content-projection.js.map +1 -0
  236. package/dist/src/trajectories/dsh-chunk-contract.js +176 -0
  237. package/dist/src/trajectories/dsh-chunk-contract.js.map +1 -0
  238. package/dist/src/trajectories/dsh-contract.js +284 -0
  239. package/dist/src/trajectories/dsh-contract.js.map +1 -0
  240. package/dist/src/trajectories/events-chunk-drill.js +95 -0
  241. package/dist/src/trajectories/events-chunk-drill.js.map +1 -0
  242. package/dist/src/trajectories/events-page.js +367 -0
  243. package/dist/src/trajectories/events-page.js.map +1 -0
  244. package/dist/src/trajectories/index.js +3 -0
  245. package/dist/src/trajectories/index.js.map +1 -1
  246. package/dist/src/trajectories/request-attempt.js +47 -0
  247. package/dist/src/trajectories/request-attempt.js.map +1 -0
  248. package/dist/src/trajectories/stream-reader.js +311 -0
  249. package/dist/src/trajectories/stream-reader.js.map +1 -0
  250. package/dist/src/trajectories/surface-fold.js +126 -0
  251. package/dist/src/trajectories/surface-fold.js.map +1 -0
  252. package/dist/src/workers/remote-harbor-worker.js +3 -0
  253. package/dist/src/workers/remote-harbor-worker.js.map +1 -1
  254. package/docs/schemas/benchmark-hook-request-v1.schema.json +65 -0
  255. package/docs/schemas/benchmark-hook-response-v1.schema.json +75 -0
  256. package/docs/schemas/benchmark-hook-v1.schema.json +33 -0
  257. package/docs/schemas/benchmark-lock-v1.schema.json +351 -0
  258. package/docs/schemas/benchmark-metric-v1.schema.json +37 -0
  259. package/docs/schemas/benchmark-package-v1.schema.json +167 -0
  260. package/docs/schemas/benchmark-phase-group.schema.json +41 -0
  261. package/docs/schemas/benchmark-profile-v1.schema.json +141 -0
  262. package/docs/schemas/benchmark-task-v1.schema.json +384 -0
  263. package/docs/schemas/benchmark-tool-result-v1.schema.json +30 -0
  264. package/docs/schemas/error.schema.json +22 -0
  265. package/docs/schemas/eval-progress.schema.json +1 -15
  266. package/docs/schemas/eval-rerun-result.schema.json +6 -1
  267. package/docs/schemas/eval-rerun-submission.schema.json +3 -1
  268. package/docs/schemas/eval-result.schema.json +1 -15
  269. package/docs/schemas/eval-trial-publication.schema.json +1 -25
  270. package/docs/schemas/eval-trial-reference.schema.json +129 -0
  271. package/docs/schemas/regrade-assessment-reference.schema.json +11 -0
  272. package/docs/schemas/run-context.schema.json +15 -0
  273. package/docs/schemas/run-manifest.schema.json +4 -0
  274. package/docs/schemas/run-request.schema.json +4 -0
  275. package/docs/schemas/trajectory-analysis.schema.json +258 -0
  276. package/docs/schemas/trajectory-events-page.schema.json +40 -0
  277. package/docs/schemas/verifier-assessment.schema.json +77 -0
  278. package/docs/schemas/verifier-evidence.schema.json +276 -0
  279. package/integrations/harbor/hitch_benchmark.py +252 -0
  280. package/integrations/harbor/hitch_candidate_recycle.py +262 -0
  281. package/integrations/harbor/hitch_harbor_agent.py +541 -43
  282. package/integrations/harbor/hitch_harbor_environment.py +138 -0
  283. package/integrations/harbor/hitch_harbor_verifier.py +60 -2
  284. package/integrations/harbor/hitch_phase_supervisor.py +452 -0
  285. package/integrations/harbor/hitch_tool_client.mjs +91 -0
  286. package/integrations/model-call/cli.js +57 -0
  287. package/package.json +17 -6
@@ -0,0 +1,452 @@
1
+ """Trusted native-phase orchestration; no SDK imports or benchmark scoring.
2
+
3
+ The private controller implements state/bind/cancel. Its native SDK owns task
4
+ progress and grading. This worker owns candidate run identities and container
5
+ replacement, and records candidate evidence only. Call after initial agent setup
6
+ and controller prepare. A failure requires whole-trial cleanup, never a retry of
7
+ the same conversation. Public package/assessment integration is a separate layer.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from datetime import datetime, timezone
13
+ import json
14
+ import math
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import shlex
19
+ import shutil
20
+ import tempfile
21
+ import time
22
+ from urllib.parse import urlparse
23
+ import uuid
24
+
25
+ from harbor.models.agent.context import AgentContext
26
+
27
+
28
+ class PhaseSupervisionError(RuntimeError):
29
+ pass
30
+
31
+
32
+ def _json(text):
33
+ def pairs(items):
34
+ value = {}
35
+ for key, item in items:
36
+ if key in value:
37
+ raise ValueError("duplicate JSON field")
38
+ value[key] = item
39
+ return value
40
+ def constant(_):
41
+ raise ValueError("non-finite JSON number")
42
+ return json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
43
+
44
+
45
+ def _positive(value, maximum):
46
+ return type(value) is int and 1 <= value <= maximum
47
+
48
+
49
+ def _digest(value):
50
+ return isinstance(value, str) and re.fullmatch(r"sha256:[a-f0-9]{64}", value)
51
+
52
+
53
+ def _write(path, value):
54
+ temporary = path.with_suffix(".pending")
55
+ with temporary.open("x", encoding="utf-8") as stream:
56
+ os.chmod(temporary, 0o600)
57
+ json.dump(value, stream, sort_keys=True, ensure_ascii=False, allow_nan=False)
58
+ stream.write("\n")
59
+ stream.flush()
60
+ os.fsync(stream.fileno())
61
+ temporary.replace(path)
62
+
63
+
64
+ class NativePhaseSupervisor:
65
+ def __init__(self, agent, environment, *, controller, binding, task_digest,
66
+ timeout_ms, shutdown_timeout_ms=30_000, poll_interval_ms=250,
67
+ run_group_id=None, finalization_timeout_ms=None, task_instruction=""):
68
+ """controller={service, argv}; binding={endpoint, tools}, both locked.
69
+
70
+ timeout_ms is the remaining *whole task* allowance, not a phase budget.
71
+ Shutdown/collection has a distinct allowance and cannot start a model.
72
+ Management credentials stay inside the controller's private session file.
73
+ """
74
+ self.agent, self.env = agent, environment
75
+ if not agent._setup_complete or not agent._phase_supervision_available:
76
+ raise ValueError("native phase supervision requires setup with a compatible frozen runtime")
77
+ if (not isinstance(controller, dict) or set(controller) != {"service", "argv"}
78
+ or not isinstance(controller["service"], str) or controller["service"] == "main"
79
+ or not re.fullmatch(r"[a-z][a-z0-9_-]*", controller["service"])
80
+ or not isinstance(controller["argv"], list) or not 1 <= len(controller["argv"]) <= 32
81
+ or any(not isinstance(v, str) or not v or "\0" in v or len(v) > 8192 for v in controller["argv"])):
82
+ raise ValueError("invalid locked native controller command")
83
+ if not isinstance(binding, dict) or set(binding) != {"endpoint", "tools"}:
84
+ raise ValueError("invalid locked native tool definition")
85
+ endpoint = urlparse(binding["endpoint"])
86
+ if (endpoint.scheme != "http" or endpoint.hostname != controller["service"] or endpoint.path != "/"
87
+ or endpoint.username or endpoint.password or endpoint.query or endpoint.fragment
88
+ or not isinstance(binding["tools"], list) or not binding["tools"]):
89
+ raise ValueError("native tools must belong to the locked private controller service")
90
+ if (not _digest(task_digest) or not _positive(timeout_ms, 9007199254740991)
91
+ or not _positive(shutdown_timeout_ms, 600_000) or not _positive(poll_interval_ms, 5000)):
92
+ raise ValueError("invalid native task identity or budget")
93
+ self.group = run_group_id or "run_group_" + uuid.uuid4().hex
94
+ if not isinstance(self.group, str) or not re.fullmatch(r"run_group_[a-f0-9]{32}", self.group):
95
+ raise ValueError("invalid native run group identity")
96
+ if not environment._hitch_ownership_labels or not _digest(agent.revision_identity):
97
+ raise ValueError("native phase supervision requires a leased environment and immutable harness")
98
+ self.controller, self.binding = _json(json.dumps(controller)), _json(json.dumps(binding))
99
+ self.task_digest, self.timeout_ms = task_digest, timeout_ms
100
+ if not isinstance(task_instruction, str):
101
+ raise ValueError("invalid locked task instruction")
102
+ self.task_instruction = task_instruction
103
+ self.shutdown_timeout_ms, self.poll_interval_ms = shutdown_timeout_ms, poll_interval_ms
104
+ if finalization_timeout_ms is not None and not _positive(finalization_timeout_ms, 9007199254740991):
105
+ raise ValueError("invalid native finalization allowance")
106
+ self.finalization_timeout_ms = finalization_timeout_ms
107
+ self.root = Path(environment.trial_paths.trial_dir)
108
+ if self.root.is_symlink() or not self.root.is_dir():
109
+ raise ValueError("native phase evidence requires a real trial directory")
110
+ self.root = self.root.resolve(strict=True)
111
+ self.directory = self.root / "hitch-native-phases"
112
+ self.prepared = self.running = self.context = None
113
+ self.phase_index, self.sequence, self.generation = 0, 0, 0
114
+ self.deadline_ns = 0
115
+ self.used = False
116
+ self.record = None
117
+ self.native_state = None
118
+ self.cancel_reason = None
119
+
120
+ def _remaining_ms(self):
121
+ return (self.deadline_ns - time.monotonic_ns()) // 1_000_000
122
+
123
+ def _save(self):
124
+ _write(self.directory / "supervision.json", self.record)
125
+
126
+ async def _rpc(self, operation, parameters=None, *, cleanup=False):
127
+ remaining = self.shutdown_timeout_ms if cleanup else self._remaining_ms()
128
+ if remaining <= 0:
129
+ raise PhaseSupervisionError("native_task_budget_expired")
130
+ request = {"request_id": "phase_rpc_" + uuid.uuid4().hex, "operation": operation, "parameters": parameters or {}}
131
+ command = "printf %s " + shlex.quote(json.dumps(request)) + " | " + shlex.join(self.controller["argv"])
132
+ try:
133
+ result = await self.env.service_exec(command, service=self.controller["service"], timeout_sec=max(1, math.ceil(min(15000, remaining) / 1000)))
134
+ if result.return_code or not isinstance(result.stdout, str) or len(result.stdout.encode()) > 2 * 1024 * 1024:
135
+ raise ValueError("invalid management result")
136
+ output = _json(result.stdout)
137
+ if not isinstance(output, dict):
138
+ raise ValueError("invalid management output")
139
+ return output
140
+ except asyncio.CancelledError:
141
+ raise
142
+ except Exception:
143
+ # Bind output may contain a token. Never echo stdout/stderr or parse
144
+ # errors, including errors from a service_exec implementation.
145
+ raise PhaseSupervisionError("native_controller_rpc_failed") from None
146
+
147
+ async def _state(self, *, finalizing=False):
148
+ state = await self._rpc("state", cleanup=finalizing)
149
+ try:
150
+ if set(state) != {"state", "generation", "sequence", "run_id", "prediction", "task_current_date"}:
151
+ raise ValueError()
152
+ generation, sequence = state["generation"], state["sequence"]
153
+ if (type(generation) is not int or not self.generation <= generation <= self.phase_index + 1
154
+ or type(sequence) is not int or sequence < self.sequence or generation < 0):
155
+ raise ValueError()
156
+ name, run_id, prediction = state["state"], state["run_id"], state["prediction"]
157
+ if name not in {"created", "context_required", "awaiting_actions", "sdk_executing", "completed", "failed", "cancelled", "finalizing"}:
158
+ raise ValueError()
159
+ if state["task_current_date"] is not None and (not isinstance(state["task_current_date"], str) or len(state["task_current_date"]) > 1024):
160
+ raise ValueError()
161
+ if prediction is not None:
162
+ if (not isinstance(prediction, dict) or set(prediction) != {"sequence", "generation", "instruction", "screenshot_file", "screenshot_sha256", "user_response"}
163
+ or type(prediction["generation"]) is not int or prediction["generation"] != generation
164
+ or type(prediction["sequence"]) is not int or prediction["sequence"] != sequence or sequence < 1
165
+ or not isinstance(prediction["instruction"], str)
166
+ or prediction["user_response"] is not None and not isinstance(prediction["user_response"], str)
167
+ or not isinstance(prediction["screenshot_file"], str) or not re.fullmatch(r"observation-[0-9]{6,}\.png", prediction["screenshot_file"])
168
+ or not isinstance(prediction["screenshot_sha256"], str) or not re.fullmatch(r"[a-f0-9]{64}", prediction["screenshot_sha256"])):
169
+ raise ValueError()
170
+ if name == "created" and (generation != 0 or sequence != 0 or prediction is not None):
171
+ raise ValueError()
172
+ if name in {"awaiting_actions", "sdk_executing"}:
173
+ if self.prepared is None or generation != self.phase_index or run_id != self.prepared.run_id:
174
+ raise ValueError()
175
+ if name == "awaiting_actions" and prediction is None:
176
+ raise ValueError()
177
+ elif run_id is not None:
178
+ raise ValueError()
179
+ if name == "context_required" and (generation < 1 or generation != self.phase_index + 1):
180
+ raise ValueError()
181
+ if name == "finalizing" and (not finalizing or prediction is not None or not self.record.get("budget_finalization")):
182
+ raise ValueError()
183
+ if name == "completed" and (prediction is not None or not finalizing and (self.prepared is None or generation != self.phase_index)):
184
+ raise ValueError()
185
+ except (ValueError, TypeError, KeyError):
186
+ raise PhaseSupervisionError("native_controller_state_invalid") from None
187
+ self.generation, self.sequence = generation, sequence
188
+ self.native_state = state
189
+ if name in {"failed", "cancelled"}:
190
+ raise PhaseSupervisionError("native_controller_" + name)
191
+ return state
192
+
193
+ async def _start(self, state):
194
+ prediction = state["prediction"]
195
+ # Keep the locked task guidance on every fresh candidate, alongside the
196
+ # current native instruction/date. Never carry a prior phase's prompt.
197
+ instruction = ("Complete this native task using the locked tool bridge. "
198
+ "Run `node /tmp/hitch-tools.mjs list` for schemas and invoke "
199
+ "`node /tmp/hitch-tools.mjs TOOL_NAME 'JSON_ARGUMENTS'`. "
200
+ "Open returned image paths with your native image viewing tool.\n"
201
+ + json.dumps({"task_instructions": self.task_instruction,
202
+ "instruction": prediction["instruction"],
203
+ "task_current_date": state["task_current_date"]}, ensure_ascii=False))
204
+ self.phase_index += 1
205
+ self.prepared = self.agent.prepare_phase(instruction=instruction, run_group_id=self.group, phase_index=self.phase_index,
206
+ task_digest=self.task_digest, remaining_timeout_ms=self._remaining_ms())
207
+ entry = {"phase_index": self.phase_index, "generation": state["generation"], "run_id": self.prepared.run_id,
208
+ "status": "prepared", "first_prediction_sequence": prediction["sequence"],
209
+ "first_screenshot_sha256": prediction["screenshot_sha256"]}
210
+ self.record["phases"].append(entry)
211
+ self._save()
212
+ response = await self._rpc("bind", {"generation": state["generation"], "run_id": self.prepared.run_id})
213
+ binding = response.get("binding")
214
+ if (set(response) != {"binding"} or not isinstance(binding, dict) or set(binding) != {"endpoint", "tools", "token"}
215
+ or binding["endpoint"] != self.binding["endpoint"] or binding["tools"] != self.binding["tools"]
216
+ or not isinstance(binding["token"], str) or not re.fullmatch(r"[a-f0-9]{64}", binding["token"])):
217
+ raise PhaseSupervisionError("native_binding_differs_from_locked_definition")
218
+ with tempfile.TemporaryDirectory(prefix="hitch-phase-binding-") as temporary:
219
+ file = Path(temporary) / "binding.json"
220
+ file.write_text(json.dumps(binding), encoding="utf-8"); file.chmod(0o600)
221
+ await self.env.upload_file(file, "/tmp/hitch-tool-binding.json")
222
+ protected = await self.env.exec("chmod 600 /tmp/hitch-tool-binding.json")
223
+ if protected.return_code:
224
+ raise PhaseSupervisionError("native_binding_protection_failed")
225
+ await self.env.upload_file(Path(__file__).with_name("hitch_tool_client.mjs"), "/tmp/hitch-tools.mjs")
226
+ self.context = AgentContext()
227
+ self.cancel_reason = None
228
+ self.running = asyncio.create_task(self.agent.run_phase(self.prepared, self.env, self.context))
229
+ entry["status"] = "candidate_running"
230
+ self._save()
231
+ # Give run_phase ownership before any cancellation can be requested.
232
+ await asyncio.sleep(0)
233
+
234
+ async def _settle(self, reason):
235
+ deadline = time.monotonic_ns() + self.shutdown_timeout_ms * 1_000_000
236
+ if reason == "task_budget_expired" and self.record.get("budget_finalization", {}).get("receipt"):
237
+ self.cancel_reason = reason
238
+ if not self.running.done():
239
+ self.cancel_reason = reason
240
+ try:
241
+ # An uncertain upload must settle as failure and whole-trial
242
+ # cleanup. Never retry or replace a container on that path.
243
+ await asyncio.wait_for(self.agent.request_phase_cancellation(self.prepared, self.env, reason=reason), self.shutdown_timeout_ms / 1000)
244
+ except asyncio.TimeoutError:
245
+ raise PhaseSupervisionError("native_candidate_shutdown_incomplete") from None
246
+ except asyncio.CancelledError:
247
+ raise
248
+ except Exception:
249
+ raise PhaseSupervisionError("native_candidate_cancellation_delivery_failed") from None
250
+ try:
251
+ await asyncio.wait_for(asyncio.shield(self.running), max(0, (deadline - time.monotonic_ns()) / 1_000_000_000))
252
+ except asyncio.TimeoutError:
253
+ raise PhaseSupervisionError("native_candidate_shutdown_incomplete") from None
254
+ except Exception as error:
255
+ # The bridge reports a nonzero executor exit for cancellation; only
256
+ # this specific case can continue to independent bundle inspection.
257
+ metadata = self.context.metadata or {}
258
+ allowed = {"cancelled", "timed_out"} if self.cancel_reason == "task_budget_expired" and self.record.get("budget_finalization", {}).get("receipt") else {"cancelled"}
259
+ if not (self.cancel_reason and getattr(error, "code", None) == "hitch_process_failed" and metadata.get("hitch_status") in allowed
260
+ and metadata.get("hitch_bridge_error_code") == "hitch_process_failed"):
261
+ raise PhaseSupervisionError("native_candidate_failed") from None
262
+ metadata = self.context.metadata or {}
263
+ if metadata.get("hitch_run_id") != self.prepared.run_id or metadata.get("hitch_phase_bundle_exported") is not True:
264
+ raise PhaseSupervisionError("native_candidate_export_incomplete")
265
+
266
+ async def _inspect(self, source):
267
+ module = (self.agent.hitch_runtime_dir / "payload/dist/src/runs/phase-bundle.js").resolve(strict=True)
268
+ node = shutil.which("node")
269
+ if not node:
270
+ raise PhaseSupervisionError("native_phase_inspection_node_missing")
271
+ payload = {"sourceDirectory": str(source), "expected": {"run_id": self.prepared.run_id,
272
+ "context": _json(self.prepared.context_json), "parent": _json(self.prepared.parent_json),
273
+ "revision_identity": self.agent.revision_identity}}
274
+ script = ("import {inspectSealedPhaseRunBundle} from " + json.dumps(module.as_uri()) + ";"
275
+ "let data='';for await(const c of process.stdin)data+=c;"
276
+ "try{process.stdout.write(JSON.stringify(await inspectSealedPhaseRunBundle(JSON.parse(data))))}"
277
+ "catch{process.stderr.write('phase evidence invalid');process.exitCode=1}")
278
+ process = await asyncio.create_subprocess_exec(node, "--input-type=module", "-e", script,
279
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
280
+ try:
281
+ stdout, _ = await asyncio.wait_for(process.communicate(json.dumps(payload).encode()), self.shutdown_timeout_ms / 1000)
282
+ except BaseException:
283
+ if process.returncode is None:
284
+ process.kill()
285
+ await process.wait()
286
+ raise
287
+ if process.returncode or len(stdout) > 65536:
288
+ raise PhaseSupervisionError("native_phase_evidence_invalid")
289
+ proof = _json(stdout)
290
+ allowed = {"succeeded", "cancelled", "timed_out"} if self.record.get("budget_finalization", {}).get("receipt") else {"succeeded", "cancelled"}
291
+ if proof["process_status"] not in allowed or proof["process_status"] != self.context.metadata.get("hitch_status"):
292
+ raise PhaseSupervisionError("native_phase_process_status_mismatch")
293
+ prior = self.record["phases"][:-1]
294
+ for phase in prior:
295
+ previous = phase["evidence"]
296
+ if (previous["provider_session_id"] == proof["provider_session_id"]
297
+ or previous["harness"] != proof["harness"] or previous["model"] != proof["model"]
298
+ or datetime.fromisoformat(previous["completed_at"].replace("Z", "+00:00")) > datetime.fromisoformat(proof["started_at"].replace("Z", "+00:00"))):
299
+ raise PhaseSupervisionError("native_phase_candidate_or_conversation_changed")
300
+ return proof
301
+
302
+ async def _retire(self, state):
303
+ finished = state["state"] == "completed"
304
+ await self._settle("task_budget_expired" if self.record.get("budget_finalization", {}).get("receipt") else "native_task_finished" if finished else "native_phase_reset")
305
+ entry = self.record["phases"][-1]
306
+ entry.update(status="candidate_exported", boundary={"state": state["state"], "generation": state["generation"], "sequence": state["sequence"]})
307
+ self._save()
308
+ if finished:
309
+ # At task end this also invokes the normal benchmark snapshot hook.
310
+ await self.env.stop_service("main")
311
+ source = self.root / "agent/hitch-run-bundle"
312
+ else:
313
+ receipt = await self.env.recycle_candidate_phase(self.phase_index)
314
+ if receipt.get("status") != "completed" or receipt.get("phase_index") != self.phase_index:
315
+ raise PhaseSupervisionError("native_candidate_replacement_incomplete")
316
+ source = self.root / f"hitch-candidate-phases/phase-{self.phase_index:04d}/agent/hitch-run-bundle"
317
+ entry["replacement_receipt_ref"] = f"hitch-candidate-phases/phase-{self.phase_index:04d}/receipt.json"
318
+ # No candidate from the retired container can mutate the source now.
319
+ entry.update(status="sealed", bundle_ref=source.relative_to(self.root).as_posix(), evidence=await self._inspect(source))
320
+ self._save()
321
+ self.prepared = self.running = self.context = None
322
+ if not finished:
323
+ await self.agent.setup(self.env)
324
+ return finished
325
+
326
+ async def _finalize_budget(self):
327
+ if self._remaining_ms() > 0 or not self.record["phases"]:
328
+ raise PhaseSupervisionError("native_task_budget_expired")
329
+ self.record["budget_finalization"] = {"status": "requested", "timeout_ms": self.finalization_timeout_ms,
330
+ "elapsed_ms": self.timeout_ms - self._remaining_ms(),
331
+ "requested_at": datetime.now(timezone.utc).isoformat()}
332
+ self._save()
333
+ state = await self._state(finalizing=True)
334
+ if state["state"] != "completed":
335
+ receipt = await self._rpc("expire_budget", cleanup=True)
336
+ expected_run = self.prepared.run_id if self.prepared else None
337
+ if (set(receipt) != {"budget_exhausted", "generation", "sequence", "run_id", "pending_prediction", "action_submitted"}
338
+ or receipt["budget_exhausted"] is not True
339
+ or receipt["run_id"] != expected_run and not (receipt["run_id"] is None and receipt["generation"] == self.phase_index + 1)
340
+ or type(receipt["generation"]) is not int or not self.phase_index <= receipt["generation"] <= self.phase_index + 1
341
+ or type(receipt["sequence"]) is not int or receipt["sequence"] < self.sequence
342
+ or type(receipt["pending_prediction"]) is not bool or type(receipt["action_submitted"]) is not bool):
343
+ raise PhaseSupervisionError("native_budget_receipt_invalid")
344
+ self.record["budget_finalization"]["receipt"] = receipt
345
+ self._save()
346
+ if self.prepared:
347
+ await self._settle("task_budget_expired")
348
+ while True:
349
+ state = await self._state(finalizing=True)
350
+ if state["state"] == "completed":
351
+ break
352
+ if state["state"] != "finalizing":
353
+ raise PhaseSupervisionError("native_budget_did_not_revoke_candidate")
354
+ await asyncio.sleep(self.poll_interval_ms / 1000)
355
+ else:
356
+ # The SDK completed just before the watchdog was observed. Do not
357
+ # invent a deadline event or replace that native completion reason.
358
+ del self.record["budget_finalization"]
359
+ if self.prepared:
360
+ await self._retire(state)
361
+ else:
362
+ # A reset already retired the previous candidate. Stop the fresh,
363
+ # unused replacement; its archived bundle/receipt remains evidence.
364
+ await self.env.stop_service("main")
365
+ if self.record.get("budget_finalization"):
366
+ self.record["budget_finalization"]["status"] = "completed"
367
+ self.record.update(status="completed", completed_at=datetime.now(timezone.utc).isoformat(),
368
+ final_native_state={k: state[k] for k in ["state", "generation", "sequence"]})
369
+ self._save()
370
+ return _json(json.dumps(self.record))
371
+
372
+ async def _cleanup(self):
373
+ cleanup = {"controller_cancelled": False, "environment_stopped": False, "candidate_settled": self.running is None}
374
+ try:
375
+ cleanup["controller_cancelled"] = await self._rpc("cancel", cleanup=True) == {"cancelled": True}
376
+ except BaseException:
377
+ pass
378
+ if self.running is not None and not self.running.done() and not self.cancel_reason:
379
+ try:
380
+ self.cancel_reason = "task_budget_expired" if self._remaining_ms() <= 0 else "cancelled"
381
+ await asyncio.wait_for(self.agent.request_phase_cancellation(self.prepared, self.env, reason=self.cancel_reason), self.shutdown_timeout_ms / 1000)
382
+ except BaseException:
383
+ pass
384
+ if self.running is not None:
385
+ try:
386
+ await asyncio.wait_for(asyncio.shield(self.running), self.shutdown_timeout_ms / 1000)
387
+ except BaseException:
388
+ pass
389
+ try:
390
+ await self.env.stop(delete=True)
391
+ cleanup["environment_stopped"] = True
392
+ except BaseException:
393
+ pass
394
+ if self.running is not None:
395
+ # Only cancel the host awaiter after whole-environment teardown;
396
+ # cancelling docker-exec alone is not proof the model stopped.
397
+ if not self.running.done() and cleanup["environment_stopped"]:
398
+ self.running.cancel()
399
+ try:
400
+ await self.running
401
+ except BaseException:
402
+ pass
403
+ cleanup["candidate_settled"] = self.running.done()
404
+ if self.running.done() and not self.running.cancelled():
405
+ self.running.exception() # Consume the already recorded failure.
406
+ self.record["cleanup"] = cleanup
407
+ self.record["cleanup_required"] = not all(cleanup.values())
408
+ self._save()
409
+
410
+ async def run(self):
411
+ if self.used:
412
+ raise PhaseSupervisionError("native_phase_supervisor_is_single_use")
413
+ self.used = True
414
+ self.directory.mkdir(mode=0o700, exist_ok=False)
415
+ self.deadline_ns = time.monotonic_ns() + self.timeout_ms * 1_000_000
416
+ self.record = {"schema_version": "hitch-native-phase-supervision@1", "scope": "candidate-evidence-only",
417
+ "run_group_id": self.group, "task_digest": self.task_digest, "status": "running", "phases": [],
418
+ "started_at": datetime.now(timezone.utc).isoformat(), "timeout_ms": self.timeout_ms}
419
+ self._save()
420
+ try:
421
+ while True:
422
+ try:
423
+ state = await self._state()
424
+ except PhaseSupervisionError as error:
425
+ if str(error) == "native_task_budget_expired" and self.finalization_timeout_ms is not None:
426
+ return await asyncio.wait_for(self._finalize_budget(), self.finalization_timeout_ms / 1000)
427
+ raise
428
+ if self._remaining_ms() <= 0 and self.finalization_timeout_ms is not None:
429
+ return await asyncio.wait_for(self._finalize_budget(), self.finalization_timeout_ms / 1000)
430
+ if self.prepared is not None and (state["state"] == "completed" or state["generation"] == self.phase_index + 1):
431
+ if await self._retire(state):
432
+ self.record.update(status="completed", completed_at=datetime.now(timezone.utc).isoformat())
433
+ self._save()
434
+ return _json(json.dumps(self.record))
435
+ continue
436
+ if self.prepared is None and state["state"] == "context_required" and state["prediction"] is not None:
437
+ await self._start(state)
438
+ elif self.running is not None and self.running.done() and state["state"] == "awaiting_actions":
439
+ # submit atomically switches to sdk_executing. If another
440
+ # prediction awaits an exited model, no native boundary was
441
+ # reached; never create a replacement conversation here.
442
+ raise PhaseSupervisionError("native_candidate_exited_before_boundary")
443
+ elif self.running is not None and self.running.done() and (self.running.cancelled() or self.running.exception() is not None):
444
+ raise PhaseSupervisionError("native_candidate_failed")
445
+ await asyncio.sleep(min(self.poll_interval_ms, max(0, self._remaining_ms())) / 1000)
446
+ except BaseException as error:
447
+ self.record.update(status="failed", failure_code=str(error) if isinstance(error, PhaseSupervisionError) else type(error).__name__)
448
+ self._save()
449
+ await self._cleanup()
450
+ if isinstance(error, asyncio.CancelledError):
451
+ raise
452
+ raise PhaseSupervisionError(self.record["failure_code"]) from None
@@ -0,0 +1,91 @@
1
+ // Generic tool-server@1 CLI transport. All tool names and schemas are package data.
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
9
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
10
+ const IMAGE_TYPES = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
11
+
12
+ function decodeImage(block) {
13
+ const extension = IMAGE_TYPES[block.mimeType];
14
+ if (!extension || typeof block.data !== "string" || block.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) throw new Error("Unsupported or oversized tool image");
15
+ const bytes = Buffer.from(block.data, "base64");
16
+ if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64") !== block.data) throw new Error("Invalid tool image encoding");
17
+ const valid = block.mimeType === "image/png" ? bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))
18
+ : block.mimeType === "image/jpeg" ? bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
19
+ : bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
20
+ if (!valid) throw new Error("Tool image MIME type differs from its bytes");
21
+ return { bytes, extension };
22
+ }
23
+
24
+ // Only this explicit envelope opts in. Existing arbitrary JSON/text tool
25
+ // responses (including objects containing a content field) stay byte-for-byte.
26
+ export async function materializeToolResult(text) {
27
+ let result;
28
+ try { result = JSON.parse(text); } catch { return text; }
29
+ if (result?.protocol !== "hitch-tool-result@1") return text;
30
+ if (!Array.isArray(result.content) || result.content.length > 64) throw new Error("Invalid tool result content");
31
+ let imageCount = 0;
32
+ const decoded = result.content.map((block) => {
33
+ if (block?.type === "text" && typeof block.text === "string") return { text: block.text };
34
+ if (block?.type !== "image" || ++imageCount > 8) throw new Error("Unsupported tool content block");
35
+ return decodeImage(block);
36
+ });
37
+ const directory = imageCount ? await mkdtemp(path.join(tmpdir(), "hitch-observations-")) : null;
38
+ try {
39
+ const content = [];
40
+ for (const [index, block] of decoded.entries()) {
41
+ if ("text" in block) { content.push({ type: "text", text: block.text }); continue; }
42
+ const target = path.join(directory, `${index}.${block.extension}`);
43
+ await writeFile(target, block.bytes, { flag: "wx", mode: 0o600 });
44
+ content.push({ type: "image", path: target, mimeType: result.content[index].mimeType,
45
+ bytes: block.bytes.length, sha256: createHash("sha256").update(block.bytes).digest("hex") });
46
+ }
47
+ return JSON.stringify({ ...result, content });
48
+ } catch (error) {
49
+ if (directory) await rm(directory, { recursive: true, force: true });
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ export async function invokeTool(binding, name, args) {
55
+ if (!binding.tools.some((tool) => tool.name === name)) throw new Error("Unknown tool name; use list");
56
+ const response = await fetch(new URL("call", binding.endpoint), {
57
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${binding.token}` },
58
+ body: JSON.stringify({ name, arguments: args }), signal: AbortSignal.timeout(60000), redirect: "error",
59
+ });
60
+ let size = 0;
61
+ const chunks = [];
62
+ for await (const chunk of response.body) {
63
+ size += chunk.length;
64
+ if (size > MAX_RESPONSE_BYTES) throw new Error("Tool response exceeds 16 MiB");
65
+ chunks.push(chunk);
66
+ }
67
+ const result = Buffer.concat(chunks).toString("utf8");
68
+ if (!response.ok) throw new Error(`Tool transport ${response.status}: ${result}`);
69
+ return materializeToolResult(result);
70
+ }
71
+
72
+ export async function readToolArguments(argument, input = process.stdin) {
73
+ if (argument !== "-") return JSON.parse(argument);
74
+ const chunks = [];
75
+ let size = 0;
76
+ for await (const chunk of input) {
77
+ const bytes = Buffer.from(chunk);
78
+ size += bytes.length;
79
+ if (size >= 1024 * 1024) throw new Error("Tool arguments exceed 1 MiB");
80
+ chunks.push(bytes);
81
+ }
82
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
83
+ }
84
+
85
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
86
+ const binding = JSON.parse(await readFile("/tmp/hitch-tool-binding.json", "utf8"));
87
+ const [name, argument = "{}"] = process.argv.slice(2);
88
+ const result = !name || name === "list" ? JSON.stringify(binding.tools, null, 2)
89
+ : await invokeTool(binding, name, await readToolArguments(argument));
90
+ process.stdout.write(`${result}\n`);
91
+ }
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Trusted no-tools harness. One Responses request, native text/image inputs,
3
+ // no tool executor, retries, subprocesses, browser, or continuation loop.
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ if (process.argv.includes('--version')) {
7
+ console.log('hitch-model-call 1.0.0');
8
+ process.exit(0);
9
+ }
10
+ const args = process.argv.slice(2);
11
+ if (args.length !== 2 || args[0] !== '--model' || !args[1]) throw new Error('Usage: model-call --model MODEL');
12
+ const model = args[1];
13
+ const key = process.env.OPENAI_API_KEY;
14
+ if (!key) throw new Error('OPENAI_API_KEY is required for the model-call harness');
15
+ const base = new URL(process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1/');
16
+ if (base.username || base.password || base.search || base.hash || (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))) throw new Error('invalid model endpoint');
17
+ if (!base.pathname.endsWith('/')) base.pathname += '/';
18
+ let raw = '';
19
+ for await (const chunk of process.stdin) {
20
+ raw += chunk;
21
+ if (Buffer.byteLength(raw) > 30 * 1024 * 1024) throw new Error('model input exceeds 30 MB');
22
+ }
23
+ const input = JSON.parse(raw);
24
+ const allowed = new Set(['schema_version', 'messages', 'max_output_tokens', 'reasoning_effort']);
25
+ if (input.schema_version !== '1' || Object.keys(input).some(k => !allowed.has(k)) || !Array.isArray(input.messages) || !input.messages.length) throw new Error('invalid model input');
26
+ for (const message of input.messages) {
27
+ if (!['system', 'user'].includes(message.role) || Object.keys(message).some(k => !['role', 'content'].includes(k)) || !Array.isArray(message.content) || !message.content.length) throw new Error('invalid model message');
28
+ for (const part of message.content) {
29
+ if (part.type === 'input_text') {
30
+ if (typeof part.text !== 'string' || Object.keys(part).some(k => !['type', 'text'].includes(k))) throw new Error('invalid text part');
31
+ } else if (part.type === 'input_image') {
32
+ if (typeof part.image_url !== 'string' || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(part.image_url) || Object.keys(part).some(k => !['type', 'image_url'].includes(k))) throw new Error('images must be native, embedded PNG/JPEG/WebP');
33
+ } else throw new Error('unsupported input modality');
34
+ }
35
+ }
36
+ const maxTokens = input.max_output_tokens ?? 8192;
37
+ if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || maxTokens > 131072) throw new Error('invalid max_output_tokens');
38
+ if (input.reasoning_effort !== undefined && !['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(input.reasoning_effort)) throw new Error('invalid reasoning effort');
39
+ const emit = event => console.log(JSON.stringify(event));
40
+ emit({ type: 'session.created', session_id: randomUUID() });
41
+ const response = await fetch(new URL('responses', base), {
42
+ redirect: 'error',
43
+ method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ model, input: input.messages, tools: [], tool_choice: 'none', store: false,
45
+ max_output_tokens: maxTokens, ...(input.reasoning_effort ? { reasoning: { effort: input.reasoning_effort } } : {}) }),
46
+ signal: AbortSignal.timeout(3600000),
47
+ });
48
+ if (!response.ok) throw new Error(`model API returned HTTP ${response.status}`);
49
+ const body = await response.json();
50
+ if (!['completed', 'incomplete'].includes(body.status) || !Array.isArray(body.output)) throw new Error('invalid model completion');
51
+ if (body.output.some(item => !['message', 'reasoning'].includes(item.type))) throw new Error('model returned a prohibited tool/action item');
52
+ const output = body.output.filter(item => item.type === 'message').flatMap(item => item.content || []).map(part => part.type === 'output_text' ? part.text : part.type === 'refusal' ? part.refusal : '').join('\n');
53
+ // An empty answer (including token exhaustion) remains a gradable model
54
+ // outcome. Provider/transport failures above remain infrastructure errors.
55
+ emit({ type: 'provider.response', response: body });
56
+ emit({ type: 'usage.updated', usage: body.usage || {} });
57
+ emit({ type: 'message.completed', text: output });