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,491 @@
1
+ """Web grounding: bounded search for entities the world model cannot ground internally.
2
+
3
+ When the env encounters a real-world entity outside its traces and knowledge base (an API's error
4
+ format, a package name, a flight code), it may emit a `ground_query` (see
5
+ `wmo.core.render.output_contract`) instead of hallucinating. A `Grounder` serves that query; the
6
+ engine caches results into the knowledge base (`grounded.md`) so an entity is searched at most
7
+ once per model, and re-completes the step with the results in context.
8
+
9
+ The default is `NullGrounder` — no network, tests and evals stay hermetic. Real backends:
10
+
11
+ - `BraveGrounder` (`BRAVE_SEARCH_API_KEY`; free tier at https://api-dashboard.search.brave.com/),
12
+ a plain keyed JSON API with no scraping fragility — for free-text entity queries.
13
+ - `FetchGrounder` (keyless): when the agent's action is itself a read-only `curl` GET of a public
14
+ URL, fetch that URL live and let the model shape the real body into the observation. Found
15
+ empirically: 42% of the terminal-tasks test slice is curl-with-URL, scoring ~0.10 below every
16
+ other step kind — the values (API payloads, search rankings) are unknowable without the network.
17
+ Live fetches are inherently non-hermetic (the web has moved since capture); use only in serve or
18
+ in explicitly-labeled eval modes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ipaddress
24
+ import json
25
+ import os
26
+ import re
27
+ import socket
28
+ import urllib.parse
29
+ import urllib.request
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Protocol
34
+
35
+ from pydantic import BaseModel, Field, ValidationError
36
+
37
+ from wmo.core.types import Action
38
+
39
+ GROUNDER_KINDS = ("none", "brave", "fetch")
40
+ _BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
41
+ _TIMEOUT_SECONDS = 15.0
42
+
43
+
44
+ class GroundingResult(BaseModel):
45
+ """One search hit: enough to ground an entity, small enough to cache in the KB."""
46
+
47
+ title: str = ""
48
+ url: str = ""
49
+ snippet: str = ""
50
+
51
+
52
+ class Grounder(Protocol):
53
+ """Anything that can answer a grounding query with search results."""
54
+
55
+ def ground(self, query: str) -> list[GroundingResult]:
56
+ """Return search results for `query` (empty when grounding is unavailable)."""
57
+ ...
58
+
59
+
60
+ class NullGrounder:
61
+ """The default: grounding disabled, never touches the network."""
62
+
63
+ def ground(self, query: str) -> list[GroundingResult]:
64
+ return []
65
+
66
+
67
+ # Injectable HTTP GET (url, headers) -> response body; lets tests exercise BraveGrounder offline.
68
+ FetchFn = Callable[[str, dict[str, str]], str]
69
+
70
+
71
+ def _assert_public_http_url(url: str) -> None:
72
+ """Reject URLs that could reach anything but a public web host (SSRF guard).
73
+
74
+ Fetch targets can derive from MODEL OUTPUT (FetchGrounder GETs the action's own curl URL;
75
+ `ground_query` is model-written), so before any request: scheme must be http(s) — no
76
+ file://, ftp:// or scheme-less tricks — and the host must not resolve into private,
77
+ loopback, or link-local space (cloud metadata endpoints live at 169.254.169.254).
78
+ """
79
+ parsed = urllib.parse.urlparse(url)
80
+ if parsed.scheme not in ("http", "https"):
81
+ raise ValueError(f"grounding fetch requires http(s), got {parsed.scheme!r}: {url}")
82
+ host = parsed.hostname
83
+ if not host:
84
+ raise ValueError(f"grounding fetch URL has no host: {url}")
85
+ try:
86
+ infos = socket.getaddrinfo(host, None)
87
+ except OSError as exc:
88
+ raise ValueError(f"grounding fetch host does not resolve: {host}") from exc
89
+ for info in infos:
90
+ address = ipaddress.ip_address(info[4][0])
91
+ if not address.is_global:
92
+ raise ValueError(
93
+ f"grounding fetch host {host} resolves to non-public address {address}"
94
+ )
95
+
96
+
97
+ class _PublicOnlyRedirects(urllib.request.HTTPRedirectHandler):
98
+ """Re-validate every 3xx hop: a public host 302-ing to 169.254.169.254 is the classic pivot.
99
+
100
+ (The remaining residue is DNS rebinding — resolve-at-check vs resolve-at-connect — which
101
+ urllib cannot pin without a custom transport; grounding fetches stay read-only GETs, so the
102
+ blast radius is exfil of a fetched body into the KB, not a write.)
103
+ """
104
+
105
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201, ANN202
106
+ _assert_public_http_url(newurl)
107
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
108
+
109
+
110
+ _OPENER = urllib.request.build_opener(_PublicOnlyRedirects)
111
+
112
+
113
+ def http_get(url: str, headers: dict[str, str]) -> str:
114
+ """Fetch a public http(s) URL as text: the default, SSRF-guarded `FetchFn`."""
115
+ _assert_public_http_url(url)
116
+ request = urllib.request.Request(url, headers=headers) # noqa: S310 — guarded above
117
+ with _OPENER.open(request, timeout=_TIMEOUT_SECONDS) as response: # noqa: S310
118
+ body: str = response.read().decode("utf-8")
119
+ return body
120
+
121
+
122
+ class _BraveWeb(BaseModel):
123
+ results: list[GroundingResult] = Field(default_factory=list)
124
+
125
+
126
+ class _BraveResponse(BaseModel):
127
+ web: _BraveWeb = Field(default_factory=_BraveWeb)
128
+
129
+
130
+ class BraveGrounder:
131
+ """Brave Search API backend (`X-Subscription-Token` keyed GET, JSON response)."""
132
+
133
+ def __init__(self, api_key: str, *, count: int = 5, fetch: FetchFn = http_get) -> None:
134
+ self._api_key = api_key
135
+ self._count = count
136
+ self._fetch = fetch
137
+
138
+ def ground(self, query: str) -> list[GroundingResult]:
139
+ params = urllib.parse.urlencode({"q": query, "count": str(self._count)})
140
+ headers = {"Accept": "application/json", "X-Subscription-Token": self._api_key}
141
+ body = self._fetch(f"{_BRAVE_ENDPOINT}?{params}", headers)
142
+ try:
143
+ payload = json.loads(body)
144
+ except json.JSONDecodeError as exc:
145
+ raise ValueError(f"Brave search returned non-JSON for {query!r}: {body[:200]}") from exc
146
+ try:
147
+ # Brave's result objects use `description` for the snippet; map it before validation.
148
+ raw_results = payload.get("web", {}).get("results", [])
149
+ for item in raw_results:
150
+ if isinstance(item, dict) and "description" in item and "snippet" not in item:
151
+ item["snippet"] = item.pop("description")
152
+ return _BraveResponse.model_validate(payload).web.results
153
+ except (ValidationError, AttributeError) as exc:
154
+ raise ValueError(
155
+ f"Brave search response for {query!r} did not match the expected shape: {exc}"
156
+ ) from exc
157
+
158
+
159
+ class FetchGrounder:
160
+ """Keyless grounder for URL-shaped queries: GET the URL, return its (capped) body.
161
+
162
+ Non-URL queries yield no results — compose with a search backend if both are wanted. Fetches
163
+ are memoized per instance (an eval or session asks about the same endpoint repeatedly) and
164
+ failures return no results rather than raising: an unreachable URL should degrade to the
165
+ ungrounded prediction, never break the step.
166
+ """
167
+
168
+ def __init__(self, *, max_chars: int = 8_000, fetch: FetchFn = http_get) -> None:
169
+ self._max_chars = max_chars
170
+ self._fetch = fetch
171
+ self._memo: dict[str, list[GroundingResult]] = {}
172
+
173
+ def ground(self, query: str) -> list[GroundingResult]:
174
+ url = query.strip()
175
+ if not url.startswith(("http://", "https://")):
176
+ return []
177
+ if url in self._memo:
178
+ return self._memo[url]
179
+ try:
180
+ body = self._fetch(url, {"Accept": "*/*", "User-Agent": "wmo-grounder"})
181
+ except Exception: # noqa: BLE001 - any transport failure degrades to "no grounding"
182
+ results: list[GroundingResult] = []
183
+ else:
184
+ if len(body) > self._max_chars:
185
+ body = body[: self._max_chars] + "\n[truncated]"
186
+ results = [GroundingResult(title=url, url=url, snippet=body)]
187
+ self._memo[url] = results
188
+ return results
189
+
190
+
191
+ # curl flags that mean the request mutates state (or uploads); those commands are never fetched.
192
+ _CURL_MUTATING_FLAGS = re.compile(
193
+ r"(^|\s)(-X\s*(?!GET\b)\w+|--request\s+(?!GET\b)\w+|-d\b|--data\b|--data-\w+|-F\b|--form\b"
194
+ r"|-T\b|--upload-file\b)"
195
+ )
196
+ _URL_IN_COMMAND = re.compile(r"https?://[^\s\"'|;>]+")
197
+
198
+
199
+ def extract_get_url(action: Action) -> str | None:
200
+ """Return the URL a read-only `curl` GET in `action` targets, or None.
201
+
202
+ Conservative by design: only bash tool calls whose command invokes `curl` without any
203
+ mutating/upload flag qualify — a fetch must be side-effect-free to be safe to execute for
204
+ grounding. The first URL in the command is taken (pipes/filters after it are the model's job
205
+ to apply to the fetched body).
206
+ """
207
+ if action.name != "bash":
208
+ return None
209
+ command = action.arguments.get("command")
210
+ if not isinstance(command, str) or "curl" not in command:
211
+ return None
212
+ if _CURL_MUTATING_FLAGS.search(command):
213
+ return None
214
+ match = _URL_IN_COMMAND.search(command)
215
+ return match.group(0) if match else None
216
+
217
+
218
+ def prefetched_knowledge(
219
+ knowledge: str | None, action: Action, grounder: Grounder | None
220
+ ) -> str | None:
221
+ """Append a live fetch of `action`'s read-only GET URL to the knowledge text, when possible.
222
+
223
+ The stateless prefetch used by replay experiments; the serving engine has its own budgeted,
224
+ KB-cached variant (`WorldModel._predict`). Returns `knowledge` unchanged when there is no
225
+ grounder, no fetchable URL, or the fetch yields nothing.
226
+ """
227
+ if grounder is None:
228
+ return knowledge
229
+ url = extract_get_url(action)
230
+ if url is None:
231
+ return knowledge
232
+ results = grounder.ground(url)
233
+ if not results:
234
+ return knowledge
235
+ block = f"## live fetch: {url}\n{render_grounding(results)}"
236
+ return f"{knowledge}\n\n{block}" if knowledge else block
237
+
238
+
239
+ # --- source grounding: pinned repo files as ground truth for read-verb actions -------------------
240
+
241
+ # Read-verb patterns whose observation is (a slice of) one file's true content. Any write/pipe
242
+ # transformation disqualifies — the observation would no longer be the raw file bytes.
243
+ _SED_RANGE = re.compile(r"\bsed\s+-n\s+'(\d+),(\d+)p'\s+(\S+)\s*$")
244
+ _CAT_FILE = re.compile(r"\bcat\s+(\S+)\s*$")
245
+ _HEAD_FILE = re.compile(r"\bhead\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$")
246
+ _WRITE_MARKERS = re.compile(r"(>|>>|\bsed\s+-i\b|\btee\b|\bmv\b|\bcp\b|\bpatch\b|git\s+apply)")
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class FileRead:
251
+ """One read-verb action: the repo-relative path and the requested 1-based line range."""
252
+
253
+ path: str
254
+ start: int | None
255
+ end: int | None
256
+
257
+
258
+ def extract_file_read(action: Action) -> FileRead | None:
259
+ """Return the file slice a pure read action requests, or None.
260
+
261
+ Conservative by design (mirrors `extract_get_url`): only bash `sed -n 'A,Bp'`/`cat`/`head -N`
262
+ with the file as the FINAL token qualify — pipes, redirects, in-place edits, or interpreters
263
+ mean the observation is not the raw file content. Paths are made repo-relative by stripping
264
+ the conventional sandbox prefix.
265
+ """
266
+ if action.name != "bash":
267
+ return None
268
+ command = action.arguments.get("command")
269
+ if not isinstance(command, str) or _WRITE_MARKERS.search(command) or "|" in command:
270
+ return None
271
+ command = command.strip()
272
+ if m := _SED_RANGE.search(command):
273
+ return FileRead(_repo_relative(m.group(3)), int(m.group(1)), int(m.group(2)))
274
+ if m := _HEAD_FILE.search(command):
275
+ return FileRead(_repo_relative(m.group(2)), 1, int(m.group(1)))
276
+ if m := _CAT_FILE.search(command):
277
+ return FileRead(_repo_relative(m.group(1)), None, None)
278
+ return None
279
+
280
+
281
+ def _repo_relative(path: str) -> str:
282
+ return path.removeprefix("/testbed/").lstrip("/")
283
+
284
+
285
+ class SourceResolver:
286
+ """Ground read-verb actions in the REAL file content at a pinned commit.
287
+
288
+ `pins` maps a trace's instance id -> {repo, base_commit} (for swe-bench this is the
289
+ committed `examples/swe-bench/instance_commits.json`, built once from the public dataset —
290
+ any corpus whose traces can be pinned the same way gets the same machinery). Files are
291
+ fetched keylessly from raw.githubusercontent.com, memoized per file, and sliced to the
292
+ requested line range. Callers own the STALENESS GATE: never resolve a path the session has
293
+ already touched — the pinned content is wrong the moment the agent edits the file (verified
294
+ live: first-touch reads match the pin exactly; post-edit re-reads do not).
295
+ """
296
+
297
+ def __init__(self, pins: dict[str, dict[str, str]], *, fetch: FetchFn = http_get) -> None:
298
+ self._pins = pins
299
+ self._fetch = fetch
300
+ self._memo: dict[str, list[str] | None] = {}
301
+
302
+ @classmethod
303
+ def from_file(cls, path: str | Path) -> SourceResolver:
304
+ pins = json.loads(Path(path).read_text(encoding="utf-8"))
305
+ return cls(pins)
306
+
307
+ @property
308
+ def pins(self) -> dict[str, dict[str, str]]:
309
+ """The instance pins (shared with RepoTreeResolver so both ground the same commit)."""
310
+ return self._pins
311
+
312
+ def resolve(self, instance_id: str, read: FileRead) -> str | None:
313
+ pin = self._pins.get(instance_id)
314
+ if pin is None:
315
+ return None
316
+ url = f"https://raw.githubusercontent.com/{pin['repo']}/{pin['base_commit']}/{read.path}"
317
+ if url not in self._memo:
318
+ try:
319
+ self._memo[url] = self._fetch(url, {"User-Agent": "wmo-grounder"}).splitlines()
320
+ except Exception: # noqa: BLE001 - unreachable file degrades to "no grounding"
321
+ self._memo[url] = None
322
+ lines = self._memo[url]
323
+ if lines is None:
324
+ return None
325
+ if read.start is not None:
326
+ lines = lines[read.start - 1 : read.end]
327
+ return "\n".join(lines)
328
+
329
+
330
+ def source_grounded_knowledge(
331
+ knowledge: str | None,
332
+ action: Action,
333
+ instance_id: str | None,
334
+ prior_actions: list[Action],
335
+ resolver: SourceResolver | None,
336
+ *,
337
+ annotate_stale: bool = False,
338
+ ) -> str | None:
339
+ """Append the pinned source slice for a read action to the knowledge text.
340
+
341
+ First-touch reads get the pin as ground truth. Previously-touched paths are refused by
342
+ default (the agent may have edited the file — a stale pin served as truth is wrong).
343
+ `annotate_stale=True` instead serves the BASE version explicitly labeled as pre-edit — the
344
+ model combines it with the session's in-context edits itself. Zero interpretation risk
345
+ (nothing replays the edits mechanically), and it unlocks the ~20%-of-steps population of
346
+ reads on edited files that the strict gate refuses.
347
+ """
348
+ if resolver is None or instance_id is None:
349
+ return knowledge
350
+ read = extract_file_read(action)
351
+ if read is None:
352
+ return knowledge
353
+ basename = read.path.rsplit("/", 1)[-1]
354
+ touched = False
355
+ for prior in prior_actions:
356
+ prior_cmd = prior.arguments.get("command") if prior.name else prior.content
357
+ if isinstance(prior_cmd, str) and basename in prior_cmd:
358
+ touched = True
359
+ break
360
+ if touched and not annotate_stale:
361
+ return knowledge
362
+ content = resolver.resolve(instance_id, read)
363
+ if content is None:
364
+ return knowledge
365
+ span = f" lines {read.start}-{read.end}" if read.start is not None else ""
366
+ if touched:
367
+ header = (
368
+ f"## source file: {read.path}{span} (BASE-COMMIT version — this session has since"
369
+ " touched/edited this file; the current content is this base with the history's"
370
+ " edits applied)"
371
+ )
372
+ else:
373
+ header = f"## source file: {read.path}{span} (ground truth at the base commit)"
374
+ block = f"{header}\n{content}"
375
+ return f"{knowledge}\n\n{block}" if knowledge else block
376
+
377
+
378
+ # --- registry polling: package facts from PyPI/npm JSON (keyless, read-only) --------------------
379
+
380
+ # The args slice after `pip show|install`, up to the next shell operator. Flags are stripped
381
+ # token-by-token below (the corpus shape is flag soup: `pip install --break-system-packages X
382
+ # 2>&1 | tail -3`, `python3 -m pip install ...`).
383
+ _PIP_CMD = re.compile(r"\bpip3?\s+(?:show|install)\s+([^|;&\n]*)")
384
+ _NPM_QUERY = re.compile(r"\bnpm\s+(?:view|info)\s+([A-Za-z0-9@/_.-]+)")
385
+ # Flags whose argument replaces the registry as the install source: the action isn't asking
386
+ # the registry about a named package, so refuse.
387
+ _PIP_NON_REGISTRY_FLAGS = frozenset(
388
+ {"-r", "--requirement", "-e", "--editable", "-c", "--constraint"}
389
+ )
390
+
391
+
392
+ def extract_package_query(action: Action) -> tuple[str, str] | None:
393
+ """Return (registry, package) for a pip/npm package action, or None.
394
+
395
+ Version specifiers are stripped (the registry answer covers them); invocations that don't
396
+ name a registry package (`pip freeze`, `pip install -r ...`, `pip install -e .`) are refused.
397
+ """
398
+ if action.name != "bash":
399
+ return None
400
+ command = action.arguments.get("command")
401
+ if not isinstance(command, str):
402
+ return None
403
+ if m := _PIP_CMD.search(command):
404
+ for token in m.group(1).split():
405
+ if token in _PIP_NON_REGISTRY_FLAGS:
406
+ return None
407
+ if token.startswith("-") or token.startswith("2>"):
408
+ continue
409
+ name = re.split(r"[=<>!\[]", token.strip("'\""))[0]
410
+ if not name or name.startswith((".", "/")):
411
+ return None # a path, not a registry package
412
+ return ("pypi", name)
413
+ return None
414
+ if m := _NPM_QUERY.search(command):
415
+ return ("npm", m.group(1))
416
+ return None
417
+
418
+
419
+ # (registry, package) -> rendered block, or None for a failed lookup. Failures are cached
420
+ # too: an unreachable registry must cost ONE timeout per package, not one per step.
421
+ _REGISTRY_MEMO: dict[tuple[str, str], str | None] = {}
422
+
423
+
424
+ def registry_grounded_knowledge(
425
+ knowledge: str | None, action: Action, *, fetch: FetchFn = http_get
426
+ ) -> str | None:
427
+ """Append the real registry record for a package action (harmless keyless poll).
428
+
429
+ PyPI's JSON API answers `pip show/install` with the actual current name/version/summary/
430
+ dependencies — the values the model otherwise invents. Unreachable registries degrade to
431
+ no grounding.
432
+ """
433
+ query = extract_package_query(action)
434
+ if query is None:
435
+ return knowledge
436
+ registry, package = query
437
+ memoize = fetch is http_get # injected fetch fns (tests) must not share process state
438
+ if memoize and query in _REGISTRY_MEMO:
439
+ block = _REGISTRY_MEMO[query]
440
+ if block is None:
441
+ return knowledge
442
+ return f"{knowledge}\n\n{block}" if knowledge else block
443
+ if registry == "pypi":
444
+ url = f"https://pypi.org/pypi/{package}/json"
445
+ else:
446
+ url = f"https://registry.npmjs.org/{package}/latest"
447
+ try:
448
+ payload = json.loads(fetch(url, {"Accept": "application/json"}))
449
+ except Exception: # noqa: BLE001 - registry unreachable: degrade to no grounding
450
+ if memoize:
451
+ _REGISTRY_MEMO[query] = None
452
+ return knowledge
453
+ info = payload.get("info", payload)
454
+ requires = info.get("requires_dist") or info.get("dependencies") or []
455
+ if isinstance(requires, dict):
456
+ requires = [f"{k}{v}" for k, v in requires.items()]
457
+ block = (
458
+ f"## registry record: {package} ({registry}, live)\n"
459
+ f"name: {info.get('name', package)}\n"
460
+ f"version: {info.get('version', '?')}\n"
461
+ f"summary: {info.get('summary', '')}\n"
462
+ f"requires: {', '.join(str(r) for r in requires[:12])}\n"
463
+ f"requires_python: {info.get('requires_python', '')}"
464
+ )
465
+ if memoize:
466
+ _REGISTRY_MEMO[query] = block
467
+ return f"{knowledge}\n\n{block}" if knowledge else block
468
+
469
+
470
+ def get_grounder(kind: str) -> Grounder:
471
+ """Construct the configured grounder (`HarnessConfig.grounder`): none | brave | fetch."""
472
+ if kind == "none":
473
+ return NullGrounder()
474
+ if kind == "fetch":
475
+ return FetchGrounder()
476
+ if kind == "brave":
477
+ api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
478
+ if not api_key:
479
+ raise ValueError(
480
+ "grounder 'brave' needs BRAVE_SEARCH_API_KEY set; get a free key at "
481
+ "https://api-dashboard.search.brave.com/ or set grounder = 'none'"
482
+ )
483
+ return BraveGrounder(api_key)
484
+ raise ValueError(f"unknown grounder {kind!r}; choose one of {', '.join(GROUNDER_KINDS)}")
485
+
486
+
487
+ def render_grounding(results: list[GroundingResult]) -> str:
488
+ """Render results as compact markdown for the KB cache and the re-completion prompt."""
489
+ if not results:
490
+ return "(no results)"
491
+ return "\n".join(f"- {r.title} ({r.url}): {r.snippet}".strip() for r in results)