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/agent/loop.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""The agent loop.
|
|
2
|
+
|
|
3
|
+
compact if needed -> stream a turn -> if it asked for tools, run them and
|
|
4
|
+
append the results -> repeat -> stop when it stops asking for tools
|
|
5
|
+
|
|
6
|
+
That is genuinely all it is. The value is in what surrounds it, and the four
|
|
7
|
+
decisions worth defending here:
|
|
8
|
+
|
|
9
|
+
1. **Compaction runs before every request**, not on a timer. Pressure is a
|
|
10
|
+
property of what just happened, and one large tool result can cross two
|
|
11
|
+
thresholds in a single turn.
|
|
12
|
+
|
|
13
|
+
2. **Parallel tool execution only when every tool is `parallel_safe` and none
|
|
14
|
+
needs a permission prompt.** Two concurrent Edits on one file, or two modals
|
|
15
|
+
racing for the same screen, are both worse outcomes than being slow.
|
|
16
|
+
|
|
17
|
+
3. **A failed stream never leaves a partial assistant message in history.** A
|
|
18
|
+
truncated turn is discarded whole, because an assistant message containing a
|
|
19
|
+
`tool_use` with no matching `tool_result` is a hard 400 from every provider —
|
|
20
|
+
and it would poison every subsequent request in the session, not just this one.
|
|
21
|
+
|
|
22
|
+
4. **The iteration cap injects a message rather than returning silently.** A loop
|
|
23
|
+
that stops at 40 turns with no explanation looks like a crash; one that says so
|
|
24
|
+
lets the model wrap up.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import anyio
|
|
34
|
+
|
|
35
|
+
from turnloop.config import Settings
|
|
36
|
+
from turnloop.core.events import (
|
|
37
|
+
MessageDone,
|
|
38
|
+
ProviderStatus,
|
|
39
|
+
StatusUpdate,
|
|
40
|
+
TextDelta,
|
|
41
|
+
ThinkingDelta,
|
|
42
|
+
TurnFinished,
|
|
43
|
+
TurnStarted,
|
|
44
|
+
)
|
|
45
|
+
from turnloop.core.messages import ContentBlock, Message, TextBlock, ToolResultBlock, ToolUseBlock
|
|
46
|
+
from turnloop.errors import FatalProviderError, ProviderError
|
|
47
|
+
from turnloop.permissions.engine import PermissionEngine, Verdict
|
|
48
|
+
from turnloop.providers.base import CompletionRequest, Provider
|
|
49
|
+
from turnloop.sessions.models import Session
|
|
50
|
+
from turnloop.tools.base import FileTracker, ToolContext, ToolRegistry
|
|
51
|
+
from turnloop.tools.runner import ToolRunner
|
|
52
|
+
from turnloop.tui.bridge import UIChannel
|
|
53
|
+
|
|
54
|
+
ITERATION_LIMIT_NOTE = (
|
|
55
|
+
"You have reached this session's tool-call limit for one request. Stop calling "
|
|
56
|
+
"tools and summarize where things stand: what you completed, what remains, and "
|
|
57
|
+
"the exact next step."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AgentLoop:
|
|
63
|
+
provider: Provider
|
|
64
|
+
registry: ToolRegistry
|
|
65
|
+
session: Session
|
|
66
|
+
permissions: PermissionEngine
|
|
67
|
+
settings: Settings
|
|
68
|
+
ui: UIChannel
|
|
69
|
+
cwd: Path | None = None
|
|
70
|
+
compactor: object | None = None
|
|
71
|
+
hooks: object | None = None
|
|
72
|
+
system: list[str] = field(default_factory=list)
|
|
73
|
+
files: FileTracker = field(default_factory=FileTracker)
|
|
74
|
+
depth: int = 0
|
|
75
|
+
subagent_id: str | None = None
|
|
76
|
+
limiter: anyio.CapacityLimiter | None = None
|
|
77
|
+
|
|
78
|
+
runner: ToolRunner = field(init=False)
|
|
79
|
+
interrupted: bool = field(default=False, init=False)
|
|
80
|
+
_shell_cwd: Path | None = field(default=None, init=False)
|
|
81
|
+
_extras: dict = field(default_factory=dict, init=False)
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
self.cwd = Path(self.cwd) if self.cwd else Path.cwd()
|
|
85
|
+
self.runner = ToolRunner(
|
|
86
|
+
registry=self.registry,
|
|
87
|
+
permissions=self.permissions,
|
|
88
|
+
hooks=self.hooks,
|
|
89
|
+
ui=self.ui,
|
|
90
|
+
)
|
|
91
|
+
if self.limiter is None:
|
|
92
|
+
self.limiter = anyio.CapacityLimiter(
|
|
93
|
+
max(1, self.provider.caps.max_concurrent_requests)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# --- public API --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async def run_turn(self, user_input: str | list[ContentBlock]) -> Message | None:
|
|
99
|
+
"""Run one user turn to completion, including all tool iterations."""
|
|
100
|
+
self.session.turn += 1
|
|
101
|
+
await self.ui.send(TurnStarted(self.session.turn))
|
|
102
|
+
|
|
103
|
+
if self.hooks is not None and isinstance(user_input, str):
|
|
104
|
+
user_input = await self._apply_prompt_hooks(user_input)
|
|
105
|
+
|
|
106
|
+
content: list[ContentBlock] = (
|
|
107
|
+
[TextBlock(text=user_input)] if isinstance(user_input, str) else list(user_input)
|
|
108
|
+
)
|
|
109
|
+
self.session.append(Message(role="user", content=content))
|
|
110
|
+
|
|
111
|
+
last: Message | None = None
|
|
112
|
+
stop_reason = "end_turn"
|
|
113
|
+
recovered_tool_names = False # the correction below is offered once per turn
|
|
114
|
+
|
|
115
|
+
for iteration in range(self.settings.max_iterations + 1):
|
|
116
|
+
if iteration == self.settings.max_iterations:
|
|
117
|
+
self.session.append(Message.user_text(ITERATION_LIMIT_NOTE, limit_reached=True))
|
|
118
|
+
|
|
119
|
+
await self._compact_if_needed()
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
last = await self._stream_once()
|
|
123
|
+
except FatalProviderError as exc:
|
|
124
|
+
# Some servers validate tool calls before we ever see them: Groq
|
|
125
|
+
# rejects a miscased name ("glob") with a 400, so the model's mistake
|
|
126
|
+
# arrives as a fatal provider error rather than as a tool result it
|
|
127
|
+
# could learn from. Hand it the valid names once and let it retry —
|
|
128
|
+
# otherwise a recoverable slip kills the whole turn.
|
|
129
|
+
if self._is_tool_validation_error(exc) and not recovered_tool_names:
|
|
130
|
+
recovered_tool_names = True
|
|
131
|
+
await self._emit_error(f"recovering from a rejected tool call: {exc}")
|
|
132
|
+
self.session.append(
|
|
133
|
+
Message.user_text(
|
|
134
|
+
"The provider rejected your last tool call because the name did "
|
|
135
|
+
"not match a defined tool. Tool names are case-sensitive. The "
|
|
136
|
+
f"available tools are exactly: {', '.join(self.registry.names())}. "
|
|
137
|
+
"Retry using one of those names verbatim.",
|
|
138
|
+
tool_name_recovery=True,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
await self._emit_error(f"provider error: {exc}")
|
|
143
|
+
raise
|
|
144
|
+
except ProviderError as exc:
|
|
145
|
+
await self._emit_error(f"provider error after retries: {exc}")
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
if last is None: # interrupted
|
|
149
|
+
stop_reason = "interrupted"
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
stop_reason = last.stop_reason or "end_turn"
|
|
153
|
+
await self._push_status()
|
|
154
|
+
|
|
155
|
+
if stop_reason != "tool_use":
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
tool_uses = last.tool_uses
|
|
159
|
+
if not tool_uses:
|
|
160
|
+
break # claimed tool_use but emitted none; treat as done
|
|
161
|
+
|
|
162
|
+
results = await self._execute_tools(tool_uses)
|
|
163
|
+
self.session.append(Message(role="user", content=list(results)))
|
|
164
|
+
|
|
165
|
+
if iteration == self.settings.max_iterations:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
await self.ui.send(TurnFinished(self.session.turn, stop_reason))
|
|
169
|
+
if self.hooks is not None:
|
|
170
|
+
await self.hooks.run_stop(self.session, stop_reason) # type: ignore[attr-defined]
|
|
171
|
+
return last
|
|
172
|
+
|
|
173
|
+
# --- one model request -------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async def _stream_once(self) -> Message | None:
|
|
176
|
+
"""Stream one assistant message, forwarding events as they arrive.
|
|
177
|
+
|
|
178
|
+
Returns None if the user interrupted. The partial message is deliberately
|
|
179
|
+
discarded: keeping it would risk an unmatched tool_use in history.
|
|
180
|
+
"""
|
|
181
|
+
request = self._build_request()
|
|
182
|
+
started = time.monotonic()
|
|
183
|
+
ttft: float | None = None
|
|
184
|
+
done: MessageDone | None = None
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
async for event in self.provider.stream(request):
|
|
188
|
+
if isinstance(event, TextDelta | ThinkingDelta) and ttft is None:
|
|
189
|
+
ttft = time.monotonic() - started
|
|
190
|
+
if isinstance(event, MessageDone):
|
|
191
|
+
done = event
|
|
192
|
+
continue
|
|
193
|
+
await self.ui.send(event)
|
|
194
|
+
except anyio.get_cancelled_exc_class():
|
|
195
|
+
self.interrupted = True
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
if done is None:
|
|
199
|
+
# Stream ended with no terminal event — the endpoint died mid-flight.
|
|
200
|
+
# On the GLM deployment this is the hourly container recycle.
|
|
201
|
+
from turnloop.errors import StreamTruncated
|
|
202
|
+
|
|
203
|
+
raise StreamTruncated(
|
|
204
|
+
f"{self.provider.name}: the stream ended without completing. "
|
|
205
|
+
"The endpoint may have been recycled; retrying will re-run the health check."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
message = done.message
|
|
209
|
+
message.meta.update(
|
|
210
|
+
{
|
|
211
|
+
"latency_ms": int((time.monotonic() - started) * 1000),
|
|
212
|
+
"ttft_ms": int(ttft * 1000) if ttft else None,
|
|
213
|
+
"turn": self.session.turn,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
if self.subagent_id:
|
|
217
|
+
message.meta["subagent_id"] = self.subagent_id
|
|
218
|
+
|
|
219
|
+
self._account(message, request)
|
|
220
|
+
self.session.append(message)
|
|
221
|
+
return message
|
|
222
|
+
|
|
223
|
+
def _build_request(self) -> CompletionRequest:
|
|
224
|
+
caps = self.provider.caps
|
|
225
|
+
verbosity = self.settings.verbosity_for(self.provider.name)
|
|
226
|
+
return CompletionRequest(
|
|
227
|
+
messages=self.session.messages,
|
|
228
|
+
system=self.system,
|
|
229
|
+
tools=self.registry.specs(verbosity),
|
|
230
|
+
max_tokens=caps.max_output,
|
|
231
|
+
temperature=0.0,
|
|
232
|
+
thinking_tokens=(
|
|
233
|
+
self.settings.max_thinking_tokens if caps.native_thinking else None
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _account(self, message: Message, request: CompletionRequest) -> None:
|
|
238
|
+
usage = message.usage
|
|
239
|
+
if usage is None:
|
|
240
|
+
return
|
|
241
|
+
caps = self.provider.caps
|
|
242
|
+
cost = caps.cost_for(
|
|
243
|
+
usage.input_tokens, usage.output_tokens,
|
|
244
|
+
usage.cache_read_tokens, usage.cache_write_tokens,
|
|
245
|
+
)
|
|
246
|
+
self.session.cost.add(self.provider.name, usage, cost)
|
|
247
|
+
|
|
248
|
+
# Feed the real input_tokens back into the estimator so the context gauge
|
|
249
|
+
# and the compaction thresholds converge on this model's tokenizer.
|
|
250
|
+
estimated = self.provider.estimator.estimate_request(
|
|
251
|
+
request.messages, request.system, request.tools
|
|
252
|
+
)
|
|
253
|
+
if usage.input_tokens:
|
|
254
|
+
self.provider.estimator.observe(estimated, usage.input_tokens)
|
|
255
|
+
|
|
256
|
+
store = getattr(self.session, "store", None)
|
|
257
|
+
if store is not None:
|
|
258
|
+
store.write_record(
|
|
259
|
+
"usage",
|
|
260
|
+
{
|
|
261
|
+
"provider": self.provider.name,
|
|
262
|
+
"model": self.provider.model,
|
|
263
|
+
"cost_usd": cost,
|
|
264
|
+
"latency_ms": message.meta.get("latency_ms"),
|
|
265
|
+
"ttft_ms": message.meta.get("ttft_ms"),
|
|
266
|
+
**usage.model_dump(),
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# --- tool execution ----------------------------------------------------
|
|
271
|
+
|
|
272
|
+
async def _execute_tools(self, blocks: list[ToolUseBlock]) -> list[ToolResultBlock]:
|
|
273
|
+
"""Run the requested tools, in parallel only when that is clearly safe."""
|
|
274
|
+
if not self.provider.caps.supports_parallel_tool_calls and len(blocks) > 1:
|
|
275
|
+
# The model cannot represent several results in one turn; answer the
|
|
276
|
+
# first and let it ask again.
|
|
277
|
+
blocks = blocks[:1]
|
|
278
|
+
|
|
279
|
+
if len(blocks) > 1 and self._safe_to_parallelize(blocks):
|
|
280
|
+
results: dict[str, ToolResultBlock] = {}
|
|
281
|
+
|
|
282
|
+
async def run_one(block: ToolUseBlock) -> None:
|
|
283
|
+
results[block.id] = await self.runner.dispatch(block, self._tool_context())
|
|
284
|
+
|
|
285
|
+
async with anyio.create_task_group() as tg:
|
|
286
|
+
for block in blocks:
|
|
287
|
+
tg.start_soon(run_one, block)
|
|
288
|
+
|
|
289
|
+
# Restore the model's ordering: Gemini in particular rejects results
|
|
290
|
+
# that do not line up with the calls it made.
|
|
291
|
+
return [results[b.id] for b in blocks if b.id in results]
|
|
292
|
+
|
|
293
|
+
return [await self.runner.dispatch(block, self._tool_context()) for block in blocks]
|
|
294
|
+
|
|
295
|
+
def _safe_to_parallelize(self, blocks: list[ToolUseBlock]) -> bool:
|
|
296
|
+
for block in blocks:
|
|
297
|
+
tool = self.registry.get(block.name)
|
|
298
|
+
if tool is None or not tool.parallel_safe:
|
|
299
|
+
return False
|
|
300
|
+
try:
|
|
301
|
+
args = tool.Args.model_validate(block.args)
|
|
302
|
+
except Exception: # noqa: BLE001 - let dispatch produce the error result
|
|
303
|
+
return False
|
|
304
|
+
# A pending prompt would put two modals on screen at once.
|
|
305
|
+
if self.permissions.check(tool, args).verdict is Verdict.ASK:
|
|
306
|
+
return False
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
def _tool_context(self) -> ToolContext:
|
|
310
|
+
return ToolContext(
|
|
311
|
+
cwd=self.cwd or Path.cwd(),
|
|
312
|
+
settings=self.settings,
|
|
313
|
+
session=self.session,
|
|
314
|
+
permissions=self.permissions,
|
|
315
|
+
emit=self.ui.send,
|
|
316
|
+
ask=self.ui.ask,
|
|
317
|
+
files=self.files,
|
|
318
|
+
depth=self.depth,
|
|
319
|
+
readonly=self.settings.permission_mode == "plan",
|
|
320
|
+
subagent_id=self.subagent_id,
|
|
321
|
+
shell_cwd=self._shell_cwd,
|
|
322
|
+
extras=self._extras,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# --- context management ------------------------------------------------
|
|
326
|
+
|
|
327
|
+
async def _compact_if_needed(self) -> None:
|
|
328
|
+
if self.compactor is None:
|
|
329
|
+
return
|
|
330
|
+
from turnloop.context.budget import Budget, output_reserve
|
|
331
|
+
|
|
332
|
+
budget = Budget.build(
|
|
333
|
+
self.provider.caps.max_context,
|
|
334
|
+
output_reserve(
|
|
335
|
+
self.settings.compaction.reserve_output_tokens, self.provider.caps.max_output
|
|
336
|
+
),
|
|
337
|
+
self.system,
|
|
338
|
+
self.registry.specs(self.settings.verbosity_for(self.provider.name)),
|
|
339
|
+
)
|
|
340
|
+
await self.compactor.maybe_compact(self.session, budget.available) # type: ignore[attr-defined]
|
|
341
|
+
|
|
342
|
+
async def _push_status(self) -> None:
|
|
343
|
+
from turnloop.context.budget import Budget, measure, output_reserve
|
|
344
|
+
|
|
345
|
+
budget = Budget.build(
|
|
346
|
+
self.provider.caps.max_context,
|
|
347
|
+
output_reserve(
|
|
348
|
+
self.settings.compaction.reserve_output_tokens, self.provider.caps.max_output
|
|
349
|
+
),
|
|
350
|
+
self.system,
|
|
351
|
+
self.registry.specs(self.settings.verbosity_for(self.provider.name)),
|
|
352
|
+
)
|
|
353
|
+
await self.ui.send(
|
|
354
|
+
StatusUpdate(
|
|
355
|
+
context_tokens=measure(self.session.messages),
|
|
356
|
+
context_max=budget.available,
|
|
357
|
+
cost_usd=self.session.cost.cost_usd,
|
|
358
|
+
usage=self.session.cost.usage,
|
|
359
|
+
gpu_seconds=self.provider.gpu_seconds(),
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# --- hooks / errors ----------------------------------------------------
|
|
364
|
+
|
|
365
|
+
async def _apply_prompt_hooks(self, prompt: str) -> str:
|
|
366
|
+
outcome = await self.hooks.run_user_prompt(prompt, self.session) # type: ignore[attr-defined]
|
|
367
|
+
if outcome is None:
|
|
368
|
+
return prompt
|
|
369
|
+
if outcome.blocked:
|
|
370
|
+
return f"{prompt}\n\n[a hook blocked this prompt: {outcome.reason}]"
|
|
371
|
+
if outcome.additional_context:
|
|
372
|
+
return f"{prompt}\n\n{outcome.additional_context}"
|
|
373
|
+
return prompt
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _is_tool_validation_error(exc: FatalProviderError) -> bool:
|
|
377
|
+
haystack = f"{exc} {exc.body or ''}".lower()
|
|
378
|
+
return "tool call validation" in haystack or "was not in request.tools" in haystack
|
|
379
|
+
|
|
380
|
+
async def _emit_error(self, message: str) -> None:
|
|
381
|
+
store = getattr(self.session, "store", None)
|
|
382
|
+
if store is not None:
|
|
383
|
+
store.write_record("error", {"message": message})
|
|
384
|
+
await self.ui.send(ProviderStatus(message, phase="retrying"))
|
|
385
|
+
|
|
386
|
+
# --- cleanup -----------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
async def aclose(self) -> None:
|
|
389
|
+
"""Kill background processes and close the provider's connections."""
|
|
390
|
+
for entry in list(self._extras.get("background", {}).values()):
|
|
391
|
+
process = entry.get("process")
|
|
392
|
+
if process is not None and process.poll() is None:
|
|
393
|
+
from turnloop.tools.shell import kill_tree
|
|
394
|
+
|
|
395
|
+
kill_tree(process.pid)
|
|
396
|
+
await self.provider.aclose()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Subagent execution.
|
|
2
|
+
|
|
3
|
+
The point of a subagent is not parallelism — it is that the parent pays for the
|
|
4
|
+
*answer* instead of the search. A child that reads twenty files and reports three
|
|
5
|
+
line numbers costs the parent three line numbers.
|
|
6
|
+
|
|
7
|
+
Isolation is achieved structurally rather than by discipline:
|
|
8
|
+
|
|
9
|
+
* a fresh `Session` (its records are still written to the parent's log, tagged, so
|
|
10
|
+
the trace stays complete for analysis)
|
|
11
|
+
* a filtered `ToolRegistry` with no `Task` and no `AskUserQuestion` — a child that
|
|
12
|
+
could spawn children makes cost unbounded, and one that could open the parent's
|
|
13
|
+
modal is a deadlock
|
|
14
|
+
* `depth=1`, checked before spawning
|
|
15
|
+
* **the same `PermissionEngine` instance.** Permissions belong to the user, not to
|
|
16
|
+
an agent. Giving a child its own engine would make delegation a privilege
|
|
17
|
+
escalation path, and would also lose the session grants the user already gave.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from turnloop.agent.loop import AgentLoop
|
|
26
|
+
from turnloop.agent.system_prompt import SUBAGENT_PROMPTS, build_system
|
|
27
|
+
from turnloop.config import Settings
|
|
28
|
+
from turnloop.core.ids import new_id
|
|
29
|
+
from turnloop.core.tokens import rough_tokens
|
|
30
|
+
from turnloop.permissions.engine import PermissionEngine
|
|
31
|
+
from turnloop.providers.base import Provider
|
|
32
|
+
from turnloop.sessions.models import Session
|
|
33
|
+
from turnloop.tools.base import ToolRegistry
|
|
34
|
+
from turnloop.tui.bridge import UIChannel
|
|
35
|
+
|
|
36
|
+
# Tools a subagent never gets, regardless of its type.
|
|
37
|
+
FORBIDDEN_FOR_SUBAGENTS = ("Task", "AskUserQuestion")
|
|
38
|
+
|
|
39
|
+
RESULT_TOKEN_CAP = 2_000
|
|
40
|
+
SMALL_WINDOW_RESULT_CAP = 1_000
|
|
41
|
+
|
|
42
|
+
CONDENSE_PROMPT = (
|
|
43
|
+
"Your answer is too long to return to the parent agent. Rewrite it to fit in "
|
|
44
|
+
"roughly {limit} tokens, keeping every file path, line number, name and "
|
|
45
|
+
"concrete finding. Drop narration, not facts."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SubagentResult:
|
|
51
|
+
text: str
|
|
52
|
+
turns: int
|
|
53
|
+
tool_calls: int
|
|
54
|
+
cost_usd: float
|
|
55
|
+
truncated: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def run_subagent(
|
|
59
|
+
*,
|
|
60
|
+
prompt: str,
|
|
61
|
+
subagent_type: str,
|
|
62
|
+
provider: Provider,
|
|
63
|
+
registry: ToolRegistry,
|
|
64
|
+
permissions: PermissionEngine,
|
|
65
|
+
settings: Settings,
|
|
66
|
+
cwd: Path,
|
|
67
|
+
ui: UIChannel,
|
|
68
|
+
parent_session: Session,
|
|
69
|
+
limiter=None,
|
|
70
|
+
max_iterations: int | None = None,
|
|
71
|
+
) -> SubagentResult:
|
|
72
|
+
subagent_id = new_id("sub")
|
|
73
|
+
child_ui = ui.for_subagent(subagent_id)
|
|
74
|
+
|
|
75
|
+
child_registry = registry.without(*FORBIDDEN_FOR_SUBAGENTS)
|
|
76
|
+
if subagent_type == "explore":
|
|
77
|
+
# A read-only child cannot leave surprises behind, which is what makes
|
|
78
|
+
# parallel exploration safe to fan out.
|
|
79
|
+
child_registry = ToolRegistry([t for t in child_registry if t.read_only])
|
|
80
|
+
|
|
81
|
+
child_session = Session(
|
|
82
|
+
session_id=subagent_id,
|
|
83
|
+
provider=provider.name,
|
|
84
|
+
model=provider.model,
|
|
85
|
+
cwd=str(cwd),
|
|
86
|
+
parent_id=parent_session.session_id,
|
|
87
|
+
subagent_type=subagent_type,
|
|
88
|
+
)
|
|
89
|
+
# Share the parent's log: one file per conversation, children tagged inside it.
|
|
90
|
+
child_session.store = parent_session.store
|
|
91
|
+
|
|
92
|
+
child_settings = settings.model_copy(deep=True)
|
|
93
|
+
if max_iterations:
|
|
94
|
+
child_settings.max_iterations = max_iterations
|
|
95
|
+
|
|
96
|
+
system = build_system(
|
|
97
|
+
settings=child_settings,
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
registry=child_registry,
|
|
100
|
+
caps=provider.caps,
|
|
101
|
+
mode=settings.permission_mode,
|
|
102
|
+
subagent_prompt=SUBAGENT_PROMPTS.get(subagent_type, SUBAGENT_PROMPTS["general"]),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
from turnloop.context.compaction import Compactor
|
|
106
|
+
|
|
107
|
+
loop = AgentLoop(
|
|
108
|
+
provider=provider,
|
|
109
|
+
registry=child_registry,
|
|
110
|
+
session=child_session,
|
|
111
|
+
permissions=permissions, # deliberately shared
|
|
112
|
+
settings=child_settings,
|
|
113
|
+
ui=child_ui,
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
compactor=Compactor(
|
|
116
|
+
config=child_settings.compaction,
|
|
117
|
+
max_context=provider.caps.max_context,
|
|
118
|
+
provider=provider,
|
|
119
|
+
ui=child_ui,
|
|
120
|
+
),
|
|
121
|
+
system=system,
|
|
122
|
+
depth=1,
|
|
123
|
+
subagent_id=subagent_id,
|
|
124
|
+
limiter=limiter,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
last = await loop.run_turn(prompt)
|
|
128
|
+
text = (last.text if last else "").strip() or "(the subagent produced no answer)"
|
|
129
|
+
|
|
130
|
+
cap = (
|
|
131
|
+
SMALL_WINDOW_RESULT_CAP
|
|
132
|
+
if provider.caps.max_context <= 100_000
|
|
133
|
+
else RESULT_TOKEN_CAP
|
|
134
|
+
)
|
|
135
|
+
truncated = False
|
|
136
|
+
if rough_tokens(text) > cap:
|
|
137
|
+
condensed = await _condense(loop, cap)
|
|
138
|
+
if condensed:
|
|
139
|
+
text = condensed
|
|
140
|
+
else:
|
|
141
|
+
text = _hard_cap(text, cap)
|
|
142
|
+
truncated = True
|
|
143
|
+
|
|
144
|
+
tool_calls = sum(len(m.tool_uses) for m in child_session.messages)
|
|
145
|
+
parent_session.cost.usage = parent_session.cost.usage + child_session.cost.usage
|
|
146
|
+
parent_session.cost.cost_usd += child_session.cost.cost_usd
|
|
147
|
+
for name, amount in child_session.cost.by_provider.items():
|
|
148
|
+
parent_session.cost.by_provider[name] = (
|
|
149
|
+
parent_session.cost.by_provider.get(name, 0.0) + amount
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return SubagentResult(
|
|
153
|
+
text=text,
|
|
154
|
+
turns=child_session.turn,
|
|
155
|
+
tool_calls=tool_calls,
|
|
156
|
+
cost_usd=child_session.cost.cost_usd,
|
|
157
|
+
truncated=truncated,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _condense(loop: AgentLoop, limit: int) -> str | None:
|
|
162
|
+
"""Ask the child to shorten its own answer.
|
|
163
|
+
|
|
164
|
+
One extra call inside the child is cheaper than the alternative, which is
|
|
165
|
+
truncating mid-sentence and handing the parent a report that stops halfway
|
|
166
|
+
through the finding it needed.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
result = await loop.run_turn(CONDENSE_PROMPT.format(limit=limit))
|
|
170
|
+
except Exception: # noqa: BLE001 - fall back to hard truncation
|
|
171
|
+
return None
|
|
172
|
+
text = (result.text if result else "").strip()
|
|
173
|
+
return text or None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _hard_cap(text: str, limit: int) -> str:
|
|
177
|
+
from turnloop.core.tokens import CHARS_PER_TOKEN
|
|
178
|
+
|
|
179
|
+
max_chars = int(limit * CHARS_PER_TOKEN)
|
|
180
|
+
if len(text) <= max_chars:
|
|
181
|
+
return text
|
|
182
|
+
return text[:max_chars] + "\n\n[subagent answer truncated]"
|