@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,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
|
|