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/output.py ADDED
@@ -0,0 +1,41 @@
1
+ """Output helpers for human and JSON responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, NoReturn
7
+
8
+ import typer
9
+
10
+ from .constants import SCHEMA_VERSION
11
+ from .errors import SubagentError
12
+
13
+
14
+ def ok_envelope(event_type: str, data: dict[str, Any]) -> dict[str, Any]:
15
+ return {
16
+ "schemaVersion": SCHEMA_VERSION,
17
+ "ok": True,
18
+ "type": event_type,
19
+ "data": data,
20
+ }
21
+
22
+
23
+ def error_envelope(error: SubagentError) -> dict[str, Any]:
24
+ return {
25
+ "schemaVersion": SCHEMA_VERSION,
26
+ "ok": False,
27
+ "type": "error",
28
+ "error": error.to_dict(),
29
+ }
30
+
31
+
32
+ def emit_json(payload: dict[str, Any]) -> None:
33
+ typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
34
+
35
+
36
+ def emit_error_and_exit(error: SubagentError, json_output: bool = False) -> NoReturn:
37
+ if json_output:
38
+ emit_json(error_envelope(error))
39
+ else:
40
+ typer.echo(f"{error.code}: {error.message}", err=True)
41
+ raise typer.Exit(code=1)
subagent/paths.py ADDED
@@ -0,0 +1,63 @@
1
+ """Filesystem path resolution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .constants import (
9
+ DEFAULT_CONFIG_PATH,
10
+ DEFAULT_HANDOFFS_DIR,
11
+ DEFAULT_STATE_DB_PATH,
12
+ DEFAULT_STATE_DIR,
13
+ ENV_CONFIG_PATH,
14
+ ENV_STATE_DIR,
15
+ PROJECT_HINT_DIRNAME,
16
+ PROJECT_HINT_FILENAME,
17
+ )
18
+
19
+
20
+ def resolve_workspace_path(cwd: Path | None = None) -> Path:
21
+ return (cwd or Path.cwd()).expanduser().resolve()
22
+
23
+
24
+ def resolve_config_path(config_path: Path | None = None) -> Path:
25
+ if config_path is not None:
26
+ return config_path.expanduser().resolve()
27
+ env_value = os.environ.get(ENV_CONFIG_PATH)
28
+ if env_value:
29
+ return Path(env_value).expanduser().resolve()
30
+ return DEFAULT_CONFIG_PATH
31
+
32
+
33
+ def resolve_state_dir(state_dir: Path | None = None) -> Path:
34
+ if state_dir is not None:
35
+ return state_dir.expanduser().resolve()
36
+ env_value = os.environ.get(ENV_STATE_DIR)
37
+ if env_value:
38
+ return Path(env_value).expanduser().resolve()
39
+ return DEFAULT_STATE_DIR
40
+
41
+
42
+ def resolve_state_db_path(state_dir: Path | None = None) -> Path:
43
+ resolved_state_dir = resolve_state_dir(state_dir)
44
+ default_parent = DEFAULT_STATE_DB_PATH.parent
45
+ if resolved_state_dir == default_parent:
46
+ return DEFAULT_STATE_DB_PATH
47
+ return resolved_state_dir / "state.db"
48
+
49
+
50
+ def resolve_handoffs_dir(state_dir: Path | None = None) -> Path:
51
+ resolved_state_dir = resolve_state_dir(state_dir)
52
+ default_parent = DEFAULT_HANDOFFS_DIR.parent
53
+ if resolved_state_dir == default_parent:
54
+ return DEFAULT_HANDOFFS_DIR
55
+ return resolved_state_dir / "handoffs"
56
+
57
+
58
+ def ensure_parent_dir(path: Path) -> None:
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+
61
+
62
+ def project_hint_path(workspace: Path) -> Path:
63
+ return workspace / PROJECT_HINT_DIRNAME / PROJECT_HINT_FILENAME
@@ -0,0 +1,114 @@
1
+ """Prompt rendering helpers for manager/worker targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .config import Pack, Profile, SubagentConfig
8
+ from .errors import SubagentError
9
+
10
+ MANAGER_PROMPT_BASE = """You are a manager agent coordinating worker subagents with `subagent` CLI.
11
+
12
+ Read this quick workflow first:
13
+ 1. Initialize controller in the workspace:
14
+ subagent controller init --cwd <workspace>
15
+ 2. Start a worker:
16
+ subagent worker start --cwd <workspace>
17
+ 3. Send work:
18
+ subagent send --worker <worker-id> --text "<instruction>"
19
+ 4. Monitor progress:
20
+ subagent watch --worker <worker-id> --ndjson
21
+ 5. If approval is requested:
22
+ subagent approve --worker <worker-id> --request <request-id> --option-id <option-id>
23
+
24
+ Operational rules:
25
+ - Keep instructions short, concrete, and outcome-oriented.
26
+ - Use `--json` for machine-readable responses and `--input` for JSON-driven calls.
27
+ - Treat `waiting_approval` as a blocking state; resolve via `approve` or `cancel`.
28
+ - Use handoff flow for continuation: `worker handoff` -> `worker continue`.
29
+ - Prefer strict mode for production; use `--debug-mode` only for local simulation/testing.
30
+ """
31
+
32
+
33
+ def _render_worker_prompt(profile: Profile, packs: list[Pack]) -> str:
34
+ lines: list[str] = []
35
+ if profile.bootstrap.strip():
36
+ lines.append(profile.bootstrap.strip())
37
+ else:
38
+ lines.append("You are a worker subagent.")
39
+ lines.append("Use STATUS:, ASK:, BLOCKED:, and DONE: prefixes when helpful.")
40
+ lines.append("Keep updates concise and action-oriented.")
41
+ if packs:
42
+ lines.append("")
43
+ lines.append("Additional pack instructions:")
44
+ for pack in packs:
45
+ lines.append(f"- Pack `{pack.name}`: {pack.description or '(no description)'}")
46
+ if pack.prompt.strip():
47
+ lines.append(pack.prompt.strip())
48
+ return "\n".join(lines).strip()
49
+
50
+
51
+ def render_prompt(
52
+ config: SubagentConfig,
53
+ *,
54
+ target: str,
55
+ profile_name: str | None = None,
56
+ pack_names: list[str] | None = None,
57
+ ) -> dict[str, Any]:
58
+ if target not in {"manager", "worker"}:
59
+ raise SubagentError(
60
+ code="INVALID_ARGUMENT",
61
+ message=f"Unknown prompt target: {target}",
62
+ details={"target": target},
63
+ )
64
+
65
+ if target == "manager":
66
+ return {
67
+ "target": "manager",
68
+ "prompt": MANAGER_PROMPT_BASE,
69
+ }
70
+
71
+ selected_profile_name = profile_name
72
+ if selected_profile_name is None:
73
+ default_profile = config.defaults.get("profile")
74
+ if isinstance(default_profile, str) and default_profile:
75
+ selected_profile_name = default_profile
76
+ if selected_profile_name is None:
77
+ raise SubagentError(
78
+ code="PROFILE_NOT_FOUND",
79
+ message="Profile is required for worker prompt rendering.",
80
+ )
81
+ profile = config.profiles.get(selected_profile_name)
82
+ if profile is None:
83
+ raise SubagentError(
84
+ code="PROFILE_NOT_FOUND",
85
+ message=f"Profile not found: {selected_profile_name}",
86
+ details={"profile": selected_profile_name},
87
+ )
88
+
89
+ selected_pack_names = list(pack_names or [])
90
+ if not selected_pack_names:
91
+ selected_pack_names = list(profile.default_packs)
92
+ if not selected_pack_names:
93
+ defaults_packs = config.defaults.get("packs")
94
+ if isinstance(defaults_packs, list):
95
+ selected_pack_names = [str(item) for item in defaults_packs]
96
+
97
+ packs: list[Pack] = []
98
+ for pack_name in selected_pack_names:
99
+ pack = config.packs.get(pack_name)
100
+ if pack is None:
101
+ raise SubagentError(
102
+ code="PACK_NOT_FOUND",
103
+ message=f"Pack not found: {pack_name}",
104
+ details={"pack": pack_name},
105
+ )
106
+ packs.append(pack)
107
+
108
+ prompt = _render_worker_prompt(profile, packs)
109
+ return {
110
+ "target": "worker",
111
+ "profile": profile.name,
112
+ "packs": [pack.name for pack in packs],
113
+ "prompt": prompt,
114
+ }
@@ -0,0 +1,342 @@
1
+ """Worker runtime process management and IPC helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .config import Launcher, SubagentConfig
17
+ from .errors import SubagentError
18
+ from .state import StateStore
19
+
20
+
21
+ def runtime_socket_path(store: StateStore, worker_id: str) -> Path:
22
+ digest = hashlib.sha1(f"{store.db_path}:{worker_id}".encode("utf-8")).hexdigest()[:16]
23
+ return Path("/tmp") / f"subagent-rt-{digest}.sock"
24
+
25
+
26
+ def runtime_log_path(store: StateStore, worker_id: str) -> Path:
27
+ return store.db_path.parent / "runtimes" / f"{worker_id}.log"
28
+
29
+
30
+ def _send_socket_request(
31
+ socket_path: Path,
32
+ *,
33
+ method: str,
34
+ params: dict[str, Any],
35
+ timeout_seconds: float,
36
+ ) -> dict[str, Any]:
37
+ try:
38
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
39
+ client.settimeout(timeout_seconds)
40
+ client.connect(str(socket_path))
41
+ payload = {
42
+ "method": method,
43
+ "params": params,
44
+ }
45
+ request_data = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
46
+ client.sendall(request_data)
47
+ chunks: list[bytes] = []
48
+ while True:
49
+ block = client.recv(4096)
50
+ if not block:
51
+ break
52
+ chunks.append(block)
53
+ if b"\n" in block:
54
+ break
55
+ except (FileNotFoundError, ConnectionRefusedError, socket.timeout, OSError) as exc:
56
+ raise SubagentError(
57
+ code="BACKEND_UNAVAILABLE",
58
+ message="Worker runtime is not reachable.",
59
+ details={
60
+ "socketPath": str(socket_path),
61
+ "method": method,
62
+ "error": str(exc),
63
+ },
64
+ ) from exc
65
+ raw = b"".join(chunks).decode("utf-8", errors="replace").strip()
66
+ if not raw:
67
+ raise SubagentError(
68
+ code="BACKEND_PROTOCOL_ERROR",
69
+ message="Worker runtime returned an empty response.",
70
+ details={"socketPath": str(socket_path), "method": method},
71
+ )
72
+ try:
73
+ parsed = json.loads(raw)
74
+ except json.JSONDecodeError as exc:
75
+ raise SubagentError(
76
+ code="BACKEND_PROTOCOL_ERROR",
77
+ message="Worker runtime returned invalid JSON.",
78
+ details={"socketPath": str(socket_path), "method": method, "response": raw},
79
+ ) from exc
80
+ if not isinstance(parsed, dict):
81
+ raise SubagentError(
82
+ code="BACKEND_PROTOCOL_ERROR",
83
+ message="Worker runtime returned non-object response.",
84
+ details={"socketPath": str(socket_path), "method": method, "response": parsed},
85
+ )
86
+ return parsed
87
+
88
+
89
+ def runtime_request(
90
+ store: StateStore,
91
+ *,
92
+ worker_id: str,
93
+ method: str,
94
+ params: dict[str, Any],
95
+ timeout_seconds: float = 30.0,
96
+ ) -> dict[str, Any]:
97
+ worker = store.get_worker(worker_id)
98
+ if worker is None:
99
+ raise SubagentError(
100
+ code="WORKER_NOT_FOUND",
101
+ message=f"Worker not found: {worker_id}",
102
+ details={"workerId": worker_id},
103
+ )
104
+ socket_path_raw = worker.get("runtime_socket")
105
+ if not isinstance(socket_path_raw, str) or not socket_path_raw:
106
+ raise SubagentError(
107
+ code="BACKEND_UNAVAILABLE",
108
+ message="Worker runtime is not initialized.",
109
+ details={"workerId": worker_id},
110
+ )
111
+ socket_path = Path(socket_path_raw)
112
+ response = _send_socket_request(
113
+ socket_path,
114
+ method=method,
115
+ params=params,
116
+ timeout_seconds=timeout_seconds,
117
+ )
118
+ ok = response.get("ok")
119
+ if ok is not True:
120
+ error_payload = response.get("error")
121
+ if isinstance(error_payload, dict):
122
+ raise SubagentError(
123
+ code=str(error_payload.get("code", "BACKEND_RPC_ERROR")),
124
+ message=str(error_payload.get("message", "Worker runtime request failed.")),
125
+ retryable=bool(error_payload.get("retryable", False)),
126
+ details=error_payload.get("details")
127
+ if isinstance(error_payload.get("details"), dict)
128
+ else {},
129
+ )
130
+ raise SubagentError(
131
+ code="BACKEND_RPC_ERROR",
132
+ message="Worker runtime request failed.",
133
+ details={"response": response},
134
+ )
135
+ result = response.get("result")
136
+ if not isinstance(result, dict):
137
+ raise SubagentError(
138
+ code="BACKEND_PROTOCOL_ERROR",
139
+ message="Worker runtime response missing result object.",
140
+ details={"response": response},
141
+ )
142
+ return result
143
+
144
+
145
+ def _runtime_launch_command(
146
+ *,
147
+ store: StateStore,
148
+ worker_id: str,
149
+ socket_path: Path,
150
+ launcher: Launcher,
151
+ cwd: str,
152
+ ) -> list[str]:
153
+ return [
154
+ sys.executable,
155
+ "-m",
156
+ "subagent.worker_runtime",
157
+ "--db-path",
158
+ str(store.db_path),
159
+ "--worker-id",
160
+ worker_id,
161
+ "--socket-path",
162
+ str(socket_path),
163
+ "--launcher-command",
164
+ launcher.command,
165
+ "--launcher-args-json",
166
+ json.dumps(launcher.args, ensure_ascii=False),
167
+ "--launcher-env-json",
168
+ json.dumps(launcher.env, ensure_ascii=False),
169
+ "--cwd",
170
+ cwd,
171
+ ]
172
+
173
+
174
+ def launch_worker_runtime(
175
+ store: StateStore,
176
+ *,
177
+ worker_id: str,
178
+ launcher: Launcher,
179
+ cwd: str,
180
+ timeout_seconds: float = 10.0,
181
+ ) -> dict[str, Any]:
182
+ socket_path = runtime_socket_path(store, worker_id)
183
+ if socket_path.exists():
184
+ socket_path.unlink()
185
+
186
+ log_path = runtime_log_path(store, worker_id)
187
+ log_path.parent.mkdir(parents=True, exist_ok=True)
188
+ command = _runtime_launch_command(
189
+ store=store,
190
+ worker_id=worker_id,
191
+ socket_path=socket_path,
192
+ launcher=launcher,
193
+ cwd=cwd,
194
+ )
195
+ with log_path.open("a", encoding="utf-8") as log_file:
196
+ process = subprocess.Popen(
197
+ command,
198
+ stdout=log_file,
199
+ stderr=log_file,
200
+ stdin=subprocess.DEVNULL,
201
+ start_new_session=True,
202
+ text=True,
203
+ env=dict(os.environ),
204
+ )
205
+ store.set_worker_runtime_endpoint(
206
+ worker_id,
207
+ runtime_pid=process.pid,
208
+ runtime_socket=str(socket_path),
209
+ )
210
+
211
+ deadline = time.monotonic() + timeout_seconds
212
+ last_error: str | None = None
213
+ while time.monotonic() < deadline:
214
+ if process.poll() is not None:
215
+ last_error = f"runtime exited with code {process.returncode}"
216
+ break
217
+ if socket_path.exists():
218
+ try:
219
+ response = _send_socket_request(
220
+ socket_path,
221
+ method="ping",
222
+ params={},
223
+ timeout_seconds=1.0,
224
+ )
225
+ except SubagentError as error:
226
+ last_error = error.message
227
+ else:
228
+ if response.get("ok") is True:
229
+ return {
230
+ "pid": process.pid,
231
+ "socketPath": str(socket_path),
232
+ "logPath": str(log_path),
233
+ }
234
+ time.sleep(0.1)
235
+ try:
236
+ process.terminate()
237
+ process.wait(timeout=1.0)
238
+ except Exception: # pragma: no cover - cleanup best effort
239
+ try:
240
+ process.kill()
241
+ except Exception:
242
+ pass
243
+ store.clear_worker_runtime_endpoint(worker_id)
244
+ raise SubagentError(
245
+ code="BACKEND_UNAVAILABLE",
246
+ message="Failed to start worker runtime.",
247
+ details={
248
+ "workerId": worker_id,
249
+ "command": command,
250
+ "socketPath": str(socket_path),
251
+ "logPath": str(log_path),
252
+ "error": last_error,
253
+ },
254
+ )
255
+
256
+
257
+ def restart_worker_runtime(
258
+ store: StateStore,
259
+ config: SubagentConfig,
260
+ *,
261
+ worker_id: str,
262
+ timeout_seconds: float = 10.0,
263
+ ) -> dict[str, Any]:
264
+ worker = store.get_worker(worker_id)
265
+ if worker is None:
266
+ raise SubagentError(
267
+ code="WORKER_NOT_FOUND",
268
+ message=f"Worker not found: {worker_id}",
269
+ details={"workerId": worker_id},
270
+ )
271
+ launcher_name = worker.get("launcher")
272
+ if not isinstance(launcher_name, str) or not launcher_name:
273
+ raise SubagentError(
274
+ code="BACKEND_UNAVAILABLE",
275
+ message="Worker launcher is not set.",
276
+ details={"workerId": worker_id},
277
+ )
278
+ launcher = config.launchers.get(launcher_name)
279
+ if launcher is None:
280
+ raise SubagentError(
281
+ code="LAUNCHER_NOT_FOUND",
282
+ message=f"Launcher not found: {launcher_name}",
283
+ details={"workerId": worker_id, "launcher": launcher_name},
284
+ )
285
+ if launcher.backend_kind != "acp-stdio":
286
+ raise SubagentError(
287
+ code="BACKEND_UNAVAILABLE",
288
+ message=f"Unsupported backend kind for runtime: {launcher.backend_kind}",
289
+ details={"workerId": worker_id, "launcher": launcher_name, "backendKind": launcher.backend_kind},
290
+ )
291
+ command = launcher.command
292
+ if shutil.which(command) is None and "/" not in command:
293
+ raise SubagentError(
294
+ code="BACKEND_UNAVAILABLE",
295
+ message=f"Launcher command not available: {command}",
296
+ details={"workerId": worker_id, "launcher": launcher_name, "command": command},
297
+ )
298
+ if "/" in command and not Path(command).expanduser().exists():
299
+ raise SubagentError(
300
+ code="BACKEND_UNAVAILABLE",
301
+ message=f"Launcher command not available: {command}",
302
+ details={"workerId": worker_id, "launcher": launcher_name, "command": command},
303
+ )
304
+ worker_cwd = worker.get("cwd")
305
+ if not isinstance(worker_cwd, str) or not worker_cwd:
306
+ raise SubagentError(
307
+ code="BACKEND_UNAVAILABLE",
308
+ message="Worker cwd is not available.",
309
+ details={"workerId": worker_id},
310
+ )
311
+ return launch_worker_runtime(
312
+ store,
313
+ worker_id=worker_id,
314
+ launcher=launcher,
315
+ cwd=worker_cwd,
316
+ timeout_seconds=timeout_seconds,
317
+ )
318
+
319
+
320
+ def stop_worker_runtime(
321
+ store: StateStore,
322
+ *,
323
+ worker_id: str,
324
+ reason: str = "worker stopped",
325
+ ) -> None:
326
+ worker = store.get_worker(worker_id)
327
+ if worker is None:
328
+ return
329
+ runtime_socket = worker.get("runtime_socket")
330
+ if isinstance(runtime_socket, str) and runtime_socket:
331
+ try:
332
+ runtime_request(
333
+ store,
334
+ worker_id=worker_id,
335
+ method="stop",
336
+ params={"reason": reason},
337
+ timeout_seconds=5.0,
338
+ )
339
+ except SubagentError:
340
+ # Stopping should be best-effort.
341
+ pass
342
+ store.clear_worker_runtime_endpoint(worker_id)