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/agents/project.py
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"""Persistent E2B filesystem projects driven by the shared pi session runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import shlex
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Collection
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from wmo.core.types import JsonObject
|
|
17
|
+
from wmo.harness.doc import HarnessDoc
|
|
18
|
+
from wmo.harness.e2b_sandbox import (
|
|
19
|
+
SandboxCleanupError,
|
|
20
|
+
SandboxFactory,
|
|
21
|
+
SandboxHandle,
|
|
22
|
+
SandboxUsage,
|
|
23
|
+
create_sandbox,
|
|
24
|
+
default_sandbox_factory,
|
|
25
|
+
kill_sandbox,
|
|
26
|
+
)
|
|
27
|
+
from wmo.harness.live_session import (
|
|
28
|
+
DEFAULT_ACTIONS_PER_TURN,
|
|
29
|
+
LiveSession,
|
|
30
|
+
SessionEvent,
|
|
31
|
+
ToolOutcome,
|
|
32
|
+
)
|
|
33
|
+
from wmo.harness.pi_e2b import start_live_runner
|
|
34
|
+
from wmo.harness.runner_link import Channel, TokenUsage
|
|
35
|
+
from wmo.harness.runtime import HarnessSearchCancelled
|
|
36
|
+
from wmo.harness.source_tree import MAX_SOURCE_PATH_BYTES, HarnessSourceFile, HarnessSourceTree
|
|
37
|
+
from wmo.harness.tools import resolve_tools
|
|
38
|
+
from wmo.providers.base import ToolCallingProvider
|
|
39
|
+
|
|
40
|
+
PROJECT_WORKSPACE = "/home/user/project"
|
|
41
|
+
DEFAULT_PROJECT_TIMEOUT_S = 21_600
|
|
42
|
+
DEFAULT_SOURCE_TREE_MAX_FILES = 1_024
|
|
43
|
+
DEFAULT_SOURCE_TREE_MAX_BYTES = 8 * 1024 * 1024
|
|
44
|
+
_OUTPUT_CAP = 16_000
|
|
45
|
+
# Combined stdout+stderr budget for one bash command (head+tail truncated per stream).
|
|
46
|
+
_BASH_OUTPUT_CAP = 32 * 1024
|
|
47
|
+
_BASH_TIMEOUT_S = 60.0
|
|
48
|
+
_PROJECT_TOOLS = frozenset({"bash", "read_file", "write_file", "submit"})
|
|
49
|
+
_RECOVERABLE_SESSION_MARKERS = (
|
|
50
|
+
"server disconnected",
|
|
51
|
+
"connection reset",
|
|
52
|
+
"connection closed",
|
|
53
|
+
"broken pipe",
|
|
54
|
+
"remoteprotocolerror",
|
|
55
|
+
"readerror",
|
|
56
|
+
"pi runner process exited",
|
|
57
|
+
"pi live runner process exited",
|
|
58
|
+
"durable outbox",
|
|
59
|
+
"durable runner",
|
|
60
|
+
"failed to send a frame to the e2b runner",
|
|
61
|
+
"session ended before completing its turn",
|
|
62
|
+
"live session runner did not become ready",
|
|
63
|
+
"channel send failed",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# One trusted in-sandbox walk turning a project directory into bounded {path: base64} JSON.
|
|
67
|
+
# Only regular files are captured; every non-regular entry (symlink, socket, pipe) is COUNTED
|
|
68
|
+
# in the payload's `skipped` list so the host can reject a candidate that would otherwise be
|
|
69
|
+
# silently thinner than what the agent built. Path and byte bounds are enforced in-sandbox so
|
|
70
|
+
# the host never downloads more than the declared budget.
|
|
71
|
+
_SNAPSHOT_SOURCE_TREE_SCRIPT = r"""
|
|
72
|
+
import base64
|
|
73
|
+
import json
|
|
74
|
+
import os
|
|
75
|
+
import stat
|
|
76
|
+
import sys
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fail(message):
|
|
80
|
+
sys.stderr.write(message + "\n")
|
|
81
|
+
raise SystemExit(2)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
root = sys.argv[1]
|
|
85
|
+
max_files = int(sys.argv[2])
|
|
86
|
+
max_bytes = int(sys.argv[3])
|
|
87
|
+
max_path_bytes = int(sys.argv[4])
|
|
88
|
+
if not os.path.isdir(root):
|
|
89
|
+
fail("source directory does not exist: " + root)
|
|
90
|
+
|
|
91
|
+
files = []
|
|
92
|
+
skipped = []
|
|
93
|
+
total_bytes = 0
|
|
94
|
+
for current, directories, names in os.walk(root, topdown=True, followlinks=False):
|
|
95
|
+
directories.sort()
|
|
96
|
+
names.sort()
|
|
97
|
+
for name in directories:
|
|
98
|
+
path = os.path.join(current, name)
|
|
99
|
+
if not stat.S_ISDIR(os.lstat(path).st_mode):
|
|
100
|
+
skipped.append(os.path.relpath(path, root).replace(os.sep, "/"))
|
|
101
|
+
for name in names:
|
|
102
|
+
path = os.path.join(current, name)
|
|
103
|
+
relative = os.path.relpath(path, root).replace(os.sep, "/")
|
|
104
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
105
|
+
skipped.append(relative)
|
|
106
|
+
continue
|
|
107
|
+
if len(relative.encode("utf-8")) > max_path_bytes:
|
|
108
|
+
fail("file path exceeds " + str(max_path_bytes) + " bytes: " + relative)
|
|
109
|
+
if len(files) >= max_files:
|
|
110
|
+
fail("directory exceeds the " + str(max_files) + " file bound")
|
|
111
|
+
with open(path, "rb") as source:
|
|
112
|
+
content = source.read(max_bytes - total_bytes + 1)
|
|
113
|
+
total_bytes += len(content)
|
|
114
|
+
if total_bytes > max_bytes:
|
|
115
|
+
fail("directory exceeds the " + str(max_bytes) + " byte bound")
|
|
116
|
+
files.append(
|
|
117
|
+
{
|
|
118
|
+
"path": relative,
|
|
119
|
+
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
json.dump({"files": files, "skipped": sorted(skipped)}, sys.stdout, separators=(",", ":"))
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ProjectBashResult:
|
|
129
|
+
"""One finished project bash command: bounded streams plus its exit code."""
|
|
130
|
+
|
|
131
|
+
stdout: str
|
|
132
|
+
stderr: str
|
|
133
|
+
exit_code: int
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _EncodedSourceFile(BaseModel):
|
|
137
|
+
"""One regular snapshot file encoded for bounded JSON transport."""
|
|
138
|
+
|
|
139
|
+
path: str
|
|
140
|
+
content_base64: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _EncodedSourceSnapshot(BaseModel):
|
|
144
|
+
"""The trusted sandbox script's wire representation."""
|
|
145
|
+
|
|
146
|
+
files: tuple[_EncodedSourceFile, ...]
|
|
147
|
+
# Non-regular entries (symlinks, sockets, pipes) the walk refused to capture. A candidate
|
|
148
|
+
# containing them is invalid, not silently thinner, so the host raises on a nonzero count.
|
|
149
|
+
skipped: tuple[str, ...] = ()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ChannelFactory(Protocol):
|
|
153
|
+
"""Start one fresh runner channel in a project's sandbox."""
|
|
154
|
+
|
|
155
|
+
def __call__(self, sandbox: SandboxHandle, workspace: str) -> Channel: ...
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class AgentProjectRun:
|
|
160
|
+
"""Result of one agent turn inside a project."""
|
|
161
|
+
|
|
162
|
+
answer: str
|
|
163
|
+
events: tuple[SessionEvent, ...]
|
|
164
|
+
worker_usage: TokenUsage
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _ProjectAgentTurnError(RuntimeError):
|
|
168
|
+
"""A worker/provider error reported by a live agent turn, not its transport."""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AgentProject:
|
|
172
|
+
"""A persistent filesystem that can run project-scoped pi agents.
|
|
173
|
+
|
|
174
|
+
The project owns environment state, while :class:`LiveSession` owns ordinary agent execution.
|
|
175
|
+
Repeated ``run`` calls for the same agent and provider reuse one live session and runner, while
|
|
176
|
+
each outer project task gets a fresh model transcript. The project filesystem is the durable
|
|
177
|
+
memory shared across those tasks.
|
|
178
|
+
Changing the agent harness or provider starts a new session against the same filesystem.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
sandbox: SandboxHandle,
|
|
184
|
+
*,
|
|
185
|
+
workspace: str = PROJECT_WORKSPACE,
|
|
186
|
+
channel_factory: ChannelFactory | None = None,
|
|
187
|
+
sandbox_factory: SandboxFactory | None = None,
|
|
188
|
+
owns_sandbox: bool = True,
|
|
189
|
+
) -> None:
|
|
190
|
+
self._sandbox = sandbox
|
|
191
|
+
self.workspace = workspace.rstrip("/")
|
|
192
|
+
self._channel_factory = channel_factory or _start_channel
|
|
193
|
+
# Replacing a caller-owned sandbox would exceed this object's authority. Injected test or
|
|
194
|
+
# application sandboxes still get the bounded fresh-session retry in the same filesystem.
|
|
195
|
+
self._sandbox_factory = sandbox_factory if owns_sandbox else None
|
|
196
|
+
self._owns_sandbox = owns_sandbox
|
|
197
|
+
self._active_sandbox_started_at = time.monotonic()
|
|
198
|
+
self._retired_sandbox_seconds = 0.0
|
|
199
|
+
self._sandbox_count = 1
|
|
200
|
+
# A lease remains live until E2B confirms its kill. Replacement failures retain both
|
|
201
|
+
# handles here so usage keeps accruing and close() can retry every unproven teardown.
|
|
202
|
+
self._live_sandboxes: dict[int, tuple[SandboxHandle, float]] = {
|
|
203
|
+
id(sandbox): (sandbox, self._active_sandbox_started_at)
|
|
204
|
+
}
|
|
205
|
+
self._closing = False
|
|
206
|
+
self._finished_at: float | None = None
|
|
207
|
+
# Keep an in-process mirror of mediated writes so a dead E2B transport can be replaced
|
|
208
|
+
# without discarding the prior proposals that make this a persistent meta-agent project.
|
|
209
|
+
self._file_contents: dict[str, str] = {}
|
|
210
|
+
self._channel: Channel | None = None
|
|
211
|
+
self._session: LiveSession | None = None
|
|
212
|
+
self._session_agent_hash: str | None = None
|
|
213
|
+
self._session_provider: ToolCallingProvider | None = None
|
|
214
|
+
self._network_locked_sandbox_id: int | None = None
|
|
215
|
+
self._active_event_sink: Callable[[SessionEvent], None] | None = None
|
|
216
|
+
# ``None`` preserves the historical unrestricted project-tool behavior. A concrete set is
|
|
217
|
+
# one logical run's exact, project-relative write grant; it is cleared even when the turn
|
|
218
|
+
# fails so a reused live session cannot inherit the preceding turn's authority.
|
|
219
|
+
self._active_writable_files: frozenset[str] | None = None
|
|
220
|
+
self._retired_worker_usage = TokenUsage()
|
|
221
|
+
try:
|
|
222
|
+
self._initialize_sandbox(self._sandbox)
|
|
223
|
+
except Exception as error:
|
|
224
|
+
if self._owns_sandbox:
|
|
225
|
+
try:
|
|
226
|
+
self._retire_sandbox(self._sandbox)
|
|
227
|
+
except SandboxCleanupError as cleanup_error:
|
|
228
|
+
raise cleanup_error from error
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def create(
|
|
233
|
+
cls,
|
|
234
|
+
*,
|
|
235
|
+
timeout: float = DEFAULT_PROJECT_TIMEOUT_S,
|
|
236
|
+
template: str | None = None,
|
|
237
|
+
api_key: str | None = None,
|
|
238
|
+
metadata: dict[str, str] | None = None,
|
|
239
|
+
) -> AgentProject:
|
|
240
|
+
"""Create one owned E2B project sandbox."""
|
|
241
|
+
factory = default_sandbox_factory(
|
|
242
|
+
timeout=timeout,
|
|
243
|
+
template=template,
|
|
244
|
+
api_key=api_key,
|
|
245
|
+
metadata=metadata,
|
|
246
|
+
)
|
|
247
|
+
sandbox = create_sandbox(factory)
|
|
248
|
+
return cls(sandbox, sandbox_factory=factory)
|
|
249
|
+
|
|
250
|
+
def write_text(self, path: str, content: str) -> None:
|
|
251
|
+
"""Write one project-relative file without allowing path traversal."""
|
|
252
|
+
if self._closing:
|
|
253
|
+
raise RuntimeError("cannot write to a closed project")
|
|
254
|
+
absolute = self._absolute_path(path)
|
|
255
|
+
try:
|
|
256
|
+
self._write_sandbox_file(self._sandbox, absolute, content)
|
|
257
|
+
except Exception as error:
|
|
258
|
+
# Proposer context is written before ``run()``, so its recovery loop cannot own an
|
|
259
|
+
# exhausted control-plane retry. Replace an owned, transport-poisoned sandbox once,
|
|
260
|
+
# replay the established mirror, and then apply this idempotent overwrite there.
|
|
261
|
+
if self._sandbox_factory is None or not _is_recoverable_transport_error(error):
|
|
262
|
+
raise
|
|
263
|
+
try:
|
|
264
|
+
self._replace_sandbox()
|
|
265
|
+
self._write_sandbox_file(self._sandbox, absolute, content)
|
|
266
|
+
except Exception as recovery_error:
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"{error}; fresh project sandbox recovery failed: {recovery_error}"
|
|
269
|
+
) from recovery_error
|
|
270
|
+
self._file_contents[self._relative_path(absolute)] = content
|
|
271
|
+
|
|
272
|
+
def read_text(self, path: str) -> str:
|
|
273
|
+
"""Read one project-relative file."""
|
|
274
|
+
if self._closing:
|
|
275
|
+
raise RuntimeError("cannot read from a closed project")
|
|
276
|
+
absolute = self._absolute_path(path)
|
|
277
|
+
relative = self._relative_path(absolute)
|
|
278
|
+
try:
|
|
279
|
+
content = self._sandbox.files.read(absolute)
|
|
280
|
+
except Exception:
|
|
281
|
+
if relative in self._file_contents:
|
|
282
|
+
return self._file_contents[relative]
|
|
283
|
+
raise
|
|
284
|
+
self._file_contents[relative] = content
|
|
285
|
+
return content
|
|
286
|
+
|
|
287
|
+
def run_bash(self, command: str, *, timeout_s: float = _BASH_TIMEOUT_S) -> ProjectBashResult:
|
|
288
|
+
"""Run one bounded shell command in the workspace as the default sandbox user.
|
|
289
|
+
|
|
290
|
+
The command runs under ``timeout --kill-after`` inside a profile-free bash with the
|
|
291
|
+
project workspace as its working directory. A nonzero exit is a RESULT, not an
|
|
292
|
+
exception: E2B raises on nonzero exits with the streams attached to the exception, so
|
|
293
|
+
that shape is unwrapped here. Only a transport failure (no exit code) propagates.
|
|
294
|
+
Streams are head+tail truncated to a fixed combined budget.
|
|
295
|
+
"""
|
|
296
|
+
if self._closing:
|
|
297
|
+
raise RuntimeError("cannot run bash in a closed project")
|
|
298
|
+
budget_s = max(1, int(timeout_s))
|
|
299
|
+
script = (
|
|
300
|
+
f"cd {shlex.quote(self.workspace)} && "
|
|
301
|
+
f"timeout --kill-after=10s {budget_s}s "
|
|
302
|
+
f"bash --noprofile --norc -c {shlex.quote(command)}"
|
|
303
|
+
)
|
|
304
|
+
result: object
|
|
305
|
+
try:
|
|
306
|
+
result = self._sandbox.commands.run(script, timeout=budget_s + 30)
|
|
307
|
+
except Exception as error: # noqa: BLE001 - E2B raises finished nonzero exits
|
|
308
|
+
exit_code = getattr(error, "exit_code", None)
|
|
309
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
|
|
310
|
+
raise # a transport failure, not a finished command
|
|
311
|
+
result = error
|
|
312
|
+
return ProjectBashResult(
|
|
313
|
+
stdout=_head_tail(str(getattr(result, "stdout", "") or ""), _BASH_OUTPUT_CAP // 2),
|
|
314
|
+
stderr=_head_tail(str(getattr(result, "stderr", "") or ""), _BASH_OUTPUT_CAP // 2),
|
|
315
|
+
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def stage_source_tree(
|
|
319
|
+
self,
|
|
320
|
+
tree: HarnessSourceTree,
|
|
321
|
+
dest: str,
|
|
322
|
+
*,
|
|
323
|
+
max_files: int = DEFAULT_SOURCE_TREE_MAX_FILES,
|
|
324
|
+
max_bytes: int = DEFAULT_SOURCE_TREE_MAX_BYTES,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Write one bounded, validated source tree under a project-relative directory."""
|
|
327
|
+
tree.validate_bounds(max_files=max_files, max_bytes=max_bytes)
|
|
328
|
+
self._absolute_path(dest) # containment: reject absolute or traversing destinations
|
|
329
|
+
for item in tree.files:
|
|
330
|
+
self.write_text(f"{dest}/{item.path}", item.content)
|
|
331
|
+
|
|
332
|
+
def snapshot_source_tree(
|
|
333
|
+
self,
|
|
334
|
+
directory: str,
|
|
335
|
+
*,
|
|
336
|
+
max_files: int = DEFAULT_SOURCE_TREE_MAX_FILES,
|
|
337
|
+
max_bytes: int = DEFAULT_SOURCE_TREE_MAX_BYTES,
|
|
338
|
+
) -> HarnessSourceTree:
|
|
339
|
+
"""Capture one project directory as a bounded, validated host-side source tree.
|
|
340
|
+
|
|
341
|
+
A single in-sandbox python walk emits every regular file as base64 JSON on stdout;
|
|
342
|
+
the host decodes and validates it into a :class:`HarnessSourceTree`. Bound breaches,
|
|
343
|
+
non-UTF-8 content, invalid paths, and non-regular entries (symlinks, sockets, pipes)
|
|
344
|
+
all raise with the offending path in the message: a candidate is captured exactly or
|
|
345
|
+
rejected, never silently thinner.
|
|
346
|
+
"""
|
|
347
|
+
if self._closing:
|
|
348
|
+
raise RuntimeError("cannot snapshot a source tree in a closed project")
|
|
349
|
+
if isinstance(max_files, bool) or not isinstance(max_files, int) or max_files < 1:
|
|
350
|
+
raise ValueError("max_files must be a positive integer")
|
|
351
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1:
|
|
352
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
353
|
+
absolute = self._absolute_path(directory)
|
|
354
|
+
command = (
|
|
355
|
+
f"python3 -c {shlex.quote(_SNAPSHOT_SOURCE_TREE_SCRIPT)} {shlex.quote(absolute)} "
|
|
356
|
+
f"{max_files} {max_bytes} {MAX_SOURCE_PATH_BYTES}"
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
result = self._sandbox.commands.run(command, timeout=120)
|
|
360
|
+
except Exception as error: # noqa: BLE001 - E2B raises command failures
|
|
361
|
+
detail = str(getattr(error, "stderr", "") or error)
|
|
362
|
+
raise RuntimeError(
|
|
363
|
+
f"project source snapshot failed: {_head_tail(detail, 4_096)}"
|
|
364
|
+
) from error
|
|
365
|
+
exit_code = int(getattr(result, "exit_code", 0) or 0)
|
|
366
|
+
if exit_code != 0:
|
|
367
|
+
stderr = str(getattr(result, "stderr", "") or "snapshot command failed")
|
|
368
|
+
raise RuntimeError(f"project source snapshot failed: {_head_tail(stderr, 4_096)}")
|
|
369
|
+
try:
|
|
370
|
+
tree = _decode_source_tree_snapshot(str(getattr(result, "stdout", "")))
|
|
371
|
+
except ValueError as error:
|
|
372
|
+
raise RuntimeError(f"project source snapshot is invalid: {error}") from error
|
|
373
|
+
tree.validate_bounds(max_files=max_files, max_bytes=max_bytes)
|
|
374
|
+
return tree
|
|
375
|
+
|
|
376
|
+
def run(
|
|
377
|
+
self,
|
|
378
|
+
agent: HarnessDoc,
|
|
379
|
+
provider: ToolCallingProvider,
|
|
380
|
+
instruction: str,
|
|
381
|
+
*,
|
|
382
|
+
timeout: float = DEFAULT_PROJECT_TIMEOUT_S,
|
|
383
|
+
on_event: Callable[[SessionEvent], None] | None = None,
|
|
384
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
385
|
+
writable_files: Collection[str] | None = None,
|
|
386
|
+
retry_recoverable: bool = True,
|
|
387
|
+
) -> AgentProjectRun:
|
|
388
|
+
"""Run one turn of an ordinary agent against this persistent project.
|
|
389
|
+
|
|
390
|
+
A transient runner-channel disconnect retries the turn once. Owned E2B
|
|
391
|
+
projects replace a transport-poisoned sandbox and replay their mirrored
|
|
392
|
+
filesystem first; injected test projects keep the sandbox and replace
|
|
393
|
+
only the ordinary live session. ``writable_files`` optionally grants the
|
|
394
|
+
agent's ``write_file`` tool access to exact project-relative files for
|
|
395
|
+
this logical run. Omitting it preserves unrestricted project writes;
|
|
396
|
+
an empty collection denies every agent write. Host ``write_text`` calls
|
|
397
|
+
are not constrained by an agent turn's grant. ``retry_recoverable=False``
|
|
398
|
+
makes the run exactly one agent attempt: a transport failure closes the
|
|
399
|
+
session and propagates instead of transparently replaying paid work.
|
|
400
|
+
A bash-capable agent REQUIRES it: shell writes bypass the replayable
|
|
401
|
+
host mirror, so a replayed turn would land on a replacement sandbox
|
|
402
|
+
missing those edits, and that mismatch is rejected here, not silently
|
|
403
|
+
downgraded.
|
|
404
|
+
"""
|
|
405
|
+
if self._closing:
|
|
406
|
+
raise RuntimeError("cannot run an agent in a closed project")
|
|
407
|
+
_check_cancelled(should_cancel)
|
|
408
|
+
if self._active_event_sink is not None:
|
|
409
|
+
raise RuntimeError("a project agent turn is already running")
|
|
410
|
+
unsupported = set(agent.tools()) - _PROJECT_TOOLS
|
|
411
|
+
if unsupported:
|
|
412
|
+
names = ", ".join(sorted(unsupported))
|
|
413
|
+
raise ValueError(f"project agents cannot use uncontained tools: {names}")
|
|
414
|
+
if retry_recoverable and "bash" in agent.tools():
|
|
415
|
+
raise ValueError(
|
|
416
|
+
"bash-capable project agents must run with retry_recoverable=False: shell "
|
|
417
|
+
"writes bypass the replayable host file mirror, so a transparently retried "
|
|
418
|
+
"turn could replay onto a replacement sandbox missing those edits"
|
|
419
|
+
)
|
|
420
|
+
write_grant = self._normalize_writable_files(writable_files)
|
|
421
|
+
usage_before = self._total_worker_usage()
|
|
422
|
+
self._active_writable_files = write_grant
|
|
423
|
+
max_attempts = 2 if retry_recoverable else 1
|
|
424
|
+
try:
|
|
425
|
+
for attempt in range(max_attempts):
|
|
426
|
+
try:
|
|
427
|
+
result = self._run_turn(
|
|
428
|
+
agent,
|
|
429
|
+
provider,
|
|
430
|
+
instruction,
|
|
431
|
+
timeout=timeout,
|
|
432
|
+
on_event=on_event,
|
|
433
|
+
should_cancel=should_cancel,
|
|
434
|
+
)
|
|
435
|
+
usage_after = self._total_worker_usage()
|
|
436
|
+
return AgentProjectRun(
|
|
437
|
+
answer=result.answer,
|
|
438
|
+
events=result.events,
|
|
439
|
+
worker_usage=_usage_delta(usage_after, usage_before),
|
|
440
|
+
)
|
|
441
|
+
except HarnessSearchCancelled:
|
|
442
|
+
raise
|
|
443
|
+
except Exception as error:
|
|
444
|
+
if not _is_recoverable_session_error(error):
|
|
445
|
+
raise
|
|
446
|
+
if attempt + 1 >= max_attempts:
|
|
447
|
+
# A transport-poisoned session is never reused by a later logical run,
|
|
448
|
+
# even when the caller deliberately owns recovery at a higher level.
|
|
449
|
+
self._close_agent_session()
|
|
450
|
+
raise
|
|
451
|
+
if self._sandbox_factory is None:
|
|
452
|
+
self._close_agent_session()
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
self._replace_sandbox()
|
|
456
|
+
except Exception as recovery_error:
|
|
457
|
+
raise RuntimeError(
|
|
458
|
+
f"{error}; fresh project sandbox recovery failed: {recovery_error}"
|
|
459
|
+
) from recovery_error
|
|
460
|
+
raise AssertionError("unreachable")
|
|
461
|
+
finally:
|
|
462
|
+
self._active_writable_files = None
|
|
463
|
+
|
|
464
|
+
def _run_turn(
|
|
465
|
+
self,
|
|
466
|
+
agent: HarnessDoc,
|
|
467
|
+
provider: ToolCallingProvider,
|
|
468
|
+
instruction: str,
|
|
469
|
+
*,
|
|
470
|
+
timeout: float,
|
|
471
|
+
on_event: Callable[[SessionEvent], None] | None,
|
|
472
|
+
should_cancel: Callable[[], bool] | None,
|
|
473
|
+
) -> AgentProjectRun:
|
|
474
|
+
"""Execute one attempt using the compatible ordinary live session."""
|
|
475
|
+
session = self._ensure_session(agent, provider)
|
|
476
|
+
events: list[SessionEvent] = []
|
|
477
|
+
answer = ""
|
|
478
|
+
turn_started = False
|
|
479
|
+
turn_running = False
|
|
480
|
+
turn_finished = False
|
|
481
|
+
turn_terminal_reason: str | None = None
|
|
482
|
+
turn_error: str | None = None
|
|
483
|
+
|
|
484
|
+
def sink(event: SessionEvent) -> None:
|
|
485
|
+
nonlocal answer, turn_error, turn_finished, turn_running, turn_terminal_reason
|
|
486
|
+
events.append(event)
|
|
487
|
+
if event.kind == "submit":
|
|
488
|
+
submitted = event.payload.get("answer")
|
|
489
|
+
answer = submitted if isinstance(submitted, str) else ""
|
|
490
|
+
elif event.kind == "error" and turn_error is None:
|
|
491
|
+
message = event.payload.get("message")
|
|
492
|
+
turn_error = message if isinstance(message, str) else "project agent session error"
|
|
493
|
+
elif turn_started and event.kind == "state":
|
|
494
|
+
status = event.payload.get("status")
|
|
495
|
+
if status == "running":
|
|
496
|
+
turn_running = True
|
|
497
|
+
elif status == "idle" and turn_running:
|
|
498
|
+
turn_finished = True
|
|
499
|
+
reason = event.payload.get("reason")
|
|
500
|
+
turn_terminal_reason = reason if isinstance(reason, str) else None
|
|
501
|
+
if on_event is not None:
|
|
502
|
+
on_event(event)
|
|
503
|
+
|
|
504
|
+
self._active_event_sink = sink
|
|
505
|
+
try:
|
|
506
|
+
session.send_user_message(instruction)
|
|
507
|
+
turn_started = True
|
|
508
|
+
deadline = time.monotonic() + timeout
|
|
509
|
+
while not turn_finished:
|
|
510
|
+
self._cancel_turn_if_requested(session, should_cancel)
|
|
511
|
+
remaining = deadline - time.monotonic()
|
|
512
|
+
if remaining <= 0:
|
|
513
|
+
session.interrupt("project_run_timeout")
|
|
514
|
+
session.flush_pending_intents()
|
|
515
|
+
# An abort acknowledgement can arrive after this deadline. Retiring the
|
|
516
|
+
# session prevents that stale idle boundary from completing the next turn.
|
|
517
|
+
self._close_agent_session()
|
|
518
|
+
raise TimeoutError(f"project agent did not finish within {timeout:g}s")
|
|
519
|
+
running = session.pump(timeout=min(0.5, remaining))
|
|
520
|
+
# A pump can synchronously run one provider completion. Observe cancellation as
|
|
521
|
+
# soon as it returns, before consuming a second model or tool request.
|
|
522
|
+
self._cancel_turn_if_requested(session, should_cancel)
|
|
523
|
+
if not running and not turn_finished:
|
|
524
|
+
if session.failure_message is not None:
|
|
525
|
+
raise RuntimeError(
|
|
526
|
+
f"project agent session failed: {session.failure_message}"
|
|
527
|
+
)
|
|
528
|
+
raise RuntimeError("project agent session ended before completing its turn")
|
|
529
|
+
if turn_error is not None:
|
|
530
|
+
raise _ProjectAgentTurnError(f"project agent session failed: {turn_error}")
|
|
531
|
+
if turn_terminal_reason in {"aborted", "turn_limit"}:
|
|
532
|
+
raise _ProjectAgentTurnError(
|
|
533
|
+
f"project agent turn ended with reason: {turn_terminal_reason}"
|
|
534
|
+
)
|
|
535
|
+
finally:
|
|
536
|
+
self._active_event_sink = None
|
|
537
|
+
return AgentProjectRun(answer=answer, events=tuple(events), worker_usage=TokenUsage())
|
|
538
|
+
|
|
539
|
+
def _cancel_turn_if_requested(
|
|
540
|
+
self,
|
|
541
|
+
session: LiveSession,
|
|
542
|
+
should_cancel: Callable[[], bool] | None,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Abort and retire the active session at one cooperative cancellation boundary."""
|
|
545
|
+
if should_cancel is None or not should_cancel():
|
|
546
|
+
return
|
|
547
|
+
session.interrupt("harness_search_cancelled")
|
|
548
|
+
with contextlib.suppress(Exception):
|
|
549
|
+
session.flush_pending_intents()
|
|
550
|
+
self._close_agent_session()
|
|
551
|
+
raise HarnessSearchCancelled("harness search cancelled")
|
|
552
|
+
|
|
553
|
+
def _ensure_session(self, agent: HarnessDoc, provider: ToolCallingProvider) -> LiveSession:
|
|
554
|
+
"""Return the compatible live session, starting one when the harness changed."""
|
|
555
|
+
if (
|
|
556
|
+
self._session is not None
|
|
557
|
+
and not self._session.closed
|
|
558
|
+
and self._session_agent_hash == agent.doc_hash
|
|
559
|
+
and self._session_provider is provider
|
|
560
|
+
):
|
|
561
|
+
return self._session
|
|
562
|
+
self._close_agent_session()
|
|
563
|
+
channel = self._channel_factory(self._sandbox, self.workspace)
|
|
564
|
+
try:
|
|
565
|
+
# Runner bootstrap has completed in channel_factory, but no agent-controlled source
|
|
566
|
+
# has been imported yet. Remove egress before session_start materializes that code.
|
|
567
|
+
self._lock_project_network()
|
|
568
|
+
skills = agent.skills()
|
|
569
|
+
session = LiveSession(
|
|
570
|
+
channel,
|
|
571
|
+
tools=resolve_tools(agent.tools()),
|
|
572
|
+
execute_tool=self._execute_tool,
|
|
573
|
+
on_event=self._emit_session_event,
|
|
574
|
+
files={
|
|
575
|
+
surface.path: surface.content for surface in agent.code_files() if surface.path
|
|
576
|
+
},
|
|
577
|
+
system_prompt=agent.assembled_prompt(),
|
|
578
|
+
skill_bodies={skill.name: skill.body for skill in skills},
|
|
579
|
+
provider=provider,
|
|
580
|
+
# Project agents explore a durable filesystem and can legitimately need one
|
|
581
|
+
# project action per model turn. Never let LiveSession's generic 40-action default
|
|
582
|
+
# silently undercut a harness that explicitly raises its turn budget.
|
|
583
|
+
actions_per_turn=max(DEFAULT_ACTIONS_PER_TURN, agent.max_turns()),
|
|
584
|
+
turn_cap=agent.max_turns(),
|
|
585
|
+
max_output_tokens=agent.max_output_tokens(),
|
|
586
|
+
temperature=agent.temperature(),
|
|
587
|
+
# Project files are durable memory. Replaying every prior project task in the
|
|
588
|
+
# model transcript only duplicates that state and eventually collapses pi's
|
|
589
|
+
# available output budget as context fills.
|
|
590
|
+
conversation_scope="turn",
|
|
591
|
+
)
|
|
592
|
+
session.start()
|
|
593
|
+
except Exception:
|
|
594
|
+
close = getattr(channel, "close", None)
|
|
595
|
+
if callable(close):
|
|
596
|
+
with contextlib.suppress(Exception):
|
|
597
|
+
close()
|
|
598
|
+
raise
|
|
599
|
+
self._channel = channel
|
|
600
|
+
self._session = session
|
|
601
|
+
self._session_agent_hash = agent.doc_hash
|
|
602
|
+
self._session_provider = provider
|
|
603
|
+
return session
|
|
604
|
+
|
|
605
|
+
def _lock_project_network(self) -> None:
|
|
606
|
+
"""Remove internet egress before untrusted project evidence can drive tools."""
|
|
607
|
+
if not self._owns_sandbox or self._network_locked_sandbox_id == id(self._sandbox):
|
|
608
|
+
return
|
|
609
|
+
update_network = getattr(self._sandbox, "update_network", None)
|
|
610
|
+
if not callable(update_network):
|
|
611
|
+
raise RuntimeError("owned project sandbox cannot disable internet access")
|
|
612
|
+
update_network({"allow_internet_access": False})
|
|
613
|
+
self._network_locked_sandbox_id = id(self._sandbox)
|
|
614
|
+
|
|
615
|
+
def _emit_session_event(self, event: SessionEvent) -> None:
|
|
616
|
+
"""Route session events to the currently active project turn."""
|
|
617
|
+
if self._active_event_sink is not None:
|
|
618
|
+
self._active_event_sink(event)
|
|
619
|
+
|
|
620
|
+
def _close_agent_session(self) -> None:
|
|
621
|
+
"""Close the current agent session without touching the project filesystem."""
|
|
622
|
+
session = self._session
|
|
623
|
+
channel = self._channel
|
|
624
|
+
self._session = None
|
|
625
|
+
self._channel = None
|
|
626
|
+
self._session_agent_hash = None
|
|
627
|
+
self._session_provider = None
|
|
628
|
+
if session is not None:
|
|
629
|
+
self._retired_worker_usage.input_tokens += session.worker_usage.input_tokens
|
|
630
|
+
self._retired_worker_usage.output_tokens += session.worker_usage.output_tokens
|
|
631
|
+
self._retired_worker_usage.calls += session.worker_usage.calls
|
|
632
|
+
close = getattr(channel, "close", None)
|
|
633
|
+
if callable(close):
|
|
634
|
+
with contextlib.suppress(Exception):
|
|
635
|
+
close()
|
|
636
|
+
elif session is not None and not session.closed:
|
|
637
|
+
# Test/local channels without an owned close hook still get the protocol-level end.
|
|
638
|
+
# Real project channels close the runner directly above so cancellation never waits
|
|
639
|
+
# for two durable abort/shutdown acknowledgements from an unreachable process.
|
|
640
|
+
with contextlib.suppress(Exception):
|
|
641
|
+
session.end()
|
|
642
|
+
session.pump(timeout=0)
|
|
643
|
+
|
|
644
|
+
def usage(self) -> SandboxUsage:
|
|
645
|
+
"""Return this project's sandbox lifetime meter."""
|
|
646
|
+
now = time.monotonic()
|
|
647
|
+
active_seconds = sum(
|
|
648
|
+
max(0.0, now - started_at) for _sandbox, started_at in self._live_sandboxes.values()
|
|
649
|
+
)
|
|
650
|
+
return SandboxUsage(
|
|
651
|
+
count=self._sandbox_count,
|
|
652
|
+
seconds=self._retired_sandbox_seconds + active_seconds,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def _total_worker_usage(self) -> TokenUsage:
|
|
656
|
+
"""Return worker usage across retired and currently attached live sessions."""
|
|
657
|
+
current = self._session.worker_usage if self._session is not None else TokenUsage()
|
|
658
|
+
return TokenUsage(
|
|
659
|
+
input_tokens=self._retired_worker_usage.input_tokens + current.input_tokens,
|
|
660
|
+
output_tokens=self._retired_worker_usage.output_tokens + current.output_tokens,
|
|
661
|
+
calls=self._retired_worker_usage.calls + current.calls,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def close(self) -> None:
|
|
665
|
+
"""Release every owned lease, retaining unproven kills for a later retry."""
|
|
666
|
+
if self._finished_at is not None:
|
|
667
|
+
return
|
|
668
|
+
self._closing = True
|
|
669
|
+
self._close_agent_session()
|
|
670
|
+
if not self._owns_sandbox:
|
|
671
|
+
finished_at = time.monotonic()
|
|
672
|
+
for _sandbox, started_at in self._live_sandboxes.values():
|
|
673
|
+
self._retired_sandbox_seconds += max(0.0, finished_at - started_at)
|
|
674
|
+
self._live_sandboxes.clear()
|
|
675
|
+
self._finished_at = finished_at
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
leases = list(self._live_sandboxes.values())
|
|
679
|
+
failures: list[SandboxCleanupError] = []
|
|
680
|
+
for sandbox, _started_at in leases:
|
|
681
|
+
try:
|
|
682
|
+
self._retire_sandbox(sandbox)
|
|
683
|
+
except SandboxCleanupError as error:
|
|
684
|
+
failures.append(error)
|
|
685
|
+
if failures:
|
|
686
|
+
raise SandboxCleanupError(
|
|
687
|
+
"failed to prove cleanup for "
|
|
688
|
+
f"{len(failures)} of {len(leases)} "
|
|
689
|
+
"meta-project E2B sandboxes",
|
|
690
|
+
resource="meta_project_sandbox",
|
|
691
|
+
sandbox_usage=self.usage(),
|
|
692
|
+
) from failures[0]
|
|
693
|
+
self._finished_at = time.monotonic()
|
|
694
|
+
|
|
695
|
+
def __enter__(self) -> AgentProject:
|
|
696
|
+
return self
|
|
697
|
+
|
|
698
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
699
|
+
self.close()
|
|
700
|
+
|
|
701
|
+
def _absolute_path(self, path: str) -> str:
|
|
702
|
+
candidate = PurePosixPath(path)
|
|
703
|
+
if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
|
|
704
|
+
raise ValueError(f"expected a relative project path, got {path!r}")
|
|
705
|
+
return f"{self.workspace}/{candidate.as_posix()}"
|
|
706
|
+
|
|
707
|
+
def _relative_path(self, absolute: str) -> str:
|
|
708
|
+
"""Return one already-contained absolute path relative to the project root."""
|
|
709
|
+
return PurePosixPath(absolute).relative_to(PurePosixPath(self.workspace)).as_posix()
|
|
710
|
+
|
|
711
|
+
def _initialize_sandbox(self, sandbox: SandboxHandle) -> None:
|
|
712
|
+
"""Create the workspace and replay the authoritative project-file mirror."""
|
|
713
|
+
sandbox.commands.run(f"mkdir -p {shlex.quote(self.workspace)}", timeout=30)
|
|
714
|
+
for relative, content in self._file_contents.items():
|
|
715
|
+
absolute = f"{self.workspace}/{relative}"
|
|
716
|
+
self._write_sandbox_file(sandbox, absolute, content)
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def _write_sandbox_file(sandbox: SandboxHandle, absolute: str, content: str) -> None:
|
|
720
|
+
directory = str(PurePosixPath(absolute).parent)
|
|
721
|
+
for attempt in range(2):
|
|
722
|
+
try:
|
|
723
|
+
sandbox.commands.run(f"mkdir -p {shlex.quote(directory)}", timeout=30)
|
|
724
|
+
sandbox.files.write(absolute, content)
|
|
725
|
+
return
|
|
726
|
+
except Exception as error: # noqa: BLE001 - classify the E2B transport boundary
|
|
727
|
+
# Both operations are idempotent: replaying ``mkdir -p`` and the same overwrite is
|
|
728
|
+
# safe even when the first request reached E2B but its response was disconnected.
|
|
729
|
+
# Keep the live project sandbox/session intact for a one-off control-plane drop.
|
|
730
|
+
if attempt > 0 or not _is_recoverable_transport_error(error):
|
|
731
|
+
raise
|
|
732
|
+
|
|
733
|
+
def _replace_sandbox(self) -> None:
|
|
734
|
+
"""Replace a transport-poisoned sandbox while retaining every project file."""
|
|
735
|
+
factory = self._sandbox_factory
|
|
736
|
+
if factory is None:
|
|
737
|
+
raise RuntimeError("project sandbox replacement is unavailable")
|
|
738
|
+
# Required durable files are synchronously mirrored by write_text/write_file. Bash is
|
|
739
|
+
# explicitly scratch-only, so recovery never scans or replays an unbounded agent-created
|
|
740
|
+
# tree before honoring cancellation or replacing a poisoned transport.
|
|
741
|
+
replacement = create_sandbox(factory)
|
|
742
|
+
replacement_started_at = time.monotonic()
|
|
743
|
+
self._sandbox_count += 1
|
|
744
|
+
self._live_sandboxes[id(replacement)] = (replacement, replacement_started_at)
|
|
745
|
+
try:
|
|
746
|
+
self._initialize_sandbox(replacement)
|
|
747
|
+
except Exception as error:
|
|
748
|
+
try:
|
|
749
|
+
self._retire_sandbox(replacement)
|
|
750
|
+
except SandboxCleanupError as cleanup_error:
|
|
751
|
+
raise cleanup_error from error
|
|
752
|
+
raise
|
|
753
|
+
|
|
754
|
+
previous = self._sandbox
|
|
755
|
+
self._close_agent_session()
|
|
756
|
+
self._active_sandbox_started_at = replacement_started_at
|
|
757
|
+
self._sandbox = replacement
|
|
758
|
+
self._network_locked_sandbox_id = None
|
|
759
|
+
if self._owns_sandbox:
|
|
760
|
+
self._retire_sandbox(previous)
|
|
761
|
+
|
|
762
|
+
def _retire_sandbox(self, sandbox: SandboxHandle) -> None:
|
|
763
|
+
"""Finalize one lease only after E2B confirms that it is gone."""
|
|
764
|
+
lease = self._live_sandboxes.get(id(sandbox))
|
|
765
|
+
if lease is None:
|
|
766
|
+
return
|
|
767
|
+
kill_sandbox(sandbox)
|
|
768
|
+
retired_at = time.monotonic()
|
|
769
|
+
_handle, started_at = self._live_sandboxes.pop(id(sandbox))
|
|
770
|
+
self._retired_sandbox_seconds += max(0.0, retired_at - started_at)
|
|
771
|
+
|
|
772
|
+
def _execute_tool(
|
|
773
|
+
self,
|
|
774
|
+
name: str,
|
|
775
|
+
arguments: JsonObject,
|
|
776
|
+
emit: Callable[[str, str], None],
|
|
777
|
+
) -> ToolOutcome:
|
|
778
|
+
del emit # Project tools return one bounded observation; they do not stream output.
|
|
779
|
+
try:
|
|
780
|
+
if name == "bash":
|
|
781
|
+
# Agent bash writes bypass the host-side file mirror, so a sandbox replacement
|
|
782
|
+
# would silently lose them. That is sound only because run() ENFORCES that a
|
|
783
|
+
# bash-capable agent runs with retry_recoverable=False: a dead transport ends
|
|
784
|
+
# the turn instead of replaying it onto a replacement sandbox.
|
|
785
|
+
result = self.run_bash(str(arguments.get("command", "")))
|
|
786
|
+
body = result.stdout
|
|
787
|
+
if result.stderr:
|
|
788
|
+
body = f"{body}\n[stderr]\n{result.stderr}" if body else result.stderr
|
|
789
|
+
if result.exit_code != 0:
|
|
790
|
+
marker = f"[exit {result.exit_code}]"
|
|
791
|
+
body = f"{body}\n{marker}" if body else marker
|
|
792
|
+
return ToolOutcome(content=body, is_error=result.exit_code != 0)
|
|
793
|
+
if name == "read_file":
|
|
794
|
+
path = self._tool_path(str(arguments.get("path", "")))
|
|
795
|
+
relative = self._relative_path(path)
|
|
796
|
+
try:
|
|
797
|
+
content = self._sandbox.files.read(path)
|
|
798
|
+
except Exception:
|
|
799
|
+
if relative not in self._file_contents:
|
|
800
|
+
raise
|
|
801
|
+
content = self._file_contents[relative]
|
|
802
|
+
else:
|
|
803
|
+
self._file_contents[relative] = content
|
|
804
|
+
return _capped(content)
|
|
805
|
+
if name == "write_file":
|
|
806
|
+
path = self._tool_path(str(arguments.get("path", "")))
|
|
807
|
+
relative = self._relative_path(path)
|
|
808
|
+
if (
|
|
809
|
+
self._active_writable_files is not None
|
|
810
|
+
and relative not in self._active_writable_files
|
|
811
|
+
):
|
|
812
|
+
raise PermissionError(
|
|
813
|
+
f"path is not writable in this project turn: {relative!r}"
|
|
814
|
+
)
|
|
815
|
+
content = str(arguments.get("content", ""))
|
|
816
|
+
self._write_sandbox_file(self._sandbox, path, content)
|
|
817
|
+
self._file_contents[relative] = content
|
|
818
|
+
return ToolOutcome(content=f"wrote {path}")
|
|
819
|
+
except Exception as error: # noqa: BLE001 - tool errors are agent observations
|
|
820
|
+
return ToolOutcome(content=f"{name} failed: {error}", is_error=True)
|
|
821
|
+
return ToolOutcome(content=f"tool {name!r} not available", is_error=True)
|
|
822
|
+
|
|
823
|
+
def _tool_path(self, path: str) -> str:
|
|
824
|
+
"""Resolve an agent-supplied path while containing it to the project."""
|
|
825
|
+
candidate = PurePosixPath(path)
|
|
826
|
+
workspace = PurePosixPath(self.workspace)
|
|
827
|
+
if candidate.is_absolute():
|
|
828
|
+
try:
|
|
829
|
+
candidate = candidate.relative_to(workspace)
|
|
830
|
+
except ValueError as error:
|
|
831
|
+
raise ValueError(f"path escapes project workspace: {path!r}") from error
|
|
832
|
+
if not candidate.parts or ".." in candidate.parts:
|
|
833
|
+
raise ValueError(f"path escapes project workspace: {path!r}")
|
|
834
|
+
return str(workspace / candidate)
|
|
835
|
+
|
|
836
|
+
def _normalize_writable_files(
|
|
837
|
+
self, writable_files: Collection[str] | None
|
|
838
|
+
) -> frozenset[str] | None:
|
|
839
|
+
"""Normalize one optional exact-file grant to project-relative paths."""
|
|
840
|
+
if writable_files is None:
|
|
841
|
+
return None
|
|
842
|
+
return frozenset(self._relative_path(self._absolute_path(path)) for path in writable_files)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _start_channel(sandbox: SandboxHandle, workspace: str) -> Channel:
|
|
846
|
+
# Project turns can be separated by long evaluation waves. Their ordinary live runner writes
|
|
847
|
+
# every semantic output frame to a sequenced E2B outbox before stdout, so the shared
|
|
848
|
+
# LiveSession can replay a dropped command stream without replacing the agent, transcript, or
|
|
849
|
+
# project sandbox. Platform live sessions keep start_live_runner's established stdio default.
|
|
850
|
+
return start_live_runner(sandbox, workspace=workspace, durable_outbox=True)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _is_recoverable_session_error(error: Exception) -> bool:
|
|
854
|
+
"""Return whether one fresh live session may recover this transport failure."""
|
|
855
|
+
if isinstance(error, _ProjectAgentTurnError):
|
|
856
|
+
return False
|
|
857
|
+
return _is_recoverable_transport_error(error)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _is_recoverable_transport_error(error: Exception) -> bool:
|
|
861
|
+
"""Return whether one idempotent E2B transport operation may be retried once."""
|
|
862
|
+
error_type = type(error)
|
|
863
|
+
if error_type.__module__ == "e2b.exceptions" and error_type.__name__ == "TimeoutException":
|
|
864
|
+
return True
|
|
865
|
+
text = str(error).lower()
|
|
866
|
+
# httpcore can race an E2B HTTP/2 GOAWAY with request body delivery. h2 then surfaces a raw
|
|
867
|
+
# ProtocolError instead of httpx's usual transport wrapper. The pool will not reassign that
|
|
868
|
+
# unavailable closed connection, so the next idempotent control-plane request opens a fresh
|
|
869
|
+
# one. Match the state-machine shape rather than every h2 ProtocolError: malformed responses
|
|
870
|
+
# remain fatal.
|
|
871
|
+
if "invalid input connectioninputs." in text and "connectionstate.closed" in text:
|
|
872
|
+
return True
|
|
873
|
+
return any(marker in text for marker in _RECOVERABLE_SESSION_MARKERS)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _check_cancelled(should_cancel: Callable[[], bool] | None) -> None:
|
|
877
|
+
"""Fail before creating or retrying a project turn when search cancellation is already set."""
|
|
878
|
+
if should_cancel is not None and should_cancel():
|
|
879
|
+
raise HarnessSearchCancelled("harness search cancelled")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _usage_delta(after: TokenUsage, before: TokenUsage) -> TokenUsage:
|
|
883
|
+
"""Subtract cumulative usage snapshots for one logical project run."""
|
|
884
|
+
return TokenUsage(
|
|
885
|
+
input_tokens=after.input_tokens - before.input_tokens,
|
|
886
|
+
output_tokens=after.output_tokens - before.output_tokens,
|
|
887
|
+
calls=after.calls - before.calls,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _capped(content: str, *, is_error: bool = False) -> ToolOutcome:
|
|
892
|
+
if len(content) <= _OUTPUT_CAP:
|
|
893
|
+
return ToolOutcome(content=content, is_error=is_error)
|
|
894
|
+
half = _OUTPUT_CAP // 2
|
|
895
|
+
marker = f"\n... {len(content) - _OUTPUT_CAP} characters truncated ...\n"
|
|
896
|
+
return ToolOutcome(
|
|
897
|
+
content=f"{content[:half]}{marker}{content[-half:]}",
|
|
898
|
+
is_error=is_error,
|
|
899
|
+
truncated=True,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _head_tail(content: str, cap: int) -> str:
|
|
904
|
+
"""Keep both ends of one stream within `cap` characters, with an omission marker."""
|
|
905
|
+
if len(content) <= cap:
|
|
906
|
+
return content
|
|
907
|
+
half = cap // 2
|
|
908
|
+
omitted = len(content) - cap
|
|
909
|
+
return f"{content[:half]}\n... {omitted} characters truncated ...\n{content[-half:]}"
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _decode_source_tree_snapshot(payload: str) -> HarnessSourceTree:
|
|
913
|
+
"""Decode bounded base64 JSON from the trusted snapshot script."""
|
|
914
|
+
encoded = _EncodedSourceSnapshot.model_validate_json(payload)
|
|
915
|
+
if encoded.skipped:
|
|
916
|
+
names = ", ".join(encoded.skipped)
|
|
917
|
+
raise ValueError(
|
|
918
|
+
f"directory contains {len(encoded.skipped)} non-regular entr(y/ies) that cannot "
|
|
919
|
+
f"be part of a portable source tree: {names}; replace them with regular files"
|
|
920
|
+
)
|
|
921
|
+
files: list[HarnessSourceFile] = []
|
|
922
|
+
for item in encoded.files:
|
|
923
|
+
try:
|
|
924
|
+
content = base64.b64decode(item.content_base64, validate=True).decode("utf-8")
|
|
925
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
926
|
+
raise ValueError(f"file {item.path!r} is not valid UTF-8 text") from error
|
|
927
|
+
files.append(HarnessSourceFile(path=item.path, content=content))
|
|
928
|
+
return HarnessSourceTree(files=tuple(files))
|