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/distill/rendering.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""A thin wmo-owned seam over tinker-cookbook chat renderers.
|
|
2
|
+
|
|
3
|
+
The Tinker provider needs five things from a renderer: turn a structured chat
|
|
4
|
+
request into prompt token ids, render only the NEW messages of a growing
|
|
5
|
+
conversation as a token suffix (so multi-turn prompts can extend the episode's
|
|
6
|
+
own token history instead of re-rendering it), expose the model's stop
|
|
7
|
+
sequences, decode token ids for plain-text callers, and parse sampled token
|
|
8
|
+
ids back into an assistant message (text plus tool calls). `ChatRendering`
|
|
9
|
+
captures that contract; `CookbookChatRendering` implements it on top of a
|
|
10
|
+
tinker-cookbook `Renderer` so cookbook churn stays contained in this module.
|
|
11
|
+
|
|
12
|
+
`build_renderer` resolves the cookbook renderer for a base model the same way
|
|
13
|
+
the cookbook's own LiteLLM provider does (`get_recommended_renderer_name`).
|
|
14
|
+
tinker-cookbook is an optional extra and is imported lazily
|
|
15
|
+
(`uv sync --extra distill`), mirroring the e2b extra's contract.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
23
|
+
|
|
24
|
+
from llm_waterfall.types import ChatFunctionCall, ChatMessage, ChatTool, ChatToolCall
|
|
25
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from tinker_cookbook.renderers import Renderer
|
|
29
|
+
from tinker_cookbook.renderers.base import Message as RendererMessage
|
|
30
|
+
from tinker_cookbook.renderers.base import RenderedMessage, ToolSpec
|
|
31
|
+
from tinker_cookbook.renderers.base import ToolCall as RendererToolCall
|
|
32
|
+
from tinker_cookbook.tokenizer_utils import Tokenizer
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
MISSING_DISTILL_EXTRA = (
|
|
37
|
+
"the tinker-cookbook SDK is not installed; run `uv sync --extra distill` "
|
|
38
|
+
"to use the Tinker distillation provider"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RendererTokenizer(Protocol):
|
|
43
|
+
"""The tokenizer slice cookbook renderers (and this seam's decodes) rely on.
|
|
44
|
+
|
|
45
|
+
HuggingFace `PreTrainedTokenizer` (what `tinker.SamplingClient.get_tokenizer`
|
|
46
|
+
returns) satisfies this structurally; tests supply small deterministic fakes.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def encode(self, text: str, add_special_tokens: bool = ...) -> list[int]:
|
|
50
|
+
"""Encode text to token ids."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def decode(self, token_ids: list[int], skip_special_tokens: bool = ...) -> str:
|
|
54
|
+
"""Decode token ids back to text (HF's `skip_special_tokens` contract)."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ParsedAssistantMessage(BaseModel):
|
|
59
|
+
"""One sampled assistant turn parsed out of student token ids."""
|
|
60
|
+
|
|
61
|
+
text: str = ""
|
|
62
|
+
"""Assistant text, with any thinking rendered inline as <think>...</think>."""
|
|
63
|
+
|
|
64
|
+
tool_calls: list[ChatToolCall] = Field(default_factory=list)
|
|
65
|
+
"""Parsed tool calls in OpenAI chat format."""
|
|
66
|
+
|
|
67
|
+
stopped: bool
|
|
68
|
+
"""True when generation terminated cleanly (stop sequence or EOS), False on truncation."""
|
|
69
|
+
|
|
70
|
+
unparsed_errors: list[str] = Field(default_factory=list)
|
|
71
|
+
"""Parser complaints for tool calls that could not be decoded and were not salvaged.
|
|
72
|
+
|
|
73
|
+
Surfaced (not just logged) so the agent scaffold can feed the complaint back to the model as an
|
|
74
|
+
observation. A turn whose only tool call failed to parse is NOT a completion: reporting it as
|
|
75
|
+
plain prose is how a truncated `write_file` became a "submitted" episode with reward 0."""
|
|
76
|
+
|
|
77
|
+
salvaged_tool_calls: int = 0
|
|
78
|
+
"""How many of `tool_calls` were recovered from a truncated/unterminated emission."""
|
|
79
|
+
|
|
80
|
+
def to_chat_message(self) -> ChatMessage:
|
|
81
|
+
"""This turn as an llm_waterfall assistant message.
|
|
82
|
+
|
|
83
|
+
The single place a parsed sample becomes a canonical `ChatMessage`, so
|
|
84
|
+
the response the agent sees and the assistant turn a conversation
|
|
85
|
+
replay reconstructs (`wmo.distill.tokens.reconstruct_conversation`)
|
|
86
|
+
cannot drift apart. Empty text and an empty tool-call list collapse to
|
|
87
|
+
None, matching the OpenAI-shaped messages the agent sends back.
|
|
88
|
+
"""
|
|
89
|
+
return ChatMessage(
|
|
90
|
+
role="assistant", content=self.text or None, tool_calls=self.tool_calls or None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ChatRendering(Protocol):
|
|
95
|
+
"""What the Tinker provider needs from a chat renderer."""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def stop_sequences(self) -> list[str] | list[int]:
|
|
99
|
+
"""Stop strings or stop token ids that end one assistant turn."""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
def build_generation_prompt(
|
|
103
|
+
self, messages: list[ChatMessage], tools: list[ChatTool] | None = None
|
|
104
|
+
) -> list[int]:
|
|
105
|
+
"""Render chat messages (and optional tool schemas) into prompt token ids."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
def render_suffix(
|
|
109
|
+
self,
|
|
110
|
+
messages: list[ChatMessage],
|
|
111
|
+
delta_start: int,
|
|
112
|
+
tools: list[ChatTool] | None = None,
|
|
113
|
+
*,
|
|
114
|
+
previous_sampled_ids: list[int],
|
|
115
|
+
) -> list[int]:
|
|
116
|
+
"""Render `messages[delta_start:]` plus the generation header as a token suffix.
|
|
117
|
+
|
|
118
|
+
The suffix extends an incrementally built prompt (previous prompt ids
|
|
119
|
+
plus the raw sampled ids of the previous assistant turn) so the next
|
|
120
|
+
prompt is a verbatim token-prefix extension of the episode so far.
|
|
121
|
+
When `previous_sampled_ids` does not already end with the template's
|
|
122
|
+
end-of-turn framing (a max_tokens truncation), that framing is
|
|
123
|
+
prepended so the delta messages start on a properly closed turn.
|
|
124
|
+
"""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
def decode(self, token_ids: list[int]) -> str:
|
|
128
|
+
"""Decode token ids back to text."""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
def decode_with_specials(self, token_ids: list[int]) -> str:
|
|
132
|
+
"""Decode token ids to text KEEPING special tokens (the template framing)."""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
def parse_response(self, sampled_ids: list[int]) -> ParsedAssistantMessage:
|
|
136
|
+
"""Parse sampled token ids into an assistant message (text plus tool calls)."""
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_TOOL_CALL_OPEN = "<tool_call>"
|
|
141
|
+
_TOOL_CALL_CLOSE = "</tool_call>"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def salvage_truncated_tool_call(text: str) -> str | None:
|
|
145
|
+
"""Close an unterminated trailing tool-call block so a parser can read it.
|
|
146
|
+
|
|
147
|
+
A turn cut off at the output-token cap ends mid-emission, so the template's closers are simply
|
|
148
|
+
absent (`</parameter></function></tool_call>`) and every parser sees plain prose. The reference
|
|
149
|
+
terminus-2 agent recovers the action instead of discarding the turn; this is the same repair,
|
|
150
|
+
restricted to appending the closers the emission opened. Nothing is rewritten or guessed: a
|
|
151
|
+
block that is malformed for any other reason (two `<function=` blocks, a stray `</think>`) still
|
|
152
|
+
fails to parse afterwards and becomes an explicit parse error the model is told about.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
text: The sampled turn's decoded text.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The repaired text, or None when there is no unterminated tool-call block to repair (no
|
|
159
|
+
opener, the last opener is already closed, or no function name was emitted yet).
|
|
160
|
+
"""
|
|
161
|
+
open_at = text.rfind(_TOOL_CALL_OPEN)
|
|
162
|
+
if open_at < 0 or text.rfind(_TOOL_CALL_CLOSE) > open_at:
|
|
163
|
+
return None
|
|
164
|
+
block = text[open_at:].rstrip()
|
|
165
|
+
if "<function=" not in block:
|
|
166
|
+
return None
|
|
167
|
+
if block.rfind("<parameter=") > block.rfind("</parameter>"):
|
|
168
|
+
block += "\n</parameter>"
|
|
169
|
+
if block.rfind("<function=") > block.rfind("</function>"):
|
|
170
|
+
block += "\n</function>"
|
|
171
|
+
return text[:open_at] + block + f"\n{_TOOL_CALL_CLOSE}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _text_content(content: JsonValue) -> str:
|
|
175
|
+
"""Flatten llm_waterfall message content (None, string, or part list) to text.
|
|
176
|
+
|
|
177
|
+
OpenAI-format content parts (`{"type": "text", "text": ...}`) are joined in
|
|
178
|
+
order; non-text parts are rejected loudly rather than dropped silently.
|
|
179
|
+
"""
|
|
180
|
+
if content is None:
|
|
181
|
+
return ""
|
|
182
|
+
if isinstance(content, str):
|
|
183
|
+
return content
|
|
184
|
+
if isinstance(content, list):
|
|
185
|
+
fragments: list[str] = []
|
|
186
|
+
for part in content:
|
|
187
|
+
if not isinstance(part, dict):
|
|
188
|
+
raise ValueError(f"unsupported message content part: {part!r}")
|
|
189
|
+
text = part.get("text")
|
|
190
|
+
if not isinstance(text, str):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"the tinker provider is text-only; message content parts must be "
|
|
193
|
+
f"text parts, got {part.get('type')!r}"
|
|
194
|
+
)
|
|
195
|
+
fragments.append(text)
|
|
196
|
+
return "".join(fragments)
|
|
197
|
+
raise ValueError(f"unsupported message content: {content!r}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def renderer_messages_from_chat(messages: list[ChatMessage]) -> list[RendererMessage]:
|
|
201
|
+
"""Convert llm_waterfall chat messages into cookbook renderer messages.
|
|
202
|
+
|
|
203
|
+
Mirrors the cookbook's `openai_messages_to_tinker`: roles pass through,
|
|
204
|
+
content flattens to text, and tool-result linkage (`tool_call_id`, `name`)
|
|
205
|
+
plus assistant `tool_calls` are preserved.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ImportError: If tinker-cookbook is not installed (distill extra).
|
|
209
|
+
ValueError: If a message carries non-text content parts.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
from tinker_cookbook.renderers.base import ToolCall
|
|
213
|
+
except ImportError as exc: # pragma: no cover - exercised via sys.modules patching
|
|
214
|
+
raise ImportError(MISSING_DISTILL_EXTRA) from exc
|
|
215
|
+
|
|
216
|
+
out: list[RendererMessage] = []
|
|
217
|
+
for msg in messages:
|
|
218
|
+
renderer_msg: RendererMessage = {
|
|
219
|
+
"role": msg.role,
|
|
220
|
+
"content": _text_content(msg.content),
|
|
221
|
+
}
|
|
222
|
+
if msg.tool_call_id is not None:
|
|
223
|
+
renderer_msg["tool_call_id"] = msg.tool_call_id
|
|
224
|
+
name = (msg.model_extra or {}).get("name")
|
|
225
|
+
if isinstance(name, str):
|
|
226
|
+
renderer_msg["name"] = name
|
|
227
|
+
if msg.tool_calls:
|
|
228
|
+
renderer_msg["tool_calls"] = [
|
|
229
|
+
ToolCall(
|
|
230
|
+
id=tc.id,
|
|
231
|
+
function=ToolCall.FunctionBody(
|
|
232
|
+
name=tc.function.name, arguments=tc.function.arguments
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
for tc in msg.tool_calls
|
|
236
|
+
]
|
|
237
|
+
out.append(renderer_msg)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _chat_tool_calls(calls: Sequence[RendererToolCall]) -> list[ChatToolCall]:
|
|
242
|
+
"""Cookbook renderer tool calls as llm_waterfall chat tool calls."""
|
|
243
|
+
return [
|
|
244
|
+
ChatToolCall(
|
|
245
|
+
id=tc.id or f"call_{index}",
|
|
246
|
+
function=ChatFunctionCall(name=tc.function.name, arguments=tc.function.arguments),
|
|
247
|
+
)
|
|
248
|
+
for index, tc in enumerate(calls)
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def tool_specs_from_chat(tools: list[ChatTool]) -> list[ToolSpec]:
|
|
253
|
+
"""Convert llm_waterfall tool definitions into cookbook ToolSpec dicts."""
|
|
254
|
+
return [
|
|
255
|
+
{
|
|
256
|
+
"name": tool.function.name,
|
|
257
|
+
"description": tool.function.description,
|
|
258
|
+
"parameters": dict(tool.function.parameters),
|
|
259
|
+
}
|
|
260
|
+
for tool in tools
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class CookbookChatRendering:
|
|
265
|
+
"""`ChatRendering` backed by a tinker-cookbook `Renderer`.
|
|
266
|
+
|
|
267
|
+
Construct via `build_renderer`. Tool schemas are injected through the
|
|
268
|
+
renderer's `create_conversation_prefix_with_tools`, folding any leading
|
|
269
|
+
system message into the tool prefix the way the cookbook's LiteLLM
|
|
270
|
+
provider does.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self, renderer: Renderer) -> None:
|
|
274
|
+
self._renderer = renderer
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def stop_sequences(self) -> list[str] | list[int]:
|
|
278
|
+
"""The renderer's stop strings or stop token ids."""
|
|
279
|
+
return self._renderer.get_stop_sequences()
|
|
280
|
+
|
|
281
|
+
def _effective_messages(
|
|
282
|
+
self, messages: list[ChatMessage], tools: list[ChatTool] | None
|
|
283
|
+
) -> tuple[list[RendererMessage], int]:
|
|
284
|
+
"""The renderer message list a full render sees, plus the index shift.
|
|
285
|
+
|
|
286
|
+
With tools, the leading system message (when present) is folded into
|
|
287
|
+
the renderer's tool-prefix messages, exactly as `build_generation_prompt`
|
|
288
|
+
does. The returned shift maps an index into `messages` to the same
|
|
289
|
+
message's index in the returned list.
|
|
290
|
+
"""
|
|
291
|
+
renderer_messages = renderer_messages_from_chat(messages)
|
|
292
|
+
shift = 0
|
|
293
|
+
if tools:
|
|
294
|
+
system_prompt = ""
|
|
295
|
+
dropped = 0
|
|
296
|
+
if renderer_messages and renderer_messages[0]["role"] == "system":
|
|
297
|
+
first_content = renderer_messages[0]["content"]
|
|
298
|
+
system_prompt = first_content if isinstance(first_content, str) else ""
|
|
299
|
+
renderer_messages = renderer_messages[1:]
|
|
300
|
+
dropped = 1
|
|
301
|
+
prefix = self._renderer.create_conversation_prefix_with_tools(
|
|
302
|
+
tool_specs_from_chat(tools), system_prompt
|
|
303
|
+
)
|
|
304
|
+
renderer_messages = list(prefix) + renderer_messages
|
|
305
|
+
shift = len(prefix) - dropped
|
|
306
|
+
return renderer_messages, shift
|
|
307
|
+
|
|
308
|
+
def build_generation_prompt(
|
|
309
|
+
self, messages: list[ChatMessage], tools: list[ChatTool] | None = None
|
|
310
|
+
) -> list[int]:
|
|
311
|
+
"""Render chat messages (and optional tool schemas) into prompt token ids."""
|
|
312
|
+
renderer_messages, _ = self._effective_messages(messages, tools)
|
|
313
|
+
return self._renderer.build_generation_prompt(renderer_messages).to_ints()
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _chunk_tokens(rendered: RenderedMessage) -> list[int]:
|
|
317
|
+
"""Flatten one rendered message's header and output chunks to token ids.
|
|
318
|
+
|
|
319
|
+
Mirrors the base `Renderer.build_generation_prompt` composition: header
|
|
320
|
+
tokens first, then each output chunk's tokens (`stop_overlap` is
|
|
321
|
+
ignored there too). Non-text chunks are rejected loudly; the tinker
|
|
322
|
+
provider is text-only.
|
|
323
|
+
"""
|
|
324
|
+
tokens: list[int] = []
|
|
325
|
+
if rendered.header is not None:
|
|
326
|
+
tokens.extend(rendered.header.tokens)
|
|
327
|
+
for chunk in rendered.output:
|
|
328
|
+
chunk_tokens = getattr(chunk, "tokens", None)
|
|
329
|
+
if chunk_tokens is None:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
"the renderer produced a non-text chunk while rendering a prompt "
|
|
332
|
+
"suffix; the tinker provider is text-only"
|
|
333
|
+
)
|
|
334
|
+
tokens.extend(chunk_tokens)
|
|
335
|
+
return tokens
|
|
336
|
+
|
|
337
|
+
def render_suffix(
|
|
338
|
+
self,
|
|
339
|
+
messages: list[ChatMessage],
|
|
340
|
+
delta_start: int,
|
|
341
|
+
tools: list[ChatTool] | None = None,
|
|
342
|
+
*,
|
|
343
|
+
previous_sampled_ids: list[int],
|
|
344
|
+
) -> list[int]:
|
|
345
|
+
"""Render `messages[delta_start:]` plus the generation header as a token suffix.
|
|
346
|
+
|
|
347
|
+
Composes per-message segments exactly the way the base cookbook
|
|
348
|
+
`Renderer.build_generation_prompt` does (same `RenderContext` for each
|
|
349
|
+
absolute position, computed over the full effective message list so
|
|
350
|
+
position-sensitive renderers frame the delta identically to a full
|
|
351
|
+
render), but emits only the delta messages and the trailing generation
|
|
352
|
+
header. Verified against the live qwen3_5 sink: for the recorded
|
|
353
|
+
episodes this composition reproduces the full render's delta tokens
|
|
354
|
+
byte for byte.
|
|
355
|
+
|
|
356
|
+
The end-of-turn framing is derived from the renderer by rendering an
|
|
357
|
+
empty assistant message and taking its output tokens (for Qwen that is
|
|
358
|
+
exactly `<|im_end|>`); when `previous_sampled_ids` does not already end
|
|
359
|
+
with it (max_tokens truncation), it is prepended so the spliced turn is
|
|
360
|
+
properly closed before the next message begins.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
messages: The FULL incoming message list of the next call.
|
|
364
|
+
delta_start: Index of the first message not covered by the
|
|
365
|
+
previous prompt plus the previous sampled turn (the message
|
|
366
|
+
right after the caller's echo of that turn).
|
|
367
|
+
tools: Tool schemas for this call; must match the ones the shared
|
|
368
|
+
prefix was rendered with.
|
|
369
|
+
previous_sampled_ids: The raw sampled ids being spliced in ahead
|
|
370
|
+
of this suffix, used only to decide end-of-turn completion.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Token ids to append after the previous prompt plus sampled ids.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
ImportError: If tinker-cookbook is not installed (distill extra).
|
|
377
|
+
ValueError: If `delta_start` is out of range or a delta message
|
|
378
|
+
renders to non-text chunks.
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
from tinker_cookbook.renderers.base import RenderContext
|
|
382
|
+
except ImportError as exc: # pragma: no cover - exercised via sys.modules patching
|
|
383
|
+
raise ImportError(MISSING_DISTILL_EXTRA) from exc
|
|
384
|
+
|
|
385
|
+
if not 1 <= delta_start <= len(messages):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"delta_start {delta_start} is out of range for {len(messages)} message(s); "
|
|
388
|
+
"it must point past the shared history (at least 1) and at most one past "
|
|
389
|
+
"the final message"
|
|
390
|
+
)
|
|
391
|
+
effective, shift = self._effective_messages(messages, tools)
|
|
392
|
+
start = delta_start + shift
|
|
393
|
+
last_user_index = max(
|
|
394
|
+
(idx for idx, msg in enumerate(effective) if msg["role"] == "user"),
|
|
395
|
+
default=-1,
|
|
396
|
+
)
|
|
397
|
+
tokens: list[int] = []
|
|
398
|
+
end_of_turn = self._end_of_turn_tokens(effective, start, last_user_index)
|
|
399
|
+
if end_of_turn and previous_sampled_ids[-len(end_of_turn) :] != end_of_turn:
|
|
400
|
+
tokens.extend(end_of_turn)
|
|
401
|
+
for idx in range(start, len(effective)):
|
|
402
|
+
ctx = RenderContext(
|
|
403
|
+
idx=idx,
|
|
404
|
+
is_last=(idx == len(effective) - 1),
|
|
405
|
+
prev_message=effective[idx - 1] if idx > 0 else None,
|
|
406
|
+
last_user_index=last_user_index,
|
|
407
|
+
)
|
|
408
|
+
tokens.extend(self._chunk_tokens(self._renderer.render_message(effective[idx], ctx)))
|
|
409
|
+
suffix_ctx = RenderContext(
|
|
410
|
+
idx=len(effective),
|
|
411
|
+
is_last=True,
|
|
412
|
+
prev_message=effective[-1] if effective else None,
|
|
413
|
+
last_user_index=last_user_index,
|
|
414
|
+
)
|
|
415
|
+
# Private cookbook seam, mirrored from the base build_generation_prompt;
|
|
416
|
+
# cookbook churn is contained here by design (module docstring).
|
|
417
|
+
tokens.extend(self._renderer._get_generation_suffix("assistant", suffix_ctx)) # noqa: SLF001
|
|
418
|
+
return tokens
|
|
419
|
+
|
|
420
|
+
def _end_of_turn_tokens(
|
|
421
|
+
self, effective: list[RendererMessage], start: int, last_user_index: int
|
|
422
|
+
) -> list[int]:
|
|
423
|
+
"""The template's end-of-turn framing, derived from the renderer.
|
|
424
|
+
|
|
425
|
+
Rendering an empty assistant message at the previous turn's position
|
|
426
|
+
yields output chunks of exactly the end-of-turn framing (for Qwen,
|
|
427
|
+
`<|im_end|>`); header tokens are excluded because the sampled turn's
|
|
428
|
+
header was already part of the previous generation prompt.
|
|
429
|
+
"""
|
|
430
|
+
from tinker_cookbook.renderers.base import RenderContext
|
|
431
|
+
|
|
432
|
+
ctx = RenderContext(
|
|
433
|
+
idx=start - 1,
|
|
434
|
+
is_last=False,
|
|
435
|
+
prev_message=effective[start - 2] if start >= 2 else None,
|
|
436
|
+
last_user_index=last_user_index,
|
|
437
|
+
)
|
|
438
|
+
empty: RendererMessage = {"role": "assistant", "content": ""}
|
|
439
|
+
rendered = self._renderer.render_message(empty, ctx)
|
|
440
|
+
tokens: list[int] = []
|
|
441
|
+
for chunk in rendered.output:
|
|
442
|
+
chunk_tokens = getattr(chunk, "tokens", None)
|
|
443
|
+
if chunk_tokens is None: # pragma: no cover - empty content renders text-only
|
|
444
|
+
raise ValueError(
|
|
445
|
+
"the renderer produced a non-text chunk for an empty assistant "
|
|
446
|
+
"message; cannot derive end-of-turn framing"
|
|
447
|
+
)
|
|
448
|
+
tokens.extend(chunk_tokens)
|
|
449
|
+
return tokens
|
|
450
|
+
|
|
451
|
+
def decode(self, token_ids: list[int]) -> str:
|
|
452
|
+
"""Decode token ids back to text with the renderer's tokenizer."""
|
|
453
|
+
return str(self._renderer.tokenizer.decode(token_ids))
|
|
454
|
+
|
|
455
|
+
def decode_with_specials(self, token_ids: list[int]) -> str:
|
|
456
|
+
"""Raw decode preserving special tokens, for human-readable episode logs.
|
|
457
|
+
|
|
458
|
+
`decode` stays the parsing/serving path and rides the tokenizer's
|
|
459
|
+
default cleanup, which may strip or normalize special tokens
|
|
460
|
+
depending on the tokenizer's configuration; this variant pins
|
|
461
|
+
`skip_special_tokens=False` so the chat template's framing
|
|
462
|
+
(`<|im_start|>`, think blocks, tool-call markers) survives verbatim
|
|
463
|
+
into the text.
|
|
464
|
+
"""
|
|
465
|
+
return str(self._renderer.tokenizer.decode(token_ids, skip_special_tokens=False))
|
|
466
|
+
|
|
467
|
+
def parse_response(self, sampled_ids: list[int]) -> ParsedAssistantMessage:
|
|
468
|
+
"""Parse sampled token ids into text plus OpenAI-format tool calls.
|
|
469
|
+
|
|
470
|
+
Thinking parts are rendered back inline as `<think>...</think>` so the
|
|
471
|
+
parsed text round-trips the decoded sample exactly (the cookbook's
|
|
472
|
+
`parse_content_blocks` preserves whitespace).
|
|
473
|
+
|
|
474
|
+
A turn whose tool call the renderer could not read is NOT reported as
|
|
475
|
+
plain prose. First the truncation repair runs
|
|
476
|
+
(`salvage_truncated_tool_call`, re-parsed through this same renderer, so
|
|
477
|
+
an emission cut off at the output cap still yields its action); when that
|
|
478
|
+
recovers nothing, the parser's own complaints are surfaced on
|
|
479
|
+
`unparsed_errors` for the scaffold to feed back to the model. The
|
|
480
|
+
RECORDED token span is never touched by any of this: the repair exists
|
|
481
|
+
only to read an action out of the turn, so the training data stays a
|
|
482
|
+
verbatim prefix of the episode.
|
|
483
|
+
"""
|
|
484
|
+
message, termination = self._renderer.parse_response(sampled_ids)
|
|
485
|
+
text = self._message_text(message)
|
|
486
|
+
tool_calls = _chat_tool_calls(message.get("tool_calls") or [])
|
|
487
|
+
unparsed_errors = [item.error for item in message.get("unparsed_tool_calls") or []]
|
|
488
|
+
salvaged = 0
|
|
489
|
+
if not tool_calls:
|
|
490
|
+
tool_calls, unparsed_errors, salvaged = self._salvage(text, unparsed_errors)
|
|
491
|
+
if unparsed_errors and not tool_calls:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"no tool call could be read from a sampled turn (%d parser complaint(s): %s); "
|
|
494
|
+
"the scaffold feeds this back to the model instead of ending the episode",
|
|
495
|
+
len(unparsed_errors),
|
|
496
|
+
"; ".join(unparsed_errors),
|
|
497
|
+
)
|
|
498
|
+
# is_clean, not is_stop_sequence: some renderers (e.g. role_colon) report a
|
|
499
|
+
# clean end-of-turn via the model's EOS token, which must not read as truncation.
|
|
500
|
+
return ParsedAssistantMessage(
|
|
501
|
+
text=text,
|
|
502
|
+
tool_calls=tool_calls,
|
|
503
|
+
stopped=termination.is_clean,
|
|
504
|
+
unparsed_errors=unparsed_errors,
|
|
505
|
+
salvaged_tool_calls=salvaged,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _message_text(self, message: RendererMessage) -> str:
|
|
509
|
+
"""The parsed turn's text, with thinking rendered back inline."""
|
|
510
|
+
content = message["content"]
|
|
511
|
+
if isinstance(content, str):
|
|
512
|
+
return content
|
|
513
|
+
fragments: list[str] = []
|
|
514
|
+
for part in content:
|
|
515
|
+
if part["type"] == "thinking":
|
|
516
|
+
fragments.append("<think>" + part["thinking"] + "</think>")
|
|
517
|
+
elif part["type"] == "text":
|
|
518
|
+
fragments.append(part["text"])
|
|
519
|
+
else:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
"sampled response contained a non-text content part "
|
|
522
|
+
f"({part['type']!r}); the tinker provider is text-only"
|
|
523
|
+
)
|
|
524
|
+
return "".join(fragments)
|
|
525
|
+
|
|
526
|
+
def _salvage(
|
|
527
|
+
self, text: str, unparsed_errors: list[str]
|
|
528
|
+
) -> tuple[list[ChatToolCall], list[str], int]:
|
|
529
|
+
"""Recover a tool call from an unterminated emission, through this renderer.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
text: The parsed turn's text (an unclosed `<tool_call>` block survives here as text,
|
|
533
|
+
which is exactly the emission to repair).
|
|
534
|
+
unparsed_errors: Parse complaints the first pass already produced.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
The recovered calls, the errors to report, and how many calls were salvaged. When
|
|
538
|
+
nothing is recoverable the errors are returned unchanged, except that an unterminated
|
|
539
|
+
block that still fails to parse contributes its own complaint.
|
|
540
|
+
"""
|
|
541
|
+
repaired = salvage_truncated_tool_call(text)
|
|
542
|
+
if repaired is None:
|
|
543
|
+
return [], unparsed_errors, 0
|
|
544
|
+
tokenizer = self._renderer.tokenizer
|
|
545
|
+
try:
|
|
546
|
+
# The end-of-turn framing is part of the repair: renderers skip block parsing
|
|
547
|
+
# entirely on a turn they read as truncated (`if not termination.is_clean:
|
|
548
|
+
# return`), so without it the re-parse hands the whole emission back as prose.
|
|
549
|
+
repaired_ids = [
|
|
550
|
+
*tokenizer.encode(repaired, add_special_tokens=False),
|
|
551
|
+
*self._end_of_turn_ids(),
|
|
552
|
+
]
|
|
553
|
+
message, _ = self._renderer.parse_response(repaired_ids)
|
|
554
|
+
except Exception as exc: # noqa: BLE001 - salvage is best effort; report, never raise
|
|
555
|
+
return [], [*unparsed_errors, f"truncated tool call could not be salvaged: {exc}"], 0
|
|
556
|
+
salvaged_calls = _chat_tool_calls(message.get("tool_calls") or [])
|
|
557
|
+
if salvaged_calls:
|
|
558
|
+
logger.info(
|
|
559
|
+
"salvaged %d tool call(s) from a tool-call emission that was cut off before its "
|
|
560
|
+
"closing tags; the episode continues instead of ending as a completion",
|
|
561
|
+
len(salvaged_calls),
|
|
562
|
+
)
|
|
563
|
+
return salvaged_calls, [], len(salvaged_calls)
|
|
564
|
+
errors = [item.error for item in message.get("unparsed_tool_calls") or []] or [
|
|
565
|
+
"tool-call block was left unterminated and could not be repaired"
|
|
566
|
+
]
|
|
567
|
+
return [], [*unparsed_errors, *errors], 0
|
|
568
|
+
|
|
569
|
+
def _end_of_turn_ids(self) -> list[int]:
|
|
570
|
+
"""Token ids that mark one assistant turn as cleanly ended, per the renderer.
|
|
571
|
+
|
|
572
|
+
Taken from the renderer's own stop sequences (token ids for the Qwen/Nemotron families,
|
|
573
|
+
strings for the plainer templates), so no template framing is hardcoded here.
|
|
574
|
+
"""
|
|
575
|
+
stops = self._renderer.get_stop_sequences()
|
|
576
|
+
if not stops:
|
|
577
|
+
return []
|
|
578
|
+
first = stops[0]
|
|
579
|
+
if isinstance(first, int):
|
|
580
|
+
return [first]
|
|
581
|
+
return list(self._renderer.tokenizer.encode(first, add_special_tokens=False))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def build_renderer(base_model: str, tokenizer: RendererTokenizer) -> CookbookChatRendering:
|
|
585
|
+
"""Build the cookbook-backed rendering for a base model.
|
|
586
|
+
|
|
587
|
+
The renderer name comes from the cookbook's own model catalog
|
|
588
|
+
(`get_recommended_renderer_name`), exactly how its LiteLLM provider picks
|
|
589
|
+
renderers, so wmo never maintains a parallel model-to-renderer table.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
base_model: Base model name in `org/model` form (e.g. `Qwen/Qwen3-8B`).
|
|
593
|
+
tokenizer: Tokenizer for that base model, typically from
|
|
594
|
+
`tinker.SamplingClient.get_tokenizer()`.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
The cookbook-backed `ChatRendering` implementation.
|
|
598
|
+
|
|
599
|
+
Raises:
|
|
600
|
+
ImportError: If tinker-cookbook is not installed (distill extra).
|
|
601
|
+
ValueError: If the cookbook has no renderer mapping for `base_model`.
|
|
602
|
+
"""
|
|
603
|
+
try:
|
|
604
|
+
from tinker_cookbook.exceptions import ConfigurationError
|
|
605
|
+
from tinker_cookbook.model_info import get_recommended_renderer_name
|
|
606
|
+
from tinker_cookbook.renderers import get_renderer
|
|
607
|
+
except ImportError as exc: # pragma: no cover - exercised via sys.modules patching
|
|
608
|
+
raise ImportError(MISSING_DISTILL_EXTRA) from exc
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
renderer_name = get_recommended_renderer_name(base_model)
|
|
612
|
+
except (ConfigurationError, KeyError, ValueError) as exc:
|
|
613
|
+
raise ValueError(
|
|
614
|
+
f"no cookbook renderer is known for base model {base_model!r} ({exc}); "
|
|
615
|
+
"use a model family listed in tinker_cookbook.model_info"
|
|
616
|
+
) from exc
|
|
617
|
+
# The cookbook types its tokenizer as HF PreTrainedTokenizer but treats it as
|
|
618
|
+
# Any at runtime; RendererTokenizer is the slice it actually calls.
|
|
619
|
+
renderer = get_renderer(renderer_name, cast("Tokenizer", tokenizer), model_name=base_model)
|
|
620
|
+
return CookbookChatRendering(renderer)
|