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.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -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)