world-model-optimizer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Copyright (c) 2026 Experiential Labs. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Hash-guarded incremental patch protocol for synchronized workspaces."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import tarfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
MAX_WORKSPACE_PATCH_BYTES = 50 * 1024 * 1024
|
|
15
|
+
MAX_WORKSPACE_PATCH_UNPACKED_BYTES = 512 * 1024 * 1024
|
|
16
|
+
MAX_WORKSPACE_PATCH_ENTRIES = 100_000
|
|
17
|
+
|
|
18
|
+
_MANIFEST_PATH = "manifest.json"
|
|
19
|
+
_DATA_PREFIX = "data/"
|
|
20
|
+
_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkspacePatchError(ValueError):
|
|
24
|
+
"""An incremental workspace patch is malformed or unsafe."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PatchFileState:
|
|
29
|
+
"""The content and executable mode expected before or after one operation."""
|
|
30
|
+
|
|
31
|
+
sha256: str
|
|
32
|
+
mode: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class WorkspacePatchOperation:
|
|
37
|
+
"""One conditional path transition in an incremental patch."""
|
|
38
|
+
|
|
39
|
+
path: str
|
|
40
|
+
before: PatchFileState | None
|
|
41
|
+
after: PatchFileState | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class WorkspacePatch:
|
|
46
|
+
"""Validated operations and replacement bytes keyed by workspace path."""
|
|
47
|
+
|
|
48
|
+
operations: tuple[WorkspacePatchOperation, ...]
|
|
49
|
+
files: dict[str, bytes]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_workspace_patch(before_archive: bytes, after_archive: bytes) -> bytes | None:
|
|
53
|
+
"""Build a deterministic patch between two full workspace archives."""
|
|
54
|
+
before = _read_workspace_archive(before_archive)
|
|
55
|
+
after = _read_workspace_archive(after_archive)
|
|
56
|
+
operations: list[WorkspacePatchOperation] = []
|
|
57
|
+
for path in sorted(set(before) | set(after)):
|
|
58
|
+
before_state = _state(before.get(path))
|
|
59
|
+
after_state = _state(after.get(path))
|
|
60
|
+
if before_state != after_state:
|
|
61
|
+
operations.append(
|
|
62
|
+
WorkspacePatchOperation(path=path, before=before_state, after=after_state)
|
|
63
|
+
)
|
|
64
|
+
if not operations:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
manifest = {
|
|
68
|
+
"version": _VERSION,
|
|
69
|
+
"operations": [
|
|
70
|
+
{
|
|
71
|
+
"path": operation.path,
|
|
72
|
+
"before": _state_json(operation.before),
|
|
73
|
+
"after": _state_json(operation.after),
|
|
74
|
+
}
|
|
75
|
+
for operation in operations
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
manifest_bytes = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
79
|
+
buffer = io.BytesIO()
|
|
80
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
|
81
|
+
_add_bytes(archive, _MANIFEST_PATH, manifest_bytes, 0o600)
|
|
82
|
+
for operation in operations:
|
|
83
|
+
if operation.after is None:
|
|
84
|
+
continue
|
|
85
|
+
body, _mode = after[operation.path]
|
|
86
|
+
_add_bytes(archive, f"{_DATA_PREFIX}{operation.path}", body, operation.after.mode)
|
|
87
|
+
content = buffer.getvalue()
|
|
88
|
+
if len(content) > MAX_WORKSPACE_PATCH_BYTES:
|
|
89
|
+
raise WorkspacePatchError(f"workspace patch exceeds {MAX_WORKSPACE_PATCH_BYTES} bytes")
|
|
90
|
+
return content
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_workspace_patch(content: bytes) -> WorkspacePatch:
|
|
94
|
+
"""Validate and decode one patch without writing any filesystem paths."""
|
|
95
|
+
if len(content) > MAX_WORKSPACE_PATCH_BYTES:
|
|
96
|
+
raise WorkspacePatchError(f"workspace patch exceeds {MAX_WORKSPACE_PATCH_BYTES} bytes")
|
|
97
|
+
members: dict[str, tuple[bytes, int]] = {}
|
|
98
|
+
total = 0
|
|
99
|
+
try:
|
|
100
|
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive:
|
|
101
|
+
entries = archive.getmembers()
|
|
102
|
+
if len(entries) > MAX_WORKSPACE_PATCH_ENTRIES + 1:
|
|
103
|
+
raise WorkspacePatchError("workspace patch has too many entries")
|
|
104
|
+
for member in entries:
|
|
105
|
+
name = _normalized_name(member.name)
|
|
106
|
+
if name in members:
|
|
107
|
+
raise WorkspacePatchError(f"duplicate workspace patch path: {name}")
|
|
108
|
+
if not member.isfile():
|
|
109
|
+
raise WorkspacePatchError(
|
|
110
|
+
f"workspace patch entry must be a regular file: {member.name}"
|
|
111
|
+
)
|
|
112
|
+
total += member.size
|
|
113
|
+
if total > MAX_WORKSPACE_PATCH_UNPACKED_BYTES:
|
|
114
|
+
raise WorkspacePatchError("workspace patch expands beyond its limit")
|
|
115
|
+
source = archive.extractfile(member)
|
|
116
|
+
if source is None:
|
|
117
|
+
raise WorkspacePatchError(
|
|
118
|
+
f"workspace patch entry has no content: {member.name}"
|
|
119
|
+
)
|
|
120
|
+
members[name] = (source.read(), member.mode & 0o777)
|
|
121
|
+
except WorkspacePatchError:
|
|
122
|
+
raise
|
|
123
|
+
except (tarfile.TarError, OSError, EOFError) as error:
|
|
124
|
+
raise WorkspacePatchError("workspace patch must be a gzip tar archive") from error
|
|
125
|
+
|
|
126
|
+
manifest_entry = members.pop(_MANIFEST_PATH, None)
|
|
127
|
+
if manifest_entry is None:
|
|
128
|
+
raise WorkspacePatchError("workspace patch has no manifest")
|
|
129
|
+
try:
|
|
130
|
+
raw = json.loads(manifest_entry[0])
|
|
131
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
132
|
+
raise WorkspacePatchError("workspace patch manifest is not valid JSON") from error
|
|
133
|
+
if not isinstance(raw, dict) or raw.get("version") != _VERSION:
|
|
134
|
+
raise WorkspacePatchError("unsupported workspace patch version")
|
|
135
|
+
raw_operations = raw.get("operations")
|
|
136
|
+
if not isinstance(raw_operations, list) or not raw_operations:
|
|
137
|
+
raise WorkspacePatchError("workspace patch has no operations")
|
|
138
|
+
|
|
139
|
+
operations: list[WorkspacePatchOperation] = []
|
|
140
|
+
files: dict[str, bytes] = {}
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
for raw_operation in raw_operations:
|
|
143
|
+
operation = _parse_operation(raw_operation)
|
|
144
|
+
if operation.path in seen:
|
|
145
|
+
raise WorkspacePatchError(f"duplicate workspace patch operation: {operation.path}")
|
|
146
|
+
seen.add(operation.path)
|
|
147
|
+
data_name = f"{_DATA_PREFIX}{operation.path}"
|
|
148
|
+
entry = members.pop(data_name, None)
|
|
149
|
+
if operation.after is None:
|
|
150
|
+
if entry is not None:
|
|
151
|
+
raise WorkspacePatchError(
|
|
152
|
+
f"deleted workspace patch path has replacement data: {operation.path}"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
if entry is None:
|
|
156
|
+
raise WorkspacePatchError(
|
|
157
|
+
f"workspace patch has no replacement data: {operation.path}"
|
|
158
|
+
)
|
|
159
|
+
body, mode = entry
|
|
160
|
+
if _digest(body) != operation.after.sha256:
|
|
161
|
+
raise WorkspacePatchError(f"workspace patch digest mismatch: {operation.path}")
|
|
162
|
+
if mode != operation.after.mode:
|
|
163
|
+
raise WorkspacePatchError(f"workspace patch mode mismatch: {operation.path}")
|
|
164
|
+
files[operation.path] = body
|
|
165
|
+
operations.append(operation)
|
|
166
|
+
if members:
|
|
167
|
+
extra = next(iter(sorted(members)))
|
|
168
|
+
raise WorkspacePatchError(f"unexpected workspace patch entry: {extra}")
|
|
169
|
+
return WorkspacePatch(operations=tuple(operations), files=files)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read_workspace_archive(content: bytes) -> dict[str, tuple[bytes, int]]:
|
|
173
|
+
"""Read regular files from a trusted full snapshot used to calculate a patch."""
|
|
174
|
+
files: dict[str, tuple[bytes, int]] = {}
|
|
175
|
+
try:
|
|
176
|
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive:
|
|
177
|
+
entries = archive.getmembers()
|
|
178
|
+
if len(entries) > MAX_WORKSPACE_PATCH_ENTRIES:
|
|
179
|
+
raise WorkspacePatchError("workspace archive has too many entries")
|
|
180
|
+
total = 0
|
|
181
|
+
for member in entries:
|
|
182
|
+
name = _normalized_name(member.name)
|
|
183
|
+
if member.isdir():
|
|
184
|
+
continue
|
|
185
|
+
if not member.isfile():
|
|
186
|
+
raise WorkspacePatchError(
|
|
187
|
+
f"workspace entry must be a regular file or directory: {member.name}"
|
|
188
|
+
)
|
|
189
|
+
if name in files:
|
|
190
|
+
raise WorkspacePatchError(f"duplicate workspace path: {name}")
|
|
191
|
+
total += member.size
|
|
192
|
+
if total > MAX_WORKSPACE_PATCH_UNPACKED_BYTES:
|
|
193
|
+
raise WorkspacePatchError("workspace archive expands beyond its limit")
|
|
194
|
+
source = archive.extractfile(member)
|
|
195
|
+
if source is None:
|
|
196
|
+
raise WorkspacePatchError(f"workspace file has no content: {name}")
|
|
197
|
+
files[name] = (source.read(), member.mode & 0o777)
|
|
198
|
+
except WorkspacePatchError:
|
|
199
|
+
raise
|
|
200
|
+
except (tarfile.TarError, OSError, EOFError) as error:
|
|
201
|
+
raise WorkspacePatchError("workspace must be a gzip tar archive") from error
|
|
202
|
+
return files
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_operation(value: object) -> WorkspacePatchOperation:
|
|
206
|
+
if not isinstance(value, dict):
|
|
207
|
+
raise WorkspacePatchError("workspace patch operation must be an object")
|
|
208
|
+
path_value = value.get("path")
|
|
209
|
+
if not isinstance(path_value, str):
|
|
210
|
+
raise WorkspacePatchError("workspace patch operation has no path")
|
|
211
|
+
path = _normalized_name(path_value)
|
|
212
|
+
if path == ".":
|
|
213
|
+
raise WorkspacePatchError("workspace patch operation cannot target the root")
|
|
214
|
+
before = _parse_state(value.get("before"))
|
|
215
|
+
after = _parse_state(value.get("after"))
|
|
216
|
+
if before == after:
|
|
217
|
+
raise WorkspacePatchError(f"workspace patch operation does not change: {path}")
|
|
218
|
+
return WorkspacePatchOperation(path=path, before=before, after=after)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_state(value: object) -> PatchFileState | None:
|
|
222
|
+
if value is None:
|
|
223
|
+
return None
|
|
224
|
+
if not isinstance(value, dict):
|
|
225
|
+
raise WorkspacePatchError("workspace patch file state must be an object")
|
|
226
|
+
sha256 = value.get("sha256")
|
|
227
|
+
mode = value.get("mode")
|
|
228
|
+
if (
|
|
229
|
+
not isinstance(sha256, str)
|
|
230
|
+
or len(sha256) != 64
|
|
231
|
+
or any(character not in "0123456789abcdef" for character in sha256)
|
|
232
|
+
or not isinstance(mode, int)
|
|
233
|
+
or isinstance(mode, bool)
|
|
234
|
+
or not 0 <= mode <= 0o777
|
|
235
|
+
):
|
|
236
|
+
raise WorkspacePatchError("workspace patch file state is invalid")
|
|
237
|
+
return PatchFileState(sha256=sha256, mode=mode)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _state(entry: tuple[bytes, int] | None) -> PatchFileState | None:
|
|
241
|
+
if entry is None:
|
|
242
|
+
return None
|
|
243
|
+
body, mode = entry
|
|
244
|
+
return PatchFileState(sha256=_digest(body), mode=mode)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _state_json(state: PatchFileState | None) -> dict[str, object] | None:
|
|
248
|
+
if state is None:
|
|
249
|
+
return None
|
|
250
|
+
return {"sha256": state.sha256, "mode": state.mode}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _add_bytes(archive: tarfile.TarFile, name: str, body: bytes, mode: int) -> None:
|
|
254
|
+
info = tarfile.TarInfo(name)
|
|
255
|
+
info.size = len(body)
|
|
256
|
+
info.mode = mode
|
|
257
|
+
info.mtime = 0
|
|
258
|
+
archive.addfile(info, io.BytesIO(body))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _normalized_name(name: str) -> str:
|
|
262
|
+
path = PurePosixPath(name)
|
|
263
|
+
if path.is_absolute() or ".." in path.parts:
|
|
264
|
+
raise WorkspacePatchError(f"unsafe workspace patch path: {name}")
|
|
265
|
+
parts = tuple(part for part in path.parts if part not in {"", "."})
|
|
266
|
+
return PurePosixPath(*parts).as_posix() if parts else "."
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _digest(body: bytes) -> str:
|
|
270
|
+
return hashlib.sha256(body, usedforsecurity=False).hexdigest()
|
wmo/ingest/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Trace ingestion: file uploads and vendor pulls -> normalized `Trace` objects.
|
|
2
|
+
|
|
3
|
+
A `TraceAdapter` turns one source's telemetry into the generic `Trace` schema. Adapters register
|
|
4
|
+
themselves on import and are looked up by name (`get_adapter`) or listed (`list_adapters`). The
|
|
5
|
+
span-based adapters share one normalizer (`wmo.ingest.normalize`) and the `BaseTraceAdapter`
|
|
6
|
+
scaffolding, so adding a new source is transport + attribute-mapping, not a rewrite. See
|
|
7
|
+
`docs/reference/ingest.md`.
|
|
8
|
+
|
|
9
|
+
Bundled adapters:
|
|
10
|
+
- `otel-genai` : OTLP-JSON spans following the OTel GenAI semantic conventions (file or pull).
|
|
11
|
+
- `chat-json` : recorded OpenAI-style chat/tool-call conversations (file).
|
|
12
|
+
Provider adapters (Braintrust, Phoenix/Arize, Langfuse, LangSmith) register when their module is
|
|
13
|
+
imported; their heavy SDKs are optional extras, imported lazily inside the adapter.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Import for the registration side effect so `get_adapter(...)` works on package import. The
|
|
17
|
+
# provider adapters are SDK-free (they parse exports as JSON and pull over httpx), so importing them
|
|
18
|
+
# here is cheap and brings no heavy dependency — their optional extras only matter if a user drives
|
|
19
|
+
# the provider's own SDK alongside `wmo ingest`.
|
|
20
|
+
from wmo.ingest import braintrust as braintrust # noqa: F401
|
|
21
|
+
from wmo.ingest import langfuse as langfuse # noqa: F401
|
|
22
|
+
from wmo.ingest import langsmith as langsmith # noqa: F401
|
|
23
|
+
from wmo.ingest import mastra as mastra # noqa: F401
|
|
24
|
+
from wmo.ingest import messages as messages # noqa: F401
|
|
25
|
+
from wmo.ingest import otel_genai as otel_genai # noqa: F401
|
|
26
|
+
from wmo.ingest import phoenix as phoenix # noqa: F401
|
|
27
|
+
from wmo.ingest import postgres as postgres # noqa: F401
|
|
28
|
+
from wmo.ingest import posthog as posthog # noqa: F401
|
|
29
|
+
from wmo.ingest.adapter import (
|
|
30
|
+
TraceAdapter,
|
|
31
|
+
VendorPull,
|
|
32
|
+
get_adapter,
|
|
33
|
+
list_adapters,
|
|
34
|
+
register_adapter,
|
|
35
|
+
)
|
|
36
|
+
from wmo.ingest.base import BaseTraceAdapter
|
|
37
|
+
from wmo.ingest.quality import drop_degenerate_traces
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"BaseTraceAdapter",
|
|
41
|
+
"TraceAdapter",
|
|
42
|
+
"VendorPull",
|
|
43
|
+
"drop_degenerate_traces",
|
|
44
|
+
"get_adapter",
|
|
45
|
+
"list_adapters",
|
|
46
|
+
"register_adapter",
|
|
47
|
+
]
|
wmo/ingest/adapter.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""TraceAdapter protocol + a small registry.
|
|
2
|
+
|
|
3
|
+
Sources differ in two ways: *transport* (file vs. vendor SDK) and *schema* (which OTel semantic
|
|
4
|
+
convention the spans follow). An adapter owns both: it pulls/reads raw spans and normalizes them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from wmo.core.types import Trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SourceCredentialError(PermissionError):
|
|
17
|
+
"""A source rejected the supplied credentials (API key, DSN password).
|
|
18
|
+
|
|
19
|
+
Adapters raise this instead of bare `PermissionError` so the streaming ingest can map it
|
|
20
|
+
to the `bad_credentials` wire code without misclassifying OS-level permission failures
|
|
21
|
+
(e.g. an unreadable local file) as credential problems. Subclassing `PermissionError`
|
|
22
|
+
keeps it a stdlib type: callers never need a driver import to catch it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VendorPull(BaseModel):
|
|
27
|
+
"""Parameters for pulling traces from an observability vendor's API or a database."""
|
|
28
|
+
|
|
29
|
+
api_key: str | None = None # falls back to the vendor's env var when None
|
|
30
|
+
project: str | None = None # vendor project / workspace to pull from
|
|
31
|
+
since: str | None = None # ISO-8601 lower bound on trace start time
|
|
32
|
+
limit: int | None = None # max traces to pull
|
|
33
|
+
|
|
34
|
+
# Database-source transport params (the `postgres` adapter); API-vendor adapters ignore them.
|
|
35
|
+
dsn: str | None = None # connection string; falls back to $WMO_POSTGRES_DSN
|
|
36
|
+
table: str | None = None # table holding the trace rows
|
|
37
|
+
trace_id_column: str | None = None # override; default "trace_id" (absent -> row-per-trace)
|
|
38
|
+
payload_column: str | None = None # override; default "payload"
|
|
39
|
+
order_column: str | None = None # override; default "created_at" when the table has it
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class TraceAdapter(Protocol):
|
|
44
|
+
"""Turns one source's raw telemetry into normalized `Trace` objects."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
def from_file(self, path: str) -> list[Trace]:
|
|
49
|
+
"""Read traces from an exported file (OTLP-JSON / vendor JSONL)."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def from_vendor(self, pull: VendorPull) -> list[Trace]:
|
|
53
|
+
"""Pull traces via a vendor SDK/API."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ADAPTERS: dict[str, TraceAdapter] = {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register_adapter(adapter: TraceAdapter) -> None:
|
|
61
|
+
_ADAPTERS[adapter.name] = adapter
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_adapter(name: str) -> TraceAdapter:
|
|
65
|
+
if name not in _ADAPTERS:
|
|
66
|
+
raise ValueError(f"no trace adapter registered for {name!r}; have {list(_ADAPTERS)}")
|
|
67
|
+
return _ADAPTERS[name]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_adapters() -> list[str]:
|
|
71
|
+
"""Names of all registered trace adapters, sorted (what the build source picker shows)."""
|
|
72
|
+
return sorted(_ADAPTERS)
|
wmo/ingest/base.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""`BaseTraceAdapter` — shared scaffolding so a new source is transport + mapping, not a rewrite.
|
|
2
|
+
|
|
3
|
+
A span-based provider adapter only needs to answer two questions:
|
|
4
|
+
1. how do I get raw bytes/objects? (a file path, or a vendor API/SDK pull)
|
|
5
|
+
2. how do I turn one raw payload into `SpanRecord`s? (the provider's export shape)
|
|
6
|
+
Everything after that — JSON/JSONL loading, grouping spans into `Trace`s, honoring `wmo.*`
|
|
7
|
+
enrichments — is shared (`wmo.ingest.normalize`). `BaseTraceAdapter` wires (1) and (2) together so
|
|
8
|
+
a concrete adapter is typically ~30 lines: set `name`, implement `spans_from_payload`, and (if it
|
|
9
|
+
supports live pulls) `_pull_payloads`.
|
|
10
|
+
|
|
11
|
+
Subclasses override:
|
|
12
|
+
- `name`: the registry key (e.g. "phoenix").
|
|
13
|
+
- `spans_from_payload(payload) -> list[SpanRecord]`: map ONE decoded JSON payload to spans. The
|
|
14
|
+
default delegates to `wmo.ingest.normalize.collect_spans` (OTLP/OpenInference-JSON); override it
|
|
15
|
+
when the provider's export is not OTLP-shaped.
|
|
16
|
+
- `_pull_payloads(pull) -> list[JsonValue]`: yield raw payloads from the vendor API/SDK. The
|
|
17
|
+
default raises a friendly "not implemented" — so `from_file` works with no override, and
|
|
18
|
+
`from_vendor` is opt-in.
|
|
19
|
+
|
|
20
|
+
`from_file` accepts a single JSON document (object or array) OR JSONL (one payload per line), and
|
|
21
|
+
skips a single corrupt JSONL line rather than aborting the whole ingest.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from pydantic import JsonValue
|
|
30
|
+
|
|
31
|
+
from wmo.core.types import Trace
|
|
32
|
+
from wmo.ingest.adapter import VendorPull
|
|
33
|
+
from wmo.ingest.normalize import SpanRecord, collect_spans, spans_to_traces
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_payloads(text: str) -> list[JsonValue]:
|
|
37
|
+
"""Parse a whole-document JSON payload, or per-line JSONL on a top-level decode failure.
|
|
38
|
+
|
|
39
|
+
Module-level (not only a `BaseTraceAdapter` method) because format auto-detection and the
|
|
40
|
+
streaming ingest need the payloads BEFORE an adapter has been chosen.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return [json.loads(text)]
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
payloads: list[JsonValue] = []
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
stripped = line.strip()
|
|
48
|
+
if not stripped:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
payloads.append(json.loads(stripped))
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue # tolerate a truncated/corrupt line; keep the rest
|
|
54
|
+
return payloads
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BaseTraceAdapter:
|
|
58
|
+
"""Default file+vendor plumbing around the shared span normalizer."""
|
|
59
|
+
|
|
60
|
+
name: str = "base"
|
|
61
|
+
|
|
62
|
+
# --- mapping hook (override for non-OTLP export shapes) -----------------------------------
|
|
63
|
+
|
|
64
|
+
def spans_from_payload(self, payload: JsonValue) -> list[SpanRecord]:
|
|
65
|
+
"""Map ONE decoded payload to `SpanRecord`s. Default: OTLP/OpenInference-JSON collection."""
|
|
66
|
+
return collect_spans(payload)
|
|
67
|
+
|
|
68
|
+
# --- transport hooks ----------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _pull_payloads(self, pull: VendorPull) -> list[JsonValue]:
|
|
71
|
+
"""Fetch raw payloads from the vendor API/SDK. Override to support live pulls."""
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"{self.name!r} does not support live vendor pulls yet; export traces to a file and "
|
|
74
|
+
f"use `from_file` (or `wmo ingest --source {self.name} --file <export>`)"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# --- public API (rarely overridden) -------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def collect_all(self, payloads: list[JsonValue]) -> list[SpanRecord]:
|
|
80
|
+
"""Map every payload to spans and re-stamp `span_id` globally unique in emission order.
|
|
81
|
+
|
|
82
|
+
Adapters assign `span_id`/`start_nano` per payload, so a trace split across payloads (e.g.
|
|
83
|
+
one row/observation/run per JSONL line) would otherwise emit colliding ids and identical
|
|
84
|
+
`start_nano`. `spans_to_traces` sorts by `(start_nano, span_id)`, so the collision scrambles
|
|
85
|
+
the action/observation pairing. Stamping a globally monotonic `span_id` makes equal-time
|
|
86
|
+
spans (the row-adapter case, all `start_nano=0`) order by emission, while real timestamps
|
|
87
|
+
(e.g. Phoenix) still dominate the sort. Uniqueness is owned here so adapters can't get it
|
|
88
|
+
wrong individually.
|
|
89
|
+
"""
|
|
90
|
+
spans: list[SpanRecord] = []
|
|
91
|
+
for payload in payloads:
|
|
92
|
+
for span in self.spans_from_payload(payload):
|
|
93
|
+
span.span_id = f"{len(spans):012d}-{span.span_id}"
|
|
94
|
+
spans.append(span)
|
|
95
|
+
return spans
|
|
96
|
+
|
|
97
|
+
def spans_from_file(self, path: str) -> list[SpanRecord]:
|
|
98
|
+
"""All (uniquely re-stamped) spans in a file export: the streaming ingest's seam."""
|
|
99
|
+
return self.collect_all(load_payloads(Path(path).read_text(encoding="utf-8")))
|
|
100
|
+
|
|
101
|
+
def spans_from_pull(self, pull: VendorPull) -> list[SpanRecord]:
|
|
102
|
+
"""All (uniquely re-stamped) spans from a vendor pull: the streaming ingest's seam."""
|
|
103
|
+
return self.collect_all(self._pull_payloads(pull))
|
|
104
|
+
|
|
105
|
+
def from_file(self, path: str) -> list[Trace]:
|
|
106
|
+
return spans_to_traces(self.spans_from_file(path), source=f"{self.name}:{path}")
|
|
107
|
+
|
|
108
|
+
def from_vendor(self, pull: VendorPull) -> list[Trace]:
|
|
109
|
+
traces = spans_to_traces(
|
|
110
|
+
self.spans_from_pull(pull), source=f"{self.name}:{pull.project or 'vendor'}"
|
|
111
|
+
)
|
|
112
|
+
if pull.limit is not None:
|
|
113
|
+
traces = traces[: pull.limit]
|
|
114
|
+
return traces
|