@steerable/runtime-win32-x64 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.
- package/package.json +1 -1
- package/python-runtime/manifest.json +1 -1
- package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/RECORD +7 -7
- package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.0.dist-info/RECORD +11 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +48 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/budget.py +124 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_notice.py +84 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/child_record.py +174 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/engines.py +499 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/history.py +114 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +496 -33
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +662 -16
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/registry.py +345 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/__init__.py +52 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py +74 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py +153 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlite_store.py +117 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +157 -6
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/METADATA +38 -3
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/RECORD +26 -16
- package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +545 -6
- package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.10.0.dist-info/METADATA +97 -0
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/RECORD +12 -12
- package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
- package/python-runtime/python/Scripts/dotenv.exe +0 -0
- package/python-runtime/python/Scripts/httpx.exe +0 -0
- package/python-runtime/python/Scripts/idna.exe +0 -0
- package/python-runtime/python/Scripts/jsonschema.exe +0 -0
- package/python-runtime/python/Scripts/mcp.exe +0 -0
- package/python-runtime/python/Scripts/pip.exe +0 -0
- package/python-runtime/python/Scripts/pip3.12.exe +0 -0
- package/python-runtime/python/Scripts/pip3.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
- package/python-runtime/python/Scripts/uvicorn.exe +0 -0
- package/runtime.json +80 -60
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.9.2.dist-info/RECORD +0 -11
- package/python-runtime/python/Lib/site-packages/steerable_sidecar-0.9.2.dist-info/METADATA +0 -58
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.9.2.dist-info → steerable_agent_harness-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.9.2.dist-info → steerable_agent_protocol-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.9.2.dist-info → steerable_agent_runtime-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.9.2.dist-info → steerable_sidecar-0.10.0.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
|
package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/in_memory.py
CHANGED
|
@@ -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
|
package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/storage/sqlalchemy_store.py
CHANGED
|
@@ -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
|
# ------------------------------------------------------------------
|