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
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]"