world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,573 @@
1
+ """Harbor agent bridge for running an exact WMO harness document.
2
+
3
+ Harbor instantiates `WmoHarborAgent` from an import path plus JSON kwargs, so one serialized
4
+ candidate (the `HarnessDoc`), one provider config, and the execution knobs travel through
5
+ harbor's own trial machinery unchanged. The agent rebuilds the runtime host-side and drives it
6
+ against `HarborAgentEnvironment`, a synchronous adapter over harbor's async task environment:
7
+ the real container is the environment; the worker LLM and tool routing stay host-side.
8
+
9
+ Two properties here are load-bearing for the optimizer:
10
+
11
+ - The WMO transcript (`wmo-run.json` in harbor's logs_dir) is written in a ``finally``, so a
12
+ trial cancelled by harbor's agent timeout still leaves whatever steps executed plus a
13
+ ``stop_reason: "cancelled-by-harbor-timeout"`` marker. Timeout trials are the most informative
14
+ failures a proposer sees; losing their transcripts would blind it.
15
+ - Episodes and cleanup run on a dedicated process-wide `ThreadPoolExecutor` sized at least as
16
+ large as agent concurrency, never on ``asyncio.to_thread``'s default executor: with high agent
17
+ concurrency the default pool (min(32, cpus + 4)) would queue episodes whose harbor timeout
18
+ clocks are already running.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import base64
25
+ import json
26
+ import logging
27
+ import shlex
28
+ import threading
29
+ from collections.abc import Callable, Mapping
30
+ from concurrent.futures import ThreadPoolExecutor
31
+ from pathlib import Path
32
+ from typing import Literal
33
+
34
+ from harbor.agents.base import BaseAgent
35
+ from harbor.environments.base import BaseEnvironment, ExecResult
36
+ from harbor.models.agent.context import AgentContext
37
+ from harbor.models.task.config import MCPServerConfig
38
+
39
+ from wmo.core.types import Action, ActionKind, JsonObject, Observation
40
+ from wmo.harness.doc import HarnessDoc
41
+ from wmo.harness.environment import is_env_action
42
+ from wmo.harness.runtime import (
43
+ DEFAULT_EVAL_EPISODE_TIMEOUT_S,
44
+ RunResult,
45
+ validate_episode_timeout_s,
46
+ )
47
+ from wmo.providers.base import Provider, ProviderConfig
48
+ from wmo.providers.registry import get_provider
49
+ from wmo.providers.retry import wrap_provider_with_retries
50
+
51
+ WMO_HARBOR_AGENT_VERSION = "1"
52
+ WMO_HARBOR_AGENT_IMPORT_PATH = "wmo.evals.harbor.agent:WmoHarborAgent"
53
+ # Keep every task command finite and leave cleanup headroom beneath the local Pi process cap. The
54
+ # local shim also joins active request handlers, so a late-starting command cannot outlive runtime
55
+ # return; E2B Pi already blocks synchronously on the same bridge.
56
+ MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC = 240
57
+ # Default size of the dedicated episode executor. It must stay >= the harbor agent concurrency
58
+ # plus in-flight cleanup, or queued episodes burn their harbor timeout budget before starting.
59
+ DEFAULT_EPISODE_WORKERS = 64
60
+ _TRACE_FILENAME = "wmo-run.json"
61
+ _CANCELLED_STOP_REASON = "cancelled-by-harbor-timeout"
62
+ _WRITE_COMMAND = (
63
+ 'mkdir -p -- "$(dirname -- "$WMO_FILE_PATH")" && '
64
+ 'printf \'%s\' "$WMO_FILE_CONTENT_B64" | base64 -d > "$WMO_FILE_PATH"'
65
+ )
66
+
67
+ _EXECUTOR_LOCK = threading.Lock()
68
+ _EPISODE_EXECUTOR: ThreadPoolExecutor | None = None
69
+ _EPISODE_EXECUTOR_WORKERS = 0
70
+
71
+
72
+ def _episode_executor(min_workers: int) -> ThreadPoolExecutor:
73
+ """The process-wide executor for episode and cleanup work, grown to `min_workers`.
74
+
75
+ One executor serves every concurrently running WmoHarborAgent in this process. When a job
76
+ asks for more workers than the current pool has, a larger pool replaces it; the old pool
77
+ keeps draining its in-flight work and is dropped without shutdown (process-lifetime infra).
78
+ """
79
+ global _EPISODE_EXECUTOR, _EPISODE_EXECUTOR_WORKERS
80
+ with _EXECUTOR_LOCK:
81
+ if _EPISODE_EXECUTOR is None or _EPISODE_EXECUTOR_WORKERS < min_workers:
82
+ _EPISODE_EXECUTOR = ThreadPoolExecutor(
83
+ max_workers=min_workers,
84
+ thread_name_prefix="wmo-harbor-episode",
85
+ )
86
+ _EPISODE_EXECUTOR_WORKERS = min_workers
87
+ return _EPISODE_EXECUTOR
88
+
89
+
90
+ class HarborAgentEnvironment:
91
+ """Expose Harbor's async task environment through WMO's synchronous protocol.
92
+
93
+ Every executed step is also recorded so a cancelled episode can still persist the partial
94
+ transcript the optimizer's proposer feeds on.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ event_loop: asyncio.AbstractEventLoop,
100
+ environment: BaseEnvironment,
101
+ *,
102
+ command_timeout_sec: int = MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC,
103
+ ) -> None:
104
+ self._event_loop = event_loop
105
+ self._environment = environment
106
+ self._command_timeout_sec = _validate_command_timeout_sec(command_timeout_sec)
107
+ self._recorded_steps: list[JsonObject] = []
108
+
109
+ def execute(self, action: Action) -> Observation:
110
+ """Execute one supported WMO tool in Harbor's owned task environment.
111
+
112
+ A command that times out or dies on a transport error is a CANDIDATE outcome, not an
113
+ infrastructure failure: it becomes an error observation the agent can react to. Letting
114
+ it escape as an exception would kill the episode before verification, turn the whole
115
+ candidate into an unscoreable HarborRewardMissingError, and (because the pruner deletes
116
+ reward-less failed trials) make the deterministic job dir re-run it forever.
117
+ """
118
+ try:
119
+ observation = self._execute(action)
120
+ except Exception as exc: # noqa: BLE001 - env failures are episode feedback, never fatal
121
+ observation = Observation(
122
+ content=f"environment command failed: {type(exc).__name__}: {exc}",
123
+ is_error=True,
124
+ )
125
+ self._recorded_steps.append(
126
+ {
127
+ "action": action.model_dump(mode="json"),
128
+ "observation": observation.model_dump(mode="json"),
129
+ }
130
+ )
131
+ return observation
132
+
133
+ def recorded_steps(self) -> list[JsonObject]:
134
+ """The steps executed so far (the partial-transcript source on cancellation)."""
135
+ return list(self._recorded_steps)
136
+
137
+ def close(self) -> None:
138
+ """Leave lifecycle ownership with Harbor."""
139
+
140
+ def _execute(self, action: Action) -> Observation:
141
+ if action.kind is not ActionKind.TOOL_CALL or not is_env_action(action):
142
+ return Observation(content=f"tool {action.name!r} not available", is_error=True)
143
+ arguments = action.arguments or {}
144
+ if action.name == "bash":
145
+ command = _string_argument(arguments, "command")
146
+ if command is None:
147
+ return _invalid_arguments("bash", "command must be a string")
148
+ return _command_observation(self._exec(command))
149
+ if action.name == "read_file":
150
+ path = _string_argument(arguments, "path", nonempty=True)
151
+ if path is None:
152
+ return _invalid_arguments("read_file", "path must be a nonempty string")
153
+ return _command_observation(self._exec(f"cat -- {shlex.quote(path)}"))
154
+ if action.name == "write_file":
155
+ path = _string_argument(arguments, "path", nonempty=True)
156
+ content = _string_argument(arguments, "content")
157
+ if path is None or content is None:
158
+ return _invalid_arguments(
159
+ "write_file", "path must be nonempty and content must be a string"
160
+ )
161
+ result = self._exec(
162
+ _WRITE_COMMAND,
163
+ env={
164
+ "WMO_FILE_PATH": path,
165
+ "WMO_FILE_CONTENT_B64": base64.b64encode(content.encode()).decode(),
166
+ },
167
+ )
168
+ observation = _command_observation(result)
169
+ if not observation.is_error:
170
+ return Observation(
171
+ content=f"wrote {path}",
172
+ metadata=observation.metadata,
173
+ )
174
+ return observation
175
+ return Observation(content=f"tool {action.name!r} not available", is_error=True)
176
+
177
+ def _exec(self, command: str, *, env: dict[str, str] | None = None) -> ExecResult:
178
+ future = asyncio.run_coroutine_threadsafe(
179
+ self._environment.exec(
180
+ command,
181
+ env=env,
182
+ timeout_sec=self._command_timeout_sec,
183
+ ),
184
+ self._event_loop,
185
+ )
186
+ return future.result()
187
+
188
+
189
+ class WmoHarborAgent(BaseAgent):
190
+ """Run the serialized WMO candidate while Harbor owns tasks and verification."""
191
+
192
+ def __init__(
193
+ self,
194
+ logs_dir: Path,
195
+ model_name: str | None = None,
196
+ logger: logging.Logger | None = None,
197
+ mcp_servers: list[MCPServerConfig] | None = None,
198
+ skills_dir: str | None = None,
199
+ *,
200
+ command_timeout_sec: int = MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC,
201
+ extra_env: dict[str, str] | None = None,
202
+ harness: JsonObject,
203
+ provider_config: JsonObject,
204
+ harness_backend: Literal["local", "e2b"] = "local",
205
+ e2b_template: str | None = None,
206
+ episode_timeout_sec: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
207
+ episode_workers: int = DEFAULT_EPISODE_WORKERS,
208
+ context_window: int | None = None,
209
+ ) -> None:
210
+ if extra_env:
211
+ raise ValueError("WMO Harbor evaluation does not inject agent environment variables")
212
+ super().__init__(
213
+ logs_dir=logs_dir,
214
+ model_name=model_name,
215
+ logger=logger,
216
+ mcp_servers=mcp_servers,
217
+ skills_dir=skills_dir,
218
+ extra_env=extra_env,
219
+ )
220
+ if harness_backend not in ("local", "e2b"):
221
+ raise ValueError("harness_backend must be local or e2b")
222
+ if harness_backend == "local" and e2b_template is not None:
223
+ raise ValueError("e2b_template requires harness_backend='e2b'")
224
+ try:
225
+ self._episode_timeout_sec = validate_episode_timeout_s(episode_timeout_sec)
226
+ except ValueError as error:
227
+ raise ValueError("episode_timeout_sec must be a finite positive number") from error
228
+ if context_window is not None and (
229
+ isinstance(context_window, bool) or not isinstance(context_window, int)
230
+ ):
231
+ raise ValueError("context_window must be an integer number of tokens")
232
+ if context_window is not None and context_window < 1024:
233
+ raise ValueError("context_window must be at least 1024 tokens")
234
+ if isinstance(episode_workers, bool) or not isinstance(episode_workers, int):
235
+ raise ValueError("episode_workers must be a positive integer")
236
+ if episode_workers < 1:
237
+ raise ValueError("episode_workers must be a positive integer")
238
+ self._harness = HarnessDoc.model_validate(harness)
239
+ self._provider_config = ProviderConfig.model_validate(provider_config)
240
+ expected_model_name = f"{self._provider_config.kind.value}/{self._provider_config.model}"
241
+ if model_name != expected_model_name:
242
+ raise ValueError(
243
+ f"Harbor model identity must be {expected_model_name!r}, got {model_name!r}"
244
+ )
245
+ self._provider = self._build_provider(self._provider_config)
246
+ self._command_timeout_sec = _validate_command_timeout_sec(command_timeout_sec)
247
+ self._harness_backend = harness_backend
248
+ self._e2b_template = e2b_template
249
+ self._episode_workers = episode_workers
250
+ self._context_window = context_window
251
+
252
+ def _build_provider(self, config: ProviderConfig) -> Provider:
253
+ """Construct the worker provider; the one seam subclasses may override.
254
+
255
+ Called exactly once, from ``__init__``, after ``BaseAgent`` has set
256
+ ``self.logs_dir`` and the validated provider config and model identity are in
257
+ place, so an override can key per-trial state (e.g. a token sink named after
258
+ the harbor trial) off the logs directory.
259
+
260
+ Args:
261
+ config: The validated worker provider config.
262
+
263
+ Returns:
264
+ The provider the episode runtime will drive. Overrides must keep the
265
+ retry contract by wrapping their provider with
266
+ ``wrap_provider_with_retries``.
267
+ """
268
+ # Retry-wrap the worker provider: Bedrock disables botocore's own retries, so one
269
+ # unwrapped ThrottlingException would otherwise kill a whole trial.
270
+ return wrap_provider_with_retries(get_provider(config))
271
+
272
+ def _build_provider(self, config: ProviderConfig) -> Provider:
273
+ """Construct the worker provider; the one seam subclasses may override.
274
+
275
+ Called exactly once, from ``__init__``, after ``BaseAgent`` has set
276
+ ``self.logs_dir`` and the validated provider config and model identity are in
277
+ place, so an override can key per-trial state (e.g. a token sink named after
278
+ the harbor trial) off the logs directory.
279
+
280
+ Args:
281
+ config: The validated worker provider config.
282
+
283
+ Returns:
284
+ The provider the episode runtime will drive. Overrides must keep the
285
+ retry contract by wrapping their provider with
286
+ ``wrap_provider_with_retries``.
287
+ """
288
+ # Retry-wrap the worker provider: Bedrock disables botocore's own retries, so one
289
+ # unwrapped ThrottlingException would otherwise kill a whole trial.
290
+ return wrap_provider_with_retries(get_provider(config))
291
+
292
+ @staticmethod
293
+ def name() -> str:
294
+ return "wmo-harness"
295
+
296
+ def version(self) -> str:
297
+ return WMO_HARBOR_AGENT_VERSION
298
+
299
+ async def setup(self, environment: BaseEnvironment) -> None:
300
+ """Use Harbor's already-started task environment without installing another agent."""
301
+ del environment
302
+
303
+ async def run(
304
+ self,
305
+ instruction: str,
306
+ environment: BaseEnvironment,
307
+ context: AgentContext,
308
+ ) -> None:
309
+ """Run the candidate in a dedicated worker thread and always persist its WMO trace."""
310
+ context.metadata = {"candidate_doc_hash": self._harness.doc_hash}
311
+ cancel_requested = threading.Event()
312
+ # Cancellation is cooperative and best-effort on the local backend: the local SSH
313
+ # pi-node runtime has no should_cancel hook, so a cancelled local episode runs to its
314
+ # own node/SSH bound before the shield below releases. The e2b backend honors it.
315
+ runtime = self._harness.runtime(
316
+ self._provider,
317
+ backend=self._harness_backend,
318
+ e2b_template=self._e2b_template,
319
+ # The wall budget applies to every backend: the local SSH transport used to hardcode
320
+ # `timeout 300 node`, so a configured budget was silently ignored there.
321
+ episode_timeout_s=self._episode_timeout_sec,
322
+ context_window=self._context_window,
323
+ # A real task environment is mutable, so an E2B transport failure must not replay
324
+ # the whole episode against already-mutated state. Local Pi has no replay wrapper.
325
+ transport_retries=0 if self._harness_backend == "e2b" else None,
326
+ should_cancel=cancel_requested.is_set,
327
+ )
328
+ bridge = HarborAgentEnvironment(
329
+ asyncio.get_running_loop(),
330
+ environment,
331
+ command_timeout_sec=self._command_timeout_sec,
332
+ )
333
+ task_id = str(self.context_id or self.session_id or "harbor-task")
334
+ loop = asyncio.get_running_loop()
335
+ executor = _episode_executor(self._episode_workers)
336
+ run_task = asyncio.ensure_future(
337
+ loop.run_in_executor(executor, lambda: runtime.run(task_id, instruction, bridge))
338
+ )
339
+ result: RunResult | None = None
340
+ try:
341
+ # Harbor enforces its agent timeout by cancelling this coroutine. Shield the
342
+ # worker so cancellation cannot detach a still-running harness from the task
343
+ # environment that Harbor is about to verify.
344
+ result = await asyncio.shield(run_task)
345
+ except asyncio.CancelledError:
346
+ cancel_requested.set()
347
+ abort = getattr(runtime, "abort", None)
348
+ try:
349
+ if callable(abort):
350
+ await self._cleanup_uncancellable(abort, executor, what="abort")
351
+ finally:
352
+ await _wait_for_quiescence(run_task)
353
+ raise
354
+ finally:
355
+ try:
356
+ close = getattr(runtime, "close", None)
357
+ if callable(close):
358
+ await self._cleanup_uncancellable(close, executor, what="close")
359
+ finally:
360
+ bridge.close()
361
+ # The trace write lives inside this inner finally: a harbor-timeout cancellation
362
+ # (the most informative failure class) must still leave the partial transcript
363
+ # for the proposer, even when cleanup itself was re-cancelled above. Synchronous
364
+ # small-file I/O, so cancellation cannot interrupt the write itself.
365
+ self._write_trace(task_id, run_task, bridge, cancelled=cancel_requested.is_set())
366
+ _populate_context(context, result)
367
+
368
+ async def _cleanup_uncancellable(
369
+ self,
370
+ call: Callable[[], object],
371
+ executor: ThreadPoolExecutor,
372
+ *,
373
+ what: str,
374
+ ) -> None:
375
+ """Run one cleanup step to completion; its failures never replace the episode outcome.
376
+
377
+ A pool-close/abort error (e.g. SandboxCleanupError) after a finished episode would
378
+ otherwise abort the trial pre-verification and discard a real result. Cancellation
379
+ semantics are preserved: a re-cancellation observed during cleanup still re-raises.
380
+ """
381
+ try:
382
+ await _run_uncancellable(call, executor)
383
+ except asyncio.CancelledError:
384
+ raise
385
+ except Exception: # noqa: BLE001 - cleanup is best-effort; the run outcome wins
386
+ self.logger.warning("harbor agent %s cleanup failed; continuing", what, exc_info=True)
387
+
388
+ def _write_trace(
389
+ self,
390
+ task_id: str,
391
+ run_task: asyncio.Future[RunResult],
392
+ bridge: HarborAgentEnvironment,
393
+ *,
394
+ cancelled: bool,
395
+ ) -> None:
396
+ """Persist the full RunResult when one exists, else the partial episode evidence."""
397
+ payload = _trace_payload(task_id, run_task, bridge, cancelled=cancelled)
398
+ try:
399
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
400
+ (self.logs_dir / _TRACE_FILENAME).write_text(payload, encoding="utf-8")
401
+ except OSError:
402
+ if not cancelled and not run_task.cancelled() and run_task.exception() is None:
403
+ raise # a healthy trial must not silently lose its transcript
404
+ self.logger.warning("failed to persist the partial WMO trace", exc_info=True)
405
+
406
+
407
+ def _trace_payload(
408
+ task_id: str,
409
+ run_task: asyncio.Future[RunResult],
410
+ bridge: HarborAgentEnvironment,
411
+ *,
412
+ cancelled: bool,
413
+ ) -> str:
414
+ if run_task.done() and not run_task.cancelled() and run_task.exception() is None:
415
+ return run_task.result().model_dump_json(indent=2)
416
+ error = None if not run_task.done() or run_task.cancelled() else run_task.exception()
417
+ stop_reason = (
418
+ _CANCELLED_STOP_REASON
419
+ if cancelled
420
+ else f"agent-exception:{type(error).__name__}"
421
+ if error is not None
422
+ else _CANCELLED_STOP_REASON
423
+ )
424
+ steps = bridge.recorded_steps()
425
+ partial: JsonObject = {
426
+ "task_id": task_id,
427
+ "steps": steps,
428
+ "stop_reason": stop_reason,
429
+ "answer": "",
430
+ "turns": len(steps),
431
+ "partial": True,
432
+ }
433
+ if error is not None:
434
+ partial["error"] = f"{type(error).__name__}: {error}"
435
+ usage = getattr(error, "worker_usage", None)
436
+ if usage is not None:
437
+ partial["worker_usage"] = usage.model_dump(mode="json")
438
+ return json.dumps(partial, indent=2, ensure_ascii=False)
439
+
440
+
441
+ async def _wait_for_quiescence(run_task: asyncio.Future[RunResult]) -> None:
442
+ """Drain a shielded runtime even if the owning Harbor task is cancelled again."""
443
+ await _wait_until_done(run_task)
444
+ if not run_task.cancelled():
445
+ run_task.exception()
446
+
447
+
448
+ async def _run_uncancellable[T](
449
+ call: Callable[[], T],
450
+ executor: ThreadPoolExecutor,
451
+ ) -> T:
452
+ """Run blocking cleanup to completion despite repeated coroutine cancellation."""
453
+ loop = asyncio.get_running_loop()
454
+ cleanup_task = asyncio.ensure_future(loop.run_in_executor(executor, call))
455
+ cancelled = await _wait_until_done(cleanup_task)
456
+ if cancelled:
457
+ # Re-deliver the cancellation; a cleanup failure is secondary (consume it so the
458
+ # event loop never logs a never-retrieved exception).
459
+ if not cleanup_task.cancelled():
460
+ cleanup_task.exception()
461
+ raise asyncio.CancelledError
462
+ return cleanup_task.result()
463
+
464
+
465
+ async def _wait_until_done[T](task: asyncio.Future[T]) -> bool:
466
+ """Wait without propagating the child result or cancelling it with the waiter."""
467
+ cancelled = False
468
+ while not task.done():
469
+ try:
470
+ await asyncio.wait({task})
471
+ except asyncio.CancelledError:
472
+ cancelled = True
473
+ return cancelled
474
+
475
+
476
+ def _populate_context(context: AgentContext, result: RunResult | None) -> None:
477
+ if result is None:
478
+ return
479
+ usage = result.worker_usage
480
+ if usage is not None:
481
+ context.n_input_tokens = usage.input_tokens
482
+ context.n_output_tokens = usage.output_tokens
483
+ metadata = dict(context.metadata or {})
484
+ metadata.update(
485
+ {
486
+ "stop_reason": result.stop_reason.value,
487
+ "turns": result.turns,
488
+ }
489
+ )
490
+ context.metadata = metadata
491
+
492
+
493
+ def _string_argument(
494
+ arguments: Mapping[str, object],
495
+ name: str,
496
+ *,
497
+ nonempty: bool = False,
498
+ ) -> str | None:
499
+ value = arguments.get(name)
500
+ if not isinstance(value, str) or (nonempty and not value):
501
+ return None
502
+ return value
503
+
504
+
505
+ def _invalid_arguments(tool: str, message: str) -> Observation:
506
+ return Observation(content=f"invalid {tool} arguments: {message}", is_error=True)
507
+
508
+
509
+ # A real task environment can emit observations no model context can use (a rendered 52 MiB
510
+ # image via read_file, verified live). Two separate hazards, one cap:
511
+ # - transport: an unbounded observation travels the whole worker transport as one frame and
512
+ # kills the runner channel mid-episode,
513
+ # - context: the cap must be small against the MODEL's window, not just the wire. The former
514
+ # 262,144 chars is roughly 75,000 tokens, so ONE observation could exceed a whole 65,536-token
515
+ # serving window by itself; `gcode-to-text` died at step 1 in every attempt of every model
516
+ # because its first natural move returns 262,227 chars.
517
+ # 10,000 is parity with the reference terminus-2 agent's own single-observation cap
518
+ # (`_limit_output_length(..., max_bytes=10000)`), which is the scaffold the published numbers were
519
+ # measured with. Truncation keeps the MIDDLE out and both ends in: head carries the format
520
+ # signature, tail carries any summary a command prints last, and the explicit marker tells the
521
+ # model to narrow its command rather than leaving it to infer a silent cut.
522
+ MAX_OBSERVATION_CHARS = 10_000
523
+
524
+
525
+ def _elision_marker(omitted: int) -> str:
526
+ """The explicit middle-elision notice, which also tells the model what to do about it."""
527
+ return (
528
+ f"\n... [{omitted} characters truncated from the middle; command output exceeded "
529
+ f"{MAX_OBSERVATION_CHARS} characters, so narrow the command or page through the "
530
+ "output] ...\n"
531
+ )
532
+
533
+
534
+ def _bounded_observation_text(content: str) -> str:
535
+ """One observation clipped to MAX_OBSERVATION_CHARS, eliding the middle with a marker.
536
+
537
+ The marker itself is inside the budget: its length is reserved using `len(content)` as an upper
538
+ bound on the omitted count, so its digit count can never grow past the reservation and push the
539
+ result over the cap.
540
+ """
541
+ if len(content) <= MAX_OBSERVATION_CHARS:
542
+ return content
543
+ keep = max(MAX_OBSERVATION_CHARS - len(_elision_marker(len(content))), 2)
544
+ head = keep - keep // 2
545
+ tail = keep // 2
546
+ return content[:head] + _elision_marker(len(content) - head - tail) + content[-tail:]
547
+
548
+
549
+ def _command_observation(result: ExecResult) -> Observation:
550
+ stdout = result.stdout or ""
551
+ stderr = result.stderr or ""
552
+ content = _bounded_observation_text(stdout + stderr)
553
+ if result.return_code != 0:
554
+ content += f"\n[exit {result.return_code}]"
555
+ return Observation(
556
+ content=content,
557
+ is_error=result.return_code != 0,
558
+ metadata={"return_code": result.return_code},
559
+ )
560
+
561
+
562
+ def _validate_command_timeout_sec(value: int) -> int:
563
+ """Validate the evaluator-owned finite task-command policy."""
564
+ if (
565
+ isinstance(value, bool)
566
+ or not isinstance(value, int)
567
+ or value < 1
568
+ or value > MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC
569
+ ):
570
+ raise ValueError(
571
+ f"command_timeout_sec must be an integer in [1, {MAX_ENVIRONMENT_COMMAND_TIMEOUT_SEC}]"
572
+ )
573
+ return value