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
wmo/cli/agent_session.py
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
# Copyright (c) 2026 Experiential Labs. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Run a platform target or the built-in pi harness from one CLI command.
|
|
4
|
+
|
|
5
|
+
Agent IDs run the champion pi harness in the platform's E2B sandbox. By
|
|
6
|
+
default no local files are sent. ``-u PATH`` opts into uploading a bounded
|
|
7
|
+
snapshot, live-syncing changes, and reconciling the final sandbox workspace
|
|
8
|
+
into that directory. Bare runs still launch the built-in vendored pi harness
|
|
9
|
+
as a local Node child.
|
|
10
|
+
|
|
11
|
+
The execution mode is chosen automatically (see :func:`register`):
|
|
12
|
+
|
|
13
|
+
* logged in + agent id: the platform owns E2B, provider credentials, metering,
|
|
14
|
+
and the transcript; the CLI owns only workspace transport and terminal I/O.
|
|
15
|
+
* logged in + no id: the built-in harness runs locally with a platform-proxied
|
|
16
|
+
worker and org-level usage record.
|
|
17
|
+
* logged out with no target: the built-in baseline pi agent can use the user's
|
|
18
|
+
local provider credentials.
|
|
19
|
+
|
|
20
|
+
Only the bare built-in path executes harness code and bash on the user's real
|
|
21
|
+
machine, so that path retains the explicit local-execution consent prompt.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Annotated, Protocol
|
|
34
|
+
|
|
35
|
+
import typer
|
|
36
|
+
from rich.console import Console
|
|
37
|
+
from rich.panel import Panel
|
|
38
|
+
|
|
39
|
+
from wmo.cli.hosted_session import (
|
|
40
|
+
DetachedCommandDriver,
|
|
41
|
+
DetachedStartDriver,
|
|
42
|
+
LiveWorkspace,
|
|
43
|
+
SessionAction,
|
|
44
|
+
patch_revision,
|
|
45
|
+
)
|
|
46
|
+
from wmo.cli.session_state import (
|
|
47
|
+
DetachedSessionState,
|
|
48
|
+
SessionStateError,
|
|
49
|
+
SessionStateStore,
|
|
50
|
+
WorkspaceCheckpoint,
|
|
51
|
+
)
|
|
52
|
+
from wmo.cli.workspace_sync import (
|
|
53
|
+
WorkspaceSnapshot,
|
|
54
|
+
WorkspaceSyncError,
|
|
55
|
+
snapshot_workspace,
|
|
56
|
+
)
|
|
57
|
+
from wmo.config import load_settings
|
|
58
|
+
from wmo.engine.play import parse_action
|
|
59
|
+
from wmo.harness.doc import RUNTIME_KIND_ID, HarnessDoc, Surface, SurfaceKind
|
|
60
|
+
from wmo.harness.live_session import LiveSession, SessionEvent, ToolOutcome
|
|
61
|
+
from wmo.harness.pi_local import LocalStdioChannel, start_local_live_runner
|
|
62
|
+
from wmo.harness.pi_vendor import pi_agent_code_surfaces
|
|
63
|
+
from wmo.harness.tools import READ_SKILL, resolve_tools
|
|
64
|
+
from wmo.harness.workspace_patch import WorkspacePatchError
|
|
65
|
+
from wmo.platform.client import PlatformClient, PlatformError, RemoteAgentSession
|
|
66
|
+
from wmo.platform.credentials import PlatformCredentials, load_credentials
|
|
67
|
+
from wmo.providers.base import ProviderConfig, ProviderKind, ToolCallingProvider
|
|
68
|
+
from wmo.providers.models import resolve_provider_model
|
|
69
|
+
from wmo.providers.registry import get_provider
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from collections.abc import Callable
|
|
73
|
+
|
|
74
|
+
from llm_waterfall import ChatRequest, ChatResponse
|
|
75
|
+
|
|
76
|
+
from wmo.core.types import JsonObject
|
|
77
|
+
|
|
78
|
+
_console = Console()
|
|
79
|
+
|
|
80
|
+
# Per-tool-call output cap (head+tail) reported to the transcript.
|
|
81
|
+
_TOOL_OUTPUT_CAP = 16_000
|
|
82
|
+
_BASH_TIMEOUT_S = 300.0
|
|
83
|
+
# Driver housekeeping cadence (event flush).
|
|
84
|
+
_TICK_S = 5.0
|
|
85
|
+
_WORKSPACE_SYNC_TICK_S = 1.0
|
|
86
|
+
# Default local worker when the user pins none.
|
|
87
|
+
_DEFAULT_PROVIDER = "bedrock"
|
|
88
|
+
_DEFAULT_MODEL = "claude-opus-4-8"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _JailEscape(RuntimeError):
|
|
92
|
+
"""A tool path resolved outside the session's working directory."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _capped(content: str, *, is_error: bool = False) -> ToolOutcome:
|
|
96
|
+
"""Cap tool output to the head+tail budget with a truncation marker."""
|
|
97
|
+
if len(content) <= _TOOL_OUTPUT_CAP:
|
|
98
|
+
return ToolOutcome(content=content, is_error=is_error)
|
|
99
|
+
half = _TOOL_OUTPUT_CAP // 2
|
|
100
|
+
dropped = len(content) - _TOOL_OUTPUT_CAP
|
|
101
|
+
capped = f"{content[:half]}\n... [{dropped} chars truncated] ...\n{content[-half:]}"
|
|
102
|
+
return ToolOutcome(content=capped, is_error=is_error, truncated=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _assemble(doc: HarnessDoc) -> tuple[str, list, dict[str, str], dict[str, str]]:
|
|
106
|
+
"""Derive the LiveSession inputs from a HarnessDoc (mirrors the hosted driver).
|
|
107
|
+
|
|
108
|
+
Returns the assembled system prompt (prompt + rendered tools + skills index),
|
|
109
|
+
the resolved tool specs, the code surfaces as {path: content} (the agent's own
|
|
110
|
+
code, materialized into the local runner), and skill bodies answered host-side.
|
|
111
|
+
"""
|
|
112
|
+
tool_names = doc.tools()
|
|
113
|
+
if doc.skills() and READ_SKILL.name not in tool_names:
|
|
114
|
+
tool_names.append(READ_SKILL.name)
|
|
115
|
+
tool_specs = resolve_tools(tool_names)
|
|
116
|
+
system = doc.assembled_prompt()
|
|
117
|
+
files = {surface.path: surface.content for surface in doc.code_files() if surface.path}
|
|
118
|
+
skill_bodies = {skill.name: skill.body for skill in doc.skills()}
|
|
119
|
+
return system, tool_specs, files, skill_bodies
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _pi_node_baseline() -> HarnessDoc:
|
|
123
|
+
"""A pi-node baseline: the default prompt/tools plus the vendored pi agent code.
|
|
124
|
+
|
|
125
|
+
``HarnessDoc.baseline`` is the in-process loop, which the live pi runner
|
|
126
|
+
cannot host (it needs the pi agent's src/agent.ts). This grafts the vendored
|
|
127
|
+
pi code surfaces on and pins ``param:runtime-kind = pi-node`` so a not-logged-in
|
|
128
|
+
session has a runnable agent without fetching a champion.
|
|
129
|
+
"""
|
|
130
|
+
base = HarnessDoc.baseline("local-session")
|
|
131
|
+
surfaces = [
|
|
132
|
+
*base.surfaces,
|
|
133
|
+
*pi_agent_code_surfaces(),
|
|
134
|
+
Surface(id=RUNTIME_KIND_ID, kind=SurfaceKind.PARAM, content="pi-node"),
|
|
135
|
+
]
|
|
136
|
+
return HarnessDoc(name="local-session", surfaces=surfaces)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LocalToolExecutor:
|
|
140
|
+
"""Jail file tools to one directory and start bash there without OS isolation."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, jail_root: Path) -> None:
|
|
143
|
+
"""Confine every tool path under ``jail_root`` (its resolved real path)."""
|
|
144
|
+
self._jail = jail_root.resolve()
|
|
145
|
+
|
|
146
|
+
def _resolve(self, path: str) -> Path:
|
|
147
|
+
"""Resolve a tool path under the jail, rejecting any escape."""
|
|
148
|
+
target = (self._jail / path).resolve()
|
|
149
|
+
try:
|
|
150
|
+
target.relative_to(self._jail)
|
|
151
|
+
except ValueError as error:
|
|
152
|
+
raise _JailEscape(path) from error
|
|
153
|
+
return target
|
|
154
|
+
|
|
155
|
+
def __call__(
|
|
156
|
+
self, name: str, args: JsonObject, emit: Callable[[str, str], None]
|
|
157
|
+
) -> ToolOutcome:
|
|
158
|
+
"""Execute one tool call locally; a failure is an observation, not a crash."""
|
|
159
|
+
try:
|
|
160
|
+
if name == "bash":
|
|
161
|
+
return self._bash(str(args.get("command", "")), emit)
|
|
162
|
+
if name == "read_file":
|
|
163
|
+
target = self._resolve(str(args.get("path", "")))
|
|
164
|
+
return _capped(target.read_text(encoding="utf-8", errors="replace"))
|
|
165
|
+
if name == "write_file":
|
|
166
|
+
path = str(args.get("path", ""))
|
|
167
|
+
target = self._resolve(path)
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_text(str(args.get("content", "")), encoding="utf-8")
|
|
170
|
+
return ToolOutcome(content=f"wrote {path}")
|
|
171
|
+
except _JailEscape as error:
|
|
172
|
+
return ToolOutcome(content=f"path {error} escapes the session directory", is_error=True)
|
|
173
|
+
except OSError as error:
|
|
174
|
+
return ToolOutcome(content=f"{name} failed: {error}", is_error=True)
|
|
175
|
+
return ToolOutcome(content=f"tool {name!r} not available", is_error=True)
|
|
176
|
+
|
|
177
|
+
def _bash(self, command: str, emit: Callable[[str, str], None]) -> ToolOutcome:
|
|
178
|
+
"""Run a fresh ``bash -lc`` in the jail root, streaming output to ``emit``."""
|
|
179
|
+
try:
|
|
180
|
+
result = subprocess.run( # noqa: S603 - the agent's tool is meant to run shell commands
|
|
181
|
+
["bash", "-lc", command], # noqa: S607 - bash on PATH is the documented contract
|
|
182
|
+
cwd=self._jail,
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
timeout=_BASH_TIMEOUT_S,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
stdout, stderr, exit_code = result.stdout, result.stderr, result.returncode
|
|
189
|
+
except subprocess.TimeoutExpired as error:
|
|
190
|
+
stdout = _as_text(error.stdout)
|
|
191
|
+
stderr = _as_text(error.stderr) + f"\n[timed out after {int(_BASH_TIMEOUT_S)}s]"
|
|
192
|
+
exit_code = 124
|
|
193
|
+
if stdout:
|
|
194
|
+
emit("stdout", stdout)
|
|
195
|
+
if stderr:
|
|
196
|
+
emit("stderr", stderr)
|
|
197
|
+
body = stdout + stderr
|
|
198
|
+
if exit_code != 0:
|
|
199
|
+
body = f"{body}\n[exit {exit_code}]"
|
|
200
|
+
return _capped(body, is_error=exit_code != 0)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _as_text(value: object) -> str:
|
|
204
|
+
"""Coerce subprocess stdout/stderr (str | bytes | None) to text."""
|
|
205
|
+
if isinstance(value, bytes):
|
|
206
|
+
return value.decode("utf-8", errors="replace")
|
|
207
|
+
return value if isinstance(value, str) else ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class RunRecorder(Protocol):
|
|
211
|
+
"""Recording slice consumed by the local driver and terminal event sink."""
|
|
212
|
+
|
|
213
|
+
def record(self, event: SessionEvent) -> None: ...
|
|
214
|
+
|
|
215
|
+
def flush(self) -> None: ...
|
|
216
|
+
|
|
217
|
+
def finish(self, *, ended_reason: str, error: str | None) -> None: ...
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class LocalPiRunRecorder:
|
|
221
|
+
"""Finish and close an org-scoped built-in pi run; it has no transcript."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, client: PlatformClient, org_id: str, run_id: str) -> None:
|
|
224
|
+
self._client = client
|
|
225
|
+
self._org_id = org_id
|
|
226
|
+
self._run_id = run_id
|
|
227
|
+
|
|
228
|
+
def record(self, event: SessionEvent) -> None:
|
|
229
|
+
"""Ignore transcript events; this row exists only for usage accounting."""
|
|
230
|
+
_ = event
|
|
231
|
+
|
|
232
|
+
def flush(self) -> None:
|
|
233
|
+
"""There is no transcript buffer for a built-in run."""
|
|
234
|
+
|
|
235
|
+
def finish(self, *, ended_reason: str, error: str | None) -> None:
|
|
236
|
+
"""Report the terminal state and release the HTTP client."""
|
|
237
|
+
status = "failed" if error is not None else "ended"
|
|
238
|
+
with contextlib.suppress(PlatformError):
|
|
239
|
+
self._client.finish_local_pi_run(
|
|
240
|
+
self._org_id,
|
|
241
|
+
self._run_id,
|
|
242
|
+
status=status,
|
|
243
|
+
ended_reason=ended_reason,
|
|
244
|
+
error=error,
|
|
245
|
+
)
|
|
246
|
+
self._client.close()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TerminalEventSink:
|
|
250
|
+
"""Render the SessionEvent stream to the terminal and mirror it to a recorder."""
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
recorder: RunRecorder | None,
|
|
256
|
+
on_running: Callable[[bool], None],
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Render to the console; ``on_running`` tracks turn state for keepalive."""
|
|
259
|
+
self._recorder = recorder
|
|
260
|
+
self._on_running = on_running
|
|
261
|
+
|
|
262
|
+
def __call__(self, event: SessionEvent) -> None:
|
|
263
|
+
"""Render one event and mirror it (never raises: a sink must not stop the loop)."""
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
self._render(event)
|
|
266
|
+
if self._recorder is not None:
|
|
267
|
+
self._recorder.record(event)
|
|
268
|
+
|
|
269
|
+
def _render(self, event: SessionEvent) -> None:
|
|
270
|
+
payload = event.payload
|
|
271
|
+
if event.kind == "assistant_message":
|
|
272
|
+
text = str(payload.get("text", ""))
|
|
273
|
+
if text:
|
|
274
|
+
_console.print(f"\n[bold cyan]agent[/bold cyan] {text}")
|
|
275
|
+
elif event.kind == "tool_call":
|
|
276
|
+
_console.print(f"[dim]$ {payload.get('name', '')} {payload.get('arguments', '')}[/dim]")
|
|
277
|
+
elif event.kind == "tool_output":
|
|
278
|
+
_console.print(str(payload.get("text", "")), end="", markup=False, highlight=False)
|
|
279
|
+
elif event.kind == "tool_result":
|
|
280
|
+
if payload.get("is_error"):
|
|
281
|
+
_console.print(f"[red]{payload.get('content', '')}[/red]")
|
|
282
|
+
elif event.kind == "submit":
|
|
283
|
+
_console.print(f"\n[bold green]submitted[/bold green] {payload.get('answer', '')}")
|
|
284
|
+
elif event.kind == "state":
|
|
285
|
+
status = str(payload.get("status", ""))
|
|
286
|
+
self._on_running(status == "running")
|
|
287
|
+
_console.print(f"[dim]({status})[/dim]")
|
|
288
|
+
elif event.kind == "error":
|
|
289
|
+
_console.print(f"[red]error: {payload.get('message', '')}[/red]")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class StdinCommandReader(threading.Thread):
|
|
293
|
+
"""Feed typed stdin lines as steer/interrupt/end intents to the session."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, session: LiveSession) -> None:
|
|
296
|
+
"""Read stdin on a daemon thread; the session's intents are thread-safe."""
|
|
297
|
+
super().__init__(daemon=True)
|
|
298
|
+
self._session = session
|
|
299
|
+
self.eof = threading.Event()
|
|
300
|
+
|
|
301
|
+
def run(self) -> None:
|
|
302
|
+
"""Map each line to an intent until end-of-input or the session closes."""
|
|
303
|
+
for raw in sys.stdin:
|
|
304
|
+
if self._session.closed:
|
|
305
|
+
return
|
|
306
|
+
line = raw.strip()
|
|
307
|
+
if line in {":quit", ":q", ":exit"}:
|
|
308
|
+
self._session.end()
|
|
309
|
+
return
|
|
310
|
+
if line == ":stop":
|
|
311
|
+
self._session.interrupt()
|
|
312
|
+
elif line:
|
|
313
|
+
self._session.send_user_message(line)
|
|
314
|
+
# The driver owns EOF handling. For a one-shot ``--task`` it must wait
|
|
315
|
+
# until the opening turn returns to idle before ending the session.
|
|
316
|
+
self.eof.set()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class LocalLiveDriver:
|
|
320
|
+
"""Own one local pi process + LiveSession and drive it against the local directory."""
|
|
321
|
+
|
|
322
|
+
def __init__(
|
|
323
|
+
self,
|
|
324
|
+
*,
|
|
325
|
+
jail_root: Path,
|
|
326
|
+
doc: HarnessDoc,
|
|
327
|
+
provider: ToolCallingProvider | None,
|
|
328
|
+
worker_fn: Callable[[ChatRequest], ChatResponse] | None,
|
|
329
|
+
recorder: RunRecorder | None,
|
|
330
|
+
instruction: str | None,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Configure the driver; ``run`` performs boot, loop, and teardown."""
|
|
333
|
+
self._jail = jail_root
|
|
334
|
+
self._doc = doc
|
|
335
|
+
self._provider = provider
|
|
336
|
+
self._worker_fn = worker_fn
|
|
337
|
+
self._recorder = recorder
|
|
338
|
+
self._instruction = instruction
|
|
339
|
+
self._executor = LocalToolExecutor(jail_root)
|
|
340
|
+
self._channel: LocalStdioChannel | None = None
|
|
341
|
+
self._interrupts = 0
|
|
342
|
+
|
|
343
|
+
def run(self) -> None:
|
|
344
|
+
"""Boot the local runner, drive the session, and always tear down."""
|
|
345
|
+
system, tool_specs, files, skill_bodies = _assemble(self._doc)
|
|
346
|
+
_console.print("[dim]starting the built-in pi harness locally...[/dim]")
|
|
347
|
+
session: LiveSession | None = None
|
|
348
|
+
reason = "user_ended"
|
|
349
|
+
error: str | None = None
|
|
350
|
+
try:
|
|
351
|
+
channel = start_local_live_runner()
|
|
352
|
+
self._channel = channel
|
|
353
|
+
session = LiveSession(
|
|
354
|
+
channel,
|
|
355
|
+
tools=tool_specs,
|
|
356
|
+
execute_tool=self._execute,
|
|
357
|
+
on_event=TerminalEventSink(
|
|
358
|
+
recorder=self._recorder, on_running=lambda _running: None
|
|
359
|
+
),
|
|
360
|
+
files=files,
|
|
361
|
+
system_prompt=system,
|
|
362
|
+
skill_bodies=skill_bodies,
|
|
363
|
+
provider=self._provider,
|
|
364
|
+
worker_fn=self._worker_fn,
|
|
365
|
+
max_output_tokens=self._doc.max_output_tokens(),
|
|
366
|
+
temperature=self._doc.temperature(),
|
|
367
|
+
)
|
|
368
|
+
session.start()
|
|
369
|
+
_console.print(
|
|
370
|
+
"[green]session ready[/green] - type to steer, [bold]:stop[/bold] to interrupt, "
|
|
371
|
+
"[bold]:quit[/bold] to end."
|
|
372
|
+
)
|
|
373
|
+
if self._instruction:
|
|
374
|
+
session.send_user_message(self._instruction)
|
|
375
|
+
reader = StdinCommandReader(session)
|
|
376
|
+
reader.start()
|
|
377
|
+
stdin_eof = getattr(reader, "eof", threading.Event())
|
|
378
|
+
self._loop(session, stdin_eof)
|
|
379
|
+
if session.status == "failed":
|
|
380
|
+
error = "local live session runner failed"
|
|
381
|
+
reason = "error"
|
|
382
|
+
_console.print(f"[red]session failed: {error}[/red]")
|
|
383
|
+
except Exception as exc: # noqa: BLE001 - report any driver failure, then tear down
|
|
384
|
+
error = str(exc)
|
|
385
|
+
reason = "error"
|
|
386
|
+
_console.print(f"[red]session failed: {exc}[/red]")
|
|
387
|
+
finally:
|
|
388
|
+
self._teardown(session, reason=reason, error=error)
|
|
389
|
+
if error is not None:
|
|
390
|
+
raise typer.Exit(code=1)
|
|
391
|
+
|
|
392
|
+
def _execute(
|
|
393
|
+
self, name: str, args: JsonObject, emit: Callable[[str, str], None]
|
|
394
|
+
) -> ToolOutcome:
|
|
395
|
+
"""Run one tool locally (each tool blocks the session pump)."""
|
|
396
|
+
return self._executor(name, args, emit)
|
|
397
|
+
|
|
398
|
+
def _loop(self, session: LiveSession, stdin_eof: threading.Event) -> None:
|
|
399
|
+
"""Pump until closed, treating closed stdin as one-shot after ``--task``."""
|
|
400
|
+
last_tick = 0.0
|
|
401
|
+
saw_running = False
|
|
402
|
+
end_sent = False
|
|
403
|
+
while not session.closed:
|
|
404
|
+
try:
|
|
405
|
+
session.pump(timeout=0.5)
|
|
406
|
+
except KeyboardInterrupt:
|
|
407
|
+
self._handle_sigint(session)
|
|
408
|
+
saw_running = saw_running or session.status == "running"
|
|
409
|
+
if (
|
|
410
|
+
stdin_eof.is_set()
|
|
411
|
+
and not end_sent
|
|
412
|
+
and (self._instruction is None or (saw_running and session.status == "idle"))
|
|
413
|
+
):
|
|
414
|
+
session.end()
|
|
415
|
+
end_sent = True
|
|
416
|
+
now = time.monotonic()
|
|
417
|
+
if now - last_tick >= _TICK_S:
|
|
418
|
+
last_tick = now
|
|
419
|
+
if self._recorder is not None:
|
|
420
|
+
self._recorder.flush()
|
|
421
|
+
|
|
422
|
+
def _handle_sigint(self, session: LiveSession) -> None:
|
|
423
|
+
"""First Ctrl-C interrupts the current turn; a second ends the session."""
|
|
424
|
+
self._interrupts += 1
|
|
425
|
+
if self._interrupts == 1:
|
|
426
|
+
_console.print("\n[yellow]interrupting (press Ctrl-C again to quit)[/yellow]")
|
|
427
|
+
session.interrupt()
|
|
428
|
+
else:
|
|
429
|
+
_console.print("\n[yellow]ending session[/yellow]")
|
|
430
|
+
session.end()
|
|
431
|
+
|
|
432
|
+
def _teardown(self, session: LiveSession | None, *, reason: str, error: str | None) -> None:
|
|
433
|
+
if session is not None and not session.closed:
|
|
434
|
+
with contextlib.suppress(Exception):
|
|
435
|
+
session.end()
|
|
436
|
+
if self._recorder is not None:
|
|
437
|
+
self._recorder.finish(ended_reason=reason, error=error)
|
|
438
|
+
if self._channel is not None:
|
|
439
|
+
with contextlib.suppress(Exception):
|
|
440
|
+
self._channel.close()
|
|
441
|
+
_console.print(f"[dim]session ended ({reason})[/dim]")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class RemoteAgentCommandReader(threading.Thread):
|
|
445
|
+
"""Forward terminal lines to a platform-owned E2B agent session."""
|
|
446
|
+
|
|
447
|
+
def __init__(self, client: PlatformClient, agent_id: str, session_id: str) -> None:
|
|
448
|
+
"""Store the hosted command-channel identity."""
|
|
449
|
+
super().__init__(daemon=True)
|
|
450
|
+
self._client = client
|
|
451
|
+
self._agent_id = agent_id
|
|
452
|
+
self._session_id = session_id
|
|
453
|
+
self.eof = threading.Event()
|
|
454
|
+
self.detach = threading.Event()
|
|
455
|
+
|
|
456
|
+
def run(self) -> None:
|
|
457
|
+
"""Map stdin lines to hosted steer, interrupt, detach, and end commands."""
|
|
458
|
+
try:
|
|
459
|
+
for raw in sys.stdin:
|
|
460
|
+
line = raw.strip()
|
|
461
|
+
if line in {":quit", ":q", ":exit", ":end"}:
|
|
462
|
+
if self._post("end"):
|
|
463
|
+
return
|
|
464
|
+
elif line == ":detach":
|
|
465
|
+
self.detach.set()
|
|
466
|
+
return
|
|
467
|
+
elif line == ":stop":
|
|
468
|
+
self._post("interrupt")
|
|
469
|
+
elif line.startswith(":"):
|
|
470
|
+
# An unknown command must never reach the agent as chat.
|
|
471
|
+
_console.print(
|
|
472
|
+
f"[yellow]unknown command {line}; use :stop, :detach, or :quit[/yellow]"
|
|
473
|
+
)
|
|
474
|
+
elif line:
|
|
475
|
+
self._post("user_message", text=line)
|
|
476
|
+
except OSError:
|
|
477
|
+
pass
|
|
478
|
+
finally:
|
|
479
|
+
# EOF keeps its plain-run meaning (end once the run settles); a
|
|
480
|
+
# detach stopped reading deliberately and must not look like EOF.
|
|
481
|
+
if not self.detach.is_set():
|
|
482
|
+
self.eof.set()
|
|
483
|
+
|
|
484
|
+
def _post(self, kind: str, *, text: str | None = None) -> bool:
|
|
485
|
+
"""Post one command; a transient failure warns and keeps the reader alive.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Whether the command was accepted.
|
|
489
|
+
"""
|
|
490
|
+
try:
|
|
491
|
+
self._client.post_agent_session_command(
|
|
492
|
+
self._agent_id, self._session_id, kind, text=text
|
|
493
|
+
)
|
|
494
|
+
except PlatformError as error:
|
|
495
|
+
_console.print(f"[red]{kind} failed:[/red] {error} (still attached; try again)")
|
|
496
|
+
return False
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class RemoteAgentDriver:
|
|
501
|
+
"""Stream a hosted E2B agent, optionally syncing one local workspace."""
|
|
502
|
+
|
|
503
|
+
def __init__(
|
|
504
|
+
self,
|
|
505
|
+
client: PlatformClient,
|
|
506
|
+
target_id: str,
|
|
507
|
+
name: str,
|
|
508
|
+
jail_root: Path | None,
|
|
509
|
+
task: str | None,
|
|
510
|
+
*,
|
|
511
|
+
credentials: PlatformCredentials,
|
|
512
|
+
state_store: SessionStateStore,
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Store the resolved agent, workspace root, and detach-promotion state."""
|
|
515
|
+
self._client = client
|
|
516
|
+
self._target_id = target_id
|
|
517
|
+
self._name = name
|
|
518
|
+
self._jail = jail_root
|
|
519
|
+
self._task = task
|
|
520
|
+
self._credentials = credentials
|
|
521
|
+
self._store = state_store
|
|
522
|
+
self._interrupts = 0
|
|
523
|
+
self._cursor = 0
|
|
524
|
+
|
|
525
|
+
def run(self) -> None:
|
|
526
|
+
"""Run and stream one E2B session, syncing local files only when requested."""
|
|
527
|
+
try:
|
|
528
|
+
initial: WorkspaceSnapshot | None = None
|
|
529
|
+
if self._jail is not None:
|
|
530
|
+
with _console.status("[dim]snapshotting local workspace...[/dim]", spinner="dots"):
|
|
531
|
+
initial = snapshot_workspace(self._jail)
|
|
532
|
+
_console.print(
|
|
533
|
+
f"[dim]uploading {len(initial.files)} files to the platform "
|
|
534
|
+
"E2B workspace...[/dim]"
|
|
535
|
+
)
|
|
536
|
+
session = self._client.create_agent_session(
|
|
537
|
+
self._target_id,
|
|
538
|
+
workspace=initial.archive if initial is not None else None,
|
|
539
|
+
instruction=self._task,
|
|
540
|
+
)
|
|
541
|
+
workspace: LiveWorkspace | None = None
|
|
542
|
+
if self._jail is not None and initial is not None:
|
|
543
|
+
workspace = LiveWorkspace(
|
|
544
|
+
self._client, self._target_id, session.id, self._jail, initial
|
|
545
|
+
)
|
|
546
|
+
quit_detail = "end and sync back" if initial is not None else "end"
|
|
547
|
+
_console.print(
|
|
548
|
+
f"[green]E2B session started[/green] for [bold]{self._name}[/bold]. "
|
|
549
|
+
"Type to steer, [bold]:stop[/bold] to interrupt, [bold]:detach[/bold] to "
|
|
550
|
+
f"leave it running, [bold]:quit[/bold] to {quit_detail}."
|
|
551
|
+
)
|
|
552
|
+
reader = RemoteAgentCommandReader(self._client, self._target_id, session.id)
|
|
553
|
+
reader.start()
|
|
554
|
+
stdin_eof = getattr(reader, "eof", threading.Event())
|
|
555
|
+
detach = getattr(reader, "detach", threading.Event())
|
|
556
|
+
terminal = self._poll(session.id, workspace, stdin_eof, detach)
|
|
557
|
+
if terminal is None:
|
|
558
|
+
self._promote(session.id, workspace)
|
|
559
|
+
return
|
|
560
|
+
workspace_conflicts = False
|
|
561
|
+
if workspace is not None:
|
|
562
|
+
workspace_conflicts = bool(workspace.finalize().conflicts)
|
|
563
|
+
if terminal.status == "failed":
|
|
564
|
+
_console.print(f"[red]session failed: {terminal.error or 'unknown error'}[/red]")
|
|
565
|
+
raise typer.Exit(code=1)
|
|
566
|
+
if workspace_conflicts:
|
|
567
|
+
raise typer.Exit(code=2)
|
|
568
|
+
except (WorkspacePatchError, WorkspaceSyncError, SessionStateError) as error:
|
|
569
|
+
raise typer.BadParameter(str(error)) from error
|
|
570
|
+
except PlatformError as error:
|
|
571
|
+
raise typer.BadParameter(str(error)) from error
|
|
572
|
+
finally:
|
|
573
|
+
self._client.close()
|
|
574
|
+
|
|
575
|
+
def _advance_cursor(self, seq: int) -> None:
|
|
576
|
+
"""Mark one transcript event as processed."""
|
|
577
|
+
self._cursor = seq
|
|
578
|
+
|
|
579
|
+
def _promote(self, session_id: str, workspace: LiveWorkspace | None) -> None:
|
|
580
|
+
"""Persist the live session as the current detached session and exit.
|
|
581
|
+
|
|
582
|
+
The transcript cursor and (with -u) the last synchronized snapshot are
|
|
583
|
+
checkpointed exactly as a `--detach` start would have left them, so the
|
|
584
|
+
next send/attach/end catches up from here. No final workspace sync
|
|
585
|
+
runs: the session stays alive.
|
|
586
|
+
"""
|
|
587
|
+
checkpoint: WorkspaceCheckpoint | None = None
|
|
588
|
+
base_archive: bytes | None = None
|
|
589
|
+
if workspace is not None and self._jail is not None:
|
|
590
|
+
checkpoint = WorkspaceCheckpoint(
|
|
591
|
+
root=str(self._jail), conflicts=tuple(sorted(workspace.conflicts))
|
|
592
|
+
)
|
|
593
|
+
base_archive = workspace.synchronized.archive
|
|
594
|
+
state = DetachedSessionState(
|
|
595
|
+
api_url=str(self._credentials.api_url),
|
|
596
|
+
web_url=self._credentials.web_url,
|
|
597
|
+
agent_id=self._target_id,
|
|
598
|
+
agent_name=self._name,
|
|
599
|
+
session_id=session_id,
|
|
600
|
+
created_at=datetime.now(tz=UTC).isoformat(),
|
|
601
|
+
cursor=self._cursor,
|
|
602
|
+
workspace=checkpoint,
|
|
603
|
+
)
|
|
604
|
+
try:
|
|
605
|
+
self._store.save(state, base_archive=base_archive)
|
|
606
|
+
self._store.set_current(session_id)
|
|
607
|
+
except SessionStateError as error:
|
|
608
|
+
# The hosted session keeps running; the user must get an
|
|
609
|
+
# addressable reference even though the local save failed.
|
|
610
|
+
msg = (
|
|
611
|
+
f"session {session_id} is still running on the platform, but its local "
|
|
612
|
+
f"reference could not be saved: {error}. Control it with "
|
|
613
|
+
f"`wmo run --session {session_id} --attach` or end it with "
|
|
614
|
+
f"`wmo run --session {session_id} --end`"
|
|
615
|
+
)
|
|
616
|
+
raise typer.BadParameter(msg) from error
|
|
617
|
+
_console.print(
|
|
618
|
+
f"[green]detached[/green]; session {session_id} stays alive as the "
|
|
619
|
+
"current session.\n"
|
|
620
|
+
'Send a message with [bold]wmo run -s "..."[/bold], attach with '
|
|
621
|
+
"[bold]wmo run -a[/bold], end with [bold]wmo run --end[/bold]."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
def _poll(
|
|
625
|
+
self,
|
|
626
|
+
session_id: str,
|
|
627
|
+
workspace: LiveWorkspace | None,
|
|
628
|
+
stdin_eof: threading.Event,
|
|
629
|
+
detach: threading.Event,
|
|
630
|
+
) -> RemoteAgentSession | None:
|
|
631
|
+
"""Render transcript events until the row is terminal or the user detaches.
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
The terminal session record, or ``None`` when the user detached.
|
|
635
|
+
"""
|
|
636
|
+
last_workspace_push = time.monotonic()
|
|
637
|
+
sink = TerminalEventSink(recorder=None, on_running=lambda _running: None)
|
|
638
|
+
saw_running = False
|
|
639
|
+
end_sent = False
|
|
640
|
+
while True:
|
|
641
|
+
try:
|
|
642
|
+
page = self._client.list_agent_session_events(
|
|
643
|
+
self._target_id, session_id, after=self._cursor
|
|
644
|
+
)
|
|
645
|
+
for event in page.events:
|
|
646
|
+
if event.kind == "workspace_patch":
|
|
647
|
+
if workspace is None:
|
|
648
|
+
raise WorkspaceSyncError(
|
|
649
|
+
"received a workspace patch without --upload-dir"
|
|
650
|
+
)
|
|
651
|
+
# Advance before the ack posts: once the object is
|
|
652
|
+
# acknowledged (hence deleted), neither an interrupt
|
|
653
|
+
# resume nor a :detach promotion may re-request it.
|
|
654
|
+
workspace.apply_remote_patch(
|
|
655
|
+
patch_revision(event),
|
|
656
|
+
before_ack=lambda seq=event.seq: self._advance_cursor(seq),
|
|
657
|
+
)
|
|
658
|
+
elif event.kind == "status":
|
|
659
|
+
detail = event.payload.get("message") or event.payload.get("status")
|
|
660
|
+
if detail:
|
|
661
|
+
_console.print(f"[dim]({detail})[/dim]")
|
|
662
|
+
else:
|
|
663
|
+
sink(SessionEvent(kind=event.kind, payload=event.payload))
|
|
664
|
+
if event.kind == "state":
|
|
665
|
+
state = event.payload.get("status")
|
|
666
|
+
saw_running = saw_running or state == "running"
|
|
667
|
+
if (
|
|
668
|
+
stdin_eof.is_set()
|
|
669
|
+
and not end_sent
|
|
670
|
+
and saw_running
|
|
671
|
+
and state == "idle"
|
|
672
|
+
):
|
|
673
|
+
self._client.post_agent_session_command(
|
|
674
|
+
self._target_id, session_id, "end"
|
|
675
|
+
)
|
|
676
|
+
end_sent = True
|
|
677
|
+
# Per event, not per page: a Ctrl-C mid-page must resume
|
|
678
|
+
# (and promote) after the events already processed.
|
|
679
|
+
self._cursor = event.seq
|
|
680
|
+
self._cursor = page.last_seq
|
|
681
|
+
if page.status in {"ended", "failed"}:
|
|
682
|
+
return self._client.get_agent_session(self._target_id, session_id)
|
|
683
|
+
if detach.is_set():
|
|
684
|
+
return None
|
|
685
|
+
now = time.monotonic()
|
|
686
|
+
if stdin_eof.is_set() and self._task is None and not end_sent:
|
|
687
|
+
self._client.post_agent_session_command(self._target_id, session_id, "end")
|
|
688
|
+
end_sent = True
|
|
689
|
+
if workspace is not None and now - last_workspace_push >= _WORKSPACE_SYNC_TICK_S:
|
|
690
|
+
workspace.try_push_local()
|
|
691
|
+
last_workspace_push = now
|
|
692
|
+
time.sleep(0.5)
|
|
693
|
+
except KeyboardInterrupt:
|
|
694
|
+
self._interrupts += 1
|
|
695
|
+
kind = "interrupt" if self._interrupts == 1 else "end"
|
|
696
|
+
# The next Ctrl-C frequently lands inside this handler (the
|
|
697
|
+
# print or the HTTP post); escalate to the end action instead
|
|
698
|
+
# of crashing out with the session still live.
|
|
699
|
+
try:
|
|
700
|
+
_console.print(
|
|
701
|
+
"\n[yellow]interrupting (press Ctrl-C again to end)[/yellow]"
|
|
702
|
+
if kind == "interrupt"
|
|
703
|
+
else "\n[yellow]ending session[/yellow]"
|
|
704
|
+
)
|
|
705
|
+
self._client.post_agent_session_command(self._target_id, session_id, kind)
|
|
706
|
+
except KeyboardInterrupt:
|
|
707
|
+
self._interrupts += 1
|
|
708
|
+
with contextlib.suppress(KeyboardInterrupt, PlatformError):
|
|
709
|
+
_console.print("\n[yellow]ending session[/yellow]")
|
|
710
|
+
self._client.post_agent_session_command(self._target_id, session_id, "end")
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class RemoteWorldModelDriver:
|
|
715
|
+
"""Interactive terminal loop over the platform's world-model session API."""
|
|
716
|
+
|
|
717
|
+
def __init__(self, client: PlatformClient, target_id: str, name: str, task: str | None) -> None:
|
|
718
|
+
"""Store the resolved target and opening task."""
|
|
719
|
+
self._client = client
|
|
720
|
+
self._target_id = target_id
|
|
721
|
+
self._name = name
|
|
722
|
+
self._task = task
|
|
723
|
+
|
|
724
|
+
def run(self) -> None:
|
|
725
|
+
"""Create one hosted session and step it until the user exits."""
|
|
726
|
+
try:
|
|
727
|
+
session = self._client.create_world_model_session(self._target_id, task=self._task)
|
|
728
|
+
_console.print(
|
|
729
|
+
Panel(
|
|
730
|
+
'Type an action such as [cyan]search {"q": "SFO"}[/cyan], '
|
|
731
|
+
"or a free-text message. Commands: [cyan]:help[/cyan], [cyan]:quit[/cyan].",
|
|
732
|
+
title=f"[bold]running world model[/bold] {self._name}",
|
|
733
|
+
subtitle=f"task: {self._task}" if self._task else "no task set",
|
|
734
|
+
border_style="cyan",
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
self._loop(session.id)
|
|
738
|
+
except PlatformError as error:
|
|
739
|
+
raise typer.BadParameter(str(error)) from error
|
|
740
|
+
finally:
|
|
741
|
+
self._client.close()
|
|
742
|
+
|
|
743
|
+
def _loop(self, session_id: str) -> None:
|
|
744
|
+
"""Read actions and render hosted observations."""
|
|
745
|
+
while True:
|
|
746
|
+
try:
|
|
747
|
+
line = _console.input("[bold]agent>[/bold] ").strip()
|
|
748
|
+
except (EOFError, KeyboardInterrupt):
|
|
749
|
+
_console.print("\n[dim]bye[/dim]")
|
|
750
|
+
return
|
|
751
|
+
if not line:
|
|
752
|
+
continue
|
|
753
|
+
if line in {":quit", ":q", ":exit"}:
|
|
754
|
+
_console.print("[dim]bye[/dim]")
|
|
755
|
+
return
|
|
756
|
+
if line == ":detach":
|
|
757
|
+
_console.print(
|
|
758
|
+
"[yellow]world-model sessions are interactive only; :detach works "
|
|
759
|
+
"for hosted agent sessions. Use :quit to leave.[/yellow]"
|
|
760
|
+
)
|
|
761
|
+
continue
|
|
762
|
+
if line in {":help", ":h"}:
|
|
763
|
+
_console.print(
|
|
764
|
+
'Tool call: [cyan]name {"arg": "value"}[/cyan]. '
|
|
765
|
+
"Any other text is sent as a message."
|
|
766
|
+
)
|
|
767
|
+
continue
|
|
768
|
+
try:
|
|
769
|
+
action = parse_action(line)
|
|
770
|
+
except ValueError as error:
|
|
771
|
+
_console.print(f"[red]parse error[/red]: {error}")
|
|
772
|
+
continue
|
|
773
|
+
try:
|
|
774
|
+
with _console.status("[dim]world model thinking...[/dim]", spinner="dots"):
|
|
775
|
+
observation = self._client.step_world_model_session(session_id, action)
|
|
776
|
+
except PlatformError as error:
|
|
777
|
+
_console.print(f"[red]step failed[/red]: {error}")
|
|
778
|
+
continue
|
|
779
|
+
style = "red" if observation.is_error else "green"
|
|
780
|
+
title = "error" if observation.is_error else "observation"
|
|
781
|
+
_console.print(
|
|
782
|
+
Panel(
|
|
783
|
+
observation.content or "[dim](empty)[/dim]",
|
|
784
|
+
title=title,
|
|
785
|
+
border_style=style,
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _local_worker_provider(provider: str | None, model: str | None) -> ToolCallingProvider:
|
|
791
|
+
"""Build the logged-out worker provider from local environment credentials."""
|
|
792
|
+
configured = load_settings().models.resolve("worker")
|
|
793
|
+
provider_name = provider or (
|
|
794
|
+
configured.provider if configured is not None else _DEFAULT_PROVIDER
|
|
795
|
+
)
|
|
796
|
+
model_name = model or (configured.model if configured is not None else _DEFAULT_MODEL)
|
|
797
|
+
try:
|
|
798
|
+
kind = ProviderKind(provider_name)
|
|
799
|
+
except ValueError:
|
|
800
|
+
kinds = ", ".join(k.value for k in ProviderKind)
|
|
801
|
+
msg = f"unknown provider {provider_name!r}; choose one of: {kinds}"
|
|
802
|
+
raise typer.BadParameter(msg) from None
|
|
803
|
+
spec = resolve_provider_model(kind, model_name)
|
|
804
|
+
use_configured_knobs = configured is not None and configured.provider == kind.value
|
|
805
|
+
built = get_provider(
|
|
806
|
+
ProviderConfig(
|
|
807
|
+
kind=kind,
|
|
808
|
+
model_type=spec.model_type,
|
|
809
|
+
model=spec.model_id,
|
|
810
|
+
region=configured.region if use_configured_knobs else None,
|
|
811
|
+
endpoint=configured.endpoint if use_configured_knobs else None,
|
|
812
|
+
deployment=configured.deployment if use_configured_knobs else None,
|
|
813
|
+
api_version=configured.api_version if use_configured_knobs else None,
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
if not isinstance(built, ToolCallingProvider):
|
|
817
|
+
msg = f"provider {kind.value}/{spec.model_id} does not support structured tool calling"
|
|
818
|
+
raise typer.BadParameter(msg)
|
|
819
|
+
return built
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
_TARGET_ARG = typer.Argument(
|
|
823
|
+
help="Platform world-model or agent id (omit to run the built-in pi harness locally)."
|
|
824
|
+
)
|
|
825
|
+
_DIR_OPT = typer.Option("--dir", help="Working directory for the built-in local pi harness.")
|
|
826
|
+
_UPLOAD_DIR_OPT = typer.Option(
|
|
827
|
+
"-u", "--upload-dir", help="Directory to upload and live-sync for a hosted agent."
|
|
828
|
+
)
|
|
829
|
+
_PROVIDER_OPT = typer.Option(
|
|
830
|
+
"--provider", help="Worker provider for the built-in local pi harness."
|
|
831
|
+
)
|
|
832
|
+
_MODEL_OPT = typer.Option("--model", help="Worker model for the built-in local pi harness.")
|
|
833
|
+
_TASK_OPT = typer.Option("--task", "--instruction", help="Opening task for either execution kind.")
|
|
834
|
+
_YES_OPT = typer.Option("--yes", help="Skip the local-execution consent prompt.")
|
|
835
|
+
_DETACH_OPT = typer.Option(
|
|
836
|
+
"-d", "--detach", help="Start the hosted agent session and return, leaving it running."
|
|
837
|
+
)
|
|
838
|
+
_SEND_OPT = typer.Option(
|
|
839
|
+
"-s", "--send", help="Send one message to the current (or --session) session and stream it."
|
|
840
|
+
)
|
|
841
|
+
_ATTACH_OPT = typer.Option(
|
|
842
|
+
"-a", "--attach", help="Attach interactively to the current (or --session) session."
|
|
843
|
+
)
|
|
844
|
+
_END_OPT = typer.Option(
|
|
845
|
+
"--end", help="End the current (or --session) session after a final workspace sync."
|
|
846
|
+
)
|
|
847
|
+
_SESSION_OPT = typer.Option(
|
|
848
|
+
"--session", help="Hosted session id to address instead of the current session."
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def register(app: typer.Typer) -> None:
|
|
853
|
+
"""Register the unified ``wmo run`` command on the root app."""
|
|
854
|
+
|
|
855
|
+
@app.command("run")
|
|
856
|
+
def run(
|
|
857
|
+
target: Annotated[str | None, _TARGET_ARG] = None,
|
|
858
|
+
directory: Annotated[str | None, _DIR_OPT] = None,
|
|
859
|
+
upload_directory: Annotated[str | None, _UPLOAD_DIR_OPT] = None,
|
|
860
|
+
provider: Annotated[str | None, _PROVIDER_OPT] = None,
|
|
861
|
+
model: Annotated[str | None, _MODEL_OPT] = None,
|
|
862
|
+
task: Annotated[str | None, _TASK_OPT] = None,
|
|
863
|
+
yes: Annotated[bool, _YES_OPT] = False,
|
|
864
|
+
detach: Annotated[bool, _DETACH_OPT] = False,
|
|
865
|
+
send: Annotated[str | None, _SEND_OPT] = None,
|
|
866
|
+
attach: Annotated[bool, _ATTACH_OPT] = False,
|
|
867
|
+
end: Annotated[bool, _END_OPT] = False,
|
|
868
|
+
session: Annotated[str | None, _SESSION_OPT] = None,
|
|
869
|
+
) -> None:
|
|
870
|
+
"""Run a platform world model/agent by id, or the built-in pi harness."""
|
|
871
|
+
action = _session_action(send=send, attach=attach, end=end)
|
|
872
|
+
if action is not None:
|
|
873
|
+
_reject_run_options_for_session_commands(
|
|
874
|
+
target=target,
|
|
875
|
+
directory=directory,
|
|
876
|
+
upload_directory=upload_directory,
|
|
877
|
+
provider=provider,
|
|
878
|
+
model=model,
|
|
879
|
+
task=task,
|
|
880
|
+
detach=detach,
|
|
881
|
+
)
|
|
882
|
+
_build_session_command_driver(action=action, text=send, session_override=session).run()
|
|
883
|
+
return
|
|
884
|
+
if session is not None:
|
|
885
|
+
raise typer.BadParameter("--session requires --send, --attach, or --end")
|
|
886
|
+
if detach and target is None:
|
|
887
|
+
raise typer.BadParameter("--detach requires a platform agent id")
|
|
888
|
+
if target is None and upload_directory is not None:
|
|
889
|
+
raise typer.BadParameter("--upload-dir is only supported for platform agent ids")
|
|
890
|
+
if target is not None and directory is not None:
|
|
891
|
+
raise typer.BadParameter(
|
|
892
|
+
"--dir is only supported for a bare `wmo run`; use --upload-dir for an agent"
|
|
893
|
+
)
|
|
894
|
+
if target is None:
|
|
895
|
+
path = directory or "."
|
|
896
|
+
else:
|
|
897
|
+
path = upload_directory
|
|
898
|
+
jail_root = Path(path).resolve() if path is not None else None
|
|
899
|
+
if jail_root is not None and not jail_root.is_dir():
|
|
900
|
+
msg = f"working directory does not exist: {jail_root}"
|
|
901
|
+
raise typer.BadParameter(msg)
|
|
902
|
+
confirm_local: Callable[[], None] | None = None
|
|
903
|
+
if target is None and jail_root is not None:
|
|
904
|
+
local_root = jail_root
|
|
905
|
+
|
|
906
|
+
def confirm_execution() -> None:
|
|
907
|
+
"""Confirm the bare harness's local execution boundary."""
|
|
908
|
+
_confirm_local_execution(local_root, target=target, yes=yes)
|
|
909
|
+
|
|
910
|
+
confirm_local = confirm_execution
|
|
911
|
+
driver = _build_driver(
|
|
912
|
+
target=target,
|
|
913
|
+
jail_root=jail_root,
|
|
914
|
+
provider=provider,
|
|
915
|
+
model=model,
|
|
916
|
+
task=task,
|
|
917
|
+
confirm_local=confirm_local,
|
|
918
|
+
detach=detach,
|
|
919
|
+
)
|
|
920
|
+
driver.run()
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _session_action(*, send: str | None, attach: bool, end: bool) -> SessionAction | None:
|
|
924
|
+
"""The single detached-session action requested, or ``None`` for a plain run."""
|
|
925
|
+
chosen: list[SessionAction] = []
|
|
926
|
+
if send is not None:
|
|
927
|
+
chosen.append("send")
|
|
928
|
+
if attach:
|
|
929
|
+
chosen.append("attach")
|
|
930
|
+
if end:
|
|
931
|
+
chosen.append("end")
|
|
932
|
+
if not chosen:
|
|
933
|
+
return None
|
|
934
|
+
if len(chosen) > 1:
|
|
935
|
+
raise typer.BadParameter("--send, --attach, and --end are mutually exclusive")
|
|
936
|
+
return chosen[0]
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _reject_run_options_for_session_commands(
|
|
940
|
+
*,
|
|
941
|
+
target: str | None,
|
|
942
|
+
directory: str | None,
|
|
943
|
+
upload_directory: str | None,
|
|
944
|
+
provider: str | None,
|
|
945
|
+
model: str | None,
|
|
946
|
+
task: str | None,
|
|
947
|
+
detach: bool,
|
|
948
|
+
) -> None:
|
|
949
|
+
"""Keep run-start options off the commands that address an existing session."""
|
|
950
|
+
if detach:
|
|
951
|
+
raise typer.BadParameter(
|
|
952
|
+
"--detach starts a new session; it cannot be combined with --send/--attach/--end"
|
|
953
|
+
)
|
|
954
|
+
if target is not None:
|
|
955
|
+
raise typer.BadParameter(
|
|
956
|
+
"--send/--attach/--end address an existing session; omit the agent id "
|
|
957
|
+
"(use --session <session-id> to pick one)"
|
|
958
|
+
)
|
|
959
|
+
if upload_directory is not None or directory is not None:
|
|
960
|
+
raise typer.BadParameter(
|
|
961
|
+
"workspace sync is chosen when the session starts "
|
|
962
|
+
"(`wmo run <agent-id> -u PATH --detach`); it cannot be changed afterwards"
|
|
963
|
+
)
|
|
964
|
+
if provider is not None or model is not None:
|
|
965
|
+
raise typer.BadParameter(
|
|
966
|
+
"hosted sessions use platform credentials; --provider/--model are not accepted"
|
|
967
|
+
)
|
|
968
|
+
if task is not None:
|
|
969
|
+
raise typer.BadParameter(
|
|
970
|
+
"--task only applies when starting a run; use --send to message the session"
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _build_session_command_driver(
|
|
975
|
+
*, action: SessionAction, text: str | None, session_override: str | None
|
|
976
|
+
) -> DetachedCommandDriver:
|
|
977
|
+
"""Assemble the authenticated driver behind --send/--attach/--end."""
|
|
978
|
+
credentials = load_credentials()
|
|
979
|
+
if not credentials.is_complete():
|
|
980
|
+
raise typer.BadParameter("run `wmo login` to use hosted agent sessions")
|
|
981
|
+
client = PlatformClient(str(credentials.api_url), str(credentials.token))
|
|
982
|
+
return DetachedCommandDriver(
|
|
983
|
+
client=client,
|
|
984
|
+
credentials=credentials,
|
|
985
|
+
state_store=SessionStateStore(),
|
|
986
|
+
action=action,
|
|
987
|
+
text=text,
|
|
988
|
+
session_override=session_override,
|
|
989
|
+
sink=TerminalEventSink(recorder=None, on_running=lambda _running: None),
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _confirm_local_execution(jail_root: Path, *, target: str | None, yes: bool) -> None:
|
|
994
|
+
"""Warn that harness code and bash run with local user permissions."""
|
|
995
|
+
label = f"agent {target}" if target else "the built-in pi harness"
|
|
996
|
+
_console.print(
|
|
997
|
+
f"[bold yellow]{label}, its harness code, and shell commands run on THIS machine"
|
|
998
|
+
"[/bold yellow].\n"
|
|
999
|
+
f"File tools stay under {jail_root}, and bash starts there, but bash is not "
|
|
1000
|
+
"OS-sandboxed and can access anything your user can."
|
|
1001
|
+
)
|
|
1002
|
+
if not yes and not typer.confirm("continue?"):
|
|
1003
|
+
raise typer.Exit(code=1)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _build_driver(
|
|
1007
|
+
*,
|
|
1008
|
+
target: str | None,
|
|
1009
|
+
jail_root: Path | None,
|
|
1010
|
+
provider: str | None,
|
|
1011
|
+
model: str | None,
|
|
1012
|
+
task: str | None,
|
|
1013
|
+
confirm_local: Callable[[], None] | None = None,
|
|
1014
|
+
detach: bool = False,
|
|
1015
|
+
) -> LocalLiveDriver | RemoteAgentDriver | RemoteWorldModelDriver | DetachedStartDriver:
|
|
1016
|
+
"""Resolve the target kind once and assemble its execution driver."""
|
|
1017
|
+
credentials = load_credentials()
|
|
1018
|
+
logged_in = credentials.is_complete()
|
|
1019
|
+
|
|
1020
|
+
if target is None:
|
|
1021
|
+
if jail_root is None:
|
|
1022
|
+
raise typer.BadParameter("a working directory is required for the built-in pi harness")
|
|
1023
|
+
if not logged_in:
|
|
1024
|
+
if confirm_local is not None:
|
|
1025
|
+
confirm_local()
|
|
1026
|
+
_console.print(
|
|
1027
|
+
"[dim]not logged in: running the built-in baseline agent with local "
|
|
1028
|
+
"credentials[/dim]"
|
|
1029
|
+
)
|
|
1030
|
+
return LocalLiveDriver(
|
|
1031
|
+
jail_root=jail_root,
|
|
1032
|
+
doc=_pi_node_baseline(),
|
|
1033
|
+
provider=_local_worker_provider(provider, model),
|
|
1034
|
+
worker_fn=None,
|
|
1035
|
+
recorder=None,
|
|
1036
|
+
instruction=task,
|
|
1037
|
+
)
|
|
1038
|
+
if provider is not None or model is not None:
|
|
1039
|
+
raise typer.BadParameter(
|
|
1040
|
+
"logged-in runs use platform credentials; omit --provider/--model, "
|
|
1041
|
+
"or run `wmo logout` to use local credentials"
|
|
1042
|
+
)
|
|
1043
|
+
if confirm_local is not None:
|
|
1044
|
+
confirm_local()
|
|
1045
|
+
client = PlatformClient(str(credentials.api_url), str(credentials.token))
|
|
1046
|
+
try:
|
|
1047
|
+
org_id = _default_org(client, credentials.default_org)
|
|
1048
|
+
run = client.create_local_pi_run(org_id)
|
|
1049
|
+
except typer.BadParameter:
|
|
1050
|
+
client.close()
|
|
1051
|
+
raise
|
|
1052
|
+
except PlatformError as error:
|
|
1053
|
+
client.close()
|
|
1054
|
+
raise typer.BadParameter(str(error)) from error
|
|
1055
|
+
recorder = LocalPiRunRecorder(client, org_id, run.id)
|
|
1056
|
+
|
|
1057
|
+
def built_in_worker(request: ChatRequest) -> ChatResponse:
|
|
1058
|
+
return client.complete_local_pi_worker(org_id, run.id, request)
|
|
1059
|
+
|
|
1060
|
+
return LocalLiveDriver(
|
|
1061
|
+
jail_root=jail_root,
|
|
1062
|
+
doc=_pi_node_baseline(),
|
|
1063
|
+
provider=None,
|
|
1064
|
+
worker_fn=built_in_worker,
|
|
1065
|
+
recorder=recorder,
|
|
1066
|
+
instruction=task,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
if not logged_in:
|
|
1070
|
+
msg = "run `wmo login` to run a platform id, or omit the id to run the built-in pi harness"
|
|
1071
|
+
raise typer.BadParameter(msg)
|
|
1072
|
+
|
|
1073
|
+
if provider is not None or model is not None:
|
|
1074
|
+
raise typer.BadParameter(
|
|
1075
|
+
"platform target runs use platform credentials; --provider/--model are not accepted"
|
|
1076
|
+
)
|
|
1077
|
+
client = PlatformClient(str(credentials.api_url), str(credentials.token))
|
|
1078
|
+
try:
|
|
1079
|
+
resolved = client.resolve_run_target(target)
|
|
1080
|
+
if resolved.kind == "world_model":
|
|
1081
|
+
if detach:
|
|
1082
|
+
client.close()
|
|
1083
|
+
raise typer.BadParameter(
|
|
1084
|
+
"world-model sessions are interactive only; --detach needs an agent id"
|
|
1085
|
+
)
|
|
1086
|
+
if jail_root is not None:
|
|
1087
|
+
client.close()
|
|
1088
|
+
raise typer.BadParameter("--upload-dir is only supported for agent ids")
|
|
1089
|
+
return RemoteWorldModelDriver(client, resolved.id, resolved.name, task)
|
|
1090
|
+
if detach:
|
|
1091
|
+
return DetachedStartDriver(
|
|
1092
|
+
client=client,
|
|
1093
|
+
credentials=credentials,
|
|
1094
|
+
state_store=SessionStateStore(),
|
|
1095
|
+
target_id=resolved.id,
|
|
1096
|
+
name=resolved.name,
|
|
1097
|
+
jail_root=jail_root,
|
|
1098
|
+
task=task,
|
|
1099
|
+
)
|
|
1100
|
+
return RemoteAgentDriver(
|
|
1101
|
+
client,
|
|
1102
|
+
resolved.id,
|
|
1103
|
+
resolved.name,
|
|
1104
|
+
jail_root,
|
|
1105
|
+
task,
|
|
1106
|
+
credentials=credentials,
|
|
1107
|
+
state_store=SessionStateStore(),
|
|
1108
|
+
)
|
|
1109
|
+
except PlatformError as error:
|
|
1110
|
+
client.close()
|
|
1111
|
+
raise typer.BadParameter(str(error)) from error
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _default_org(client: PlatformClient, configured: str | None) -> str:
|
|
1115
|
+
"""Resolve the login's organization, auto-picking only an unambiguous sole org."""
|
|
1116
|
+
if configured is not None:
|
|
1117
|
+
return configured
|
|
1118
|
+
identity = client.whoami()
|
|
1119
|
+
if len(identity.orgs) == 1:
|
|
1120
|
+
return identity.orgs[0].id
|
|
1121
|
+
raise typer.BadParameter(
|
|
1122
|
+
"no default organization selected; run `wmo login` again and choose an organization"
|
|
1123
|
+
)
|