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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- 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
|