turnloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,217 @@
1
+ """Google Gemini adapter (generativelanguage v1beta).
2
+
3
+ Three things differ enough from the other two APIs to be worth naming:
4
+
5
+ * Roles are "user"/"model", and the system prompt is a separate
6
+ `systemInstruction` field rather than a message.
7
+ * Tool results are `functionResponse` parts and must be matched to calls **by
8
+ function name**, not by id — Gemini issues no call ids at all. So the adapter
9
+ keeps a name lookup from the preceding model turn.
10
+ * Function declarations reject the JSON-Schema keywords pydantic emits by
11
+ default; `ToolSpec.to_gemini()` strips them (see core/messages.py).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncIterator
17
+
18
+ import httpx
19
+
20
+ from turnloop.core.events import (
21
+ MessageDone,
22
+ StreamEvent,
23
+ TextDelta,
24
+ ThinkingDelta,
25
+ ToolUseStart,
26
+ )
27
+ from turnloop.core.ids import new_id
28
+ from turnloop.core.messages import (
29
+ ContentBlock,
30
+ Message,
31
+ TextBlock,
32
+ ThinkingBlock,
33
+ ToolResultBlock,
34
+ ToolUseBlock,
35
+ Usage,
36
+ )
37
+ from turnloop.providers.base import (
38
+ CompletionRequest,
39
+ Provider,
40
+ classify_error,
41
+ raise_for_class,
42
+ )
43
+ from turnloop.providers.openai_compat import _brief
44
+ from turnloop.providers.sse import iter_sse
45
+
46
+ _STOP_REASONS = {
47
+ "STOP": "end_turn",
48
+ "MAX_TOKENS": "max_tokens",
49
+ "SAFETY": "refusal",
50
+ "RECITATION": "refusal",
51
+ "PROHIBITED_CONTENT": "refusal",
52
+ }
53
+
54
+
55
+ class GeminiProvider(Provider):
56
+ kind = "gemini"
57
+
58
+ def __init__(self, *args, **kwargs):
59
+ super().__init__(*args, **kwargs)
60
+ self.base_url = self.base_url or "https://generativelanguage.googleapis.com/v1beta"
61
+
62
+ def headers(self) -> dict[str, str]:
63
+ h = super().headers()
64
+ if self.api_key:
65
+ h["x-goog-api-key"] = self.api_key
66
+ return h
67
+
68
+ # --- request construction ---------------------------------------------
69
+
70
+ def build_payload(self, req: CompletionRequest) -> dict:
71
+ payload: dict = {
72
+ "contents": self.to_wire_contents(req.messages),
73
+ "generationConfig": {
74
+ "maxOutputTokens": req.max_tokens or self.caps.max_output,
75
+ },
76
+ }
77
+ if req.temperature is not None:
78
+ payload["generationConfig"]["temperature"] = req.temperature
79
+ if req.stop_sequences:
80
+ payload["generationConfig"]["stopSequences"] = req.stop_sequences
81
+ if system := "\n\n".join(s for s in req.system if s.strip()):
82
+ payload["systemInstruction"] = {"parts": [{"text": system}]}
83
+ if req.tools:
84
+ payload["tools"] = [{"functionDeclarations": [t.to_gemini() for t in req.tools]}]
85
+ if req.tool_choice != "auto":
86
+ mode = {"none": "NONE", "required": "ANY"}[req.tool_choice]
87
+ payload["toolConfig"] = {"functionCallingConfig": {"mode": mode}}
88
+ return payload
89
+
90
+ def to_wire_contents(self, messages: list[Message]) -> list[dict]:
91
+ contents: list[dict] = []
92
+ # tool_use_id -> function name, so results can be labeled correctly.
93
+ call_names: dict[str, str] = {}
94
+
95
+ for msg in messages:
96
+ parts: list[dict] = []
97
+ for block in msg.content:
98
+ if isinstance(block, TextBlock):
99
+ if block.text:
100
+ parts.append({"text": block.text})
101
+ elif isinstance(block, ToolUseBlock):
102
+ call_names[block.id] = block.name
103
+ parts.append({"functionCall": {"name": block.name, "args": block.args}})
104
+ elif isinstance(block, ToolResultBlock):
105
+ name = call_names.get(block.tool_use_id, "unknown_tool")
106
+ content = (
107
+ f"Error: {block.content}" if block.is_error else block.content
108
+ ) or "(no output)"
109
+ parts.append(
110
+ {
111
+ "functionResponse": {
112
+ "name": name,
113
+ "response": {"output": content},
114
+ }
115
+ }
116
+ )
117
+ # ThinkingBlocks are not resendable to Gemini.
118
+ if parts:
119
+ contents.append({"role": "user" if msg.role == "user" else "model", "parts": parts})
120
+ return contents
121
+
122
+ # --- streaming --------------------------------------------------------
123
+
124
+ async def _stream_once(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
125
+ url = f"{self.base_url}/models/{self.model}:streamGenerateContent?alt=sse"
126
+ payload = self.build_payload(req)
127
+ bytes_seen = 0
128
+ acc = _Accumulator()
129
+
130
+ try:
131
+ async with self.client.stream(
132
+ "POST", url, json=payload, headers=self.headers()
133
+ ) as resp:
134
+ if resp.status_code >= 400:
135
+ body = (await resp.aread()).decode("utf-8", errors="replace")
136
+ raise_for_class(
137
+ classify_error(None, resp),
138
+ f"{self.name}: HTTP {resp.status_code} {_brief(body)}",
139
+ status=resp.status_code,
140
+ body=body,
141
+ )
142
+ async for frame in iter_sse(resp.aiter_lines()):
143
+ bytes_seen += len(frame.data)
144
+ chunk = frame.json()
145
+ if chunk is None:
146
+ continue
147
+ if err := chunk.get("error"):
148
+ raise_for_class("fatal", f"{self.name}: {err.get('message', err)}")
149
+ for event in acc.feed(chunk):
150
+ yield event
151
+ except (httpx.HTTPError, httpx.StreamError) as exc:
152
+ kind = classify_error(exc, None, bytes_seen)
153
+ raise_for_class(
154
+ "fatal" if kind == "fatal" else "retryable",
155
+ f"{self.name}: {type(exc).__name__}: {exc}",
156
+ )
157
+
158
+ yield acc.finish(self.name, self.model)
159
+
160
+
161
+ class _Accumulator:
162
+ def __init__(self):
163
+ self.text: list[str] = []
164
+ self.thinking: list[str] = []
165
+ self.calls: list[ToolUseBlock] = []
166
+ self.stop_reason: str | None = None
167
+ self.usage = Usage()
168
+
169
+ def feed(self, chunk: dict) -> list[StreamEvent]:
170
+ events: list[StreamEvent] = []
171
+
172
+ if meta := chunk.get("usageMetadata"):
173
+ self.usage = Usage(
174
+ input_tokens=meta.get("promptTokenCount", 0) or 0,
175
+ output_tokens=(meta.get("candidatesTokenCount", 0) or 0)
176
+ + (meta.get("thoughtsTokenCount", 0) or 0),
177
+ cache_read_tokens=meta.get("cachedContentTokenCount", 0) or 0,
178
+ )
179
+
180
+ for cand in chunk.get("candidates") or []:
181
+ for part in (cand.get("content") or {}).get("parts") or []:
182
+ if part.get("thought") and part.get("text"):
183
+ self.thinking.append(part["text"])
184
+ events.append(ThinkingDelta(part["text"]))
185
+ elif text := part.get("text"):
186
+ self.text.append(text)
187
+ events.append(TextDelta(text))
188
+ elif call := part.get("functionCall"):
189
+ block = ToolUseBlock(
190
+ id=new_id("call"), name=call.get("name", ""), args=call.get("args") or {}
191
+ )
192
+ self.calls.append(block)
193
+ events.append(
194
+ ToolUseStart(index=len(self.calls) - 1, id=block.id, name=block.name)
195
+ )
196
+ if fr := cand.get("finishReason"):
197
+ self.stop_reason = _STOP_REASONS.get(fr, "end_turn")
198
+
199
+ return events
200
+
201
+ def finish(self, provider: str, model: str) -> MessageDone:
202
+ content: list[ContentBlock] = []
203
+ if thinking := "".join(self.thinking):
204
+ content.append(ThinkingBlock(text=thinking, provider=f"{provider}:thought"))
205
+ if text := "".join(self.text):
206
+ content.append(TextBlock(text=text))
207
+ content.extend(self.calls)
208
+
209
+ stop = "tool_use" if self.calls else (self.stop_reason or "end_turn")
210
+ msg = Message(
211
+ role="assistant",
212
+ content=content,
213
+ usage=self.usage,
214
+ stop_reason=stop, # type: ignore[arg-type]
215
+ meta={"provider": provider, "model": model},
216
+ )
217
+ return MessageDone(message=msg, usage=self.usage, stop_reason=stop)
@@ -0,0 +1,226 @@
1
+ """Offline provider with three modes.
2
+
3
+ This is not a stub — it is load-bearing infrastructure:
4
+
5
+ * `scripted` — a fixed list of assistant turns. Powers golden-transcript tests
6
+ and lets the whole TUI be developed without touching a network.
7
+ * `replay` — re-emits the assistant messages of a previously recorded session
8
+ JSONL, so the loop, compaction and graders can be exercised against realistic
9
+ model behavior at zero cost.
10
+ * `chaos` — deliberately emits malformed tool arguments, truncated JSON,
11
+ unknown tool names and empty turns at configured rates. This is how the
12
+ tool-calling-reliability measurement gets tested, and how the loop's
13
+ never-raise guarantee is proven rather than asserted.
14
+
15
+ Default provider for the whole project, so a fresh clone runs instantly and a
16
+ first run cannot accidentally boot a $18.16/hr GPU.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import random
23
+ from collections.abc import AsyncIterator
24
+ from pathlib import Path
25
+ from typing import Any, Literal
26
+
27
+ from turnloop.core.events import (
28
+ MessageDone,
29
+ StreamEvent,
30
+ TextDelta,
31
+ ThinkingDelta,
32
+ ToolUseArgsDelta,
33
+ ToolUseStart,
34
+ )
35
+ from turnloop.core.ids import new_id
36
+ from turnloop.core.messages import (
37
+ ContentBlock,
38
+ Message,
39
+ TextBlock,
40
+ ThinkingBlock,
41
+ ToolUseBlock,
42
+ Usage,
43
+ )
44
+ from turnloop.core.tokens import history_tokens, rough_tokens, tool_spec_tokens
45
+ from turnloop.providers.base import Capabilities, CompletionRequest, Provider
46
+
47
+ MockMode = Literal["scripted", "replay", "chaos", "echo"]
48
+
49
+
50
+ class ScriptTurn:
51
+ """One canned assistant response.
52
+
53
+ `tools` entries are (name, args) pairs. Args may be a dict (normal) or a raw
54
+ string (to simulate a model emitting invalid JSON).
55
+ """
56
+
57
+ def __init__(self, text: str = "", thinking: str = "",
58
+ tools: list[tuple[str, Any]] | None = None,
59
+ stop_reason: str | None = None):
60
+ self.text = text
61
+ self.thinking = thinking
62
+ self.tools = tools or []
63
+ self.stop_reason = stop_reason or ("tool_use" if self.tools else "end_turn")
64
+
65
+
66
+ class MockProvider(Provider):
67
+ kind = "mock"
68
+
69
+ def __init__(self, *args, mode: MockMode = "scripted",
70
+ script: list[ScriptTurn] | None = None,
71
+ replay_path: str | Path | None = None,
72
+ chaos_rate: float = 0.3, seed: int = 0,
73
+ chunk_size: int = 24, **kwargs):
74
+ kwargs.setdefault("caps", Capabilities(max_context=32_000, max_output=4_096))
75
+ kwargs.setdefault("name", "mock")
76
+ kwargs.setdefault("model", "mock-1")
77
+ super().__init__(*args, **kwargs)
78
+ self.mode = mode
79
+ self.script = list(script or [])
80
+ self.chaos_rate = chaos_rate
81
+ self.chunk_size = chunk_size
82
+ self.rng = random.Random(seed)
83
+ self.calls = 0 # how many requests this provider has served
84
+ self.requests: list[CompletionRequest] = [] # inspected by tests
85
+ self.replay_turns: list[Message] = (
86
+ _load_replay(Path(replay_path)) if replay_path else []
87
+ )
88
+
89
+ # Health checks and network are never involved.
90
+ async def preflight(self) -> AsyncIterator[StreamEvent]: # type: ignore[override]
91
+ return
92
+ yield # pragma: no cover - makes this an async generator
93
+
94
+ async def _stream_once(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
95
+ self.requests.append(req)
96
+ turn = self._next_turn(req)
97
+ self.calls += 1
98
+
99
+ blocks: list[ContentBlock] = []
100
+
101
+ if turn.thinking:
102
+ for piece in _chunks(turn.thinking, self.chunk_size):
103
+ yield ThinkingDelta(piece)
104
+ blocks.append(ThinkingBlock(text=turn.thinking, provider="mock:reasoning"))
105
+
106
+ if turn.text:
107
+ for piece in _chunks(turn.text, self.chunk_size):
108
+ yield TextDelta(piece)
109
+ blocks.append(TextBlock(text=turn.text))
110
+
111
+ for index, (name, raw_args) in enumerate(turn.tools):
112
+ call_id = new_id("call")
113
+ yield ToolUseStart(index=index, id=call_id, name=name)
114
+ serialized = raw_args if isinstance(raw_args, str) else json.dumps(raw_args)
115
+ for piece in _chunks(serialized, self.chunk_size):
116
+ yield ToolUseArgsDelta(index=index, fragment=piece)
117
+ args = raw_args if isinstance(raw_args, dict) else _try_parse(serialized)
118
+ blocks.append(ToolUseBlock(id=call_id, name=name, args=args))
119
+
120
+ # Count the tool schemas too. A real endpoint bills for them, and omitting
121
+ # them here made reported usage ~5x smaller than the estimate, which the
122
+ # estimator's sanity guard then (correctly) rejected as implausible.
123
+ usage = Usage(
124
+ input_tokens=(
125
+ history_tokens(req.messages)
126
+ + rough_tokens("\n".join(req.system))
127
+ + tool_spec_tokens(req.tools)
128
+ ),
129
+ output_tokens=rough_tokens(turn.text + turn.thinking) + 8 * len(turn.tools),
130
+ )
131
+ message = Message(
132
+ role="assistant",
133
+ content=blocks,
134
+ usage=usage,
135
+ stop_reason=turn.stop_reason, # type: ignore[arg-type]
136
+ meta={"provider": self.name, "model": self.model, "mock_mode": self.mode},
137
+ )
138
+ yield MessageDone(message=message, usage=usage, stop_reason=turn.stop_reason)
139
+
140
+ # --- turn selection ---------------------------------------------------
141
+
142
+ def _next_turn(self, req: CompletionRequest) -> ScriptTurn:
143
+ if self.mode == "scripted":
144
+ if self.calls < len(self.script):
145
+ return self.script[self.calls]
146
+ # Running off the end of a script means the loop asked for one more
147
+ # turn than the test expected. Ending the turn is the safe answer;
148
+ # a test asserting call counts will still catch the discrepancy.
149
+ return ScriptTurn(text="(script exhausted)")
150
+
151
+ if self.mode == "replay":
152
+ if self.calls < len(self.replay_turns):
153
+ return _turn_from_message(self.replay_turns[self.calls])
154
+ return ScriptTurn(text="(replay exhausted)")
155
+
156
+ if self.mode == "echo":
157
+ last = req.messages[-1].text if req.messages else ""
158
+ return ScriptTurn(text=f"echo: {last[:500]}")
159
+
160
+ return self._chaos_turn(req)
161
+
162
+ def _chaos_turn(self, req: CompletionRequest) -> ScriptTurn:
163
+ """Emit a deliberately broken turn with probability chaos_rate."""
164
+ available = [t.name for t in req.tools] or ["Read"]
165
+ if self.rng.random() >= self.chaos_rate:
166
+ # A well-formed call, so a chaos run still makes progress.
167
+ return ScriptTurn(
168
+ text="Looking at that now.",
169
+ tools=[(self.rng.choice(available), {"file_path": "README.md"})],
170
+ )
171
+
172
+ failure = self.rng.choice(
173
+ ["bad_json", "wrong_schema", "unknown_tool", "empty", "truncated_json"]
174
+ )
175
+ if failure == "bad_json":
176
+ return ScriptTurn(tools=[(self.rng.choice(available), "{not json at all")])
177
+ if failure == "truncated_json":
178
+ return ScriptTurn(tools=[(self.rng.choice(available), '{"file_path": "READ')])
179
+ if failure == "wrong_schema":
180
+ return ScriptTurn(tools=[(self.rng.choice(available), {"nonexistent_field": 1})])
181
+ if failure == "unknown_tool":
182
+ return ScriptTurn(tools=[("NoSuchTool", {"x": 1})])
183
+ return ScriptTurn(text="", stop_reason="end_turn")
184
+
185
+
186
+ def _chunks(text: str, size: int) -> list[str]:
187
+ return [text[i : i + size] for i in range(0, len(text), size)] or []
188
+
189
+
190
+ def _try_parse(raw: str) -> dict:
191
+ from turnloop.providers.openai_compat import _parse_args
192
+
193
+ return _parse_args(raw)
194
+
195
+
196
+ def _turn_from_message(msg: Message) -> ScriptTurn:
197
+ return ScriptTurn(
198
+ text=msg.text,
199
+ thinking="".join(b.text for b in msg.thinking),
200
+ tools=[(b.name, b.args) for b in msg.tool_uses],
201
+ stop_reason=msg.stop_reason or None,
202
+ )
203
+
204
+
205
+ def _load_replay(path: Path) -> list[Message]:
206
+ """Pull assistant messages out of a recorded session JSONL."""
207
+ turns: list[Message] = []
208
+ if not path.exists():
209
+ return turns
210
+ for line in path.read_text(encoding="utf-8").splitlines():
211
+ if not line.strip():
212
+ continue
213
+ try:
214
+ record = json.loads(line)
215
+ except json.JSONDecodeError:
216
+ continue
217
+ if record.get("kind") != "message":
218
+ continue
219
+ payload = record.get("payload") or {}
220
+ if payload.get("role") != "assistant":
221
+ continue
222
+ try:
223
+ turns.append(Message.model_validate(payload))
224
+ except Exception: # noqa: BLE001 - a malformed line should not kill a replay
225
+ continue
226
+ return turns