subagent-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subagent/__init__.py +7 -0
- subagent/acp_client.py +366 -0
- subagent/approval_utils.py +57 -0
- subagent/cli.py +1125 -0
- subagent/config.py +305 -0
- subagent/constants.py +21 -0
- subagent/controller_service.py +267 -0
- subagent/daemon.py +133 -0
- subagent/errors.py +24 -0
- subagent/handoff_service.py +354 -0
- subagent/hints.py +36 -0
- subagent/input_contract.py +121 -0
- subagent/launcher_service.py +30 -0
- subagent/output.py +41 -0
- subagent/paths.py +63 -0
- subagent/prompt_service.py +114 -0
- subagent/runtime_service.py +342 -0
- subagent/simple_yaml.py +202 -0
- subagent/state.py +1049 -0
- subagent/turn_service.py +558 -0
- subagent/worker_runtime.py +758 -0
- subagent/worker_service.py +362 -0
- subagent_cli-0.1.1.dist-info/METADATA +98 -0
- subagent_cli-0.1.1.dist-info/RECORD +27 -0
- subagent_cli-0.1.1.dist-info/WHEEL +4 -0
- subagent_cli-0.1.1.dist-info/entry_points.txt +3 -0
- subagent_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""Long-lived per-worker ACP runtime process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .acp_client import AcpStdioClient
|
|
15
|
+
from .approval_utils import resolve_option
|
|
16
|
+
from .errors import SubagentError
|
|
17
|
+
from .state import (
|
|
18
|
+
WORKER_STATE_IDLE,
|
|
19
|
+
WORKER_STATE_RUNNING,
|
|
20
|
+
WORKER_STATE_WAITING_APPROVAL,
|
|
21
|
+
StateStore,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _extract_session_id(payload: Any) -> str:
|
|
26
|
+
if not isinstance(payload, dict):
|
|
27
|
+
raise SubagentError(
|
|
28
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
29
|
+
message="Backend returned invalid session response.",
|
|
30
|
+
details={"response": payload},
|
|
31
|
+
)
|
|
32
|
+
session_id = payload.get("sessionId")
|
|
33
|
+
if isinstance(session_id, str) and session_id:
|
|
34
|
+
return session_id
|
|
35
|
+
raise SubagentError(
|
|
36
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
37
|
+
message="Backend response is missing sessionId.",
|
|
38
|
+
details={"response": payload},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract_text_chunks(payload: Any) -> list[str]:
|
|
43
|
+
chunks: list[str] = []
|
|
44
|
+
|
|
45
|
+
def walk(node: Any) -> None:
|
|
46
|
+
if isinstance(node, dict):
|
|
47
|
+
node_type = node.get("type")
|
|
48
|
+
text = node.get("text")
|
|
49
|
+
if isinstance(text, str) and (not isinstance(node_type, str) or node_type == "text"):
|
|
50
|
+
chunks.append(text)
|
|
51
|
+
for value in node.values():
|
|
52
|
+
walk(value)
|
|
53
|
+
return
|
|
54
|
+
if isinstance(node, list):
|
|
55
|
+
for value in node:
|
|
56
|
+
walk(value)
|
|
57
|
+
|
|
58
|
+
walk(payload)
|
|
59
|
+
return chunks
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_permission_options(params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
63
|
+
options_payload = params.get("options")
|
|
64
|
+
if not isinstance(options_payload, list):
|
|
65
|
+
return []
|
|
66
|
+
options: list[dict[str, Any]] = []
|
|
67
|
+
for item in options_payload:
|
|
68
|
+
if not isinstance(item, dict):
|
|
69
|
+
continue
|
|
70
|
+
option_id = item.get("optionId")
|
|
71
|
+
if not isinstance(option_id, str) or not option_id:
|
|
72
|
+
option_id = item.get("id")
|
|
73
|
+
if not isinstance(option_id, str) or not option_id:
|
|
74
|
+
continue
|
|
75
|
+
label = item.get("name")
|
|
76
|
+
options.append(
|
|
77
|
+
{
|
|
78
|
+
"id": option_id,
|
|
79
|
+
"alias": option_id.lower(),
|
|
80
|
+
"label": str(label) if isinstance(label, str) and label else option_id,
|
|
81
|
+
"kind": item.get("kind"),
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return options
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_prompt_blocks(text: str, blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
88
|
+
output: list[dict[str, Any]] = []
|
|
89
|
+
if text.strip():
|
|
90
|
+
output.append({"type": "text", "text": text})
|
|
91
|
+
output.extend(blocks)
|
|
92
|
+
return output
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class WorkerRuntime:
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
db_path: Path,
|
|
100
|
+
worker_id: str,
|
|
101
|
+
socket_path: Path,
|
|
102
|
+
launcher_command: str,
|
|
103
|
+
launcher_args: list[str],
|
|
104
|
+
launcher_env: dict[str, str],
|
|
105
|
+
cwd: Path,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.store = StateStore(db_path)
|
|
108
|
+
self.worker_id = worker_id
|
|
109
|
+
self.socket_path = socket_path
|
|
110
|
+
self.launcher_command = launcher_command
|
|
111
|
+
self.launcher_args = launcher_args
|
|
112
|
+
self.launcher_env = launcher_env
|
|
113
|
+
self.cwd = cwd
|
|
114
|
+
|
|
115
|
+
self.client: AcpStdioClient | None = None
|
|
116
|
+
self.session_id: str = ""
|
|
117
|
+
self.server_socket: socket.socket | None = None
|
|
118
|
+
self._shutdown_requested = False
|
|
119
|
+
|
|
120
|
+
self._lock = threading.RLock()
|
|
121
|
+
self._cv = threading.Condition(self._lock)
|
|
122
|
+
self._turn_thread: threading.Thread | None = None
|
|
123
|
+
self._active_turn_id: str | None = None
|
|
124
|
+
self._turn_result: dict[str, Any] | None = None
|
|
125
|
+
self._turn_error: SubagentError | None = None
|
|
126
|
+
self._pending_permission: dict[str, Any] | None = None
|
|
127
|
+
self._cancel_requested = False
|
|
128
|
+
self._cancel_reason = "canceled by manager"
|
|
129
|
+
|
|
130
|
+
def run(self) -> int:
|
|
131
|
+
self.store.bootstrap()
|
|
132
|
+
worker = self.store.get_worker(self.worker_id)
|
|
133
|
+
if worker is None:
|
|
134
|
+
raise SubagentError(
|
|
135
|
+
code="WORKER_NOT_FOUND",
|
|
136
|
+
message=f"Worker not found: {self.worker_id}",
|
|
137
|
+
details={"workerId": self.worker_id},
|
|
138
|
+
)
|
|
139
|
+
self.client = AcpStdioClient(
|
|
140
|
+
command=self.launcher_command,
|
|
141
|
+
args=self.launcher_args,
|
|
142
|
+
cwd=self.cwd,
|
|
143
|
+
env=self.launcher_env,
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
self.client.request(
|
|
147
|
+
"initialize",
|
|
148
|
+
{
|
|
149
|
+
"protocolVersion": 1,
|
|
150
|
+
"clientInfo": {"name": "subagent-cli", "version": __version__},
|
|
151
|
+
"clientCapabilities": {
|
|
152
|
+
"fs": {"readTextFile": False, "writeTextFile": False},
|
|
153
|
+
"terminal": False,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
timeout_seconds=10.0,
|
|
157
|
+
)
|
|
158
|
+
existing_session_id = worker.get("session_id")
|
|
159
|
+
resumed_session = False
|
|
160
|
+
if isinstance(existing_session_id, str) and existing_session_id:
|
|
161
|
+
try:
|
|
162
|
+
session_payload = self.client.request(
|
|
163
|
+
"session/load",
|
|
164
|
+
{"sessionId": existing_session_id},
|
|
165
|
+
timeout_seconds=15.0,
|
|
166
|
+
)
|
|
167
|
+
except SubagentError:
|
|
168
|
+
session_payload = self.client.request(
|
|
169
|
+
"session/new",
|
|
170
|
+
{"cwd": str(self.cwd), "mcpServers": []},
|
|
171
|
+
timeout_seconds=15.0,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
resumed_session = True
|
|
175
|
+
else:
|
|
176
|
+
session_payload = self.client.request(
|
|
177
|
+
"session/new",
|
|
178
|
+
{"cwd": str(self.cwd), "mcpServers": []},
|
|
179
|
+
timeout_seconds=15.0,
|
|
180
|
+
)
|
|
181
|
+
self.session_id = _extract_session_id(session_payload)
|
|
182
|
+
self.store.set_worker_session_id(self.worker_id, self.session_id)
|
|
183
|
+
self.store.append_worker_event(
|
|
184
|
+
self.worker_id,
|
|
185
|
+
event_type="progress.update",
|
|
186
|
+
turn_id=None,
|
|
187
|
+
data={"method": "session.load" if resumed_session else "session.new"},
|
|
188
|
+
raw={
|
|
189
|
+
"runtime": "acp-stdio",
|
|
190
|
+
"phase": "session.start",
|
|
191
|
+
"sessionId": self.session_id,
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
self.store.set_worker_runtime_endpoint(
|
|
195
|
+
self.worker_id,
|
|
196
|
+
runtime_pid=os.getpid(),
|
|
197
|
+
runtime_socket=str(self.socket_path),
|
|
198
|
+
)
|
|
199
|
+
self.store.update_worker_state(
|
|
200
|
+
self.worker_id,
|
|
201
|
+
next_state=WORKER_STATE_IDLE,
|
|
202
|
+
allow_any_transition=True,
|
|
203
|
+
)
|
|
204
|
+
return self._serve()
|
|
205
|
+
finally:
|
|
206
|
+
try:
|
|
207
|
+
self.store.clear_worker_runtime_endpoint(self.worker_id)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
if self.client is not None:
|
|
211
|
+
self.client.close()
|
|
212
|
+
if self.server_socket is not None:
|
|
213
|
+
try:
|
|
214
|
+
self.server_socket.close()
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
if self.socket_path.exists():
|
|
218
|
+
try:
|
|
219
|
+
self.socket_path.unlink()
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
def _serve(self) -> int:
|
|
224
|
+
if self.socket_path.exists():
|
|
225
|
+
self.socket_path.unlink()
|
|
226
|
+
self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
227
|
+
self.server_socket.bind(str(self.socket_path))
|
|
228
|
+
self.server_socket.listen(8)
|
|
229
|
+
while not self._shutdown_requested:
|
|
230
|
+
try:
|
|
231
|
+
conn, _ = self.server_socket.accept()
|
|
232
|
+
except OSError:
|
|
233
|
+
if self._shutdown_requested:
|
|
234
|
+
break
|
|
235
|
+
raise
|
|
236
|
+
thread = threading.Thread(
|
|
237
|
+
target=self._handle_connection_socket,
|
|
238
|
+
args=(conn,),
|
|
239
|
+
daemon=True,
|
|
240
|
+
name=f"worker-runtime-socket-{self.worker_id}",
|
|
241
|
+
)
|
|
242
|
+
thread.start()
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
def _handle_connection_socket(self, conn: socket.socket) -> None:
|
|
246
|
+
with conn:
|
|
247
|
+
response = self._handle_connection(conn)
|
|
248
|
+
try:
|
|
249
|
+
conn.sendall((json.dumps(response, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
250
|
+
except OSError:
|
|
251
|
+
# The client may disconnect while runtime is shutting down.
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
def _handle_connection(self, conn: socket.socket) -> dict[str, Any]:
|
|
255
|
+
try:
|
|
256
|
+
data = self._read_line(conn)
|
|
257
|
+
request = json.loads(data)
|
|
258
|
+
if not isinstance(request, dict):
|
|
259
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request must be an object.")
|
|
260
|
+
method = request.get("method")
|
|
261
|
+
params = request.get("params")
|
|
262
|
+
if not isinstance(method, str) or not method:
|
|
263
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request missing method.")
|
|
264
|
+
if not isinstance(params, dict):
|
|
265
|
+
params = {}
|
|
266
|
+
result = self._dispatch(method, params)
|
|
267
|
+
return {"ok": True, "result": result}
|
|
268
|
+
except SubagentError as error:
|
|
269
|
+
return {"ok": False, "error": error.to_dict()}
|
|
270
|
+
except Exception as error: # pragma: no cover - defensive
|
|
271
|
+
err = SubagentError(
|
|
272
|
+
code="BACKEND_RUNTIME_ERROR",
|
|
273
|
+
message=f"Runtime internal error: {error}",
|
|
274
|
+
)
|
|
275
|
+
return {"ok": False, "error": err.to_dict()}
|
|
276
|
+
|
|
277
|
+
def _read_line(self, conn: socket.socket) -> str:
|
|
278
|
+
chunks: list[bytes] = []
|
|
279
|
+
while True:
|
|
280
|
+
block = conn.recv(4096)
|
|
281
|
+
if not block:
|
|
282
|
+
break
|
|
283
|
+
chunks.append(block)
|
|
284
|
+
if b"\n" in block:
|
|
285
|
+
break
|
|
286
|
+
if not chunks:
|
|
287
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request is empty.")
|
|
288
|
+
return b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
289
|
+
|
|
290
|
+
def _dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
291
|
+
if method == "ping":
|
|
292
|
+
return {"workerId": self.worker_id, "sessionId": self.session_id}
|
|
293
|
+
if method == "start_turn":
|
|
294
|
+
return self._handle_start_turn(params)
|
|
295
|
+
if method == "approve":
|
|
296
|
+
return self._handle_approve(params)
|
|
297
|
+
if method == "cancel_turn":
|
|
298
|
+
return self._handle_cancel_turn(params)
|
|
299
|
+
if method == "stop":
|
|
300
|
+
return self._handle_stop(params)
|
|
301
|
+
raise SubagentError(
|
|
302
|
+
code="INVALID_ARGUMENT",
|
|
303
|
+
message=f"Unknown runtime method: {method}",
|
|
304
|
+
details={"method": method},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _handle_start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
308
|
+
turn_id = params.get("turnId")
|
|
309
|
+
text = params.get("text")
|
|
310
|
+
blocks = params.get("blocks")
|
|
311
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
312
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`turnId` is required.")
|
|
313
|
+
if not isinstance(text, str):
|
|
314
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`text` must be a string.")
|
|
315
|
+
if not isinstance(blocks, list):
|
|
316
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`blocks` must be a list.")
|
|
317
|
+
normalized_blocks: list[dict[str, Any]] = []
|
|
318
|
+
for index, item in enumerate(blocks):
|
|
319
|
+
if not isinstance(item, dict):
|
|
320
|
+
raise SubagentError(
|
|
321
|
+
code="INVALID_ARGUMENT",
|
|
322
|
+
message=f"`blocks[{index}]` must be an object.",
|
|
323
|
+
)
|
|
324
|
+
normalized_blocks.append(item)
|
|
325
|
+
|
|
326
|
+
with self._cv:
|
|
327
|
+
if self._active_turn_id is not None:
|
|
328
|
+
raise SubagentError(
|
|
329
|
+
code="WORKER_BUSY",
|
|
330
|
+
message="worker has an active turn",
|
|
331
|
+
details={"workerId": self.worker_id, "turnId": self._active_turn_id},
|
|
332
|
+
)
|
|
333
|
+
self._active_turn_id = turn_id
|
|
334
|
+
self._turn_result = None
|
|
335
|
+
self._turn_error = None
|
|
336
|
+
self._pending_permission = None
|
|
337
|
+
self._cancel_requested = False
|
|
338
|
+
self._cancel_reason = "canceled by manager"
|
|
339
|
+
self._turn_thread = threading.Thread(
|
|
340
|
+
target=self._run_turn,
|
|
341
|
+
args=(turn_id, text, normalized_blocks),
|
|
342
|
+
daemon=True,
|
|
343
|
+
name=f"worker-turn-{self.worker_id}",
|
|
344
|
+
)
|
|
345
|
+
self._turn_thread.start()
|
|
346
|
+
|
|
347
|
+
while True:
|
|
348
|
+
if self._turn_error is not None:
|
|
349
|
+
raise self._turn_error
|
|
350
|
+
if self._pending_permission is not None:
|
|
351
|
+
return {
|
|
352
|
+
"workerId": self.worker_id,
|
|
353
|
+
"turnId": turn_id,
|
|
354
|
+
"state": WORKER_STATE_WAITING_APPROVAL,
|
|
355
|
+
"requestId": self._pending_permission["request_id"],
|
|
356
|
+
}
|
|
357
|
+
if self._turn_result is not None:
|
|
358
|
+
return self._turn_result
|
|
359
|
+
self._cv.wait(timeout=0.1)
|
|
360
|
+
|
|
361
|
+
def _run_turn(self, turn_id: str, text: str, blocks: list[dict[str, Any]]) -> None:
|
|
362
|
+
assert self.client is not None
|
|
363
|
+
|
|
364
|
+
def on_notification(method: str, params: dict[str, Any]) -> None:
|
|
365
|
+
if method != "session/update":
|
|
366
|
+
self.store.append_worker_event(
|
|
367
|
+
self.worker_id,
|
|
368
|
+
event_type="progress.update",
|
|
369
|
+
turn_id=turn_id,
|
|
370
|
+
data={"method": method},
|
|
371
|
+
raw={
|
|
372
|
+
"runtime": "acp-stdio",
|
|
373
|
+
"phase": "notification",
|
|
374
|
+
"method": method,
|
|
375
|
+
"params": params,
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
update = params.get("update")
|
|
380
|
+
text_chunks = _extract_text_chunks(update)
|
|
381
|
+
if not text_chunks:
|
|
382
|
+
self.store.append_worker_event(
|
|
383
|
+
self.worker_id,
|
|
384
|
+
event_type="progress.update",
|
|
385
|
+
turn_id=turn_id,
|
|
386
|
+
data={"method": "session/update"},
|
|
387
|
+
raw={"runtime": "acp-stdio", "phase": "session.update", "update": update},
|
|
388
|
+
)
|
|
389
|
+
return
|
|
390
|
+
for chunk in text_chunks:
|
|
391
|
+
self.store.append_worker_event(
|
|
392
|
+
self.worker_id,
|
|
393
|
+
event_type="progress.message",
|
|
394
|
+
turn_id=turn_id,
|
|
395
|
+
data={"role": "assistant", "text": chunk},
|
|
396
|
+
raw={"runtime": "acp-stdio", "phase": "session.update", "update": update},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def on_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
400
|
+
if method != "session/request_permission":
|
|
401
|
+
raise SubagentError(
|
|
402
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
403
|
+
message=f"Unsupported backend request method: {method}",
|
|
404
|
+
details={"method": method},
|
|
405
|
+
)
|
|
406
|
+
options = _normalize_permission_options(params)
|
|
407
|
+
message = "Backend requested permission for a tool call."
|
|
408
|
+
tool_call = params.get("toolCall")
|
|
409
|
+
if isinstance(tool_call, dict):
|
|
410
|
+
kind = tool_call.get("kind")
|
|
411
|
+
if isinstance(kind, str) and kind:
|
|
412
|
+
message = f"Backend requested permission for `{kind}`."
|
|
413
|
+
request = self.store.create_approval_request(
|
|
414
|
+
self.worker_id,
|
|
415
|
+
turn_id=turn_id,
|
|
416
|
+
message=message,
|
|
417
|
+
kind="tool.call",
|
|
418
|
+
options=options or None,
|
|
419
|
+
)
|
|
420
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_WAITING_APPROVAL)
|
|
421
|
+
self.store.append_worker_event(
|
|
422
|
+
self.worker_id,
|
|
423
|
+
event_type="approval.requested",
|
|
424
|
+
turn_id=turn_id,
|
|
425
|
+
data={
|
|
426
|
+
"requestId": request["request_id"],
|
|
427
|
+
"kind": request["kind"],
|
|
428
|
+
"message": request["message"],
|
|
429
|
+
"options": request["options"],
|
|
430
|
+
},
|
|
431
|
+
raw={
|
|
432
|
+
"runtime": "acp-stdio",
|
|
433
|
+
"phase": "approval.requested",
|
|
434
|
+
"request": params,
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
with self._cv:
|
|
438
|
+
self._pending_permission = {
|
|
439
|
+
"request_id": request["request_id"],
|
|
440
|
+
"response": None,
|
|
441
|
+
}
|
|
442
|
+
self._cv.notify_all()
|
|
443
|
+
while True:
|
|
444
|
+
pending = self._pending_permission
|
|
445
|
+
if pending is None:
|
|
446
|
+
raise SubagentError(
|
|
447
|
+
code="BACKEND_RUNTIME_ERROR",
|
|
448
|
+
message="Pending permission state was lost.",
|
|
449
|
+
)
|
|
450
|
+
response = pending.get("response")
|
|
451
|
+
if isinstance(response, dict):
|
|
452
|
+
self._pending_permission = None
|
|
453
|
+
break
|
|
454
|
+
self._cv.wait(timeout=0.1)
|
|
455
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
|
|
456
|
+
return response
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
response = self.client.request(
|
|
460
|
+
"session/prompt",
|
|
461
|
+
{
|
|
462
|
+
"sessionId": self.session_id,
|
|
463
|
+
"prompt": _build_prompt_blocks(text, blocks),
|
|
464
|
+
},
|
|
465
|
+
timeout_seconds=3600.0,
|
|
466
|
+
on_notification=on_notification,
|
|
467
|
+
on_request=on_request,
|
|
468
|
+
)
|
|
469
|
+
stop_reason = "completed"
|
|
470
|
+
if isinstance(response, dict):
|
|
471
|
+
raw_reason = response.get("stopReason")
|
|
472
|
+
if isinstance(raw_reason, str) and raw_reason:
|
|
473
|
+
stop_reason = raw_reason
|
|
474
|
+
|
|
475
|
+
with self._cv:
|
|
476
|
+
canceled = self._cancel_requested
|
|
477
|
+
cancel_reason = self._cancel_reason
|
|
478
|
+
if canceled:
|
|
479
|
+
canceled_event = self.store.append_worker_event(
|
|
480
|
+
self.worker_id,
|
|
481
|
+
event_type="turn.canceled",
|
|
482
|
+
turn_id=turn_id,
|
|
483
|
+
data={"turnId": turn_id, "reason": cancel_reason},
|
|
484
|
+
raw={"runtime": "acp-stdio", "phase": "turn.canceled"},
|
|
485
|
+
)
|
|
486
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_IDLE)
|
|
487
|
+
result = {
|
|
488
|
+
"workerId": self.worker_id,
|
|
489
|
+
"turnId": turn_id,
|
|
490
|
+
"state": WORKER_STATE_IDLE,
|
|
491
|
+
"eventId": canceled_event["event_id"],
|
|
492
|
+
}
|
|
493
|
+
else:
|
|
494
|
+
completed_event = self.store.append_worker_event(
|
|
495
|
+
self.worker_id,
|
|
496
|
+
event_type="turn.completed",
|
|
497
|
+
turn_id=turn_id,
|
|
498
|
+
data={
|
|
499
|
+
"turnId": turn_id,
|
|
500
|
+
"outcome": "completed",
|
|
501
|
+
"state": WORKER_STATE_IDLE,
|
|
502
|
+
"stopReason": stop_reason,
|
|
503
|
+
},
|
|
504
|
+
raw={"runtime": "acp-stdio", "phase": "turn.completed"},
|
|
505
|
+
)
|
|
506
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_IDLE)
|
|
507
|
+
result = {
|
|
508
|
+
"workerId": self.worker_id,
|
|
509
|
+
"turnId": turn_id,
|
|
510
|
+
"state": WORKER_STATE_IDLE,
|
|
511
|
+
"eventId": completed_event["event_id"],
|
|
512
|
+
"stopReason": stop_reason,
|
|
513
|
+
}
|
|
514
|
+
with self._cv:
|
|
515
|
+
self._active_turn_id = None
|
|
516
|
+
self._turn_result = result
|
|
517
|
+
self._cv.notify_all()
|
|
518
|
+
except SubagentError as error:
|
|
519
|
+
self.store.append_worker_event(
|
|
520
|
+
self.worker_id,
|
|
521
|
+
event_type="turn.failed",
|
|
522
|
+
turn_id=turn_id,
|
|
523
|
+
data={"turnId": turn_id, "error": error.to_dict()},
|
|
524
|
+
raw={"runtime": "acp-stdio", "phase": "turn.failed"},
|
|
525
|
+
)
|
|
526
|
+
self.store.update_worker_state(
|
|
527
|
+
self.worker_id,
|
|
528
|
+
next_state=WORKER_STATE_IDLE,
|
|
529
|
+
last_error=error.message,
|
|
530
|
+
)
|
|
531
|
+
with self._cv:
|
|
532
|
+
self._active_turn_id = None
|
|
533
|
+
self._turn_error = error
|
|
534
|
+
self._cv.notify_all()
|
|
535
|
+
|
|
536
|
+
def _wait_for_turn_terminal(self) -> dict[str, Any]:
|
|
537
|
+
with self._cv:
|
|
538
|
+
while True:
|
|
539
|
+
if self._turn_error is not None:
|
|
540
|
+
raise self._turn_error
|
|
541
|
+
if self._turn_result is not None:
|
|
542
|
+
return self._turn_result
|
|
543
|
+
self._cv.wait(timeout=0.1)
|
|
544
|
+
|
|
545
|
+
def _handle_approve(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
546
|
+
request_id = params.get("requestId")
|
|
547
|
+
decision = params.get("decision")
|
|
548
|
+
option_id = params.get("optionId")
|
|
549
|
+
alias = params.get("alias")
|
|
550
|
+
note = params.get("note")
|
|
551
|
+
if not isinstance(request_id, str) or not request_id:
|
|
552
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`requestId` is required.")
|
|
553
|
+
if decision is not None and not isinstance(decision, str):
|
|
554
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`decision` must be a string.")
|
|
555
|
+
if option_id is not None and not isinstance(option_id, str):
|
|
556
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`optionId` must be a string.")
|
|
557
|
+
if alias is not None and not isinstance(alias, str):
|
|
558
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`alias` must be a string.")
|
|
559
|
+
if note is not None and not isinstance(note, str):
|
|
560
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`note` must be a string.")
|
|
561
|
+
|
|
562
|
+
with self._cv:
|
|
563
|
+
pending = self._pending_permission
|
|
564
|
+
if pending is None:
|
|
565
|
+
raise SubagentError(
|
|
566
|
+
code="APPROVAL_NOT_FOUND",
|
|
567
|
+
message=f"Approval request not found: {request_id}",
|
|
568
|
+
details={"workerId": self.worker_id, "requestId": request_id},
|
|
569
|
+
)
|
|
570
|
+
pending_id = pending.get("request_id")
|
|
571
|
+
if str(pending_id) != request_id:
|
|
572
|
+
raise SubagentError(
|
|
573
|
+
code="APPROVAL_NOT_FOUND",
|
|
574
|
+
message=f"Approval request not found: {request_id}",
|
|
575
|
+
details={"workerId": self.worker_id, "requestId": request_id, "pendingRequestId": pending_id},
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
request = self.store.get_approval_request(self.worker_id, request_id)
|
|
579
|
+
if request is None:
|
|
580
|
+
raise SubagentError(
|
|
581
|
+
code="APPROVAL_NOT_FOUND",
|
|
582
|
+
message=f"Approval request not found: {request_id}",
|
|
583
|
+
details={"workerId": self.worker_id, "requestId": request_id},
|
|
584
|
+
)
|
|
585
|
+
selected_option_id, selected_alias, resolved_decision = resolve_option(
|
|
586
|
+
request,
|
|
587
|
+
decision=decision,
|
|
588
|
+
option_id=option_id,
|
|
589
|
+
alias=alias,
|
|
590
|
+
)
|
|
591
|
+
decided = self.store.decide_approval_request(
|
|
592
|
+
self.worker_id,
|
|
593
|
+
request_id,
|
|
594
|
+
decision=resolved_decision,
|
|
595
|
+
selected_option_id=selected_option_id,
|
|
596
|
+
selected_alias=selected_alias,
|
|
597
|
+
note=note,
|
|
598
|
+
)
|
|
599
|
+
turn_id = request.get("turn_id")
|
|
600
|
+
self.store.append_worker_event(
|
|
601
|
+
self.worker_id,
|
|
602
|
+
event_type="approval.decided",
|
|
603
|
+
turn_id=str(turn_id) if isinstance(turn_id, str) else None,
|
|
604
|
+
data={
|
|
605
|
+
"requestId": request_id,
|
|
606
|
+
"decision": decided["decision"],
|
|
607
|
+
"optionId": decided["selected_option_id"],
|
|
608
|
+
"alias": decided["selected_alias"],
|
|
609
|
+
"note": note,
|
|
610
|
+
},
|
|
611
|
+
raw={"runtime": "acp-stdio", "phase": "approval.decided"},
|
|
612
|
+
)
|
|
613
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
|
|
614
|
+
|
|
615
|
+
response_payload: dict[str, Any] = {"outcome": {"outcome": "selected", "optionId": selected_option_id}}
|
|
616
|
+
if selected_option_id in {"cancel", "cancelled"} or selected_alias in {"cancel", "cancelled"}:
|
|
617
|
+
response_payload = {"outcome": {"outcome": "cancelled"}}
|
|
618
|
+
|
|
619
|
+
with self._cv:
|
|
620
|
+
pending = self._pending_permission
|
|
621
|
+
if pending is None:
|
|
622
|
+
raise SubagentError(
|
|
623
|
+
code="APPROVAL_NOT_FOUND",
|
|
624
|
+
message=f"Approval request not found: {request_id}",
|
|
625
|
+
details={"workerId": self.worker_id, "requestId": request_id},
|
|
626
|
+
)
|
|
627
|
+
pending["response"] = response_payload
|
|
628
|
+
self._cv.notify_all()
|
|
629
|
+
|
|
630
|
+
result = self._wait_for_turn_terminal()
|
|
631
|
+
return {
|
|
632
|
+
"workerId": self.worker_id,
|
|
633
|
+
"requestId": request_id,
|
|
634
|
+
"decision": decided["decision"],
|
|
635
|
+
"optionId": decided["selected_option_id"],
|
|
636
|
+
"alias": decided["selected_alias"],
|
|
637
|
+
"state": result["state"],
|
|
638
|
+
"eventId": result["eventId"],
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
def _handle_cancel_turn(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
642
|
+
reason = params.get("reason")
|
|
643
|
+
if reason is None:
|
|
644
|
+
reason = "canceled by manager"
|
|
645
|
+
if not isinstance(reason, str):
|
|
646
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`reason` must be a string.")
|
|
647
|
+
|
|
648
|
+
with self._cv:
|
|
649
|
+
if self._active_turn_id is None:
|
|
650
|
+
raise SubagentError(
|
|
651
|
+
code="WORKER_NOT_RUNNING",
|
|
652
|
+
message="worker has no active turn to cancel",
|
|
653
|
+
details={"workerId": self.worker_id},
|
|
654
|
+
)
|
|
655
|
+
self._cancel_requested = True
|
|
656
|
+
self._cancel_reason = reason
|
|
657
|
+
pending = self._pending_permission
|
|
658
|
+
|
|
659
|
+
if pending is not None:
|
|
660
|
+
request_id = str(pending["request_id"])
|
|
661
|
+
request = self.store.get_approval_request(self.worker_id, request_id)
|
|
662
|
+
if request is not None:
|
|
663
|
+
self.store.decide_approval_request(
|
|
664
|
+
self.worker_id,
|
|
665
|
+
request_id,
|
|
666
|
+
decision="cancelled",
|
|
667
|
+
selected_option_id="cancelled",
|
|
668
|
+
selected_alias="cancelled",
|
|
669
|
+
note=reason,
|
|
670
|
+
)
|
|
671
|
+
turn_id = request.get("turn_id")
|
|
672
|
+
self.store.append_worker_event(
|
|
673
|
+
self.worker_id,
|
|
674
|
+
event_type="approval.decided",
|
|
675
|
+
turn_id=str(turn_id) if isinstance(turn_id, str) else None,
|
|
676
|
+
data={
|
|
677
|
+
"requestId": request_id,
|
|
678
|
+
"decision": "cancelled",
|
|
679
|
+
"optionId": "cancelled",
|
|
680
|
+
"alias": "cancelled",
|
|
681
|
+
"note": reason,
|
|
682
|
+
},
|
|
683
|
+
raw={"runtime": "acp-stdio", "phase": "approval.decided"},
|
|
684
|
+
)
|
|
685
|
+
self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
|
|
686
|
+
with self._cv:
|
|
687
|
+
if self._pending_permission is not None:
|
|
688
|
+
self._pending_permission["response"] = {"outcome": {"outcome": "cancelled"}}
|
|
689
|
+
self._cv.notify_all()
|
|
690
|
+
else:
|
|
691
|
+
assert self.client is not None
|
|
692
|
+
self.client.notify(
|
|
693
|
+
"session/cancel",
|
|
694
|
+
{"sessionId": self.session_id},
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
result = self._wait_for_turn_terminal()
|
|
698
|
+
return {
|
|
699
|
+
"workerId": self.worker_id,
|
|
700
|
+
"state": result["state"],
|
|
701
|
+
"eventId": result["eventId"],
|
|
702
|
+
"turnId": result["turnId"],
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
def _handle_stop(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
706
|
+
reason = params.get("reason")
|
|
707
|
+
if reason is None:
|
|
708
|
+
reason = "worker stopped"
|
|
709
|
+
if not isinstance(reason, str):
|
|
710
|
+
raise SubagentError(code="INVALID_ARGUMENT", message="`reason` must be a string.")
|
|
711
|
+
with self._cv:
|
|
712
|
+
active_turn_id = self._active_turn_id
|
|
713
|
+
if active_turn_id is not None:
|
|
714
|
+
self._handle_cancel_turn({"reason": reason})
|
|
715
|
+
self._shutdown_requested = True
|
|
716
|
+
server_socket = self.server_socket
|
|
717
|
+
if server_socket is not None:
|
|
718
|
+
try:
|
|
719
|
+
server_socket.close()
|
|
720
|
+
except OSError:
|
|
721
|
+
pass
|
|
722
|
+
return {"workerId": self.worker_id, "stopped": True}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _parse_args() -> argparse.Namespace:
|
|
726
|
+
parser = argparse.ArgumentParser(description="subagent worker runtime")
|
|
727
|
+
parser.add_argument("--db-path", required=True)
|
|
728
|
+
parser.add_argument("--worker-id", required=True)
|
|
729
|
+
parser.add_argument("--socket-path", required=True)
|
|
730
|
+
parser.add_argument("--launcher-command", required=True)
|
|
731
|
+
parser.add_argument("--launcher-args-json", required=True)
|
|
732
|
+
parser.add_argument("--launcher-env-json", required=True)
|
|
733
|
+
parser.add_argument("--cwd", required=True)
|
|
734
|
+
return parser.parse_args()
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def main() -> None:
|
|
738
|
+
args = _parse_args()
|
|
739
|
+
launcher_args = json.loads(args.launcher_args_json)
|
|
740
|
+
launcher_env = json.loads(args.launcher_env_json)
|
|
741
|
+
if not isinstance(launcher_args, list):
|
|
742
|
+
raise SystemExit(2)
|
|
743
|
+
if not isinstance(launcher_env, dict):
|
|
744
|
+
raise SystemExit(2)
|
|
745
|
+
runtime = WorkerRuntime(
|
|
746
|
+
db_path=Path(args.db_path).expanduser().resolve(),
|
|
747
|
+
worker_id=str(args.worker_id),
|
|
748
|
+
socket_path=Path(args.socket_path),
|
|
749
|
+
launcher_command=str(args.launcher_command),
|
|
750
|
+
launcher_args=[str(item) for item in launcher_args],
|
|
751
|
+
launcher_env={str(key): str(value) for key, value in launcher_env.items()},
|
|
752
|
+
cwd=Path(args.cwd).expanduser().resolve(),
|
|
753
|
+
)
|
|
754
|
+
raise SystemExit(runtime.run())
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
if __name__ == "__main__":
|
|
758
|
+
main()
|