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 ADDED
@@ -0,0 +1,7 @@
1
+ """subagent CLI package."""
2
+
3
+ from .constants import SCHEMA_VERSION
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ __all__ = ["SCHEMA_VERSION", "__version__"]
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
+