whileai 0.62__tar.gz → 0.63__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 (94) hide show
  1. {whileai-0.62/whileai.egg-info → whileai-0.63}/PKG-INFO +44 -4
  2. {whileai-0.62 → whileai-0.63}/README.md +43 -3
  3. {whileai-0.62 → whileai-0.63}/pyproject.toml +1 -1
  4. {whileai-0.62 → whileai-0.63}/whileai/simulations/__init__.py +8 -0
  5. {whileai-0.62 → whileai-0.63}/whileai/simulations/export.py +18 -0
  6. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/agents.py +149 -27
  7. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/diversity.py +29 -7
  8. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/generator.py +29 -3
  9. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/scenarios.py +68 -3
  10. {whileai-0.62 → whileai-0.63}/whileai/simulations/ingest/traces.py +32 -18
  11. {whileai-0.62 → whileai-0.63}/whileai/simulations/run/config.py +47 -3
  12. {whileai-0.62 → whileai-0.63}/whileai/simulations/run/engine.py +147 -1
  13. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/delta.py +113 -1
  14. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/grade_llm.py +91 -2
  15. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/grading.py +121 -0
  16. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/hygiene.py +9 -1
  17. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/passat.py +51 -0
  18. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/preflight.py +59 -0
  19. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/stats.py +204 -14
  20. {whileai-0.62 → whileai-0.63}/whileai/simulations/simulation.py +8 -0
  21. whileai-0.63/whileai/simulations/text.py +39 -0
  22. {whileai-0.62 → whileai-0.63/whileai.egg-info}/PKG-INFO +44 -4
  23. {whileai-0.62 → whileai-0.63}/whileai.egg-info/SOURCES.txt +1 -0
  24. {whileai-0.62 → whileai-0.63}/LICENSE +0 -0
  25. {whileai-0.62 → whileai-0.63}/MANIFEST.in +0 -0
  26. {whileai-0.62 → whileai-0.63}/setup.cfg +0 -0
  27. {whileai-0.62 → whileai-0.63}/whileai/__init__.py +0 -0
  28. {whileai-0.62 → whileai-0.63}/whileai/_env.py +0 -0
  29. {whileai-0.62 → whileai-0.63}/whileai/auth.py +0 -0
  30. {whileai-0.62 → whileai-0.63}/whileai/cli.py +0 -0
  31. {whileai-0.62 → whileai-0.63}/whileai/ingest.py +0 -0
  32. {whileai-0.62 → whileai-0.63}/whileai/init_evals.py +0 -0
  33. {whileai-0.62 → whileai-0.63}/whileai/py.typed +0 -0
  34. {whileai-0.62 → whileai-0.63}/whileai/simulations/__main__.py +0 -0
  35. {whileai-0.62 → whileai-0.63}/whileai/simulations/data.py +0 -0
  36. {whileai-0.62 → whileai-0.63}/whileai/simulations/environment.py +0 -0
  37. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/__init__.py +0 -0
  38. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/actionspace.py +0 -0
  39. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/adapters.py +0 -0
  40. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/anthropic_backend.py +0 -0
  41. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/coverage.py +0 -0
  42. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/embeddings.py +0 -0
  43. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/explore.py +0 -0
  44. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/offline_agent.py +0 -0
  45. {whileai-0.62 → whileai-0.63}/whileai/simulations/generate/usage_meter.py +0 -0
  46. {whileai-0.62 → whileai-0.63}/whileai/simulations/ingest/__init__.py +0 -0
  47. {whileai-0.62 → whileai-0.63}/whileai/simulations/ingest/otel.py +0 -0
  48. {whileai-0.62 → whileai-0.63}/whileai/simulations/ingest/platform.py +0 -0
  49. {whileai-0.62 → whileai-0.63}/whileai/simulations/monitor.py +0 -0
  50. {whileai-0.62 → whileai-0.63}/whileai/simulations/py.typed +0 -0
  51. {whileai-0.62 → whileai-0.63}/whileai/simulations/run/__init__.py +0 -0
  52. {whileai-0.62 → whileai-0.63}/whileai/simulations/run/rows.py +0 -0
  53. {whileai-0.62 → whileai-0.63}/whileai/simulations/run/spec.py +0 -0
  54. {whileai-0.62 → whileai-0.63}/whileai/simulations/schema.py +0 -0
  55. {whileai-0.62 → whileai-0.63}/whileai/simulations/schemas/row-v1.json +0 -0
  56. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/__init__.py +0 -0
  57. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/agreement.py +0 -0
  58. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/audit.py +0 -0
  59. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/checklist.py +0 -0
  60. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/curriculum.py +0 -0
  61. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/grounding.py +0 -0
  62. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/hack_scan.py +0 -0
  63. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/judge_trust.py +0 -0
  64. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/judging.py +0 -0
  65. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/labels.py +0 -0
  66. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/llm_judge.py +0 -0
  67. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/logprobs.py +0 -0
  68. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/markers.py +0 -0
  69. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/optimize.py +0 -0
  70. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/pairwise.py +0 -0
  71. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/privileged.py +0 -0
  72. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/publish_gate.py +0 -0
  73. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/quality.py +0 -0
  74. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/reference.py +0 -0
  75. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/rubric.py +0 -0
  76. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/spec.py +0 -0
  77. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/stage.py +0 -0
  78. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/style.py +0 -0
  79. {whileai-0.62 → whileai-0.63}/whileai/simulations/score/trace.py +0 -0
  80. {whileai-0.62 → whileai-0.63}/whileai/simulations/training.py +0 -0
  81. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/__init__.py +0 -0
  82. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/base.py +0 -0
  83. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/code.py +0 -0
  84. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/math.py +0 -0
  85. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/structured.py +0 -0
  86. {whileai-0.62 → whileai-0.63}/whileai/simulations/verify/text.py +0 -0
  87. {whileai-0.62 → whileai-0.63}/whileai/simulations/world/__init__.py +0 -0
  88. {whileai-0.62 → whileai-0.63}/whileai/simulations/world/sandbox.py +0 -0
  89. {whileai-0.62 → whileai-0.63}/whileai/templates/__init__.py +0 -0
  90. {whileai-0.62 → whileai-0.63}/whileai/templates/evals.py +0 -0
  91. {whileai-0.62 → whileai-0.63}/whileai.egg-info/dependency_links.txt +0 -0
  92. {whileai-0.62 → whileai-0.63}/whileai.egg-info/entry_points.txt +0 -0
  93. {whileai-0.62 → whileai-0.63}/whileai.egg-info/requires.txt +0 -0
  94. {whileai-0.62 → whileai-0.63}/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.62
3
+ Version: 0.63
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
@@ -219,6 +219,18 @@ print(wai.pass_at(scored.rows), *scored.warnings) # a hollow run says so here
219
219
  `scored.warnings` is new: no rollout called a tool, a declared tool no
220
220
  rollout touched, a marker that fired on no row. A 1.00 on a run like that is not a result; the note names the fix.
221
221
 
222
+ A declared tool the world cannot answer is the quiet version of the same
223
+ failure: with `execute=`, a tool that is in the schema but has no branch in
224
+ your function fails exactly like a world fault, the agent reports the miss
225
+ honestly, and a candour rubric rewards the row. Every run now records calls
226
+ and successes per tool in `data.coverage["tools"]` (`n`, `ok`, `fault_n`, and
227
+ `injected` for faults the run scheduled itself), lists the tools that never
228
+ work in `data.coverage["dead_tools"]` (the Wilson 95% upper bound on the
229
+ success rate is under 0.30, so 0 of 9 or 4 of 612 is dead and 0 of 3 or 2 of
230
+ 5 is not), adds `dead_tools` to `data.degraded`, and puts the names and the
231
+ one fix that applies to your world in `data.warnings` and `data.report()`.
232
+ Steps with no recorded result are not evidence and never accuse a tool.
233
+
222
234
  ### Bring a model
223
235
 
224
236
  Bring your own model. Any OpenAI-compatible chat endpoint that returns tool
@@ -227,7 +239,31 @@ your key. To put a number on a model you serve (`wai.serve`, or your own
227
239
  vLLM), make it the agent: `wai.simulate(tasks=pinned,
228
240
  agent=wai.local_model(endpoint, name, tools=TOOLS, system=POLICY,
229
241
  thinking=False))`, and run both arms of a before/after through that same
230
- call so the only difference is the weights.
242
+ call so the only difference is the weights. `thinking=False` reaches the
243
+ simulated user as well when the agent's own model plays it (the default),
244
+ and whatever a user model still emits as `<think>` is stripped before it
245
+ becomes a user turn; the run reports those under
246
+ `data.search["user_think"]` (counts and shares of the user turns, zeros when
247
+ none) and says so in `data.warnings`. `delta_report` fails a before/after
248
+ whose arms differ in how often they answered at all (`"answered"` in
249
+ `not_comparable`, a two-proportion test at p < 0.01 and a gap over the
250
+ re-run band or 10 points), which is what a reasoning base against an
251
+ adapter trained on think-free targets does under one shared token budget:
252
+ set `thinking=` the same on both arms.
253
+
254
+ Two `local_model` knobs the situation writer cannot guess for you.
255
+ `result_shapes={tool_name: example_result}` pins what a tool returns, so a
256
+ policy branch that only exists for some results ("credits over $200 go to
257
+ `escalate_to_human`") is reached on purpose instead of by luck. Ids, dates
258
+ and people are re-drawn per call and a number moves by up to about a third
259
+ of itself (`900.0` lands in roughly 600 to 1200), so pick a template value
260
+ whose whole range sits on one side of the threshold and run the same pinned
261
+ tasks under one shape per side. `fault_plans={message: {tool: {"mode":
262
+ "timeout", "rate": 1.0}}}` replays a known fault schedule; `simulate()`
263
+ writes these from `fault_rate=`, so pass your own only to replay one.
264
+ `timeout=` is 300 s by default, enough for a served model that scaled to
265
+ zero to answer its first request; when a call still times out the run says
266
+ so in `data.warnings` with the fix.
231
267
 
232
268
  ```bash
233
269
  export OPENAI_API_KEY=...
@@ -527,6 +563,7 @@ Pass `spec=` if you have a local tools-and-system-prompt folder of your own: a d
527
563
  | `user_model` | `None` | Who plays the simulated user in follow-up turns. `None` is the agent's own model; an `openai:`/`vllm:` spec moves that job to another model |
528
564
  | `traces` | `None` | Graded traces of the deployed agent — a list of plain row dicts or a JSONL path. Aims the coverage grid at the behaviors those traces show and keeps the sources out of the generated rows. See [Close the loop](#close-the-loop-aim-the-budget-with-traces) |
529
565
  | `tasks` | `None` | Re-run a previous run's task set instead of drawing a new one: that run, its rows, or its JSONL path. k is **not** inherited — see [Same tasks, new prompt](#trust-the-numbers) |
566
+ | `timeout` | `300` | Seconds per agent completion, for `local_model` and every model spec. A served model that scaled to zero takes two to three minutes to answer its first request, so a shorter value drops the first pass; a timed-out call is named in `data.warnings` with the fix |
530
567
  | `logprobs` | `False` | Ask the rollout model for the log-probability of every token it generates. Each agent turn's step gets `logprob` and `n_tokens`, the row gets the totals. `"tokens"` keeps the per-token list. Model backends only |
531
568
  | `sampling` | `None` | How your own callable agent samples, `{"temperature": 0.7, "max_tokens": 1024, "model": "my-model"}`, recorded on every row as given. A model backend records its own and ignores this |
532
569
  | `reproducible` | `False` | Same seed, same concurrency, same agent: same rows. Runs batch by batch, so uneven latency costs throughput. Needs the clock off. `concurrency: 1` always runs this way |
@@ -902,7 +939,10 @@ rows, report = wai.write_rubrics(rows, domain="refunds") # per-prompt criteria
902
939
  scored = wai.run_judge(rows, wai.rubric_judge()) # a verdict per criterion; markers rubric:<item>
903
940
  clean, report = wai.decontaminate(
904
941
  train_rows, against=[eval_rows]
905
- ) # 8-gram overlap with the eval set
942
+ ) # same task id, verbatim, or 8-gram overlap with the eval set
943
+ clean, report = wai.decontaminate(
944
+ train_rows, against=[eval_rows], embedder=embed, similarity=0.85
945
+ ) # plus a semantic pass: n_semantic counted apart, a flag means "reads alike"
906
946
  wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy, answered
907
947
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
908
948
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
@@ -923,7 +963,7 @@ wai.grounding_report(rows) # grounded rate, and the invented values by tool and
923
963
 
924
964
  **Judge trust.** Label 50 to 100 rows by hand with `attach_labels` (0/1) -- `report["ok"]` means measured and clean, so with no labels it is `False` and the report says the judge is unmeasured rather than untrustworthy (`format_judge_trust` prints `NOT MEASURED`). The report gives agreement with a Wilson interval and Cohen's kappa, agreement on two task halves (tune the rubric on one, read the other), judge pass rate on short versus long replies within the same human label (length bias the humans rule out), and, with the judge callable, a re-judge of a sample as-is (consistency) and with neutral filler appended (a flip means the judge reads length). Disagreements come back as a review queue. `format_judge_trust(report)` prints it. `probes="all"` (or a list) tries the reward hacks a policy finds first on the judge on purpose: filler, the rubric's own words stuffed in, a claim of success with no evidence, the ask echoed back, a well-formed tool call with empty arguments, a sycophantic opener, a polite refusal. An additive probe is exploitable when failing replies start passing; a replacement probe when a reply with no content passes. `report["exploitable_by"]` names the holes at or over 10%, and a policy trained on this judge will find those same holes. Standalone: `wai.judge_probes(rows, judge, rubric=...)`. The gold set needs both passes and failures; with one class only the report says so and skips the kappa and length flags. With the hosted judge, call `wai.grade` once first (or `whileai.simulations.score.grade_llm.warm_judge`; it is not re-exported) so the cold start, two to three minutes, is not counted as timeouts.
925
965
 
926
- **Decontamination.** Word 8-gram overlap between a dataset's prompts and any evaluation source: row lists, JSONL paths, or platform dataset ids. A row is contaminated when it is an eval prompt verbatim or when one eval text covers at least 80% of its words (`overlap=`, the Llama 2 rule); one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question. Short prompts match verbatim only. `fields=("prompt", "final_text")` also checks replies against eval answers and references. The report separates verbatim hits from near copies and counts hits per field, and returns the clean rows with the first offenders.
966
+ **Decontamination.** Four rules between a dataset's rows and any evaluation source (row lists, JSONL paths, or platform dataset ids), each counted on its own and a row counted once. A row is contaminated when it shares a `scenario_id` or `task_id` with an eval row (`n_same_task`: a task is a situation, not a string, so a rephrasing of an eval situation is the eval situation), when it is an eval prompt verbatim (`n_exact`), or when one eval text covers at least 80% of its words in shared 8-grams (`n_near`; `overlap=`, the Llama 2 rule; one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question, and short prompts match verbatim only). `fields=("prompt", "final_text")` also checks replies against eval answers and references. Word overlap does not see a paraphrase: a holdout written by re-running the generator was 70% within 0.85 cosine of the training batch, and the 8-gram rule flagged 4 of its 101 prompts where a semantic pass flagged 16. Pass `embedder=` (any callable from a list of texts to one vector per text, so nothing is imported; with sentence-transformers, `embedder=lambda texts: model.encode(texts, normalize_embeddings=True).tolist()`) and rows whose prompt is within `similarity=` (0.85 cosine) of an eval prompt are flagged as `n_semantic`. That flag means the two prompts read alike, not that they are the same task: "cancel one reservation" and "cancel three reservations" for different customers score 0.93 with no shared answer. So the task-id rule decides first, the semantic pass only looks across different task ids, and `report["notes"]` says the flag is a question to check. The default stays lexical; the threshold was read off BGE (unrelated prompts score about 0.55 there) and needs picking for another model, so when the eval rows carry task ids the pass measures how alike distinct tasks read to your embedder (the 99th percentile of similarity over eval-prompt pairs with different task ids) and `notes` says it, and says when `similarity=` sits below it, since a threshold there flags tasks that merely share a domain. The report returns the clean rows with the first offenders, their coverage or similarity, and hits per field.
927
967
 
928
968
  **Intervals and comparison.** Every pass@1 carries a 95% interval from a bootstrap over tasks (`pass_at(rows).ci95`), and `metric_summary` / `marker_summary` do the same for markers. pass^k and pass@k carry their own (`pass_pow_k_ci95`, `pass_at_k_ci95`), a bootstrap over the k-eligible groups. Markers come from the judge: return `{"reward": ..., "markers": {"name": value}}` from a `grader=` or `run_judge` callable and they land on `row["markers"]`, which is what `marker_summary`, `delta_report` and `from_row` read. `compare_runs` pairs the tasks two runs share, bootstraps the paired difference, and adds a sign-flip permutation p-value; fewer than five shared tasks falls back to an unpaired test and says so. Tasks on one side only are dropped from a paired comparison; `note` says how many and `paired_share` is the fraction that paired, so a verdict over a quarter of the eval reads as one. The verdict `no_difference_detected` means the interval covers zero, not that the runs are equal. A task is a situation, not a string: every report (`pass_at`, `compare_runs`, `delta_report`, `eval_variance`, `curriculum`, `group_signal`, the exporters) groups rows by `wai.task_key(row)`, the engine's `scenario_id` when the row has one, so repeats and rephrasings of one situation count as one task and the same rows give the same task count everywhere. `pass_at(rows).config` and `delta_report(...)["config"]` say what the rows were produced with (temperature, reply budget, policy and judge versions), and `delta_report` warns when the two sides differ.
929
969
 
@@ -183,6 +183,18 @@ print(wai.pass_at(scored.rows), *scored.warnings) # a hollow run says so here
183
183
  `scored.warnings` is new: no rollout called a tool, a declared tool no
184
184
  rollout touched, a marker that fired on no row. A 1.00 on a run like that is not a result; the note names the fix.
185
185
 
186
+ A declared tool the world cannot answer is the quiet version of the same
187
+ failure: with `execute=`, a tool that is in the schema but has no branch in
188
+ your function fails exactly like a world fault, the agent reports the miss
189
+ honestly, and a candour rubric rewards the row. Every run now records calls
190
+ and successes per tool in `data.coverage["tools"]` (`n`, `ok`, `fault_n`, and
191
+ `injected` for faults the run scheduled itself), lists the tools that never
192
+ work in `data.coverage["dead_tools"]` (the Wilson 95% upper bound on the
193
+ success rate is under 0.30, so 0 of 9 or 4 of 612 is dead and 0 of 3 or 2 of
194
+ 5 is not), adds `dead_tools` to `data.degraded`, and puts the names and the
195
+ one fix that applies to your world in `data.warnings` and `data.report()`.
196
+ Steps with no recorded result are not evidence and never accuse a tool.
197
+
186
198
  ### Bring a model
187
199
 
188
200
  Bring your own model. Any OpenAI-compatible chat endpoint that returns tool
@@ -191,7 +203,31 @@ your key. To put a number on a model you serve (`wai.serve`, or your own
191
203
  vLLM), make it the agent: `wai.simulate(tasks=pinned,
192
204
  agent=wai.local_model(endpoint, name, tools=TOOLS, system=POLICY,
193
205
  thinking=False))`, and run both arms of a before/after through that same
194
- call so the only difference is the weights.
206
+ call so the only difference is the weights. `thinking=False` reaches the
207
+ simulated user as well when the agent's own model plays it (the default),
208
+ and whatever a user model still emits as `<think>` is stripped before it
209
+ becomes a user turn; the run reports those under
210
+ `data.search["user_think"]` (counts and shares of the user turns, zeros when
211
+ none) and says so in `data.warnings`. `delta_report` fails a before/after
212
+ whose arms differ in how often they answered at all (`"answered"` in
213
+ `not_comparable`, a two-proportion test at p < 0.01 and a gap over the
214
+ re-run band or 10 points), which is what a reasoning base against an
215
+ adapter trained on think-free targets does under one shared token budget:
216
+ set `thinking=` the same on both arms.
217
+
218
+ Two `local_model` knobs the situation writer cannot guess for you.
219
+ `result_shapes={tool_name: example_result}` pins what a tool returns, so a
220
+ policy branch that only exists for some results ("credits over $200 go to
221
+ `escalate_to_human`") is reached on purpose instead of by luck. Ids, dates
222
+ and people are re-drawn per call and a number moves by up to about a third
223
+ of itself (`900.0` lands in roughly 600 to 1200), so pick a template value
224
+ whose whole range sits on one side of the threshold and run the same pinned
225
+ tasks under one shape per side. `fault_plans={message: {tool: {"mode":
226
+ "timeout", "rate": 1.0}}}` replays a known fault schedule; `simulate()`
227
+ writes these from `fault_rate=`, so pass your own only to replay one.
228
+ `timeout=` is 300 s by default, enough for a served model that scaled to
229
+ zero to answer its first request; when a call still times out the run says
230
+ so in `data.warnings` with the fix.
195
231
 
196
232
  ```bash
197
233
  export OPENAI_API_KEY=...
@@ -491,6 +527,7 @@ Pass `spec=` if you have a local tools-and-system-prompt folder of your own: a d
491
527
  | `user_model` | `None` | Who plays the simulated user in follow-up turns. `None` is the agent's own model; an `openai:`/`vllm:` spec moves that job to another model |
492
528
  | `traces` | `None` | Graded traces of the deployed agent — a list of plain row dicts or a JSONL path. Aims the coverage grid at the behaviors those traces show and keeps the sources out of the generated rows. See [Close the loop](#close-the-loop-aim-the-budget-with-traces) |
493
529
  | `tasks` | `None` | Re-run a previous run's task set instead of drawing a new one: that run, its rows, or its JSONL path. k is **not** inherited — see [Same tasks, new prompt](#trust-the-numbers) |
530
+ | `timeout` | `300` | Seconds per agent completion, for `local_model` and every model spec. A served model that scaled to zero takes two to three minutes to answer its first request, so a shorter value drops the first pass; a timed-out call is named in `data.warnings` with the fix |
494
531
  | `logprobs` | `False` | Ask the rollout model for the log-probability of every token it generates. Each agent turn's step gets `logprob` and `n_tokens`, the row gets the totals. `"tokens"` keeps the per-token list. Model backends only |
495
532
  | `sampling` | `None` | How your own callable agent samples, `{"temperature": 0.7, "max_tokens": 1024, "model": "my-model"}`, recorded on every row as given. A model backend records its own and ignores this |
496
533
  | `reproducible` | `False` | Same seed, same concurrency, same agent: same rows. Runs batch by batch, so uneven latency costs throughput. Needs the clock off. `concurrency: 1` always runs this way |
@@ -866,7 +903,10 @@ rows, report = wai.write_rubrics(rows, domain="refunds") # per-prompt criteria
866
903
  scored = wai.run_judge(rows, wai.rubric_judge()) # a verdict per criterion; markers rubric:<item>
867
904
  clean, report = wai.decontaminate(
868
905
  train_rows, against=[eval_rows]
869
- ) # 8-gram overlap with the eval set
906
+ ) # same task id, verbatim, or 8-gram overlap with the eval set
907
+ clean, report = wai.decontaminate(
908
+ train_rows, against=[eval_rows], embedder=embed, similarity=0.85
909
+ ) # plus a semantic pass: n_semantic counted apart, a flag means "reads alike"
870
910
  wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy, answered
871
911
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
872
912
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
@@ -887,7 +927,7 @@ wai.grounding_report(rows) # grounded rate, and the invented values by tool and
887
927
 
888
928
  **Judge trust.** Label 50 to 100 rows by hand with `attach_labels` (0/1) -- `report["ok"]` means measured and clean, so with no labels it is `False` and the report says the judge is unmeasured rather than untrustworthy (`format_judge_trust` prints `NOT MEASURED`). The report gives agreement with a Wilson interval and Cohen's kappa, agreement on two task halves (tune the rubric on one, read the other), judge pass rate on short versus long replies within the same human label (length bias the humans rule out), and, with the judge callable, a re-judge of a sample as-is (consistency) and with neutral filler appended (a flip means the judge reads length). Disagreements come back as a review queue. `format_judge_trust(report)` prints it. `probes="all"` (or a list) tries the reward hacks a policy finds first on the judge on purpose: filler, the rubric's own words stuffed in, a claim of success with no evidence, the ask echoed back, a well-formed tool call with empty arguments, a sycophantic opener, a polite refusal. An additive probe is exploitable when failing replies start passing; a replacement probe when a reply with no content passes. `report["exploitable_by"]` names the holes at or over 10%, and a policy trained on this judge will find those same holes. Standalone: `wai.judge_probes(rows, judge, rubric=...)`. The gold set needs both passes and failures; with one class only the report says so and skips the kappa and length flags. With the hosted judge, call `wai.grade` once first (or `whileai.simulations.score.grade_llm.warm_judge`; it is not re-exported) so the cold start, two to three minutes, is not counted as timeouts.
889
929
 
890
- **Decontamination.** Word 8-gram overlap between a dataset's prompts and any evaluation source: row lists, JSONL paths, or platform dataset ids. A row is contaminated when it is an eval prompt verbatim or when one eval text covers at least 80% of its words (`overlap=`, the Llama 2 rule); one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question. Short prompts match verbatim only. `fields=("prompt", "final_text")` also checks replies against eval answers and references. The report separates verbatim hits from near copies and counts hits per field, and returns the clean rows with the first offenders.
930
+ **Decontamination.** Four rules between a dataset's rows and any evaluation source (row lists, JSONL paths, or platform dataset ids), each counted on its own and a row counted once. A row is contaminated when it shares a `scenario_id` or `task_id` with an eval row (`n_same_task`: a task is a situation, not a string, so a rephrasing of an eval situation is the eval situation), when it is an eval prompt verbatim (`n_exact`), or when one eval text covers at least 80% of its words in shared 8-grams (`n_near`; `overlap=`, the Llama 2 rule; one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question, and short prompts match verbatim only). `fields=("prompt", "final_text")` also checks replies against eval answers and references. Word overlap does not see a paraphrase: a holdout written by re-running the generator was 70% within 0.85 cosine of the training batch, and the 8-gram rule flagged 4 of its 101 prompts where a semantic pass flagged 16. Pass `embedder=` (any callable from a list of texts to one vector per text, so nothing is imported; with sentence-transformers, `embedder=lambda texts: model.encode(texts, normalize_embeddings=True).tolist()`) and rows whose prompt is within `similarity=` (0.85 cosine) of an eval prompt are flagged as `n_semantic`. That flag means the two prompts read alike, not that they are the same task: "cancel one reservation" and "cancel three reservations" for different customers score 0.93 with no shared answer. So the task-id rule decides first, the semantic pass only looks across different task ids, and `report["notes"]` says the flag is a question to check. The default stays lexical; the threshold was read off BGE (unrelated prompts score about 0.55 there) and needs picking for another model, so when the eval rows carry task ids the pass measures how alike distinct tasks read to your embedder (the 99th percentile of similarity over eval-prompt pairs with different task ids) and `notes` says it, and says when `similarity=` sits below it, since a threshold there flags tasks that merely share a domain. The report returns the clean rows with the first offenders, their coverage or similarity, and hits per field.
891
931
 
892
932
  **Intervals and comparison.** Every pass@1 carries a 95% interval from a bootstrap over tasks (`pass_at(rows).ci95`), and `metric_summary` / `marker_summary` do the same for markers. pass^k and pass@k carry their own (`pass_pow_k_ci95`, `pass_at_k_ci95`), a bootstrap over the k-eligible groups. Markers come from the judge: return `{"reward": ..., "markers": {"name": value}}` from a `grader=` or `run_judge` callable and they land on `row["markers"]`, which is what `marker_summary`, `delta_report` and `from_row` read. `compare_runs` pairs the tasks two runs share, bootstraps the paired difference, and adds a sign-flip permutation p-value; fewer than five shared tasks falls back to an unpaired test and says so. Tasks on one side only are dropped from a paired comparison; `note` says how many and `paired_share` is the fraction that paired, so a verdict over a quarter of the eval reads as one. The verdict `no_difference_detected` means the interval covers zero, not that the runs are equal. A task is a situation, not a string: every report (`pass_at`, `compare_runs`, `delta_report`, `eval_variance`, `curriculum`, `group_signal`, the exporters) groups rows by `wai.task_key(row)`, the engine's `scenario_id` when the row has one, so repeats and rephrasings of one situation count as one task and the same rows give the same task count everywhere. `pass_at(rows).config` and `delta_report(...)["config"]` say what the rows were produced with (temperature, reply budget, policy and judge versions), and `delta_report` warns when the two sides differ.
893
933
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.62"
7
+ version = "0.63"
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"}
@@ -80,11 +80,14 @@ from .ingest.traces import (
80
80
  drop_leaky_rows,
81
81
  flaw_rows,
82
82
  format_trace_report,
83
+ infer_harness,
83
84
  leakage_report,
84
85
  load_traces,
85
86
  mine_traces,
87
+ opening_share,
86
88
  simulate_from_traces,
87
89
  split_pseudo_production,
90
+ tools_from_traces,
88
91
  trace_report,
89
92
  )
90
93
  from .monitor import HackMonitor, format_hack_monitor
@@ -175,6 +178,7 @@ from .score.stats import (
175
178
  detectable_effect,
176
179
  eval_variance,
177
180
  holdout_size,
181
+ marker_names,
178
182
  marker_summary,
179
183
  metric_summary,
180
184
  task_key,
@@ -310,6 +314,7 @@ __all__ = [
310
314
  "holdout_size",
311
315
  "hosted_model",
312
316
  "import_hf",
317
+ "infer_harness",
313
318
  "inspect",
314
319
  "issue_delegated_credential",
315
320
  "judge_agreement",
@@ -329,6 +334,7 @@ __all__ = [
329
334
  "loss_mask",
330
335
  "mark_grounding",
331
336
  "mark_rows",
337
+ "marker_names",
332
338
  "marker_summary",
333
339
  "mean_kl",
334
340
  "metric_summary",
@@ -339,6 +345,7 @@ __all__ = [
339
345
  "normalize_judge_result",
340
346
  "novelty",
341
347
  "open_ended_probes",
348
+ "opening_share",
342
349
  "optimize",
343
350
  "outcome_check",
344
351
  "pairwise_judge",
@@ -393,6 +400,7 @@ __all__ = [
393
400
  "to_row",
394
401
  "to_trl",
395
402
  "tool_call_roundtrip",
403
+ "tools_from_traces",
396
404
  "trace_flag_report",
397
405
  "trace_flags",
398
406
  "trace_markers",
@@ -13,6 +13,16 @@ target model emits thinking tokens. ``training_rows`` closes that gap:
13
13
  * strips ``<think>`` blocks from assistant turns, so a thinking rollout
14
14
  model never teaches a non-thinking student to emit them.
15
15
 
16
+ The strip cuts the other way too. A reasoning base (Qwen3) trained on
17
+ think-free targets learns to print an empty ``<think></think>`` and
18
+ answer at once. That is fine until the eval, where that adapter meets
19
+ the untrained base under one shared ``max_tokens``: the base reasons and
20
+ runs out of budget, the adapter answers, and the delta is a win over
21
+ replies the base never produced (#297). ``delta_report`` fails that
22
+ comparison; the fix is ``thinking=`` set the same on both arms, or
23
+ ``strip_think=False`` when the student is a reasoning model and should
24
+ keep reasoning.
25
+
16
26
  Two wire shapes come out of here, and they are not the same shape:
17
27
 
18
28
  ``format="openai"`` (the default)
@@ -411,6 +421,14 @@ def training_rows(
411
421
  its policy trains an agent that never saw its rules. ``mask_mode``
412
422
  picks which assistant turns carry loss (see ``loss_mask``).
413
423
 
424
+ ``strip_think=True`` (the default) removes ``<think>`` blocks from the
425
+ assistant turns. On a reasoning base such as Qwen3 that teaches the
426
+ adapter to emit an empty ``<think></think>`` and answer at once, so
427
+ at eval it answers while the untrained base is still reasoning under
428
+ the same ``max_tokens`` (#297). Pass ``strip_think=False`` when the
429
+ student should keep reasoning, and set ``thinking=`` the same on both
430
+ arms of the eval either way.
431
+
414
432
  ``unroll=True`` turns an N-turn conversation into N samples, the
415
433
  k-th ending at the k-th assistant turn with loss on that turn only
416
434
  (rlhf-book ch. 4, multi-turn masking). Every earlier agent turn then
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
17
17
  from whileai._env import getenv
18
18
  from whileai.auth import SIGN_IN_URL
19
19
 
20
+ from ..text import split_reasoning
20
21
  from ..world.sandbox import MockEnvironment
21
22
  from .anthropic_backend import ANTHROPIC_BASE_URL, is_anthropic_url
22
23
  from .anthropic_backend import DEFAULT_MODEL as ANTHROPIC_DEFAULT_MODEL
@@ -780,8 +781,13 @@ def _strip_tool_markup(text: str) -> str:
780
781
  return cleaned.strip()
781
782
 
782
783
 
783
- _THINK_BLOCK = re.compile(r"<think>.*?</think>\s*", re.S | re.I)
784
- _THINK_OPEN = re.compile(r"<think>.*\Z", re.S | re.I)
784
+ #: A simulated-user turn that carried reasoning and left fewer spoken
785
+ #: characters than this was reasoning with no spoken line: the writer
786
+ #: thought and never typed, so the turn is retried, never emitted as a
787
+ #: fragment. The floor applies only to turns that carried reasoning; a
788
+ #: bare ``yes`` or ``order 4821`` with no ``<think>`` is a real user turn
789
+ #: and passes untouched (#284).
790
+ _MIN_SPOKEN = 25
785
791
 
786
792
 
787
793
  def _strip_think(text: str) -> str:
@@ -789,8 +795,23 @@ def _strip_think(text: str) -> str:
789
795
  an unclosed ``<think>`` (the token cap landed inside it) goes to the
790
796
  end. What is left is the reply, which is what a grader, a marker and
791
797
  the next turn's history should see (#264)."""
792
- text = _THINK_BLOCK.sub("", text)
793
- return _THINK_OPEN.sub("", text)
798
+ return split_reasoning(text)[0]
799
+
800
+
801
+ def _note_user_turn(turn_stats: dict | None, closed: int, unclosed: bool) -> None:
802
+ """One simulated-user reply came back: count the turn, and count it
803
+ again as stripped when it carried reasoning, and as unclosed when the
804
+ reasoning was cut off. The run reports counts and shares per arm
805
+ (#284: 18 unclosed on one arm, 0 on the other)."""
806
+ if not turn_stats:
807
+ return
808
+ lock = turn_stats.get("lock")
809
+ with lock if lock is not None else contextlib.nullcontext():
810
+ turn_stats["user_turns"] = turn_stats.get("user_turns", 0) + 1
811
+ if closed or unclosed:
812
+ turn_stats["user_think_stripped"] = turn_stats.get("user_think_stripped", 0) + 1
813
+ if unclosed:
814
+ turn_stats["user_think_unclosed"] = turn_stats.get("user_think_unclosed", 0) + 1
794
815
 
795
816
 
796
817
  def _spoken_text(reply: dict) -> str:
@@ -897,9 +918,21 @@ def _want_followup(
897
918
 
898
919
  A question or refusal earns an answer while the turn budget has
899
920
  room: the depth cap is ``budget // 2`` user turns (avg_turns=12
900
- allows 6, avg_turns=4 allows 2). After a completed action about
901
- half of people react or ask the next thing, when the thread has
902
- budget for it. Short-budget threads still end on the agent.
921
+ allows 6, avg_turns=4 allows 2). Otherwise the thread continues with
922
+ probability ``1 - 1/cap``, which makes the mean depth track the cap.
923
+ A budget under 4 still ends on the agent.
924
+
925
+ Before this, any reply that was not a question, a refusal, or a
926
+ recognised success ended the thread, and a completed action only
927
+ continued on a fixed coin flip. "Your reservation has been cancelled"
928
+ matches none of those, so threads died at one user turn and the mean
929
+ sat near 1.5 whatever ``avg_turns`` said: measured 1.54 at avg_turns=6
930
+ and 1.52 at avg_turns=10 on the same spec. That silently caps every
931
+ behaviour that needs three turns to happen at all. Confirm-before-
932
+ acting is the clearest case: the user asks, the agent names the action
933
+ and asks, the user says yes, the agent acts. At 1.5 user turns most
934
+ rollouts never reach the write, so the rule is never exercised and the
935
+ training set cannot demonstrate it.
903
936
  """
904
937
  cap = max(2, int(budget) // 2)
905
938
  if int(user_turns) >= cap:
@@ -909,10 +942,15 @@ def _want_followup(
909
942
  return True
910
943
  if _AGENT_REFUSAL.search(text):
911
944
  return True
912
- if int(budget) >= 4 and _AGENT_SUCCESS.search(text):
913
- digest = hashlib.sha256(f"{message}:{turn_i}:react".encode()).hexdigest()
914
- return int(digest[:8], 16) % 2 == 0
915
- return False
945
+ if int(budget) < 4:
946
+ # A short thread still ends on the agent: with room for one user line
947
+ # there is nothing a second one could be for.
948
+ return False
949
+ # Geometric with p = 1 - 1/cap: a thread of cap turns in expectation,
950
+ # deterministic in the message and turn so a seeded run reproduces.
951
+ digest = hashlib.sha256(f"{message}:{turn_i}:react".encode()).hexdigest()
952
+ draw = int(digest[:8], 16) / float(1 << 32)
953
+ return draw < (1.0 - 1.0 / float(cap))
916
954
 
917
955
 
918
956
  # Follow-up user turns only. Opener temperature lives on the writer (0.45–1.05).
@@ -1052,11 +1090,6 @@ _CONFIRM_ONLY = re.compile(
1052
1090
  re.I,
1053
1091
  )
1054
1092
  _AGENT_QUESTION = re.compile(r"\?\s*$|\b(which|what|who|where|can you|could you)\b", re.I)
1055
- _AGENT_SUCCESS = re.compile(
1056
- r"\b(done|created|opened|fixed|all set|i (have|'ve|just)|"
1057
- r"successfully|completed)\b",
1058
- re.I,
1059
- )
1060
1093
  _AGENT_REFUSAL = re.compile(
1061
1094
  r"\b(can't|cannot|won't|unable|not allowed|against (the )?(policy|rules?))\b",
1062
1095
  re.I,
@@ -1220,7 +1253,16 @@ def _user_followup(
1220
1253
  tools: list | None = None,
1221
1254
  force: bool = False,
1222
1255
  persona_tags: dict | None = None,
1256
+ extra: Mapping[str, Any] | None = None,
1257
+ turn_stats: dict | None = None,
1223
1258
  ) -> str:
1259
+ """The simulated user's next line, or ``""`` when the writer produced
1260
+ none worth keeping. ``extra`` carries the agent's request fields
1261
+ (``thinking=False`` on ``local_model``) so the user model is asked not
1262
+ to reason either; whatever it still emits as ``<think>`` is stripped
1263
+ before the words become a user turn, an unclosed block is dropped
1264
+ whole, and a turn that was reasoning with no spoken line is retried
1265
+ rather than emitted as a fragment (#284)."""
1224
1266
  from .generator import _realize_typed_message, _strip_directive_phrases, clean_user_message
1225
1267
 
1226
1268
  trace = _render_user_trace(messages, steps)
@@ -1275,10 +1317,14 @@ def _user_followup(
1275
1317
  # Floor, not ceiling: a 5s wait on a busy endpoint silently killed
1276
1318
  # every follow-up and collapsed whole datasets to single-turn.
1277
1319
  wait = max(8.0 if asked else 5.0, min(30.0, float(timeout or 30) / 2))
1278
- attempts = (
1320
+ attempts = list(
1279
1321
  (body, retry_body, retry_body) if force else ((body, retry_body) if asked else (body,))
1280
1322
  )
1281
- for attempt, content in enumerate(attempts):
1323
+ retried_for_reasoning = False
1324
+ attempt = 0
1325
+ while attempt < len(attempts):
1326
+ content = attempts[attempt]
1327
+ attempt += 1
1282
1328
  try:
1283
1329
  reply = complete(
1284
1330
  base_url,
@@ -1290,15 +1336,23 @@ def _user_followup(
1290
1336
  tools=None,
1291
1337
  api_key=api_key,
1292
1338
  temperature=(_RESPONSE_TEMP_LO + _RESPONSE_TEMP_HI) / 2,
1293
- max_tokens=120 if attempt else 180,
1339
+ max_tokens=120 if attempt > 1 else 180,
1294
1340
  timeout=wait,
1341
+ extra=extra,
1295
1342
  )
1296
1343
  except Exception:
1297
1344
  continue
1345
+ spoken, closed, unclosed = split_reasoning(reply.get("content") or "")
1346
+ _note_user_turn(turn_stats, closed, unclosed)
1347
+ if (closed or unclosed) and len(spoken.strip()) < _MIN_SPOKEN:
1348
+ # Reasoning and no spoken line. One more try with the short
1349
+ # prompt; never a fragment, never empty user speech.
1350
+ if not retried_for_reasoning:
1351
+ retried_for_reasoning = True
1352
+ attempts.append(retry_body)
1353
+ continue
1298
1354
  text = _scrub_ai_traces(
1299
- _realize_typed_message(
1300
- _strip_directive_phrases(clean_user_message(reply.get("content") or "")), tags
1301
- )
1355
+ _realize_typed_message(_strip_directive_phrases(clean_user_message(spoken)), tags)
1302
1356
  )
1303
1357
  if _accept_followup(text, prior, agent_text) and not _repeats_user_history(text, messages):
1304
1358
  return text
@@ -1350,6 +1404,8 @@ def _human_answer(
1350
1404
  api_key: str | None,
1351
1405
  timeout: float,
1352
1406
  stance: str = "",
1407
+ extra: Mapping[str, Any] | None = None,
1408
+ turn_stats: dict | None = None,
1353
1409
  ) -> str:
1354
1410
  """The simulated user answers the agent's question, in character.
1355
1411
 
@@ -1389,9 +1445,20 @@ def _human_answer(
1389
1445
  },
1390
1446
  ]
1391
1447
  reply = complete(
1392
- base_url, model, msgs, api_key=api_key, temperature=0.9, timeout=timeout, max_tokens=120
1448
+ base_url,
1449
+ model,
1450
+ msgs,
1451
+ api_key=api_key,
1452
+ temperature=0.9,
1453
+ timeout=timeout,
1454
+ max_tokens=120,
1455
+ extra=extra,
1393
1456
  )
1394
- return (_spoken_text(reply) or "").strip()
1457
+ spoken, closed, unclosed = split_reasoning(reply.get("content") or "")
1458
+ _note_user_turn(turn_stats, closed, unclosed)
1459
+ if (closed or unclosed) and len(spoken.strip()) < _MIN_SPOKEN:
1460
+ return ""
1461
+ return _strip_tool_markup(spoken).strip()
1395
1462
 
1396
1463
 
1397
1464
  def _answer_tool_call(env: Any, execute: Callable | None, tool: str, arguments: dict) -> dict:
@@ -1421,6 +1488,12 @@ def _answer_tool_call(env: Any, execute: Callable | None, tool: str, arguments:
1421
1488
  #: says otherwise. Recorded on every row under ``sampling`` (rlhf-book ch. 9:
1422
1489
  #: rejection sampling is run at 0.7 to 1.0; the row has to say what it was).
1423
1490
  LOCAL_MODEL_TEMPERATURE = 0.8
1491
+ # Seconds one completion may take. A served model that scaled to zero
1492
+ # takes two to three minutes to answer its first request (113 s measured
1493
+ # on the account's own endpoint, #302; the hosted judge is the same
1494
+ # shape), and the old 60 s dropped every rollout of the first pass and
1495
+ # returned an empty run that looked finished.
1496
+ LOCAL_MODEL_TIMEOUT = 300.0
1424
1497
 
1425
1498
 
1426
1499
  def reply_budget(max_tokens: int | None = None) -> int:
@@ -1451,7 +1524,7 @@ def local_model(
1451
1524
  opening_rate: float = 0.0,
1452
1525
  human_tools: set | None = None,
1453
1526
  execute: Callable | None = None,
1454
- timeout: float = 60,
1527
+ timeout: float = LOCAL_MODEL_TIMEOUT,
1455
1528
  max_tokens: int | None = None,
1456
1529
  user_model: str | None = None,
1457
1530
  thinking: bool | None = None,
@@ -1463,8 +1536,50 @@ def local_model(
1463
1536
  ``chat_template_kwargs={"enable_thinking": False}`` so the reply is
1464
1537
  the answer, not the reasoning, the way the hosted Qwen path already
1465
1538
  does; ``True`` asks for it; ``None`` (the default) sends nothing and
1466
- leaves the server's default. Either way ``<think>`` markup never
1467
- reaches ``step["text"]`` or ``final_text``.
1539
+ leaves the server's default. The same field goes to the simulated
1540
+ user when the agent's own model plays it (the default) or
1541
+ ``user_model`` sits on the same endpoint, so the customer is asked
1542
+ not to reason either; a ``user_model`` on another endpoint keeps
1543
+ that server's default. Either way ``<think>`` markup never reaches
1544
+ ``step["text"]``, ``final_text``, or a user turn (``step["user"]`` and
1545
+ the ``messages`` history): what the user model still emits as
1546
+ reasoning is stripped before it becomes speech, and a turn that was
1547
+ reasoning with no spoken line is retried, then dropped (#284). The run
1548
+ reports those under ``search["user_think"]``: ``user_turns``,
1549
+ ``stripped`` and ``unclosed`` as counts, ``stripped_share`` and
1550
+ ``unclosed_share`` as shares of the user turns, zeros when none.
1551
+
1552
+ ``result_shapes`` pins what a tool returns: ``{tool_name: example
1553
+ result dict}``. The sandbox fills the example on every call instead
1554
+ of inventing a record, so a policy branch that only exists for some
1555
+ tool results (a credit over $200 must be escalated) is reached on
1556
+ purpose rather than by luck. Field names and free text stay as
1557
+ written; ids, dates and people are re-drawn per call, and a number
1558
+ moves by up to about a third of itself (``900.0`` lands in roughly
1559
+ 600 to 1200, ``90.0`` in 60 to 120), so pick a template value whose
1560
+ whole range sits on the side of the threshold you want. An argument
1561
+ that shares a key with the template is echoed back (``invoice_id``
1562
+ in, same ``invoice_id`` out). To measure a branch, run the same
1563
+ pinned tasks under two shapes, one per side of the rule. Without it
1564
+ the situation writer drafts an example per tool (``write_result_shapes``)
1565
+ and the branch is exercised at random.
1566
+
1567
+ ``fault_plans`` schedules faults per ask: ``{message: {tool_name:
1568
+ {"mode": "timeout", "rate": 1.0}}}``, keyed by the exact user message,
1569
+ with ``mode`` one of ``timeout``, ``malformed``, ``stale`` or
1570
+ ``permission_denied`` and ``rate`` the chance the fault fires on a
1571
+ call. The plan may also carry ``world_state``, ``stance``, ``tone``
1572
+ and ``texture``, which are popped off and shape the world and the
1573
+ simulated user for that ask. ``simulate()`` writes these itself from
1574
+ ``fault_rate=``; pass your own only to replay a known plan (``tasks=``
1575
+ does this for you).
1576
+
1577
+ ``timeout`` is seconds per completion, ``LOCAL_MODEL_TIMEOUT`` (300)
1578
+ by default: a served model that scaled to zero takes two to three
1579
+ minutes to answer its first request, and a timeout under that drops
1580
+ every rollout of the first pass. When a call still times out the run
1581
+ says so in ``data.warnings`` with the fix (raise ``timeout=``, or send
1582
+ one throwaway request first so the endpoint is warm).
1468
1583
  """
1469
1584
  local = threading.local()
1470
1585
  plans = fault_plans if fault_plans is not None else {}
@@ -1479,6 +1594,9 @@ def local_model(
1479
1594
  user_key: str | None = None
1480
1595
  else:
1481
1596
  user_url, user_name, user_key = base_url, model, api_key
1597
+ # thinking= reaches the user simulator on the agent's own endpoint.
1598
+ # Another server has its own template fields, so it keeps its default.
1599
+ user_extras = extras if str(user_url).rstrip("/") == str(base_url).rstrip("/") else None
1482
1600
  shapes = result_shapes if result_shapes is not None else {}
1483
1601
  cap = default_max_turns(n_tools=len(tools)) if max_turns is None else max(1, int(max_turns))
1484
1602
  min_users = max(1, min(int(min_user_turns), max(1, cap // 2)))
@@ -1590,6 +1708,8 @@ def local_model(
1590
1708
  api_key=user_key,
1591
1709
  timeout=timeout,
1592
1710
  stance=stance,
1711
+ extra=user_extras,
1712
+ turn_stats=turn_stats,
1593
1713
  )
1594
1714
  # an empty answer used to default to "go ahead", which
1595
1715
  # silently taught the agent that asking always clears
@@ -1682,6 +1802,8 @@ def local_model(
1682
1802
  persona_tags=persona_tags,
1683
1803
  tools=tools,
1684
1804
  force=force_followup,
1805
+ extra=user_extras,
1806
+ turn_stats=turn_stats,
1685
1807
  )
1686
1808
  if follow:
1687
1809
  last_user = follow