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
wmo/platform/client.py ADDED
@@ -0,0 +1,551 @@
1
+ """Typed HTTP client for the platform's CLI registry surface.
2
+
3
+ Every call carries the org API key as a bearer credential; the platform scopes
4
+ reads and writes to that key's organization at member strength. Error payloads
5
+ are the platform's uniform ``{"error": message}`` shape, surfaced as
6
+ :class:`PlatformError` with the HTTP status attached.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from importlib import metadata
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import httpx
18
+ from llm_waterfall import ChatRequest, ChatResponse
19
+ from pydantic import BaseModel
20
+
21
+ from wmo.core.types import Action, JsonValue, Observation
22
+
23
+ _TIMEOUT_SECONDS = 120.0
24
+ _WORKSPACE_TIMEOUT_SECONDS = 300.0
25
+
26
+
27
+ class PlatformError(RuntimeError):
28
+ """A platform request failed; carries the HTTP status when one exists."""
29
+
30
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
31
+ super().__init__(message)
32
+ self.status_code = status_code
33
+
34
+
35
+ class ActorInfo(BaseModel):
36
+ """Who the platform resolved the credential to."""
37
+
38
+ kind: str # "api_key" | "user"
39
+ id: str
40
+
41
+
42
+ class OrgInfo(BaseModel):
43
+ """One organization visible to the credential."""
44
+
45
+ id: str
46
+ slug: str
47
+ name: str
48
+
49
+
50
+ class WhoAmI(BaseModel):
51
+ """Response of ``GET /api/whoami``."""
52
+
53
+ actor: ActorInfo
54
+ orgs: list[OrgInfo]
55
+
56
+
57
+ class RemoteWorldModel(BaseModel):
58
+ """The slice of a world-model row the CLI presents."""
59
+
60
+ id: str
61
+ name: str
62
+ display_name: str | None = None
63
+ status: str
64
+ updated_at: str | None = None
65
+
66
+
67
+ class RemoteHarness(BaseModel):
68
+ """The slice of a registry harness row the CLI presents."""
69
+
70
+ id: str
71
+ name: str
72
+ latest_version: int
73
+ updated_at: str | None = None
74
+
75
+
76
+ class RemoteHarnessVersion(BaseModel):
77
+ """One doc-less entry of a harness's version lineage."""
78
+
79
+ version: int
80
+ doc_hash: str
81
+ created_at: str | None = None
82
+
83
+
84
+ class HarnessVersionDoc(BaseModel):
85
+ """One full harness version, doc included."""
86
+
87
+ version: int
88
+ doc: dict[str, JsonValue]
89
+ doc_hash: str
90
+
91
+
92
+ class PushedHarnessVersion(BaseModel):
93
+ """Response of a harness push: the version the doc landed as."""
94
+
95
+ name: str
96
+ version: int
97
+ doc_hash: str
98
+ created: bool # False when the push was an idempotent repeat of the tip
99
+
100
+
101
+ class RunTarget(BaseModel):
102
+ """A platform id resolved to one executable resource kind."""
103
+
104
+ id: str
105
+ kind: Literal["world_model", "agent"]
106
+ org_id: str
107
+ name: str
108
+ display_name: str | None = None
109
+ status: str
110
+
111
+
112
+ class RemoteWorldModelSession(BaseModel):
113
+ """The slice of a hosted world-model session needed by ``wmo run``."""
114
+
115
+ id: str
116
+ world_model_id: str
117
+ status: str
118
+
119
+
120
+ class RemoteAgentSession(BaseModel):
121
+ """Hosted E2B agent session state needed by the CLI driver."""
122
+
123
+ id: str
124
+ agent_id: str
125
+ status: str
126
+ workspace_sync: bool
127
+ launched_from: str
128
+ starting_detail: str | None = None
129
+ ended_reason: str | None = None
130
+ error: str | None = None
131
+
132
+
133
+ class RemoteAgentSessionEvent(BaseModel):
134
+ """One durable event from a hosted agent session transcript."""
135
+
136
+ seq: int
137
+ kind: Literal[
138
+ "user_message",
139
+ "assistant_message",
140
+ "tool_call",
141
+ "tool_output",
142
+ "tool_result",
143
+ "submit",
144
+ "state",
145
+ "status",
146
+ "error",
147
+ "workspace_patch",
148
+ ]
149
+ payload: dict[str, JsonValue]
150
+
151
+
152
+ class RemoteAgentEventPage(BaseModel):
153
+ """One poll page of hosted transcript events and current session status."""
154
+
155
+ events: list[RemoteAgentSessionEvent]
156
+ last_seq: int
157
+ status: str
158
+
159
+
160
+ class WorkspacePatchResult(BaseModel):
161
+ """Paths accepted or rejected while applying a live workspace patch."""
162
+
163
+ applied: list[str]
164
+ conflicts: list[str]
165
+
166
+
167
+ class LocalPiRunInfo(BaseModel):
168
+ """An org-scoped platform usage record for the built-in local pi harness."""
169
+
170
+ id: str
171
+ org_id: str
172
+ status: str
173
+ worker_provider: str
174
+ worker_model: str
175
+
176
+
177
+ def fetch_cli_config(web_url: str, *, transport: httpx.BaseTransport | None = None) -> str | None:
178
+ """Ask the web app which backend host the CLI should call.
179
+
180
+ ``GET {web_url}/api/cli/config`` is public: the backend URL is not a
181
+ secret (every Endpoints page shows it) and everything behind it is
182
+ bearer-gated.
183
+ """
184
+ with httpx.Client(timeout=30.0, transport=transport) as client:
185
+ response = client.get(f"{web_url.rstrip('/')}/api/cli/config")
186
+ if response.status_code != 200:
187
+ msg = f"platform discovery failed with HTTP {response.status_code} at {web_url}"
188
+ raise PlatformError(msg, status_code=response.status_code)
189
+ api_url = response.json().get("apiUrl")
190
+ return str(api_url).rstrip("/") if api_url else None
191
+
192
+
193
+ class PlatformClient:
194
+ """Requests against the platform registry, authenticated with an org API key."""
195
+
196
+ def __init__(
197
+ self,
198
+ api_url: str,
199
+ token: str,
200
+ *,
201
+ transport: httpx.BaseTransport | None = None,
202
+ ) -> None:
203
+ try:
204
+ version = metadata.version("world-model-optimizer")
205
+ except metadata.PackageNotFoundError:
206
+ version = "dev"
207
+ self._client = httpx.Client(
208
+ base_url=api_url.rstrip("/"),
209
+ headers={
210
+ "Authorization": f"Bearer {token}",
211
+ "User-Agent": f"wmo/{version}",
212
+ },
213
+ timeout=_TIMEOUT_SECONDS,
214
+ transport=transport,
215
+ )
216
+ # Bundle bytes move directly against storage's signed URLs; that
217
+ # client carries no platform credential.
218
+ self._transfer = httpx.Client(
219
+ headers={"User-Agent": f"wmo/{version}"},
220
+ timeout=_TIMEOUT_SECONDS,
221
+ transport=transport,
222
+ )
223
+
224
+ def __enter__(self) -> PlatformClient:
225
+ return self
226
+
227
+ def __exit__(self, *exc_info: object) -> None:
228
+ self.close()
229
+
230
+ def close(self) -> None:
231
+ self._client.close()
232
+ self._transfer.close()
233
+
234
+ # -- identity ------------------------------------------------------------------------------
235
+
236
+ def whoami(self) -> WhoAmI:
237
+ response = self._client.get("/api/whoami")
238
+ self._raise_for_error(response)
239
+ return WhoAmI.model_validate(response.json())
240
+
241
+ # -- unified runs --------------------------------------------------------------------------
242
+
243
+ def resolve_run_target(self, target_id: str) -> RunTarget:
244
+ """Resolve an opaque platform id without guessing from failed requests."""
245
+ response = self._client.get(f"/api/run-targets/{target_id}")
246
+ self._raise_for_error(response)
247
+ return RunTarget.model_validate(response.json())
248
+
249
+ def create_world_model_session(
250
+ self, world_model_id: str, *, task: str | None = None
251
+ ) -> RemoteWorldModelSession:
252
+ """Open a hosted session for a platform world model."""
253
+ response = self._client.post(
254
+ f"/api/world-models/{world_model_id}/sessions", json={"task": task}
255
+ )
256
+ self._raise_for_error(response)
257
+ return RemoteWorldModelSession.model_validate(response.json())
258
+
259
+ def step_world_model_session(self, session_id: str, action: Action) -> Observation:
260
+ """Advance a hosted world-model session by one action."""
261
+ response = self._client.post(
262
+ f"/api/sessions/{session_id}/step",
263
+ json={"action": action.model_dump(mode="json")},
264
+ )
265
+ self._raise_for_error(response)
266
+ return Observation.model_validate(response.json()["observation"])
267
+
268
+ def create_agent_session(
269
+ self,
270
+ agent_id: str,
271
+ *,
272
+ workspace: bytes | None,
273
+ instruction: str | None = None,
274
+ ) -> RemoteAgentSession:
275
+ """Create a hosted agent session, optionally staging a local snapshot."""
276
+ payload: dict[str, JsonValue] = {"instruction": instruction}
277
+ if workspace is not None:
278
+ upload = self._client.post(
279
+ f"/api/agents/{agent_id}/workspace-uploads",
280
+ files={"workspace": ("workspace.tar.gz", workspace, "application/gzip")},
281
+ timeout=_WORKSPACE_TIMEOUT_SECONDS,
282
+ )
283
+ self._raise_for_error(upload)
284
+ payload["workspace_upload_id"] = str(upload.json()["id"])
285
+ response = self._client.post(
286
+ f"/api/agents/{agent_id}/sessions",
287
+ json=payload,
288
+ )
289
+ self._raise_for_error(response)
290
+ return RemoteAgentSession.model_validate(response.json())
291
+
292
+ def get_agent_session(self, agent_id: str, session_id: str) -> RemoteAgentSession:
293
+ """Read current hosted agent session state."""
294
+ response = self._client.get(f"/api/agents/{agent_id}/sessions/{session_id}")
295
+ self._raise_for_error(response)
296
+ return RemoteAgentSession.model_validate(response.json())
297
+
298
+ def resolve_agent_session(self, session_id: str) -> RemoteAgentSession:
299
+ """Resolve a bare session id to its owning agent and current state."""
300
+ response = self._client.get(f"/api/agent-sessions/{session_id}")
301
+ self._raise_for_error(response)
302
+ return RemoteAgentSession.model_validate(response.json())
303
+
304
+ def end_agent_session(self, agent_id: str, session_id: str) -> RemoteAgentSession:
305
+ """Request an end, reconciling directly when the hosted driver is gone."""
306
+ response = self._client.post(f"/api/agents/{agent_id}/sessions/{session_id}/end")
307
+ self._raise_for_error(response)
308
+ return RemoteAgentSession.model_validate(response.json())
309
+
310
+ def list_agent_session_events(
311
+ self, agent_id: str, session_id: str, *, after: int
312
+ ) -> RemoteAgentEventPage:
313
+ """Poll hosted transcript events after one durable sequence cursor."""
314
+ response = self._client.get(
315
+ f"/api/agents/{agent_id}/sessions/{session_id}/events",
316
+ params={"after": after},
317
+ )
318
+ self._raise_for_error(response)
319
+ return RemoteAgentEventPage.model_validate(response.json())
320
+
321
+ def post_agent_session_command(
322
+ self, agent_id: str, session_id: str, kind: str, *, text: str | None = None
323
+ ) -> None:
324
+ """Steer, interrupt, or end one hosted agent session."""
325
+ response = self._client.post(
326
+ f"/api/agents/{agent_id}/sessions/{session_id}/commands",
327
+ json={"kind": kind, "text": text},
328
+ )
329
+ self._raise_for_error(response)
330
+
331
+ def upload_agent_workspace_patch(
332
+ self, agent_id: str, session_id: str, content: bytes
333
+ ) -> WorkspacePatchResult:
334
+ """Apply local changes conditionally to a running hosted workspace."""
335
+ response = self._client.post(
336
+ f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches",
337
+ files={"patch": ("workspace-patch.tar.gz", content, "application/gzip")},
338
+ timeout=_WORKSPACE_TIMEOUT_SECONDS,
339
+ )
340
+ self._raise_for_error(response)
341
+ return WorkspacePatchResult.model_validate(response.json())
342
+
343
+ def download_agent_workspace_patch(
344
+ self, agent_id: str, session_id: str, revision: str
345
+ ) -> bytes:
346
+ """Download one remote-to-local live workspace patch."""
347
+ response = self._client.get(
348
+ f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches/{revision}",
349
+ timeout=_WORKSPACE_TIMEOUT_SECONDS,
350
+ )
351
+ self._raise_for_error(response)
352
+ return response.content
353
+
354
+ def acknowledge_agent_workspace_patch(
355
+ self, agent_id: str, session_id: str, revision: str
356
+ ) -> None:
357
+ """Remove a remote patch after it is safely reflected or reported locally."""
358
+ response = self._client.post(
359
+ f"/api/agents/{agent_id}/sessions/{session_id}/workspace/patches/{revision}/ack"
360
+ )
361
+ self._raise_for_error(response)
362
+
363
+ def download_agent_workspace(self, agent_id: str, session_id: str) -> bytes:
364
+ """Download a terminal hosted session's final E2B workspace snapshot."""
365
+ response = self._client.get(
366
+ f"/api/agents/{agent_id}/sessions/{session_id}/workspace",
367
+ timeout=_WORKSPACE_TIMEOUT_SECONDS,
368
+ )
369
+ self._raise_for_error(response)
370
+ return response.content
371
+
372
+ def acknowledge_agent_workspace(self, agent_id: str, session_id: str) -> None:
373
+ """Confirm the final archive is safe locally so platform objects can be removed."""
374
+ response = self._client.post(f"/api/agents/{agent_id}/sessions/{session_id}/workspace/ack")
375
+ self._raise_for_error(response)
376
+
377
+ # -- world models --------------------------------------------------------------------------
378
+
379
+ def list_world_models(self, org_id: str) -> list[RemoteWorldModel]:
380
+ response = self._client.get(f"/api/orgs/{org_id}/world-models")
381
+ self._raise_for_error(response)
382
+ rows = response.json().get("world_models", [])
383
+ return [RemoteWorldModel.model_validate(row) for row in rows]
384
+
385
+ def push_model_bundle(
386
+ self,
387
+ org_id: str,
388
+ name: str,
389
+ bundle_path: Path,
390
+ sha256: str,
391
+ byte_size: int,
392
+ meta: dict[str, JsonValue],
393
+ ) -> RemoteWorldModel:
394
+ """Push a packed bundle file: ticket, direct PUT to storage, finalize.
395
+
396
+ The bundle bytes stream from disk straight to the signed staging URL;
397
+ only the finalize declaration (digest + size + serve metadata) goes
398
+ through the API.
399
+ """
400
+ ticket_response = self._client.post(
401
+ f"/api/orgs/{org_id}/world-models/{name}/bundle/uploads"
402
+ )
403
+ self._raise_for_error(ticket_response)
404
+ ticket = ticket_response.json()
405
+ upload_url = str(ticket["upload_url"])
406
+ with bundle_path.open("rb") as fh:
407
+ upload_response = self._transfer.put(
408
+ upload_url,
409
+ content=fh,
410
+ headers={
411
+ "Content-Type": "application/gzip",
412
+ "x-upsert": "false",
413
+ "Authorization": f"Bearer {ticket.get('token', '')}",
414
+ },
415
+ )
416
+ if not upload_response.is_success:
417
+ msg = (
418
+ f"bundle upload to storage failed with HTTP {upload_response.status_code}: "
419
+ f"{upload_response.text[:200]}"
420
+ )
421
+ raise PlatformError(msg, status_code=upload_response.status_code)
422
+
423
+ finalize = self._client.post(
424
+ f"/api/orgs/{org_id}/world-models/{name}/bundle",
425
+ json={
426
+ "staging_path": ticket["staging_path"],
427
+ "sha256": sha256,
428
+ "byte_size": byte_size,
429
+ "meta": meta,
430
+ },
431
+ )
432
+ self._raise_for_error(finalize)
433
+ return RemoteWorldModel.model_validate(finalize.json())
434
+
435
+ def download_model_bundle(self, org_id: str, name: str, dest: Path) -> str:
436
+ """Stream a model's bundle from storage to ``dest``, verifying its digest.
437
+
438
+ The API hands back an expiring signed URL plus the recorded sha256;
439
+ the bytes come straight from storage's CDN and are hashed as they
440
+ stream to disk.
441
+
442
+ Returns:
443
+ The verified sha256 hex digest.
444
+ """
445
+ response = self._client.get(f"/api/orgs/{org_id}/world-models/{name}/bundle")
446
+ self._raise_for_error(response)
447
+ payload = response.json()
448
+ declared = str(payload["sha256"])
449
+
450
+ digest = hashlib.sha256()
451
+ part_path = dest.with_name(f"{dest.name}.part")
452
+ with self._transfer.stream("GET", str(payload["url"])) as stream:
453
+ if not stream.is_success:
454
+ msg = f"bundle download failed with HTTP {stream.status_code}"
455
+ raise PlatformError(msg, status_code=stream.status_code)
456
+ with part_path.open("wb") as fh:
457
+ for chunk in stream.iter_bytes():
458
+ digest.update(chunk)
459
+ fh.write(chunk)
460
+ actual = digest.hexdigest()
461
+ if actual != declared:
462
+ part_path.unlink(missing_ok=True)
463
+ msg = f"bundle digest mismatch for {name}: expected {declared}, got {actual}"
464
+ raise PlatformError(msg)
465
+ part_path.replace(dest)
466
+ return actual
467
+
468
+ # -- harnesses -----------------------------------------------------------------------------
469
+
470
+ def list_harnesses(self, org_id: str) -> list[RemoteHarness]:
471
+ response = self._client.get(f"/api/orgs/{org_id}/harnesses")
472
+ self._raise_for_error(response)
473
+ rows = response.json().get("harnesses", [])
474
+ return [RemoteHarness.model_validate(row) for row in rows]
475
+
476
+ def get_harness(
477
+ self, org_id: str, name: str
478
+ ) -> tuple[RemoteHarness, list[RemoteHarnessVersion]]:
479
+ response = self._client.get(f"/api/orgs/{org_id}/harnesses/{name}")
480
+ self._raise_for_error(response)
481
+ payload = response.json()
482
+ harness = RemoteHarness.model_validate(payload["harness"])
483
+ versions = [RemoteHarnessVersion.model_validate(row) for row in payload["versions"]]
484
+ return harness, versions
485
+
486
+ def get_harness_version(self, org_id: str, name: str, version: int) -> HarnessVersionDoc:
487
+ response = self._client.get(f"/api/orgs/{org_id}/harnesses/{name}/versions/{version}")
488
+ self._raise_for_error(response)
489
+ return HarnessVersionDoc.model_validate(response.json())
490
+
491
+ def push_harness_version(
492
+ self,
493
+ org_id: str,
494
+ name: str,
495
+ doc: dict[str, JsonValue],
496
+ doc_hash: str,
497
+ ) -> PushedHarnessVersion:
498
+ response = self._client.post(
499
+ f"/api/orgs/{org_id}/harnesses/{name}/versions",
500
+ json={"doc": doc, "doc_hash": doc_hash},
501
+ )
502
+ self._raise_for_error(response)
503
+ return PushedHarnessVersion.model_validate(response.json())
504
+
505
+ # -- built-in local pi runs ---------------------------------------------------------------
506
+
507
+ def create_local_pi_run(self, org_id: str) -> LocalPiRunInfo:
508
+ """Open a metered platform run for WMO's built-in local pi harness."""
509
+ response = self._client.post(f"/api/orgs/{org_id}/local-pi-runs")
510
+ self._raise_for_error(response)
511
+ return LocalPiRunInfo.model_validate(response.json())
512
+
513
+ def complete_local_pi_worker(
514
+ self, org_id: str, run_id: str, request: ChatRequest
515
+ ) -> ChatResponse:
516
+ """Answer one built-in pi worker turn through the platform."""
517
+ response = self._client.post(
518
+ f"/api/orgs/{org_id}/local-pi-runs/{run_id}/worker-completion",
519
+ json=request.model_dump(mode="json", exclude_none=True),
520
+ )
521
+ self._raise_for_error(response)
522
+ return ChatResponse.model_validate(response.json())
523
+
524
+ def finish_local_pi_run(
525
+ self,
526
+ org_id: str,
527
+ run_id: str,
528
+ *,
529
+ status: str,
530
+ ended_reason: str,
531
+ error: str | None = None,
532
+ ) -> None:
533
+ """Report the terminal transition of a built-in local pi run."""
534
+ response = self._client.post(
535
+ f"/api/orgs/{org_id}/local-pi-runs/{run_id}/finish",
536
+ json={"status": status, "ended_reason": ended_reason, "error": error},
537
+ )
538
+ self._raise_for_error(response)
539
+
540
+ # -- internals -----------------------------------------------------------------------------
541
+
542
+ def _raise_for_error(self, response: httpx.Response) -> None:
543
+ if response.is_success:
544
+ return
545
+ try:
546
+ message = response.json().get("error", response.text)
547
+ except (json.JSONDecodeError, ValueError):
548
+ message = response.text or f"HTTP {response.status_code}"
549
+ if response.status_code == 401:
550
+ message = f"{message} — run `wmo login` (or check WMO_PLATFORM_TOKEN)"
551
+ raise PlatformError(str(message), status_code=response.status_code)
@@ -0,0 +1,126 @@
1
+ """Platform login credentials, stored once per user.
2
+
3
+ Unlike everything else in wmo (project-local under `./.wmo/`), the platform credential is
4
+ user-global: `~/.wmo/credentials.toml`, directory overridable via `$WMO_HOME`. Environment
5
+ variables override the file so CI and headless runs never need one written to disk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ import tomllib
13
+ from pathlib import Path
14
+
15
+ import tomli_w
16
+ from pydantic import BaseModel
17
+
18
+ ENV_HOME = "WMO_HOME"
19
+ ENV_WEB_URL = "WMO_PLATFORM_URL"
20
+ ENV_API_URL = "WMO_PLATFORM_API_URL"
21
+ ENV_TOKEN = "WMO_PLATFORM_TOKEN"
22
+ ENV_ORG = "WMO_PLATFORM_ORG"
23
+
24
+ CREDENTIALS_FILENAME = "credentials.toml"
25
+
26
+ # The hosted platform a bare `wmo login` connects to; `--url` (previews,
27
+ # self-hosted, the local stack) and saved credentials both take precedence.
28
+ DEFAULT_WEB_URL = "https://platform.experientiallabs.ai"
29
+
30
+
31
+ class PlatformCredentials(BaseModel):
32
+ """The saved connection: where the platform lives and which key acts for us."""
33
+
34
+ web_url: str | None = None # the browser-facing app (login page, keys page)
35
+ api_url: str | None = None # the backend host requests go to
36
+ token: str | None = None # org API key (xpl_…)
37
+ default_org: str | None = None # organization id used when --org is omitted
38
+
39
+ def is_complete(self) -> bool:
40
+ """Whether requests can be made without further configuration."""
41
+ return bool(self.api_url and self.token)
42
+
43
+
44
+ def wmo_home() -> Path:
45
+ """The user-global wmo directory (`$WMO_HOME` or `~/.wmo`)."""
46
+ override = os.environ.get(ENV_HOME)
47
+ return Path(override) if override else Path.home() / ".wmo"
48
+
49
+
50
+ def credentials_path() -> Path:
51
+ """Where the credential file lives."""
52
+ return wmo_home() / CREDENTIALS_FILENAME
53
+
54
+
55
+ def load_credentials() -> PlatformCredentials:
56
+ """Read the credential file, then apply environment overrides.
57
+
58
+ Env alone is sufficient (no file needed); a set-but-empty env var is
59
+ treated as unset rather than clearing a file value.
60
+ """
61
+ data: dict[str, str] = {}
62
+ path = credentials_path()
63
+ if path.exists():
64
+ section = tomllib.loads(path.read_text(encoding="utf-8")).get("platform", {})
65
+ data = {key: value for key, value in section.items() if isinstance(value, str)}
66
+ # Files written before the platform's org-only change carry the old
67
+ # default_project key. A project id is NOT an org id, so carrying the
68
+ # value over would send a guaranteed-miss id to /api/orgs/{org_id}/...;
69
+ # discard it instead, so the user gets the clear "pass --org" prompt
70
+ # (or the sole-org auto-pick at the next login) rather than a
71
+ # confusing org-not-found. The stale key drops on the next save.
72
+ data.pop("default_project", None)
73
+ credentials = PlatformCredentials.model_validate(data)
74
+ overrides = {
75
+ "web_url": os.environ.get(ENV_WEB_URL),
76
+ "api_url": os.environ.get(ENV_API_URL),
77
+ "token": os.environ.get(ENV_TOKEN),
78
+ "default_org": os.environ.get(ENV_ORG),
79
+ }
80
+ updates = {key: value for key, value in overrides.items() if value}
81
+ return credentials.model_copy(update=updates) if updates else credentials
82
+
83
+
84
+ def save_credentials(credentials: PlatformCredentials) -> Path:
85
+ """Persist the credential file with owner-only permissions.
86
+
87
+ Mirrors the dotenv writer: refuses symlinks (a credential rewrite must never
88
+ land in whatever a link points at), writes through a 0600 mkstemp, and swaps
89
+ into place atomically.
90
+
91
+ Raises:
92
+ ValueError: If the target path is a symlink.
93
+ """
94
+ path = credentials_path()
95
+ if path.is_symlink():
96
+ msg = (
97
+ f"refusing to write credentials through the symlink {path}; "
98
+ "remove the link or point $WMO_HOME elsewhere"
99
+ )
100
+ raise ValueError(msg)
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ payload = {
103
+ "platform": {
104
+ key: value
105
+ for key, value in credentials.model_dump(mode="json").items()
106
+ if value is not None
107
+ }
108
+ }
109
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.")
110
+ try:
111
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
112
+ fh.write(tomli_w.dumps(payload))
113
+ os.replace(tmp_name, path)
114
+ except BaseException:
115
+ os.unlink(tmp_name)
116
+ raise
117
+ return path
118
+
119
+
120
+ def clear_credentials() -> bool:
121
+ """Delete the credential file; returns whether one existed."""
122
+ path = credentials_path()
123
+ if not path.exists():
124
+ return False
125
+ path.unlink()
126
+ return True