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
turnloop/core/events.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Streaming events.
|
|
2
|
+
|
|
3
|
+
One event union covers model output, provider status, and tool progress, so the
|
|
4
|
+
TUI needs exactly one inbound pathway. In particular a 29-minute GLM cold boot
|
|
5
|
+
reports progress through `ProviderStatus` on the same channel as tokens.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Literal, TypeAlias
|
|
12
|
+
|
|
13
|
+
from turnloop.core.messages import Message, Usage
|
|
14
|
+
|
|
15
|
+
# --- model output ----------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class TextDelta:
|
|
20
|
+
text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class ThinkingDelta:
|
|
25
|
+
text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ToolUseStart:
|
|
30
|
+
index: int
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ToolUseArgsDelta:
|
|
37
|
+
"""A fragment of the JSON argument string.
|
|
38
|
+
|
|
39
|
+
Both Anthropic (`input_json_delta`) and OpenAI-compatible endpoints stream
|
|
40
|
+
tool arguments as unparseable partial JSON. Fragments are accumulated per
|
|
41
|
+
`index` and parsed only once the block closes.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
index: int
|
|
45
|
+
fragment: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class MessageDone:
|
|
50
|
+
message: Message
|
|
51
|
+
usage: Usage
|
|
52
|
+
stop_reason: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- provider / transport status -------------------------------------------
|
|
56
|
+
|
|
57
|
+
StatusPhase: TypeAlias = Literal["connecting", "cold_boot", "retrying", "ready", "streaming"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class ProviderStatus:
|
|
62
|
+
text: str
|
|
63
|
+
phase: StatusPhase = "connecting"
|
|
64
|
+
elapsed_s: float = 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- tool execution -------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class ToolStarted:
|
|
72
|
+
tool_use_id: str
|
|
73
|
+
name: str
|
|
74
|
+
summary: str # one-line human description, e.g. "Read src/app.py"
|
|
75
|
+
subagent_id: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class ToolProgress:
|
|
80
|
+
"""Incremental output from a running tool (bash lines, subagent turns)."""
|
|
81
|
+
|
|
82
|
+
tool_use_id: str
|
|
83
|
+
text: str
|
|
84
|
+
subagent_id: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class ToolFinished:
|
|
89
|
+
tool_use_id: str
|
|
90
|
+
name: str
|
|
91
|
+
is_error: bool
|
|
92
|
+
display: str | None = None
|
|
93
|
+
duration_s: float = 0.0
|
|
94
|
+
subagent_id: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- session-level UI updates ---------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class StatusUpdate:
|
|
102
|
+
"""Pushed after every request so the status bar never computes anything."""
|
|
103
|
+
|
|
104
|
+
context_tokens: int = 0
|
|
105
|
+
context_max: int = 0
|
|
106
|
+
cost_usd: float = 0.0
|
|
107
|
+
usage: Usage = field(default_factory=Usage)
|
|
108
|
+
gpu_seconds: float | None = None # self-hosted, wall-clock-billed providers
|
|
109
|
+
note: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(slots=True)
|
|
113
|
+
class TurnStarted:
|
|
114
|
+
turn: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
118
|
+
class TurnFinished:
|
|
119
|
+
turn: int
|
|
120
|
+
stop_reason: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(slots=True)
|
|
124
|
+
class CompactionHappened:
|
|
125
|
+
# "hard" is the last-resort tier: a single message larger than the whole
|
|
126
|
+
# window, truncated in place because no cut point can help.
|
|
127
|
+
tier: Literal["micro", "thinking", "full", "hard"]
|
|
128
|
+
tokens_before: int
|
|
129
|
+
tokens_after: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
StreamEvent: TypeAlias = (
|
|
133
|
+
TextDelta | ThinkingDelta | ToolUseStart | ToolUseArgsDelta | MessageDone | ProviderStatus
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
UIEvent: TypeAlias = (
|
|
137
|
+
StreamEvent
|
|
138
|
+
| ToolStarted
|
|
139
|
+
| ToolProgress
|
|
140
|
+
| ToolFinished
|
|
141
|
+
| StatusUpdate
|
|
142
|
+
| TurnStarted
|
|
143
|
+
| TurnFinished
|
|
144
|
+
| CompactionHappened
|
|
145
|
+
)
|
turnloop/core/ids.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Identifier generation.
|
|
2
|
+
|
|
3
|
+
Indirected through a module-level counter so golden-transcript tests can make
|
|
4
|
+
ids deterministic by calling `use_deterministic_ids()`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
_counter: itertools.count | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def new_id(prefix: str) -> str:
|
|
16
|
+
if _counter is not None:
|
|
17
|
+
return f"{prefix}_{next(_counter):08d}"
|
|
18
|
+
return f"{prefix}_{uuid4().hex[:16]}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def use_deterministic_ids(start: int = 1) -> None:
|
|
22
|
+
"""Make new_id() emit prefix_00000001, prefix_00000002, ... (tests only)."""
|
|
23
|
+
global _counter
|
|
24
|
+
_counter = itertools.count(start)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def use_random_ids() -> None:
|
|
28
|
+
global _counter
|
|
29
|
+
_counter = None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The internal message model.
|
|
2
|
+
|
|
3
|
+
Deliberately Anthropic-shaped: content blocks with an explicit `thinking` type
|
|
4
|
+
and tool results carrying an `is_error` flag. That shape is a strict superset of
|
|
5
|
+
what OpenAI-compatible and Gemini endpoints express, so every adapter is
|
|
6
|
+
lossy-on-the-way-in and lossless-on-the-way-out rather than the reverse.
|
|
7
|
+
|
|
8
|
+
Nothing in this module knows about HTTP or about any provider.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Annotated, Literal, TypeAlias
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
StopReason: TypeAlias = Literal[
|
|
18
|
+
"end_turn", # model finished talking
|
|
19
|
+
"tool_use", # model wants tools run; the loop continues
|
|
20
|
+
"max_tokens", # output cap hit mid-sentence
|
|
21
|
+
"stop_sequence",
|
|
22
|
+
"refusal",
|
|
23
|
+
"error", # our own marker for a truncated/failed stream
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Usage(BaseModel):
|
|
28
|
+
input_tokens: int = 0
|
|
29
|
+
output_tokens: int = 0
|
|
30
|
+
cache_read_tokens: int = 0
|
|
31
|
+
cache_write_tokens: int = 0
|
|
32
|
+
|
|
33
|
+
def __add__(self, other: Usage) -> Usage:
|
|
34
|
+
return Usage(
|
|
35
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
36
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
37
|
+
cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
|
|
38
|
+
cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total(self) -> int:
|
|
43
|
+
return self.input_tokens + self.output_tokens
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TextBlock(BaseModel):
|
|
47
|
+
type: Literal["text"] = "text"
|
|
48
|
+
text: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ThinkingBlock(BaseModel):
|
|
52
|
+
"""Reasoning output.
|
|
53
|
+
|
|
54
|
+
`signature` is Anthropic's cryptographic signature over the thinking text;
|
|
55
|
+
it must be echoed verbatim if the block is ever sent back. Endpoints that
|
|
56
|
+
surface reasoning as plain text (GLM's `reasoning_content`, Groq) have no
|
|
57
|
+
signature, so those blocks are display/trace only and are never resent —
|
|
58
|
+
see providers/openai_compat.py.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
type: Literal["thinking"] = "thinking"
|
|
62
|
+
text: str = ""
|
|
63
|
+
signature: str | None = None
|
|
64
|
+
provider: str | None = None # provenance, e.g. "glm:reasoning_content"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def resendable(self) -> bool:
|
|
68
|
+
return self.signature is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToolUseBlock(BaseModel):
|
|
72
|
+
type: Literal["tool_use"] = "tool_use"
|
|
73
|
+
id: str
|
|
74
|
+
name: str
|
|
75
|
+
args: dict = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolResultBlock(BaseModel):
|
|
79
|
+
type: Literal["tool_result"] = "tool_result"
|
|
80
|
+
tool_use_id: str
|
|
81
|
+
content: str = ""
|
|
82
|
+
is_error: bool = False
|
|
83
|
+
# Rich rendering for the TUI. Never crosses the wire — keeping it out of
|
|
84
|
+
# `content` is what lets a tool show a colored diff to the human while
|
|
85
|
+
# sending the model a plain summary.
|
|
86
|
+
display: str | None = Field(default=None, exclude=False)
|
|
87
|
+
truncated: bool = False # set by micro-compaction so it only happens once
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
ContentBlock: TypeAlias = Annotated[
|
|
91
|
+
TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock,
|
|
92
|
+
Field(discriminator="type"),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Message(BaseModel):
|
|
97
|
+
role: Literal["user", "assistant"]
|
|
98
|
+
content: list[ContentBlock] = Field(default_factory=list)
|
|
99
|
+
usage: Usage | None = None
|
|
100
|
+
stop_reason: StopReason | None = None
|
|
101
|
+
# provider, model, latency_ms, ttft_ms, compaction markers, subagent_id
|
|
102
|
+
meta: dict = Field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
# --- convenience accessors used all over the loop -----------------------
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def text(self) -> str:
|
|
108
|
+
return "".join(b.text for b in self.content if isinstance(b, TextBlock))
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def tool_uses(self) -> list[ToolUseBlock]:
|
|
112
|
+
return [b for b in self.content if isinstance(b, ToolUseBlock)]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def tool_results(self) -> list[ToolResultBlock]:
|
|
116
|
+
return [b for b in self.content if isinstance(b, ToolResultBlock)]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def thinking(self) -> list[ThinkingBlock]:
|
|
120
|
+
return [b for b in self.content if isinstance(b, ThinkingBlock)]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_empty(self) -> bool:
|
|
124
|
+
return not any(
|
|
125
|
+
(isinstance(b, TextBlock | ThinkingBlock) and b.text.strip())
|
|
126
|
+
or isinstance(b, ToolUseBlock | ToolResultBlock)
|
|
127
|
+
for b in self.content
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def user_text(cls, text: str, **meta) -> Message:
|
|
132
|
+
return cls(role="user", content=[TextBlock(text=text)], meta=meta)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def assistant_text(cls, text: str, **meta) -> Message:
|
|
136
|
+
return cls(role="assistant", content=[TextBlock(text=text)], meta=meta)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ToolSpec(BaseModel):
|
|
140
|
+
"""A tool as presented to a model. Built from a Tool class by the registry."""
|
|
141
|
+
|
|
142
|
+
name: str
|
|
143
|
+
description: str
|
|
144
|
+
input_schema: dict
|
|
145
|
+
|
|
146
|
+
def to_anthropic(self) -> dict:
|
|
147
|
+
return {
|
|
148
|
+
"name": self.name,
|
|
149
|
+
"description": self.description,
|
|
150
|
+
"input_schema": self.input_schema,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def to_openai(self) -> dict:
|
|
154
|
+
return {
|
|
155
|
+
"type": "function",
|
|
156
|
+
"function": {
|
|
157
|
+
"name": self.name,
|
|
158
|
+
"description": self.description,
|
|
159
|
+
"parameters": self.input_schema,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
def to_gemini(self) -> dict:
|
|
164
|
+
return {
|
|
165
|
+
"name": self.name,
|
|
166
|
+
"description": self.description,
|
|
167
|
+
"parameters": strip_unsupported_schema(self.input_schema),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_GEMINI_UNSUPPORTED = {
|
|
172
|
+
"additionalProperties",
|
|
173
|
+
"$schema",
|
|
174
|
+
"$defs",
|
|
175
|
+
"definitions",
|
|
176
|
+
"discriminator",
|
|
177
|
+
"exclusiveMinimum",
|
|
178
|
+
"exclusiveMaximum",
|
|
179
|
+
"const",
|
|
180
|
+
"examples",
|
|
181
|
+
"default",
|
|
182
|
+
"title",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def strip_unsupported_schema(schema: dict, defs: dict | None = None) -> dict:
|
|
187
|
+
"""Inline $refs and drop keywords Gemini's function-declaration parser rejects.
|
|
188
|
+
|
|
189
|
+
Gemini returns a 400 for a schema containing `$defs`/`additionalProperties`,
|
|
190
|
+
which pydantic emits routinely for nested models (e.g. Edit's list[EditOp]).
|
|
191
|
+
"""
|
|
192
|
+
defs = defs or schema.get("$defs") or schema.get("definitions") or {}
|
|
193
|
+
out: dict = {}
|
|
194
|
+
for key, value in schema.items():
|
|
195
|
+
if key == "$ref":
|
|
196
|
+
target = str(value).rsplit("/", 1)[-1]
|
|
197
|
+
resolved = defs.get(target, {})
|
|
198
|
+
out.update(strip_unsupported_schema(resolved, defs))
|
|
199
|
+
continue
|
|
200
|
+
if key in _GEMINI_UNSUPPORTED:
|
|
201
|
+
continue
|
|
202
|
+
if key == "anyOf":
|
|
203
|
+
# Gemini has no union type. Take the first non-null branch; the
|
|
204
|
+
# description is expected to carry the nuance.
|
|
205
|
+
branches = [b for b in value if b.get("type") != "null"]
|
|
206
|
+
if branches:
|
|
207
|
+
out.update(strip_unsupported_schema(branches[0], defs))
|
|
208
|
+
continue
|
|
209
|
+
if isinstance(value, dict):
|
|
210
|
+
out[key] = strip_unsupported_schema(value, defs)
|
|
211
|
+
elif isinstance(value, list):
|
|
212
|
+
out[key] = [
|
|
213
|
+
strip_unsupported_schema(v, defs) if isinstance(v, dict) else v for v in value
|
|
214
|
+
]
|
|
215
|
+
else:
|
|
216
|
+
out[key] = value
|
|
217
|
+
return out
|
turnloop/core/tokens.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Token estimation without a tokenizer dependency.
|
|
2
|
+
|
|
3
|
+
Why no tiktoken: it is wrong for GLM and Gemini anyway, it is a 2 MB+ download
|
|
4
|
+
with a vocab fetch at first use, and every provider reports exact usage in its
|
|
5
|
+
response. So: estimate cheaply, then calibrate the estimator against reported
|
|
6
|
+
usage with an EWMA. It converges within two turns and costs nothing.
|
|
7
|
+
|
|
8
|
+
This matters concretely. A flat 4-chars-per-token guess is ~20% off on GLM's
|
|
9
|
+
tokenizer, and 20% of a 65k window is 13k tokens — either wasted headroom or a
|
|
10
|
+
context-overflow 400 mid-session.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from turnloop.core.messages import (
|
|
16
|
+
ContentBlock,
|
|
17
|
+
Message,
|
|
18
|
+
TextBlock,
|
|
19
|
+
ThinkingBlock,
|
|
20
|
+
ToolResultBlock,
|
|
21
|
+
ToolSpec,
|
|
22
|
+
ToolUseBlock,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Code and JSON tokenize denser than prose. Measured against several tokenizers
|
|
26
|
+
# on mixed source-code corpora this sits closer than the usual 4.0.
|
|
27
|
+
CHARS_PER_TOKEN = 3.6
|
|
28
|
+
|
|
29
|
+
# Fixed overhead the wire format adds per message and per tool-call envelope.
|
|
30
|
+
PER_MESSAGE_OVERHEAD = 4
|
|
31
|
+
PER_TOOL_CALL_OVERHEAD = 12
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rough_tokens(text: str) -> int:
|
|
35
|
+
if not text:
|
|
36
|
+
return 0
|
|
37
|
+
return max(1, int(len(text) / CHARS_PER_TOKEN))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def block_tokens(block: ContentBlock) -> int:
|
|
41
|
+
if isinstance(block, TextBlock | ThinkingBlock):
|
|
42
|
+
return rough_tokens(block.text)
|
|
43
|
+
if isinstance(block, ToolUseBlock):
|
|
44
|
+
import json
|
|
45
|
+
|
|
46
|
+
return rough_tokens(block.name) + rough_tokens(json.dumps(block.args)) + PER_TOOL_CALL_OVERHEAD
|
|
47
|
+
if isinstance(block, ToolResultBlock):
|
|
48
|
+
return rough_tokens(block.content) + PER_TOOL_CALL_OVERHEAD
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def message_tokens(message: Message) -> int:
|
|
53
|
+
return PER_MESSAGE_OVERHEAD + sum(block_tokens(b) for b in message.content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def history_tokens(messages: list[Message]) -> int:
|
|
57
|
+
return sum(message_tokens(m) for m in messages)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tool_spec_tokens(specs: list[ToolSpec]) -> int:
|
|
61
|
+
import json
|
|
62
|
+
|
|
63
|
+
return sum(
|
|
64
|
+
rough_tokens(s.name) + rough_tokens(s.description) + rough_tokens(json.dumps(s.input_schema))
|
|
65
|
+
for s in specs
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TokenEstimator:
|
|
70
|
+
"""A self-calibrating estimator, one instance per provider.
|
|
71
|
+
|
|
72
|
+
`observe()` is called with the estimate that was made for a request and the
|
|
73
|
+
input_tokens the provider actually reported. The ratio is folded into a
|
|
74
|
+
correction factor with an EWMA, so `estimate()` tightens over a session.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, alpha: float = 0.4):
|
|
78
|
+
self.alpha = alpha
|
|
79
|
+
self.factor = 1.0
|
|
80
|
+
self.samples = 0
|
|
81
|
+
|
|
82
|
+
def estimate(self, raw: int) -> int:
|
|
83
|
+
return max(1, int(raw * self.factor))
|
|
84
|
+
|
|
85
|
+
def observe(self, estimated_raw: int, actual: int) -> None:
|
|
86
|
+
if estimated_raw <= 0 or actual <= 0:
|
|
87
|
+
return
|
|
88
|
+
observed = actual / estimated_raw
|
|
89
|
+
# Guard against a wild first sample (e.g. a cache-heavy request where
|
|
90
|
+
# reported input_tokens excludes cache reads).
|
|
91
|
+
if not 0.25 <= observed <= 4.0:
|
|
92
|
+
return
|
|
93
|
+
if self.samples == 0:
|
|
94
|
+
self.factor = observed
|
|
95
|
+
else:
|
|
96
|
+
self.factor = (1 - self.alpha) * self.factor + self.alpha * observed
|
|
97
|
+
self.samples += 1
|
|
98
|
+
|
|
99
|
+
def estimate_request(
|
|
100
|
+
self, messages: list[Message], system: str | list[str], specs: list[ToolSpec]
|
|
101
|
+
) -> int:
|
|
102
|
+
system_text = system if isinstance(system, str) else "\n".join(system)
|
|
103
|
+
raw = history_tokens(messages) + rough_tokens(system_text) + tool_spec_tokens(specs)
|
|
104
|
+
return self.estimate(raw)
|