local-coding-agent 1.1.0__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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,132 @@
1
+ """UTF-8 JSONL process-bound adapter for the proposal-only service seam."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from typing import Any, BinaryIO, TextIO
10
+
11
+ from .service import DelegationRequest, DelegationService
12
+
13
+
14
+ class StdioDelegationAdapter:
15
+ """Expose one bounded ``delegate_code`` operation over newline-delimited JSON.
16
+
17
+ The adapter owns framing and request decoding only. Workspace registration,
18
+ model-profile allowlisting, idempotency, policy, validation, and result
19
+ ownership remain in :class:`DelegationService`.
20
+ """
21
+
22
+ def __init__(self, service: DelegationService, *, max_request_bytes: int = 64 * 1024) -> None:
23
+ if max_request_bytes <= 0:
24
+ raise ValueError("max_request_bytes must be positive")
25
+ self._service = service
26
+ self._max_request_bytes = max_request_bytes
27
+
28
+ def handle_line(self, raw_line: str | bytes) -> str:
29
+ if not isinstance(raw_line, (str, bytes)):
30
+ return self._encode_failure("invalid_request", "line must be text or UTF-8 bytes")
31
+ try:
32
+ request_size = len(raw_line) if isinstance(raw_line, bytes) else len(raw_line.encode("utf-8"))
33
+ except UnicodeEncodeError as error:
34
+ return self._encode_failure("invalid_utf8", f"request is not valid UTF-8: {error}")
35
+ if request_size > self._max_request_bytes:
36
+ return self._encode_failure(
37
+ "request_too_large",
38
+ f"request exceeds max_request_bytes={self._max_request_bytes}",
39
+ )
40
+ try:
41
+ line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
42
+ except UnicodeDecodeError as error:
43
+ return self._encode_failure("invalid_utf8", f"request is not valid UTF-8: {error}")
44
+ try:
45
+ message = json.loads(line)
46
+ except (TypeError, json.JSONDecodeError) as error:
47
+ return self._encode_failure("invalid_json", f"request is not valid JSON: {error}")
48
+ if not isinstance(message, Mapping):
49
+ return self._encode_failure("invalid_request", "request must be a JSON object")
50
+ if message.get("method") != "delegate_code":
51
+ return self._encode_failure("unknown_method", "only delegate_code is supported")
52
+ params = message.get("params")
53
+ if not isinstance(params, Mapping):
54
+ return self._encode_failure("invalid_request", "params must be a JSON object")
55
+ try:
56
+ request = self._request_from_params(params)
57
+ except (TypeError, ValueError) as error:
58
+ return self._encode_failure("invalid_request", str(error))
59
+ result = self._service.delegate(message.get("caller_id"), request)
60
+ return self._encode(result)
61
+
62
+ def serve(
63
+ self,
64
+ input_stream: BinaryIO | TextIO | None = None,
65
+ output_stream: BinaryIO | TextIO | None = None,
66
+ ) -> None:
67
+ source = input_stream or getattr(sys.stdin, "buffer", sys.stdin)
68
+ target = output_stream or getattr(sys.stdout, "buffer", sys.stdout)
69
+ while True:
70
+ raw_line = source.readline(self._max_request_bytes + 1)
71
+ if raw_line in (b"", ""):
72
+ break
73
+ if self._line_size(raw_line) > self._max_request_bytes:
74
+ while not self._line_terminated(raw_line):
75
+ raw_line = source.readline(self._max_request_bytes + 1)
76
+ if raw_line in (b"", ""):
77
+ break
78
+ response = self._encode_failure(
79
+ "request_too_large",
80
+ f"request exceeds max_request_bytes={self._max_request_bytes}",
81
+ ) + "\n"
82
+ elif not raw_line.strip():
83
+ continue
84
+ else:
85
+ response = self.handle_line(raw_line) + "\n"
86
+ encoded = response.encode("utf-8")
87
+ try:
88
+ target.write(encoded)
89
+ except TypeError:
90
+ target.write(response)
91
+ target.flush()
92
+
93
+ @staticmethod
94
+ def _request_from_params(params: Mapping[str, Any]) -> DelegationRequest:
95
+ return DelegationRequest.from_mapping(params)
96
+
97
+ @staticmethod
98
+ def _line_size(raw_line: str | bytes) -> int:
99
+ return len(raw_line) if isinstance(raw_line, bytes) else len(raw_line.encode("utf-8"))
100
+
101
+ @staticmethod
102
+ def _line_terminated(raw_line: str | bytes) -> bool:
103
+ return raw_line.endswith(b"\n") if isinstance(raw_line, bytes) else raw_line.endswith("\n")
104
+
105
+ @staticmethod
106
+ def _encode(value: Mapping[str, Any]) -> str:
107
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
108
+
109
+ @classmethod
110
+ def _encode_failure(cls, kind: str, message: str) -> str:
111
+ return cls._encode(
112
+ {
113
+ "status": "failed",
114
+ "error": {"kind": kind, "message": message},
115
+ "audit": [{"event": "protocol_rejected", "kind": kind}],
116
+ "applied": False,
117
+ }
118
+ )
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(description="Run the proposal-only JSONL delegation adapter")
123
+ parser.add_argument("--workspace-ref", default="workspace")
124
+ parser.add_argument("--workspace", required=True)
125
+ args = parser.parse_args(argv)
126
+ service = DelegationService({args.workspace_ref: args.workspace})
127
+ StdioDelegationAdapter(service).serve()
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
@@ -0,0 +1,516 @@
1
+ """Continuable background subagents and coordinator (R30).
2
+
3
+ Provides lightweight in-process subagent coordination, isolated TaskEnvelopes,
4
+ mailbox-based inter-agent messaging, status reporting, and continuable worker loops.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import math
11
+ import queue
12
+ import re
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from dataclasses import asdict, dataclass, field
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Mapping, Sequence
20
+
21
+ from .task import TaskEnvelope
22
+
23
+
24
+ def _now_iso() -> str:
25
+ return datetime.now(timezone.utc).isoformat()
26
+
27
+
28
+ def _safe_copy(val: Any) -> Any:
29
+ """Safely copy or serialize object payloads, handling uncopyable types gracefully."""
30
+ try:
31
+ return copy.deepcopy(val)
32
+ except Exception:
33
+ if isinstance(val, dict):
34
+ return {str(k): _safe_copy(v) for k, v in val.items()}
35
+ elif isinstance(val, (list, tuple, set)):
36
+ return [_safe_copy(x) for x in val]
37
+ return str(val)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class MailboxMessage:
42
+ """A message routed between subagents or the coordinator."""
43
+
44
+ id: str
45
+ sender_id: str
46
+ target_id: str
47
+ content: Any
48
+ timestamp: str = field(default_factory=_now_iso)
49
+
50
+ def as_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "id": self.id,
53
+ "sender_id": self.sender_id,
54
+ "target_id": self.target_id,
55
+ "content": _safe_copy(self.content),
56
+ "timestamp": self.timestamp,
57
+ }
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class SubagentReport:
62
+ """A progress or status report emitted by a subagent."""
63
+
64
+ report_id: str
65
+ agent_id: str
66
+ status: str
67
+ payload: dict[str, Any]
68
+ timestamp: str = field(default_factory=_now_iso)
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "report_id": self.report_id,
73
+ "agent_id": self.agent_id,
74
+ "status": self.status,
75
+ "payload": _safe_copy(self.payload),
76
+ "timestamp": self.timestamp,
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class SubagentContext:
82
+ """Runtime execution context provided to child subagent workers."""
83
+
84
+ agent_id: str
85
+ role: str
86
+ goal: str
87
+ task: TaskEnvelope
88
+ profile: str
89
+ allowed_tools: tuple[str, ...]
90
+ coordinator: "SubagentCoordinator"
91
+ cancel_event: threading.Event
92
+ workspace_ref: str = "default"
93
+ parent_id: str | None = None
94
+
95
+ def send_message(self, target_agent_id: str, message: Any) -> bool:
96
+ """Send a message to another agent or the coordinator."""
97
+ return self.coordinator.send_message(target_agent_id, message, sender_id=self.agent_id)
98
+
99
+ def receive_messages(self, clear: bool = True) -> list[dict[str, Any]]:
100
+ """Receive pending messages from this agent's mailbox."""
101
+ return self.coordinator.receive_messages(self.agent_id, clear=clear)
102
+
103
+ def report(self, status: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
104
+ """Report intermediate progress or results to the coordinator."""
105
+ return self.coordinator.report(self.agent_id, status, payload)
106
+
107
+ def is_cancelled(self) -> bool:
108
+ """True if cooperative cancellation was requested."""
109
+ return self.cancel_event.is_set()
110
+
111
+
112
+ @dataclass
113
+ class _SubagentRecord:
114
+ agent_id: str
115
+ role: str
116
+ goal: str
117
+ profile: str
118
+ task: TaskEnvelope
119
+ allowed_tools: tuple[str, ...]
120
+ workspace_ref: str
121
+ parent_id: str | None
122
+ status: str # "pending", "running", "paused", "completed", "failed", "cancelled"
123
+ created_at: str
124
+ updated_at: str
125
+ cancel_event: threading.Event
126
+ pause_event: threading.Event
127
+ step_event: threading.Event
128
+ completed_event: threading.Event
129
+ result: dict[str, Any] | None = None
130
+ error: dict[str, Any] | None = None
131
+ thread: threading.Thread | None = None
132
+
133
+
134
+ class SubagentCoordinator:
135
+ """Coordinates continuable background subagents with isolated context and mailboxes."""
136
+
137
+ DEFAULT_TOOLS = ("list_files", "read_file", "search_text", "propose_patch")
138
+
139
+ def __init__(
140
+ self,
141
+ *,
142
+ workspaces: Mapping[str, str | Path] | None = None,
143
+ max_workers: int = 8,
144
+ default_profile: str = "default",
145
+ ) -> None:
146
+ if max_workers <= 0:
147
+ raise ValueError("max_workers must be positive")
148
+ self._max_workers = max_workers
149
+ self._default_profile = default_profile
150
+ self._workspaces: dict[str, Path] = {}
151
+ if workspaces:
152
+ for ref, path in workspaces.items():
153
+ self._workspaces[ref] = Path(path).resolve()
154
+
155
+ self._lock = threading.RLock()
156
+ self._agents: dict[str, _SubagentRecord] = {}
157
+ self._mailboxes: dict[str, list[MailboxMessage]] = {}
158
+ self._reports: dict[str, list[SubagentReport]] = {}
159
+ self._stopping = False
160
+
161
+ def spawn_subagent(
162
+ self,
163
+ role: str,
164
+ goal: str,
165
+ files: Sequence[str] | tuple[str, ...],
166
+ profile: str | None = None,
167
+ *,
168
+ allowed_tools: Sequence[str] | None = None,
169
+ context: str = "",
170
+ constraints: Sequence[str] = (),
171
+ checks: Sequence[str] = (),
172
+ acceptance: Sequence[str] = (),
173
+ workspace_ref: str = "default",
174
+ parent_id: str | None = None,
175
+ worker_loop: Callable[[SubagentContext], dict[str, Any] | None] | None = None,
176
+ agent_id: str | None = None,
177
+ ) -> str:
178
+ """Spawn a new background subagent with an isolated TaskEnvelope and mailbox."""
179
+ if not isinstance(role, str) or not role.strip():
180
+ raise ValueError("role must be a non-empty string")
181
+ if not isinstance(goal, str) or not goal.strip():
182
+ raise ValueError("goal must be a non-empty string")
183
+ if not files:
184
+ raise ValueError("files must not be empty")
185
+
186
+ selected_id = agent_id or f"subagent-{uuid.uuid4().hex[:12]}"
187
+ selected_profile = profile or self._default_profile
188
+ tools_subset = tuple(allowed_tools) if allowed_tools is not None else self.DEFAULT_TOOLS
189
+
190
+ task = TaskEnvelope(
191
+ id=selected_id,
192
+ goal=goal,
193
+ files=tuple(files),
194
+ context=context,
195
+ constraints=tuple(constraints),
196
+ checks=tuple(checks),
197
+ acceptance=tuple(acceptance),
198
+ )
199
+
200
+ cancel_evt = threading.Event()
201
+ pause_evt = threading.Event()
202
+ pause_evt.set() # Not paused by default
203
+ step_evt = threading.Event()
204
+ completed_evt = threading.Event()
205
+
206
+ record = _SubagentRecord(
207
+ agent_id=selected_id,
208
+ role=role,
209
+ goal=goal,
210
+ profile=selected_profile,
211
+ task=task,
212
+ allowed_tools=tools_subset,
213
+ workspace_ref=workspace_ref,
214
+ parent_id=parent_id,
215
+ status="pending",
216
+ created_at=_now_iso(),
217
+ updated_at=_now_iso(),
218
+ cancel_event=cancel_evt,
219
+ pause_event=pause_evt,
220
+ step_event=step_evt,
221
+ completed_event=completed_evt,
222
+ )
223
+
224
+ with self._lock:
225
+ if self._stopping:
226
+ raise RuntimeError("SubagentCoordinator is shutting down")
227
+ active_count = sum(1 for r in self._agents.values() if r.thread is not None and r.thread.is_alive())
228
+ if active_count >= self._max_workers:
229
+ raise RuntimeError(f"Maximum active workers limit reached ({self._max_workers})")
230
+ if selected_id in self._agents:
231
+ raise ValueError(f"subagent '{selected_id}' already exists")
232
+ self._agents[selected_id] = record
233
+ self._mailboxes[selected_id] = []
234
+ self._reports[selected_id] = []
235
+
236
+ subagent_ctx = SubagentContext(
237
+ agent_id=selected_id,
238
+ role=role,
239
+ goal=goal,
240
+ task=task,
241
+ profile=selected_profile,
242
+ allowed_tools=tools_subset,
243
+ coordinator=self,
244
+ cancel_event=cancel_evt,
245
+ workspace_ref=workspace_ref,
246
+ parent_id=parent_id,
247
+ )
248
+
249
+ thread = threading.Thread(
250
+ target=self._run_child_worker,
251
+ args=(record, subagent_ctx, worker_loop),
252
+ name=f"dsh-subagent-{selected_id}",
253
+ daemon=True,
254
+ )
255
+ record.thread = thread
256
+ thread.start()
257
+
258
+ return selected_id
259
+
260
+ def _run_child_worker(
261
+ self,
262
+ record: _SubagentRecord,
263
+ ctx: SubagentContext,
264
+ custom_loop: Callable[[SubagentContext], dict[str, Any] | None] | None,
265
+ ) -> None:
266
+ """In-process execution loop for child subagents."""
267
+ with self._lock:
268
+ if record.cancel_event.is_set():
269
+ record.status = "cancelled"
270
+ record.updated_at = _now_iso()
271
+ record.completed_event.set()
272
+ return
273
+ record.status = "running"
274
+ record.updated_at = _now_iso()
275
+
276
+ try:
277
+ if custom_loop is not None:
278
+ outcome = custom_loop(ctx)
279
+ with self._lock:
280
+ if record.cancel_event.is_set():
281
+ record.status = "cancelled"
282
+ else:
283
+ record.status = "completed" if outcome and outcome.get("status") != "failed" else "failed"
284
+ record.result = outcome if isinstance(outcome, dict) else {"status": "completed"}
285
+ record.updated_at = _now_iso()
286
+ else:
287
+ # Default continuable in-process execution loop
288
+ self._default_continuable_loop(record, ctx)
289
+ except Exception as exc:
290
+ with self._lock:
291
+ record.status = "failed"
292
+ record.error = {"kind": "subagent_exception", "message": str(exc)}
293
+ record.result = {"status": "failed", "error": record.error}
294
+ record.updated_at = _now_iso()
295
+ finally:
296
+ record.completed_event.set()
297
+
298
+ def _default_continuable_loop(self, record: _SubagentRecord, ctx: SubagentContext) -> None:
299
+ """Default iterative turns execution with mailbox check and reporting."""
300
+ max_turns = 5
301
+ turn = 0
302
+ while turn < max_turns:
303
+ if record.cancel_event.is_set():
304
+ with self._lock:
305
+ record.status = "cancelled"
306
+ record.updated_at = _now_iso()
307
+ return
308
+
309
+ # Respect pause/step
310
+ record.pause_event.wait()
311
+ if record.cancel_event.is_set():
312
+ with self._lock:
313
+ record.status = "cancelled"
314
+ record.updated_at = _now_iso()
315
+ return
316
+
317
+ turn += 1
318
+ incoming = ctx.receive_messages(clear=True)
319
+ ctx.report(
320
+ "working",
321
+ {
322
+ "turn": turn,
323
+ "processed_messages": len(incoming),
324
+ "goal": record.goal,
325
+ "role": record.role,
326
+ },
327
+ )
328
+
329
+ # Check if any incoming message commands termination or conclusion
330
+ for msg in incoming:
331
+ content = msg.get("content")
332
+ if isinstance(content, dict) and content.get("action") == "finish":
333
+ with self._lock:
334
+ record.status = "completed"
335
+ record.result = {
336
+ "status": "accepted",
337
+ "summary": f"Completed subagent task for role {record.role}",
338
+ "patch": content.get("patch", ""),
339
+ }
340
+ record.updated_at = _now_iso()
341
+ return
342
+
343
+ time.sleep(0.01)
344
+
345
+ # Default completion
346
+ with self._lock:
347
+ record.status = "completed"
348
+ record.result = {
349
+ "status": "accepted",
350
+ "summary": f"Subagent {record.agent_id} ({record.role}) completed goal: {record.goal}",
351
+ "patch": "",
352
+ }
353
+ record.updated_at = _now_iso()
354
+
355
+ def send_message(self, target_agent_id: str, message: Any, sender_id: str = "coordinator") -> bool:
356
+ """Post a message to a subagent's mailbox."""
357
+ msg_id = f"msg-{uuid.uuid4().hex[:8]}"
358
+ msg = MailboxMessage(
359
+ id=msg_id,
360
+ sender_id=sender_id,
361
+ target_id=target_agent_id,
362
+ content=message,
363
+ )
364
+ with self._lock:
365
+ if target_agent_id not in self._mailboxes and target_agent_id != "coordinator":
366
+ return False
367
+ if target_agent_id not in self._mailboxes:
368
+ self._mailboxes[target_agent_id] = []
369
+ self._mailboxes[target_agent_id].append(msg)
370
+ return True
371
+
372
+ def receive_messages(self, agent_id: str, clear: bool = True) -> list[dict[str, Any]]:
373
+ """Retrieve and optionally drain messages for the specified agent."""
374
+ with self._lock:
375
+ queue_list = self._mailboxes.get(agent_id, [])
376
+ if clear:
377
+ self._mailboxes[agent_id] = []
378
+ return [m.as_dict() for m in queue_list]
379
+
380
+ def report(self, agent_id: str, status: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
381
+ """Record an intermediate status report from an agent."""
382
+ rep_id = f"rep-{uuid.uuid4().hex[:8]}"
383
+ rep = SubagentReport(
384
+ report_id=rep_id,
385
+ agent_id=agent_id,
386
+ status=status,
387
+ payload=payload or {},
388
+ )
389
+ with self._lock:
390
+ if agent_id in self._reports:
391
+ self._reports[agent_id].append(rep)
392
+ else:
393
+ self._reports[agent_id] = [rep]
394
+
395
+ agent = self._agents.get(agent_id)
396
+ if agent is not None:
397
+ agent.updated_at = _now_iso()
398
+ return rep.as_dict()
399
+
400
+ def get_reports(self, agent_id: str | None = None) -> list[dict[str, Any]]:
401
+ """Retrieve reports for a specific agent or across all agents."""
402
+ with self._lock:
403
+ if agent_id is not None:
404
+ return [r.as_dict() for r in self._reports.get(agent_id, [])]
405
+ all_reports = []
406
+ for reports in self._reports.values():
407
+ all_reports.extend([r.as_dict() for r in reports])
408
+ return all_reports
409
+
410
+ def get_subagent_status(self, agent_id: str) -> dict[str, Any]:
411
+ """Query the current lifecycle state and results of a subagent."""
412
+ with self._lock:
413
+ record = self._agents.get(agent_id)
414
+ if record is None:
415
+ return {
416
+ "status": "unknown",
417
+ "agent_id": agent_id,
418
+ "error": {"kind": "unknown_subagent", "message": f"no subagent found with id {agent_id!r}"},
419
+ }
420
+ snapshot: dict[str, Any] = {
421
+ "agent_id": record.agent_id,
422
+ "role": record.role,
423
+ "goal": record.goal,
424
+ "profile": record.profile,
425
+ "status": record.status,
426
+ "allowed_tools": list(record.allowed_tools),
427
+ "created_at": record.created_at,
428
+ "updated_at": record.updated_at,
429
+ "parent_id": record.parent_id,
430
+ "pending_messages_count": len(self._mailboxes.get(agent_id, [])),
431
+ "reports_count": len(self._reports.get(agent_id, [])),
432
+ }
433
+ if record.result is not None:
434
+ snapshot["result"] = copy.deepcopy(record.result)
435
+ if record.error is not None:
436
+ snapshot["error"] = copy.deepcopy(record.error)
437
+ return snapshot
438
+
439
+ def cancel_subagent(self, agent_id: str) -> bool:
440
+ """Cancel a running or pending subagent."""
441
+ with self._lock:
442
+ record = self._agents.get(agent_id)
443
+ if record is None:
444
+ return False
445
+ record.cancel_event.set()
446
+ record.pause_event.set() # Unpause so it can exit
447
+ if record.status in ("pending", "paused"):
448
+ record.status = "cancelled"
449
+ record.updated_at = _now_iso()
450
+ record.completed_event.set()
451
+ return True
452
+
453
+ def pause_subagent(self, agent_id: str) -> bool:
454
+ """Pause a running subagent."""
455
+ with self._lock:
456
+ record = self._agents.get(agent_id)
457
+ if record is None or record.status not in ("running", "pending"):
458
+ return False
459
+ record.pause_event.clear()
460
+ record.status = "paused"
461
+ record.updated_at = _now_iso()
462
+ return True
463
+
464
+ def resume_subagent(self, agent_id: str) -> bool:
465
+ """Resume a paused subagent."""
466
+ with self._lock:
467
+ record = self._agents.get(agent_id)
468
+ if record is None or record.status != "paused":
469
+ return False
470
+ record.pause_event.set()
471
+ record.status = "running"
472
+ record.updated_at = _now_iso()
473
+ return True
474
+
475
+ def wait_subagent(self, agent_id: str, timeout: float | None = None) -> dict[str, Any]:
476
+ """Wait for a subagent to finish, with optional timeout."""
477
+ with self._lock:
478
+ record = self._agents.get(agent_id)
479
+ if record is None:
480
+ return self.get_subagent_status(agent_id)
481
+ evt = record.completed_event
482
+
483
+ evt.wait(timeout=timeout)
484
+ return self.get_subagent_status(agent_id)
485
+
486
+ def wait_all(self, timeout: float | None = None) -> dict[str, dict[str, Any]]:
487
+ """Wait for all active subagents to finish within the specified timeout."""
488
+ with self._lock:
489
+ records = list(self._agents.values())
490
+
491
+ deadline = (time.monotonic() + timeout) if timeout is not None else None
492
+ for r in records:
493
+ rem = max(0.0, deadline - time.monotonic()) if deadline is not None else None
494
+ r.completed_event.wait(timeout=rem)
495
+
496
+ with self._lock:
497
+ return {r.agent_id: self.get_subagent_status(r.agent_id) for r in self._agents.values()}
498
+
499
+ def list_subagents(self) -> list[dict[str, Any]]:
500
+ """List all subagents registered with this coordinator."""
501
+ with self._lock:
502
+ return [self.get_subagent_status(aid) for aid in self._agents]
503
+
504
+ def shutdown(self, timeout: float = 5.0) -> None:
505
+ """Cancel all subagents and wait for threads to join."""
506
+ with self._lock:
507
+ self._stopping = True
508
+ for r in self._agents.values():
509
+ r.cancel_event.set()
510
+ r.pause_event.set()
511
+
512
+ deadline = time.monotonic() + timeout
513
+ for r in list(self._agents.values()):
514
+ if r.thread is not None and r.thread.is_alive():
515
+ rem = max(0.0, deadline - time.monotonic())
516
+ r.thread.join(timeout=rem)