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,543 @@
1
+ # Copyright (c) 2026 Experiential Labs. All rights reserved.
2
+
3
+ """Interactive live sessions: the host engine that drives one long-lived pi runner.
4
+
5
+ A *session* differs from an *episode* (`runner_link.RunnerLink`) in three ways: it is multi-turn
6
+ (the user sends messages, steers a running turn, and interrupts, over one persistent transcript),
7
+ its tools execute for REAL against a live filesystem/shell rather than being answered by a
8
+ world-model simulation, and it emits a stream of typed events (assistant text, tool calls, tool
9
+ output, state changes) that a UI renders live. Everything else is the RunnerLink model unchanged:
10
+ the worker LLM is answered host-side so credentials never reach the runner, and the wire is the
11
+ same length-prefixed / base64-line frame `Channel`.
12
+
13
+ The engine is transport- and platform-agnostic. It answers `llm_request` frames host-side via a
14
+ fully-configured `ToolCallingProvider`'s `complete_chat` (Bedrock or Azure — provider-agnostic per
15
+ wmo #142) or an injected `worker_fn` (tests), and answers `tool_request` frames with an injected
16
+ `ToolExecutor` (the platform runs bash/read_file/write_file against the session's E2B sandbox
17
+ filesystem; a CLI would run them against a local directory). Because the host answers every
18
+ `llm_request` and every `tool_request`, it *is* the trusted observer of what the agent did — the
19
+ feed is derived from the frames the host itself produced/answered, so a compromised runner cannot
20
+ narrate a different story than the actions it actually took.
21
+
22
+ New session frames (additive to the RunnerLink vocabulary; unknown types are ignored on both
23
+ sides, so eval episodes are unaffected):
24
+
25
+ host -> runner
26
+ session_start {session_id, system, tools, files, turn_cap, max_output_tokens, temperature,
27
+ conversation_scope}
28
+ user_message {msg_id, text}
29
+ abort {reason}
30
+ ping {nonce}
31
+ shutdown (reused; ends the runner process)
32
+ runner -> host
33
+ hello {...} (reused)
34
+ llm_request {req_id, openai_body} (reused)
35
+ tool_request {req_id, name, arguments} (reused)
36
+ state {status: "idle"|"running", turns, reason?, cleared_steers?, msg_id?}
37
+ pong {nonce}
38
+ episode_error {note} (reused; a fatal runner error ends the session)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import queue
44
+ import uuid
45
+ from collections.abc import Callable
46
+ from dataclasses import dataclass, field
47
+ from typing import Any, Literal, cast
48
+
49
+ from llm_waterfall import ChatRequest, ChatResponse
50
+ from pydantic import JsonValue
51
+
52
+ from wmo.core.types import JsonObject
53
+ from wmo.harness.runner_link import Channel, TokenUsage, WorkerFn, params_schema
54
+ from wmo.harness.runtime import DEFAULT_MAX_OUTPUT_TOKENS
55
+ from wmo.harness.tools import READ_SKILL, SUBMIT, ToolSpec
56
+ from wmo.providers.base import ToolCallingProvider
57
+
58
+ # The action budget a single user turn may spend before the runner is told to stop — the live
59
+ # analogue of `HostEpisode.max_env_actions`, so a champion optimized under that pressure behaves
60
+ # the same way. It resets on every user message.
61
+ DEFAULT_ACTIONS_PER_TURN = 40
62
+ # Turns a single user message may run before the runner aborts it (distinct from a user interrupt).
63
+ # 3x the doc default (20) — real tasks against a live filesystem legitimately run longer than the
64
+ # short world-model-simulation tasks the harness was scored on.
65
+ DEFAULT_TURN_CAP = 60
66
+
67
+ # --------------------------------------------------------------------------------------------------
68
+ # Events: the typed stream the engine emits; a UI renders these, a store persists them.
69
+ # --------------------------------------------------------------------------------------------------
70
+ EventKind = Literal[
71
+ "user_message",
72
+ "assistant_message",
73
+ "tool_call",
74
+ "tool_output",
75
+ "tool_result",
76
+ "submit",
77
+ "state",
78
+ "error",
79
+ ]
80
+ ConversationScope = Literal["session", "turn"]
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class SessionEvent:
85
+ """One thing that happened in a session, in the order the host observed it."""
86
+
87
+ kind: EventKind
88
+ payload: JsonObject
89
+
90
+
91
+ EventSink = Callable[[SessionEvent], None]
92
+
93
+ # (name, arguments, emit_output) -> (content, is_error). `emit_output(stream, chunk)` streams
94
+ # partial stdout/stderr so the UI shows a long command's output as it runs; the returned content is
95
+ # the final (already length-capped by the executor) observation the agent sees.
96
+ OutputEmitter = Callable[[str, str], None]
97
+ ToolExecutor = Callable[[str, JsonObject, OutputEmitter], "ToolOutcome"]
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ToolOutcome:
102
+ """The result of executing one real tool call: what the agent sees, and whether it failed."""
103
+
104
+ content: str
105
+ is_error: bool = False
106
+ truncated: bool = False
107
+
108
+
109
+ # --------------------------------------------------------------------------------------------------
110
+ # Inbox: user intents the driver feeds in from another thread, drained into frames on each pump.
111
+ # --------------------------------------------------------------------------------------------------
112
+ @dataclass
113
+ class _UserMessage:
114
+ text: str
115
+ msg_id: str
116
+
117
+
118
+ @dataclass
119
+ class _Interrupt:
120
+ reason: str = "user_interrupt"
121
+
122
+
123
+ @dataclass
124
+ class _End:
125
+ pass
126
+
127
+
128
+ _Intent = _UserMessage | _Interrupt | _End
129
+
130
+
131
+ class LiveSession:
132
+ """Drives one interactive pi session over a `Channel`, emitting a typed event stream.
133
+
134
+ Lifecycle: `start()` sends `session_start` and waits for the runner's first `state:idle`; then
135
+ the driver loops calling `pump(timeout)` while feeding intents via `send_user_message`,
136
+ `interrupt`, and `end` (thread-safe — a driver may enqueue from a command-poll thread). `pump`
137
+ drains the inbox to frames, then processes one inbound frame (answering `llm_request` /
138
+ `tool_request`, emitting events). `closed` flips once the runner process ends or `end()` is
139
+ honored. `worker_usage` accumulates the metered worker-LLM spend across the whole session.
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ channel: Channel,
145
+ *,
146
+ tools: list[ToolSpec],
147
+ execute_tool: ToolExecutor,
148
+ on_event: EventSink,
149
+ files: dict[str, str] | None = None,
150
+ system_prompt: str = "",
151
+ skill_bodies: dict[str, str] | None = None,
152
+ provider: ToolCallingProvider | None = None,
153
+ worker_fn: WorkerFn | None = None,
154
+ actions_per_turn: int = DEFAULT_ACTIONS_PER_TURN,
155
+ turn_cap: int = DEFAULT_TURN_CAP,
156
+ max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
157
+ temperature: float = 0.7,
158
+ conversation_scope: ConversationScope = "session",
159
+ ) -> None:
160
+ self._channel = channel
161
+ self._tools = list(tools)
162
+ self._execute_tool = execute_tool
163
+ self._on_event = on_event
164
+ self._files = dict(files or {})
165
+ self._system_prompt = system_prompt
166
+ self._skill_bodies = dict(skill_bodies or {})
167
+ if self._skill_bodies and READ_SKILL.name not in {tool.name for tool in self._tools}:
168
+ self._tools.append(READ_SKILL)
169
+ # The worker LLM is answered host-side by a fully-configured provider's
170
+ # `complete_chat` (Bedrock or Azure — provider-agnostic per #142), or an
171
+ # injected `worker_fn` (tests). Left unset, an `llm_request` is answered
172
+ # with an error instead of crashing the host.
173
+ if worker_fn is not None:
174
+ self._worker_fn: WorkerFn | None = worker_fn
175
+ elif provider is not None:
176
+ self._worker_fn = provider.complete_chat
177
+ else:
178
+ self._worker_fn = None
179
+ self._actions_per_turn = actions_per_turn
180
+ self._turn_cap = turn_cap
181
+ if max_output_tokens < 1:
182
+ raise ValueError("max_output_tokens must be >= 1")
183
+ if not 0.0 <= temperature <= 2.0:
184
+ raise ValueError("temperature must be in [0, 2]")
185
+ if conversation_scope not in ("session", "turn"):
186
+ raise ValueError("conversation_scope must be 'session' or 'turn'")
187
+ self._max_output_tokens = max_output_tokens
188
+ self._temperature = temperature
189
+ self._conversation_scope = conversation_scope
190
+
191
+ self._inbox: queue.Queue[_Intent] = queue.Queue()
192
+ self._session_id = uuid.uuid4().hex
193
+ self._status: str = "starting"
194
+ self._closed = False
195
+ self._failure_message: str | None = None
196
+ self._actions_this_turn = 0
197
+ self._aborting = False
198
+ self._pending_ping: str | None = None
199
+ self.worker_usage = TokenUsage()
200
+
201
+ # -- lifecycle -------------------------------------------------------------------------------
202
+
203
+ @property
204
+ def status(self) -> str:
205
+ """The runner's last-known agent state: "starting" | "idle" | "running" | "ended"."""
206
+ return self._status
207
+
208
+ @property
209
+ def closed(self) -> bool:
210
+ return self._closed
211
+
212
+ @property
213
+ def failure_message(self) -> str | None:
214
+ """Return the runner or channel error that closed this session, when one exists."""
215
+ return self._failure_message
216
+
217
+ def start(self, hello_timeout: float = 60.0) -> None:
218
+ """Send `session_start` and block until the runner reports its first idle state."""
219
+ self._channel.send(
220
+ {
221
+ "type": "session_start",
222
+ "session_id": self._session_id,
223
+ "system": self._system_prompt,
224
+ "tools": self._tool_specs(),
225
+ "files": self._files,
226
+ "turn_cap": self._turn_cap,
227
+ "max_output_tokens": self._max_output_tokens,
228
+ "temperature": self._temperature,
229
+ "conversation_scope": self._conversation_scope,
230
+ }
231
+ )
232
+ # The runner sends `state:idle` once the agent is constructed and ready for the first
233
+ # message; surface a fatal construction error (bad champion code) instead of hanging.
234
+ deadline = _Deadline(hello_timeout)
235
+ while not deadline.expired():
236
+ frame = self._recv(deadline.remaining())
237
+ if frame is None:
238
+ break
239
+ if self._handle_frame(frame):
240
+ if self._status in ("idle", "running"):
241
+ return
242
+ if self._closed:
243
+ break
244
+ if self._failure_message is not None:
245
+ raise RuntimeError(f"live session runner did not become ready: {self._failure_message}")
246
+ raise RuntimeError("live session runner did not become ready")
247
+
248
+ # -- driver-facing intents (thread-safe) -----------------------------------------------------
249
+
250
+ def send_user_message(self, text: str) -> str:
251
+ """Queue a user message; returns the msg_id echoed on its `user_message` event."""
252
+ msg_id = uuid.uuid4().hex
253
+ self._inbox.put(_UserMessage(text=text, msg_id=msg_id))
254
+ return msg_id
255
+
256
+ def interrupt(self, reason: str = "user_interrupt") -> None:
257
+ """Queue an interrupt: abort the current run (does not end the session)."""
258
+ self._inbox.put(_Interrupt(reason=reason))
259
+
260
+ def flush_pending_intents(self) -> None:
261
+ """Send queued controls without consuming another runner frame.
262
+
263
+ The driver uses this when it must abort and retire a session immediately. Calling
264
+ :meth:`pump` would also read one inbound frame, which could start another synchronous
265
+ provider or tool operation after cancellation was already observed.
266
+ """
267
+ if not self._closed:
268
+ self._drain_inbox()
269
+
270
+ def end(self) -> None:
271
+ """Queue a graceful end: abort any run, then shut the runner down."""
272
+ self._inbox.put(_End())
273
+
274
+ def ping(self) -> None:
275
+ """Send a liveness ping; a returned `pong` proves the runner event loop is alive."""
276
+ nonce = uuid.uuid4().hex
277
+ self._pending_ping = nonce
278
+ self._safe_send({"type": "ping", "nonce": nonce})
279
+
280
+ # -- pump ------------------------------------------------------------------------------------
281
+
282
+ def pump(self, timeout: float = 0.2) -> bool:
283
+ """Drain queued intents to frames, then process one inbound frame. False once closed."""
284
+ if self._closed:
285
+ return False
286
+ self._drain_inbox()
287
+ if self._closed:
288
+ return False
289
+ frame = self._recv(timeout)
290
+ if frame is None:
291
+ return not self._closed
292
+ self._handle_frame(frame)
293
+ return not self._closed
294
+
295
+ # -- internals -------------------------------------------------------------------------------
296
+
297
+ def _drain_inbox(self) -> None:
298
+ while True:
299
+ try:
300
+ intent = self._inbox.get_nowait()
301
+ except queue.Empty:
302
+ return
303
+ if isinstance(intent, _UserMessage):
304
+ self._actions_this_turn = 0
305
+ # NB: do not clear `_aborting` here — a new message can be drained before
306
+ # the cancelled turn's in-flight submit frame is read, and clearing now
307
+ # would let that stale submit emit. The runner's next `state` frame (the
308
+ # real turn boundary) clears it in `_on_state`.
309
+ self._emit("user_message", {"msg_id": intent.msg_id, "text": intent.text})
310
+ self._safe_send(
311
+ {"type": "user_message", "msg_id": intent.msg_id, "text": intent.text}
312
+ )
313
+ elif isinstance(intent, _Interrupt):
314
+ # Mark the current turn as aborting so a `submit` tool_request that was
315
+ # already in flight when the interrupt fired does not emit a final submit
316
+ # event: the host, not the runner, owns event emission, so this gate is
317
+ # the only place the race can be closed. Cleared at the next turn boundary.
318
+ self._aborting = True
319
+ self._safe_send({"type": "abort", "reason": intent.reason})
320
+ else: # _End
321
+ self._safe_send({"type": "abort", "reason": "shutdown"})
322
+ self._safe_send({"type": "shutdown"})
323
+ self._mark_closed("ended")
324
+
325
+ def _handle_frame(self, frame: JsonObject) -> bool:
326
+ kind = frame.get("type")
327
+ if kind == "llm_request":
328
+ self._answer_llm(frame)
329
+ elif kind == "tool_request":
330
+ self._answer_tool(frame)
331
+ elif kind == "state":
332
+ self._on_state(frame)
333
+ elif kind == "pong":
334
+ if frame.get("nonce") == self._pending_ping:
335
+ self._pending_ping = None
336
+ elif kind == "episode_error":
337
+ note = frame.get("note")
338
+ self._failure_message = note if isinstance(note, str) else "runner error"
339
+ self._emit("error", {"message": self._failure_message})
340
+ self._mark_closed("failed")
341
+ elif kind == "hello":
342
+ pass # the pool already consumed the handshake; a duplicate is harmless
343
+ # unknown frames are ignored (forward-compatible)
344
+ return kind is not None
345
+
346
+ def _answer_llm(self, frame: JsonObject) -> None:
347
+ req_id = frame.get("req_id")
348
+ body = frame.get("openai_body")
349
+ if self._worker_fn is None:
350
+ self._emit("error", {"message": "live session has no worker configured"})
351
+ self._safe_send(
352
+ {"type": "llm_response", "req_id": req_id, "error": "no worker configured"}
353
+ )
354
+ return
355
+ try:
356
+ request_body = dict(body) if isinstance(body, dict) else {}
357
+ request_body["temperature"] = self._temperature
358
+ request = ChatRequest.model_validate(request_body)
359
+ completion = self._worker_fn(request)
360
+ self._meter(completion)
361
+ self._emit_assistant(completion)
362
+ self._safe_send(
363
+ {"type": "llm_response", "req_id": req_id, "completion": completion.wire_payload()}
364
+ )
365
+ except Exception as exc: # noqa: BLE001 - report to the runner, never crash the host
366
+ self._emit("error", {"message": f"worker LLM error: {exc}"})
367
+ self._safe_send({"type": "llm_response", "req_id": req_id, "error": str(exc)})
368
+
369
+ def _answer_tool(self, frame: JsonObject) -> None:
370
+ req_id = frame.get("req_id")
371
+ name = frame.get("name")
372
+ name = name if isinstance(name, str) else ""
373
+ args = frame.get("arguments")
374
+ args = cast("JsonObject", args) if isinstance(args, dict) else {}
375
+ call_id = uuid.uuid4().hex
376
+
377
+ if name == SUBMIT.name:
378
+ # If the turn is being interrupted, do NOT emit a final submit for it —
379
+ # the answer belongs to a cancelled run. Still respond so the runner's
380
+ # submit tool does not hang; the aborted run ends via `state:idle`.
381
+ if not self._aborting:
382
+ answer = args.get("answer")
383
+ self._emit("submit", {"answer": answer if isinstance(answer, str) else ""})
384
+ self._respond_tool(req_id, "submitted", is_error=False)
385
+ return
386
+
387
+ if self._aborting:
388
+ # The turn is being interrupted; do NOT run a real side-effecting tool
389
+ # (bash / write_file) for a cancelled run. Respond so the runner's tool
390
+ # call does not hang; the aborted run ends via `state:idle`.
391
+ self._respond_tool(req_id, "interrupted", is_error=True)
392
+ return
393
+
394
+ self._emit("tool_call", {"call_id": call_id, "name": name, "arguments": args})
395
+ known = {t.name for t in self._tools}
396
+ if name not in known:
397
+ outcome = ToolOutcome(content=f"tool {name!r} not available", is_error=True)
398
+ elif name == READ_SKILL.name:
399
+ outcome = self._read_skill(args)
400
+ elif self._actions_this_turn >= self._actions_per_turn:
401
+ outcome = ToolOutcome(content="environment action budget exhausted", is_error=True)
402
+ else:
403
+ self._actions_this_turn += 1
404
+
405
+ def emit_output(stream: str, chunk: str) -> None:
406
+ if chunk:
407
+ self._emit("tool_output", {"call_id": call_id, "stream": stream, "text": chunk})
408
+
409
+ try:
410
+ outcome = self._execute_tool(name, args, emit_output)
411
+ except Exception as exc: # noqa: BLE001 - a tool failure must not crash the host loop
412
+ # A transient sandbox/FS error becomes an error result, so the runner
413
+ # always gets a tool_response and pump() keeps running.
414
+ outcome = ToolOutcome(content=f"tool {name!r} failed: {exc}", is_error=True)
415
+ self._emit(
416
+ "tool_result",
417
+ {
418
+ "call_id": call_id,
419
+ "content": outcome.content,
420
+ "is_error": outcome.is_error,
421
+ "truncated": outcome.truncated,
422
+ },
423
+ )
424
+ self._respond_tool(req_id, outcome.content, is_error=outcome.is_error)
425
+
426
+ def _read_skill(self, args: JsonObject) -> ToolOutcome:
427
+ raw_name = args.get("name")
428
+ name = raw_name if isinstance(raw_name, str) else ""
429
+ body = self._skill_bodies.get(name)
430
+ if body is None:
431
+ return ToolOutcome(content=f"no skill named {name!r}", is_error=True)
432
+ return ToolOutcome(content=body)
433
+
434
+ def _respond_tool(self, req_id: JsonValue, content: str, *, is_error: bool) -> None:
435
+ self._safe_send(
436
+ {"type": "tool_response", "req_id": req_id, "content": content, "is_error": is_error}
437
+ )
438
+
439
+ def _on_state(self, frame: JsonObject) -> None:
440
+ status = frame.get("status")
441
+ if isinstance(status, str):
442
+ self._status = status
443
+ # Only the terminal `idle` frame is the cancelled turn's true boundary. The
444
+ # runner emits `state:running` at each prompt start; clearing on that (or any
445
+ # non-idle frame) could re-enable submit emission while the turn is still
446
+ # aborting, letting a stale in-flight `submit` surface as a final answer.
447
+ if self._status == "idle":
448
+ self._aborting = False
449
+ payload: JsonObject = {"status": self._status}
450
+ for key in ("turns", "reason", "msg_id"):
451
+ if key in frame:
452
+ payload[key] = frame[key]
453
+ cleared = frame.get("cleared_steers")
454
+ if isinstance(cleared, list) and cleared:
455
+ payload["cleared_steers"] = cleared
456
+ self._emit("state", payload)
457
+
458
+ def _emit_assistant(self, completion: ChatResponse) -> None:
459
+ if not completion.choices:
460
+ return
461
+ text = completion.choices[0].message.content
462
+ if isinstance(text, str) and text.strip():
463
+ self._emit("assistant_message", {"text": text})
464
+
465
+ def _meter(self, completion: ChatResponse) -> None:
466
+ self.worker_usage.calls += 1
467
+ reported = completion.token_usage()
468
+ self.worker_usage.input_tokens += reported.input_tokens
469
+ self.worker_usage.output_tokens += reported.output_tokens
470
+
471
+ def _tool_specs(self) -> list[JsonObject]:
472
+ return [
473
+ {"name": t.name, "description": t.description, "parameters": params_schema(t)}
474
+ for t in self._tools
475
+ ]
476
+
477
+ def _emit(self, kind: EventKind, payload: JsonObject) -> None:
478
+ try:
479
+ self._on_event(SessionEvent(kind=kind, payload=payload))
480
+ except Exception: # noqa: BLE001 - a sink error must never stop the session loop
481
+ pass
482
+
483
+ def _recv(self, timeout: float | None) -> JsonObject | None:
484
+ try:
485
+ frame = _recv_with_timeout(self._channel, timeout)
486
+ except TimeoutError:
487
+ return None
488
+ except Exception as exc: # noqa: BLE001 - a dead runner ends the session, not the process
489
+ if not self._closed:
490
+ self._failure_message = str(exc)
491
+ self._emit("error", {"message": self._failure_message})
492
+ self._mark_closed("failed")
493
+ return None
494
+ if frame is None and not self._closed:
495
+ self._mark_closed("ended")
496
+ return frame
497
+
498
+ def _safe_send(self, frame: JsonObject) -> None:
499
+ if self._closed:
500
+ return
501
+ try:
502
+ self._channel.send(frame)
503
+ except Exception as exc: # noqa: BLE001 - a broken channel ends the session cleanly
504
+ self._failure_message = f"channel send failed: {exc}"
505
+ self._emit("error", {"message": self._failure_message})
506
+ self._mark_closed("failed")
507
+
508
+ def _mark_closed(self, status: str) -> None:
509
+ self._closed = True
510
+ self._status = status
511
+
512
+
513
+ def _recv_with_timeout(channel: Channel, timeout: float | None) -> JsonObject | None:
514
+ """Call `channel.recv`, passing `timeout` when the channel supports it (E2BStdioChannel does).
515
+
516
+ The bare `Channel` protocol has a no-arg `recv`; the E2B channel accepts an optional timeout so
517
+ the pump can wake to flush the inbox even while the runner is thinking. A channel without the
518
+ parameter blocks — acceptable for in-process test doubles that always have a frame ready.
519
+ """
520
+ recv: Any = channel.recv
521
+ try:
522
+ return cast("JsonObject | None", recv(timeout))
523
+ except TypeError:
524
+ return cast("JsonObject | None", recv())
525
+
526
+
527
+ @dataclass
528
+ class _Deadline:
529
+ seconds: float
530
+ _remaining: float = field(init=False)
531
+
532
+ def __post_init__(self) -> None:
533
+ import time
534
+
535
+ self._end = time.monotonic() + self.seconds
536
+
537
+ def remaining(self) -> float:
538
+ import time
539
+
540
+ return max(0.0, self._end - time.monotonic())
541
+
542
+ def expired(self) -> bool:
543
+ return self.remaining() <= 0.0