@steerable/runtime-darwin-arm64 0.9.2 → 0.10.0

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 (37) hide show
  1. package/package.json +1 -1
  2. package/python-runtime/manifest.json +1 -1
  3. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/METADATA +1 -1
  4. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/RECORD +7 -7
  5. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/METADATA +1 -1
  6. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.10.0.dist-info/RECORD +11 -0
  7. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/__init__.py +48 -0
  8. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/budget.py +124 -0
  9. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_notice.py +84 -0
  10. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/child_record.py +174 -0
  11. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/engines.py +499 -0
  12. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/history.py +114 -1
  13. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/orchestration.py +496 -33
  14. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/pool.py +662 -16
  15. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/registry.py +345 -0
  16. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
  17. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
  18. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
  19. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
  20. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_runtime/subagent.py +157 -6
  21. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/METADATA +38 -3
  22. package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/RECORD +26 -16
  23. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar/sidecar.py +545 -6
  24. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.10.0.dist-info/METADATA +97 -0
  25. package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/RECORD +9 -9
  26. package/runtime.json +57 -37
  27. package/python-runtime/python/lib/python3.12/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
  28. package/python-runtime/python/lib/python3.12/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
  29. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/INSTALLER +0 -0
  30. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/WHEEL +0 -0
  31. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/INSTALLER +0 -0
  32. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/WHEEL +0 -0
  33. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/INSTALLER +0 -0
  34. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/WHEEL +0 -0
  35. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/INSTALLER +0 -0
  36. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/WHEEL +0 -0
  37. /package/python-runtime/python/lib/python3.12/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,499 @@
1
+ """Sub-agents that are not our own CoreLoop: an external agent over ACP.
2
+
3
+ A child pool so far has meant "a second CoreLoop on the same provider". This
4
+ module is the other kind: the parent hands a task to a separate agent process
5
+ — Codex, Claude Code, another Steerable — and gets back only its final answer.
6
+ The protocol is the Agent Client Protocol (ACP), which the sidecar already
7
+ speaks as a server, so the same adapter can be spawned as the peer.
8
+
9
+ Three decisions shape this module:
10
+
11
+ - **The parent waits for one answer.** Streamed agent text and tool calls are
12
+ mapped onto the pool's child events so a host can render progress, but the
13
+ child's outcome is the answer text plus the prompt's stop reason.
14
+ - **Permissions are a fixed, engine-level policy.** The tool titles and option
15
+ labels a foreign agent sends are its own prose, not our tool names, so
16
+ routing them to the user's approval UI would ask the user to authorize text
17
+ nobody validated. The policy reads only the ACP tool *kind* (`read`,
18
+ `execute`, …) and answers allow or reject.
19
+ - **No token bill.** The engine does not report tokens, so the child's usage
20
+ is unknown, never zero — the turn's total is then honestly partial instead
21
+ of quietly smaller.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import logging
29
+ from collections.abc import Callable, Mapping
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Literal
32
+
33
+ __all__ = [
34
+ "AcpEngineSpec",
35
+ "AcpOutcome",
36
+ "AcpPermissionPolicy",
37
+ "AcpToolBridge",
38
+ "AcpUnavailable",
39
+ "PermissionDiagnostic",
40
+ "run_acp_child",
41
+ ]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ #: How long the handshake (spawn → initialize → new session) may take before
46
+ #: the spawn is treated as failed. A command that does not answer is a broken
47
+ #: configuration, and the user needs that error now, not after a task timeout.
48
+ HANDSHAKE_TIMEOUT_S = 60.0
49
+
50
+ #: Grace between `cancel` and killing the process. The protocol says a cancel
51
+ #: is cooperative; a peer that ignores it must not hold the turn open.
52
+ CANCEL_GRACE_S = 5.0
53
+
54
+ #: Stop reasons that mean "the peer finished its answer". Anything else
55
+ #: (a refusal, a token limit, a cancel) is reported as an error so the parent
56
+ #: does not treat a truncated answer as a conclusion.
57
+ _COMPLETED_STOP_REASONS = frozenset({"", "end_turn", "max_turn_requests"})
58
+
59
+
60
+ class AcpUnavailable(Exception):
61
+ """The external engine could not be started or failed its handshake.
62
+
63
+ Raised at spawn time so the caller fails the spawn (no empty child is
64
+ created) and reports the command.
65
+ """
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class AcpEngineSpec:
70
+ """How to launch and drive one external agent process.
71
+
72
+ ``timeout_s`` bounds a whole prompt: an external agent that never answers
73
+ would otherwise hold the parent's turn open until the host gives up, and
74
+ the parent has no way to tell "still working" from "wedged".
75
+ """
76
+
77
+ command: str
78
+ args: tuple[str, ...] = ()
79
+ env: Mapping[str, str] = field(default_factory=dict)
80
+ cwd: str | None = None
81
+ timeout_s: float = 1800.0
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class AcpOutcome:
86
+ """One external run's terminal result.
87
+
88
+ ``status`` uses the pool's vocabulary so an ACP child and a CoreLoop child
89
+ arrive at the same call sites: ``completed`` / ``error`` / ``interrupted``.
90
+ """
91
+
92
+ status: str
93
+ answer: str
94
+ error: str | None = None
95
+ stop_reason: str | None = None
96
+
97
+
98
+ #: What the policy decides for one permission request.
99
+ PermissionDecision = Literal["allow", "reject"]
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class AcpPermissionPolicy:
104
+ """The engine-level answer to a foreign agent's permission request.
105
+
106
+ Deliberately not the parent's ``ApprovalExecutor``: an ACP request carries
107
+ the *peer's* tool title and option text, which are prose we did not write
108
+ and cannot validate. Judging permission from untrusted text is worse than
109
+ a fixed policy, so the only input is the ACP tool kind — a closed enum of
110
+ ten values the protocol defines.
111
+
112
+ ``default`` applies to kinds not listed in ``by_kind``; ``reject`` is the
113
+ default because a foreign engine running with our user's privileges must
114
+ opt in, not out.
115
+ """
116
+
117
+ default: PermissionDecision = "reject"
118
+ by_kind: Mapping[str, PermissionDecision] = field(default_factory=dict)
119
+
120
+ def decide(self, kind: str | None) -> PermissionDecision:
121
+ """The decision for one tool kind (``None`` when the peer omitted it)."""
122
+ if kind is None:
123
+ return self.default
124
+ return self.by_kind.get(kind, self.default)
125
+
126
+ def matched_by(self, kind: str | None) -> Literal["kind", "default"]:
127
+ """Whether ``decide`` used a kind-specific rule or the default."""
128
+ if kind is None or kind not in self.by_kind:
129
+ return "default"
130
+ return "kind"
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class PermissionDiagnostic:
135
+ """One permission decision, in fixed vocabulary only.
136
+
137
+ Deliberately excludes the peer's title and option labels: they are the
138
+ foreign engine's prose and must not enter the parent's record or events.
139
+ What is recorded is what we decided and why.
140
+ """
141
+
142
+ kind: str | None
143
+ decision: PermissionDecision
144
+ matched_by: Literal["kind", "default"]
145
+
146
+
147
+ class AcpToolBridge:
148
+ """Forwards the peer's file and terminal requests into our tool domain.
149
+
150
+ A foreign agent that asks to read a file or run a command is asking *us*
151
+ to do it — the protocol routes those through the client. Passing them to
152
+ the parent's tool executor keeps one confinement story: the project fence
153
+ and the egress allow-list apply to an ACP child exactly as they do to a
154
+ local one. Permission and confinement stay separate questions: the policy
155
+ says whether the call may happen at all, the fence says how far it may
156
+ reach.
157
+
158
+ A bridge with no executor refuses every request: an external agent is not
159
+ granted filesystem access by being spawned.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ read_text_file: Callable[[str, int | None, int | None], Any] | None = None,
166
+ write_text_file: Callable[[str, str], Any] | None = None,
167
+ create_terminal: Callable[
168
+ [str, list[str], Mapping[str, str] | None], Any
169
+ ]
170
+ | None = None,
171
+ ) -> None:
172
+ self._read = read_text_file
173
+ self._write = write_text_file
174
+ self._terminal = create_terminal
175
+
176
+ async def read_text_file(
177
+ self, path: str, line: int | None = None, limit: int | None = None
178
+ ) -> str:
179
+ """Read through the parent's tool domain, or refuse.
180
+
181
+ Raises:
182
+ AcpUnavailable: when no read capability was wired.
183
+ """
184
+ if self._read is None:
185
+ raise AcpUnavailable(
186
+ f"fs/read_text_file refused: this host grants no file access "
187
+ f"to external agents (requested {path!r})"
188
+ )
189
+ return str(await self._read(path, line, limit))
190
+
191
+ async def write_text_file(self, path: str, content: str) -> None:
192
+ """Write through the parent's tool domain, or refuse.
193
+
194
+ Raises:
195
+ AcpUnavailable: when no write capability was wired.
196
+ """
197
+ if self._write is None:
198
+ raise AcpUnavailable(
199
+ f"fs/write_text_file refused: this host grants no file access "
200
+ f"to external agents (requested {path!r})"
201
+ )
202
+ await self._write(path, content)
203
+
204
+ async def create_terminal(
205
+ self, command: str, args: list[str], env: Mapping[str, str] | None = None
206
+ ) -> Any:
207
+ """Run a command through the parent's tool domain, or refuse.
208
+
209
+ Raises:
210
+ AcpUnavailable: when no terminal capability was wired.
211
+ """
212
+ if self._terminal is None:
213
+ raise AcpUnavailable(
214
+ f"terminal/create refused: this host grants no command "
215
+ f"execution to external agents (requested {command!r})"
216
+ )
217
+ return await self._terminal(command, args, env)
218
+
219
+
220
+ def _text_of(update: Any) -> str:
221
+ """The text of one streamed content chunk, or empty.
222
+
223
+ ACP content is a union of text, image and embedded-resource blocks; only
224
+ text contributes to the answer, and a block whose shape we do not know is
225
+ dropped rather than stringified into the transcript.
226
+ """
227
+ content = getattr(update, "content", None)
228
+ if content is None:
229
+ return ""
230
+ if isinstance(content, str):
231
+ return content
232
+ if isinstance(content, list):
233
+ return "".join(_text_of(part) for part in content)
234
+ text = getattr(content, "text", None)
235
+ return str(text) if isinstance(text, str) else ""
236
+
237
+
238
+ def _answer_from_events(events: list[dict[str, Any]]) -> str:
239
+ """The child's final answer: the last contiguous block of agent text.
240
+
241
+ Working rounds narrate progress; the last run of agent text is what the
242
+ parent should read as the conclusion — the same rule the local pool applies
243
+ to a child's final request.
244
+ """
245
+ blocks: list[str] = []
246
+ current: list[str] = []
247
+ for event in events:
248
+ if event.get("kind") == "child_message":
249
+ current.append(str(event.get("text") or ""))
250
+ elif current:
251
+ blocks.append("".join(current))
252
+ current = []
253
+ if current:
254
+ blocks.append("".join(current))
255
+ return (blocks[-1].strip() if blocks else "")
256
+
257
+
258
+ async def run_acp_child(
259
+ spec: AcpEngineSpec,
260
+ task: str,
261
+ *,
262
+ tool_bridge: AcpToolBridge,
263
+ permission: AcpPermissionPolicy,
264
+ on_event: Callable[[dict[str, Any]], None] | None = None,
265
+ ) -> AcpOutcome:
266
+ """Spawn an external ACP agent, run one prompt, return its final answer.
267
+
268
+ The peer's streamed updates are mapped onto the pool's child event names
269
+ (``child_message`` / ``child_reasoning`` / ``child_tool``), so a host that
270
+ already renders a sub-agent's progress needs no second vocabulary.
271
+ Permission requests are answered by ``permission`` — a fixed policy over
272
+ the tool kind, never the parent's approval UI.
273
+
274
+ Args:
275
+ spec: How to launch and bound the peer.
276
+ task: The self-contained instructions for it.
277
+ tool_bridge: Where its file and terminal requests land.
278
+ permission: The engine-level permission policy.
279
+ on_event: Optional synchronous sink for progress events.
280
+
281
+ Returns:
282
+ The run's terminal outcome. A peer that stops without finishing is an
283
+ ``error`` with the stop reason named, never a truncated answer passed
284
+ off as a conclusion.
285
+
286
+ Raises:
287
+ AcpUnavailable: when the peer cannot be launched or fails its
288
+ handshake — the caller must fail the spawn rather than create an
289
+ empty child.
290
+ """
291
+ import acp
292
+
293
+ events: list[dict[str, Any]] = []
294
+
295
+ def emit(event: dict[str, Any]) -> None:
296
+ events.append(event)
297
+ if on_event is not None:
298
+ with contextlib.suppress(Exception):
299
+ on_event(event)
300
+
301
+ class _Client(acp.Client):
302
+ async def request_permission(
303
+ self, session_id: str, tool_call: Any, options: list[Any], **kwargs: Any
304
+ ) -> Any:
305
+ raw_kind = getattr(tool_call, "kind", None)
306
+ kind = str(raw_kind) if raw_kind is not None else None
307
+ decision = permission.decide(kind)
308
+ # Fixed vocabulary only: the peer's title and option labels never
309
+ # leave this method (they are its prose, not our tool names).
310
+ diagnostic = PermissionDiagnostic(
311
+ kind=kind,
312
+ decision=decision,
313
+ matched_by=permission.matched_by(kind),
314
+ )
315
+ emit(
316
+ {
317
+ "kind": "child_permission",
318
+ "toolKind": diagnostic.kind,
319
+ "decision": diagnostic.decision,
320
+ "matchedBy": diagnostic.matched_by,
321
+ }
322
+ )
323
+ if decision == "allow":
324
+ for option in options:
325
+ if getattr(option, "kind", None) in ("allow_once", "allow_always"):
326
+ return acp.RequestPermissionResponse(
327
+ outcome=acp.schema.AllowedOutcome(
328
+ outcome="selected", option_id=option.option_id
329
+ )
330
+ )
331
+ # No allow option, or the policy rejects: the peer must not
332
+ # continue this call.
333
+ return acp.RequestPermissionResponse(
334
+ outcome=acp.schema.DeniedOutcome(outcome="cancelled")
335
+ )
336
+
337
+ async def session_update(
338
+ self, session_id: str, update: Any, **kwargs: Any
339
+ ) -> None:
340
+ kind = getattr(update, "session_update", "")
341
+ if kind == "agent_message_chunk":
342
+ emit({"kind": "child_message", "text": _text_of(update)})
343
+ elif kind == "agent_thought_chunk":
344
+ emit({"kind": "child_reasoning", "text": _text_of(update)})
345
+ elif kind == "tool_call":
346
+ emit(
347
+ {
348
+ "kind": "child_tool",
349
+ "toolCallId": str(getattr(update, "tool_call_id", "")),
350
+ # The peer's title is its own prose; the kind is the
351
+ # protocol's closed vocabulary, which is what a host
352
+ # may render as a category.
353
+ "toolKind": getattr(update, "kind", None),
354
+ }
355
+ )
356
+ elif kind == "tool_call_update":
357
+ emit(
358
+ {
359
+ "kind": "child_tool_done",
360
+ "toolCallId": str(getattr(update, "tool_call_id", "")),
361
+ "status": getattr(update, "status", None),
362
+ }
363
+ )
364
+
365
+ async def read_text_file(
366
+ self,
367
+ path: str,
368
+ session_id: str,
369
+ line: int | None = None,
370
+ limit: int | None = None,
371
+ **kwargs: Any,
372
+ ) -> Any:
373
+ text = await tool_bridge.read_text_file(path, line, limit)
374
+ return acp.ReadTextFileResponse(content=text)
375
+
376
+ async def write_text_file(
377
+ self, path: str, content: str, session_id: str, **kwargs: Any
378
+ ) -> Any:
379
+ await tool_bridge.write_text_file(path, content)
380
+ return None
381
+
382
+ async def create_terminal(
383
+ self,
384
+ command: str,
385
+ session_id: str,
386
+ args: list[str] | None = None,
387
+ env: Mapping[str, str] | None = None,
388
+ **kwargs: Any,
389
+ ) -> Any:
390
+ return await tool_bridge.create_terminal(command, list(args or []), env)
391
+
392
+ context = acp.spawn_agent_process(
393
+ lambda _agent: _Client(),
394
+ spec.command,
395
+ *spec.args,
396
+ env=dict(spec.env) if spec.env else None,
397
+ cwd=spec.cwd or None,
398
+ )
399
+ try:
400
+ connection, process = await asyncio.wait_for(
401
+ context.__aenter__(), timeout=HANDSHAKE_TIMEOUT_S
402
+ )
403
+ except FileNotFoundError as exc:
404
+ raise AcpUnavailable(
405
+ f"cannot launch ACP engine {spec.command!r}: no such command"
406
+ ) from exc
407
+ except OSError as exc:
408
+ raise AcpUnavailable(
409
+ f"cannot launch ACP engine {spec.command!r}: {exc}"
410
+ ) from exc
411
+ except asyncio.TimeoutError as exc:
412
+ raise AcpUnavailable(
413
+ f"ACP engine {spec.command!r} did not start within "
414
+ f"{HANDSHAKE_TIMEOUT_S:.0f}s"
415
+ ) from exc
416
+
417
+ session_id: str | None = None
418
+ try:
419
+ try:
420
+ await asyncio.wait_for(
421
+ connection.initialize(protocol_version=acp.PROTOCOL_VERSION),
422
+ timeout=HANDSHAKE_TIMEOUT_S,
423
+ )
424
+ session = await asyncio.wait_for(
425
+ connection.new_session(cwd=spec.cwd or "", mcp_servers=[]),
426
+ timeout=HANDSHAKE_TIMEOUT_S,
427
+ )
428
+ session_id = session.session_id
429
+ except asyncio.TimeoutError as exc:
430
+ raise AcpUnavailable(
431
+ f"ACP engine {spec.command!r} did not complete its handshake "
432
+ f"within {HANDSHAKE_TIMEOUT_S:.0f}s"
433
+ ) from exc
434
+ except Exception as exc: # noqa: BLE001 — any handshake failure
435
+ raise AcpUnavailable(
436
+ f"ACP engine {spec.command!r} refused the handshake: {exc}"
437
+ ) from exc
438
+
439
+ try:
440
+ response = await asyncio.wait_for(
441
+ connection.prompt(
442
+ session_id=session_id,
443
+ prompt=[acp.schema.TextContentBlock(type="text", text=task)],
444
+ ),
445
+ timeout=spec.timeout_s,
446
+ )
447
+ except asyncio.TimeoutError:
448
+ return AcpOutcome(
449
+ status="error",
450
+ answer=_answer_from_events(events),
451
+ error=(
452
+ f"ACP engine {spec.command!r} did not finish within "
453
+ f"{spec.timeout_s:.0f}s"
454
+ ),
455
+ )
456
+ except asyncio.CancelledError:
457
+ # The parent cancelled the turn (or the pool is winding down).
458
+ await _cancel(connection, session_id)
459
+ raise
460
+ except Exception as exc: # noqa: BLE001 — a peer failure is data
461
+ return AcpOutcome(
462
+ status="error",
463
+ answer=_answer_from_events(events),
464
+ error=f"ACP engine {spec.command!r} failed: {exc}",
465
+ )
466
+
467
+ stop_reason = str(getattr(response, "stop_reason", "") or "")
468
+ answer = _answer_from_events(events)
469
+ if stop_reason in _COMPLETED_STOP_REASONS:
470
+ return AcpOutcome(
471
+ status="completed",
472
+ answer=answer,
473
+ stop_reason=stop_reason or None,
474
+ )
475
+ # A truncated answer is not a conclusion: report why it stopped so the
476
+ # parent can decide to resume or take the task elsewhere.
477
+ return AcpOutcome(
478
+ status="error",
479
+ answer=answer,
480
+ stop_reason=stop_reason,
481
+ error=f"ACP engine stopped with {stop_reason!r}",
482
+ )
483
+ finally:
484
+ with contextlib.suppress(Exception):
485
+ await context.__aexit__(None, None, None)
486
+ with contextlib.suppress(Exception):
487
+ if process.returncode is None:
488
+ process.kill()
489
+
490
+
491
+ async def _cancel(connection: Any, session_id: str) -> None:
492
+ """Ask the peer to stop, then leave the caller to kill it.
493
+
494
+ The protocol's cancel is cooperative. A peer that keeps generating after
495
+ the parent gave up would burn its own budget for an answer nobody reads.
496
+ """
497
+ with contextlib.suppress(Exception):
498
+ await connection.cancel(session_id=session_id)
499
+ await asyncio.sleep(CANCEL_GRACE_S)
@@ -188,6 +188,11 @@ class HistorySeed:
188
188
  turn_id: str | None = None
189
189
  kind: str = KIND_HISTORY_SEED
190
190
  message_kinds: tuple[str, ...] = ()
191
+ #: Which model produced the seeded history. Recorded so a reader can tell
192
+ #: "this transcript came from another model" — a cross-model fork re-reads
193
+ #: the whole prefix on a new route, and that cost has to be explainable
194
+ #: afterwards. Audit metadata only: never read at projection time.
195
+ source_model: str | None = None
191
196
 
192
197
 
193
198
  #: The record is a linear log of items, declared-rewrite markers, region
@@ -558,6 +563,7 @@ class ContextManager:
558
563
  source_record_id: str | None = None,
559
564
  source_until_seq: int | None = None,
560
565
  turn_id: str | None = None,
566
+ source_model: str | None = None,
561
567
  ) -> HistorySeed:
562
568
  """Inline-seed a projected prefix as ONE entry (fork/regenerate).
563
569
 
@@ -573,6 +579,7 @@ class ContextManager:
573
579
  source_record_id=source_record_id,
574
580
  source_until_seq=source_until_seq,
575
581
  turn_id=turn_id or self.turn_id,
582
+ source_model=source_model,
576
583
  )
577
584
  self._record.append(entry)
578
585
  self._pending.append(entry)
@@ -627,7 +634,6 @@ class ContextManager:
627
634
  for entry in self._record[self._boundary_index + 1 :]
628
635
  if isinstance(entry, HistoryItem)
629
636
  ]
630
-
631
637
  @property
632
638
  def latest_boundary(self) -> CompactionBoundary | None:
633
639
  if self._boundary_index < 0:
@@ -726,6 +732,108 @@ def message_from_dict(data: dict[str, Any]) -> LLMMessage:
726
732
  )
727
733
 
728
734
 
735
+ def completed_turn_prefix(
736
+ messages: Sequence["LLMMessage"],
737
+ *,
738
+ max_turns: int | None = None,
739
+ ) -> list["LLMMessage"]:
740
+ """The balanced prefix of a transcript — the fork seed primitive.
741
+
742
+ A fork seed must be a transcript a provider accepts and a loop can
743
+ replay. Two rules, both load-bearing:
744
+
745
+ - it ends at an **assistant** message whose tool calls are all answered
746
+ (a dangling call cannot be replayed, and a trailing user message is a
747
+ question the parent never got to answer);
748
+ - everything before that point is kept.
749
+
750
+ This is deliberately *not* "drop the last turn". A Steerable turn is one
751
+ user request that may contain dozens of tool rounds — dropping it would
752
+ throw away the very investigation a child is asked to continue ("verify
753
+ these findings"), which is the main reason to fork at all. The in-flight
754
+ round is dropped; the work before it is kept.
755
+
756
+ Args:
757
+ messages: a projection, oldest first.
758
+ max_turns: keep only the last N *user* messages and everything after
759
+ the first of them. ``None`` keeps the whole prefix; this is the
760
+ escape hatch for an over-long parent conversation.
761
+
762
+ Returns:
763
+ A transcript ending at a completed assistant turn, or an empty list
764
+ when the transcript contains no such point (a fork then behaves like
765
+ a fresh child).
766
+ """
767
+ if not messages:
768
+ return []
769
+ cut = _balanced_prefix_length(messages)
770
+ if cut <= 0:
771
+ return []
772
+ prefix = list(messages[:cut])
773
+ if max_turns is not None:
774
+ prefix = _last_turns(prefix, max_turns)
775
+ return prefix
776
+
777
+
778
+ def _balanced_prefix_length(messages: Sequence["LLMMessage"]) -> int:
779
+ """Index one past the last assistant message that closes a balanced run.
780
+
781
+ Walks forward pairing ``tool_calls`` with the ``tool`` results that
782
+ answer them, so a partly-answered assistant message is excluded whole
783
+ rather than truncated mid-call. Only an assistant message advances the
784
+ kept boundary: a trailing user message or a stray tool result is waiting
785
+ on something, which is exactly what a seed cannot contain.
786
+ """
787
+ last_balanced_end = 0
788
+ index = 0
789
+ total = len(messages)
790
+ while index < total:
791
+ message = messages[index]
792
+ index += 1
793
+ if message.role != "assistant":
794
+ continue
795
+ pending = {call.id for call in (message.tool_calls or [])}
796
+ if pending:
797
+ # Consume the results that belong to this assistant message. A
798
+ # result for anything else ends the pairing: the transcript did
799
+ # not come back in the order a replay needs.
800
+ while index < total and messages[index].role == "tool":
801
+ call_id = messages[index].tool_call_id
802
+ if call_id is not None:
803
+ pending.discard(call_id)
804
+ index += 1
805
+ if pending:
806
+ return last_balanced_end
807
+ last_balanced_end = index
808
+ continue
809
+ # Text-only assistant message: balanced by construction, unless a
810
+ # result follows it, which would mean the log is out of order.
811
+ while index < total and messages[index].role == "tool":
812
+ index += 1
813
+ last_balanced_end = index
814
+ return last_balanced_end
815
+
816
+
817
+ def _last_turns(
818
+ messages: Sequence["LLMMessage"], max_turns: int
819
+ ) -> list["LLMMessage"]:
820
+ """Keep the trailing ``max_turns`` user messages and what follows.
821
+
822
+ Counting user messages rather than assistant turns keeps the cut at a
823
+ request boundary, so the kept span still starts where a user spoke.
824
+ """
825
+ if max_turns <= 0:
826
+ return []
827
+ seen = 0
828
+ for index in range(len(messages) - 1, -1, -1):
829
+ if messages[index].role != "user":
830
+ continue
831
+ seen += 1
832
+ if seen >= max_turns:
833
+ return list(messages[index:])
834
+ return list(messages)
835
+
836
+
729
837
  def entry_to_dict(entry: RecordEntry) -> dict[str, Any]:
730
838
  """Serialize one record entry for ``StorageAdapter.append_history``."""
731
839
  if isinstance(entry, HistoryItem):
@@ -797,6 +905,10 @@ def entry_to_dict(entry: RecordEntry) -> dict[str, Any]:
797
905
  "token_estimate": entry.token_estimate,
798
906
  "source_record_id": entry.source_record_id,
799
907
  "source_until_seq": entry.source_until_seq,
908
+ # Additive + optional: a seed written before this field existed
909
+ # has no `source_model`, and a reader that does not know it
910
+ # ignores the key.
911
+ **({"source_model": entry.source_model} if entry.source_model else {}),
800
912
  "messages": [message_to_dict(m) for m in entry.messages],
801
913
  "message_kinds": list(entry.message_kinds),
802
914
  }
@@ -934,6 +1046,7 @@ def entry_from_dict(data: dict[str, Any]) -> RecordEntry:
934
1046
  source_until_seq=data.get("source_until_seq"),
935
1047
  turn_id=data.get("turn_id"),
936
1048
  message_kinds=tuple(str(k) for k in data.get("message_kinds") or ()),
1049
+ source_model=data.get("source_model"),
937
1050
  )
938
1051
  raise RecordFormatError(f"unknown record entry envelope: {envelope!r}")
939
1052