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/env/base.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The `Env` protocol and its world-model backend.
|
|
2
|
+
|
|
3
|
+
An `Env` is one episode's worth of environment: `reset` starts it, `step` advances it. Real
|
|
4
|
+
environments (a benchmark harness, a coded oracle app, a simulator) implement the same protocol in
|
|
5
|
+
their example folders, which is what makes "iterate in the world model, validate in the real env"
|
|
6
|
+
a one-line swap instead of two agent loops.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from wmo.core.types import Action, EnvState, Observation
|
|
14
|
+
from wmo.engine.world_model import WorldModel
|
|
15
|
+
from wmo.optimize.reward import EpisodeScore
|
|
16
|
+
from wmo.tracking import RunRecord
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Env(Protocol):
|
|
21
|
+
"""One episode of an environment an agent steps against."""
|
|
22
|
+
|
|
23
|
+
def reset(self, task: str | None = None, seed_state: EnvState | None = None) -> EnvState:
|
|
24
|
+
"""Start a fresh episode; returns the environment's LIVE state view.
|
|
25
|
+
|
|
26
|
+
Contract: the returned `EnvState` is the env's current state object, updated in place as
|
|
27
|
+
the episode advances — callers that need a point-in-time snapshot must copy it
|
|
28
|
+
(`state.model_copy(deep=True)`), which is what `run_episode` does per recorded step.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def step(self, action: Action) -> Observation:
|
|
33
|
+
"""Apply `action` to the current episode and return the environment's response."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
"""Release episode resources (sessions, containers, sim handles). Idempotent."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WorldModelEnv:
|
|
42
|
+
"""`Env` backed by a `WorldModel` session.
|
|
43
|
+
|
|
44
|
+
Each `reset` opens a new session (ending any previous one); `step` delegates to
|
|
45
|
+
`WorldModel.step`. `close` ends the session in the world model — freeing its history and
|
|
46
|
+
metering — and keeps the final token/cost record available as `usage`.
|
|
47
|
+
|
|
48
|
+
RL rollouts need the episode judged before the session's history is freed, and `run_episode`
|
|
49
|
+
closes the env in its `finally` — so with `score_on_close=True`, `close` scores the session
|
|
50
|
+
(`WorldModel.score_session`) right before ending it and keeps the result as `last_score`:
|
|
51
|
+
|
|
52
|
+
env = WorldModelEnv(wm, score_on_close=True)
|
|
53
|
+
result = run_episode(env, agent, task)
|
|
54
|
+
reward = env.last_score.reward # scalar for GRPO/PPO/REINFORCE++; .critique for SDPO
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, world_model: WorldModel, *, score_on_close: bool = False) -> None:
|
|
58
|
+
self._world_model = world_model
|
|
59
|
+
self._score_on_close = score_on_close
|
|
60
|
+
self._session_id: str | None = None
|
|
61
|
+
self._usage: RunRecord | None = None
|
|
62
|
+
self._last_score: EpisodeScore | None = None
|
|
63
|
+
self._score_error: Exception | None = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def session_id(self) -> str:
|
|
67
|
+
if self._session_id is None:
|
|
68
|
+
raise RuntimeError("WorldModelEnv has no active episode; call reset() first")
|
|
69
|
+
return self._session_id
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def usage(self) -> RunRecord | None:
|
|
73
|
+
"""Token/cost/time of the current episode (live) or the last closed one (final)."""
|
|
74
|
+
if self._session_id is not None:
|
|
75
|
+
return self._world_model.session_usage(self._session_id)
|
|
76
|
+
return self._usage
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def last_score(self) -> EpisodeScore:
|
|
80
|
+
"""The episode score captured by the most recent scoring `close`.
|
|
81
|
+
|
|
82
|
+
Raises if no scored episode has completed yet — either the env was built without
|
|
83
|
+
`score_on_close=True`, or `close` hasn't run. If the judge call itself failed during
|
|
84
|
+
`close` (throttle, network), the ORIGINAL failure is re-raised here: `run_episode`
|
|
85
|
+
deliberately swallows teardown errors, and a batch caller must see the real cause
|
|
86
|
+
instead of a misleading "no scored episode".
|
|
87
|
+
"""
|
|
88
|
+
if self._score_error is not None:
|
|
89
|
+
raise RuntimeError("scoring failed during close; see cause") from self._score_error
|
|
90
|
+
if self._last_score is None:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"no scored episode yet; construct WorldModelEnv(wm, score_on_close=True) "
|
|
93
|
+
"and complete an episode (run_episode closes — and thus scores — for you)"
|
|
94
|
+
)
|
|
95
|
+
return self._last_score
|
|
96
|
+
|
|
97
|
+
def reset(self, task: str | None = None, seed_state: EnvState | None = None) -> EnvState:
|
|
98
|
+
self.close() # a leftover session would otherwise leak in the world model
|
|
99
|
+
session = self._world_model.new_session(task=task, seed_state=seed_state)
|
|
100
|
+
self._session_id = session.id
|
|
101
|
+
return session.state
|
|
102
|
+
|
|
103
|
+
def step(self, action: Action) -> Observation:
|
|
104
|
+
return self._world_model.step(self.session_id, action)
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
if self._session_id is None:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
if self._score_on_close:
|
|
111
|
+
self._last_score = None
|
|
112
|
+
self._score_error = None
|
|
113
|
+
try:
|
|
114
|
+
self._last_score = self._world_model.score_session(self._session_id)
|
|
115
|
+
except Exception as exc: # noqa: BLE001 - preserved and re-raised by last_score
|
|
116
|
+
# A judge failure must not leak the session or masquerade as "unscored";
|
|
117
|
+
# the session still ends below and last_score re-raises this cause.
|
|
118
|
+
self._score_error = exc
|
|
119
|
+
finally:
|
|
120
|
+
self._usage = self._world_model.end_session(self._session_id)
|
|
121
|
+
self._session_id = None
|
wmo/env/closed_loop.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Closed-loop evaluation: roll every pool candidate over a scenario set, collect the matrix.
|
|
2
|
+
|
|
3
|
+
Each (candidate model, scenario, episode) cell runs one `run_episode` with the candidate driving
|
|
4
|
+
`LLMAgent` against the env, then reads the env's episode score (VERIFY). The result is the
|
|
5
|
+
`OutcomeMatrix` the routing optimizer fits on and the improvement report cites.
|
|
6
|
+
|
|
7
|
+
Measurement notes:
|
|
8
|
+
- Latency is measured per POLICY CALL (the candidate's own completions), not per episode: episode
|
|
9
|
+
wall time is dominated by the world model's simulation latency, which production traffic never
|
|
10
|
+
pays, so quoting it would flatter nobody honestly.
|
|
11
|
+
- Cost is the candidate side only, priced by its own pool entry; the env's serve/judge cost is
|
|
12
|
+
metered separately by the world model (D12 cost split).
|
|
13
|
+
- Every raw candidate reply is stored (`ScenarioOutcome.replies`): that is the future
|
|
14
|
+
distillation feed. Providers do not yet surface separated thinking blocks; when they do, the
|
|
15
|
+
capture point is `_TimedProvider.complete`.
|
|
16
|
+
- A cell is recorded SCORED only when the episode also ran clean. An episode that errored
|
|
17
|
+
mid-flight (provider throttle, agent crash) is unscored even if the env still produced a
|
|
18
|
+
score, because that score grades a run the candidate never got to finish: counting it as
|
|
19
|
+
reward would read infrastructure failure as incapability. The salvaged critique is kept on
|
|
20
|
+
the row, prefixed, so the diagnostic text is not lost.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
29
|
+
|
|
30
|
+
from wmo.env.base import Env
|
|
31
|
+
from wmo.env.episode import run_episode
|
|
32
|
+
from wmo.env.llm_agent import LLMAgent
|
|
33
|
+
from wmo.env.scenarios import Scenario
|
|
34
|
+
from wmo.optimize.outcomes import OutcomeMatrix, ScenarioOutcome
|
|
35
|
+
from wmo.optimize.reward import EpisodeScore
|
|
36
|
+
from wmo.providers.base import (
|
|
37
|
+
DEFAULT_MAX_TOKENS,
|
|
38
|
+
Completion,
|
|
39
|
+
Message,
|
|
40
|
+
Provider,
|
|
41
|
+
ProviderConfig,
|
|
42
|
+
TokenUsage,
|
|
43
|
+
VerifyResult,
|
|
44
|
+
)
|
|
45
|
+
from wmo.providers.pool import ModelPool, PoolEntry, pool_provider
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _TimedProvider:
|
|
54
|
+
"""Wraps the candidate's provider to record per-call seconds, usage, and raw replies."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, provider: Provider) -> None:
|
|
57
|
+
self._provider = provider
|
|
58
|
+
self.call_seconds: list[float] = []
|
|
59
|
+
self.replies: list[str] = []
|
|
60
|
+
self.usage = TokenUsage()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def config(self) -> ProviderConfig:
|
|
64
|
+
return self._provider.config
|
|
65
|
+
|
|
66
|
+
def complete(
|
|
67
|
+
self,
|
|
68
|
+
system: str,
|
|
69
|
+
messages: list[Message],
|
|
70
|
+
*,
|
|
71
|
+
temperature: float = 0.7,
|
|
72
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
73
|
+
) -> Completion:
|
|
74
|
+
started = time.monotonic()
|
|
75
|
+
completion = self._provider.complete(
|
|
76
|
+
system, messages, temperature=temperature, max_tokens=max_tokens
|
|
77
|
+
)
|
|
78
|
+
self.call_seconds.append(time.monotonic() - started)
|
|
79
|
+
self.replies.append(completion.text)
|
|
80
|
+
self.usage = TokenUsage(
|
|
81
|
+
input_tokens=self.usage.input_tokens + completion.usage.input_tokens,
|
|
82
|
+
cached_input_tokens=self.usage.cached_input_tokens
|
|
83
|
+
+ completion.usage.cached_input_tokens,
|
|
84
|
+
output_tokens=self.usage.output_tokens + completion.usage.output_tokens,
|
|
85
|
+
)
|
|
86
|
+
return completion
|
|
87
|
+
|
|
88
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
89
|
+
return self._provider.embed(texts)
|
|
90
|
+
|
|
91
|
+
def verify(self) -> VerifyResult:
|
|
92
|
+
return self._provider.verify()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_NEEDS_SCORING_ENV = (
|
|
96
|
+
"env produced no episode score; evaluate_pool needs a scoring env "
|
|
97
|
+
"(e.g. WorldModelEnv(world_model, score_on_close=True))"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Prefix on a critique salvaged from an episode that errored: the text is diagnostic, the
|
|
101
|
+
# verdict is not evidence (see the module docstring).
|
|
102
|
+
_SALVAGE_PREFIX = "salvage-judged despite error: "
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _ScoringEnv(Protocol):
|
|
106
|
+
"""The scoring surface `evaluate_pool` needs on top of `Env`; `WorldModelEnv` provides it."""
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def last_score(self) -> EpisodeScore: ...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_episode_score(env: Env) -> tuple[EpisodeScore | None, str | None]:
|
|
113
|
+
"""Read `env.last_score` defensively, returning (score, reason it is missing).
|
|
114
|
+
|
|
115
|
+
The two failure modes are NOT the same failure. A missing attribute means the caller handed
|
|
116
|
+
`evaluate_pool` a non-scoring env, so no cell of the sweep can ever be evidence and the
|
|
117
|
+
error is fatal. A `last_score` that RAISES (WorldModelEnv re-raises a close-time scoring
|
|
118
|
+
failure, e.g. a throttled judge call) is per-episode: that one cell is unscored with the
|
|
119
|
+
reason recorded, and the sweep keeps its other completed cells.
|
|
120
|
+
"""
|
|
121
|
+
scoring = cast("_ScoringEnv", env)
|
|
122
|
+
try:
|
|
123
|
+
raw: object = scoring.last_score
|
|
124
|
+
except AttributeError as exc:
|
|
125
|
+
raise ValueError(_NEEDS_SCORING_ENV) from exc
|
|
126
|
+
except RuntimeError as exc:
|
|
127
|
+
return None, f"episode scoring failed: {exc}"
|
|
128
|
+
if raw is None:
|
|
129
|
+
return None, None
|
|
130
|
+
if not isinstance(raw, EpisodeScore):
|
|
131
|
+
# An unscored row must always say WHY (the outcomes contract); a wrong-typed score
|
|
132
|
+
# silently becoming reward=None/error=None would violate it.
|
|
133
|
+
return None, (
|
|
134
|
+
f"env last_score is {type(raw).__name__}, not EpisodeScore; episode left unscored"
|
|
135
|
+
)
|
|
136
|
+
return raw, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scenario_id(scenario: Scenario) -> str:
|
|
140
|
+
"""Stable id for a scenario: its first provenance trace id, else a hash of the task.
|
|
141
|
+
|
|
142
|
+
Provisional until wm-create's generate contract ships first-class scenario ids
|
|
143
|
+
(DECISIONS.md 2026-07-23); both forms are deterministic across runs.
|
|
144
|
+
"""
|
|
145
|
+
if scenario.provenance:
|
|
146
|
+
return scenario.provenance[0]
|
|
147
|
+
return hashlib.sha256(scenario.task.encode("utf-8")).hexdigest()[:12]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def evaluate_pool(
|
|
151
|
+
env_factory: Callable[[], Env],
|
|
152
|
+
pool: ModelPool,
|
|
153
|
+
scenarios: list[Scenario],
|
|
154
|
+
*,
|
|
155
|
+
episodes_per_scenario: int = 1,
|
|
156
|
+
max_steps: int = 20,
|
|
157
|
+
agent_temperature: float = 0.0,
|
|
158
|
+
tools_hint: str | None = None,
|
|
159
|
+
provider_factory: Callable[[PoolEntry], Provider] = pool_provider,
|
|
160
|
+
on_outcome: Callable[[ScenarioOutcome], None] | None = None,
|
|
161
|
+
) -> OutcomeMatrix:
|
|
162
|
+
"""Run every pool candidate over `scenarios`, one fresh env per episode.
|
|
163
|
+
|
|
164
|
+
The env must score episodes on close (`WorldModelEnv(..., score_on_close=True)`): a matrix
|
|
165
|
+
without verified rewards is not evidence. Episodes that error, and episodes whose scoring
|
|
166
|
+
itself fails, are recorded unscored (`reward=None`, `error` set) rather than defaulted to 0,
|
|
167
|
+
and never abort the sweep. `on_outcome` fires after each cell for progress display; a
|
|
168
|
+
callback that raises is logged and ignored, since a broken progress pipe must not throw away
|
|
169
|
+
the cells already paid for.
|
|
170
|
+
"""
|
|
171
|
+
outcomes: list[ScenarioOutcome] = []
|
|
172
|
+
for entry in pool.models:
|
|
173
|
+
for scenario in scenarios:
|
|
174
|
+
sid = scenario_id(scenario)
|
|
175
|
+
for episode in range(episodes_per_scenario):
|
|
176
|
+
timed = _TimedProvider(provider_factory(entry))
|
|
177
|
+
agent = LLMAgent(timed, temperature=agent_temperature, tools_hint=tools_hint)
|
|
178
|
+
env = env_factory()
|
|
179
|
+
result = run_episode(env, agent, scenario.task, max_steps=max_steps)
|
|
180
|
+
score, score_error = _read_episode_score(env)
|
|
181
|
+
error = result.error
|
|
182
|
+
if score_error is not None:
|
|
183
|
+
error = f"{error}; {score_error}" if error else score_error
|
|
184
|
+
if score is None and error is None:
|
|
185
|
+
raise ValueError(_NEEDS_SCORING_ENV)
|
|
186
|
+
critique = score.critique if score else ""
|
|
187
|
+
if score is not None and result.error is not None:
|
|
188
|
+
# The episode broke mid-flight, so this verdict grades an unfinished run:
|
|
189
|
+
# keep the text, drop the reward (see the module docstring).
|
|
190
|
+
critique = f"{_SALVAGE_PREFIX}{critique}" if critique else ""
|
|
191
|
+
score = None
|
|
192
|
+
outcome = ScenarioOutcome(
|
|
193
|
+
scenario_id=sid,
|
|
194
|
+
task=scenario.task,
|
|
195
|
+
model=entry.name,
|
|
196
|
+
episode=episode,
|
|
197
|
+
reward=score.reward if score else None,
|
|
198
|
+
success=score.success if score else False,
|
|
199
|
+
critique=critique,
|
|
200
|
+
steps=len(result.steps),
|
|
201
|
+
stop_reason=str(result.stop_reason),
|
|
202
|
+
usage=timed.usage,
|
|
203
|
+
cost_usd=entry.cost_usd(timed.usage),
|
|
204
|
+
call_seconds=timed.call_seconds,
|
|
205
|
+
replies=timed.replies,
|
|
206
|
+
error=error,
|
|
207
|
+
)
|
|
208
|
+
outcomes.append(outcome)
|
|
209
|
+
if on_outcome is not None:
|
|
210
|
+
try:
|
|
211
|
+
on_outcome(outcome)
|
|
212
|
+
except Exception: # noqa: BLE001 - a broken progress pipe never costs cells
|
|
213
|
+
logger.warning(
|
|
214
|
+
"on_outcome callback failed for %s on %s ep%d; sweep continues",
|
|
215
|
+
entry.name,
|
|
216
|
+
sid,
|
|
217
|
+
episode,
|
|
218
|
+
exc_info=True,
|
|
219
|
+
)
|
|
220
|
+
logger.info(
|
|
221
|
+
"closed-loop %s on %s ep%d: reward=%s cost=$%.5f steps=%d",
|
|
222
|
+
entry.name,
|
|
223
|
+
sid,
|
|
224
|
+
episode,
|
|
225
|
+
"unscored" if outcome.reward is None else f"{outcome.reward:.2f}",
|
|
226
|
+
outcome.cost_usd,
|
|
227
|
+
outcome.steps,
|
|
228
|
+
)
|
|
229
|
+
return OutcomeMatrix(pool=pool.models, outcomes=outcomes)
|
wmo/env/episode.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""`run_episode`: the one agent-vs-environment rollout loop.
|
|
2
|
+
|
|
3
|
+
Every workstream that "runs an agent against an environment for N steps and scores the result"
|
|
4
|
+
uses this loop, so episode records are comparable across the world model and real backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import traceback
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from wmo.core.types import Action, ActionKind, EnvState, Step
|
|
16
|
+
from wmo.env.base import Env
|
|
17
|
+
|
|
18
|
+
# An agent signals it is finished by returning a MESSAGE action whose content equals this.
|
|
19
|
+
DONE_SIGNAL = "<DONE>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Agent(Protocol):
|
|
24
|
+
"""Anything that maps the episode so far to the next action.
|
|
25
|
+
|
|
26
|
+
`act` sees the task, the env's live state view (see `Env.reset`), and the full history of
|
|
27
|
+
steps taken this episode. Return an `Action` to continue, or a MESSAGE action whose content
|
|
28
|
+
is `DONE_SIGNAL` to stop.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def act(self, task: str | None, state: EnvState, history: list[Step]) -> Action: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StopReason(StrEnum):
|
|
35
|
+
AGENT_DONE = "agent_done" # the agent returned DONE_SIGNAL
|
|
36
|
+
MAX_STEPS = "max_steps" # the step budget ran out
|
|
37
|
+
ENV_ERROR = "env_error" # env.step raised; episode recorded up to the failure
|
|
38
|
+
AGENT_ERROR = "agent_error" # agent.act raised; episode recorded up to the failure
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EpisodeResult(BaseModel):
|
|
42
|
+
"""One completed rollout: what happened, why it stopped."""
|
|
43
|
+
|
|
44
|
+
task: str | None = None
|
|
45
|
+
steps: list[Step] = Field(default_factory=list)
|
|
46
|
+
stop_reason: StopReason
|
|
47
|
+
error: str | None = None # set on ENV_ERROR/AGENT_ERROR; names the failure
|
|
48
|
+
error_traceback: str | None = None # full traceback of that failure, for debugging batches
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_episode(
|
|
52
|
+
env: Env,
|
|
53
|
+
agent: Agent,
|
|
54
|
+
task: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
seed_state: EnvState | None = None,
|
|
57
|
+
max_steps: int = 20,
|
|
58
|
+
) -> EpisodeResult:
|
|
59
|
+
"""Roll one episode of `agent` against `env`, bounded by `max_steps`.
|
|
60
|
+
|
|
61
|
+
The env's `reset`/`close` bracket the episode; each turn the agent proposes an action from the
|
|
62
|
+
accumulated history and the env answers with an observation. Each recorded step's
|
|
63
|
+
`state_before` is a deep copy of the env's state at that moment (the live state object keeps
|
|
64
|
+
mutating; see `Env.reset`). An env exception is recorded (not raised) so batch runs survive a
|
|
65
|
+
flaky backend; callers inspect `stop_reason`/`error`.
|
|
66
|
+
"""
|
|
67
|
+
if max_steps < 1:
|
|
68
|
+
raise ValueError(f"max_steps must be >= 1, got {max_steps}")
|
|
69
|
+
try:
|
|
70
|
+
state = env.reset(task=task, seed_state=seed_state)
|
|
71
|
+
history: list[Step] = []
|
|
72
|
+
for _ in range(max_steps):
|
|
73
|
+
try:
|
|
74
|
+
action = agent.act(task, state, history)
|
|
75
|
+
except Exception as exc: # noqa: BLE001 - batch runs must survive one bad episode
|
|
76
|
+
return EpisodeResult(
|
|
77
|
+
task=task,
|
|
78
|
+
steps=history,
|
|
79
|
+
stop_reason=StopReason.AGENT_ERROR,
|
|
80
|
+
error=f"{type(exc).__name__}: {exc} (in agent.act)",
|
|
81
|
+
error_traceback="".join(traceback.format_exception(exc)),
|
|
82
|
+
)
|
|
83
|
+
if action.kind is ActionKind.MESSAGE and action.content == DONE_SIGNAL:
|
|
84
|
+
return EpisodeResult(task=task, steps=history, stop_reason=StopReason.AGENT_DONE)
|
|
85
|
+
state_before = state.model_copy(deep=True)
|
|
86
|
+
try:
|
|
87
|
+
observation = env.step(action)
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - batch runs must survive one bad episode
|
|
89
|
+
return EpisodeResult(
|
|
90
|
+
task=task,
|
|
91
|
+
steps=history,
|
|
92
|
+
stop_reason=StopReason.ENV_ERROR,
|
|
93
|
+
error=(
|
|
94
|
+
f"{type(exc).__name__}: {exc} "
|
|
95
|
+
f"(while executing {action.kind.value} {action.name or action.content!r})"
|
|
96
|
+
),
|
|
97
|
+
error_traceback="".join(traceback.format_exception(exc)),
|
|
98
|
+
)
|
|
99
|
+
history.append(
|
|
100
|
+
Step(action=action, observation=observation, state_before=state_before, task=task)
|
|
101
|
+
)
|
|
102
|
+
return EpisodeResult(task=task, steps=history, stop_reason=StopReason.MAX_STEPS)
|
|
103
|
+
finally:
|
|
104
|
+
try:
|
|
105
|
+
env.close()
|
|
106
|
+
except Exception: # noqa: BLE001, S110 - a teardown failure must not mask the result
|
|
107
|
+
pass
|
wmo/env/llm_agent.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""A minimal LLM agent for rollouts: one tool call (or DONE) per turn, JSON-formatted.
|
|
2
|
+
|
|
3
|
+
This is the reusable counterpart of the throwaway agent inside `wmo demo`: it implements the
|
|
4
|
+
`Agent` protocol so scenario verification and research runs can roll real episodes against a
|
|
5
|
+
world model without every caller re-writing the same prompt-and-parse loop. It is deliberately
|
|
6
|
+
simple — no planning scaffold — because its role is "a competent baseline agent", not SOTA.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
from wmo.core.parsing import extract_json_object
|
|
16
|
+
from wmo.core.types import Action, ActionKind, EnvState, JsonObject, Step
|
|
17
|
+
from wmo.env.episode import DONE_SIGNAL
|
|
18
|
+
from wmo.providers.base import Message, Provider
|
|
19
|
+
|
|
20
|
+
AGENT_SYSTEM = """You are an agent operating in a tool environment to complete a task.
|
|
21
|
+
|
|
22
|
+
Each turn, respond with ONLY a JSON object, no prose around it — one of:
|
|
23
|
+
{"tool": "<tool name>", "arguments": {...}} to act,
|
|
24
|
+
{"done": true, "summary": "<what you achieved>"} when the task is complete or impossible.
|
|
25
|
+
|
|
26
|
+
Choose tool names and arguments consistent with the environment's responses so far. Work
|
|
27
|
+
efficiently: no redundant calls, finish as soon as the task is done."""
|
|
28
|
+
|
|
29
|
+
_MAX_HISTORY_CHARS = 500
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AgentReply(BaseModel):
|
|
33
|
+
"""Lenient view of the agent's JSON reply."""
|
|
34
|
+
|
|
35
|
+
tool: str | None = None
|
|
36
|
+
arguments: JsonObject = {}
|
|
37
|
+
done: bool = False
|
|
38
|
+
summary: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LLMAgent:
|
|
42
|
+
"""`Agent`-protocol adapter around a provider: history in, one JSON tool call out."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, provider: Provider, *, temperature: float = 0.0, tools_hint: str | None = None
|
|
46
|
+
) -> None:
|
|
47
|
+
self._provider = provider
|
|
48
|
+
self._temperature = temperature
|
|
49
|
+
# Corpus-derived tool surface (names + argument keys observed in the traces). Without
|
|
50
|
+
# it, capable models honestly refuse to invent tools while weaker ones hallucinate
|
|
51
|
+
# them, and closed-loop rewards measure affordance-guessing instead of capability.
|
|
52
|
+
self._system = AGENT_SYSTEM if not tools_hint else f"{AGENT_SYSTEM}\n\n{tools_hint}"
|
|
53
|
+
|
|
54
|
+
def act(self, task: str | None, state: EnvState, history: list[Step]) -> Action:
|
|
55
|
+
prompt = _render_turn(task, state, history)
|
|
56
|
+
completion = self._provider.complete(
|
|
57
|
+
self._system,
|
|
58
|
+
[Message(role="user", content=prompt)],
|
|
59
|
+
temperature=self._temperature,
|
|
60
|
+
max_tokens=1024,
|
|
61
|
+
)
|
|
62
|
+
raw = extract_json_object(completion.text)
|
|
63
|
+
if raw is not None:
|
|
64
|
+
try:
|
|
65
|
+
reply = _AgentReply.model_validate_json(raw)
|
|
66
|
+
except ValidationError:
|
|
67
|
+
reply = None
|
|
68
|
+
if reply is not None:
|
|
69
|
+
if reply.done or reply.tool is None:
|
|
70
|
+
return Action(kind=ActionKind.MESSAGE, content=DONE_SIGNAL)
|
|
71
|
+
return Action(kind=ActionKind.TOOL_CALL, name=reply.tool, arguments=reply.arguments)
|
|
72
|
+
# Unparseable reply: surface it as a message action; the env will answer and the episode
|
|
73
|
+
# continues rather than crashing the batch.
|
|
74
|
+
return Action(kind=ActionKind.MESSAGE, content=completion.text.strip()[:_MAX_HISTORY_CHARS])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _render_turn(task: str | None, state: EnvState, history: list[Step]) -> str:
|
|
78
|
+
lines = [f"TASK: {task or '(none)'}"]
|
|
79
|
+
if state.scratchpad:
|
|
80
|
+
lines.append(f"ENVIRONMENT NOTES: {state.scratchpad}")
|
|
81
|
+
if history:
|
|
82
|
+
lines.append("EPISODE SO FAR:")
|
|
83
|
+
for index, step in enumerate(history):
|
|
84
|
+
action = step.action
|
|
85
|
+
if action.kind is ActionKind.TOOL_CALL:
|
|
86
|
+
call = f"{action.name}({json.dumps(action.arguments, default=str)})"
|
|
87
|
+
else:
|
|
88
|
+
call = f"message: {action.content}"
|
|
89
|
+
observation = step.observation.content[:_MAX_HISTORY_CHARS]
|
|
90
|
+
error_mark = " [ERROR]" if step.observation.is_error else ""
|
|
91
|
+
lines.append(f"{index}. {call} -> {observation}{error_mark}")
|
|
92
|
+
lines.append("Your next move (JSON only):")
|
|
93
|
+
return "\n".join(lines)
|
wmo/env/scenarios.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Scenarios: the task prompts an agent trains and is evaluated on, derived from traces.
|
|
2
|
+
|
|
3
|
+
v1 scenario creation is deliberately minimal (the prompts already recorded in the corpus's traces
|
|
4
|
+
ARE the scenarios): `scenarios_from_traces` extracts one `Scenario` per unique task from the given
|
|
5
|
+
traces. Callers control leakage by choosing which traces to pass — extract training scenarios from
|
|
6
|
+
the train split and held-out scenarios from the test split, and the two can never overlap because
|
|
7
|
+
the whole-trace split already separated them.
|
|
8
|
+
|
|
9
|
+
Principled scenario *generation* (coverage, difficulty calibration, counterfactuals) is a later
|
|
10
|
+
layer that will produce the same `Scenario` type.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from wmo.core.types import ActionKind, Trace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Scenario(BaseModel):
|
|
21
|
+
"""One task an agent can attempt against an environment."""
|
|
22
|
+
|
|
23
|
+
task: str # the prompt handed to the agent (and to Env.reset)
|
|
24
|
+
provenance: list[str] = Field(default_factory=list) # trace_ids this scenario came from
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scenarios_from_traces(traces: list[Trace]) -> list[Scenario]:
|
|
28
|
+
"""Extract the unique task prompts from `traces`, in first-seen order.
|
|
29
|
+
|
|
30
|
+
Traces without a task (or with a whitespace-only one) are skipped: a scenario is exactly "a
|
|
31
|
+
prompt we can hand to an agent", and an empty prompt isn't one. Duplicate tasks collapse into
|
|
32
|
+
a single scenario whose `provenance` lists every contributing trace.
|
|
33
|
+
"""
|
|
34
|
+
by_task: dict[str, Scenario] = {}
|
|
35
|
+
for trace in traces:
|
|
36
|
+
task = _trace_task(trace)
|
|
37
|
+
if task is None:
|
|
38
|
+
continue
|
|
39
|
+
scenario = by_task.get(task)
|
|
40
|
+
if scenario is None:
|
|
41
|
+
by_task[task] = Scenario(task=task, provenance=[trace.trace_id])
|
|
42
|
+
elif trace.trace_id not in scenario.provenance:
|
|
43
|
+
scenario.provenance.append(trace.trace_id)
|
|
44
|
+
return list(by_task.values())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _trace_task(trace: Trace) -> str | None:
|
|
48
|
+
"""The trace's task prompt: first non-empty per-step task (steps carry it in this corpus)."""
|
|
49
|
+
for step in trace.steps:
|
|
50
|
+
if step.task and step.task.strip():
|
|
51
|
+
return step.task.strip()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tools_hint_from_traces(traces: list[Trace]) -> str:
|
|
56
|
+
"""Summarize the corpus's observed tool surface for the rollout agent's system prompt.
|
|
57
|
+
|
|
58
|
+
One line per tool: name + the union of argument keys seen in the traces (the D28
|
|
59
|
+
tools.json derivation, inlined). Empty string for tool-less corpora. This is the
|
|
60
|
+
trace-derived stopgap for the scenario tool-surface contract: agents told what exists
|
|
61
|
+
stop being scored on their willingness to hallucinate affordances.
|
|
62
|
+
"""
|
|
63
|
+
args_by_tool: dict[str, set[str]] = {}
|
|
64
|
+
for trace in traces:
|
|
65
|
+
for step in trace.steps:
|
|
66
|
+
action = step.action
|
|
67
|
+
if action.kind is not ActionKind.TOOL_CALL or not action.name:
|
|
68
|
+
continue
|
|
69
|
+
args_by_tool.setdefault(action.name, set()).update(action.arguments)
|
|
70
|
+
if not args_by_tool:
|
|
71
|
+
return ""
|
|
72
|
+
lines = [f"- {name}({', '.join(sorted(keys))})" for name, keys in sorted(args_by_tool.items())]
|
|
73
|
+
return "AVAILABLE TOOLS (observed in this environment):\n" + "\n".join(lines)
|
wmo/evals/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""World-model evaluation: one interface, an open-loop and a closed-loop implementation.
|
|
2
|
+
|
|
3
|
+
- `base` — the general `Evaluation`/`EvalResult` interface: mode-specific inputs are bound at
|
|
4
|
+
construction; `run()` returns a report with a one-line `summary()` and a `headline` score.
|
|
5
|
+
- `open_loop` — teacher-forced replay of held-out trace steps, scored for per-step reconstruction
|
|
6
|
+
fidelity (the default `wmo eval` mode).
|
|
7
|
+
- `closed_loop` — a live agent runs tasks with the world model as its environment, gold-judged for
|
|
8
|
+
end-to-end task success over k=3 passes (`wmo eval --mode closed-loop`).
|
|
9
|
+
- `agreement` — compare two closed-loop reports task-by-task (e.g. simulated vs real): the
|
|
10
|
+
outcome-agreement validity check.
|
|
11
|
+
- `gold` / `tasks` — the gold-assertion judge and the task specs closed-loop eval scores against.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from wmo.evals.agreement import AgreementReport, compute_agreement
|
|
15
|
+
from wmo.evals.base import EvalResult, Evaluation
|
|
16
|
+
from wmo.evals.closed_loop import (
|
|
17
|
+
ClosedLoopEval,
|
|
18
|
+
ClosedLoopReport,
|
|
19
|
+
TaskOutcome,
|
|
20
|
+
WorldModelEnvironment,
|
|
21
|
+
evaluate_closed_loop,
|
|
22
|
+
evaluate_with_env,
|
|
23
|
+
)
|
|
24
|
+
from wmo.evals.gold import GoldJudge, GoldVerdict
|
|
25
|
+
from wmo.evals.grid import GridCell, GridResult, ModelSpec, merge_results, run_grid
|
|
26
|
+
from wmo.evals.open_loop import EvalReport, OpenLoopEval, evaluate_files
|
|
27
|
+
from wmo.evals.tasks import TaskSpec, load_tasks
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AgreementReport",
|
|
31
|
+
"ClosedLoopEval",
|
|
32
|
+
"ClosedLoopReport",
|
|
33
|
+
"EvalReport",
|
|
34
|
+
"EvalResult",
|
|
35
|
+
"Evaluation",
|
|
36
|
+
"GoldJudge",
|
|
37
|
+
"GoldVerdict",
|
|
38
|
+
"GridCell",
|
|
39
|
+
"GridResult",
|
|
40
|
+
"ModelSpec",
|
|
41
|
+
"OpenLoopEval",
|
|
42
|
+
"TaskOutcome",
|
|
43
|
+
"TaskSpec",
|
|
44
|
+
"WorldModelEnvironment",
|
|
45
|
+
"compute_agreement",
|
|
46
|
+
"evaluate_closed_loop",
|
|
47
|
+
"evaluate_files",
|
|
48
|
+
"evaluate_with_env",
|
|
49
|
+
"load_tasks",
|
|
50
|
+
"merge_results",
|
|
51
|
+
"run_grid",
|
|
52
|
+
]
|