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,619 @@
|
|
|
1
|
+
"""RunnerLink: the transport that replaces per-episode SSH + reverse tunnel for the pi runner.
|
|
2
|
+
|
|
3
|
+
The control plane (this process) holds the model credentials and the world-model session state; a
|
|
4
|
+
long-lived pi *runner* — local, on nucbox, or any remote box — dials the host and blocks reading
|
|
5
|
+
frames. One episode is driven over one bidirectional frame channel: the host sends an
|
|
6
|
+
`episode_start`, then answers the two callbacks the runner pushes up — `llm_request` (the worker
|
|
7
|
+
LLM completion, produced host-side so no creds ever reach the runner) and `tool_request` (the
|
|
8
|
+
environment tool call, routed to the `AgentEnvironment` / world model) — until `done`.
|
|
9
|
+
|
|
10
|
+
Frames are length-prefixed JSON (4-byte big-endian length + UTF-8 body) over a raw socket, so the
|
|
11
|
+
transport adds ZERO dependency on either side (Python stdlib here; Node stdlib in the runner). The
|
|
12
|
+
episode-driving logic is decoupled from the socket behind the `Channel` protocol so a scripted
|
|
13
|
+
in-process peer can exercise the whole broker offline (see runner_link_test.py).
|
|
14
|
+
|
|
15
|
+
The link is provider-neutral: the caller supplies a structured tool-calling provider, which owns
|
|
16
|
+
authentication, routing, wire translation, retries, and failover. RunnerLink only validates and
|
|
17
|
+
brokers frames plus environment tool calls.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import struct
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Protocol, cast
|
|
30
|
+
|
|
31
|
+
from llm_waterfall import ChatRequest, ChatResponse
|
|
32
|
+
|
|
33
|
+
from wmo.core.types import Action, ActionKind, EnvState, JsonObject, Observation, Step
|
|
34
|
+
from wmo.harness.environment import AgentEnvironment, is_env_action
|
|
35
|
+
from wmo.harness.runtime import (
|
|
36
|
+
DEFAULT_MAX_OUTPUT_TOKENS,
|
|
37
|
+
DEFAULT_MAX_TURNS,
|
|
38
|
+
RunResult,
|
|
39
|
+
RuntimeCancelled,
|
|
40
|
+
StopReason,
|
|
41
|
+
TokenUsage,
|
|
42
|
+
)
|
|
43
|
+
from wmo.harness.skills import SkillLibrary
|
|
44
|
+
from wmo.harness.tools import READ_SKILL, ToolSpec
|
|
45
|
+
from wmo.providers.base import ContextWindowProvider, ToolCallingProvider
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
DEFAULT_MAX_ENV_ACTIONS = 40
|
|
50
|
+
DEFAULT_CANCEL_POLL_INTERVAL_S = 0.5
|
|
51
|
+
|
|
52
|
+
DONE_REASON_STOP_REASONS: dict[str, StopReason] = {
|
|
53
|
+
"submit": StopReason.SUBMITTED,
|
|
54
|
+
"no_tool_call": StopReason.NO_TOOL_CALL,
|
|
55
|
+
"output_truncated": StopReason.OUTPUT_TRUNCATED,
|
|
56
|
+
"unparsed_tool_call": StopReason.UNPARSED_TOOL_CALL,
|
|
57
|
+
"provider_error": StopReason.PROVIDER_ERROR,
|
|
58
|
+
"max_turns": StopReason.MAX_TURNS,
|
|
59
|
+
}
|
|
60
|
+
"""The `done` frame's `reason` vocabulary (pi_entry/runner_termination.ts) mapped onto stop reasons.
|
|
61
|
+
|
|
62
|
+
Before this existed, every `done` frame became `SUBMITTED`, so a genuine `submit`, a prose-only
|
|
63
|
+
turn, a turn truncated at the output cap, and a dropped tool call were indistinguishable and all
|
|
64
|
+
scored reward 0 as clean completions."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stop_reason_for_done(reason: object) -> StopReason:
|
|
68
|
+
"""The stop reason one `done` frame's `reason` field means.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
reason: The frame's `reason` value, if any.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The mapped stop reason. A missing or unrecognized value becomes `UNKNOWN_DONE_REASON`,
|
|
75
|
+
which is a SCAFFOLD LOSS, not a completion.
|
|
76
|
+
|
|
77
|
+
Failing toward "completion" here is the same defect this mapping exists to remove, one
|
|
78
|
+
level up: a stale runner (its `.ts` older than the host) sends the pre-`reason` `done`
|
|
79
|
+
frame, every episode reads as a clean submit, `scaffold_loss_rate` reads ~0, and a probe
|
|
80
|
+
whose entire job is to count scaffold losses PASSES. Reward-0 corpses then enter the
|
|
81
|
+
solve-rate denominator as completed-but-failed attempts, which is exactly how a 7.8%
|
|
82
|
+
submit rate coexisted with "every trial that finished, passed". A warning is not enough
|
|
83
|
+
protection: it scrolls past in a 24-hour run. So the unknown case is counted as a loss,
|
|
84
|
+
where it is loud and forces a look.
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(reason, str) and reason in DONE_REASON_STOP_REASONS:
|
|
87
|
+
return DONE_REASON_STOP_REASONS[reason]
|
|
88
|
+
logger.warning(
|
|
89
|
+
"runner sent a done frame with reason=%r, so this runner predates termination reporting; "
|
|
90
|
+
"the episode is recorded as a scaffold loss (unknown_done_reason) rather than a "
|
|
91
|
+
"completion. Redeploy the pi runner files",
|
|
92
|
+
reason,
|
|
93
|
+
)
|
|
94
|
+
return StopReason.UNKNOWN_DONE_REASON
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_WORKER_ERROR_LOG_CHARS = 500
|
|
98
|
+
"""Warning-level cap on a worker exception message (full detail at debug)."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bounded_error_text(exc: Exception) -> str:
|
|
102
|
+
"""One warning-sized line for a worker failure, truncated past the cap."""
|
|
103
|
+
text = " ".join(str(exc).split())
|
|
104
|
+
if len(text) > _WORKER_ERROR_LOG_CHARS:
|
|
105
|
+
return text[:_WORKER_ERROR_LOG_CHARS] + "... (truncated)"
|
|
106
|
+
return text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------------------------------
|
|
110
|
+
# Wire framing: length-prefixed JSON over a raw socket (stdlib only, both sides).
|
|
111
|
+
# --------------------------------------------------------------------------------------------------
|
|
112
|
+
class _SupportsSocket(Protocol):
|
|
113
|
+
def sendall(self, data: bytes) -> None: ...
|
|
114
|
+
def recv(self, n: int) -> bytes: ...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_frame(sock: _SupportsSocket, frame: JsonObject) -> None:
|
|
118
|
+
"""Send one JSON frame: 4-byte big-endian length prefix + UTF-8 body."""
|
|
119
|
+
body = json.dumps(frame).encode("utf-8")
|
|
120
|
+
sock.sendall(struct.pack(">I", len(body)) + body)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_frame(sock: _SupportsSocket) -> JsonObject | None:
|
|
124
|
+
"""Read one framed JSON message, or None if the peer closed the connection cleanly."""
|
|
125
|
+
header = _recv_exactly(sock, 4)
|
|
126
|
+
if header is None:
|
|
127
|
+
return None
|
|
128
|
+
(length,) = struct.unpack(">I", header)
|
|
129
|
+
body = _recv_exactly(sock, length)
|
|
130
|
+
if body is None:
|
|
131
|
+
return None
|
|
132
|
+
return cast("JsonObject", json.loads(body))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _recv_exactly(sock: _SupportsSocket, n: int) -> bytes | None:
|
|
136
|
+
buf = bytearray()
|
|
137
|
+
while len(buf) < n:
|
|
138
|
+
chunk = sock.recv(n - len(buf))
|
|
139
|
+
if not chunk:
|
|
140
|
+
return None
|
|
141
|
+
buf += chunk
|
|
142
|
+
return bytes(buf)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Channel(Protocol):
|
|
146
|
+
"""A bidirectional frame channel to the runner peer (a socket, or a test double)."""
|
|
147
|
+
|
|
148
|
+
def send(self, frame: JsonObject) -> None: ...
|
|
149
|
+
def recv(self, timeout: float | None = None) -> JsonObject | None: ...
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SocketChannel:
|
|
153
|
+
"""A `Channel` backed by a connected socket, using the length-prefixed JSON framing."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, sock: _SupportsSocket) -> None:
|
|
156
|
+
self._sock = sock
|
|
157
|
+
self._recv_buffer = bytearray()
|
|
158
|
+
|
|
159
|
+
def send(self, frame: JsonObject) -> None:
|
|
160
|
+
write_frame(self._sock, frame)
|
|
161
|
+
|
|
162
|
+
def recv(self, timeout: float | None = None) -> JsonObject | None:
|
|
163
|
+
settimeout = getattr(self._sock, "settimeout", None)
|
|
164
|
+
gettimeout = getattr(self._sock, "gettimeout", None)
|
|
165
|
+
previous = gettimeout() if timeout is not None and callable(gettimeout) else None
|
|
166
|
+
if timeout is not None and callable(settimeout):
|
|
167
|
+
settimeout(timeout)
|
|
168
|
+
try:
|
|
169
|
+
if not self._fill_recv_buffer(4):
|
|
170
|
+
return None
|
|
171
|
+
(length,) = struct.unpack(">I", self._recv_buffer[:4])
|
|
172
|
+
if not self._fill_recv_buffer(4 + length):
|
|
173
|
+
return None
|
|
174
|
+
body = bytes(self._recv_buffer[4 : 4 + length])
|
|
175
|
+
del self._recv_buffer[: 4 + length]
|
|
176
|
+
return cast("JsonObject", json.loads(body))
|
|
177
|
+
finally:
|
|
178
|
+
if timeout is not None and callable(settimeout):
|
|
179
|
+
settimeout(previous)
|
|
180
|
+
|
|
181
|
+
def _fill_recv_buffer(self, size: int) -> bool:
|
|
182
|
+
"""Read through ``size`` bytes while preserving partial frames across timed polls."""
|
|
183
|
+
while len(self._recv_buffer) < size:
|
|
184
|
+
chunk = self._sock.recv(size - len(self._recv_buffer))
|
|
185
|
+
if not chunk:
|
|
186
|
+
return False
|
|
187
|
+
self._recv_buffer += chunk
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# The process-wide runner channel doc.runtime(PI_TRANSPORT=link) drives. A search/eval sets it once
|
|
192
|
+
# (its runner connection is process-scoped infra), so create_harness's internal doc.runtime() calls
|
|
193
|
+
# reach the runner without threading a channel through every signature; cleared at teardown.
|
|
194
|
+
_ACTIVE_CHANNEL: Channel | None = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def set_active_channel(channel: Channel | None) -> None:
|
|
198
|
+
global _ACTIVE_CHANNEL
|
|
199
|
+
_ACTIVE_CHANNEL = channel
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def active_channel() -> Channel | None:
|
|
203
|
+
return _ACTIVE_CHANNEL
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def provider_context_window(provider: object) -> int | None:
|
|
207
|
+
"""The served context window a provider reports, or None when it cannot say.
|
|
208
|
+
|
|
209
|
+
Providers whose served window is a property of the deployment (the Tinker student, whose
|
|
210
|
+
catalog name pins a context tier) implement `ContextWindowProvider`; everything else returns
|
|
211
|
+
None and the runner keeps its documented fallback. A probe that fails must never break an
|
|
212
|
+
episode, so any error degrades to None with a warning.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
provider: The worker provider, or None.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The served window in tokens, or None when unknown.
|
|
219
|
+
"""
|
|
220
|
+
if not isinstance(provider, ContextWindowProvider):
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
window = provider.context_window()
|
|
224
|
+
except Exception as exc: # noqa: BLE001 - a capability probe never fails an episode
|
|
225
|
+
logger.warning(
|
|
226
|
+
"could not resolve the served context window from %s (%s); the pi runner falls back "
|
|
227
|
+
"to its default window, so context-overflow trimming may be miscalibrated",
|
|
228
|
+
type(provider).__name__,
|
|
229
|
+
_bounded_error_text(exc),
|
|
230
|
+
)
|
|
231
|
+
return None
|
|
232
|
+
return window
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def params_schema(tool: ToolSpec) -> JsonObject:
|
|
236
|
+
"""A JSON-schema `parameters` object for a tool, as the model's function-calling API expects."""
|
|
237
|
+
props: JsonObject = {
|
|
238
|
+
name: {"type": "string", "description": desc} for name, desc in tool.arguments.items()
|
|
239
|
+
}
|
|
240
|
+
return {"type": "object", "properties": props, "required": list(tool.arguments)}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --------------------------------------------------------------------------------------------------
|
|
244
|
+
# Host-side episode: environment tool routing, budget, and transcript recording.
|
|
245
|
+
# --------------------------------------------------------------------------------------------------
|
|
246
|
+
@dataclass
|
|
247
|
+
class HostEpisode:
|
|
248
|
+
"""Per-episode host state: routes tool calls to the environment under a budget, records Steps.
|
|
249
|
+
|
|
250
|
+
Same budget/step-recording contract the SSH shim's `_Episode` had, minus the HTTP specifics.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
instruction: str
|
|
254
|
+
tools: list[ToolSpec]
|
|
255
|
+
environment: AgentEnvironment
|
|
256
|
+
skills: SkillLibrary = field(default_factory=SkillLibrary)
|
|
257
|
+
max_env_actions: int = DEFAULT_MAX_ENV_ACTIONS
|
|
258
|
+
steps: list[Step] = field(default_factory=list)
|
|
259
|
+
answer: str = ""
|
|
260
|
+
_env_calls: int = 0
|
|
261
|
+
|
|
262
|
+
def tool_specs(self) -> list[JsonObject]:
|
|
263
|
+
return [
|
|
264
|
+
{"name": t.name, "description": t.description, "parameters": params_schema(t)}
|
|
265
|
+
for t in self.tools
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
def run_tool(self, name: str, arguments: JsonObject) -> JsonObject:
|
|
269
|
+
"""Answer one runtime/environment tool call under AgentRuntime-compatible semantics."""
|
|
270
|
+
action = Action(kind=ActionKind.TOOL_CALL, name=name, arguments=arguments)
|
|
271
|
+
if name not in {t.name for t in self.tools}:
|
|
272
|
+
obs = Observation(content=f"tool {name!r} not available", is_error=True)
|
|
273
|
+
elif name == READ_SKILL.name:
|
|
274
|
+
raw_name = arguments.get("name")
|
|
275
|
+
skill_name = raw_name if isinstance(raw_name, str) else ""
|
|
276
|
+
skill = self.skills.get(skill_name)
|
|
277
|
+
if skill is None:
|
|
278
|
+
obs = Observation(content=f"no skill named {skill_name!r}", is_error=True)
|
|
279
|
+
else:
|
|
280
|
+
obs = Observation(content=skill.body)
|
|
281
|
+
elif self._env_calls >= self.max_env_actions:
|
|
282
|
+
obs = Observation(content="environment action budget exhausted", is_error=True)
|
|
283
|
+
elif not is_env_action(action):
|
|
284
|
+
obs = Observation(content=f"tool {name!r} not available", is_error=True)
|
|
285
|
+
else:
|
|
286
|
+
self._env_calls += 1
|
|
287
|
+
obs = self.environment.execute(action)
|
|
288
|
+
self.steps.append(
|
|
289
|
+
Step(action=action, observation=obs, state_before=EnvState(), task=self.instruction)
|
|
290
|
+
)
|
|
291
|
+
return {"content": obs.content, "is_error": obs.is_error}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# The worker function the host uses to answer llm_request frames; injectable for tests.
|
|
295
|
+
WorkerFn = Callable[[ChatRequest], ChatResponse]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class RunnerLink:
|
|
299
|
+
"""Drives one pi episode over a `Channel` to the runner peer.
|
|
300
|
+
|
|
301
|
+
Sends `episode_start`, then answers `llm_request` (worker LLM, host-side) and `tool_request`
|
|
302
|
+
(environment) frames until `done`/`episode_error`, returning a `RunResult` shaped exactly like
|
|
303
|
+
the other runtimes. One `RunnerLink.run` == one episode; concurrent episodes multiplex over the
|
|
304
|
+
same channel by `episode_id` (a later migration step).
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
channel: Channel,
|
|
310
|
+
*,
|
|
311
|
+
tools: list[ToolSpec] | None = None,
|
|
312
|
+
provider: ToolCallingProvider | None = None,
|
|
313
|
+
worker_fn: WorkerFn | None = None,
|
|
314
|
+
files: dict[str, str] | None = None,
|
|
315
|
+
system_prompt: str = "",
|
|
316
|
+
max_env_actions: int = DEFAULT_MAX_ENV_ACTIONS,
|
|
317
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
318
|
+
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
319
|
+
temperature: float = 0.7,
|
|
320
|
+
skills: SkillLibrary | None = None,
|
|
321
|
+
episode_timeout_s: float | None = None,
|
|
322
|
+
context_window: int | None = None,
|
|
323
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
324
|
+
cancel_poll_interval_s: float = DEFAULT_CANCEL_POLL_INTERVAL_S,
|
|
325
|
+
) -> None:
|
|
326
|
+
self._channel = channel
|
|
327
|
+
# Tools bound at construction make RunnerLink satisfy the runtime contract closed-loop eval
|
|
328
|
+
# drives — `run(task_id, instruction, environment)` — while `run(..., tools=...)` still lets
|
|
329
|
+
# a caller (or the conformance tests) override per episode.
|
|
330
|
+
self._tools = list(tools or [])
|
|
331
|
+
self._skills = skills if skills is not None else SkillLibrary()
|
|
332
|
+
if len(self._skills) and READ_SKILL.name not in {tool.name for tool in self._tools}:
|
|
333
|
+
self._tools.append(READ_SKILL)
|
|
334
|
+
if worker_fn is None and provider is None:
|
|
335
|
+
raise ValueError("RunnerLink needs a ToolCallingProvider or worker_fn")
|
|
336
|
+
# worker_fn lets tests answer llm_request without a real provider.
|
|
337
|
+
if worker_fn is not None:
|
|
338
|
+
self._worker_fn = worker_fn
|
|
339
|
+
else:
|
|
340
|
+
assert provider is not None
|
|
341
|
+
self._worker_fn = provider.complete_chat
|
|
342
|
+
self._files = files or {}
|
|
343
|
+
self._system_prompt = system_prompt
|
|
344
|
+
self._max_env_actions = max_env_actions
|
|
345
|
+
if max_turns < 1:
|
|
346
|
+
raise ValueError("max_turns must be >= 1")
|
|
347
|
+
if max_output_tokens < 1:
|
|
348
|
+
raise ValueError("max_output_tokens must be >= 1")
|
|
349
|
+
if not 0.0 <= temperature <= 2.0:
|
|
350
|
+
raise ValueError("temperature must be in [0, 2]")
|
|
351
|
+
if episode_timeout_s is not None and episode_timeout_s <= 0:
|
|
352
|
+
raise ValueError("episode_timeout_s must be positive when set")
|
|
353
|
+
if cancel_poll_interval_s <= 0:
|
|
354
|
+
raise ValueError("cancel_poll_interval_s must be positive")
|
|
355
|
+
if context_window is not None and (
|
|
356
|
+
isinstance(context_window, bool) or not isinstance(context_window, int)
|
|
357
|
+
):
|
|
358
|
+
raise ValueError("context_window must be an integer number of tokens when set")
|
|
359
|
+
if context_window is not None and context_window < 1024:
|
|
360
|
+
raise ValueError("context_window must be at least 1024 tokens when set")
|
|
361
|
+
self._max_turns = max_turns
|
|
362
|
+
self._max_output_tokens = max_output_tokens
|
|
363
|
+
self._temperature = temperature
|
|
364
|
+
self._episode_timeout_s = episode_timeout_s
|
|
365
|
+
# The runner clamps pi's output budget and trims against this number, so a wrong value is
|
|
366
|
+
# worse than none: 128k assumed against a 64k server let every context-overflow 400 through.
|
|
367
|
+
self._context_window = (
|
|
368
|
+
context_window if context_window is not None else provider_context_window(provider)
|
|
369
|
+
)
|
|
370
|
+
self._should_cancel = should_cancel
|
|
371
|
+
self._cancel_poll_interval_s = cancel_poll_interval_s
|
|
372
|
+
|
|
373
|
+
def run(
|
|
374
|
+
self,
|
|
375
|
+
task_id: str,
|
|
376
|
+
instruction: str,
|
|
377
|
+
environment: AgentEnvironment,
|
|
378
|
+
*,
|
|
379
|
+
tools: list[ToolSpec] | None = None,
|
|
380
|
+
) -> RunResult:
|
|
381
|
+
episode_tools = list(tools) if tools is not None else list(self._tools)
|
|
382
|
+
if len(self._skills) and READ_SKILL.name not in {tool.name for tool in episode_tools}:
|
|
383
|
+
episode_tools.append(READ_SKILL)
|
|
384
|
+
episode = HostEpisode(
|
|
385
|
+
instruction=instruction,
|
|
386
|
+
tools=episode_tools,
|
|
387
|
+
environment=environment,
|
|
388
|
+
skills=self._skills,
|
|
389
|
+
max_env_actions=self._max_env_actions,
|
|
390
|
+
)
|
|
391
|
+
episode_id = uuid.uuid4().hex
|
|
392
|
+
usage = TokenUsage()
|
|
393
|
+
self._check_cancelled(usage)
|
|
394
|
+
deadline = (
|
|
395
|
+
time.monotonic() + self._episode_timeout_s
|
|
396
|
+
if self._episode_timeout_s is not None
|
|
397
|
+
else None
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def send_frame(frame: JsonObject) -> RunResult | None:
|
|
401
|
+
try:
|
|
402
|
+
self._channel.send(frame)
|
|
403
|
+
except Exception:
|
|
404
|
+
self._check_cancelled(usage)
|
|
405
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
406
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
407
|
+
raise
|
|
408
|
+
self._check_cancelled(usage)
|
|
409
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
410
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
stopped = send_frame(
|
|
414
|
+
{
|
|
415
|
+
"type": "episode_start",
|
|
416
|
+
"episode_id": episode_id,
|
|
417
|
+
"task_id": task_id,
|
|
418
|
+
"instruction": instruction,
|
|
419
|
+
"system": self._system_prompt,
|
|
420
|
+
"tools": episode.tool_specs(),
|
|
421
|
+
"files": self._files,
|
|
422
|
+
"max_env_actions": self._max_env_actions,
|
|
423
|
+
"max_turns": self._max_turns,
|
|
424
|
+
"max_output_tokens": self._max_output_tokens,
|
|
425
|
+
"temperature": self._temperature,
|
|
426
|
+
"episode_timeout_s": self._episode_timeout_s,
|
|
427
|
+
"context_window": self._context_window,
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
if stopped is not None:
|
|
431
|
+
return stopped
|
|
432
|
+
while True:
|
|
433
|
+
self._check_cancelled(usage)
|
|
434
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
435
|
+
if remaining is not None and remaining <= 0:
|
|
436
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
437
|
+
recv_timeout = remaining
|
|
438
|
+
if self._should_cancel is not None:
|
|
439
|
+
recv_timeout = (
|
|
440
|
+
self._cancel_poll_interval_s
|
|
441
|
+
if recv_timeout is None
|
|
442
|
+
else min(recv_timeout, self._cancel_poll_interval_s)
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
frame = self._channel.recv(timeout=recv_timeout)
|
|
446
|
+
except TimeoutError:
|
|
447
|
+
self._check_cancelled(usage)
|
|
448
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
449
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
450
|
+
if self._should_cancel is not None:
|
|
451
|
+
continue
|
|
452
|
+
raise
|
|
453
|
+
except Exception:
|
|
454
|
+
self._check_cancelled(usage)
|
|
455
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
456
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
457
|
+
raise
|
|
458
|
+
self._check_cancelled(usage)
|
|
459
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
460
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
461
|
+
if frame is None: # channel closed before the episode finished
|
|
462
|
+
return self._error_result(
|
|
463
|
+
task_id, episode, instruction, "runner channel closed", usage=usage
|
|
464
|
+
)
|
|
465
|
+
kind = frame.get("type")
|
|
466
|
+
if kind == "llm_request":
|
|
467
|
+
response = self._llm_response(episode_id, frame, usage)
|
|
468
|
+
self._check_cancelled(usage)
|
|
469
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
470
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
471
|
+
# A send timeout is transport failure with an uncertain delivery state. Let it
|
|
472
|
+
# propagate so the owning runtime retires rather than sending a second response.
|
|
473
|
+
stopped = send_frame(response)
|
|
474
|
+
if stopped is not None:
|
|
475
|
+
return stopped
|
|
476
|
+
elif kind == "tool_request":
|
|
477
|
+
name = frame.get("name")
|
|
478
|
+
args = frame.get("arguments")
|
|
479
|
+
obs = episode.run_tool(
|
|
480
|
+
name if isinstance(name, str) else "",
|
|
481
|
+
args if isinstance(args, dict) else {},
|
|
482
|
+
)
|
|
483
|
+
self._check_cancelled(usage)
|
|
484
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
485
|
+
return self._budget_result(task_id, episode, instruction, usage)
|
|
486
|
+
stopped = send_frame(
|
|
487
|
+
{
|
|
488
|
+
"type": "tool_response",
|
|
489
|
+
"episode_id": episode_id,
|
|
490
|
+
"req_id": frame.get("req_id"),
|
|
491
|
+
**obs,
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
if stopped is not None:
|
|
495
|
+
return stopped
|
|
496
|
+
elif kind == "done":
|
|
497
|
+
answer = frame.get("answer")
|
|
498
|
+
episode.answer = answer if isinstance(answer, str) else ""
|
|
499
|
+
stop_reason = stop_reason_for_done(frame.get("reason"))
|
|
500
|
+
if stop_reason is not StopReason.SUBMITTED:
|
|
501
|
+
logger.info(
|
|
502
|
+
"episode %s for task %s ended without an explicit submit (%s); recording "
|
|
503
|
+
"it as a scaffold loss, not a task failure",
|
|
504
|
+
episode_id,
|
|
505
|
+
task_id,
|
|
506
|
+
stop_reason.value,
|
|
507
|
+
)
|
|
508
|
+
return RunResult(
|
|
509
|
+
task_id=task_id,
|
|
510
|
+
steps=episode.steps,
|
|
511
|
+
stop_reason=stop_reason,
|
|
512
|
+
answer=episode.answer,
|
|
513
|
+
turns=len(episode.steps),
|
|
514
|
+
worker_usage=usage if usage.calls else None,
|
|
515
|
+
)
|
|
516
|
+
elif kind == "episode_error":
|
|
517
|
+
note = frame.get("note")
|
|
518
|
+
return self._error_result(
|
|
519
|
+
task_id,
|
|
520
|
+
episode,
|
|
521
|
+
instruction,
|
|
522
|
+
note if isinstance(note, str) else "runner error",
|
|
523
|
+
usage=usage,
|
|
524
|
+
)
|
|
525
|
+
# unknown frame types are ignored (forward-compatible)
|
|
526
|
+
|
|
527
|
+
def _check_cancelled(self, usage: TokenUsage) -> None:
|
|
528
|
+
if self._should_cancel is not None and self._should_cancel():
|
|
529
|
+
raise RuntimeCancelled(
|
|
530
|
+
"runtime episode cancelled",
|
|
531
|
+
worker_usage=(usage.model_copy() if usage.calls else None),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _budget_result(
|
|
535
|
+
self,
|
|
536
|
+
task_id: str,
|
|
537
|
+
episode: HostEpisode,
|
|
538
|
+
instruction: str,
|
|
539
|
+
usage: TokenUsage,
|
|
540
|
+
) -> RunResult:
|
|
541
|
+
assert self._episode_timeout_s is not None
|
|
542
|
+
return self._error_result(
|
|
543
|
+
task_id,
|
|
544
|
+
episode,
|
|
545
|
+
instruction,
|
|
546
|
+
f"evaluation episode exceeded {self._episode_timeout_s:g}s wall budget",
|
|
547
|
+
stop=StopReason.BUDGET,
|
|
548
|
+
usage=usage,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def _llm_response(self, episode_id: str, frame: JsonObject, usage: TokenUsage) -> JsonObject:
|
|
552
|
+
req_id = frame.get("req_id")
|
|
553
|
+
body = frame.get("openai_body")
|
|
554
|
+
try:
|
|
555
|
+
# The runner owns message/tool serialization, while HarnessDoc owns sampling policy.
|
|
556
|
+
# Override any runner default at the final host boundary before the real model call.
|
|
557
|
+
request_body = dict(body) if isinstance(body, dict) else {}
|
|
558
|
+
request_body["temperature"] = self._temperature
|
|
559
|
+
request = ChatRequest.model_validate(request_body)
|
|
560
|
+
completion = self._worker_fn(request)
|
|
561
|
+
# Meter the worker leg from the provider's structured response.
|
|
562
|
+
usage.calls += 1
|
|
563
|
+
reported = completion.token_usage()
|
|
564
|
+
usage.input_tokens += reported.input_tokens
|
|
565
|
+
usage.output_tokens += reported.output_tokens
|
|
566
|
+
response: JsonObject = {
|
|
567
|
+
"type": "llm_response",
|
|
568
|
+
"episode_id": episode_id,
|
|
569
|
+
"req_id": req_id,
|
|
570
|
+
"completion": completion.wire_payload(),
|
|
571
|
+
}
|
|
572
|
+
except Exception as exc: # noqa: BLE001 - report to the runner, never crash the host
|
|
573
|
+
# Never silent: a provider that fails every call otherwise ends the
|
|
574
|
+
# episode as a clean-looking zero-turn "submitted" (the runner owns
|
|
575
|
+
# what it does with the error frame), which buried a live outage.
|
|
576
|
+
logger.warning(
|
|
577
|
+
"worker completion failed for episode %s (req %s): %s: %s; "
|
|
578
|
+
"returning the error frame to the runner",
|
|
579
|
+
episode_id,
|
|
580
|
+
req_id,
|
|
581
|
+
type(exc).__name__,
|
|
582
|
+
_bounded_error_text(exc),
|
|
583
|
+
)
|
|
584
|
+
logger.debug("worker completion failure detail", exc_info=exc)
|
|
585
|
+
response = {
|
|
586
|
+
"type": "llm_response",
|
|
587
|
+
"episode_id": episode_id,
|
|
588
|
+
"req_id": req_id,
|
|
589
|
+
"error": str(exc),
|
|
590
|
+
}
|
|
591
|
+
return response
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def _error_result(
|
|
595
|
+
task_id: str,
|
|
596
|
+
episode: HostEpisode,
|
|
597
|
+
instruction: str,
|
|
598
|
+
note: str,
|
|
599
|
+
*,
|
|
600
|
+
stop: StopReason | None = None,
|
|
601
|
+
usage: TokenUsage | None = None,
|
|
602
|
+
) -> RunResult:
|
|
603
|
+
resolved_stop = stop or (StopReason.MAX_TURNS if episode.steps else StopReason.ERROR)
|
|
604
|
+
episode.steps.append(
|
|
605
|
+
Step(
|
|
606
|
+
action=Action(kind=ActionKind.MESSAGE, content="(runner link)"),
|
|
607
|
+
observation=Observation(content=note, is_error=True),
|
|
608
|
+
state_before=EnvState(),
|
|
609
|
+
task=instruction,
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
return RunResult(
|
|
613
|
+
task_id=task_id,
|
|
614
|
+
steps=episode.steps,
|
|
615
|
+
stop_reason=resolved_stop,
|
|
616
|
+
answer="",
|
|
617
|
+
turns=len(episode.steps),
|
|
618
|
+
worker_usage=usage if usage is not None and usage.calls else None,
|
|
619
|
+
)
|