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,619 @@
1
+ """RunnerLink: the transport that replaces per-episode SSH + reverse tunnel for the pi runner.
2
+
3
+ The control plane (this process) holds the model credentials and the world-model session state; a
4
+ long-lived pi *runner* — local, on nucbox, or any remote box — dials the host and blocks reading
5
+ frames. One episode is driven over one bidirectional frame channel: the host sends an
6
+ `episode_start`, then answers the two callbacks the runner pushes up — `llm_request` (the worker
7
+ LLM completion, produced host-side so no creds ever reach the runner) and `tool_request` (the
8
+ environment tool call, routed to the `AgentEnvironment` / world model) — until `done`.
9
+
10
+ Frames are length-prefixed JSON (4-byte big-endian length + UTF-8 body) over a raw socket, so the
11
+ transport adds ZERO dependency on either side (Python stdlib here; Node stdlib in the runner). The
12
+ episode-driving logic is decoupled from the socket behind the `Channel` protocol so a scripted
13
+ in-process peer can exercise the whole broker offline (see runner_link_test.py).
14
+
15
+ The link is provider-neutral: the caller supplies a structured tool-calling provider, which owns
16
+ authentication, routing, wire translation, retries, and failover. RunnerLink only validates and
17
+ brokers frames plus environment tool calls.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import struct
25
+ import time
26
+ import uuid
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from typing import Protocol, cast
30
+
31
+ from llm_waterfall import ChatRequest, ChatResponse
32
+
33
+ from wmo.core.types import Action, ActionKind, EnvState, JsonObject, Observation, Step
34
+ from wmo.harness.environment import AgentEnvironment, is_env_action
35
+ from wmo.harness.runtime import (
36
+ DEFAULT_MAX_OUTPUT_TOKENS,
37
+ DEFAULT_MAX_TURNS,
38
+ RunResult,
39
+ RuntimeCancelled,
40
+ StopReason,
41
+ TokenUsage,
42
+ )
43
+ from wmo.harness.skills import SkillLibrary
44
+ from wmo.harness.tools import READ_SKILL, ToolSpec
45
+ from wmo.providers.base import ContextWindowProvider, ToolCallingProvider
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ DEFAULT_MAX_ENV_ACTIONS = 40
50
+ DEFAULT_CANCEL_POLL_INTERVAL_S = 0.5
51
+
52
+ DONE_REASON_STOP_REASONS: dict[str, StopReason] = {
53
+ "submit": StopReason.SUBMITTED,
54
+ "no_tool_call": StopReason.NO_TOOL_CALL,
55
+ "output_truncated": StopReason.OUTPUT_TRUNCATED,
56
+ "unparsed_tool_call": StopReason.UNPARSED_TOOL_CALL,
57
+ "provider_error": StopReason.PROVIDER_ERROR,
58
+ "max_turns": StopReason.MAX_TURNS,
59
+ }
60
+ """The `done` frame's `reason` vocabulary (pi_entry/runner_termination.ts) mapped onto stop reasons.
61
+
62
+ Before this existed, every `done` frame became `SUBMITTED`, so a genuine `submit`, a prose-only
63
+ turn, a turn truncated at the output cap, and a dropped tool call were indistinguishable and all
64
+ scored reward 0 as clean completions."""
65
+
66
+
67
+ def stop_reason_for_done(reason: object) -> StopReason:
68
+ """The stop reason one `done` frame's `reason` field means.
69
+
70
+ Args:
71
+ reason: The frame's `reason` value, if any.
72
+
73
+ Returns:
74
+ The mapped stop reason. A missing or unrecognized value becomes `UNKNOWN_DONE_REASON`,
75
+ which is a SCAFFOLD LOSS, not a completion.
76
+
77
+ Failing toward "completion" here is the same defect this mapping exists to remove, one
78
+ level up: a stale runner (its `.ts` older than the host) sends the pre-`reason` `done`
79
+ frame, every episode reads as a clean submit, `scaffold_loss_rate` reads ~0, and a probe
80
+ whose entire job is to count scaffold losses PASSES. Reward-0 corpses then enter the
81
+ solve-rate denominator as completed-but-failed attempts, which is exactly how a 7.8%
82
+ submit rate coexisted with "every trial that finished, passed". A warning is not enough
83
+ protection: it scrolls past in a 24-hour run. So the unknown case is counted as a loss,
84
+ where it is loud and forces a look.
85
+ """
86
+ if isinstance(reason, str) and reason in DONE_REASON_STOP_REASONS:
87
+ return DONE_REASON_STOP_REASONS[reason]
88
+ logger.warning(
89
+ "runner sent a done frame with reason=%r, so this runner predates termination reporting; "
90
+ "the episode is recorded as a scaffold loss (unknown_done_reason) rather than a "
91
+ "completion. Redeploy the pi runner files",
92
+ reason,
93
+ )
94
+ return StopReason.UNKNOWN_DONE_REASON
95
+
96
+
97
+ _WORKER_ERROR_LOG_CHARS = 500
98
+ """Warning-level cap on a worker exception message (full detail at debug)."""
99
+
100
+
101
+ def _bounded_error_text(exc: Exception) -> str:
102
+ """One warning-sized line for a worker failure, truncated past the cap."""
103
+ text = " ".join(str(exc).split())
104
+ if len(text) > _WORKER_ERROR_LOG_CHARS:
105
+ return text[:_WORKER_ERROR_LOG_CHARS] + "... (truncated)"
106
+ return text
107
+
108
+
109
+ # --------------------------------------------------------------------------------------------------
110
+ # Wire framing: length-prefixed JSON over a raw socket (stdlib only, both sides).
111
+ # --------------------------------------------------------------------------------------------------
112
+ class _SupportsSocket(Protocol):
113
+ def sendall(self, data: bytes) -> None: ...
114
+ def recv(self, n: int) -> bytes: ...
115
+
116
+
117
+ def write_frame(sock: _SupportsSocket, frame: JsonObject) -> None:
118
+ """Send one JSON frame: 4-byte big-endian length prefix + UTF-8 body."""
119
+ body = json.dumps(frame).encode("utf-8")
120
+ sock.sendall(struct.pack(">I", len(body)) + body)
121
+
122
+
123
+ def read_frame(sock: _SupportsSocket) -> JsonObject | None:
124
+ """Read one framed JSON message, or None if the peer closed the connection cleanly."""
125
+ header = _recv_exactly(sock, 4)
126
+ if header is None:
127
+ return None
128
+ (length,) = struct.unpack(">I", header)
129
+ body = _recv_exactly(sock, length)
130
+ if body is None:
131
+ return None
132
+ return cast("JsonObject", json.loads(body))
133
+
134
+
135
+ def _recv_exactly(sock: _SupportsSocket, n: int) -> bytes | None:
136
+ buf = bytearray()
137
+ while len(buf) < n:
138
+ chunk = sock.recv(n - len(buf))
139
+ if not chunk:
140
+ return None
141
+ buf += chunk
142
+ return bytes(buf)
143
+
144
+
145
+ class Channel(Protocol):
146
+ """A bidirectional frame channel to the runner peer (a socket, or a test double)."""
147
+
148
+ def send(self, frame: JsonObject) -> None: ...
149
+ def recv(self, timeout: float | None = None) -> JsonObject | None: ...
150
+
151
+
152
+ class SocketChannel:
153
+ """A `Channel` backed by a connected socket, using the length-prefixed JSON framing."""
154
+
155
+ def __init__(self, sock: _SupportsSocket) -> None:
156
+ self._sock = sock
157
+ self._recv_buffer = bytearray()
158
+
159
+ def send(self, frame: JsonObject) -> None:
160
+ write_frame(self._sock, frame)
161
+
162
+ def recv(self, timeout: float | None = None) -> JsonObject | None:
163
+ settimeout = getattr(self._sock, "settimeout", None)
164
+ gettimeout = getattr(self._sock, "gettimeout", None)
165
+ previous = gettimeout() if timeout is not None and callable(gettimeout) else None
166
+ if timeout is not None and callable(settimeout):
167
+ settimeout(timeout)
168
+ try:
169
+ if not self._fill_recv_buffer(4):
170
+ return None
171
+ (length,) = struct.unpack(">I", self._recv_buffer[:4])
172
+ if not self._fill_recv_buffer(4 + length):
173
+ return None
174
+ body = bytes(self._recv_buffer[4 : 4 + length])
175
+ del self._recv_buffer[: 4 + length]
176
+ return cast("JsonObject", json.loads(body))
177
+ finally:
178
+ if timeout is not None and callable(settimeout):
179
+ settimeout(previous)
180
+
181
+ def _fill_recv_buffer(self, size: int) -> bool:
182
+ """Read through ``size`` bytes while preserving partial frames across timed polls."""
183
+ while len(self._recv_buffer) < size:
184
+ chunk = self._sock.recv(size - len(self._recv_buffer))
185
+ if not chunk:
186
+ return False
187
+ self._recv_buffer += chunk
188
+ return True
189
+
190
+
191
+ # The process-wide runner channel doc.runtime(PI_TRANSPORT=link) drives. A search/eval sets it once
192
+ # (its runner connection is process-scoped infra), so create_harness's internal doc.runtime() calls
193
+ # reach the runner without threading a channel through every signature; cleared at teardown.
194
+ _ACTIVE_CHANNEL: Channel | None = None
195
+
196
+
197
+ def set_active_channel(channel: Channel | None) -> None:
198
+ global _ACTIVE_CHANNEL
199
+ _ACTIVE_CHANNEL = channel
200
+
201
+
202
+ def active_channel() -> Channel | None:
203
+ return _ACTIVE_CHANNEL
204
+
205
+
206
+ def provider_context_window(provider: object) -> int | None:
207
+ """The served context window a provider reports, or None when it cannot say.
208
+
209
+ Providers whose served window is a property of the deployment (the Tinker student, whose
210
+ catalog name pins a context tier) implement `ContextWindowProvider`; everything else returns
211
+ None and the runner keeps its documented fallback. A probe that fails must never break an
212
+ episode, so any error degrades to None with a warning.
213
+
214
+ Args:
215
+ provider: The worker provider, or None.
216
+
217
+ Returns:
218
+ The served window in tokens, or None when unknown.
219
+ """
220
+ if not isinstance(provider, ContextWindowProvider):
221
+ return None
222
+ try:
223
+ window = provider.context_window()
224
+ except Exception as exc: # noqa: BLE001 - a capability probe never fails an episode
225
+ logger.warning(
226
+ "could not resolve the served context window from %s (%s); the pi runner falls back "
227
+ "to its default window, so context-overflow trimming may be miscalibrated",
228
+ type(provider).__name__,
229
+ _bounded_error_text(exc),
230
+ )
231
+ return None
232
+ return window
233
+
234
+
235
+ def params_schema(tool: ToolSpec) -> JsonObject:
236
+ """A JSON-schema `parameters` object for a tool, as the model's function-calling API expects."""
237
+ props: JsonObject = {
238
+ name: {"type": "string", "description": desc} for name, desc in tool.arguments.items()
239
+ }
240
+ return {"type": "object", "properties": props, "required": list(tool.arguments)}
241
+
242
+
243
+ # --------------------------------------------------------------------------------------------------
244
+ # Host-side episode: environment tool routing, budget, and transcript recording.
245
+ # --------------------------------------------------------------------------------------------------
246
+ @dataclass
247
+ class HostEpisode:
248
+ """Per-episode host state: routes tool calls to the environment under a budget, records Steps.
249
+
250
+ Same budget/step-recording contract the SSH shim's `_Episode` had, minus the HTTP specifics.
251
+ """
252
+
253
+ instruction: str
254
+ tools: list[ToolSpec]
255
+ environment: AgentEnvironment
256
+ skills: SkillLibrary = field(default_factory=SkillLibrary)
257
+ max_env_actions: int = DEFAULT_MAX_ENV_ACTIONS
258
+ steps: list[Step] = field(default_factory=list)
259
+ answer: str = ""
260
+ _env_calls: int = 0
261
+
262
+ def tool_specs(self) -> list[JsonObject]:
263
+ return [
264
+ {"name": t.name, "description": t.description, "parameters": params_schema(t)}
265
+ for t in self.tools
266
+ ]
267
+
268
+ def run_tool(self, name: str, arguments: JsonObject) -> JsonObject:
269
+ """Answer one runtime/environment tool call under AgentRuntime-compatible semantics."""
270
+ action = Action(kind=ActionKind.TOOL_CALL, name=name, arguments=arguments)
271
+ if name not in {t.name for t in self.tools}:
272
+ obs = Observation(content=f"tool {name!r} not available", is_error=True)
273
+ elif name == READ_SKILL.name:
274
+ raw_name = arguments.get("name")
275
+ skill_name = raw_name if isinstance(raw_name, str) else ""
276
+ skill = self.skills.get(skill_name)
277
+ if skill is None:
278
+ obs = Observation(content=f"no skill named {skill_name!r}", is_error=True)
279
+ else:
280
+ obs = Observation(content=skill.body)
281
+ elif self._env_calls >= self.max_env_actions:
282
+ obs = Observation(content="environment action budget exhausted", is_error=True)
283
+ elif not is_env_action(action):
284
+ obs = Observation(content=f"tool {name!r} not available", is_error=True)
285
+ else:
286
+ self._env_calls += 1
287
+ obs = self.environment.execute(action)
288
+ self.steps.append(
289
+ Step(action=action, observation=obs, state_before=EnvState(), task=self.instruction)
290
+ )
291
+ return {"content": obs.content, "is_error": obs.is_error}
292
+
293
+
294
+ # The worker function the host uses to answer llm_request frames; injectable for tests.
295
+ WorkerFn = Callable[[ChatRequest], ChatResponse]
296
+
297
+
298
+ class RunnerLink:
299
+ """Drives one pi episode over a `Channel` to the runner peer.
300
+
301
+ Sends `episode_start`, then answers `llm_request` (worker LLM, host-side) and `tool_request`
302
+ (environment) frames until `done`/`episode_error`, returning a `RunResult` shaped exactly like
303
+ the other runtimes. One `RunnerLink.run` == one episode; concurrent episodes multiplex over the
304
+ same channel by `episode_id` (a later migration step).
305
+ """
306
+
307
+ def __init__(
308
+ self,
309
+ channel: Channel,
310
+ *,
311
+ tools: list[ToolSpec] | None = None,
312
+ provider: ToolCallingProvider | None = None,
313
+ worker_fn: WorkerFn | None = None,
314
+ files: dict[str, str] | None = None,
315
+ system_prompt: str = "",
316
+ max_env_actions: int = DEFAULT_MAX_ENV_ACTIONS,
317
+ max_turns: int = DEFAULT_MAX_TURNS,
318
+ max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
319
+ temperature: float = 0.7,
320
+ skills: SkillLibrary | None = None,
321
+ episode_timeout_s: float | None = None,
322
+ context_window: int | None = None,
323
+ should_cancel: Callable[[], bool] | None = None,
324
+ cancel_poll_interval_s: float = DEFAULT_CANCEL_POLL_INTERVAL_S,
325
+ ) -> None:
326
+ self._channel = channel
327
+ # Tools bound at construction make RunnerLink satisfy the runtime contract closed-loop eval
328
+ # drives — `run(task_id, instruction, environment)` — while `run(..., tools=...)` still lets
329
+ # a caller (or the conformance tests) override per episode.
330
+ self._tools = list(tools or [])
331
+ self._skills = skills if skills is not None else SkillLibrary()
332
+ if len(self._skills) and READ_SKILL.name not in {tool.name for tool in self._tools}:
333
+ self._tools.append(READ_SKILL)
334
+ if worker_fn is None and provider is None:
335
+ raise ValueError("RunnerLink needs a ToolCallingProvider or worker_fn")
336
+ # worker_fn lets tests answer llm_request without a real provider.
337
+ if worker_fn is not None:
338
+ self._worker_fn = worker_fn
339
+ else:
340
+ assert provider is not None
341
+ self._worker_fn = provider.complete_chat
342
+ self._files = files or {}
343
+ self._system_prompt = system_prompt
344
+ self._max_env_actions = max_env_actions
345
+ if max_turns < 1:
346
+ raise ValueError("max_turns must be >= 1")
347
+ if max_output_tokens < 1:
348
+ raise ValueError("max_output_tokens must be >= 1")
349
+ if not 0.0 <= temperature <= 2.0:
350
+ raise ValueError("temperature must be in [0, 2]")
351
+ if episode_timeout_s is not None and episode_timeout_s <= 0:
352
+ raise ValueError("episode_timeout_s must be positive when set")
353
+ if cancel_poll_interval_s <= 0:
354
+ raise ValueError("cancel_poll_interval_s must be positive")
355
+ if context_window is not None and (
356
+ isinstance(context_window, bool) or not isinstance(context_window, int)
357
+ ):
358
+ raise ValueError("context_window must be an integer number of tokens when set")
359
+ if context_window is not None and context_window < 1024:
360
+ raise ValueError("context_window must be at least 1024 tokens when set")
361
+ self._max_turns = max_turns
362
+ self._max_output_tokens = max_output_tokens
363
+ self._temperature = temperature
364
+ self._episode_timeout_s = episode_timeout_s
365
+ # The runner clamps pi's output budget and trims against this number, so a wrong value is
366
+ # worse than none: 128k assumed against a 64k server let every context-overflow 400 through.
367
+ self._context_window = (
368
+ context_window if context_window is not None else provider_context_window(provider)
369
+ )
370
+ self._should_cancel = should_cancel
371
+ self._cancel_poll_interval_s = cancel_poll_interval_s
372
+
373
+ def run(
374
+ self,
375
+ task_id: str,
376
+ instruction: str,
377
+ environment: AgentEnvironment,
378
+ *,
379
+ tools: list[ToolSpec] | None = None,
380
+ ) -> RunResult:
381
+ episode_tools = list(tools) if tools is not None else list(self._tools)
382
+ if len(self._skills) and READ_SKILL.name not in {tool.name for tool in episode_tools}:
383
+ episode_tools.append(READ_SKILL)
384
+ episode = HostEpisode(
385
+ instruction=instruction,
386
+ tools=episode_tools,
387
+ environment=environment,
388
+ skills=self._skills,
389
+ max_env_actions=self._max_env_actions,
390
+ )
391
+ episode_id = uuid.uuid4().hex
392
+ usage = TokenUsage()
393
+ self._check_cancelled(usage)
394
+ deadline = (
395
+ time.monotonic() + self._episode_timeout_s
396
+ if self._episode_timeout_s is not None
397
+ else None
398
+ )
399
+
400
+ def send_frame(frame: JsonObject) -> RunResult | None:
401
+ try:
402
+ self._channel.send(frame)
403
+ except Exception:
404
+ self._check_cancelled(usage)
405
+ if deadline is not None and time.monotonic() >= deadline:
406
+ return self._budget_result(task_id, episode, instruction, usage)
407
+ raise
408
+ self._check_cancelled(usage)
409
+ if deadline is not None and time.monotonic() >= deadline:
410
+ return self._budget_result(task_id, episode, instruction, usage)
411
+ return None
412
+
413
+ stopped = send_frame(
414
+ {
415
+ "type": "episode_start",
416
+ "episode_id": episode_id,
417
+ "task_id": task_id,
418
+ "instruction": instruction,
419
+ "system": self._system_prompt,
420
+ "tools": episode.tool_specs(),
421
+ "files": self._files,
422
+ "max_env_actions": self._max_env_actions,
423
+ "max_turns": self._max_turns,
424
+ "max_output_tokens": self._max_output_tokens,
425
+ "temperature": self._temperature,
426
+ "episode_timeout_s": self._episode_timeout_s,
427
+ "context_window": self._context_window,
428
+ }
429
+ )
430
+ if stopped is not None:
431
+ return stopped
432
+ while True:
433
+ self._check_cancelled(usage)
434
+ remaining = None if deadline is None else deadline - time.monotonic()
435
+ if remaining is not None and remaining <= 0:
436
+ return self._budget_result(task_id, episode, instruction, usage)
437
+ recv_timeout = remaining
438
+ if self._should_cancel is not None:
439
+ recv_timeout = (
440
+ self._cancel_poll_interval_s
441
+ if recv_timeout is None
442
+ else min(recv_timeout, self._cancel_poll_interval_s)
443
+ )
444
+ try:
445
+ frame = self._channel.recv(timeout=recv_timeout)
446
+ except TimeoutError:
447
+ self._check_cancelled(usage)
448
+ if deadline is not None and time.monotonic() >= deadline:
449
+ return self._budget_result(task_id, episode, instruction, usage)
450
+ if self._should_cancel is not None:
451
+ continue
452
+ raise
453
+ except Exception:
454
+ self._check_cancelled(usage)
455
+ if deadline is not None and time.monotonic() >= deadline:
456
+ return self._budget_result(task_id, episode, instruction, usage)
457
+ raise
458
+ self._check_cancelled(usage)
459
+ if deadline is not None and time.monotonic() >= deadline:
460
+ return self._budget_result(task_id, episode, instruction, usage)
461
+ if frame is None: # channel closed before the episode finished
462
+ return self._error_result(
463
+ task_id, episode, instruction, "runner channel closed", usage=usage
464
+ )
465
+ kind = frame.get("type")
466
+ if kind == "llm_request":
467
+ response = self._llm_response(episode_id, frame, usage)
468
+ self._check_cancelled(usage)
469
+ if deadline is not None and time.monotonic() >= deadline:
470
+ return self._budget_result(task_id, episode, instruction, usage)
471
+ # A send timeout is transport failure with an uncertain delivery state. Let it
472
+ # propagate so the owning runtime retires rather than sending a second response.
473
+ stopped = send_frame(response)
474
+ if stopped is not None:
475
+ return stopped
476
+ elif kind == "tool_request":
477
+ name = frame.get("name")
478
+ args = frame.get("arguments")
479
+ obs = episode.run_tool(
480
+ name if isinstance(name, str) else "",
481
+ args if isinstance(args, dict) else {},
482
+ )
483
+ self._check_cancelled(usage)
484
+ if deadline is not None and time.monotonic() >= deadline:
485
+ return self._budget_result(task_id, episode, instruction, usage)
486
+ stopped = send_frame(
487
+ {
488
+ "type": "tool_response",
489
+ "episode_id": episode_id,
490
+ "req_id": frame.get("req_id"),
491
+ **obs,
492
+ }
493
+ )
494
+ if stopped is not None:
495
+ return stopped
496
+ elif kind == "done":
497
+ answer = frame.get("answer")
498
+ episode.answer = answer if isinstance(answer, str) else ""
499
+ stop_reason = stop_reason_for_done(frame.get("reason"))
500
+ if stop_reason is not StopReason.SUBMITTED:
501
+ logger.info(
502
+ "episode %s for task %s ended without an explicit submit (%s); recording "
503
+ "it as a scaffold loss, not a task failure",
504
+ episode_id,
505
+ task_id,
506
+ stop_reason.value,
507
+ )
508
+ return RunResult(
509
+ task_id=task_id,
510
+ steps=episode.steps,
511
+ stop_reason=stop_reason,
512
+ answer=episode.answer,
513
+ turns=len(episode.steps),
514
+ worker_usage=usage if usage.calls else None,
515
+ )
516
+ elif kind == "episode_error":
517
+ note = frame.get("note")
518
+ return self._error_result(
519
+ task_id,
520
+ episode,
521
+ instruction,
522
+ note if isinstance(note, str) else "runner error",
523
+ usage=usage,
524
+ )
525
+ # unknown frame types are ignored (forward-compatible)
526
+
527
+ def _check_cancelled(self, usage: TokenUsage) -> None:
528
+ if self._should_cancel is not None and self._should_cancel():
529
+ raise RuntimeCancelled(
530
+ "runtime episode cancelled",
531
+ worker_usage=(usage.model_copy() if usage.calls else None),
532
+ )
533
+
534
+ def _budget_result(
535
+ self,
536
+ task_id: str,
537
+ episode: HostEpisode,
538
+ instruction: str,
539
+ usage: TokenUsage,
540
+ ) -> RunResult:
541
+ assert self._episode_timeout_s is not None
542
+ return self._error_result(
543
+ task_id,
544
+ episode,
545
+ instruction,
546
+ f"evaluation episode exceeded {self._episode_timeout_s:g}s wall budget",
547
+ stop=StopReason.BUDGET,
548
+ usage=usage,
549
+ )
550
+
551
+ def _llm_response(self, episode_id: str, frame: JsonObject, usage: TokenUsage) -> JsonObject:
552
+ req_id = frame.get("req_id")
553
+ body = frame.get("openai_body")
554
+ try:
555
+ # The runner owns message/tool serialization, while HarnessDoc owns sampling policy.
556
+ # Override any runner default at the final host boundary before the real model call.
557
+ request_body = dict(body) if isinstance(body, dict) else {}
558
+ request_body["temperature"] = self._temperature
559
+ request = ChatRequest.model_validate(request_body)
560
+ completion = self._worker_fn(request)
561
+ # Meter the worker leg from the provider's structured response.
562
+ usage.calls += 1
563
+ reported = completion.token_usage()
564
+ usage.input_tokens += reported.input_tokens
565
+ usage.output_tokens += reported.output_tokens
566
+ response: JsonObject = {
567
+ "type": "llm_response",
568
+ "episode_id": episode_id,
569
+ "req_id": req_id,
570
+ "completion": completion.wire_payload(),
571
+ }
572
+ except Exception as exc: # noqa: BLE001 - report to the runner, never crash the host
573
+ # Never silent: a provider that fails every call otherwise ends the
574
+ # episode as a clean-looking zero-turn "submitted" (the runner owns
575
+ # what it does with the error frame), which buried a live outage.
576
+ logger.warning(
577
+ "worker completion failed for episode %s (req %s): %s: %s; "
578
+ "returning the error frame to the runner",
579
+ episode_id,
580
+ req_id,
581
+ type(exc).__name__,
582
+ _bounded_error_text(exc),
583
+ )
584
+ logger.debug("worker completion failure detail", exc_info=exc)
585
+ response = {
586
+ "type": "llm_response",
587
+ "episode_id": episode_id,
588
+ "req_id": req_id,
589
+ "error": str(exc),
590
+ }
591
+ return response
592
+
593
+ @staticmethod
594
+ def _error_result(
595
+ task_id: str,
596
+ episode: HostEpisode,
597
+ instruction: str,
598
+ note: str,
599
+ *,
600
+ stop: StopReason | None = None,
601
+ usage: TokenUsage | None = None,
602
+ ) -> RunResult:
603
+ resolved_stop = stop or (StopReason.MAX_TURNS if episode.steps else StopReason.ERROR)
604
+ episode.steps.append(
605
+ Step(
606
+ action=Action(kind=ActionKind.MESSAGE, content="(runner link)"),
607
+ observation=Observation(content=note, is_error=True),
608
+ state_before=EnvState(),
609
+ task=instruction,
610
+ )
611
+ )
612
+ return RunResult(
613
+ task_id=task_id,
614
+ steps=episode.steps,
615
+ stop_reason=resolved_stop,
616
+ answer="",
617
+ turns=len(episode.steps),
618
+ worker_usage=usage if usage is not None and usage.calls else None,
619
+ )