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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- 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
|