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/harness/skills.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Skills: reusable techniques a harness ships, one `SKILL.md`-style unit each.
|
|
2
|
+
|
|
3
|
+
A skill is one markdown file with tiny frontmatter (`name`, `description`) and a body. Harnesses
|
|
4
|
+
surface skills by **progressive disclosure**: only the name+description index is preloaded into the
|
|
5
|
+
system prompt (`render_index`); the agent pulls a full body on demand with the `read_skill` tool,
|
|
6
|
+
so always-loaded context stays small while the library grows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, field_validator
|
|
15
|
+
|
|
16
|
+
_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
17
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Skill(BaseModel):
|
|
21
|
+
"""One reusable skill: a name, a one-line trigger description, and a body to reuse.
|
|
22
|
+
|
|
23
|
+
Validation lives on the MODEL, not just the markdown parser: skill names become file paths
|
|
24
|
+
(`skills/<name>.md`), so a programmatically-constructed skill with a hostile name (`../../x`)
|
|
25
|
+
must be impossible, not merely improbable. The description is coerced to one line (it lives in
|
|
26
|
+
frontmatter, where a newline silently truncates on round-trip); the body is stripped so
|
|
27
|
+
round-trips compare equal.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
body: str
|
|
33
|
+
|
|
34
|
+
@field_validator("name")
|
|
35
|
+
@classmethod
|
|
36
|
+
def _kebab_name(cls, v: str) -> str:
|
|
37
|
+
if not _NAME_RE.match(v):
|
|
38
|
+
raise ValueError(f"skill name {v!r} must be kebab-case ([a-z0-9-])")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
@field_validator("description")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _one_line(cls, v: str) -> str:
|
|
44
|
+
return " ".join(v.split())
|
|
45
|
+
|
|
46
|
+
@field_validator("body")
|
|
47
|
+
@classmethod
|
|
48
|
+
def _stripped(cls, v: str) -> str:
|
|
49
|
+
return v.strip()
|
|
50
|
+
|
|
51
|
+
def to_markdown(self) -> str:
|
|
52
|
+
"""Serialize to a SKILL.md-style file (frontmatter + body)."""
|
|
53
|
+
return f"---\nname: {self.name}\ndescription: {self.description}\n---\n{self.body}"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_markdown(cls, text: str) -> Skill:
|
|
57
|
+
match = _FRONTMATTER_RE.match(text)
|
|
58
|
+
if match is None:
|
|
59
|
+
raise ValueError("skill file has no frontmatter")
|
|
60
|
+
meta = parse_frontmatter(match.group(1))
|
|
61
|
+
name = meta.get("name", "")
|
|
62
|
+
if not _NAME_RE.match(name):
|
|
63
|
+
raise ValueError(f"skill name {name!r} must be kebab-case")
|
|
64
|
+
return cls(name=name, description=meta.get("description", ""), body=match.group(2))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_frontmatter(block: str) -> dict[str, str]:
|
|
68
|
+
"""Parse the tiny `key: value` frontmatter (one field per line)."""
|
|
69
|
+
out: dict[str, str] = {}
|
|
70
|
+
for line in block.splitlines():
|
|
71
|
+
key, sep, value = line.partition(":")
|
|
72
|
+
if sep:
|
|
73
|
+
out[key.strip()] = value.strip()
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SkillLibrary:
|
|
78
|
+
"""An in-memory set of skills, loadable from / writable to a `skills/` directory."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, skills: list[Skill] | None = None) -> None:
|
|
81
|
+
self._skills: dict[str, Skill] = {s.name: s for s in (skills or [])}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dir(cls, root: str | Path) -> SkillLibrary:
|
|
85
|
+
"""Load every `*.md` under `root` (a malformed skill file is an error, not skipped —
|
|
86
|
+
a harness that ships a broken skill should fail loudly at load time, not at rollout)."""
|
|
87
|
+
library = cls()
|
|
88
|
+
root = Path(root)
|
|
89
|
+
if not root.exists():
|
|
90
|
+
return library
|
|
91
|
+
for path in sorted(root.glob("*.md")):
|
|
92
|
+
skill = Skill.from_markdown(path.read_text(encoding="utf-8"))
|
|
93
|
+
library._skills[skill.name] = skill
|
|
94
|
+
return library
|
|
95
|
+
|
|
96
|
+
def write_dir(self, root: str | Path) -> None:
|
|
97
|
+
"""Write one `<name>.md` per skill under `root` (created if missing)."""
|
|
98
|
+
root = Path(root)
|
|
99
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
for skill in self._skills.values():
|
|
101
|
+
(root / f"{skill.name}.md").write_text(skill.to_markdown(), encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
def __len__(self) -> int:
|
|
104
|
+
return len(self._skills)
|
|
105
|
+
|
|
106
|
+
def names(self) -> list[str]:
|
|
107
|
+
return sorted(self._skills)
|
|
108
|
+
|
|
109
|
+
def get(self, name: str) -> Skill | None:
|
|
110
|
+
return self._skills.get(name)
|
|
111
|
+
|
|
112
|
+
def render_index(self) -> str:
|
|
113
|
+
"""Progressive-disclosure index: name + description only (bodies via read_skill)."""
|
|
114
|
+
if not self._skills:
|
|
115
|
+
return ""
|
|
116
|
+
return "\n".join(f"- {s.name}: {s.description}" for s in self._skills.values())
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Portable source trees for editing and reconstructing complete harnesses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from wmo.core.text import validate_durable_text
|
|
13
|
+
|
|
14
|
+
# _SAFE_PATH_RE and _SLUG_RE are doc's path and surface-id grammars; sharing them keeps this
|
|
15
|
+
# module's file checks and Surface validation from ever drifting apart.
|
|
16
|
+
from wmo.harness.doc import (
|
|
17
|
+
_SAFE_PATH_RE,
|
|
18
|
+
_SLUG_RE,
|
|
19
|
+
_STORE_METADATA_FILES,
|
|
20
|
+
CODE_RUNTIME_ID,
|
|
21
|
+
MAX_OUTPUT_TOKENS_ID,
|
|
22
|
+
MAX_SURFACE_PATH_BYTES,
|
|
23
|
+
MAX_TURNS_ID,
|
|
24
|
+
RUNTIME_KIND_ID,
|
|
25
|
+
TEMPERATURE_ID,
|
|
26
|
+
TOOL_POLICY_ID,
|
|
27
|
+
HarnessDoc,
|
|
28
|
+
Surface,
|
|
29
|
+
SurfaceKind,
|
|
30
|
+
code_surface_id,
|
|
31
|
+
)
|
|
32
|
+
from wmo.harness.skills import Skill
|
|
33
|
+
|
|
34
|
+
SYSTEM_FILE = "SYSTEM.md"
|
|
35
|
+
CONFIG_FILE = "config.toml"
|
|
36
|
+
RUNTIME_FILE = "runtime.py"
|
|
37
|
+
SKILLS_DIR = "skills"
|
|
38
|
+
|
|
39
|
+
_RESERVED_RENDER_FILES = frozenset({SYSTEM_FILE, CONFIG_FILE, RUNTIME_FILE})
|
|
40
|
+
_TREE_HASH_BYTES = 16
|
|
41
|
+
MAX_SOURCE_PATH_BYTES = MAX_SURFACE_PATH_BYTES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HarnessSourceFile(BaseModel):
|
|
45
|
+
"""One UTF-8 text file at a canonical path inside a harness source tree."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(frozen=True)
|
|
48
|
+
|
|
49
|
+
path: str
|
|
50
|
+
content: str
|
|
51
|
+
|
|
52
|
+
@model_validator(mode="after")
|
|
53
|
+
def _validate_file(self) -> HarnessSourceFile:
|
|
54
|
+
candidate = PurePosixPath(self.path)
|
|
55
|
+
if (
|
|
56
|
+
candidate.is_absolute()
|
|
57
|
+
or not candidate.parts
|
|
58
|
+
or candidate.as_posix() != self.path
|
|
59
|
+
or ".." in candidate.parts
|
|
60
|
+
or not _SAFE_PATH_RE.fullmatch(self.path)
|
|
61
|
+
or len(self.path.encode("utf-8")) > MAX_SOURCE_PATH_BYTES
|
|
62
|
+
):
|
|
63
|
+
raise ValueError(f"source path {self.path!r} must be a canonical relative POSIX path")
|
|
64
|
+
validate_durable_text(self.content, field=f"source file {self.path!r}")
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HarnessSourceTree(BaseModel):
|
|
69
|
+
"""A complete editable file representation that can be parsed into a HarnessDoc.
|
|
70
|
+
|
|
71
|
+
Round-tripping through `from_doc`/`to_doc` is lossless only on the canonical subset: a
|
|
72
|
+
multi-prompt document renders as one SYSTEM.md and reparses as a single `prompt:core`
|
|
73
|
+
surface, and validation-only `Surface.budget` values are dropped.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True)
|
|
77
|
+
|
|
78
|
+
files: tuple[HarnessSourceFile, ...]
|
|
79
|
+
|
|
80
|
+
@field_validator("files")
|
|
81
|
+
@classmethod
|
|
82
|
+
def _canonical_files(
|
|
83
|
+
cls, files: tuple[HarnessSourceFile, ...]
|
|
84
|
+
) -> tuple[HarnessSourceFile, ...]:
|
|
85
|
+
paths = [item.path for item in files]
|
|
86
|
+
duplicates = sorted({path for path in paths if paths.count(path) > 1})
|
|
87
|
+
if duplicates:
|
|
88
|
+
raise ValueError(f"duplicate source path(s): {duplicates}")
|
|
89
|
+
metadata = sorted(path for path in paths if path in _STORE_METADATA_FILES)
|
|
90
|
+
if metadata:
|
|
91
|
+
raise ValueError(f"source tree cannot contain store metadata file(s): {metadata}")
|
|
92
|
+
# Renders land on real filesystems, so structural conflicts must fail here, at
|
|
93
|
+
# validation time, not later inside a store write.
|
|
94
|
+
by_fold: dict[str, str] = {}
|
|
95
|
+
for path in sorted(paths):
|
|
96
|
+
claimed = by_fold.setdefault(path.casefold(), path)
|
|
97
|
+
if claimed != path:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"source paths {claimed!r} and {path!r} differ only by letter case and "
|
|
100
|
+
"would collide on a case-insensitive filesystem; rename one"
|
|
101
|
+
)
|
|
102
|
+
directory_prefixes: dict[str, str] = {}
|
|
103
|
+
for path in paths:
|
|
104
|
+
parts = path.split("/")
|
|
105
|
+
for index in range(1, len(parts)):
|
|
106
|
+
directory_prefixes.setdefault("/".join(parts[:index]), path)
|
|
107
|
+
for path in sorted(paths):
|
|
108
|
+
child = directory_prefixes.get(path)
|
|
109
|
+
if child is not None:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"source path {path!r} is a file but is also the directory holding "
|
|
112
|
+
f"{child!r}; rename one so no file path is a directory prefix of another"
|
|
113
|
+
)
|
|
114
|
+
return tuple(sorted(files, key=lambda item: item.path))
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_doc(cls, doc: HarnessDoc) -> HarnessSourceTree:
|
|
118
|
+
"""Render a harness into the exact source files an external editor should see."""
|
|
119
|
+
files: dict[str, str] = {
|
|
120
|
+
SYSTEM_FILE: doc.system_prompt(),
|
|
121
|
+
CONFIG_FILE: tomli_w.dumps(
|
|
122
|
+
{
|
|
123
|
+
"harness": {
|
|
124
|
+
"tools": doc.tools(),
|
|
125
|
+
"max_turns": doc.max_turns(),
|
|
126
|
+
"max_output_tokens": doc.max_output_tokens(),
|
|
127
|
+
"temperature": doc.temperature(),
|
|
128
|
+
"runtime_kind": doc.runtime_kind(),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
for skill in doc.skills():
|
|
134
|
+
files[f"{SKILLS_DIR}/{skill.name}.md"] = skill.to_markdown()
|
|
135
|
+
runtime = doc.surface(CODE_RUNTIME_ID)
|
|
136
|
+
if runtime is not None:
|
|
137
|
+
files[RUNTIME_FILE] = runtime.content
|
|
138
|
+
for surface in doc.code_files():
|
|
139
|
+
assert surface.path is not None
|
|
140
|
+
if surface.path in _RESERVED_RENDER_FILES or surface.path.startswith(f"{SKILLS_DIR}/"):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"code surface path {surface.path!r} collides with a reserved file"
|
|
143
|
+
)
|
|
144
|
+
files[surface.path] = surface.content
|
|
145
|
+
return cls(
|
|
146
|
+
files=tuple(
|
|
147
|
+
HarnessSourceFile(path=path, content=content) for path, content in files.items()
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def to_doc(self, name: str) -> HarnessDoc:
|
|
152
|
+
"""Parse the complete source tree into one validated harness document."""
|
|
153
|
+
files = self.file_map()
|
|
154
|
+
if SYSTEM_FILE not in files:
|
|
155
|
+
raise ValueError(f"harness source tree is missing required {SYSTEM_FILE}")
|
|
156
|
+
surfaces = [
|
|
157
|
+
Surface(
|
|
158
|
+
id="prompt:core",
|
|
159
|
+
kind=SurfaceKind.PROMPT,
|
|
160
|
+
content=files[SYSTEM_FILE],
|
|
161
|
+
)
|
|
162
|
+
]
|
|
163
|
+
config_text = files.get(CONFIG_FILE)
|
|
164
|
+
if config_text is not None:
|
|
165
|
+
parsed_config = tomllib.loads(config_text)
|
|
166
|
+
unknown_tables = sorted(set(parsed_config) - {"harness"})
|
|
167
|
+
if unknown_tables:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{CONFIG_FILE} contains unknown top-level table(s): {unknown_tables}"
|
|
170
|
+
)
|
|
171
|
+
config = parsed_config.get("harness", {})
|
|
172
|
+
if not isinstance(config, dict):
|
|
173
|
+
raise ValueError(f"{CONFIG_FILE} [harness] must be a table")
|
|
174
|
+
allowed_fields = {
|
|
175
|
+
"tools",
|
|
176
|
+
"max_turns",
|
|
177
|
+
"max_output_tokens",
|
|
178
|
+
"temperature",
|
|
179
|
+
"runtime_kind",
|
|
180
|
+
}
|
|
181
|
+
unknown_fields = sorted(set(config) - allowed_fields)
|
|
182
|
+
if unknown_fields:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"{CONFIG_FILE} [harness] contains unknown field(s): {unknown_fields}"
|
|
185
|
+
)
|
|
186
|
+
tools = config.get("tools")
|
|
187
|
+
if tools is not None:
|
|
188
|
+
if not isinstance(tools, list) or not all(isinstance(item, str) for item in tools):
|
|
189
|
+
raise ValueError(f"{CONFIG_FILE} harness.tools must be an array of strings")
|
|
190
|
+
surfaces.append(
|
|
191
|
+
Surface(
|
|
192
|
+
id=TOOL_POLICY_ID,
|
|
193
|
+
kind=SurfaceKind.TOOL_POLICY,
|
|
194
|
+
content="\n".join(tools),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
scalar_fields = (
|
|
198
|
+
("max_turns", MAX_TURNS_ID),
|
|
199
|
+
("max_output_tokens", MAX_OUTPUT_TOKENS_ID),
|
|
200
|
+
("temperature", TEMPERATURE_ID),
|
|
201
|
+
)
|
|
202
|
+
for field, surface_id in scalar_fields:
|
|
203
|
+
if field in config:
|
|
204
|
+
surfaces.append(
|
|
205
|
+
Surface(
|
|
206
|
+
id=surface_id,
|
|
207
|
+
kind=SurfaceKind.PARAM,
|
|
208
|
+
content=str(config[field]),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
runtime_kind = config.get("runtime_kind")
|
|
212
|
+
if runtime_kind is not None and not isinstance(runtime_kind, str):
|
|
213
|
+
raise ValueError(f"{CONFIG_FILE} harness.runtime_kind must be a string")
|
|
214
|
+
if runtime_kind and runtime_kind != "kit-python":
|
|
215
|
+
surfaces.append(
|
|
216
|
+
Surface(
|
|
217
|
+
id=RUNTIME_KIND_ID,
|
|
218
|
+
kind=SurfaceKind.PARAM,
|
|
219
|
+
content=str(runtime_kind),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
runtime = files.get(RUNTIME_FILE)
|
|
223
|
+
if runtime is not None:
|
|
224
|
+
surfaces.append(Surface(id=CODE_RUNTIME_ID, kind=SurfaceKind.CODE, content=runtime))
|
|
225
|
+
for item in self.files:
|
|
226
|
+
path = PurePosixPath(item.path)
|
|
227
|
+
if path.parts[0] != SKILLS_DIR:
|
|
228
|
+
continue
|
|
229
|
+
if len(path.parts) != 2 or path.suffix != ".md":
|
|
230
|
+
raise ValueError(f"skill source path {item.path!r} must be skills/<skill-name>.md")
|
|
231
|
+
skill = Skill.from_markdown(item.content)
|
|
232
|
+
if path.stem != skill.name:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"skill source path {item.path!r} does not match declared name {skill.name!r}"
|
|
235
|
+
)
|
|
236
|
+
surfaces.append(
|
|
237
|
+
Surface(
|
|
238
|
+
id=f"skill:{skill.name}",
|
|
239
|
+
kind=SurfaceKind.SKILL,
|
|
240
|
+
content=skill.to_markdown(),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
code_paths_by_id: dict[str, str] = {}
|
|
244
|
+
for item in self.files:
|
|
245
|
+
if item.path in _RESERVED_RENDER_FILES or item.path.startswith(f"{SKILLS_DIR}/"):
|
|
246
|
+
continue
|
|
247
|
+
surface_id = _code_surface_id(item.path)
|
|
248
|
+
if surface_id == CODE_RUNTIME_ID:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"code file path {item.path!r} would alias the reserved in-process runtime "
|
|
251
|
+
f"surface {CODE_RUNTIME_ID!r} (which renders as {RUNTIME_FILE}); rename "
|
|
252
|
+
"the file"
|
|
253
|
+
)
|
|
254
|
+
claimed = code_paths_by_id.get(surface_id)
|
|
255
|
+
if claimed is not None:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"code file paths {claimed!r} and {item.path!r} both map to surface id "
|
|
258
|
+
f"{surface_id!r} ('/' and '.' both become '-'); rename one so every "
|
|
259
|
+
"path keeps a distinct id"
|
|
260
|
+
)
|
|
261
|
+
code_paths_by_id[surface_id] = item.path
|
|
262
|
+
surfaces.append(
|
|
263
|
+
Surface(
|
|
264
|
+
id=surface_id,
|
|
265
|
+
kind=SurfaceKind.CODE,
|
|
266
|
+
path=item.path,
|
|
267
|
+
content=item.content,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return HarnessDoc(name=name, surfaces=surfaces)
|
|
271
|
+
|
|
272
|
+
def file_map(self) -> dict[str, str]:
|
|
273
|
+
"""Return a new path-to-content mapping in canonical path order."""
|
|
274
|
+
return {item.path: item.content for item in self.files}
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def total_bytes(self) -> int:
|
|
278
|
+
"""Return the UTF-8 payload size across all source files."""
|
|
279
|
+
return sum(len(item.content.encode("utf-8")) for item in self.files)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def tree_hash(self) -> str:
|
|
283
|
+
"""Return a content address covering every source path and byte."""
|
|
284
|
+
digest = hashlib.blake2b(digest_size=_TREE_HASH_BYTES)
|
|
285
|
+
for item in self.files:
|
|
286
|
+
path = item.path.encode("utf-8")
|
|
287
|
+
content = item.content.encode("utf-8")
|
|
288
|
+
digest.update(len(path).to_bytes(4, "big"))
|
|
289
|
+
digest.update(path)
|
|
290
|
+
digest.update(len(content).to_bytes(8, "big"))
|
|
291
|
+
digest.update(content)
|
|
292
|
+
return digest.hexdigest()
|
|
293
|
+
|
|
294
|
+
def validate_bounds(self, *, max_files: int, max_bytes: int) -> None:
|
|
295
|
+
"""Reject a tree that exceeds explicit file-count or UTF-8 byte bounds."""
|
|
296
|
+
if isinstance(max_files, bool) or not isinstance(max_files, int) or max_files < 1:
|
|
297
|
+
raise ValueError("max_files must be a positive integer")
|
|
298
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1:
|
|
299
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
300
|
+
if len(self.files) > max_files:
|
|
301
|
+
raise ValueError(
|
|
302
|
+
f"source tree has {len(self.files)} files, more than {max_files} files allowed"
|
|
303
|
+
)
|
|
304
|
+
if self.total_bytes > max_bytes:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"source tree has {self.total_bytes} bytes, more than {max_bytes} bytes allowed"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _code_surface_id(path: str) -> str:
|
|
311
|
+
"""Derive a code file's surface id, failing with the path and the allowed grammar."""
|
|
312
|
+
surface_id = code_surface_id(path)
|
|
313
|
+
if not _SLUG_RE.fullmatch(surface_id.partition(":")[2]):
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"code file path {path!r} maps to surface id {surface_id!r}, which is not a valid "
|
|
316
|
+
"'code:<kebab-slug>' id; use lowercase [a-z0-9] runs separated by single '/', '.', "
|
|
317
|
+
"or '-' characters (for example src/agent-loop.ts)"
|
|
318
|
+
)
|
|
319
|
+
return surface_id
|
wmo/harness/store.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Harnesses on disk: immutable numbered versions plus movable aliases, per name.
|
|
2
|
+
|
|
3
|
+
Like world models under `.wmo/models/<name>/`, a harness is a named artifact — but harnesses
|
|
4
|
+
accumulate *versions*, because they are the thing `wmo optimize harness` iterates on. A
|
|
5
|
+
version, once
|
|
6
|
+
written, never changes; deployment state lives in movable aliases; and every eval result keys to an
|
|
7
|
+
immutable version rather than to "whatever the harness currently is".
|
|
8
|
+
|
|
9
|
+
.wmo/harnesses/<name>/
|
|
10
|
+
aliases.toml # [aliases] champion = 3 (movable pointers; rollback = re-point)
|
|
11
|
+
v1/
|
|
12
|
+
doc.json # the authoritative HarnessDoc serialization
|
|
13
|
+
SYSTEM.md # rendered export of the same document, for running the harness
|
|
14
|
+
config.toml # outside wmo — regenerated on every save, never read back
|
|
15
|
+
skills/<slug>.md # when doc.json is present
|
|
16
|
+
v3/ ...
|
|
17
|
+
|
|
18
|
+
`doc.json` is authoritative; the rendered files are an export. A directory with rendered files but
|
|
19
|
+
no `doc.json` (a hand-authored harness) still loads: the files parse into a single-prompt document.
|
|
20
|
+
Rendered-only loads are strict: unknown config.toml tables or fields, non-.md files under skills/,
|
|
21
|
+
and a skill filename that does not match its frontmatter name are errors with actionable messages,
|
|
22
|
+
not silently ignored. Dotfiles (.DS_Store and friends) are skipped.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import tomllib
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import tomli_w
|
|
31
|
+
|
|
32
|
+
from wmo.config.store import validate_name
|
|
33
|
+
from wmo.harness.doc import HarnessDoc
|
|
34
|
+
from wmo.harness.source_tree import SYSTEM_FILE, HarnessSourceFile, HarnessSourceTree
|
|
35
|
+
|
|
36
|
+
HARNESSES_DIR = "harnesses"
|
|
37
|
+
CHAMPION_ALIAS = "champion"
|
|
38
|
+
|
|
39
|
+
_DOC_FILE = "doc.json"
|
|
40
|
+
_ALIASES_FILE = "aliases.toml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HarnessStore:
|
|
44
|
+
"""Named, versioned harnesses under `<root>/harnesses/<name>/`."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, root: str | Path = ".wmo") -> None:
|
|
47
|
+
self.root = Path(root)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def harnesses_dir(self) -> Path:
|
|
51
|
+
return self.root / HARNESSES_DIR
|
|
52
|
+
|
|
53
|
+
def dir_for(self, name: str) -> Path:
|
|
54
|
+
return self.harnesses_dir / validate_name(name)
|
|
55
|
+
|
|
56
|
+
# -- enumeration -------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def list_names(self) -> list[str]:
|
|
59
|
+
if not self.harnesses_dir.exists():
|
|
60
|
+
return []
|
|
61
|
+
return sorted(
|
|
62
|
+
d.name for d in self.harnesses_dir.iterdir() if d.is_dir() and self.versions(d.name)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def versions(self, name: str) -> list[int]:
|
|
66
|
+
directory = self.dir_for(name)
|
|
67
|
+
if not directory.exists():
|
|
68
|
+
return []
|
|
69
|
+
found: list[int] = []
|
|
70
|
+
for child in directory.iterdir():
|
|
71
|
+
if child.is_dir() and child.name.startswith("v") and child.name[1:].isdigit():
|
|
72
|
+
found.append(int(child.name[1:]))
|
|
73
|
+
return sorted(found)
|
|
74
|
+
|
|
75
|
+
def exists(self, name: str) -> bool:
|
|
76
|
+
return bool(self.versions(name))
|
|
77
|
+
|
|
78
|
+
# -- aliases -----------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def aliases(self, name: str) -> dict[str, int]:
|
|
81
|
+
path = self.dir_for(name) / _ALIASES_FILE
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return {}
|
|
84
|
+
data = tomllib.loads(path.read_text(encoding="utf-8")).get("aliases", {})
|
|
85
|
+
return {k: v for k, v in data.items() if isinstance(v, int)}
|
|
86
|
+
|
|
87
|
+
def set_alias(self, name: str, alias: str, version: int) -> None:
|
|
88
|
+
"""Point `alias` at `version` (moving it if it exists). Rollback is re-pointing."""
|
|
89
|
+
if version not in self.versions(name):
|
|
90
|
+
raise ValueError(f"harness {name!r} has no version v{version}")
|
|
91
|
+
current = self.aliases(name)
|
|
92
|
+
current[alias] = version
|
|
93
|
+
path = self.dir_for(name) / _ALIASES_FILE
|
|
94
|
+
path.write_text(tomli_w.dumps({"aliases": current}), encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
# -- load / save ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def resolve_version(self, name: str, ref: str | None = None) -> int:
|
|
99
|
+
"""Resolve a version ref: `None` -> champion alias, else latest; `"vN"`/`"N"`; an alias."""
|
|
100
|
+
available = self.versions(name)
|
|
101
|
+
if not available:
|
|
102
|
+
raise FileNotFoundError(
|
|
103
|
+
f"no harness named {name!r} under {self.harnesses_dir} "
|
|
104
|
+
f"(have: {', '.join(self.list_names()) or 'none'})"
|
|
105
|
+
)
|
|
106
|
+
aliases = self.aliases(name)
|
|
107
|
+
if ref is None:
|
|
108
|
+
return aliases.get(CHAMPION_ALIAS, available[-1])
|
|
109
|
+
normalized = ref.removeprefix("v")
|
|
110
|
+
if normalized.isdigit():
|
|
111
|
+
version = int(normalized)
|
|
112
|
+
if version not in available:
|
|
113
|
+
raise ValueError(f"harness {name!r} has no version v{version}")
|
|
114
|
+
return version
|
|
115
|
+
if ref in aliases:
|
|
116
|
+
return aliases[ref]
|
|
117
|
+
raise ValueError(f"harness {name!r} has no version or alias {ref!r}")
|
|
118
|
+
|
|
119
|
+
def load(self, name: str, ref: str | None = None) -> HarnessDoc:
|
|
120
|
+
version = self.resolve_version(name, ref)
|
|
121
|
+
directory = self.dir_for(name) / f"v{version}"
|
|
122
|
+
doc_path = directory / _DOC_FILE
|
|
123
|
+
if doc_path.exists():
|
|
124
|
+
doc = HarnessDoc.model_validate_json(doc_path.read_text(encoding="utf-8"))
|
|
125
|
+
else:
|
|
126
|
+
doc = _parse_rendered(name, directory)
|
|
127
|
+
return doc.model_copy(update={"name": name, "version": version})
|
|
128
|
+
|
|
129
|
+
def save_version(self, doc: HarnessDoc, *, alias: str | None = None) -> HarnessDoc:
|
|
130
|
+
"""Write `doc` as the next version of its name; optionally point `alias` at it.
|
|
131
|
+
|
|
132
|
+
Versions are append-only: this never touches an existing version directory.
|
|
133
|
+
"""
|
|
134
|
+
validate_name(doc.name)
|
|
135
|
+
version = (self.versions(doc.name)[-1] + 1) if self.exists(doc.name) else 1
|
|
136
|
+
stamped = doc.model_copy(update={"version": version})
|
|
137
|
+
directory = self.dir_for(doc.name) / f"v{version}"
|
|
138
|
+
directory.mkdir(parents=True, exist_ok=False) # append-only: collision is a bug
|
|
139
|
+
(directory / _DOC_FILE).write_text(stamped.model_dump_json(indent=2), encoding="utf-8")
|
|
140
|
+
for rel_path, content in _render(stamped).items():
|
|
141
|
+
target = directory / rel_path
|
|
142
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
target.write_text(content, encoding="utf-8")
|
|
144
|
+
if alias is not None:
|
|
145
|
+
self.set_alias(doc.name, alias, version)
|
|
146
|
+
return stamped
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _render(doc: HarnessDoc) -> dict[str, str]:
|
|
150
|
+
"""Render the document to its file export (relative path -> content)."""
|
|
151
|
+
return HarnessSourceTree.from_doc(doc).file_map()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_rendered(name: str, directory: Path) -> HarnessDoc:
|
|
155
|
+
"""Parse a rendered/hand-authored directory (no doc.json) into a document.
|
|
156
|
+
|
|
157
|
+
The whole `SYSTEM.md` becomes one `prompt:core` surface — section boundaries are not
|
|
158
|
+
recoverable from a rendered prompt, which is exactly why `doc.json` is the authoritative form.
|
|
159
|
+
"""
|
|
160
|
+
files: list[HarnessSourceFile] = []
|
|
161
|
+
for path in sorted(directory.rglob("*")):
|
|
162
|
+
if not path.is_file():
|
|
163
|
+
continue
|
|
164
|
+
rel_path = path.relative_to(directory)
|
|
165
|
+
if any(part.startswith(".") for part in rel_path.parts):
|
|
166
|
+
# Finder and editors drop metadata like .DS_Store (often with NUL bytes) into
|
|
167
|
+
# hand-authored dirs. A dotfile can never be a harness surface (its code_surface_id
|
|
168
|
+
# is not a valid slug), so skip it instead of failing the load on its content.
|
|
169
|
+
continue
|
|
170
|
+
rel = rel_path.as_posix()
|
|
171
|
+
if rel in {_DOC_FILE, _ALIASES_FILE}:
|
|
172
|
+
continue
|
|
173
|
+
files.append(HarnessSourceFile(path=rel, content=path.read_text(encoding="utf-8")))
|
|
174
|
+
if not files:
|
|
175
|
+
raise ValueError(f"harness dir {directory} has neither {_DOC_FILE} nor {SYSTEM_FILE}")
|
|
176
|
+
return HarnessSourceTree(files=tuple(files)).to_doc(name)
|