whileai 0.53__tar.gz → 0.55__tar.gz

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 (88) hide show
  1. {whileai-0.53/whileai.egg-info → whileai-0.55}/PKG-INFO +8 -8
  2. {whileai-0.53 → whileai-0.55}/README.md +7 -7
  3. {whileai-0.53 → whileai-0.55}/pyproject.toml +1 -1
  4. {whileai-0.53 → whileai-0.55}/whileai/simulations/__init__.py +8 -3
  5. {whileai-0.53 → whileai-0.55}/whileai/simulations/data.py +8 -0
  6. {whileai-0.53 → whileai-0.55}/whileai/simulations/environment.py +4 -4
  7. {whileai-0.53 → whileai-0.55}/whileai/simulations/export.py +46 -3
  8. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/agents.py +34 -2
  9. {whileai-0.53 → whileai-0.55}/whileai/simulations/run/engine.py +27 -0
  10. {whileai-0.53 → whileai-0.55}/whileai/simulations/run/spec.py +2 -2
  11. {whileai-0.53 → whileai-0.55}/whileai/simulations/schema.py +26 -2
  12. {whileai-0.53 → whileai-0.55}/whileai/simulations/schemas/row-v1.json +1 -0
  13. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/delta.py +7 -0
  14. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/grading.py +11 -2
  15. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/judging.py +52 -2
  16. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/passat.py +11 -0
  17. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/privileged.py +8 -2
  18. {whileai-0.53 → whileai-0.55}/whileai/simulations/training.py +77 -8
  19. {whileai-0.53 → whileai-0.55/whileai.egg-info}/PKG-INFO +8 -8
  20. {whileai-0.53 → whileai-0.55}/LICENSE +0 -0
  21. {whileai-0.53 → whileai-0.55}/MANIFEST.in +0 -0
  22. {whileai-0.53 → whileai-0.55}/setup.cfg +0 -0
  23. {whileai-0.53 → whileai-0.55}/whileai/__init__.py +0 -0
  24. {whileai-0.53 → whileai-0.55}/whileai/_env.py +0 -0
  25. {whileai-0.53 → whileai-0.55}/whileai/auth.py +0 -0
  26. {whileai-0.53 → whileai-0.55}/whileai/cli.py +0 -0
  27. {whileai-0.53 → whileai-0.55}/whileai/ingest.py +0 -0
  28. {whileai-0.53 → whileai-0.55}/whileai/py.typed +0 -0
  29. {whileai-0.53 → whileai-0.55}/whileai/simulations/__main__.py +0 -0
  30. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/__init__.py +0 -0
  31. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/actionspace.py +0 -0
  32. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/adapters.py +0 -0
  33. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/coverage.py +0 -0
  34. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/diversity.py +0 -0
  35. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/embeddings.py +0 -0
  36. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/explore.py +0 -0
  37. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/generator.py +0 -0
  38. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/offline_agent.py +0 -0
  39. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/scenarios.py +0 -0
  40. {whileai-0.53 → whileai-0.55}/whileai/simulations/generate/usage_meter.py +0 -0
  41. {whileai-0.53 → whileai-0.55}/whileai/simulations/ingest/__init__.py +0 -0
  42. {whileai-0.53 → whileai-0.55}/whileai/simulations/ingest/otel.py +0 -0
  43. {whileai-0.53 → whileai-0.55}/whileai/simulations/ingest/platform.py +0 -0
  44. {whileai-0.53 → whileai-0.55}/whileai/simulations/ingest/traces.py +0 -0
  45. {whileai-0.53 → whileai-0.55}/whileai/simulations/monitor.py +0 -0
  46. {whileai-0.53 → whileai-0.55}/whileai/simulations/py.typed +0 -0
  47. {whileai-0.53 → whileai-0.55}/whileai/simulations/run/__init__.py +0 -0
  48. {whileai-0.53 → whileai-0.55}/whileai/simulations/run/config.py +0 -0
  49. {whileai-0.53 → whileai-0.55}/whileai/simulations/run/rows.py +0 -0
  50. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/__init__.py +0 -0
  51. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/agreement.py +0 -0
  52. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/checklist.py +0 -0
  53. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/curriculum.py +0 -0
  54. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/grade_llm.py +0 -0
  55. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/grounding.py +0 -0
  56. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/hack_scan.py +0 -0
  57. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/hygiene.py +0 -0
  58. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/judge_trust.py +0 -0
  59. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/labels.py +0 -0
  60. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/llm_judge.py +0 -0
  61. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/logprobs.py +0 -0
  62. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/markers.py +0 -0
  63. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/optimize.py +0 -0
  64. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/pairwise.py +0 -0
  65. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/preflight.py +0 -0
  66. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/publish_gate.py +0 -0
  67. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/quality.py +0 -0
  68. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/reference.py +0 -0
  69. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/rubric.py +0 -0
  70. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/spec.py +0 -0
  71. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/stage.py +0 -0
  72. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/stats.py +0 -0
  73. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/style.py +0 -0
  74. {whileai-0.53 → whileai-0.55}/whileai/simulations/score/trace.py +0 -0
  75. {whileai-0.53 → whileai-0.55}/whileai/simulations/simulation.py +0 -0
  76. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/__init__.py +0 -0
  77. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/base.py +0 -0
  78. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/code.py +0 -0
  79. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/math.py +0 -0
  80. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/structured.py +0 -0
  81. {whileai-0.53 → whileai-0.55}/whileai/simulations/verify/text.py +0 -0
  82. {whileai-0.53 → whileai-0.55}/whileai/simulations/world/__init__.py +0 -0
  83. {whileai-0.53 → whileai-0.55}/whileai/simulations/world/sandbox.py +0 -0
  84. {whileai-0.53 → whileai-0.55}/whileai.egg-info/SOURCES.txt +0 -0
  85. {whileai-0.53 → whileai-0.55}/whileai.egg-info/dependency_links.txt +0 -0
  86. {whileai-0.53 → whileai-0.55}/whileai.egg-info/entry_points.txt +0 -0
  87. {whileai-0.53 → whileai-0.55}/whileai.egg-info/requires.txt +0 -0
  88. {whileai-0.53 → whileai-0.55}/whileai.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.53
3
+ Version: 0.55
4
4
  Summary: While Python SDK: trace ingestion and verification client, plus agent simulations grounded in your tools and system prompt.
5
5
  Author: While
6
6
  License: Apache-2.0
@@ -51,7 +51,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
51
51
 
52
52
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
53
53
 
54
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
54
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
55
55
 
56
56
  ## Overview
57
57
 
@@ -230,7 +230,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
230
230
  print(data.pass_at)
231
231
  wai.judge_trust(data.trajectories) # 3 trust the numbers
232
232
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
233
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
233
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
234
234
  ```
235
235
 
236
236
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -391,9 +391,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
391
391
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
392
392
  data.grade()
393
393
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
394
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
395
- # pip install -e envs/github-agent
396
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
394
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
395
+ # pip install -e envs/my-agent
396
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
397
397
  ```
398
398
 
399
399
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -681,10 +681,10 @@ login`.
681
681
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
682
682
 
683
683
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
684
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
684
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
685
685
 
686
686
  # iterate, then push the next version with lineage
687
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
687
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
688
688
 
689
689
  wai.datasets() # list yours + storage used
690
690
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
@@ -15,7 +15,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
15
15
 
16
16
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
17
17
 
18
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
18
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
19
19
 
20
20
  ## Overview
21
21
 
@@ -194,7 +194,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
194
194
  print(data.pass_at)
195
195
  wai.judge_trust(data.trajectories) # 3 trust the numbers
196
196
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
197
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
197
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
198
198
  ```
199
199
 
200
200
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -355,9 +355,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
355
355
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
356
356
  data.grade()
357
357
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
358
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
359
- # pip install -e envs/github-agent
360
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
358
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
359
+ # pip install -e envs/my-agent
360
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
361
361
  ```
362
362
 
363
363
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -645,10 +645,10 @@ login`.
645
645
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
646
646
 
647
647
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
648
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
648
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
649
649
 
650
650
  # iterate, then push the next version with lineage
651
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
651
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
652
652
 
653
653
  wai.datasets() # list yours + storage used
654
654
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.53"
7
+ version = "0.55"
8
8
  description = "While Python SDK: trace ingestion and verification client, plus agent simulations grounded in your tools and system prompt."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -7,12 +7,13 @@ malicious), and rolls the agent. Grade 0/1 later. Optimize for post-training.
7
7
  Five calls, spec to gated dataset:
8
8
 
9
9
  import whileai.simulations as wai
10
- data = wai.simulate(agent="openai:gpt-4.1-mini", spec="specs/github",
11
- mode="rl", situations=200, repeats=8) # generate
10
+ data = wai.simulate(agent="openai:gpt-4.1-mini", tools=my_tools,
11
+ system_prompt=my_policy, mode="rl",
12
+ situations=200, repeats=8) # generate
12
13
  scored = data.grade(judge=my_judge) # grade 0/1
13
14
  print(scored.pass_at); wai.judge_trust(scored.rows, judge=my_judge) # trust
14
15
  rows, report = wai.optimize(scored, mode="rl") # prune
15
- wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # publish, gated
16
+ wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # publish, gated
16
17
 
17
18
  Everything else exported here is one layer down from those five.
18
19
  """
@@ -174,6 +175,7 @@ from .training import (
174
175
  TrainingRun,
175
176
  attach_delta,
176
177
  attach_holdout,
178
+ delete_model,
177
179
  delete_run,
178
180
  get_run,
179
181
  list_runs,
@@ -182,6 +184,7 @@ from .training import (
182
184
  serve,
183
185
  train,
184
186
  training_run,
187
+ unserve,
185
188
  )
186
189
  from .verify import Verifier, verifier
187
190
  from .world.sandbox import MockEnvironment
@@ -252,6 +255,7 @@ __all__ = [
252
255
  "dedupe_groups",
253
256
  "delete_dataset",
254
257
  "delete_empty_datasets",
258
+ "delete_model",
255
259
  "delete_run",
256
260
  "delta_report",
257
261
  "dimensions_from_traces",
@@ -381,6 +385,7 @@ __all__ = [
381
385
  "trim_unanimous_groups",
382
386
  "ungrounded_arguments",
383
387
  "unpublish",
388
+ "unserve",
384
389
  "update_dataset",
385
390
  "validate",
386
391
  "verifier",
@@ -652,6 +652,14 @@ class SimulationData:
652
652
  report["selection"] = self.search.get("selection")
653
653
  return report
654
654
 
655
+ def leak_report(self, *, min_len: int = 12) -> dict[str, Any]:
656
+ """Did any reply quote its own ``privileged`` block? Reads the
657
+ trajectories, which still carry the block; ``rows()`` is scrubbed
658
+ and would check nothing. Same report as ``leak_report``."""
659
+ from .score.privileged import leak_report
660
+
661
+ return leak_report(self.trajectories, min_len=min_len)
662
+
655
663
  @property
656
664
  def rows(self) -> RowList:
657
665
  """The exported rows: exactly what ``save()`` and ``output=`` write.
@@ -10,13 +10,13 @@ reward). ``export_environment`` writes those three as an installable
10
10
  ``verifiers`` package, the shape Prime Intellect and TRL consume::
11
11
 
12
12
  import whileai.simulations as wai
13
- data = wai.simulate(spec="specs/github", mode="rl", repeats=8)
13
+ data = wai.simulate(tools=my_tools, system_prompt=my_policy, mode="rl", repeats=8)
14
14
  scored = data.grade()
15
- wai.export_environment(scored, "envs/github-agent", reward=my_verifier)
15
+ wai.export_environment(scored, "envs/my-agent", reward=my_verifier)
16
16
 
17
17
  # then, with verifiers installed:
18
- # pip install -e envs/github-agent
19
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> ...
18
+ # pip install -e envs/my-agent
19
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> ...
20
20
 
21
21
  What goes in the package:
22
22
 
@@ -50,6 +50,7 @@ from pathlib import Path
50
50
  from typing import Any
51
51
 
52
52
  from .schema import check, stamp
53
+ from .score.privileged import leak_report
53
54
  from .score.quality import load_jsonl, write_jsonl
54
55
  from .score.stats import task_key
55
56
 
@@ -601,8 +602,14 @@ def export_training(
601
602
 
602
603
  With a path source and no ``output``, writes ``<name>.train.jsonl``
603
604
  next to it. ``validate=True`` refuses to write a dataset whose tool
604
- calls do not round-trip to structured arguments; pass ``validate=False``
605
- to export anyway and read the report instead.
605
+ calls do not round-trip to structured arguments, or whose assistant
606
+ turns quote the row's own ``privileged`` block (the export scrubs the
607
+ key, not the reply that recited it); pass ``validate=False`` to export
608
+ anyway and read the report instead. The leak check reads the source
609
+ before the scrub, so pass the ``SimulationData`` or its
610
+ ``trajectories``; rows that already came through ``rows()``, ``save()``
611
+ or a file carry nothing to check, and ``report["privileged_leaks"]``
612
+ says so.
606
613
 
607
614
  ``format="openai"`` (the default) writes the OpenAI chat-completions
608
615
  wire row: the full ``messages`` list, ``function.arguments`` as a JSON
@@ -638,7 +645,20 @@ def export_training(
638
645
  "them teaches string-wrapped arguments. Fix the rows or pass "
639
646
  "validate=False."
640
647
  )
641
- _, _, _, src = _resolve(source)
648
+ raw, _, _, src = _resolve(source)
649
+ # The scrub drops the ``privileged`` key at any depth and copies the
650
+ # assistant's reply through verbatim, so a reply that recited the block
651
+ # still recites it in the training file. Check the unscrubbed side,
652
+ # which is the only place the needles still exist (#249).
653
+ leaks = leak_report(raw)
654
+ if validate and leaks["n_leaked"]:
655
+ raise ValueError(
656
+ f"privileged_leak: {leaks['n_leaked']} of {leaks['n_checked']} rows quote "
657
+ "their own privileged context (reference, principle or hidden state) in "
658
+ "an assistant turn; the export scrubs the key, not the reply, so training "
659
+ "on them teaches the model to say what only the grader was told. Drop "
660
+ "those rows (leak_report(...)['leaked'] names them) or pass validate=False."
661
+ )
642
662
  dest = output
643
663
  if not dest and src:
644
664
  path = Path(src)
@@ -659,6 +679,9 @@ def export_training(
659
679
  "tool_output_chars_cut": sum(int(r.get("tool_output_chars_cut") or 0) for r in rows),
660
680
  "trained_messages": sum(sum(r["loss_mask"]) for r in rows),
661
681
  "masked_messages": sum(len(r["loss_mask"]) - sum(r["loss_mask"]) for r in rows),
682
+ "privileged_leaks": {
683
+ k: leaks[k] for k in ("checked", "n_checked", "n_leaked", "leaked", "summary")
684
+ },
662
685
  }
663
686
  # SFT clones every row it is given. A failed rollout in the file
664
687
  # teaches the failure, so say how many there are instead of leaving
@@ -673,6 +696,19 @@ def export_training(
673
696
  "n_ungraded": len(rows) - n_pass - n_fail,
674
697
  }
675
698
  warnings: list[str] = []
699
+ if leaks["n_leaked"]:
700
+ warnings.append(
701
+ f"{leaks['n_leaked']} of {leaks['n_checked']} rows quote their own privileged "
702
+ "context in an assistant turn and are exported anyway (validate=False); "
703
+ "report['privileged_leaks']['leaked'] names them."
704
+ )
705
+ n_cut = sum(1 for r in raw if isinstance(r, dict) and r.get("finish_reason") == "length")
706
+ if n_cut:
707
+ warnings.append(
708
+ f"{n_cut} of {len(raw)} rows were cut by the reply token cap (finish_reason "
709
+ "'length') and are exported as SFT targets; a model trained on them learns to "
710
+ "stop mid-thought. Filter on finish_reason == 'stop' or raise agent_max_tokens=."
711
+ )
676
712
  if n_fail:
677
713
  warnings.append(
678
714
  f"{n_fail} of {len(rows)} rows have reward below 0.5 and are exported as "
@@ -698,6 +734,7 @@ export_dataset = export_training
698
734
  _PAIR_KEYS = (
699
735
  "tie",
700
736
  "pairwise",
737
+ "first_turn_differs",
701
738
  "chosen_score",
702
739
  "rejected_score",
703
740
  "margin",
@@ -817,6 +854,12 @@ def export_preference(
817
854
  length_note = length_confound_warning(chosen_longer, len(deltas))
818
855
  if length_note:
819
856
  report["warnings"] = [length_note]
857
+ identical = sum(1 for r in out_rows if r.get("first_turn_differs") is False)
858
+ if identical:
859
+ from .score.judging import first_turn_note
860
+
861
+ report["first_turn_identical"] = identical
862
+ report.setdefault("warnings", []).append(first_turn_note(identical, len(out_rows)))
820
863
  margins = [r["margin"] for r in out_rows if isinstance(r.get("margin"), (int, float))]
821
864
  if margins:
822
865
  report["mean_margin"] = round(sum(margins) / len(margins), 4)
@@ -10,7 +10,7 @@ import os
10
10
  import re
11
11
  import threading
12
12
  import time
13
- from collections.abc import Callable
13
+ from collections.abc import Callable, Mapping
14
14
  from typing import Any
15
15
  from urllib.parse import urlparse
16
16
 
@@ -531,6 +531,7 @@ def complete(
531
531
  timeout: float = 60,
532
532
  n: int = 1,
533
533
  logprobs: bool | str = False,
534
+ extra: Mapping[str, Any] | None = None,
534
535
  ) -> dict:
535
536
  """POST /chat/completions. Reuses a thread-local keep-alive connection.
536
537
 
@@ -577,6 +578,8 @@ def complete(
577
578
  if tools:
578
579
  payload["tools"] = _wire_tools(tools)
579
580
  payload.update(_request_extras(base_url, model))
581
+ if extra:
582
+ payload.update(dict(extra))
580
583
  headers = {"Content-Type": "application/json", "Connection": "keep-alive"}
581
584
  if key:
582
585
  headers["Authorization"] = f"Bearer {key}"
@@ -730,8 +733,21 @@ def _strip_tool_markup(text: str) -> str:
730
733
  return cleaned.strip()
731
734
 
732
735
 
736
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>\s*", re.S | re.I)
737
+ _THINK_OPEN = re.compile(r"<think>.*\Z", re.S | re.I)
738
+
739
+
740
+ def _strip_think(text: str) -> str:
741
+ """Drop a thinking model's reasoning markup. A closed block goes whole;
742
+ an unclosed ``<think>`` (the token cap landed inside it) goes to the
743
+ end. What is left is the reply, which is what a grader, a marker and
744
+ the next turn's history should see (#264)."""
745
+ text = _THINK_BLOCK.sub("", text)
746
+ return _THINK_OPEN.sub("", text)
747
+
748
+
733
749
  def _spoken_text(reply: dict) -> str:
734
- return _strip_tool_markup(str(reply.get("content") or ""))
750
+ return _strip_tool_markup(_strip_think(str(reply.get("content") or "")))
735
751
 
736
752
 
737
753
  def _calls_from_reply(reply: dict) -> tuple[list[dict], dict]:
@@ -1391,9 +1407,23 @@ def local_model(
1391
1407
  timeout: float = 60,
1392
1408
  max_tokens: int | None = None,
1393
1409
  user_model: str | None = None,
1410
+ thinking: bool | None = None,
1394
1411
  ) -> Callable:
1412
+ """An agent that talks to an OpenAI-compatible endpoint (a served
1413
+ adapter, a local vLLM, any chat server) for ``simulate(agent=...)``.
1414
+
1415
+ ``thinking`` is for reasoning bases such as Qwen3: ``False`` sends
1416
+ ``chat_template_kwargs={"enable_thinking": False}`` so the reply is
1417
+ the answer, not the reasoning, the way the hosted Qwen path already
1418
+ does; ``True`` asks for it; ``None`` (the default) sends nothing and
1419
+ leaves the server's default. Either way ``<think>`` markup never
1420
+ reaches ``step["text"]`` or ``final_text``.
1421
+ """
1395
1422
  local = threading.local()
1396
1423
  plans = fault_plans if fault_plans is not None else {}
1424
+ extras: dict[str, Any] | None = (
1425
+ None if thinking is None else {"chat_template_kwargs": {"enable_thinking": bool(thinking)}}
1426
+ )
1397
1427
  # The simulated user's model. None means the agent's own model plays
1398
1428
  # the user (the default); a backend spec moves that role to another
1399
1429
  # model, with the key resolved for that endpoint.
@@ -1438,6 +1468,7 @@ def local_model(
1438
1468
  temperature=temperature,
1439
1469
  timeout=timeout,
1440
1470
  max_tokens=120,
1471
+ extra=extras,
1441
1472
  )
1442
1473
  opener_text = (_spoken_text(greet) or "").strip()
1443
1474
  if opener_text:
@@ -1477,6 +1508,7 @@ def local_model(
1477
1508
  timeout=timeout,
1478
1509
  max_tokens=reply_budget(max_tokens),
1479
1510
  logprobs=logprobs,
1511
+ extra=extras,
1480
1512
  )
1481
1513
  calls, assistant = _calls_from_reply(reply)
1482
1514
  # One agent turn, one set of sampling facts, on its first step.
@@ -160,6 +160,32 @@ def _agent_error_text(exc: BaseException) -> str:
160
160
  return f"<agent error: {type(exc).__name__}: {public_llm_error(exc)}>"
161
161
 
162
162
 
163
+ FINISH_REASONS = ("stop", "length", "tool", "error")
164
+
165
+
166
+ def _finish_reason(raw: dict, steps: list, final_text: str) -> str:
167
+ """Why the rollout ended, on the row where a trainer can read it.
168
+
169
+ ``length``: a turn was cut by the reply token cap (the backend said
170
+ so). ``error``: the agent raised. ``tool``: the last thing the agent
171
+ did was call a tool and no final reply followed, so the turn budget
172
+ ran out. ``stop``: the agent finished on its own. A callable agent may
173
+ say it outright with ``finish_reason`` in what it returns. A length
174
+ cut scored 0 teaches the cheapest fix, shorter thinking, before it
175
+ teaches the task (#253), so the trainer masks these by default.
176
+ """
177
+ told = raw.get("finish_reason")
178
+ if isinstance(told, str) and told in FINISH_REASONS:
179
+ return told
180
+ if final_text.startswith("<agent error:"):
181
+ return "error"
182
+ if any(isinstance(s, dict) and s.get("truncated") for s in steps):
183
+ return "length"
184
+ if not final_text.strip() and steps and isinstance(steps[-1], dict) and steps[-1].get("tool"):
185
+ return "tool"
186
+ return "stop"
187
+
188
+
163
189
  def _hit_length_cap(row: dict) -> bool:
164
190
  """A step the backend flagged as cut by its token cap, or a reply that
165
191
  ends mid-sentence by the hygiene rule."""
@@ -802,6 +828,7 @@ class Run:
802
828
  t["seeded"] = [str(x) for x in seeded]
803
829
  t.update(_row_conversation(meta, prompt, c.seed))
804
830
  t["behavior_signature"] = behavior_signature(t)
831
+ t["finish_reason"] = _finish_reason(raw, t["steps"], t["final_text"])
805
832
  # Sampling facts roll up from the agent turns: the summed logprob
806
833
  # and token count a trainer needs for an importance ratio or a KL.
807
834
  lp_steps = [
@@ -69,8 +69,8 @@ RUBRIC_FILES = ("rubric.md", "rubric.txt")
69
69
  def spec_rubric(spec: Any) -> str | None:
70
70
  """The rubric shipped with a spec: what doing the job means, in prose,
71
71
  for the judge. ``rubric.md`` (or ``.txt``) next to the spec file the
72
- loader resolves ``spec`` to (so ``spec="github"`` reads
73
- ``specs/github/rubric.md``, the same shorthand ``simulate`` accepts),
72
+ loader resolves ``spec`` to (so ``spec="my-agent"`` reads
73
+ ``specs/my-agent/rubric.md``, the same shorthand ``simulate`` accepts),
74
74
  or a ``rubric`` key in the spec dict. None when the spec carries none."""
75
75
  if isinstance(spec, dict):
76
76
  text = str(spec.get("rubric") or "").strip()
@@ -561,6 +561,9 @@ def attach(row: dict, judgment: Judgment) -> dict:
561
561
  meta = dict(judgment.evidence)
562
562
  if judgment.scorer.version:
563
563
  meta["version"] = judgment.scorer.version
564
+ if judgment.scorer.kind != "judge":
565
+ # A named scorer reads back as "judge" unless the row says otherwise.
566
+ meta["scorer_kind"] = judgment.scorer.kind
564
567
  if meta:
565
568
  row["judge_meta"] = meta
566
569
  return row
@@ -576,6 +579,19 @@ def _scorer_version(row: dict) -> str | None:
576
579
  return None
577
580
 
578
581
 
582
+ def _scorer_kind(row: dict) -> str | None:
583
+ """The kind the grading run stamped, if any (``judge_meta.scorer_kind``)."""
584
+ meta = row.get("judge_meta")
585
+ if isinstance(meta, dict) and meta.get("scorer_kind") in (
586
+ "rule",
587
+ "judge",
588
+ "reward_model",
589
+ "human",
590
+ ):
591
+ return str(meta["scorer_kind"])
592
+ return None
593
+
594
+
579
595
  def _judgments(row: dict, rollout_id: str) -> list[Judgment]:
580
596
  out: list[Judgment] = []
581
597
  has_primary = (
@@ -588,7 +604,11 @@ def _judgments(row: dict, rollout_id: str) -> list[Judgment]:
588
604
  judge = row.get("judge_name")
589
605
  label = row.get("label_source")
590
606
  name = judge or label or "unlabeled"
591
- kind: Any = "judge" if judge else "rule"
607
+ # The stamped kind wins; without one, a named judge is a model judge
608
+ # and a bare label is a rule. A Verifier run through ``run_judge``
609
+ # carries ``judge_name`` too, so the inference alone called every
610
+ # verifier a judge (#250).
611
+ kind: Any = _scorer_kind(row) or ("judge" if judge else "rule")
592
612
  reward = _number(row.get("reward"))
593
613
  status: Any = row.get("judge_status") or "ok"
594
614
  evidence: dict = {}
@@ -802,7 +822,11 @@ def to_row(
802
822
  if primary.reason:
803
823
  row["reason"] = primary.reason
804
824
  if primary.scorer.name != "unlabeled":
805
- if primary.scorer.kind == "judge":
825
+ # ``judge_name`` is what a grading run called itself, whatever
826
+ # its kind; a stamped kind proves a run named it. ``label_source``
827
+ # is the engine's own rule label.
828
+ stamped = (primary.evidence.get("judge_meta") or {}).get("scorer_kind")
829
+ if primary.scorer.kind != "rule" or stamped:
806
830
  row["judge_name"] = primary.scorer.name
807
831
  if primary.evidence.get("label_source"):
808
832
  row["label_source"] = primary.evidence["label_source"]
@@ -44,6 +44,7 @@
44
44
  "token_logprobs": {"type": "array", "items": {"type": "number"}, "$comment": "per-token log-probabilities in generation order across the agent's turns; simulate(logprobs=\"tokens\")"},
45
45
  "sampling": {"type": ["object", "null"], "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}, "model": {"type": "string"}}, "$comment": "how the row was sampled: a model backend records its resolved temperature, reply budget and model; a callable agent records simulate(sampling=) verbatim, else null"},
46
46
  "policy_version": {"type": "string", "$comment": "Rollout.policy.version: <model_version>@<sha256 of the system policy>[:16]"},
47
+ "finish_reason": {"type": "string", "enum": ["stop", "length", "tool", "error"], "$comment": "why the rollout ended: on its own, cut by the reply token cap, turn budget ran out on a tool call, or the agent raised"},
47
48
  "writer_model": {"type": "string", "$comment": "who wrote the prompt: the situation writer's model tag, or template / seed / pinned when no model wrote it"},
48
49
  "user_model": {"type": "string", "$comment": "who played the simulated user; absent when the agent took a single message"},
49
50
  "calibration": {
@@ -370,6 +370,13 @@ def delta_report(
370
370
  f"Before allowed {cfg_a['max_tokens']} reply tokens and after {cfg_b['max_tokens']}; "
371
371
  "re-run one side so both use the same agent_max_tokens=."
372
372
  )
373
+ if _both("truncated_share") and abs(cfg_a["truncated_share"] - cfg_b["truncated_share"]) > 0.05:
374
+ warnings.append(
375
+ f"The token cap cut {cfg_a['truncated_share']:.0%} of before rows and "
376
+ f"{cfg_b['truncated_share']:.0%} of after rows; a side that is cut more often is "
377
+ "not the same eval. Raise agent_max_tokens= on both sides or read the delta with "
378
+ "that in mind."
379
+ )
373
380
  if _both("policy_version") and cfg_a["policy_version"] == cfg_b["policy_version"]:
374
381
  warnings.append(
375
382
  "Before and after are the same policy version; this compares a model to itself."
@@ -40,6 +40,15 @@ _FAULT_ALIASES = {
40
40
  "entity already acted on": "already_done",
41
41
  }
42
42
  _CLEAN_FAULT = {"", "ok", "created", "deleted", "success", "exit_0", "no fault", "clean"}
43
+ #: Statuses a tool result can carry that mean the call failed. A ``status``
44
+ #: key with any other value (``paid``, ``open``, ``completed``) is the
45
+ #: tool's own vocabulary, not a fault (#261).
46
+ _KNOWN_FAULTS = (
47
+ _BAD_STATUS
48
+ | set(_FAULT_ALIASES)
49
+ | set(_FAULT_ALIASES.values())
50
+ | {"stale", "malformed", "already_done", "not_found", "deny", "garbled"}
51
+ )
43
52
  _HTTP_FAIL = re.compile(r"^[45]\d\d$")
44
53
  _INFRA_STUB = re.compile(
45
54
  r"^<agent error:|returned\s+[45]\d\d\b|^(https?://\S+\s+)?[45]\d\d(\s|$)", re.I
@@ -426,10 +435,10 @@ def _fault_from_result(result) -> str:
426
435
  if status in {"already_done", "already_acted_on"} or reason == "already_acted_on":
427
436
  return "already_done"
428
437
  name = normalize_fault_name(status)
429
- if name:
438
+ if name and (name in _KNOWN_FAULTS or status in _KNOWN_FAULTS):
430
439
  return name
431
440
  if _step_faulted(result):
432
- return normalize_fault_name(status) or "error"
441
+ return name or "error"
433
442
  return ""
434
443
 
435
444
 
@@ -402,6 +402,11 @@ def run_judge(
402
402
  name = judge_name or getattr(judge, "__name__", "") or _instance_name(judge) or "judge"
403
403
  if name == "<lambda>":
404
404
  name = "lambda_judge"
405
+ # A Verifier says what it is (``kind="rule"``); a function judge does
406
+ # not, and the schema then infers "judge" from the name. Stamp the
407
+ # declared kind so a verifier does not read back as a model judge (#250).
408
+ kind = getattr(judge, "kind", None)
409
+ scorer_kind = kind if kind in ("rule", "reward_model", "human") else None
405
410
  verdicts: list[dict[str, Any]]
406
411
  if concurrency > 1 and len(src_rows) > 1:
407
412
  with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
@@ -429,8 +434,11 @@ def run_judge(
429
434
  out["reason"] = verdict["reason"]
430
435
  out["judge_status"] = verdict["judge_status"]
431
436
  out["judge_name"] = name
432
- if verdict["judge_meta"]:
433
- out["judge_meta"] = verdict["judge_meta"]
437
+ meta = dict(verdict["judge_meta"] or {})
438
+ if scorer_kind:
439
+ meta["scorer_kind"] = scorer_kind
440
+ if meta:
441
+ out["judge_meta"] = meta
434
442
  fc = (verdict["judge_meta"] or {}).get("failure_class")
435
443
  if fc:
436
444
  out["failure_class"] = str(fc)
@@ -555,6 +563,42 @@ def length_confound_warning(chosen_longer: int, n: int) -> str | None:
555
563
  return None
556
564
 
557
565
 
566
+ def _first_turn(row: dict) -> str:
567
+ """What the policy emitted first, read the way the hosted DPO trainer
568
+ reads it: the first tool step as its call, else the first assistant
569
+ text, else ``final_text``."""
570
+ steps = [s for s in (row.get("steps") or []) if isinstance(s, dict)]
571
+ for step in steps:
572
+ if step.get("tool"):
573
+ args = step.get("arguments")
574
+ if args is None:
575
+ args = step.get("args")
576
+ return json.dumps({"name": step["tool"], "arguments": args or {}}, sort_keys=True)
577
+ for step in steps:
578
+ if str(step.get("text") or "").strip():
579
+ return str(step["text"]).strip()
580
+ for message in row.get("messages") or []:
581
+ if isinstance(message, dict) and message.get("role") == "assistant":
582
+ text = str(message.get("content") or "").strip()
583
+ if text:
584
+ return text
585
+ return str(row.get("final_text") or "").strip()
586
+
587
+
588
+ def first_turn_note(identical: int, n: int) -> str:
589
+ """The warning for pairs whose first assistant turns read the same."""
590
+ if not identical:
591
+ return ""
592
+ left = n - identical
593
+ return (
594
+ f"{identical}/{n} pairs have identical first assistant turns (same opening tool call "
595
+ "or line); their contrast is later in the rollout. The hosted DPO trainer compares "
596
+ f"first turns only and will drop them, leaving {left} (it needs at least 8). Keep "
597
+ "[p for p in pairs if p['first_turn_differs']] to see what it will train on, or "
598
+ "export_preference(pairs) for a trainer that reads whole conversations."
599
+ )
600
+
601
+
558
602
  def build_preference_pairs(
559
603
  rows: Sequence[dict],
560
604
  *,
@@ -643,6 +687,7 @@ def build_preference_pairs(
643
687
  "rejected_model": r_model,
644
688
  "same_policy": (c_model == r_model) if c_model and r_model else None,
645
689
  "length_delta": reply_length(chosen) - reply_length(rejected),
690
+ "first_turn_differs": _first_turn(chosen) != _first_turn(rejected),
646
691
  "chosen_reason": str(chosen.get("reason") or ""),
647
692
  "rejected_reason": str(rejected.get("reason") or ""),
648
693
  "rejected_failure_class": rejected.get("failure_class"),
@@ -665,10 +710,13 @@ def build_preference_pairs(
665
710
  for p in pairs
666
711
  if p["chosen_score"] not in (0.0, 1.0) or p["rejected_score"] not in (0.0, 1.0)
667
712
  )
713
+ identical = sum(1 for p in pairs if not p["first_turn_differs"])
668
714
  warnings: list[str] = []
669
715
  length_note = length_confound_warning(chosen_longer, n)
670
716
  if length_note:
671
717
  warnings.append(length_note)
718
+ if identical:
719
+ warnings.append(first_turn_note(identical, n))
672
720
  if mixed_policy:
673
721
  warnings.append(
674
722
  f"{mixed_policy}/{n} pairs mix policies (chosen and rejected from different "
@@ -686,6 +734,8 @@ def build_preference_pairs(
686
734
  "min_margin": min_margin,
687
735
  "mean_margin": round(sum(p["margin"] for p in pairs) / n, 4) if n else None,
688
736
  "partial_score_pairs": partial,
737
+ "first_turn_identical": identical,
738
+ "trainer_pairs": n - identical,
689
739
  "same_policy_pairs": same_policy,
690
740
  "mixed_policy_pairs": mixed_policy,
691
741
  "eval_sourced": eval_pairs,
@@ -114,6 +114,14 @@ def run_config(
114
114
  mixed.append(key)
115
115
  out[key] = next(iter(values)) if len(values) == 1 else None
116
116
  out["mixed"] = mixed
117
+ # The share of rows the token cap cut. A side that was cut more often
118
+ # is not the same eval; ``delta_report`` warns when the two differ.
119
+ # ``None`` when no row says how it finished (rows from before 0.54).
120
+ reasons = [r.get("finish_reason") for r in rows if isinstance(r, dict)]
121
+ known = [x for x in reasons if isinstance(x, str)]
122
+ out["truncated_share"] = (
123
+ round(sum(1 for x in known if x == "length") / len(known), 4) if known else None
124
+ )
117
125
  return out
118
126
 
119
127
 
@@ -190,6 +198,9 @@ class PassAt:
190
198
  f"headroom {fmt(self.headroom)}"
191
199
  )
192
200
  tail = f"({self.n_groups} groups, k={self.k}"
201
+ cut = self.config.get("truncated_share") if self.config else None
202
+ if cut:
203
+ tail += f"; {cut:.0%} of rows cut by the token cap"
193
204
  if self.note:
194
205
  tail += f"; {self.note}"
195
206
  return f"{head} {tail})"
@@ -15,7 +15,6 @@ it is vacuous. The report says so instead of passing.
15
15
  from __future__ import annotations
16
16
 
17
17
  import re
18
- from collections.abc import Sequence
19
18
  from typing import Any
20
19
 
21
20
  from .style import assistant_text
@@ -52,9 +51,14 @@ def _needles(privileged: Any, *, min_len: int) -> list[tuple[str, str]]:
52
51
  return out
53
52
 
54
53
 
55
- def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
54
+ def leak_report(rows: Any, *, min_len: int = 12) -> dict[str, Any]:
56
55
  """Which rows quote their own ``privileged`` block in the agent's text.
57
56
 
57
+ Takes the ``SimulationData`` itself, ``data.trajectories``, or any list
58
+ of rows. Given the data object it reads the trajectories, which still
59
+ carry the block; ``data.rows()`` is the scrubbed export and checks
60
+ nothing (the report says so).
61
+
58
62
  Checks every row that carries ``privileged`` (``reference``,
59
63
  ``principle``, and every string in ``hidden_state`` at least
60
64
  ``min_len`` characters long) against the final reply and every
@@ -69,6 +73,8 @@ def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
69
73
  and the report is vacuous. When it can tell the rows came through the
70
74
  export, ``summary`` says so and names the accessor to use instead.
71
75
  """
76
+ if hasattr(rows, "trajectories") and not isinstance(rows, (list, tuple)):
77
+ rows = rows.trajectories
72
78
  n_rows = 0
73
79
  n_checked = 0
74
80
  exported = False
@@ -474,6 +474,11 @@ class TrainingRun:
474
474
  else:
475
475
  self.finish("done")
476
476
 
477
+ @property
478
+ def id(self) -> str:
479
+ """The run id, the handle ``get_run``, ``serve`` and ``delete_run`` take."""
480
+ return self.run_id
481
+
477
482
  def __repr__(self) -> str:
478
483
  return (
479
484
  f"TrainingRun({self.run_id!r}, {self.name!r}, status={self.status!r}, step={self.step})"
@@ -558,6 +563,10 @@ def _measured_temperature(
558
563
  return None
559
564
 
560
565
 
566
+ #: what GRPO does with a sampled reply the token cap cut
567
+ TRUNCATED = ("mask", "zero")
568
+
569
+
561
570
  def train(
562
571
  dataset: str,
563
572
  *,
@@ -573,6 +582,7 @@ def train(
573
582
  max_completion_length: int | None = None,
574
583
  loss_type: str | None = None,
575
584
  temperature: float | None = None,
585
+ truncated: str | None = None,
576
586
  config: Mapping[str, Any] | None = None,
577
587
  wait: bool = False,
578
588
  timeout: float | None = None,
@@ -612,10 +622,15 @@ def train(
612
622
  sampling temperature the trainer rolls out at (GRPO); the dataset's
613
623
  rows say what they were measured at under ``sampling.temperature``,
614
624
  and ``train`` says so when the two differ, since a before/after
615
- comparison across temperatures is not like for like. Each has a
625
+ comparison across temperatures is not like for like. ``truncated``
626
+ says what GRPO does with a sampled reply the token cap cut:
627
+ ``"mask"`` (the default) gives it no gradient, ``"zero"`` scores it 0
628
+ the old way. A cut reply scored 0 teaches shorter thinking before it
629
+ teaches the task, so ``"zero"`` is the knob to reach for only when the
630
+ cap itself is the behavior under training (#253). Each has a
616
631
  trainer default when left ``None``. ``config`` passes further host
617
632
  keys as given
618
- (``epsilonHigh``, ``scaleRewards``, ``maskTruncated``, ``balance``).
633
+ (``epsilonHigh``, ``scaleRewards``, ``balance``).
619
634
  Every knob lands on the run's ``config`` so the run page shows it.
620
635
 
621
636
  A dataset already training answers with that run instead of a second.
@@ -682,6 +697,13 @@ def train(
682
697
  if not 0 < float(temperature) <= 2:
683
698
  raise ValueError("temperature: above 0 and at most 2")
684
699
  body["temperature"] = float(temperature)
700
+ if truncated is not None:
701
+ if method != "grpo":
702
+ raise ValueError("truncated= says what GRPO does with a token-capped reply; grpo only")
703
+ if truncated not in TRUNCATED:
704
+ raise ValueError(f"truncated must be one of {', '.join(TRUNCATED)}; got {truncated!r}")
705
+ if method == "grpo":
706
+ body["maskTruncated"] = (truncated or "mask") == "mask"
685
707
  for key, value in dict(config or {}).items():
686
708
  if key in body:
687
709
  raise ValueError(f"config[{key!r}] collides with a named argument")
@@ -808,11 +830,40 @@ def models(*, api_key: str | None = None) -> list[dict[str, Any]]:
808
830
  """The account's hosted models: ``name``, ``baseModel``, ``adapter``,
809
831
  ``adapterRunId``, ``version``, ``endpoint`` (an OpenAI-compatible
810
832
  base URL; send the account key as the bearer and ``name`` as the
811
- model)."""
833
+ model).
834
+
835
+ A row here is a registry entry, not a running GPU: the endpoint
836
+ behind it idles to zero on its own and an unused model costs nothing.
837
+ The row stays until ``unserve(name)`` removes it; serving the same
838
+ name again bumps its ``version`` rather than adding a row."""
812
839
  out = _call("GET", "/models", api_key)
813
840
  return list(out.get("models") or []) if isinstance(out, dict) else []
814
841
 
815
842
 
843
+ def unserve(
844
+ name: str,
845
+ *,
846
+ api_key: str | None = None,
847
+ transport: Callable[..., Any] | None = None,
848
+ ) -> dict[str, Any]:
849
+ """Stop hosting ``name``: removes the model row from the account, so
850
+ ``models()`` no longer lists it and its endpoint stops answering for
851
+ that name. The inverse of ``serve``, the way ``delete_dataset`` is the
852
+ inverse of ``push``. The adapter weights and the training run stay;
853
+ ``serve`` the run again to bring it back (at version 1).
854
+ Returns ``{"name": ..., "deleted": True}``."""
855
+ call = transport or _call
856
+ key = str(name).strip().lower()
857
+ if not key:
858
+ raise ValueError("unserve: name is the hosted model's name, as models() lists it")
859
+ out = call("DELETE", f"/models/{key}", api_key)
860
+ return dict(out) if isinstance(out, dict) else {"name": key, "deleted": True}
861
+
862
+
863
+ #: Same call, the other spelling: symmetric with ``delete_dataset``.
864
+ delete_model = unserve
865
+
866
+
816
867
  def serve(
817
868
  name: str,
818
869
  run: TrainingRun | str | None = None,
@@ -825,18 +876,36 @@ def serve(
825
876
  row; ``endpoint`` is the OpenAI-compatible base URL and ``name`` the
826
877
  model id to send. Posting an existing name bumps ``version``.
827
878
 
828
- ``run`` is a ``TrainingRun`` or its id; the adapter and base model
829
- come from the run record unless ``base_model`` is given. No ``run``
830
- serves the bare base (``base_model`` required).
879
+ ``run`` is a ``TrainingRun``, the record ``get_run`` returns, or the
880
+ run id; the adapter and base model come from the run record unless
881
+ ``base_model`` is given. No ``run`` serves the bare base
882
+ (``base_model`` required). ``unserve`` is the inverse.
831
883
  """
832
884
  call = transport or _call
833
885
  adapter: str | None = None
834
886
  base = base_model
887
+ run_id: str | None
835
888
  if isinstance(run, TrainingRun):
836
889
  adapter = run.adapter
837
- run_id: str | None = run.run_id
890
+ run_id = run.run_id
891
+ elif isinstance(run, dict):
892
+ # The record ``get_run`` returns. Reading the id here is what lets
893
+ # train in one process and serve in the next compose (#262).
894
+ run_id = str(run.get("runId") or run.get("run_id") or run.get("id") or "").strip() or None
895
+ if not run_id:
896
+ raise TypeError(
897
+ "run is a dict with no runId; pass the record wai.get_run(run_id) returns, "
898
+ "a TrainingRun, or the run id string"
899
+ )
900
+ adapter = str(run.get("adapter") or "").strip() or None
901
+ base = base or run.get("baseModel") or run.get("base_model")
902
+ elif run is None or isinstance(run, str):
903
+ run_id = (str(run).strip() or None) if run else None
838
904
  else:
839
- run_id = str(run) if run else None
905
+ raise TypeError(
906
+ "run must be a TrainingRun, the record wai.get_run(run_id) returns, or the run "
907
+ f"id string; got {type(run).__name__}"
908
+ )
840
909
  if run_id and (adapter is None or base is None):
841
910
  meta = call("GET", f"/runs/{run_id}", api_key)
842
911
  meta = meta if isinstance(meta, dict) else {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.53
3
+ Version: 0.55
4
4
  Summary: While Python SDK: trace ingestion and verification client, plus agent simulations grounded in your tools and system prompt.
5
5
  Author: While
6
6
  License: Apache-2.0
@@ -51,7 +51,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
51
51
 
52
52
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
53
53
 
54
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
54
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
55
55
 
56
56
  ## Overview
57
57
 
@@ -230,7 +230,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
230
230
  print(data.pass_at)
231
231
  wai.judge_trust(data.trajectories) # 3 trust the numbers
232
232
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
233
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
233
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
234
234
  ```
235
235
 
236
236
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -391,9 +391,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
391
391
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
392
392
  data.grade()
393
393
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
394
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
395
- # pip install -e envs/github-agent
396
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
394
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
395
+ # pip install -e envs/my-agent
396
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
397
397
  ```
398
398
 
399
399
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -681,10 +681,10 @@ login`.
681
681
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
682
682
 
683
683
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
684
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
684
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
685
685
 
686
686
  # iterate, then push the next version with lineage
687
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
687
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
688
688
 
689
689
  wai.datasets() # list yours + storage used
690
690
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes