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
subagent/__init__.py
ADDED
subagent/acp_client.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Minimal ACP stdio JSON-RPC client used by turn execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import deque
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from .errors import SubagentError
|
|
16
|
+
|
|
17
|
+
NotificationHandler = Callable[[str, dict[str, Any]], None]
|
|
18
|
+
RequestHandler = Callable[[str, dict[str, Any]], Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AcpStdioClient:
|
|
22
|
+
"""Line-oriented JSON-RPC client for ACP stdio adapters."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
command: str,
|
|
28
|
+
args: list[str],
|
|
29
|
+
cwd: Path,
|
|
30
|
+
env: dict[str, str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
merged_env = dict(os.environ)
|
|
33
|
+
if env:
|
|
34
|
+
merged_env.update(env)
|
|
35
|
+
try:
|
|
36
|
+
self._proc = subprocess.Popen(
|
|
37
|
+
[command, *args],
|
|
38
|
+
stdin=subprocess.PIPE,
|
|
39
|
+
stdout=subprocess.PIPE,
|
|
40
|
+
stderr=subprocess.PIPE,
|
|
41
|
+
cwd=str(cwd),
|
|
42
|
+
env=merged_env,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
bufsize=1,
|
|
46
|
+
)
|
|
47
|
+
except FileNotFoundError as exc:
|
|
48
|
+
raise SubagentError(
|
|
49
|
+
code="BACKEND_UNAVAILABLE",
|
|
50
|
+
message=f"Launcher command not found: {command}",
|
|
51
|
+
details={"command": command},
|
|
52
|
+
) from exc
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise SubagentError(
|
|
55
|
+
code="BACKEND_UNAVAILABLE",
|
|
56
|
+
message=f"Failed to start launcher command: {command}",
|
|
57
|
+
details={"command": command, "error": str(exc)},
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
self._next_request_id = 1
|
|
61
|
+
self._messages: queue.Queue[dict[str, Any] | object] = queue.Queue()
|
|
62
|
+
self._pending_responses: dict[str, dict[str, Any]] = {}
|
|
63
|
+
self._stderr_lines: deque[str] = deque(maxlen=80)
|
|
64
|
+
self._eof_token = object()
|
|
65
|
+
self._closed = False
|
|
66
|
+
self._request_id_lock = threading.Lock()
|
|
67
|
+
self._send_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
self._stdout_thread = threading.Thread(
|
|
70
|
+
target=self._stdout_reader,
|
|
71
|
+
name="acp-stdout-reader",
|
|
72
|
+
daemon=True,
|
|
73
|
+
)
|
|
74
|
+
self._stderr_thread = threading.Thread(
|
|
75
|
+
target=self._stderr_reader,
|
|
76
|
+
name="acp-stderr-reader",
|
|
77
|
+
daemon=True,
|
|
78
|
+
)
|
|
79
|
+
self._stdout_thread.start()
|
|
80
|
+
self._stderr_thread.start()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
if self._closed:
|
|
84
|
+
return
|
|
85
|
+
self._closed = True
|
|
86
|
+
if self._proc.poll() is None:
|
|
87
|
+
self._proc.terminate()
|
|
88
|
+
try:
|
|
89
|
+
self._proc.wait(timeout=0.5)
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
self._proc.kill()
|
|
92
|
+
self._proc.wait(timeout=0.5)
|
|
93
|
+
|
|
94
|
+
def request(
|
|
95
|
+
self,
|
|
96
|
+
method: str,
|
|
97
|
+
params: dict[str, Any] | None = None,
|
|
98
|
+
*,
|
|
99
|
+
timeout_seconds: float = 60.0,
|
|
100
|
+
on_notification: NotificationHandler | None = None,
|
|
101
|
+
on_request: RequestHandler | None = None,
|
|
102
|
+
) -> Any:
|
|
103
|
+
with self._request_id_lock:
|
|
104
|
+
request_id = self._next_request_id
|
|
105
|
+
self._next_request_id += 1
|
|
106
|
+
|
|
107
|
+
self._send_jsonrpc(
|
|
108
|
+
{
|
|
109
|
+
"jsonrpc": "2.0",
|
|
110
|
+
"id": request_id,
|
|
111
|
+
"method": method,
|
|
112
|
+
"params": params or {},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
return self._wait_for_response(
|
|
116
|
+
request_id=request_id,
|
|
117
|
+
method=method,
|
|
118
|
+
timeout_seconds=timeout_seconds,
|
|
119
|
+
on_notification=on_notification,
|
|
120
|
+
on_request=on_request,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def notify(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
params: dict[str, Any] | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._send_jsonrpc(
|
|
129
|
+
{
|
|
130
|
+
"jsonrpc": "2.0",
|
|
131
|
+
"method": method,
|
|
132
|
+
"params": params or {},
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _wait_for_response(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
request_id: int,
|
|
140
|
+
method: str,
|
|
141
|
+
timeout_seconds: float,
|
|
142
|
+
on_notification: NotificationHandler | None,
|
|
143
|
+
on_request: RequestHandler | None,
|
|
144
|
+
) -> Any:
|
|
145
|
+
response_key = str(request_id)
|
|
146
|
+
if response_key in self._pending_responses:
|
|
147
|
+
return self._consume_response(response_key, method)
|
|
148
|
+
|
|
149
|
+
deadline = time.monotonic() + timeout_seconds
|
|
150
|
+
while True:
|
|
151
|
+
if response_key in self._pending_responses:
|
|
152
|
+
return self._consume_response(response_key, method)
|
|
153
|
+
if time.monotonic() >= deadline:
|
|
154
|
+
raise SubagentError(
|
|
155
|
+
code="BACKEND_TIMEOUT",
|
|
156
|
+
message=f"Timed out waiting for backend response: {method}",
|
|
157
|
+
retryable=True,
|
|
158
|
+
details={"method": method, "timeoutSeconds": timeout_seconds},
|
|
159
|
+
)
|
|
160
|
+
remaining = max(0.01, deadline - time.monotonic())
|
|
161
|
+
try:
|
|
162
|
+
item = self._messages.get(timeout=remaining)
|
|
163
|
+
except queue.Empty:
|
|
164
|
+
if self._proc.poll() is not None:
|
|
165
|
+
raise SubagentError(
|
|
166
|
+
code="BACKEND_UNAVAILABLE",
|
|
167
|
+
message=f"Backend process exited while waiting for `{method}`.",
|
|
168
|
+
details={
|
|
169
|
+
"method": method,
|
|
170
|
+
"exitCode": self._proc.returncode,
|
|
171
|
+
"stderrTail": list(self._stderr_lines),
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if item is self._eof_token:
|
|
177
|
+
raise SubagentError(
|
|
178
|
+
code="BACKEND_UNAVAILABLE",
|
|
179
|
+
message=f"Backend stream closed while waiting for `{method}`.",
|
|
180
|
+
details={
|
|
181
|
+
"method": method,
|
|
182
|
+
"exitCode": self._proc.poll(),
|
|
183
|
+
"stderrTail": list(self._stderr_lines),
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
if not isinstance(item, dict):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
kind = item.get("_kind")
|
|
190
|
+
if kind == "parse_error":
|
|
191
|
+
raise SubagentError(
|
|
192
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
193
|
+
message="Received non-JSON message from backend",
|
|
194
|
+
details={"line": item.get("line"), "error": item.get("error")},
|
|
195
|
+
)
|
|
196
|
+
if kind != "jsonrpc":
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
payload = item.get("payload")
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
continue
|
|
202
|
+
self._dispatch_message(
|
|
203
|
+
payload,
|
|
204
|
+
on_notification=on_notification,
|
|
205
|
+
on_request=on_request,
|
|
206
|
+
)
|
|
207
|
+
if response_key in self._pending_responses:
|
|
208
|
+
return self._consume_response(response_key, method)
|
|
209
|
+
|
|
210
|
+
def _dispatch_message(
|
|
211
|
+
self,
|
|
212
|
+
message: dict[str, Any],
|
|
213
|
+
*,
|
|
214
|
+
on_notification: NotificationHandler | None,
|
|
215
|
+
on_request: RequestHandler | None,
|
|
216
|
+
) -> None:
|
|
217
|
+
if "id" in message and ("result" in message or "error" in message):
|
|
218
|
+
response_id = str(message.get("id"))
|
|
219
|
+
self._pending_responses[response_id] = message
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
method = message.get("method")
|
|
223
|
+
if not isinstance(method, str):
|
|
224
|
+
return
|
|
225
|
+
params = message.get("params")
|
|
226
|
+
if not isinstance(params, dict):
|
|
227
|
+
params = {}
|
|
228
|
+
|
|
229
|
+
if "id" in message:
|
|
230
|
+
self._handle_server_request(
|
|
231
|
+
request_id=message.get("id"),
|
|
232
|
+
method=method,
|
|
233
|
+
params=params,
|
|
234
|
+
on_request=on_request,
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
if on_notification is not None:
|
|
238
|
+
on_notification(method, params)
|
|
239
|
+
|
|
240
|
+
def _handle_server_request(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
request_id: Any,
|
|
244
|
+
method: str,
|
|
245
|
+
params: dict[str, Any],
|
|
246
|
+
on_request: RequestHandler | None,
|
|
247
|
+
) -> None:
|
|
248
|
+
if on_request is None:
|
|
249
|
+
self._send_jsonrpc(
|
|
250
|
+
{
|
|
251
|
+
"jsonrpc": "2.0",
|
|
252
|
+
"id": request_id,
|
|
253
|
+
"error": {
|
|
254
|
+
"code": -32601,
|
|
255
|
+
"message": f"Method not found: {method}",
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
try:
|
|
261
|
+
result = on_request(method, params)
|
|
262
|
+
except SubagentError as error:
|
|
263
|
+
self._send_jsonrpc(
|
|
264
|
+
{
|
|
265
|
+
"jsonrpc": "2.0",
|
|
266
|
+
"id": request_id,
|
|
267
|
+
"error": {
|
|
268
|
+
"code": -32000,
|
|
269
|
+
"message": error.message,
|
|
270
|
+
"data": error.to_dict(),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
except Exception as error: # pragma: no cover - safety fallback
|
|
276
|
+
self._send_jsonrpc(
|
|
277
|
+
{
|
|
278
|
+
"jsonrpc": "2.0",
|
|
279
|
+
"id": request_id,
|
|
280
|
+
"error": {
|
|
281
|
+
"code": -32603,
|
|
282
|
+
"message": f"Internal handler error: {error}",
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
self._send_jsonrpc(
|
|
289
|
+
{
|
|
290
|
+
"jsonrpc": "2.0",
|
|
291
|
+
"id": request_id,
|
|
292
|
+
"result": result if result is not None else {},
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _consume_response(self, response_key: str, method: str) -> Any:
|
|
297
|
+
response = self._pending_responses.pop(response_key)
|
|
298
|
+
if "error" in response:
|
|
299
|
+
error = response["error"]
|
|
300
|
+
raise SubagentError(
|
|
301
|
+
code="BACKEND_RPC_ERROR",
|
|
302
|
+
message=f"Backend rejected `{method}` request.",
|
|
303
|
+
details={"method": method, "error": error},
|
|
304
|
+
)
|
|
305
|
+
return response.get("result")
|
|
306
|
+
|
|
307
|
+
def _send_jsonrpc(self, payload: dict[str, Any]) -> None:
|
|
308
|
+
with self._send_lock:
|
|
309
|
+
if self._proc.poll() is not None:
|
|
310
|
+
raise SubagentError(
|
|
311
|
+
code="BACKEND_UNAVAILABLE",
|
|
312
|
+
message="Backend process is not running.",
|
|
313
|
+
details={
|
|
314
|
+
"exitCode": self._proc.returncode,
|
|
315
|
+
"stderrTail": list(self._stderr_lines),
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
stdin = self._proc.stdin
|
|
319
|
+
if stdin is None:
|
|
320
|
+
raise SubagentError(
|
|
321
|
+
code="BACKEND_UNAVAILABLE",
|
|
322
|
+
message="Backend stdin is not available.",
|
|
323
|
+
)
|
|
324
|
+
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
325
|
+
try:
|
|
326
|
+
stdin.write(message + "\n")
|
|
327
|
+
stdin.flush()
|
|
328
|
+
except BrokenPipeError as exc:
|
|
329
|
+
raise SubagentError(
|
|
330
|
+
code="BACKEND_UNAVAILABLE",
|
|
331
|
+
message="Backend process closed stdin.",
|
|
332
|
+
details={
|
|
333
|
+
"exitCode": self._proc.poll(),
|
|
334
|
+
"stderrTail": list(self._stderr_lines),
|
|
335
|
+
},
|
|
336
|
+
) from exc
|
|
337
|
+
|
|
338
|
+
def _stdout_reader(self) -> None:
|
|
339
|
+
stdout = self._proc.stdout
|
|
340
|
+
if stdout is None:
|
|
341
|
+
self._messages.put(self._eof_token)
|
|
342
|
+
return
|
|
343
|
+
for raw_line in stdout:
|
|
344
|
+
line = raw_line.strip()
|
|
345
|
+
if not line:
|
|
346
|
+
continue
|
|
347
|
+
try:
|
|
348
|
+
parsed = json.loads(line)
|
|
349
|
+
except json.JSONDecodeError as error:
|
|
350
|
+
self._messages.put(
|
|
351
|
+
{
|
|
352
|
+
"_kind": "parse_error",
|
|
353
|
+
"line": line,
|
|
354
|
+
"error": str(error),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
continue
|
|
358
|
+
self._messages.put({"_kind": "jsonrpc", "payload": parsed})
|
|
359
|
+
self._messages.put(self._eof_token)
|
|
360
|
+
|
|
361
|
+
def _stderr_reader(self) -> None:
|
|
362
|
+
stderr = self._proc.stderr
|
|
363
|
+
if stderr is None:
|
|
364
|
+
return
|
|
365
|
+
for raw_line in stderr:
|
|
366
|
+
self._stderr_lines.append(raw_line.rstrip("\n"))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared helpers for approval option resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import SubagentError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_option(
|
|
11
|
+
request: dict[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
decision: str | None,
|
|
14
|
+
option_id: str | None,
|
|
15
|
+
alias: str | None,
|
|
16
|
+
) -> tuple[str, str | None, str]:
|
|
17
|
+
options = request.get("options")
|
|
18
|
+
if not isinstance(options, list):
|
|
19
|
+
options = []
|
|
20
|
+
|
|
21
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
22
|
+
by_alias: dict[str, dict[str, Any]] = {}
|
|
23
|
+
for option in options:
|
|
24
|
+
if not isinstance(option, dict):
|
|
25
|
+
continue
|
|
26
|
+
option_key = option.get("id")
|
|
27
|
+
option_alias = option.get("alias")
|
|
28
|
+
if isinstance(option_key, str):
|
|
29
|
+
by_id[option_key] = option
|
|
30
|
+
if isinstance(option_alias, str):
|
|
31
|
+
by_alias[option_alias] = option
|
|
32
|
+
|
|
33
|
+
selected: dict[str, Any] | None = None
|
|
34
|
+
if option_id:
|
|
35
|
+
selected = by_id.get(option_id)
|
|
36
|
+
elif alias:
|
|
37
|
+
selected = by_alias.get(alias)
|
|
38
|
+
elif decision:
|
|
39
|
+
selected = by_alias.get(decision) or by_id.get(decision)
|
|
40
|
+
|
|
41
|
+
if selected is None:
|
|
42
|
+
raise SubagentError(
|
|
43
|
+
code="INVALID_APPROVAL_DECISION",
|
|
44
|
+
message="Could not resolve approval option. Use --option-id or --alias.",
|
|
45
|
+
details={
|
|
46
|
+
"decision": decision,
|
|
47
|
+
"optionId": option_id,
|
|
48
|
+
"alias": alias,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
selected_option_id = str(selected.get("id"))
|
|
53
|
+
selected_alias = selected.get("alias")
|
|
54
|
+
selected_alias_value = str(selected_alias) if isinstance(selected_alias, str) else None
|
|
55
|
+
resolved_decision = decision or selected_alias_value or selected_option_id
|
|
56
|
+
return selected_option_id, selected_alias_value, resolved_decision
|
|
57
|
+
|