aiagent-runner 0.1.3__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.
- aiagent_runner/__init__.py +9 -0
- aiagent_runner/__main__.py +282 -0
- aiagent_runner/config.py +99 -0
- aiagent_runner/coordinator.py +687 -0
- aiagent_runner/coordinator_config.py +203 -0
- aiagent_runner/executor.py +99 -0
- aiagent_runner/mcp_client.py +698 -0
- aiagent_runner/prompt_builder.py +120 -0
- aiagent_runner/runner.py +236 -0
- aiagent_runner-0.1.3.dist-info/METADATA +185 -0
- aiagent_runner-0.1.3.dist-info/RECORD +13 -0
- aiagent_runner-0.1.3.dist-info/WHEEL +4 -0
- aiagent_runner-0.1.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
# src/aiagent_runner/coordinator.py
|
|
2
|
+
# Coordinator - Single orchestrator for all Agent Instances
|
|
3
|
+
# Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, TextIO
|
|
14
|
+
|
|
15
|
+
from aiagent_runner.coordinator_config import CoordinatorConfig
|
|
16
|
+
from aiagent_runner.mcp_client import MCPClient, MCPError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentInstanceKey:
|
|
23
|
+
"""Unique key for an Agent Instance: (agent_id, project_id)."""
|
|
24
|
+
agent_id: str
|
|
25
|
+
project_id: str
|
|
26
|
+
|
|
27
|
+
def __hash__(self):
|
|
28
|
+
return hash((self.agent_id, self.project_id))
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other):
|
|
31
|
+
if not isinstance(other, AgentInstanceKey):
|
|
32
|
+
return False
|
|
33
|
+
return self.agent_id == other.agent_id and self.project_id == other.project_id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AgentInstanceInfo:
|
|
38
|
+
"""Information about a running Agent Instance."""
|
|
39
|
+
key: AgentInstanceKey
|
|
40
|
+
process: subprocess.Popen
|
|
41
|
+
working_directory: str
|
|
42
|
+
provider: str # "claude", "gemini", "openai", "other"
|
|
43
|
+
model: Optional[str] # "claude-sonnet-4-5", "gemini-2.0-flash", etc.
|
|
44
|
+
started_at: datetime
|
|
45
|
+
log_file_handle: Optional["TextIO"] = None # Keep file handle open during process lifetime
|
|
46
|
+
task_id: Optional[str] = None # Phase 4: ログファイルパス登録用
|
|
47
|
+
log_file_path: Optional[str] = None # Phase 4: ログファイルパス
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Coordinator:
|
|
51
|
+
"""Single orchestrator that manages all Agent Instances.
|
|
52
|
+
|
|
53
|
+
The Coordinator operates in a polling loop:
|
|
54
|
+
1. health_check() - Verify MCP server is available
|
|
55
|
+
2. list_active_projects_with_agents() - Get all projects and their agents
|
|
56
|
+
3. For each (agent_id, project_id) pair:
|
|
57
|
+
- Check if we have a passkey configured
|
|
58
|
+
- get_agent_action(agent_id, project_id) - Check what action to take
|
|
59
|
+
- Spawn Agent Instance if needed
|
|
60
|
+
4. Clean up finished processes
|
|
61
|
+
5. Wait for polling interval
|
|
62
|
+
6. Repeat
|
|
63
|
+
|
|
64
|
+
Key differences from the old Runner:
|
|
65
|
+
- Single instance manages ALL (agent_id, project_id) combinations
|
|
66
|
+
- Does NOT authenticate - spawned Agent Instances do that
|
|
67
|
+
- Tracks running processes to avoid duplicates
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, config: CoordinatorConfig):
|
|
71
|
+
"""Initialize Coordinator.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config: Coordinator configuration with agents and ai_providers
|
|
75
|
+
"""
|
|
76
|
+
self.config = config
|
|
77
|
+
# Phase 5: Pass coordinator_token for Coordinator-only API authorization
|
|
78
|
+
logger.debug(f"Initializing MCPClient with coordinator_token: {'set' if config.coordinator_token else 'NOT SET'}")
|
|
79
|
+
self.mcp_client = MCPClient(
|
|
80
|
+
config.mcp_socket_path,
|
|
81
|
+
coordinator_token=config.coordinator_token
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._running = False
|
|
85
|
+
self._instances: dict[AgentInstanceKey, AgentInstanceInfo] = {}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def log_directory(self) -> Path:
|
|
89
|
+
"""Get log directory, creating if needed."""
|
|
90
|
+
if self.config.log_directory:
|
|
91
|
+
log_dir = Path(self.config.log_directory).expanduser()
|
|
92
|
+
else:
|
|
93
|
+
log_dir = Path.home() / ".aiagent-coordinator" / "logs"
|
|
94
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
return log_dir
|
|
96
|
+
|
|
97
|
+
def _get_log_directory(self, working_dir: Optional[str], agent_id: str) -> Path:
|
|
98
|
+
"""Get log directory for an agent.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
working_dir: Project working directory (None or empty string for fallback)
|
|
102
|
+
agent_id: Agent ID
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Path to log directory
|
|
106
|
+
"""
|
|
107
|
+
if working_dir:
|
|
108
|
+
# プロジェクトのワーキングディレクトリ基準
|
|
109
|
+
log_dir = Path(working_dir) / ".aiagent" / "logs" / agent_id
|
|
110
|
+
else:
|
|
111
|
+
# フォールバック: アプリ管轄ディレクトリ
|
|
112
|
+
log_dir = (
|
|
113
|
+
Path.home()
|
|
114
|
+
/ "Library" / "Application Support" / "AIAgentPM"
|
|
115
|
+
/ "agent_logs" / agent_id
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
return log_dir
|
|
120
|
+
|
|
121
|
+
async def start(self) -> None:
|
|
122
|
+
"""Start the Coordinator loop.
|
|
123
|
+
|
|
124
|
+
Runs until stop() is called or an unrecoverable error occurs.
|
|
125
|
+
"""
|
|
126
|
+
logger.info(
|
|
127
|
+
f"Starting Coordinator, polling every {self.config.polling_interval}s, "
|
|
128
|
+
f"max_concurrent={self.config.max_concurrent}"
|
|
129
|
+
)
|
|
130
|
+
logger.info(f"Configured agents: {list(self.config.agents.keys())}")
|
|
131
|
+
|
|
132
|
+
# Multi-device: Log root_agent_id if set
|
|
133
|
+
if self.config.root_agent_id:
|
|
134
|
+
logger.info(f"Multi-device mode: root_agent_id={self.config.root_agent_id}")
|
|
135
|
+
|
|
136
|
+
self._running = True
|
|
137
|
+
|
|
138
|
+
while self._running:
|
|
139
|
+
try:
|
|
140
|
+
await self._run_once()
|
|
141
|
+
except MCPError as e:
|
|
142
|
+
logger.error(f"MCP error: {e}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.exception(f"Unexpected error: {e}")
|
|
145
|
+
|
|
146
|
+
if self._running:
|
|
147
|
+
await asyncio.sleep(self.config.polling_interval)
|
|
148
|
+
|
|
149
|
+
def stop(self) -> None:
|
|
150
|
+
"""Stop the Coordinator loop."""
|
|
151
|
+
logger.info("Stopping Coordinator")
|
|
152
|
+
self._running = False
|
|
153
|
+
|
|
154
|
+
# Terminate all running instances
|
|
155
|
+
for key, info in list(self._instances.items()):
|
|
156
|
+
logger.info(f"Terminating {key.agent_id}/{key.project_id}")
|
|
157
|
+
try:
|
|
158
|
+
info.process.terminate()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.warning(f"Failed to terminate {key}: {e}")
|
|
161
|
+
|
|
162
|
+
async def _run_once(self) -> None:
|
|
163
|
+
"""Run one iteration of the polling loop."""
|
|
164
|
+
# Step 1: Health check
|
|
165
|
+
try:
|
|
166
|
+
health = await self.mcp_client.health_check()
|
|
167
|
+
if health.status != "ok":
|
|
168
|
+
logger.warning(f"MCP server unhealthy: {health.status}")
|
|
169
|
+
return
|
|
170
|
+
except MCPError as e:
|
|
171
|
+
logger.error(f"MCP server not available: {e}")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Step 2: Get active projects with agents
|
|
175
|
+
# Multi-device: Pass root_agent_id for working directory resolution
|
|
176
|
+
try:
|
|
177
|
+
projects = await self.mcp_client.list_active_projects_with_agents(
|
|
178
|
+
root_agent_id=self.config.root_agent_id
|
|
179
|
+
)
|
|
180
|
+
except MCPError as e:
|
|
181
|
+
logger.error(f"Failed to get project list: {e}")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
logger.debug(f"Found {len(projects)} active projects")
|
|
185
|
+
|
|
186
|
+
# Debug: Log project details including agents
|
|
187
|
+
for project in projects:
|
|
188
|
+
logger.debug(
|
|
189
|
+
f"Project {project.project_id}: agents={project.agents}, "
|
|
190
|
+
f"working_dir={project.working_directory}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Step 3: Clean up finished processes, register log file paths, and invalidate sessions
|
|
194
|
+
finished_instances = self._cleanup_finished()
|
|
195
|
+
for key, info, exit_code in finished_instances:
|
|
196
|
+
# Register log file path (if available)
|
|
197
|
+
if info.task_id and info.log_file_path:
|
|
198
|
+
try:
|
|
199
|
+
success = await self.mcp_client.register_execution_log_file(
|
|
200
|
+
agent_id=key.agent_id,
|
|
201
|
+
task_id=info.task_id,
|
|
202
|
+
log_file_path=info.log_file_path
|
|
203
|
+
)
|
|
204
|
+
if success:
|
|
205
|
+
logger.info(
|
|
206
|
+
f"Registered log file for {key.agent_id}/{key.project_id}: "
|
|
207
|
+
f"{info.log_file_path}"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
logger.warning(
|
|
211
|
+
f"Failed to register log file for {key.agent_id}/{key.project_id}"
|
|
212
|
+
)
|
|
213
|
+
except MCPError as e:
|
|
214
|
+
logger.error(
|
|
215
|
+
f"Error registering log file for {key.agent_id}/{key.project_id}: {e}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# If process exited with error, report to chat
|
|
219
|
+
if exit_code != 0 and info.log_file_path:
|
|
220
|
+
error_msg = self._extract_error_from_log(info.log_file_path)
|
|
221
|
+
if error_msg:
|
|
222
|
+
try:
|
|
223
|
+
success = await self.mcp_client.report_agent_error(
|
|
224
|
+
agent_id=key.agent_id,
|
|
225
|
+
project_id=key.project_id,
|
|
226
|
+
error_message=error_msg
|
|
227
|
+
)
|
|
228
|
+
if success:
|
|
229
|
+
logger.info(
|
|
230
|
+
f"Reported error for {key.agent_id}/{key.project_id}: {error_msg[:50]}..."
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
logger.warning(
|
|
234
|
+
f"Failed to report error for {key.agent_id}/{key.project_id}"
|
|
235
|
+
)
|
|
236
|
+
except MCPError as e:
|
|
237
|
+
logger.error(
|
|
238
|
+
f"Error reporting error for {key.agent_id}/{key.project_id}: {e}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Invalidate session so shouldStart returns True for next instance
|
|
242
|
+
try:
|
|
243
|
+
success = await self.mcp_client.invalidate_session(
|
|
244
|
+
agent_id=key.agent_id,
|
|
245
|
+
project_id=key.project_id
|
|
246
|
+
)
|
|
247
|
+
if success:
|
|
248
|
+
logger.info(
|
|
249
|
+
f"Invalidated session for {key.agent_id}/{key.project_id}"
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"Failed to invalidate session for {key.agent_id}/{key.project_id}"
|
|
254
|
+
)
|
|
255
|
+
except MCPError as e:
|
|
256
|
+
logger.error(
|
|
257
|
+
f"Error invalidating session for {key.agent_id}/{key.project_id}: {e}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Step 4: For each (agent_id, project_id), check if should start
|
|
261
|
+
for project in projects:
|
|
262
|
+
project_id = project.project_id
|
|
263
|
+
working_dir = project.working_directory
|
|
264
|
+
|
|
265
|
+
logger.debug(f"Processing project {project_id}, agents: {project.agents}")
|
|
266
|
+
|
|
267
|
+
for agent_id in project.agents:
|
|
268
|
+
key = AgentInstanceKey(agent_id, project_id)
|
|
269
|
+
logger.debug(f"Checking agent {agent_id} for project {project_id}")
|
|
270
|
+
|
|
271
|
+
# Skip if we don't have passkey configured
|
|
272
|
+
passkey = self.config.get_agent_passkey(agent_id)
|
|
273
|
+
logger.debug(f"Passkey for {agent_id}: {'configured' if passkey else 'NOT FOUND'}")
|
|
274
|
+
if not passkey:
|
|
275
|
+
logger.debug(f"No passkey configured for {agent_id}, skipping")
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# Check if instance is already running
|
|
279
|
+
instance_running = key in self._instances
|
|
280
|
+
|
|
281
|
+
# UC008: Always check get_agent_action for running instances to detect stop
|
|
282
|
+
if instance_running:
|
|
283
|
+
logger.debug(f"Instance {agent_id}/{project_id} running, checking for stop action")
|
|
284
|
+
try:
|
|
285
|
+
result = await self.mcp_client.get_agent_action(agent_id, project_id)
|
|
286
|
+
logger.debug(f"get_agent_action for running instance: action={result.action}, reason={result.reason}")
|
|
287
|
+
if result.action == "stop":
|
|
288
|
+
logger.info(f"Stopping instance {agent_id}/{project_id} due to {result.reason}")
|
|
289
|
+
await self._stop_instance(key)
|
|
290
|
+
except MCPError as e:
|
|
291
|
+
logger.error(f"Failed to check stop action for {agent_id}/{project_id}: {e}")
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Skip if at max concurrent
|
|
295
|
+
if len(self._instances) >= self.config.max_concurrent:
|
|
296
|
+
logger.debug(f"At max concurrent ({self.config.max_concurrent}), skipping")
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
# Check what action to take
|
|
300
|
+
logger.debug(f"Calling get_agent_action({agent_id}, {project_id})")
|
|
301
|
+
try:
|
|
302
|
+
result = await self.mcp_client.get_agent_action(agent_id, project_id)
|
|
303
|
+
logger.debug(
|
|
304
|
+
f"get_agent_action result: action={result.action}, reason={result.reason}, "
|
|
305
|
+
f"provider: {result.provider}, model: {result.model}, "
|
|
306
|
+
f"kick_command: {result.kick_command}, task_id: {result.task_id}"
|
|
307
|
+
)
|
|
308
|
+
if result.action == "start":
|
|
309
|
+
provider = result.provider or "claude"
|
|
310
|
+
self._spawn_instance(
|
|
311
|
+
agent_id=agent_id,
|
|
312
|
+
project_id=project_id,
|
|
313
|
+
passkey=passkey,
|
|
314
|
+
working_dir=working_dir,
|
|
315
|
+
provider=provider,
|
|
316
|
+
model=result.model,
|
|
317
|
+
kick_command=result.kick_command,
|
|
318
|
+
task_id=result.task_id
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
logger.debug(f"get_agent_action returned action='{result.action}' (reason: {result.reason}) for {agent_id}/{project_id}")
|
|
322
|
+
except MCPError as e:
|
|
323
|
+
logger.error(f"Failed to get_agent_action for {agent_id}/{project_id}: {e}")
|
|
324
|
+
|
|
325
|
+
async def _stop_instance(self, key: AgentInstanceKey) -> None:
|
|
326
|
+
"""Stop a running Agent Instance.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
key: The AgentInstanceKey identifying the instance to stop.
|
|
330
|
+
"""
|
|
331
|
+
info = self._instances.get(key)
|
|
332
|
+
if not info:
|
|
333
|
+
logger.warning(f"Instance {key.agent_id}/{key.project_id} not found in _instances")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
logger.info(f"Terminating instance {key.agent_id}/{key.project_id} (PID: {info.process.pid})")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
info.process.terminate()
|
|
340
|
+
# Wait a short time for graceful shutdown
|
|
341
|
+
try:
|
|
342
|
+
info.process.wait(timeout=5)
|
|
343
|
+
except subprocess.TimeoutExpired:
|
|
344
|
+
logger.warning(f"Instance {key.agent_id}/{key.project_id} did not terminate, killing")
|
|
345
|
+
info.process.kill()
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.error(f"Error terminating process: {e}")
|
|
348
|
+
|
|
349
|
+
# Close log file handle
|
|
350
|
+
if info.log_file_handle:
|
|
351
|
+
try:
|
|
352
|
+
info.log_file_handle.close()
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
# Remove from instances
|
|
357
|
+
del self._instances[key]
|
|
358
|
+
logger.info(f"Instance {key.agent_id}/{key.project_id} stopped and removed")
|
|
359
|
+
|
|
360
|
+
def _cleanup_finished(self) -> list[tuple[AgentInstanceKey, AgentInstanceInfo, int]]:
|
|
361
|
+
"""Clean up finished Agent Instance processes.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
List of (key, info, exit_code) tuples for finished instances
|
|
365
|
+
that need log file path registration.
|
|
366
|
+
"""
|
|
367
|
+
finished: list[tuple[AgentInstanceKey, AgentInstanceInfo, int]] = []
|
|
368
|
+
for key, info in self._instances.items():
|
|
369
|
+
retcode = info.process.poll()
|
|
370
|
+
if retcode is not None:
|
|
371
|
+
logger.info(
|
|
372
|
+
f"Instance {key.agent_id}/{key.project_id} finished with code {retcode}"
|
|
373
|
+
)
|
|
374
|
+
# Close log file handle
|
|
375
|
+
if info.log_file_handle:
|
|
376
|
+
try:
|
|
377
|
+
info.log_file_handle.close()
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
finished.append((key, info, retcode))
|
|
381
|
+
|
|
382
|
+
for key, _, _ in finished:
|
|
383
|
+
del self._instances[key]
|
|
384
|
+
|
|
385
|
+
return finished
|
|
386
|
+
|
|
387
|
+
def _extract_error_from_log(self, log_file_path: str) -> Optional[str]:
|
|
388
|
+
"""Extract error message from log file.
|
|
389
|
+
|
|
390
|
+
Looks for common error patterns in the last 50 lines of the log.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
log_file_path: Path to the log file
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Error message if found, None otherwise
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
with open(log_file_path, "r") as f:
|
|
400
|
+
lines = f.readlines()
|
|
401
|
+
|
|
402
|
+
# Check last 50 lines for errors
|
|
403
|
+
last_lines = lines[-50:] if len(lines) > 50 else lines
|
|
404
|
+
|
|
405
|
+
error_patterns = [
|
|
406
|
+
"[API Error:",
|
|
407
|
+
"Error:",
|
|
408
|
+
"ERROR:",
|
|
409
|
+
"error:",
|
|
410
|
+
"quota",
|
|
411
|
+
"rate limit",
|
|
412
|
+
"exhausted",
|
|
413
|
+
"unauthorized",
|
|
414
|
+
"authentication failed",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
for line in reversed(last_lines):
|
|
418
|
+
line_lower = line.lower()
|
|
419
|
+
for pattern in error_patterns:
|
|
420
|
+
if pattern.lower() in line_lower:
|
|
421
|
+
# Found an error line, return it (cleaned up)
|
|
422
|
+
return line.strip()
|
|
423
|
+
|
|
424
|
+
# If no specific error found but process failed, return generic message
|
|
425
|
+
return None
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.warning(f"Failed to read log file {log_file_path}: {e}")
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
def _spawn_instance(
|
|
431
|
+
self,
|
|
432
|
+
agent_id: str,
|
|
433
|
+
project_id: str,
|
|
434
|
+
passkey: str,
|
|
435
|
+
working_dir: str,
|
|
436
|
+
provider: str,
|
|
437
|
+
model: Optional[str] = None,
|
|
438
|
+
kick_command: Optional[str] = None,
|
|
439
|
+
task_id: Optional[str] = None
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Spawn an Agent Instance process.
|
|
442
|
+
|
|
443
|
+
The Agent Instance (Claude Code) will:
|
|
444
|
+
1. authenticate(agent_id, passkey, project_id)
|
|
445
|
+
2. get_my_task()
|
|
446
|
+
3. Execute the task
|
|
447
|
+
4. report_completed()
|
|
448
|
+
5. Exit
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
agent_id: Agent ID
|
|
452
|
+
project_id: Project ID
|
|
453
|
+
passkey: Agent passkey
|
|
454
|
+
working_dir: Working directory for the task
|
|
455
|
+
provider: AI provider (claude, gemini, openai, other)
|
|
456
|
+
model: Specific model (claude-sonnet-4-5, gemini-2.0-flash, etc.)
|
|
457
|
+
kick_command: Custom CLI command (takes priority if set)
|
|
458
|
+
task_id: Task ID (for log file path registration)
|
|
459
|
+
"""
|
|
460
|
+
# kick_command takes priority over provider-based selection
|
|
461
|
+
if kick_command:
|
|
462
|
+
# Parse kick_command into command and args
|
|
463
|
+
parts = kick_command.split()
|
|
464
|
+
cli_command = parts[0]
|
|
465
|
+
cli_args = parts[1:] if len(parts) > 1 else []
|
|
466
|
+
logger.info(f"Using kick_command: {kick_command}")
|
|
467
|
+
else:
|
|
468
|
+
# Use provider-based CLI selection
|
|
469
|
+
provider_config = self.config.get_provider(provider)
|
|
470
|
+
cli_command = provider_config.cli_command
|
|
471
|
+
cli_args = provider_config.cli_args
|
|
472
|
+
|
|
473
|
+
# Build prompt for the Agent Instance
|
|
474
|
+
prompt = self._build_agent_prompt(agent_id, project_id, passkey)
|
|
475
|
+
|
|
476
|
+
# Generate log file path (use working_dir-based path for project context)
|
|
477
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
478
|
+
log_dir = self._get_log_directory(working_dir, agent_id)
|
|
479
|
+
log_file = log_dir / f"{timestamp}.log"
|
|
480
|
+
|
|
481
|
+
# Build MCP config for Agent Instance (Unix Socket transport)
|
|
482
|
+
# Agent Instance connects to the SAME MCP daemon that the app started
|
|
483
|
+
# This ensures all components share the same database and state
|
|
484
|
+
socket_path = self.config.mcp_socket_path
|
|
485
|
+
if socket_path:
|
|
486
|
+
# Always expand tilde in socket path
|
|
487
|
+
socket_path = os.path.expanduser(socket_path)
|
|
488
|
+
else:
|
|
489
|
+
socket_path = os.path.expanduser(
|
|
490
|
+
"~/Library/Application Support/AIAgentPM/mcp.sock"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
mcp_config_dict = {
|
|
494
|
+
"mcpServers": {
|
|
495
|
+
"agent-pm": {
|
|
496
|
+
"command": "nc",
|
|
497
|
+
"args": ["-U", socket_path]
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
mcp_config = json.dumps(mcp_config_dict)
|
|
503
|
+
|
|
504
|
+
# Debug: Log the MCP config
|
|
505
|
+
logger.debug(f"MCP config: {mcp_config}")
|
|
506
|
+
logger.info(f"Agent Instance will connect via Unix Socket: {socket_path}")
|
|
507
|
+
|
|
508
|
+
# Handle provider-specific MCP configuration
|
|
509
|
+
# Gemini CLI uses file-based config (.gemini/settings.json)
|
|
510
|
+
# Claude CLI uses inline JSON via --mcp-config flag
|
|
511
|
+
if provider == "gemini":
|
|
512
|
+
self._prepare_gemini_mcp_config(working_dir, socket_path)
|
|
513
|
+
logger.debug("Prepared Gemini MCP config file")
|
|
514
|
+
|
|
515
|
+
# Build command
|
|
516
|
+
cmd = [
|
|
517
|
+
cli_command,
|
|
518
|
+
*cli_args,
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
# Add MCP config (only for non-Gemini providers)
|
|
522
|
+
# Gemini reads from .gemini/settings.json automatically
|
|
523
|
+
if provider != "gemini":
|
|
524
|
+
cmd.extend(["--mcp-config", mcp_config])
|
|
525
|
+
|
|
526
|
+
# Add model flag if specified
|
|
527
|
+
# Note: Gemini uses -m, Claude uses --model
|
|
528
|
+
if model:
|
|
529
|
+
model_flag = "-m" if provider == "gemini" else "--model"
|
|
530
|
+
cmd.extend([model_flag, model])
|
|
531
|
+
logger.debug(f"Using model: {model} (flag: {model_flag})")
|
|
532
|
+
|
|
533
|
+
# Add verbose flag for debugging if enabled
|
|
534
|
+
if self.config.debug_mode:
|
|
535
|
+
if provider == "gemini":
|
|
536
|
+
cmd.append("--debug")
|
|
537
|
+
else:
|
|
538
|
+
cmd.append("--verbose")
|
|
539
|
+
|
|
540
|
+
# Add prompt
|
|
541
|
+
# Gemini: uses positional argument for one-shot mode (-p is deprecated)
|
|
542
|
+
# Claude: uses -p flag
|
|
543
|
+
if provider == "gemini":
|
|
544
|
+
cmd.append(prompt) # Positional argument at the end for one-shot mode
|
|
545
|
+
else:
|
|
546
|
+
cmd.extend(["-p", prompt])
|
|
547
|
+
|
|
548
|
+
model_desc = f"{provider}/{model}" if model else provider
|
|
549
|
+
logger.info(
|
|
550
|
+
f"Spawning {model_desc} instance for {agent_id}/{project_id} "
|
|
551
|
+
f"at {working_dir}"
|
|
552
|
+
)
|
|
553
|
+
logger.debug(f"Command: {' '.join(cmd[:5])}...")
|
|
554
|
+
|
|
555
|
+
# Ensure working directory exists
|
|
556
|
+
Path(working_dir).mkdir(parents=True, exist_ok=True)
|
|
557
|
+
|
|
558
|
+
# Open log file (keep handle open during process lifetime)
|
|
559
|
+
log_f = open(log_file, "w")
|
|
560
|
+
|
|
561
|
+
# Spawn process
|
|
562
|
+
process = subprocess.Popen(
|
|
563
|
+
cmd,
|
|
564
|
+
cwd=working_dir,
|
|
565
|
+
stdout=log_f,
|
|
566
|
+
stderr=subprocess.STDOUT,
|
|
567
|
+
env={
|
|
568
|
+
**os.environ,
|
|
569
|
+
"AGENT_ID": agent_id,
|
|
570
|
+
"PROJECT_ID": project_id,
|
|
571
|
+
"AGENT_PASSKEY": passkey,
|
|
572
|
+
"WORKING_DIRECTORY": working_dir,
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
key = AgentInstanceKey(agent_id, project_id)
|
|
577
|
+
self._instances[key] = AgentInstanceInfo(
|
|
578
|
+
key=key,
|
|
579
|
+
process=process,
|
|
580
|
+
working_directory=working_dir,
|
|
581
|
+
provider=provider,
|
|
582
|
+
model=model,
|
|
583
|
+
started_at=datetime.now(),
|
|
584
|
+
log_file_handle=log_f,
|
|
585
|
+
task_id=task_id,
|
|
586
|
+
log_file_path=str(log_file)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
logger.info(f"Spawned instance {agent_id}/{project_id} (PID: {process.pid})")
|
|
590
|
+
|
|
591
|
+
def _prepare_gemini_mcp_config(self, working_dir: str, socket_path: str) -> None:
|
|
592
|
+
"""Prepare MCP config file for Gemini CLI.
|
|
593
|
+
|
|
594
|
+
Gemini CLI reads MCP configuration from .gemini/settings.json in the
|
|
595
|
+
working directory, unlike Claude CLI which accepts --mcp-config flag.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
working_dir: Working directory where .gemini/settings.json will be created
|
|
599
|
+
socket_path: Unix socket path for MCP connection
|
|
600
|
+
"""
|
|
601
|
+
gemini_dir = Path(working_dir) / ".gemini"
|
|
602
|
+
gemini_dir.mkdir(parents=True, exist_ok=True)
|
|
603
|
+
|
|
604
|
+
config = {
|
|
605
|
+
"mcpServers": {
|
|
606
|
+
"agent-pm": {
|
|
607
|
+
"command": "nc",
|
|
608
|
+
"args": ["-U", socket_path],
|
|
609
|
+
"trust": True # Auto-approve tool calls
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
config_file = gemini_dir / "settings.json"
|
|
615
|
+
with open(config_file, "w") as f:
|
|
616
|
+
json.dump(config, f, indent=2)
|
|
617
|
+
|
|
618
|
+
logger.debug(f"Created Gemini MCP config at {config_file}")
|
|
619
|
+
|
|
620
|
+
def _build_agent_prompt(self, agent_id: str, project_id: str, passkey: str) -> str:
|
|
621
|
+
"""Build the prompt for an Agent Instance.
|
|
622
|
+
|
|
623
|
+
The Agent Instance will use this prompt to know how to authenticate
|
|
624
|
+
and what to do. Uses state-driven workflow control via get_next_action.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
agent_id: Agent ID
|
|
628
|
+
project_id: Project ID
|
|
629
|
+
passkey: Agent passkey
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Prompt string for the Agent Instance
|
|
633
|
+
"""
|
|
634
|
+
return f"""You are an AI Agent Instance managed by the AI Agent PM system.
|
|
635
|
+
|
|
636
|
+
## Authentication
|
|
637
|
+
Call `authenticate` with:
|
|
638
|
+
- agent_id: "{agent_id}"
|
|
639
|
+
- passkey: "{passkey}"
|
|
640
|
+
- project_id: "{project_id}"
|
|
641
|
+
|
|
642
|
+
Save the session_token from the response.
|
|
643
|
+
|
|
644
|
+
## Workflow (CRITICAL: Follow Exactly)
|
|
645
|
+
After authenticating, you MUST follow this loop WITHOUT exception:
|
|
646
|
+
|
|
647
|
+
1. Call `get_next_action` with your session_token
|
|
648
|
+
2. Read the `action` and `instruction` fields
|
|
649
|
+
3. Execute ONLY what the `instruction` tells you to do
|
|
650
|
+
4. Call `get_next_action` again (ALWAYS return to step 1)
|
|
651
|
+
|
|
652
|
+
NEVER skip step 4. ALWAYS call `get_next_action` after completing each instruction.
|
|
653
|
+
|
|
654
|
+
## Task Decomposition (Required)
|
|
655
|
+
Before executing any actual work, you MUST decompose the task into sub-tasks:
|
|
656
|
+
- When `get_next_action` returns action="create_subtasks", use `create_task` tool
|
|
657
|
+
- Create 2-5 concrete sub-tasks with `parent_task_id` set to the main task ID
|
|
658
|
+
- Only after sub-tasks are created will `get_next_action` guide you to execute them
|
|
659
|
+
|
|
660
|
+
## Important Rules
|
|
661
|
+
- ONLY follow instructions from `get_next_action` - do NOT execute task.description directly
|
|
662
|
+
- Task description is for context/understanding only, not for direct execution
|
|
663
|
+
- The system controls the workflow; you execute the steps
|
|
664
|
+
- If you receive a system_prompt from authenticate, adopt that role
|
|
665
|
+
- You are working in the project directory
|
|
666
|
+
|
|
667
|
+
Begin by calling `authenticate`.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def run_coordinator_async(config: CoordinatorConfig) -> None:
|
|
672
|
+
"""Run the Coordinator asynchronously.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
config: Coordinator configuration
|
|
676
|
+
"""
|
|
677
|
+
coordinator = Coordinator(config)
|
|
678
|
+
await coordinator.start()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def run_coordinator(config: CoordinatorConfig) -> None:
|
|
682
|
+
"""Run the Coordinator synchronously.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
config: Coordinator configuration
|
|
686
|
+
"""
|
|
687
|
+
asyncio.run(run_coordinator_async(config))
|