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/daemon.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Minimal local daemon process for subagent runtime bootstrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from .constants import DAEMON_STATUS_PATH
|
|
15
|
+
from .output import emit_json
|
|
16
|
+
from .paths import resolve_state_dir
|
|
17
|
+
from .state import StateStore
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="subagentd: local control-plane daemon")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _utc_now() -> str:
|
|
23
|
+
return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _status_path_for_state_dir(state_dir: Path) -> Path:
|
|
27
|
+
default_parent = DAEMON_STATUS_PATH.parent
|
|
28
|
+
if state_dir == default_parent:
|
|
29
|
+
return DAEMON_STATUS_PATH
|
|
30
|
+
return state_dir / "subagentd-status.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_status(path: Path, payload: dict[str, Any]) -> None:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("run")
|
|
39
|
+
def run_daemon(
|
|
40
|
+
once: bool = typer.Option(
|
|
41
|
+
False,
|
|
42
|
+
"--once",
|
|
43
|
+
help="Initialize state and exit immediately (bootstrap mode).",
|
|
44
|
+
),
|
|
45
|
+
heartbeat_seconds: int = typer.Option(
|
|
46
|
+
5,
|
|
47
|
+
"--heartbeat-seconds",
|
|
48
|
+
min=1,
|
|
49
|
+
help="Heartbeat update interval when running foreground.",
|
|
50
|
+
),
|
|
51
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON response."),
|
|
52
|
+
) -> None:
|
|
53
|
+
state_dir = resolve_state_dir()
|
|
54
|
+
store = StateStore(state_dir / "state.db")
|
|
55
|
+
store.bootstrap()
|
|
56
|
+
status_path = _status_path_for_state_dir(state_dir)
|
|
57
|
+
base_payload = {
|
|
58
|
+
"schemaVersion": "v1",
|
|
59
|
+
"pid": os.getpid(),
|
|
60
|
+
"stateDir": str(state_dir),
|
|
61
|
+
"dbPath": str(store.db_path),
|
|
62
|
+
"startedAt": _utc_now(),
|
|
63
|
+
"lastHeartbeatAt": _utc_now(),
|
|
64
|
+
"mode": "once" if once else "foreground",
|
|
65
|
+
}
|
|
66
|
+
_write_status(status_path, base_payload)
|
|
67
|
+
|
|
68
|
+
if once:
|
|
69
|
+
if json_output:
|
|
70
|
+
emit_json(base_payload)
|
|
71
|
+
else:
|
|
72
|
+
typer.echo(f"subagentd initialized: {store.db_path}")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if json_output:
|
|
76
|
+
emit_json(base_payload)
|
|
77
|
+
else:
|
|
78
|
+
typer.echo(f"subagentd running (pid={base_payload['pid']})")
|
|
79
|
+
try:
|
|
80
|
+
while True:
|
|
81
|
+
time.sleep(heartbeat_seconds)
|
|
82
|
+
base_payload["lastHeartbeatAt"] = _utc_now()
|
|
83
|
+
_write_status(status_path, base_payload)
|
|
84
|
+
except KeyboardInterrupt:
|
|
85
|
+
base_payload["stoppedAt"] = _utc_now()
|
|
86
|
+
_write_status(status_path, base_payload)
|
|
87
|
+
raise typer.Exit(code=0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("status")
|
|
91
|
+
def daemon_status(
|
|
92
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON response."),
|
|
93
|
+
) -> None:
|
|
94
|
+
state_dir = resolve_state_dir()
|
|
95
|
+
status_path = _status_path_for_state_dir(state_dir)
|
|
96
|
+
if not status_path.exists():
|
|
97
|
+
payload = {
|
|
98
|
+
"schemaVersion": "v1",
|
|
99
|
+
"running": False,
|
|
100
|
+
"stateDir": str(state_dir),
|
|
101
|
+
"statusFile": str(status_path),
|
|
102
|
+
}
|
|
103
|
+
if json_output:
|
|
104
|
+
emit_json(payload)
|
|
105
|
+
else:
|
|
106
|
+
typer.echo("subagentd not initialized")
|
|
107
|
+
raise typer.Exit(code=1)
|
|
108
|
+
payload = json.loads(status_path.read_text(encoding="utf-8"))
|
|
109
|
+
pid = payload.get("pid")
|
|
110
|
+
running = False
|
|
111
|
+
if isinstance(pid, int):
|
|
112
|
+
try:
|
|
113
|
+
os.kill(pid, 0)
|
|
114
|
+
except OSError:
|
|
115
|
+
running = False
|
|
116
|
+
else:
|
|
117
|
+
running = True
|
|
118
|
+
payload["running"] = running
|
|
119
|
+
if json_output:
|
|
120
|
+
emit_json(payload)
|
|
121
|
+
else:
|
|
122
|
+
typer.echo(
|
|
123
|
+
f"subagentd running={running} pid={payload.get('pid')} "
|
|
124
|
+
f"startedAt={payload.get('startedAt')}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> None:
|
|
129
|
+
app()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
subagent/errors.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Typed errors with stable error codes for CLI contracts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class SubagentError(Exception):
|
|
9
|
+
"""Error mapped to the versioned CLI error envelope."""
|
|
10
|
+
|
|
11
|
+
code: str
|
|
12
|
+
message: str
|
|
13
|
+
retryable: bool = False
|
|
14
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict[str, Any]:
|
|
17
|
+
payload = {
|
|
18
|
+
"code": self.code,
|
|
19
|
+
"message": self.message,
|
|
20
|
+
"retryable": self.retryable,
|
|
21
|
+
}
|
|
22
|
+
if self.details:
|
|
23
|
+
payload["details"] = self.details
|
|
24
|
+
return payload
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Handoff artifact generation and continue flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import SubagentConfig
|
|
11
|
+
from .errors import SubagentError
|
|
12
|
+
from .paths import resolve_handoffs_dir, resolve_workspace_path
|
|
13
|
+
from .state import StateStore
|
|
14
|
+
from .turn_service import send_message
|
|
15
|
+
from .worker_service import start_worker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_text(value: Any, default: str) -> str:
|
|
19
|
+
if isinstance(value, str) and value.strip():
|
|
20
|
+
return value.strip()
|
|
21
|
+
return default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pick_task_from_events(events: list[dict[str, Any]]) -> str:
|
|
25
|
+
for event in events:
|
|
26
|
+
if str(event.get("event_type")) != "message.sent":
|
|
27
|
+
continue
|
|
28
|
+
data = event.get("data")
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
continue
|
|
31
|
+
text = data.get("text")
|
|
32
|
+
if isinstance(text, str) and text.strip():
|
|
33
|
+
return text.strip()
|
|
34
|
+
return "Continue the previous worker task safely."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pick_turn_id(events: list[dict[str, Any]]) -> str | None:
|
|
38
|
+
for event in reversed(events):
|
|
39
|
+
turn_id = event.get("turn_id")
|
|
40
|
+
if isinstance(turn_id, str) and turn_id:
|
|
41
|
+
return turn_id
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_handoff_markdown(
|
|
46
|
+
*,
|
|
47
|
+
worker: dict[str, Any],
|
|
48
|
+
task: str,
|
|
49
|
+
completed_lines: list[str],
|
|
50
|
+
pending_lines: list[str],
|
|
51
|
+
risk_lines: list[str],
|
|
52
|
+
handoff_path: Path,
|
|
53
|
+
checkpoint_path: Path,
|
|
54
|
+
) -> str:
|
|
55
|
+
files_of_interest = "- (not captured in v1 fallback handoff)"
|
|
56
|
+
commands_run = "- (not captured in v1 fallback handoff)"
|
|
57
|
+
completed = "\n".join(completed_lines or ["- No completed steps captured yet."])
|
|
58
|
+
pending = "\n".join(pending_lines or ["- No explicit pending items captured."])
|
|
59
|
+
risks = "\n".join(risk_lines or ["- No explicit risks captured."])
|
|
60
|
+
recommended = (
|
|
61
|
+
f"- Run `subagent worker continue --from-worker {worker['worker_id']}` to continue with a new worker."
|
|
62
|
+
)
|
|
63
|
+
artifacts = "\n".join(
|
|
64
|
+
[
|
|
65
|
+
f"- {handoff_path}",
|
|
66
|
+
f"- {checkpoint_path}",
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
lines = [
|
|
70
|
+
"# Handoff",
|
|
71
|
+
"",
|
|
72
|
+
"## Task",
|
|
73
|
+
task,
|
|
74
|
+
"",
|
|
75
|
+
"## Goal",
|
|
76
|
+
"Continue from the latest known context and complete the pending work safely.",
|
|
77
|
+
"",
|
|
78
|
+
"## Current Status",
|
|
79
|
+
f"Worker `{worker['worker_id']}` is currently `{worker['state']}`.",
|
|
80
|
+
"",
|
|
81
|
+
"## Completed",
|
|
82
|
+
completed,
|
|
83
|
+
"",
|
|
84
|
+
"## Pending",
|
|
85
|
+
pending,
|
|
86
|
+
"",
|
|
87
|
+
"## Files of Interest",
|
|
88
|
+
files_of_interest,
|
|
89
|
+
"",
|
|
90
|
+
"## Commands Run",
|
|
91
|
+
commands_run,
|
|
92
|
+
"",
|
|
93
|
+
"## Risks / Notes",
|
|
94
|
+
risks,
|
|
95
|
+
"",
|
|
96
|
+
"## Recommended Next Step",
|
|
97
|
+
recommended,
|
|
98
|
+
"",
|
|
99
|
+
"## Artifacts",
|
|
100
|
+
artifacts,
|
|
101
|
+
"",
|
|
102
|
+
]
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def create_handoff(
|
|
107
|
+
store: StateStore,
|
|
108
|
+
*,
|
|
109
|
+
worker_id: str,
|
|
110
|
+
handoffs_dir: Path | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
worker = store.get_worker(worker_id)
|
|
113
|
+
if worker is None:
|
|
114
|
+
raise SubagentError(
|
|
115
|
+
code="WORKER_NOT_FOUND",
|
|
116
|
+
message=f"Worker not found: {worker_id}",
|
|
117
|
+
details={"workerId": worker_id},
|
|
118
|
+
)
|
|
119
|
+
events = store.list_worker_events(worker_id)
|
|
120
|
+
task = _pick_task_from_events(events)
|
|
121
|
+
source_turn_id = _pick_turn_id(events)
|
|
122
|
+
|
|
123
|
+
completed_lines: list[str] = []
|
|
124
|
+
pending_lines: list[str] = []
|
|
125
|
+
for event in events:
|
|
126
|
+
event_type = str(event.get("event_type"))
|
|
127
|
+
turn_id = event.get("turn_id")
|
|
128
|
+
if event_type == "turn.completed":
|
|
129
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
130
|
+
outcome = _safe_text(data.get("outcome"), "completed")
|
|
131
|
+
completed_lines.append(f"- Turn `{turn_id}` completed with outcome `{outcome}`.")
|
|
132
|
+
if event_type == "approval.requested":
|
|
133
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
134
|
+
request_id = _safe_text(data.get("requestId"), "unknown")
|
|
135
|
+
pending_lines.append(f"- Approval decision pending for request `{request_id}`.")
|
|
136
|
+
|
|
137
|
+
pending_requests = store.list_pending_approval_requests(worker_id)
|
|
138
|
+
risk_lines = [
|
|
139
|
+
f"- Pending approval request `{req['request_id']}` may block continuation."
|
|
140
|
+
for req in pending_requests
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
root = resolve_handoffs_dir(handoffs_dir)
|
|
144
|
+
snapshot_id = f"hs_{uuid.uuid4().hex[:10]}"
|
|
145
|
+
snapshot_dir = root / worker_id / snapshot_id
|
|
146
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
handoff_path = snapshot_dir / "handoff.md"
|
|
148
|
+
checkpoint_path = snapshot_dir / "checkpoint.json"
|
|
149
|
+
|
|
150
|
+
markdown = _build_handoff_markdown(
|
|
151
|
+
worker=worker,
|
|
152
|
+
task=task,
|
|
153
|
+
completed_lines=completed_lines,
|
|
154
|
+
pending_lines=pending_lines,
|
|
155
|
+
risk_lines=risk_lines,
|
|
156
|
+
handoff_path=handoff_path,
|
|
157
|
+
checkpoint_path=checkpoint_path,
|
|
158
|
+
)
|
|
159
|
+
handoff_path.write_text(markdown, encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
checkpoint = {
|
|
162
|
+
"schemaVersion": "v1",
|
|
163
|
+
"workerId": worker["worker_id"],
|
|
164
|
+
"controllerId": worker["controller_id"],
|
|
165
|
+
"launcher": worker["launcher"],
|
|
166
|
+
"profile": worker["profile"],
|
|
167
|
+
"packs": worker["packs"],
|
|
168
|
+
"cwd": worker["cwd"],
|
|
169
|
+
"state": "handoff_ready",
|
|
170
|
+
"sourceTurnId": source_turn_id,
|
|
171
|
+
"handoffPath": str(handoff_path),
|
|
172
|
+
"artifacts": [str(handoff_path)],
|
|
173
|
+
"filesChanged": [],
|
|
174
|
+
}
|
|
175
|
+
checkpoint_path.write_text(
|
|
176
|
+
json.dumps(checkpoint, ensure_ascii=False, indent=2),
|
|
177
|
+
encoding="utf-8",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
snapshot = store.register_handoff_snapshot(
|
|
181
|
+
worker_id=worker_id,
|
|
182
|
+
source_turn_id=source_turn_id,
|
|
183
|
+
handoff_path=str(handoff_path),
|
|
184
|
+
checkpoint_path=str(checkpoint_path),
|
|
185
|
+
)
|
|
186
|
+
return {
|
|
187
|
+
"snapshotId": snapshot["snapshot_id"],
|
|
188
|
+
"workerId": worker_id,
|
|
189
|
+
"handoffPath": str(handoff_path),
|
|
190
|
+
"checkpointPath": str(checkpoint_path),
|
|
191
|
+
"sourceTurnId": source_turn_id,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _load_checkpoint_if_exists(path: Path) -> dict[str, Any]:
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return {}
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
return {}
|
|
202
|
+
return payload if isinstance(payload, dict) else {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def resolve_handoff_input(
|
|
206
|
+
store: StateStore,
|
|
207
|
+
*,
|
|
208
|
+
from_worker: str | None,
|
|
209
|
+
from_handoff: Path | None,
|
|
210
|
+
handoffs_dir: Path | None = None,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
if bool(from_worker) == bool(from_handoff):
|
|
213
|
+
raise SubagentError(
|
|
214
|
+
code="INVALID_ARGUMENT",
|
|
215
|
+
message="Specify exactly one of --from-worker or --from-handoff.",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if from_worker:
|
|
219
|
+
latest = store.get_latest_handoff_snapshot(from_worker)
|
|
220
|
+
if latest is None:
|
|
221
|
+
created = create_handoff(store, worker_id=from_worker, handoffs_dir=handoffs_dir)
|
|
222
|
+
handoff_path = Path(created["handoffPath"]).expanduser().resolve()
|
|
223
|
+
checkpoint_path = Path(created["checkpointPath"]).expanduser().resolve()
|
|
224
|
+
checkpoint = _load_checkpoint_if_exists(checkpoint_path)
|
|
225
|
+
return {
|
|
226
|
+
"sourceWorkerId": from_worker,
|
|
227
|
+
"handoffPath": handoff_path,
|
|
228
|
+
"checkpointPath": checkpoint_path,
|
|
229
|
+
"checkpoint": checkpoint,
|
|
230
|
+
}
|
|
231
|
+
handoff_path = Path(str(latest["handoff_path"])).expanduser().resolve()
|
|
232
|
+
checkpoint_path = Path(str(latest["checkpoint_path"])).expanduser().resolve()
|
|
233
|
+
checkpoint = _load_checkpoint_if_exists(checkpoint_path)
|
|
234
|
+
return {
|
|
235
|
+
"sourceWorkerId": from_worker,
|
|
236
|
+
"handoffPath": handoff_path,
|
|
237
|
+
"checkpointPath": checkpoint_path,
|
|
238
|
+
"checkpoint": checkpoint,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
assert from_handoff is not None
|
|
242
|
+
handoff_path = from_handoff.expanduser().resolve()
|
|
243
|
+
if not handoff_path.exists():
|
|
244
|
+
raise SubagentError(
|
|
245
|
+
code="HANDOFF_NOT_FOUND",
|
|
246
|
+
message=f"Handoff file not found: {handoff_path}",
|
|
247
|
+
details={"handoffPath": str(handoff_path)},
|
|
248
|
+
)
|
|
249
|
+
checkpoint_path = handoff_path.parent / "checkpoint.json"
|
|
250
|
+
checkpoint = _load_checkpoint_if_exists(checkpoint_path)
|
|
251
|
+
source_worker_id = checkpoint.get("workerId")
|
|
252
|
+
source_worker = str(source_worker_id) if isinstance(source_worker_id, str) else None
|
|
253
|
+
return {
|
|
254
|
+
"sourceWorkerId": source_worker,
|
|
255
|
+
"handoffPath": handoff_path,
|
|
256
|
+
"checkpointPath": checkpoint_path if checkpoint_path.exists() else None,
|
|
257
|
+
"checkpoint": checkpoint,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def continue_worker(
|
|
262
|
+
store: StateStore,
|
|
263
|
+
config: SubagentConfig,
|
|
264
|
+
*,
|
|
265
|
+
from_worker: str | None,
|
|
266
|
+
from_handoff: Path | None,
|
|
267
|
+
launcher: str | None,
|
|
268
|
+
profile: str | None,
|
|
269
|
+
packs: list[str],
|
|
270
|
+
cwd: Path | None,
|
|
271
|
+
label: str | None,
|
|
272
|
+
controller_id: str | None,
|
|
273
|
+
handoffs_dir: Path | None = None,
|
|
274
|
+
debug_mode: bool = False,
|
|
275
|
+
execution_mode: str = "strict",
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
source = resolve_handoff_input(
|
|
278
|
+
store,
|
|
279
|
+
from_worker=from_worker,
|
|
280
|
+
from_handoff=from_handoff,
|
|
281
|
+
handoffs_dir=handoffs_dir,
|
|
282
|
+
)
|
|
283
|
+
checkpoint = source.get("checkpoint", {})
|
|
284
|
+
if not isinstance(checkpoint, dict):
|
|
285
|
+
checkpoint = {}
|
|
286
|
+
|
|
287
|
+
source_handoff_path = Path(source["handoffPath"])
|
|
288
|
+
source_worker_id = source.get("sourceWorkerId")
|
|
289
|
+
checkpoint_launcher = checkpoint.get("launcher")
|
|
290
|
+
checkpoint_profile = checkpoint.get("profile")
|
|
291
|
+
checkpoint_packs = checkpoint.get("packs")
|
|
292
|
+
checkpoint_cwd = checkpoint.get("cwd")
|
|
293
|
+
checkpoint_controller = checkpoint.get("controllerId")
|
|
294
|
+
|
|
295
|
+
target_launcher = launcher or (str(checkpoint_launcher) if isinstance(checkpoint_launcher, str) else None)
|
|
296
|
+
target_profile = profile or (str(checkpoint_profile) if isinstance(checkpoint_profile, str) else None)
|
|
297
|
+
target_packs: list[str]
|
|
298
|
+
if packs:
|
|
299
|
+
target_packs = packs
|
|
300
|
+
elif isinstance(checkpoint_packs, list):
|
|
301
|
+
target_packs = [str(item) for item in checkpoint_packs]
|
|
302
|
+
else:
|
|
303
|
+
target_packs = []
|
|
304
|
+
|
|
305
|
+
target_cwd = resolve_workspace_path(cwd if cwd is not None else Path(str(checkpoint_cwd)) if isinstance(checkpoint_cwd, str) else Path.cwd())
|
|
306
|
+
target_controller = controller_id or (
|
|
307
|
+
str(checkpoint_controller) if isinstance(checkpoint_controller, str) else None
|
|
308
|
+
)
|
|
309
|
+
target_label = label or (
|
|
310
|
+
f"continued-from-{source_worker_id}" if isinstance(source_worker_id, str) and source_worker_id else "continued-worker"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
started = start_worker(
|
|
314
|
+
store,
|
|
315
|
+
config,
|
|
316
|
+
workspace=target_cwd,
|
|
317
|
+
worker_cwd=target_cwd,
|
|
318
|
+
controller_id=target_controller,
|
|
319
|
+
launcher=target_launcher,
|
|
320
|
+
profile=target_profile,
|
|
321
|
+
packs=target_packs,
|
|
322
|
+
label=target_label,
|
|
323
|
+
debug_mode=debug_mode,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
prompt_text = (
|
|
327
|
+
"Read the handoff document and continue from the previous worker context. "
|
|
328
|
+
"Validate with the repository state and proceed with the next safe step."
|
|
329
|
+
)
|
|
330
|
+
blocks = [
|
|
331
|
+
{
|
|
332
|
+
"type": "resource_link",
|
|
333
|
+
"resource": {
|
|
334
|
+
"uri": source_handoff_path.as_uri(),
|
|
335
|
+
"mimeType": "text/markdown",
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
turn = send_message(
|
|
340
|
+
store,
|
|
341
|
+
worker_id=str(started["workerId"]),
|
|
342
|
+
text=prompt_text,
|
|
343
|
+
blocks=blocks,
|
|
344
|
+
request_approval=False,
|
|
345
|
+
config=config,
|
|
346
|
+
execution_mode=execution_mode,
|
|
347
|
+
)
|
|
348
|
+
return {
|
|
349
|
+
"sourceWorkerId": source_worker_id,
|
|
350
|
+
"sourceHandoffPath": str(source_handoff_path),
|
|
351
|
+
"checkpointPath": str(source["checkpointPath"]) if source.get("checkpointPath") else None,
|
|
352
|
+
"worker": started,
|
|
353
|
+
"bootstrapTurn": turn,
|
|
354
|
+
}
|
subagent/hints.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Project-local controller hint read/write helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .constants import SCHEMA_VERSION
|
|
10
|
+
from .paths import project_hint_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_project_hint(workspace: Path) -> dict[str, Any] | None:
|
|
14
|
+
hint_path = project_hint_path(workspace)
|
|
15
|
+
if not hint_path.exists():
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
payload = json.loads(hint_path.read_text(encoding="utf-8"))
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
return None
|
|
21
|
+
if not isinstance(payload, dict):
|
|
22
|
+
return None
|
|
23
|
+
return payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_project_hint(workspace: Path, *, controller_id: str, label: str) -> Path:
|
|
27
|
+
hint_path = project_hint_path(workspace)
|
|
28
|
+
hint_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
payload = {
|
|
30
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
31
|
+
"controllerId": controller_id,
|
|
32
|
+
"label": label,
|
|
33
|
+
"workspaceKey": str(workspace),
|
|
34
|
+
}
|
|
35
|
+
hint_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
36
|
+
return hint_path
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Helpers for `--input` JSON contract and duplicate field protection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import SubagentError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_input_payload(input_path: str | None) -> dict[str, Any] | None:
|
|
13
|
+
if input_path is None:
|
|
14
|
+
return None
|
|
15
|
+
if input_path == "-":
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
raw = sys.stdin.read()
|
|
19
|
+
else:
|
|
20
|
+
raw = Path(input_path).read_text(encoding="utf-8")
|
|
21
|
+
try:
|
|
22
|
+
payload = json.loads(raw)
|
|
23
|
+
except json.JSONDecodeError as error:
|
|
24
|
+
raise SubagentError(
|
|
25
|
+
code="INVALID_INPUT",
|
|
26
|
+
message="--input must contain valid JSON",
|
|
27
|
+
details={"error": str(error)},
|
|
28
|
+
) from error
|
|
29
|
+
if not isinstance(payload, dict):
|
|
30
|
+
raise SubagentError(
|
|
31
|
+
code="INVALID_INPUT",
|
|
32
|
+
message="--input JSON must be an object",
|
|
33
|
+
)
|
|
34
|
+
return payload
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reject_duplicates(
|
|
38
|
+
payload: dict[str, Any] | None,
|
|
39
|
+
*,
|
|
40
|
+
flag_values: dict[str, Any],
|
|
41
|
+
value_is_default: dict[str, bool],
|
|
42
|
+
mapping: dict[str, str],
|
|
43
|
+
) -> None:
|
|
44
|
+
if payload is None:
|
|
45
|
+
return
|
|
46
|
+
for json_field, flag_name in mapping.items():
|
|
47
|
+
if json_field not in payload:
|
|
48
|
+
continue
|
|
49
|
+
if flag_name not in flag_values:
|
|
50
|
+
continue
|
|
51
|
+
if value_is_default.get(flag_name, True):
|
|
52
|
+
continue
|
|
53
|
+
raise SubagentError(
|
|
54
|
+
code="INVALID_INPUT",
|
|
55
|
+
message=f"Field `{json_field}` is provided by both --input and flags.",
|
|
56
|
+
details={"field": json_field, "flag": flag_name},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_string(payload: dict[str, Any], key: str) -> str | None:
|
|
61
|
+
value = payload.get(key)
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
if not isinstance(value, str):
|
|
65
|
+
raise SubagentError(
|
|
66
|
+
code="INVALID_INPUT",
|
|
67
|
+
message=f"`{key}` must be a string",
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_bool(payload: dict[str, Any], key: str) -> bool | None:
|
|
73
|
+
if key not in payload:
|
|
74
|
+
return None
|
|
75
|
+
value = payload[key]
|
|
76
|
+
if not isinstance(value, bool):
|
|
77
|
+
raise SubagentError(
|
|
78
|
+
code="INVALID_INPUT",
|
|
79
|
+
message=f"`{key}` must be a boolean",
|
|
80
|
+
)
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_string_list(payload: dict[str, Any], key: str) -> list[str] | None:
|
|
85
|
+
if key not in payload:
|
|
86
|
+
return None
|
|
87
|
+
value = payload[key]
|
|
88
|
+
if not isinstance(value, list):
|
|
89
|
+
raise SubagentError(
|
|
90
|
+
code="INVALID_INPUT",
|
|
91
|
+
message=f"`{key}` must be a list of strings",
|
|
92
|
+
)
|
|
93
|
+
out: list[str] = []
|
|
94
|
+
for idx, item in enumerate(value):
|
|
95
|
+
if not isinstance(item, str):
|
|
96
|
+
raise SubagentError(
|
|
97
|
+
code="INVALID_INPUT",
|
|
98
|
+
message=f"`{key}[{idx}]` must be a string",
|
|
99
|
+
)
|
|
100
|
+
out.append(item)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_blocks(payload: dict[str, Any], key: str = "blocks") -> list[dict[str, Any]] | None:
|
|
105
|
+
if key not in payload:
|
|
106
|
+
return None
|
|
107
|
+
value = payload[key]
|
|
108
|
+
if not isinstance(value, list):
|
|
109
|
+
raise SubagentError(
|
|
110
|
+
code="INVALID_INPUT",
|
|
111
|
+
message=f"`{key}` must be a list",
|
|
112
|
+
)
|
|
113
|
+
blocks: list[dict[str, Any]] = []
|
|
114
|
+
for idx, item in enumerate(value):
|
|
115
|
+
if not isinstance(item, dict):
|
|
116
|
+
raise SubagentError(
|
|
117
|
+
code="INVALID_INPUT",
|
|
118
|
+
message=f"`{key}[{idx}]` must be an object",
|
|
119
|
+
)
|
|
120
|
+
blocks.append(item)
|
|
121
|
+
return blocks
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Launcher helper operations (e.g., probe)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import SubagentConfig
|
|
9
|
+
from .errors import SubagentError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def probe_launcher(config: SubagentConfig, launcher_name: str) -> dict[str, Any]:
|
|
13
|
+
launcher = config.launchers.get(launcher_name)
|
|
14
|
+
if launcher is None:
|
|
15
|
+
raise SubagentError(
|
|
16
|
+
code="LAUNCHER_NOT_FOUND",
|
|
17
|
+
message=f"Launcher not found: {launcher_name}",
|
|
18
|
+
details={"launcher": launcher_name},
|
|
19
|
+
)
|
|
20
|
+
command_path = shutil.which(launcher.command)
|
|
21
|
+
available = command_path is not None
|
|
22
|
+
return {
|
|
23
|
+
"name": launcher.name,
|
|
24
|
+
"backendKind": launcher.backend_kind,
|
|
25
|
+
"command": launcher.command,
|
|
26
|
+
"resolvedCommandPath": command_path,
|
|
27
|
+
"available": available,
|
|
28
|
+
"args": launcher.args,
|
|
29
|
+
"envKeys": sorted(launcher.env.keys()),
|
|
30
|
+
}
|