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,880 @@
|
|
|
1
|
+
# Copyright (c) 2026 Experiential Labs. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Detached lifecycle and workspace transport for hosted E2B agent sessions.
|
|
4
|
+
|
|
5
|
+
``wmo run <agent-id> --detach`` starts a normal platform-owned agent session
|
|
6
|
+
and returns immediately, remembering it as the current session in WMO user
|
|
7
|
+
state. Later ``wmo run --send/--attach/--end`` invocations address that
|
|
8
|
+
session (or any accessible session via ``--session <id>``) through the same
|
|
9
|
+
authenticated platform protocol the web app uses: durable transcript polling,
|
|
10
|
+
queued commands, live workspace patches, and the final workspace handoff.
|
|
11
|
+
|
|
12
|
+
No local process runs between invocations, so a persisted checkpoint (the
|
|
13
|
+
transcript cursor plus the last synchronized workspace snapshot) lets the next
|
|
14
|
+
command catch up on hosted workspace patches and upload local edits before
|
|
15
|
+
proceeding. Sessions remain ordinary platform records: they stay visible and
|
|
16
|
+
controllable from the web application throughout.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Literal
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from wmo.cli.session_state import (
|
|
34
|
+
DetachedSessionState,
|
|
35
|
+
SessionStateError,
|
|
36
|
+
SessionStateStore,
|
|
37
|
+
WorkspaceCheckpoint,
|
|
38
|
+
)
|
|
39
|
+
from wmo.cli.workspace_sync import (
|
|
40
|
+
WorkspaceSnapshot,
|
|
41
|
+
WorkspaceSyncError,
|
|
42
|
+
advance_snapshot_paths,
|
|
43
|
+
apply_patch_to_snapshot,
|
|
44
|
+
apply_workspace_patch,
|
|
45
|
+
snapshot_from_archive,
|
|
46
|
+
snapshot_workspace,
|
|
47
|
+
sync_workspace,
|
|
48
|
+
write_conflict_archive,
|
|
49
|
+
)
|
|
50
|
+
from wmo.harness.live_session import SessionEvent
|
|
51
|
+
from wmo.harness.workspace_patch import WorkspacePatchError, build_workspace_patch
|
|
52
|
+
from wmo.platform.client import PlatformClient, PlatformError
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from collections.abc import Callable, Iterable
|
|
56
|
+
|
|
57
|
+
from wmo.cli.workspace_sync import SyncResult
|
|
58
|
+
from wmo.platform.client import (
|
|
59
|
+
RemoteAgentEventPage,
|
|
60
|
+
RemoteAgentSession,
|
|
61
|
+
RemoteAgentSessionEvent,
|
|
62
|
+
)
|
|
63
|
+
from wmo.platform.credentials import PlatformCredentials
|
|
64
|
+
|
|
65
|
+
_console = Console()
|
|
66
|
+
|
|
67
|
+
TERMINAL_STATUSES = frozenset({"ended", "failed"})
|
|
68
|
+
SessionAction = Literal["send", "attach", "end"]
|
|
69
|
+
|
|
70
|
+
_POLL_INTERVAL_S = 0.5
|
|
71
|
+
_WORKSPACE_SYNC_TICK_S = 1.0
|
|
72
|
+
# A session whose driver died keeps answering `running` on the events poll;
|
|
73
|
+
# the detail read reconciles it server-side, so probe it occasionally.
|
|
74
|
+
_STALE_PROBE_S = 30.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def patch_revision(event: RemoteAgentSessionEvent) -> str:
|
|
78
|
+
"""Extract the patch revision announced by one ``workspace_patch`` event."""
|
|
79
|
+
revision = event.payload.get("revision")
|
|
80
|
+
if not isinstance(revision, str) or not revision:
|
|
81
|
+
raise WorkspaceSyncError("workspace patch event has no revision")
|
|
82
|
+
return revision
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LiveWorkspace:
|
|
86
|
+
"""Bidirectional file transport between one local root and a hosted session."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
client: PlatformClient,
|
|
91
|
+
agent_id: str,
|
|
92
|
+
session_id: str,
|
|
93
|
+
root: Path,
|
|
94
|
+
synchronized: WorkspaceSnapshot,
|
|
95
|
+
conflicts: Iterable[str] = (),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Bind the transport to its synchronized base snapshot and known conflicts."""
|
|
98
|
+
self._client = client
|
|
99
|
+
self._agent_id = agent_id
|
|
100
|
+
self._session_id = session_id
|
|
101
|
+
self.root = root
|
|
102
|
+
self.synchronized = synchronized
|
|
103
|
+
self.conflicts: set[str] = set(conflicts)
|
|
104
|
+
|
|
105
|
+
def apply_remote_patch(
|
|
106
|
+
self, revision: str, *, before_ack: Callable[[], None] | None = None
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Download and apply one announced E2B patch, then advance the local base.
|
|
109
|
+
|
|
110
|
+
``before_ack`` runs after the local base advanced but before the patch
|
|
111
|
+
is acknowledged: a detached checkpoint persisted there guarantees an
|
|
112
|
+
acknowledged (hence deleted) patch is never needed again.
|
|
113
|
+
"""
|
|
114
|
+
content = self._client.download_agent_workspace_patch(
|
|
115
|
+
self._agent_id, self._session_id, revision
|
|
116
|
+
)
|
|
117
|
+
result = apply_workspace_patch(self.root, content)
|
|
118
|
+
new_conflicts = self._record_conflicts(result.conflicts)
|
|
119
|
+
# Advance the base by base+patch, never by re-reading the directory:
|
|
120
|
+
# a disk snapshot here would absorb not-yet-uploaded local edits into
|
|
121
|
+
# the base, so they would never upload (and the final sync could even
|
|
122
|
+
# delete them). Conflicted paths stay at their base state.
|
|
123
|
+
self.synchronized = apply_patch_to_snapshot(
|
|
124
|
+
self.synchronized, content, conflicts=result.conflicts
|
|
125
|
+
)
|
|
126
|
+
if before_ack is not None:
|
|
127
|
+
before_ack()
|
|
128
|
+
self._client.acknowledge_agent_workspace_patch(self._agent_id, self._session_id, revision)
|
|
129
|
+
if result.applied:
|
|
130
|
+
_console.print(f"[dim]workspace updated ({len(result.applied)} changed paths)[/dim]")
|
|
131
|
+
if new_conflicts:
|
|
132
|
+
paths = ", ".join(new_conflicts)
|
|
133
|
+
_console.print(f"[yellow]workspace sync conflict[/yellow]: {paths}")
|
|
134
|
+
|
|
135
|
+
def push_local(self) -> bool:
|
|
136
|
+
"""Send local edits made since the last synchronized snapshot.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Whether the synchronized base advanced (all changes were accepted).
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
current = snapshot_workspace(self.root)
|
|
143
|
+
except WorkspaceSyncError:
|
|
144
|
+
return False
|
|
145
|
+
content = build_workspace_patch(self.synchronized.archive, current.archive)
|
|
146
|
+
if content is None:
|
|
147
|
+
return False
|
|
148
|
+
result = self._client.upload_agent_workspace_patch(
|
|
149
|
+
self._agent_id, self._session_id, content
|
|
150
|
+
)
|
|
151
|
+
new_conflicts = self._record_conflicts(result.conflicts)
|
|
152
|
+
if new_conflicts:
|
|
153
|
+
paths = ", ".join(new_conflicts)
|
|
154
|
+
_console.print(f"[yellow]workspace sync conflict[/yellow]: {paths}")
|
|
155
|
+
if result.conflicts:
|
|
156
|
+
# A conflicted path was rejected by E2B, so ``current`` cannot
|
|
157
|
+
# become the synchronized base for it; accepted sibling paths did
|
|
158
|
+
# land, so they advance individually.
|
|
159
|
+
if result.applied:
|
|
160
|
+
self.synchronized = advance_snapshot_paths(
|
|
161
|
+
self.synchronized, current, result.applied
|
|
162
|
+
)
|
|
163
|
+
return False
|
|
164
|
+
self.synchronized = current
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def try_push_local(self) -> bool:
|
|
168
|
+
"""Push local edits, tolerating a workspace that cannot accept patches yet.
|
|
169
|
+
|
|
170
|
+
A sandbox that is still booting (or winding down) answers 409/503; the
|
|
171
|
+
caller's sync loop retries on its next tick, and an end falls back to
|
|
172
|
+
the conflict-preserving final sync. Anything else still raises.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
return self.push_local()
|
|
176
|
+
except PlatformError as error:
|
|
177
|
+
if error.status_code not in {409, 503}:
|
|
178
|
+
raise
|
|
179
|
+
_console.print("[dim]local changes will sync once the workspace is running[/dim]")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _record_conflicts(self, conflicts: Iterable[str]) -> list[str]:
|
|
183
|
+
"""Track conflicts, returning only ones not already reported."""
|
|
184
|
+
fresh = [path for path in conflicts if path not in self.conflicts]
|
|
185
|
+
self.conflicts.update(conflicts)
|
|
186
|
+
return fresh
|
|
187
|
+
|
|
188
|
+
def finalize(self) -> SyncResult:
|
|
189
|
+
"""Reconcile the terminal session's final E2B workspace into the root.
|
|
190
|
+
|
|
191
|
+
Applies the three-way merge against the last synchronized snapshot,
|
|
192
|
+
preserves conflicting local paths (plus the full result under
|
|
193
|
+
``.wmo-conflicts/``), and acknowledges the handoff so the platform can
|
|
194
|
+
remove its private archive objects.
|
|
195
|
+
"""
|
|
196
|
+
with _console.status("[dim]syncing E2B workspace back...[/dim]", spinner="dots"):
|
|
197
|
+
final_archive = self._client.download_agent_workspace(self._agent_id, self._session_id)
|
|
198
|
+
result = sync_workspace(
|
|
199
|
+
self.root,
|
|
200
|
+
self.synchronized,
|
|
201
|
+
final_archive,
|
|
202
|
+
protected_paths=frozenset(self.conflicts),
|
|
203
|
+
)
|
|
204
|
+
if result.conflicts:
|
|
205
|
+
recovery = write_conflict_archive(self.root, self._session_id, final_archive)
|
|
206
|
+
self._client.acknowledge_agent_workspace(self._agent_id, self._session_id)
|
|
207
|
+
paths = ", ".join(result.conflicts)
|
|
208
|
+
_console.print(
|
|
209
|
+
f"[red]workspace conflicts preserved locally[/red]: {paths}\n"
|
|
210
|
+
f"The full E2B result is saved at [bold]{recovery}[/bold]."
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
self._client.acknowledge_agent_workspace(self._agent_id, self._session_id)
|
|
214
|
+
_console.print(f"[green]workspace synced[/green] ({len(result.applied)} changed paths)")
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class DetachedStartDriver:
|
|
219
|
+
"""Start a hosted agent session, persist its reference, and return."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
client: PlatformClient,
|
|
225
|
+
credentials: PlatformCredentials,
|
|
226
|
+
state_store: SessionStateStore,
|
|
227
|
+
target_id: str,
|
|
228
|
+
name: str,
|
|
229
|
+
jail_root: Path | None,
|
|
230
|
+
task: str | None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Store the resolved agent target and optional workspace root."""
|
|
233
|
+
self._client = client
|
|
234
|
+
self._credentials = credentials
|
|
235
|
+
self._store = state_store
|
|
236
|
+
self._target_id = target_id
|
|
237
|
+
self._name = name
|
|
238
|
+
self._jail = jail_root
|
|
239
|
+
self._task = task
|
|
240
|
+
|
|
241
|
+
def run(self) -> None:
|
|
242
|
+
"""Create the session, persist the reference and checkpoint, and exit."""
|
|
243
|
+
try:
|
|
244
|
+
initial: WorkspaceSnapshot | None = None
|
|
245
|
+
if self._jail is not None:
|
|
246
|
+
with _console.status("[dim]snapshotting local workspace...[/dim]", spinner="dots"):
|
|
247
|
+
initial = snapshot_workspace(self._jail)
|
|
248
|
+
_console.print(
|
|
249
|
+
f"[dim]uploading {len(initial.files)} files to the platform "
|
|
250
|
+
"E2B workspace...[/dim]"
|
|
251
|
+
)
|
|
252
|
+
session = self._client.create_agent_session(
|
|
253
|
+
self._target_id,
|
|
254
|
+
workspace=initial.archive if initial is not None else None,
|
|
255
|
+
instruction=self._task,
|
|
256
|
+
)
|
|
257
|
+
workspace: WorkspaceCheckpoint | None = None
|
|
258
|
+
if self._jail is not None:
|
|
259
|
+
workspace = WorkspaceCheckpoint(root=str(self._jail))
|
|
260
|
+
state = DetachedSessionState(
|
|
261
|
+
api_url=str(self._credentials.api_url),
|
|
262
|
+
web_url=self._credentials.web_url,
|
|
263
|
+
agent_id=self._target_id,
|
|
264
|
+
agent_name=self._name,
|
|
265
|
+
session_id=session.id,
|
|
266
|
+
created_at=datetime.now(tz=UTC).isoformat(),
|
|
267
|
+
workspace=workspace,
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
self._store.save(
|
|
271
|
+
state, base_archive=initial.archive if initial is not None else None
|
|
272
|
+
)
|
|
273
|
+
self._store.set_current(session.id)
|
|
274
|
+
except SessionStateError as error:
|
|
275
|
+
# The hosted session is already running; the user must get an
|
|
276
|
+
# addressable reference even though the local save failed.
|
|
277
|
+
msg = (
|
|
278
|
+
f"session {session.id} is running on the platform, but its local "
|
|
279
|
+
f"reference could not be saved: {error}. Control it with "
|
|
280
|
+
f"`wmo run --session {session.id} --attach` or end it with "
|
|
281
|
+
f"`wmo run --session {session.id} --end`"
|
|
282
|
+
)
|
|
283
|
+
raise typer.BadParameter(msg) from error
|
|
284
|
+
_console.print(
|
|
285
|
+
f"[green]detached E2B session started[/green] for [bold]{self._name}[/bold]\n"
|
|
286
|
+
f" agent {self._target_id}\n"
|
|
287
|
+
f" session {session.id}\n"
|
|
288
|
+
'Send a message with [bold]wmo run -s "..."[/bold], attach with '
|
|
289
|
+
"[bold]wmo run -a[/bold], end with [bold]wmo run --end[/bold]."
|
|
290
|
+
)
|
|
291
|
+
except (WorkspacePatchError, WorkspaceSyncError, SessionStateError) as error:
|
|
292
|
+
raise typer.BadParameter(str(error)) from error
|
|
293
|
+
except PlatformError as error:
|
|
294
|
+
raise typer.BadParameter(str(error)) from error
|
|
295
|
+
finally:
|
|
296
|
+
self._client.close()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class AttachedCommandReader(threading.Thread):
|
|
300
|
+
"""Terminal input for an attached session: steer, interrupt, detach, or end."""
|
|
301
|
+
|
|
302
|
+
def __init__(self, client: PlatformClient, agent_id: str, session_id: str) -> None:
|
|
303
|
+
"""Read stdin on a daemon thread; commands post through the platform."""
|
|
304
|
+
super().__init__(daemon=True)
|
|
305
|
+
self._client = client
|
|
306
|
+
self._agent_id = agent_id
|
|
307
|
+
self._session_id = session_id
|
|
308
|
+
self.detach = threading.Event()
|
|
309
|
+
self.ended = threading.Event()
|
|
310
|
+
|
|
311
|
+
def run(self) -> None:
|
|
312
|
+
"""Map lines to hosted commands; leaving the terminal detaches, never ends."""
|
|
313
|
+
try:
|
|
314
|
+
for raw in sys.stdin:
|
|
315
|
+
if self.detach.is_set():
|
|
316
|
+
return
|
|
317
|
+
line = raw.strip()
|
|
318
|
+
if line in {":detach", ":quit", ":q", ":exit"}:
|
|
319
|
+
self.detach.set()
|
|
320
|
+
return
|
|
321
|
+
if line == ":end":
|
|
322
|
+
# A failed end must be reported, never silently converted
|
|
323
|
+
# into a detach that leaves the session running; after a
|
|
324
|
+
# successful end the driver streams to the final handoff,
|
|
325
|
+
# so a following EOF must not look like a detach either.
|
|
326
|
+
try:
|
|
327
|
+
self._client.end_agent_session(self._agent_id, self._session_id)
|
|
328
|
+
except PlatformError as error:
|
|
329
|
+
_console.print(
|
|
330
|
+
f"[red]end failed:[/red] {error} "
|
|
331
|
+
"(retry [bold]:end[/bold], or run `wmo run --end` later)"
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
self.ended.set()
|
|
335
|
+
return
|
|
336
|
+
elif line == ":stop":
|
|
337
|
+
self._post("interrupt")
|
|
338
|
+
elif line.startswith(":"):
|
|
339
|
+
# An unknown command must never reach the agent as chat.
|
|
340
|
+
_console.print(
|
|
341
|
+
f"[yellow]unknown command {line}; use :stop, :detach, or :end[/yellow]"
|
|
342
|
+
)
|
|
343
|
+
elif line:
|
|
344
|
+
self._post("user_message", text=line)
|
|
345
|
+
except OSError:
|
|
346
|
+
pass
|
|
347
|
+
finally:
|
|
348
|
+
# Closed stdin means the terminal went away, not that the hosted
|
|
349
|
+
# session should end. Ending stays explicit (:end or --end); after
|
|
350
|
+
# one, the driver keeps streaming to the final handoff.
|
|
351
|
+
if not self.ended.is_set():
|
|
352
|
+
self.detach.set()
|
|
353
|
+
|
|
354
|
+
def _post(self, kind: str, *, text: str | None = None) -> None:
|
|
355
|
+
"""Post one command; a transient failure warns and keeps the reader alive."""
|
|
356
|
+
try:
|
|
357
|
+
self._client.post_agent_session_command(
|
|
358
|
+
self._agent_id, self._session_id, kind, text=text
|
|
359
|
+
)
|
|
360
|
+
except PlatformError as error:
|
|
361
|
+
_console.print(f"[red]{kind} failed:[/red] {error} (still attached; try again)")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass
|
|
365
|
+
class _Stream:
|
|
366
|
+
"""Mutable streaming state threaded through the detached event loop."""
|
|
367
|
+
|
|
368
|
+
state: DetachedSessionState
|
|
369
|
+
persisted: bool
|
|
370
|
+
workspace: LiveWorkspace | None
|
|
371
|
+
render: bool
|
|
372
|
+
cursor: int = 0
|
|
373
|
+
pending_ack: str | None = None
|
|
374
|
+
pending_text: str | None = None
|
|
375
|
+
message_seen: bool = False
|
|
376
|
+
turn_idle: bool = False
|
|
377
|
+
foreign_patch_noted: bool = False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class DetachedCommandDriver:
|
|
381
|
+
"""Send to, attach to, or end one hosted session from its stored reference."""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
client: PlatformClient,
|
|
387
|
+
credentials: PlatformCredentials,
|
|
388
|
+
state_store: SessionStateStore,
|
|
389
|
+
action: SessionAction,
|
|
390
|
+
text: str | None,
|
|
391
|
+
session_override: str | None,
|
|
392
|
+
sink: Callable[[SessionEvent], None],
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Bind one action to the resolved credentials, state store, and renderer."""
|
|
395
|
+
self._client = client
|
|
396
|
+
self._credentials = credentials
|
|
397
|
+
self._store = state_store
|
|
398
|
+
self._action = action
|
|
399
|
+
self._text = text
|
|
400
|
+
self._session_override = session_override
|
|
401
|
+
self._sink = sink
|
|
402
|
+
self._interrupts = 0
|
|
403
|
+
self._persisted_snapshot: WorkspaceSnapshot | None = None
|
|
404
|
+
self._salvage_reason: str | None = None
|
|
405
|
+
|
|
406
|
+
def run(self) -> None:
|
|
407
|
+
"""Resolve the session, catch up, and run the requested action."""
|
|
408
|
+
try:
|
|
409
|
+
self._run()
|
|
410
|
+
except (WorkspacePatchError, WorkspaceSyncError, SessionStateError) as error:
|
|
411
|
+
raise typer.BadParameter(str(error)) from error
|
|
412
|
+
except PlatformError as error:
|
|
413
|
+
raise typer.BadParameter(str(error)) from error
|
|
414
|
+
finally:
|
|
415
|
+
self._client.close()
|
|
416
|
+
|
|
417
|
+
# -- resolution --------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def _run(self) -> None:
|
|
420
|
+
state, persisted, remote = self._resolve()
|
|
421
|
+
if remote.status in TERMINAL_STATUSES:
|
|
422
|
+
self._handle_already_terminal(state, persisted, remote)
|
|
423
|
+
return
|
|
424
|
+
workspace = self._load_workspace(state, persisted)
|
|
425
|
+
stream = _Stream(
|
|
426
|
+
state=state,
|
|
427
|
+
persisted=persisted,
|
|
428
|
+
workspace=workspace,
|
|
429
|
+
render=persisted,
|
|
430
|
+
cursor=state.cursor,
|
|
431
|
+
pending_ack=state.workspace.pending_ack if state.workspace is not None else None,
|
|
432
|
+
)
|
|
433
|
+
self._retry_pending_ack(stream)
|
|
434
|
+
match self._action:
|
|
435
|
+
case "send":
|
|
436
|
+
self._send(stream)
|
|
437
|
+
case "attach":
|
|
438
|
+
self._attach(stream)
|
|
439
|
+
case "end":
|
|
440
|
+
self._end(stream)
|
|
441
|
+
|
|
442
|
+
def _resolve(self) -> tuple[DetachedSessionState, bool, RemoteAgentSession]:
|
|
443
|
+
"""Resolve the addressed session to state, persistence, and remote record."""
|
|
444
|
+
api_url = str(self._credentials.api_url)
|
|
445
|
+
override = self._session_override
|
|
446
|
+
if override is not None:
|
|
447
|
+
stored = self._store.load(override)
|
|
448
|
+
if stored is None:
|
|
449
|
+
return self._resolve_remote(override, api_url)
|
|
450
|
+
state = stored
|
|
451
|
+
else:
|
|
452
|
+
current = self._store.current_session_id()
|
|
453
|
+
if current is None:
|
|
454
|
+
raise typer.BadParameter(
|
|
455
|
+
"no current session: start one with `wmo run <agent-id> --detach`, "
|
|
456
|
+
"or address one with --session <session-id>"
|
|
457
|
+
)
|
|
458
|
+
loaded = self._store.load(current)
|
|
459
|
+
if loaded is None:
|
|
460
|
+
raise typer.BadParameter(
|
|
461
|
+
f"the current session pointer references {current} but its state is "
|
|
462
|
+
"gone; start a new session with `wmo run <agent-id> --detach`"
|
|
463
|
+
)
|
|
464
|
+
state = loaded
|
|
465
|
+
if state.api_url != api_url:
|
|
466
|
+
stored_home = state.web_url or state.api_url
|
|
467
|
+
active_home = self._credentials.web_url or api_url
|
|
468
|
+
raise typer.BadParameter(
|
|
469
|
+
f"session {state.session_id} belongs to {stored_home}, but this login "
|
|
470
|
+
f"points at {active_home}; run `wmo login --url {stored_home}` to switch, "
|
|
471
|
+
"or start a new session here with `wmo run <agent-id> --detach`"
|
|
472
|
+
)
|
|
473
|
+
try:
|
|
474
|
+
remote = self._client.get_agent_session(state.agent_id, state.session_id)
|
|
475
|
+
except PlatformError as error:
|
|
476
|
+
if error.status_code == 404:
|
|
477
|
+
raise typer.BadParameter(
|
|
478
|
+
f"session {state.session_id} was not found on "
|
|
479
|
+
f"{state.web_url or state.api_url}; it may have been removed, or "
|
|
480
|
+
"this login may lack access (check `wmo status`)"
|
|
481
|
+
) from error
|
|
482
|
+
raise
|
|
483
|
+
return state, True, remote
|
|
484
|
+
|
|
485
|
+
def _resolve_remote(
|
|
486
|
+
self, session_id: str, api_url: str
|
|
487
|
+
) -> tuple[DetachedSessionState, bool, RemoteAgentSession]:
|
|
488
|
+
"""Resolve a session id with no local state through the platform."""
|
|
489
|
+
try:
|
|
490
|
+
remote = self._client.resolve_agent_session(session_id)
|
|
491
|
+
except PlatformError as error:
|
|
492
|
+
if error.status_code == 404:
|
|
493
|
+
raise typer.BadParameter(
|
|
494
|
+
f"session {session_id} was not found on "
|
|
495
|
+
f"{self._credentials.web_url or api_url}; check --session and that "
|
|
496
|
+
"your login (`wmo status`) can access it"
|
|
497
|
+
) from error
|
|
498
|
+
raise
|
|
499
|
+
name = remote.agent_id
|
|
500
|
+
with contextlib.suppress(PlatformError):
|
|
501
|
+
target = self._client.resolve_run_target(remote.agent_id)
|
|
502
|
+
name = target.display_name or target.name
|
|
503
|
+
state = DetachedSessionState(
|
|
504
|
+
api_url=api_url,
|
|
505
|
+
web_url=self._credentials.web_url,
|
|
506
|
+
agent_id=remote.agent_id,
|
|
507
|
+
agent_name=name,
|
|
508
|
+
session_id=session_id,
|
|
509
|
+
created_at=datetime.now(tz=UTC).isoformat(),
|
|
510
|
+
)
|
|
511
|
+
return state, False, remote
|
|
512
|
+
|
|
513
|
+
def _handle_already_terminal(
|
|
514
|
+
self, state: DetachedSessionState, persisted: bool, remote: RemoteAgentSession
|
|
515
|
+
) -> None:
|
|
516
|
+
"""Reconcile (--end) or explain (send/attach) an already-finished session."""
|
|
517
|
+
if self._action != "end":
|
|
518
|
+
detail = (
|
|
519
|
+
"reconcile its final workspace and clear it"
|
|
520
|
+
if persisted and state.workspace is not None
|
|
521
|
+
else "clear it"
|
|
522
|
+
)
|
|
523
|
+
raise typer.BadParameter(
|
|
524
|
+
f"session {state.session_id} is already {remote.status}; run "
|
|
525
|
+
f"`wmo run --session {state.session_id} --end` to {detail}, or start a "
|
|
526
|
+
f"new session with `wmo run {state.agent_id} --detach`"
|
|
527
|
+
)
|
|
528
|
+
workspace = self._load_workspace(state, persisted)
|
|
529
|
+
stream = _Stream(
|
|
530
|
+
state=state,
|
|
531
|
+
persisted=persisted,
|
|
532
|
+
workspace=workspace,
|
|
533
|
+
render=False,
|
|
534
|
+
cursor=state.cursor,
|
|
535
|
+
pending_ack=state.workspace.pending_ack if state.workspace is not None else None,
|
|
536
|
+
)
|
|
537
|
+
self._retry_pending_ack(stream)
|
|
538
|
+
self._finish_terminal(stream)
|
|
539
|
+
|
|
540
|
+
def _load_workspace(self, state: DetachedSessionState, persisted: bool) -> LiveWorkspace | None:
|
|
541
|
+
"""Rehydrate the workspace transport from the persisted checkpoint."""
|
|
542
|
+
if not persisted or state.workspace is None:
|
|
543
|
+
return None
|
|
544
|
+
root = Path(state.workspace.root)
|
|
545
|
+
if not root.is_dir():
|
|
546
|
+
if self._action == "end":
|
|
547
|
+
self._salvage_reason = f"the synchronized workspace {root} no longer exists"
|
|
548
|
+
return None
|
|
549
|
+
raise typer.BadParameter(
|
|
550
|
+
f"the synchronized workspace {root} no longer exists; restore it, or run "
|
|
551
|
+
f"`wmo run --session {state.session_id} --end` to save the final "
|
|
552
|
+
"workspace without a local sync"
|
|
553
|
+
)
|
|
554
|
+
try:
|
|
555
|
+
base = self._store.load_base_archive(state)
|
|
556
|
+
except SessionStateError as error:
|
|
557
|
+
# `--end` still completes the handoff without a local sync; the
|
|
558
|
+
# stored error messages point other actions at exactly that.
|
|
559
|
+
if self._action == "end":
|
|
560
|
+
self._salvage_reason = str(error)
|
|
561
|
+
return None
|
|
562
|
+
raise
|
|
563
|
+
synchronized = snapshot_from_archive(base)
|
|
564
|
+
self._persisted_snapshot = synchronized
|
|
565
|
+
return LiveWorkspace(
|
|
566
|
+
self._client,
|
|
567
|
+
state.agent_id,
|
|
568
|
+
state.session_id,
|
|
569
|
+
root,
|
|
570
|
+
synchronized,
|
|
571
|
+
conflicts=state.workspace.conflicts,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# -- actions -----------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
def _send(self, stream: _Stream) -> None:
|
|
577
|
+
"""Catch up, deliver one message, and stream its turn until idle."""
|
|
578
|
+
text = self._text if self._text is not None else ""
|
|
579
|
+
self._push_workspace(stream)
|
|
580
|
+
if self._catch_up(stream) == "terminal":
|
|
581
|
+
self._finish_terminal(
|
|
582
|
+
stream, failure_note="the session ended before the message could be sent"
|
|
583
|
+
)
|
|
584
|
+
return
|
|
585
|
+
self._client.post_agent_session_command(
|
|
586
|
+
stream.state.agent_id, stream.state.session_id, "user_message", text=text
|
|
587
|
+
)
|
|
588
|
+
stream.pending_text = text
|
|
589
|
+
stream.render = True
|
|
590
|
+
outcome = self._stream_until(stream, stop=lambda: stream.turn_idle)
|
|
591
|
+
if outcome == "terminal":
|
|
592
|
+
self._finish_terminal(stream)
|
|
593
|
+
return
|
|
594
|
+
self._persist(stream, stream.cursor)
|
|
595
|
+
if outcome == "detached":
|
|
596
|
+
_console.print("[dim]detached; the session stays alive[/dim]")
|
|
597
|
+
return
|
|
598
|
+
_console.print(f"[dim]turn finished; session {stream.state.session_id} stays alive[/dim]")
|
|
599
|
+
|
|
600
|
+
def _attach(self, stream: _Stream) -> None:
|
|
601
|
+
"""Catch up, then stream interactively until the user detaches or ends."""
|
|
602
|
+
self._push_workspace(stream)
|
|
603
|
+
if self._catch_up(stream) == "terminal":
|
|
604
|
+
self._finish_terminal(stream)
|
|
605
|
+
return
|
|
606
|
+
stream.render = True
|
|
607
|
+
reader = AttachedCommandReader(self._client, stream.state.agent_id, stream.state.session_id)
|
|
608
|
+
reader.start()
|
|
609
|
+
_console.print(
|
|
610
|
+
f"[green]attached[/green] to [bold]{stream.state.agent_name}[/bold] "
|
|
611
|
+
f"({stream.state.session_id}). Type to steer, [bold]:stop[/bold] to interrupt, "
|
|
612
|
+
"[bold]:detach[/bold] to leave it running, [bold]:end[/bold] to end it."
|
|
613
|
+
)
|
|
614
|
+
outcome = self._stream_until(stream, stop=reader.detach.is_set)
|
|
615
|
+
if outcome == "terminal":
|
|
616
|
+
self._finish_terminal(stream)
|
|
617
|
+
return
|
|
618
|
+
self._persist(stream, stream.cursor)
|
|
619
|
+
_console.print("[dim]detached; the session stays alive[/dim]")
|
|
620
|
+
|
|
621
|
+
def _end(self, stream: _Stream) -> None:
|
|
622
|
+
"""Catch up, push local edits, end the session, and run the final sync."""
|
|
623
|
+
self._push_workspace(stream)
|
|
624
|
+
if self._catch_up(stream) != "terminal":
|
|
625
|
+
remote = self._client.end_agent_session(stream.state.agent_id, stream.state.session_id)
|
|
626
|
+
if remote.status not in TERMINAL_STATUSES:
|
|
627
|
+
outcome = self._stream_until(stream, stop=lambda: False)
|
|
628
|
+
if outcome == "detached":
|
|
629
|
+
self._persist(stream, stream.cursor)
|
|
630
|
+
_console.print(
|
|
631
|
+
"[yellow]end requested; leaving before the final sync (rerun "
|
|
632
|
+
"`wmo run --end` to finish)[/yellow]"
|
|
633
|
+
)
|
|
634
|
+
raise typer.Exit(code=1)
|
|
635
|
+
self._finish_terminal(stream)
|
|
636
|
+
|
|
637
|
+
def _finish_terminal(self, stream: _Stream, *, failure_note: str | None = None) -> None:
|
|
638
|
+
"""Run the final workspace handoff, clear local state, and report the outcome."""
|
|
639
|
+
conflicted = False
|
|
640
|
+
if stream.workspace is not None:
|
|
641
|
+
try:
|
|
642
|
+
result = stream.workspace.finalize()
|
|
643
|
+
conflicted = bool(result.conflicts)
|
|
644
|
+
except PlatformError as error:
|
|
645
|
+
if error.status_code != 404:
|
|
646
|
+
# Keep local state so `wmo run --end` can retry the handoff.
|
|
647
|
+
raise
|
|
648
|
+
_console.print(
|
|
649
|
+
"[yellow]no final workspace archive is available; it may already "
|
|
650
|
+
"have been synchronized[/yellow]"
|
|
651
|
+
)
|
|
652
|
+
elif (
|
|
653
|
+
stream.persisted
|
|
654
|
+
and stream.state.workspace is not None
|
|
655
|
+
and self._salvage_reason is not None
|
|
656
|
+
):
|
|
657
|
+
self._salvage_final_workspace(stream)
|
|
658
|
+
terminal: RemoteAgentSession | None
|
|
659
|
+
try:
|
|
660
|
+
terminal = self._client.get_agent_session(
|
|
661
|
+
stream.state.agent_id, stream.state.session_id
|
|
662
|
+
)
|
|
663
|
+
except PlatformError as error:
|
|
664
|
+
# The record (or its agent) can vanish between the handoff and
|
|
665
|
+
# this read; local state must still be cleared, or it is stuck
|
|
666
|
+
# with no CLI path to remove it.
|
|
667
|
+
if error.status_code != 404:
|
|
668
|
+
raise
|
|
669
|
+
terminal = None
|
|
670
|
+
if stream.persisted:
|
|
671
|
+
self._store.delete(stream.state.session_id)
|
|
672
|
+
if failure_note is not None:
|
|
673
|
+
_console.print(f"[red]{failure_note}[/red]")
|
|
674
|
+
if terminal is not None and terminal.status == "failed":
|
|
675
|
+
_console.print(f"[red]session failed: {terminal.error or 'unknown error'}[/red]")
|
|
676
|
+
raise typer.Exit(code=1)
|
|
677
|
+
detail = (
|
|
678
|
+
terminal.ended_reason or terminal.status
|
|
679
|
+
if terminal is not None
|
|
680
|
+
else "no longer visible on the platform"
|
|
681
|
+
)
|
|
682
|
+
_console.print(f"[dim]session ended ({detail})[/dim]")
|
|
683
|
+
if failure_note is not None:
|
|
684
|
+
raise typer.Exit(code=1)
|
|
685
|
+
if conflicted:
|
|
686
|
+
raise typer.Exit(code=2)
|
|
687
|
+
|
|
688
|
+
def _salvage_final_workspace(self, stream: _Stream) -> None:
|
|
689
|
+
"""Save the final archive without a local sync when the checkpoint is unusable.
|
|
690
|
+
|
|
691
|
+
The handoff is still acknowledged so the platform can remove its
|
|
692
|
+
private archive object; the user's data lands as a recovery archive
|
|
693
|
+
(under the workspace root when it exists, else in WMO state, where it
|
|
694
|
+
survives the state cleanup).
|
|
695
|
+
"""
|
|
696
|
+
state = stream.state
|
|
697
|
+
try:
|
|
698
|
+
content = self._client.download_agent_workspace(state.agent_id, state.session_id)
|
|
699
|
+
except PlatformError as error:
|
|
700
|
+
if error.status_code != 404:
|
|
701
|
+
# Keep local state so `wmo run --end` can retry the handoff.
|
|
702
|
+
raise
|
|
703
|
+
_console.print(
|
|
704
|
+
"[yellow]no final workspace archive is available; it may already "
|
|
705
|
+
"have been synchronized[/yellow]"
|
|
706
|
+
)
|
|
707
|
+
return
|
|
708
|
+
workspace_state = state.workspace
|
|
709
|
+
root = Path(workspace_state.root) if workspace_state is not None else None
|
|
710
|
+
if root is not None and root.is_dir():
|
|
711
|
+
recovery = write_conflict_archive(root, state.session_id, content)
|
|
712
|
+
else:
|
|
713
|
+
recovery = self._store.write_recovery_archive(state.session_id, content)
|
|
714
|
+
self._client.acknowledge_agent_workspace(state.agent_id, state.session_id)
|
|
715
|
+
_console.print(
|
|
716
|
+
f"[yellow]final workspace saved without a local sync "
|
|
717
|
+
f"({self._salvage_reason}).[/yellow]\n"
|
|
718
|
+
f"The full E2B result is at [bold]{recovery}[/bold]."
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def _retry_pending_ack(self, stream: _Stream) -> None:
|
|
722
|
+
"""Deliver a patch acknowledgement a previous invocation could not send."""
|
|
723
|
+
workspace_state = stream.state.workspace
|
|
724
|
+
if not stream.persisted or workspace_state is None or workspace_state.pending_ack is None:
|
|
725
|
+
return
|
|
726
|
+
revision = workspace_state.pending_ack
|
|
727
|
+
try:
|
|
728
|
+
self._client.acknowledge_agent_workspace_patch(
|
|
729
|
+
stream.state.agent_id, stream.state.session_id, revision
|
|
730
|
+
)
|
|
731
|
+
except PlatformError as error:
|
|
732
|
+
# 404 means the object is already gone (acknowledged after all, or
|
|
733
|
+
# removed with the session); anything else stays retryable.
|
|
734
|
+
if error.status_code != 404:
|
|
735
|
+
raise
|
|
736
|
+
stream.pending_ack = None
|
|
737
|
+
self._persist(stream, stream.cursor)
|
|
738
|
+
|
|
739
|
+
# -- event loop --------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def _catch_up(self, stream: _Stream) -> str:
|
|
742
|
+
"""Apply everything durable that happened while no CLI process ran.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
``"terminal"`` when the session finished, otherwise ``"ready"``.
|
|
746
|
+
"""
|
|
747
|
+
while True:
|
|
748
|
+
page = self._poll(stream)
|
|
749
|
+
if page.status in TERMINAL_STATUSES:
|
|
750
|
+
return "terminal"
|
|
751
|
+
if not page.events:
|
|
752
|
+
return "ready"
|
|
753
|
+
|
|
754
|
+
def _stream_until(self, stream: _Stream, *, stop: Callable[[], bool]) -> str:
|
|
755
|
+
"""Poll, render, and synchronize until ``stop``, terminal state, or detach.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
``"terminal"``, ``"stopped"``, or ``"detached"`` (double Ctrl-C).
|
|
759
|
+
"""
|
|
760
|
+
last_push = time.monotonic()
|
|
761
|
+
last_probe = time.monotonic()
|
|
762
|
+
while True:
|
|
763
|
+
try:
|
|
764
|
+
page = self._poll(stream)
|
|
765
|
+
if page.status in TERMINAL_STATUSES:
|
|
766
|
+
return "terminal"
|
|
767
|
+
if stop():
|
|
768
|
+
return "stopped"
|
|
769
|
+
now = time.monotonic()
|
|
770
|
+
if stream.workspace is not None and now - last_push >= _WORKSPACE_SYNC_TICK_S:
|
|
771
|
+
self._push_workspace(stream)
|
|
772
|
+
last_push = now
|
|
773
|
+
if now - last_probe >= _STALE_PROBE_S:
|
|
774
|
+
# The detail read lazily reconciles a dead driver so the
|
|
775
|
+
# next events poll reports the truthful terminal status.
|
|
776
|
+
with contextlib.suppress(PlatformError):
|
|
777
|
+
self._client.get_agent_session(
|
|
778
|
+
stream.state.agent_id, stream.state.session_id
|
|
779
|
+
)
|
|
780
|
+
last_probe = now
|
|
781
|
+
time.sleep(_POLL_INTERVAL_S)
|
|
782
|
+
except KeyboardInterrupt:
|
|
783
|
+
self._interrupts += 1
|
|
784
|
+
if self._interrupts >= 2:
|
|
785
|
+
return "detached"
|
|
786
|
+
# The next Ctrl-C frequently lands inside this handler (the
|
|
787
|
+
# print or the HTTP post); it must detach, not crash out.
|
|
788
|
+
try:
|
|
789
|
+
_console.print("\n[yellow]interrupting (press Ctrl-C again to detach)[/yellow]")
|
|
790
|
+
with contextlib.suppress(PlatformError):
|
|
791
|
+
self._client.post_agent_session_command(
|
|
792
|
+
stream.state.agent_id, stream.state.session_id, "interrupt"
|
|
793
|
+
)
|
|
794
|
+
except KeyboardInterrupt:
|
|
795
|
+
return "detached"
|
|
796
|
+
|
|
797
|
+
def _poll(self, stream: _Stream) -> RemoteAgentEventPage:
|
|
798
|
+
"""Fetch one page after the cursor, process it, and persist the checkpoint."""
|
|
799
|
+
page = self._client.list_agent_session_events(
|
|
800
|
+
stream.state.agent_id, stream.state.session_id, after=stream.cursor
|
|
801
|
+
)
|
|
802
|
+
for event in page.events:
|
|
803
|
+
self._handle_event(stream, event)
|
|
804
|
+
# Advance per event, not only per page: a Ctrl-C landing mid-page
|
|
805
|
+
# must resume after the events already processed (re-fetching a
|
|
806
|
+
# patch event whose object was acknowledged would 404).
|
|
807
|
+
stream.cursor = event.seq
|
|
808
|
+
stream.cursor = page.last_seq
|
|
809
|
+
self._persist(stream, page.last_seq)
|
|
810
|
+
return page
|
|
811
|
+
|
|
812
|
+
def _handle_event(self, stream: _Stream, event: RemoteAgentSessionEvent) -> None:
|
|
813
|
+
"""Apply one durable event: workspace transport, turn tracking, rendering."""
|
|
814
|
+
if event.kind == "workspace_patch":
|
|
815
|
+
if stream.workspace is None:
|
|
816
|
+
if not stream.foreign_patch_noted:
|
|
817
|
+
_console.print(
|
|
818
|
+
"[dim](workspace patches are synchronized by the launching CLI; "
|
|
819
|
+
"skipping)[/dim]"
|
|
820
|
+
)
|
|
821
|
+
stream.foreign_patch_noted = True
|
|
822
|
+
return
|
|
823
|
+
revision = patch_revision(event)
|
|
824
|
+
|
|
825
|
+
def checkpoint_before_ack() -> None:
|
|
826
|
+
stream.cursor = event.seq
|
|
827
|
+
stream.pending_ack = revision
|
|
828
|
+
self._persist(stream, event.seq)
|
|
829
|
+
|
|
830
|
+
stream.workspace.apply_remote_patch(revision, before_ack=checkpoint_before_ack)
|
|
831
|
+
stream.pending_ack = None
|
|
832
|
+
self._persist(stream, stream.cursor)
|
|
833
|
+
return
|
|
834
|
+
if event.kind == "status":
|
|
835
|
+
detail = event.payload.get("message") or event.payload.get("status")
|
|
836
|
+
if detail and stream.render:
|
|
837
|
+
_console.print(f"[dim]({detail})[/dim]")
|
|
838
|
+
return
|
|
839
|
+
if event.kind == "user_message" and stream.pending_text is not None:
|
|
840
|
+
# Turn attribution matches the echoed text among events after the
|
|
841
|
+
# send-time cursor. The command API cannot correlate a command id
|
|
842
|
+
# to its transcript echo, so an identical message posted by
|
|
843
|
+
# another actor in the same window can end the stream one turn
|
|
844
|
+
# early; the session itself is unaffected (a known approximation).
|
|
845
|
+
if event.payload.get("text") == stream.pending_text:
|
|
846
|
+
stream.message_seen = True
|
|
847
|
+
if event.kind == "state" and stream.message_seen and event.payload.get("status") == "idle":
|
|
848
|
+
stream.turn_idle = True
|
|
849
|
+
if stream.render:
|
|
850
|
+
self._sink(SessionEvent(kind=event.kind, payload=event.payload))
|
|
851
|
+
|
|
852
|
+
# -- checkpointing -----------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
def _push_workspace(self, stream: _Stream) -> None:
|
|
855
|
+
"""Upload local edits made since the checkpoint, then persist it."""
|
|
856
|
+
if stream.workspace is None:
|
|
857
|
+
return
|
|
858
|
+
stream.workspace.try_push_local()
|
|
859
|
+
self._persist(stream, stream.cursor)
|
|
860
|
+
|
|
861
|
+
def _persist(self, stream: _Stream, cursor: int) -> None:
|
|
862
|
+
"""Advance the durable checkpoint (cursor, conflicts, base snapshot)."""
|
|
863
|
+
if not stream.persisted:
|
|
864
|
+
return
|
|
865
|
+
state = stream.state
|
|
866
|
+
workspace_state = state.workspace
|
|
867
|
+
archive: bytes | None = None
|
|
868
|
+
if workspace_state is not None:
|
|
869
|
+
update: dict[str, tuple[str, ...] | str | None] = {"pending_ack": stream.pending_ack}
|
|
870
|
+
if stream.workspace is not None:
|
|
871
|
+
update["conflicts"] = tuple(sorted(stream.workspace.conflicts))
|
|
872
|
+
if stream.workspace.synchronized is not self._persisted_snapshot:
|
|
873
|
+
archive = stream.workspace.synchronized.archive
|
|
874
|
+
workspace_state = workspace_state.model_copy(update=update)
|
|
875
|
+
if cursor == state.cursor and workspace_state == state.workspace and archive is None:
|
|
876
|
+
return
|
|
877
|
+
updated = state.model_copy(update={"cursor": cursor, "workspace": workspace_state})
|
|
878
|
+
stream.state = self._store.save(updated, base_archive=archive)
|
|
879
|
+
if stream.workspace is not None:
|
|
880
|
+
self._persisted_snapshot = stream.workspace.synchronized
|