@steerable/runtime-win32-x64 0.9.2 → 0.10.1

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 (68) hide show
  1. package/bin/steerable-egress-proxy.exe +0 -0
  2. package/bin/win-spawn-helper.exe +0 -0
  3. package/package.json +1 -1
  4. package/python-runtime/manifest.json +1 -1
  5. package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
  6. package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  7. package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
  8. package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
  9. package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
  10. package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
  11. package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
  12. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/METADATA +1 -1
  13. package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/RECORD +7 -7
  14. package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/METADATA +1 -1
  15. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.1.dist-info/RECORD +11 -0
  16. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +48 -0
  17. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/budget.py +124 -0
  18. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_notice.py +84 -0
  19. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_record.py +174 -0
  20. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/engines.py +499 -0
  21. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/history.py +114 -1
  22. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +496 -33
  23. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +662 -16
  24. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/registry.py +345 -0
  25. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
  26. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
  27. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
  28. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
  29. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +157 -6
  30. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/METADATA +39 -4
  31. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/RECORD +26 -16
  32. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native/steerable_agent_runtime_native.pyd +0 -0
  33. package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/METADATA +1 -1
  34. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.9.dist-info/RECORD +9 -0
  35. package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
  36. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.1.dist-info/METADATA +97 -0
  37. package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/RECORD +12 -12
  38. package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
  39. package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
  40. package/python-runtime/python/Scripts/dotenv.exe +0 -0
  41. package/python-runtime/python/Scripts/httpx.exe +0 -0
  42. package/python-runtime/python/Scripts/idna.exe +0 -0
  43. package/python-runtime/python/Scripts/jsonschema.exe +0 -0
  44. package/python-runtime/python/Scripts/mcp.exe +0 -0
  45. package/python-runtime/python/Scripts/pip.exe +0 -0
  46. package/python-runtime/python/Scripts/pip3.12.exe +0 -0
  47. package/python-runtime/python/Scripts/pip3.exe +0 -0
  48. package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
  49. package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
  50. package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
  51. package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
  52. package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
  53. package/python-runtime/python/Scripts/uvicorn.exe +0 -0
  54. package/runtime.json +90 -70
  55. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
  56. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime_native-1.0.8.dist-info/RECORD +0 -9
  57. package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
  58. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/INSTALLER +0 -0
  59. /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.1.dist-info}/WHEEL +0 -0
  60. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/INSTALLER +0 -0
  61. /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.1.dist-info}/WHEEL +0 -0
  62. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/INSTALLER +0 -0
  63. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.1.dist-info}/WHEEL +0 -0
  64. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/INSTALLER +0 -0
  65. /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime_native-1.0.8.dist-info → steerable_agent_runtime_native-1.0.9.dist-info}/WHEEL +0 -0
  66. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/INSTALLER +0 -0
  67. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/WHEEL +0 -0
  68. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,345 @@
1
+ """The child-agent registry: the durable answer to "what did this session spawn".
2
+
3
+ The ``AgentPool`` only knows the children it is currently driving — it is
4
+ built per turn and shut down with it, so a parent that finished a turn has no
5
+ way to learn what it spawned. This registry is the durable index instead: one
6
+ row per spawned child, written at spawn and settled at each terminal exit.
7
+
8
+ State is **composed**, never stored as a single truth:
9
+
10
+ - a terminal status comes from the row (``completed`` / ``error`` /
11
+ ``cancelled`` / ``budget_exhausted`` / ``closed``);
12
+ - ``running`` is derived from a live in-process handle and is never written,
13
+ so a killed process cannot leave a permanently running row behind;
14
+ - a row that never reached a terminal write while no handle exists is
15
+ ``interrupted`` — the parent turn ended or the process died mid-run, and
16
+ ``agent_send`` can resume it.
17
+
18
+ That composition is what makes "what happened while the app was closed" a
19
+ question the registry can answer, instead of a question the UI guesses at.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, replace
26
+ from datetime import datetime, timezone
27
+
28
+ from .child_record import (
29
+ INTERRUPTED_CHILD_STATUS,
30
+ RESUMABLE_CHILD_STATUSES,
31
+ ChildRecord,
32
+ )
33
+ from .llm import LLMUsage
34
+ from .storage import StorageAdapter
35
+
36
+ __all__ = [
37
+ "ChildRegistry",
38
+ "ChildStatusView",
39
+ "SpawnedChild",
40
+ "default_record_id",
41
+ "registry_status",
42
+ ]
43
+
44
+
45
+ def default_record_id(parent_record_id: str, child_id: str) -> str:
46
+ """The one place the child record id is spelled.
47
+
48
+ ``<parent>:child:<lineage id>`` — hosts read child traces by this id
49
+ (``child_spawned`` publishes it), so deriving it anywhere else risks two
50
+ spellings of the same record.
51
+ """
52
+ return f"{parent_record_id}:child:{child_id}"
53
+
54
+
55
+ def _now_iso() -> str:
56
+ return datetime.now(timezone.utc).isoformat()
57
+
58
+
59
+ #: The loop's completion vocabulary → the registry's. ``failed`` is what
60
+ #: ``CoreLoop`` reports for a run that ended in error; ``error`` is what the
61
+ #: pool records for a child whose run raised out of the loop entirely. One
62
+ #: spelling reaches storage so a resumed child is not told "failed" while its
63
+ #: row says "error".
64
+ _COMPLETION_TO_REGISTRY: dict[str, str] = {
65
+ "completed": "completed",
66
+ "failed": "error",
67
+ "error": "error",
68
+ "cancelled": "cancelled",
69
+ "budget_exhausted": "budget_exhausted",
70
+ "closed": "closed",
71
+ }
72
+
73
+
74
+ def registry_status(completion_status: str) -> str:
75
+ """Translate a loop completion status into a registry status.
76
+
77
+ An unknown status raises rather than writing a row the resume path cannot
78
+ interpret: a child whose row says something nobody understands is worse
79
+ than a loudly failed bookkeeping write, which the caller logs and
80
+ continues from.
81
+ """
82
+ try:
83
+ return _COMPLETION_TO_REGISTRY[completion_status]
84
+ except KeyError:
85
+ raise ValueError(
86
+ f"unknown child completion status {completion_status!r}; expected one of "
87
+ f"{sorted(_COMPLETION_TO_REGISTRY)}"
88
+ ) from None
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class SpawnedChild:
93
+ """What a caller must supply to register one spawn."""
94
+
95
+ parent_record_id: str
96
+ child_id: str
97
+ depth: int
98
+ profile: str | None
99
+ engine: str
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class ChildStatusView:
104
+ """One child as ``agent_list`` / the ``children`` CLI report it.
105
+
106
+ ``status`` is the composed answer (see the module docstring); ``live``
107
+ says whether an in-process handle exists right now, which is the one
108
+ fact the stored row cannot know.
109
+ """
110
+
111
+ record: ChildRecord
112
+ status: str
113
+ live: bool
114
+
115
+ @property
116
+ def resumable(self) -> bool:
117
+ """True when ``agent_send`` can continue this child.
118
+
119
+ A finished child can always be given a follow-up turn; an
120
+ ``interrupted`` one resumes from its record. A ``closed`` child never
121
+ does — that is what closing means.
122
+ """
123
+ if self.live:
124
+ return False
125
+ return (
126
+ self.status in RESUMABLE_CHILD_STATUSES
127
+ and self.record.status != "closed"
128
+ )
129
+
130
+ def to_wire(self, *, answer_preview: str | None = None) -> dict[str, object]:
131
+ """Model/host-facing payload.
132
+
133
+ ``answerPreview`` is supplied by the caller (read from the child's own
134
+ record) rather than stored here, so the registry never duplicates the
135
+ transcript.
136
+ """
137
+ payload: dict[str, object] = {
138
+ **self.record.to_wire(),
139
+ "status": self.status,
140
+ "live": self.live,
141
+ "resumable": self.resumable,
142
+ }
143
+ if answer_preview:
144
+ payload["answerPreview"] = answer_preview
145
+ return payload
146
+
147
+
148
+ class ChildRegistry:
149
+ """Durable child-agent index over one ``StorageAdapter``.
150
+
151
+ A registry with ``store=None`` is inert: writes are no-ops, reads return
152
+ nothing. That is the shape a host without a storage adapter gets, and it
153
+ keeps the pool free of ``if store is not None`` branches.
154
+ """
155
+
156
+ def __init__(self, store: StorageAdapter | None) -> None:
157
+ self._store = store
158
+ #: Live handles by record id — the only source of ``running``.
159
+ self._live: dict[str, Callable[[], bool]] = {}
160
+ #: Rows noted but not yet written. ``spawn`` must not await, but the
161
+ #: row has to be visible to an ``agent_list`` issued immediately
162
+ #: after it, so the note is synchronous and the write happens at the
163
+ #: child's first await.
164
+ self._pending: dict[str, ChildRecord] = {}
165
+
166
+ # ------------------------------------------------------------------
167
+ # Writes
168
+ # ------------------------------------------------------------------
169
+
170
+ def note_spawn(
171
+ self, child: SpawnedChild, *, record_id: str | None = None
172
+ ) -> str:
173
+ """Note one spawn without awaiting storage; returns the record id.
174
+
175
+ Called inside ``AgentPool.spawn``, which is synchronous. The row is
176
+ readable at once (reads merge the pending set) and becomes durable at
177
+ ``flush_spawn``, which ``_run_child`` awaits before the child's first
178
+ model request — so a crash between the two leaves at worst an
179
+ unrecorded child, never a row claiming a child that never ran.
180
+ """
181
+ resolved = record_id or default_record_id(child.parent_record_id, child.child_id)
182
+ if self._store is None:
183
+ return resolved
184
+ now = _now_iso()
185
+ existing = self._pending.get(resolved)
186
+ created_at = existing.created_at if existing is not None else now
187
+ self._pending[resolved] = ChildRecord(
188
+ record_id=resolved,
189
+ parent_record_id=child.parent_record_id,
190
+ child_id=child.child_id,
191
+ depth=child.depth,
192
+ profile=child.profile,
193
+ engine=child.engine,
194
+ # ``running`` is never written; the row stays non-terminal until
195
+ # a real terminal status arrives.
196
+ status="pending",
197
+ resumable=False,
198
+ prompt_tokens=None,
199
+ completion_tokens=None,
200
+ usage_known=True,
201
+ created_at=created_at,
202
+ updated_at=now,
203
+ )
204
+ return resolved
205
+
206
+ async def flush_spawn(self, record_id: str) -> None:
207
+ """Persist a noted spawn. Idempotent; a no-op once written."""
208
+ record = self._pending.pop(record_id, None)
209
+ if record is None or self._store is None:
210
+ return
211
+ existing = await self._store.get_child(record_id)
212
+ # A resumed child keeps its original creation time: spawn order is
213
+ # what the panel and the CLI sort by, so a resumed child must not
214
+ # jump to the end of the list.
215
+ if existing is not None:
216
+ record = replace(record, created_at=existing.created_at)
217
+ await self._store.upsert_child(record)
218
+
219
+ async def spawned(self, child: SpawnedChild, *, record_id: str | None = None) -> str:
220
+ """Note and immediately persist one spawn; returns the record id."""
221
+ resolved = self.note_spawn(child, record_id=record_id)
222
+ await self.flush_spawn(resolved)
223
+ return resolved
224
+
225
+ async def finished(
226
+ self,
227
+ record_id: str,
228
+ *,
229
+ status: str,
230
+ usage: LLMUsage | None = None,
231
+ usage_known: bool = True,
232
+ ) -> bool:
233
+ """Settle one child's terminal status and usage.
234
+
235
+ ``usage=None`` with ``usage_known=False`` is the external-engine case
236
+ (ACP reports no token bill); ``usage=None`` with ``usage_known=True``
237
+ means a local child that reported nothing, which is still "known zero"
238
+ for budget purposes.
239
+ """
240
+ if self._store is None:
241
+ return False
242
+ # A child that settled before its row reached disk still needs one.
243
+ self._pending.pop(record_id, None)
244
+ prompt = usage.prompt_tokens if usage is not None else None
245
+ completion = usage.completion_tokens if usage is not None else None
246
+ return await self._store.finish_child(
247
+ record_id,
248
+ status=status,
249
+ prompt_tokens=prompt,
250
+ completion_tokens=completion,
251
+ usage_known=usage_known,
252
+ resumable=status in RESUMABLE_CHILD_STATUSES,
253
+ updated_at=_now_iso(),
254
+ )
255
+
256
+ # ------------------------------------------------------------------
257
+ # Live handles
258
+ # ------------------------------------------------------------------
259
+
260
+ def mark_live(self, record_id: str, is_live: Callable[[], bool]) -> None:
261
+ """Register the liveness probe for a child that is running now.
262
+
263
+ A *probe* rather than a boolean, so a caller that keeps the pool alive
264
+ across an await boundary cannot leave a stale ``True`` behind: the
265
+ registry asks the handle whether it is still running when it reports.
266
+ """
267
+ self._live[record_id] = is_live
268
+
269
+ def mark_settled(self, record_id: str) -> None:
270
+ """Forget a live handle once its run reached a terminal status."""
271
+ self._live.pop(record_id, None)
272
+
273
+ # ------------------------------------------------------------------
274
+ # Reads
275
+ # ------------------------------------------------------------------
276
+
277
+ async def get(self, record_id: str) -> ChildStatusView | None:
278
+ """One child with its composed status, or ``None``."""
279
+ if self._store is None:
280
+ return None
281
+ record = self._pending.get(record_id) or await self._store.get_child(record_id)
282
+ return self._view(record) if record is not None else None
283
+
284
+ async def list_for_parent(
285
+ self, parent_record_id: str, *, include_finished: bool = True
286
+ ) -> list[ChildStatusView]:
287
+ """Every child of one parent, oldest first.
288
+
289
+ Filtering happens after composition, not in the query: a row with a
290
+ non-terminal status and no live handle is ``interrupted``, which is
291
+ exactly what ``include_finished=False`` must keep.
292
+ """
293
+ if self._store is None:
294
+ return []
295
+ stored = {r.record_id: r for r in await self._store.list_children(parent_record_id)}
296
+ # Noted-but-unwritten rows are visible immediately, so a spawn
297
+ # issued in this same turn is already listed.
298
+ for record in self._pending.values():
299
+ if record.parent_record_id == parent_record_id:
300
+ stored[record.record_id] = record
301
+ records = sorted(stored.values(), key=lambda r: r.created_at)
302
+ views = [self._view(record) for record in records]
303
+ if not include_finished:
304
+ views = [view for view in views if not view.record.terminal]
305
+ return views
306
+
307
+ async def list_all(self, *, prefix: str | None = None) -> list[ChildStatusView]:
308
+ """Every child, optionally filtered by a ``parent_record_id`` prefix.
309
+
310
+ This is how a host answers "what did this session spawn" after a
311
+ restart, when no pool exists to ask.
312
+ """
313
+ if self._store is None:
314
+ return []
315
+ stored = {r.record_id: r for r in await self._store.list_all_children(prefix=prefix)}
316
+ for record in self._pending.values():
317
+ if prefix is None or record.parent_record_id.startswith(prefix):
318
+ stored[record.record_id] = record
319
+ records = sorted(stored.values(), key=lambda r: r.created_at)
320
+ return [self._view(record) for record in records]
321
+
322
+ async def find_child(self, parent_record_id: str, child_id: str) -> ChildStatusView | None:
323
+ """Resolve a lineage id (``0.2``) to its child within one parent.
324
+
325
+ Resume is addressed by lineage id — that is what the parent model
326
+ remembers and what tool results carry — so the lookup lives here
327
+ rather than making every caller scan ``list_for_parent``.
328
+ """
329
+ for view in await self.list_for_parent(parent_record_id):
330
+ if view.record.child_id == child_id:
331
+ return view
332
+ return None
333
+
334
+ def _view(self, record: ChildRecord) -> ChildStatusView:
335
+ probe = self._live.get(record.record_id)
336
+ live = bool(probe()) if probe is not None else False
337
+ if record.terminal:
338
+ # Closed is terminal and wins over any stale probe: a closed
339
+ # child is never "running", even if its task has not settled yet.
340
+ return ChildStatusView(record=record, status=record.status, live=False)
341
+ if live:
342
+ return ChildStatusView(record=record, status="running", live=True)
343
+ return ChildStatusView(
344
+ record=record, status=INTERRUPTED_CHILD_STATUS, live=False
345
+ )
@@ -14,6 +14,8 @@ from steerable_agent_protocol.generated import (
14
14
  TraceSpan,
15
15
  )
16
16
 
17
+ from ..child_record import ChildRecord
18
+
17
19
 
18
20
  @runtime_checkable
19
21
  class StorageAdapter(Protocol):
@@ -114,6 +116,56 @@ class StorageAdapter(Protocol):
114
116
  """
115
117
  ...
116
118
 
119
+ # -- Child-agent index ------------------------------------------------
120
+ #
121
+ # The durable answer to "whose child is this, how deep, what state".
122
+ # Rows carry no task text and no answer: those live in the child's own
123
+ # ``history`` record, so each fact has one authoritative copy.
124
+
125
+ async def upsert_child(self, record: ChildRecord) -> None:
126
+ """Write one child record whole, replacing any row with its
127
+ ``record_id``. Called at spawn; idempotent on retry."""
128
+ ...
129
+
130
+ async def finish_child(
131
+ self,
132
+ record_id: str,
133
+ *,
134
+ status: str,
135
+ prompt_tokens: int | None = None,
136
+ completion_tokens: int | None = None,
137
+ usage_known: bool = True,
138
+ resumable: bool = False,
139
+ updated_at: str,
140
+ ) -> bool:
141
+ """Record a child's terminal status and usage.
142
+
143
+ Returns ``False`` when no row exists for ``record_id`` — the child was
144
+ spawned without a registry, so there is nothing to finish.
145
+ """
146
+ ...
147
+
148
+ async def get_child(self, record_id: str) -> ChildRecord | None:
149
+ """One child record, or ``None``."""
150
+ ...
151
+
152
+ async def list_children(
153
+ self, parent_record_id: str, *, include_finished: bool = True
154
+ ) -> list[ChildRecord]:
155
+ """Direct children of one parent, oldest first."""
156
+ ...
157
+
158
+ async def list_all_children(
159
+ self, *, prefix: str | None = None
160
+ ) -> list[ChildRecord]:
161
+ """Every child record, optionally filtered by a ``parent_record_id``
162
+ prefix. Oldest first.
163
+
164
+ Enumerating all children is how a host answers "what did this session
165
+ spawn" after a restart, when no live pool exists.
166
+ """
167
+ ...
168
+
117
169
 
118
170
  from .in_memory import InMemoryStorage # noqa: E402
119
171
  from .sqlite_store import SqliteStorage # noqa: E402
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from collections.abc import Iterable
7
7
  from copy import deepcopy
8
+ from dataclasses import replace
8
9
  from typing import Any
9
10
 
10
11
  from steerable_agent_protocol.generated import (
@@ -16,6 +17,7 @@ from steerable_agent_protocol.generated import (
16
17
  TraceSpan,
17
18
  )
18
19
 
20
+ from ..child_record import TERMINAL_CHILD_STATUSES, ChildRecord
19
21
  from ..errors import StorageError
20
22
 
21
23
 
@@ -32,6 +34,10 @@ class InMemoryStorage:
32
34
  self._spans: dict[str, list[TraceSpan]] = {}
33
35
  self._events: dict[str, list[TraceEvent]] = {}
34
36
  self._history: dict[str, list[dict[str, Any]]] = {}
37
+ #: Insertion order preserved (dicts are ordered) so ``list_children``
38
+ #: returns the spawn order a host renders, matching the SQL
39
+ #: ``ORDER BY created_at, rowid``.
40
+ self._children: dict[str, ChildRecord] = {}
35
41
 
36
42
  # ------------------------------------------------------------------
37
43
  # Sessions
@@ -213,3 +219,71 @@ class InMemoryStorage:
213
219
  if prefix is not None:
214
220
  ids = [record_id for record_id in ids if record_id.startswith(prefix)]
215
221
  return sorted(ids)
222
+
223
+ # ------------------------------------------------------------------
224
+ # Child-agent index
225
+ # ------------------------------------------------------------------
226
+
227
+ async def upsert_child(self, record: ChildRecord) -> None:
228
+ async with self._lock:
229
+ # Re-inserting an existing record keeps its original position:
230
+ # reassigning an existing key in a dict does not reorder it.
231
+ self._children[record.record_id] = replace(record)
232
+
233
+ async def finish_child(
234
+ self,
235
+ record_id: str,
236
+ *,
237
+ status: str,
238
+ prompt_tokens: int | None = None,
239
+ completion_tokens: int | None = None,
240
+ usage_known: bool = True,
241
+ resumable: bool = False,
242
+ updated_at: str,
243
+ ) -> bool:
244
+ async with self._lock:
245
+ existing = self._children.get(record_id)
246
+ if existing is None:
247
+ return False
248
+ self._children[record_id] = existing.with_terminal_status(
249
+ status=status,
250
+ prompt_tokens=prompt_tokens,
251
+ completion_tokens=completion_tokens,
252
+ usage_known=usage_known,
253
+ resumable=resumable,
254
+ updated_at=updated_at,
255
+ )
256
+ return True
257
+
258
+ async def get_child(self, record_id: str) -> ChildRecord | None:
259
+ async with self._lock:
260
+ return self._children.get(record_id)
261
+
262
+ async def list_children(
263
+ self, parent_record_id: str, *, include_finished: bool = True
264
+ ) -> list[ChildRecord]:
265
+ async with self._lock:
266
+ records = [
267
+ record
268
+ for record in self._children.values()
269
+ if record.parent_record_id == parent_record_id
270
+ ]
271
+ if not include_finished:
272
+ records = [r for r in records if r.status not in TERMINAL_CHILD_STATUSES]
273
+ # Parity with the SQL ``ORDER BY created_at, rowid``: sort by
274
+ # ``created_at`` only — Python's sort is stable, so insertion order
275
+ # (this dict's order) breaks ties the way ``rowid`` does.
276
+ records.sort(key=lambda r: r.created_at)
277
+ return records
278
+
279
+ async def list_all_children(
280
+ self, *, prefix: str | None = None
281
+ ) -> list[ChildRecord]:
282
+ async with self._lock:
283
+ records = list(self._children.values())
284
+ if prefix is not None:
285
+ records = [
286
+ r for r in records if r.parent_record_id.startswith(prefix)
287
+ ]
288
+ records.sort(key=lambda r: r.created_at)
289
+ return records
@@ -36,6 +36,7 @@ except Exception as exc: # pragma: no cover - optional dep
36
36
  "`pip install steerable-agent-runtime[sqlalchemy]`."
37
37
  ) from exc
38
38
 
39
+ from ..child_record import TERMINAL_CHILD_STATUSES, ChildRecord
39
40
  from ..errors import StorageError
40
41
 
41
42
  metadata = MetaData()
@@ -156,6 +157,26 @@ history_table = Table(
156
157
  UniqueConstraint("recordId", "seq", name="steerable_history_entry_seq_uniq"),
157
158
  )
158
159
 
160
+ # Schema v2: the child-agent index. Only the edge and the state — task text
161
+ # and answers live in the child's own history record.
162
+ children_table = Table(
163
+ "steerable_child",
164
+ metadata,
165
+ Column("recordId", String(191), primary_key=True),
166
+ Column("parentRecordId", String(191), nullable=False, index=True),
167
+ Column("childId", String(191), nullable=False),
168
+ Column("depth", Integer, nullable=False),
169
+ Column("profile", String(191), nullable=True),
170
+ Column("engine", String(32), nullable=False),
171
+ Column("status", String(32), nullable=False),
172
+ Column("resumable", Boolean, nullable=False, default=False),
173
+ Column("promptTokens", Integer, nullable=True),
174
+ Column("completionTokens", Integer, nullable=True),
175
+ Column("usageKnown", Boolean, nullable=False, default=True),
176
+ Column("createdAt", DateTime, nullable=False),
177
+ Column("updatedAt", DateTime, nullable=False),
178
+ )
179
+
159
180
 
160
181
  def _model_to_row(model: Any, table: Table) -> dict[str, Any]:
161
182
  """Dump only the columns the table persists, coercing value types.
@@ -193,6 +214,62 @@ def _row_to_model(table: Table, row: dict[str, Any]) -> dict[str, Any]:
193
214
  return out
194
215
 
195
216
 
217
+ def _child_to_row(record: ChildRecord) -> dict[str, Any]:
218
+ """``ChildRecord`` → the ``children_table`` column values.
219
+
220
+ Timestamps cross from ISO strings to the table's ``DateTime`` columns,
221
+ the same bridge ``_model_to_row`` makes for protocol models.
222
+ """
223
+ return {
224
+ "recordId": record.record_id,
225
+ "parentRecordId": record.parent_record_id,
226
+ "childId": record.child_id,
227
+ "depth": record.depth,
228
+ "profile": record.profile,
229
+ "engine": record.engine,
230
+ "status": record.status,
231
+ "resumable": record.resumable,
232
+ "promptTokens": record.prompt_tokens,
233
+ "completionTokens": record.completion_tokens,
234
+ "usageKnown": record.usage_known,
235
+ "createdAt": _as_datetime(record.created_at),
236
+ "updatedAt": _as_datetime(record.updated_at),
237
+ }
238
+
239
+
240
+ def _child_from_row(row: dict[str, Any]) -> ChildRecord:
241
+ """Inverse of ``_child_to_row``."""
242
+ return ChildRecord(
243
+ record_id=row["recordId"],
244
+ parent_record_id=row["parentRecordId"],
245
+ child_id=row["childId"],
246
+ depth=int(row["depth"]),
247
+ profile=row["profile"],
248
+ engine=row["engine"],
249
+ status=row["status"],
250
+ resumable=bool(row["resumable"]),
251
+ prompt_tokens=row["promptTokens"],
252
+ completion_tokens=row["completionTokens"],
253
+ usage_known=bool(row["usageKnown"]),
254
+ created_at=_as_iso(row["createdAt"]),
255
+ updated_at=_as_iso(row["updatedAt"]),
256
+ )
257
+
258
+
259
+ def _as_datetime(value: str | datetime) -> datetime:
260
+ """ISO string or ``datetime`` → ``datetime`` (idempotent)."""
261
+ if isinstance(value, datetime):
262
+ return value
263
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
264
+
265
+
266
+ def _as_iso(value: str | datetime) -> str:
267
+ """``datetime`` or ISO string → ISO string (idempotent)."""
268
+ if isinstance(value, datetime):
269
+ return value.isoformat()
270
+ return value
271
+
272
+
196
273
  class SqlAlchemyStorage:
197
274
  """SQLAlchemy 2.0 async StorageAdapter."""
198
275
 
@@ -411,6 +488,82 @@ class SqlAlchemyStorage:
411
488
  )
412
489
  return sorted(row[0] for row in result.all())
413
490
 
491
+ # ------------------------------------------------------------------
492
+ # Child-agent index (schema v2)
493
+ # ------------------------------------------------------------------
494
+
495
+ async def upsert_child(self, record: ChildRecord) -> None:
496
+ await self._upsert(
497
+ children_table,
498
+ "recordId",
499
+ record.record_id,
500
+ _child_to_row(record),
501
+ )
502
+
503
+ async def finish_child(
504
+ self,
505
+ record_id: str,
506
+ *,
507
+ status: str,
508
+ prompt_tokens: int | None = None,
509
+ completion_tokens: int | None = None,
510
+ usage_known: bool = True,
511
+ resumable: bool = False,
512
+ updated_at: str,
513
+ ) -> bool:
514
+ existing = await self._get_one(
515
+ children_table, children_table.c.recordId == record_id
516
+ )
517
+ if existing is None:
518
+ return False
519
+ async with self._sessionmaker() as session:
520
+ await session.execute(
521
+ children_table.update()
522
+ .where(children_table.c.recordId == record_id)
523
+ .values(
524
+ status=status,
525
+ resumable=resumable,
526
+ promptTokens=prompt_tokens,
527
+ completionTokens=completion_tokens,
528
+ usageKnown=usage_known,
529
+ updatedAt=datetime.fromisoformat(updated_at.replace("Z", "+00:00")),
530
+ )
531
+ )
532
+ await session.commit()
533
+ return True
534
+
535
+ async def get_child(self, record_id: str) -> ChildRecord | None:
536
+ row = await self._get_one(
537
+ children_table, children_table.c.recordId == record_id
538
+ )
539
+ return _child_from_row(row) if row else None
540
+
541
+ async def list_children(
542
+ self, parent_record_id: str, *, include_finished: bool = True
543
+ ) -> list[ChildRecord]:
544
+ clauses = [children_table.c.parentRecordId == parent_record_id]
545
+ if not include_finished:
546
+ clauses.append(children_table.c.status.not_in(TERMINAL_CHILD_STATUSES))
547
+ rows = await self._select_many(
548
+ children_table,
549
+ clauses=clauses,
550
+ order_by=[children_table.c.createdAt.asc()],
551
+ )
552
+ return [_child_from_row(row) for row in rows]
553
+
554
+ async def list_all_children(
555
+ self, *, prefix: str | None = None
556
+ ) -> list[ChildRecord]:
557
+ clauses = []
558
+ if prefix is not None:
559
+ clauses.append(children_table.c.parentRecordId.startswith(prefix))
560
+ rows = await self._select_many(
561
+ children_table,
562
+ clauses=clauses,
563
+ order_by=[children_table.c.createdAt.asc()],
564
+ )
565
+ return [_child_from_row(row) for row in rows]
566
+
414
567
  # ------------------------------------------------------------------
415
568
  # Internal helpers
416
569
  # ------------------------------------------------------------------