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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|