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,698 @@
|
|
|
1
|
+
# src/aiagent_runner/mcp_client.py
|
|
2
|
+
# MCP client for communication with AI Agent PM server
|
|
3
|
+
# Reference: docs/plan/PHASE3_PULL_ARCHITECTURE.md - Phase 3-5
|
|
4
|
+
# Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
|
|
5
|
+
# Reference: docs/design/MULTI_DEVICE_IMPLEMENTATION_PLAN.md - Phase 4.3
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
# HTTP transport support (optional dependency)
|
|
16
|
+
try:
|
|
17
|
+
import aiohttp
|
|
18
|
+
HAS_AIOHTTP = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_AIOHTTP = False
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthenticationError(Exception):
|
|
26
|
+
"""Raised when authentication fails."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionExpiredError(Exception):
|
|
31
|
+
"""Raised when session token has expired."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MCPError(Exception):
|
|
36
|
+
"""General MCP communication error."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Phase 4: Coordinator API data classes
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class HealthCheckResult:
|
|
44
|
+
"""Result of health check."""
|
|
45
|
+
status: str
|
|
46
|
+
version: Optional[str] = None
|
|
47
|
+
timestamp: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ProjectWithAgents:
|
|
52
|
+
"""Project with its assigned agents."""
|
|
53
|
+
project_id: str
|
|
54
|
+
project_name: str
|
|
55
|
+
working_directory: str
|
|
56
|
+
agents: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class AgentActionResult:
|
|
61
|
+
"""Result of get_agent_action check."""
|
|
62
|
+
action: str # "start", "hold", "stop", "restart"
|
|
63
|
+
reason: Optional[str] = None # Reason for the action
|
|
64
|
+
provider: Optional[str] = None # "claude", "gemini", "openai", "other"
|
|
65
|
+
model: Optional[str] = None # "claude-sonnet-4-5", "gemini-2.0-flash", etc.
|
|
66
|
+
kick_command: Optional[str] = None # Custom CLI command (takes priority if set)
|
|
67
|
+
task_id: Optional[str] = None # Phase 4: タスクID(ログファイル登録用)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Phase 3/4: Agent API data classes
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class AuthResult:
|
|
74
|
+
"""Result of authentication."""
|
|
75
|
+
session_token: str
|
|
76
|
+
expires_in: int
|
|
77
|
+
agent_name: Optional[str] = None
|
|
78
|
+
system_prompt: Optional[str] = None
|
|
79
|
+
instruction: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class TaskInfo:
|
|
84
|
+
"""Information about a pending task."""
|
|
85
|
+
task_id: str
|
|
86
|
+
project_id: str
|
|
87
|
+
title: str
|
|
88
|
+
description: str
|
|
89
|
+
priority: str
|
|
90
|
+
working_directory: Optional[str] = None
|
|
91
|
+
context: Optional[dict] = None
|
|
92
|
+
handoff: Optional[dict] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ExecutionStartResult:
|
|
97
|
+
"""Result of reporting execution start."""
|
|
98
|
+
execution_id: str
|
|
99
|
+
started_at: datetime
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MCPClient:
|
|
103
|
+
"""Client for MCP server communication.
|
|
104
|
+
|
|
105
|
+
Handles authentication, task retrieval, and execution reporting.
|
|
106
|
+
|
|
107
|
+
Supports two transport modes:
|
|
108
|
+
- Unix socket: For local connections (default)
|
|
109
|
+
- HTTP: For remote connections (multi-device operation)
|
|
110
|
+
|
|
111
|
+
The transport is automatically selected based on the URL:
|
|
112
|
+
- http:// or https:// → HTTP transport
|
|
113
|
+
- Everything else → Unix socket transport
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
socket_path: Optional[str] = None,
|
|
119
|
+
coordinator_token: Optional[str] = None
|
|
120
|
+
):
|
|
121
|
+
"""Initialize MCP client.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
socket_path: Path to MCP Unix socket, or HTTP URL for remote connections.
|
|
125
|
+
Unix socket: ~/Library/Application Support/AIAgentPM/mcp.sock
|
|
126
|
+
HTTP URL: http://hostname:port/mcp
|
|
127
|
+
Defaults to standard Unix socket location.
|
|
128
|
+
coordinator_token: Token for Coordinator-only API calls (Phase 5).
|
|
129
|
+
If not provided, reads from MCP_COORDINATOR_TOKEN env var.
|
|
130
|
+
"""
|
|
131
|
+
# Determine transport type based on URL scheme
|
|
132
|
+
if socket_path and socket_path.startswith(("http://", "https://")):
|
|
133
|
+
self._url = socket_path
|
|
134
|
+
self._use_http = True
|
|
135
|
+
logger.info(f"Using HTTP transport: {self._url}")
|
|
136
|
+
else:
|
|
137
|
+
# Unix socket path - expand tilde
|
|
138
|
+
if socket_path:
|
|
139
|
+
self._url = os.path.expanduser(socket_path)
|
|
140
|
+
else:
|
|
141
|
+
self._url = self._default_socket_path()
|
|
142
|
+
self._use_http = False
|
|
143
|
+
logger.info(f"Using Unix socket transport: {self._url}")
|
|
144
|
+
|
|
145
|
+
# Backward compatibility
|
|
146
|
+
self.socket_path = self._url if not self._use_http else None
|
|
147
|
+
|
|
148
|
+
self._session_token: Optional[str] = None
|
|
149
|
+
# Phase 5: Coordinator token for Coordinator-only API calls
|
|
150
|
+
self._coordinator_token = coordinator_token or os.environ.get("MCP_COORDINATOR_TOKEN")
|
|
151
|
+
|
|
152
|
+
def _default_socket_path(self) -> str:
|
|
153
|
+
"""Get default MCP socket path."""
|
|
154
|
+
return os.path.expanduser(
|
|
155
|
+
"~/Library/Application Support/AIAgentPM/mcp.sock"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _call_tool(self, tool_name: str, args: dict) -> dict:
|
|
159
|
+
"""Call an MCP tool via Unix socket or HTTP.
|
|
160
|
+
|
|
161
|
+
Automatically selects the transport based on the URL scheme.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
tool_name: Name of the tool to call
|
|
165
|
+
args: Arguments for the tool
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Tool result as dictionary
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
MCPError: If communication fails
|
|
172
|
+
"""
|
|
173
|
+
if self._use_http:
|
|
174
|
+
return await self._call_tool_http(tool_name, args)
|
|
175
|
+
else:
|
|
176
|
+
return await self._call_tool_unix(tool_name, args)
|
|
177
|
+
|
|
178
|
+
async def _call_tool_unix(self, tool_name: str, args: dict) -> dict:
|
|
179
|
+
"""Call an MCP tool via Unix socket.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
tool_name: Name of the tool to call
|
|
183
|
+
args: Arguments for the tool
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Tool result as dictionary
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
MCPError: If communication fails
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
reader, writer = await asyncio.open_unix_connection(self._url)
|
|
193
|
+
except (ConnectionRefusedError, FileNotFoundError) as e:
|
|
194
|
+
raise MCPError(f"Cannot connect to MCP server at {self._url}: {e}")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
request = json.dumps({
|
|
198
|
+
"jsonrpc": "2.0",
|
|
199
|
+
"method": "tools/call",
|
|
200
|
+
"params": {"name": tool_name, "arguments": args},
|
|
201
|
+
"id": 1
|
|
202
|
+
})
|
|
203
|
+
writer.write(request.encode() + b"\n")
|
|
204
|
+
await writer.drain()
|
|
205
|
+
|
|
206
|
+
response = await reader.readline()
|
|
207
|
+
data = json.loads(response)
|
|
208
|
+
|
|
209
|
+
return self._parse_response(data)
|
|
210
|
+
finally:
|
|
211
|
+
writer.close()
|
|
212
|
+
await writer.wait_closed()
|
|
213
|
+
|
|
214
|
+
async def _call_tool_http(self, tool_name: str, args: dict) -> dict:
|
|
215
|
+
"""Call an MCP tool via HTTP.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
tool_name: Name of the tool to call
|
|
219
|
+
args: Arguments for the tool
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Tool result as dictionary
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
MCPError: If communication fails or aiohttp is not installed
|
|
226
|
+
"""
|
|
227
|
+
if not HAS_AIOHTTP:
|
|
228
|
+
raise MCPError(
|
|
229
|
+
"HTTP transport requires aiohttp. Install with: pip install aiohttp"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
request_body = {
|
|
233
|
+
"jsonrpc": "2.0",
|
|
234
|
+
"method": "tools/call",
|
|
235
|
+
"params": {"name": tool_name, "arguments": args},
|
|
236
|
+
"id": 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
headers = {"Content-Type": "application/json"}
|
|
240
|
+
|
|
241
|
+
# Add coordinator token as Authorization header if available
|
|
242
|
+
if self._coordinator_token:
|
|
243
|
+
headers["Authorization"] = f"Bearer {self._coordinator_token}"
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
async with aiohttp.ClientSession() as session:
|
|
247
|
+
async with session.post(
|
|
248
|
+
self._url,
|
|
249
|
+
json=request_body,
|
|
250
|
+
headers=headers,
|
|
251
|
+
timeout=aiohttp.ClientTimeout(total=30)
|
|
252
|
+
) as response:
|
|
253
|
+
if response.status != 200:
|
|
254
|
+
text = await response.text()
|
|
255
|
+
raise MCPError(f"HTTP {response.status}: {text}")
|
|
256
|
+
|
|
257
|
+
data = await response.json()
|
|
258
|
+
return self._parse_response(data)
|
|
259
|
+
|
|
260
|
+
except aiohttp.ClientError as e:
|
|
261
|
+
raise MCPError(f"Cannot connect to MCP server at {self._url}: {e}")
|
|
262
|
+
|
|
263
|
+
def _parse_response(self, data: dict) -> dict:
|
|
264
|
+
"""Parse MCP JSON-RPC response.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
data: Raw JSON-RPC response
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Parsed tool result
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
MCPError: If response contains an error
|
|
274
|
+
"""
|
|
275
|
+
if "error" in data:
|
|
276
|
+
raise MCPError(data["error"].get("message", "Unknown error"))
|
|
277
|
+
|
|
278
|
+
# Parse MCP protocol response format
|
|
279
|
+
# MCP returns: {"result": {"content": [{"type": "text", "text": "JSON"}]}}
|
|
280
|
+
result = data.get("result", {})
|
|
281
|
+
content = result.get("content", [])
|
|
282
|
+
if content and isinstance(content, list) and len(content) > 0:
|
|
283
|
+
first_content = content[0]
|
|
284
|
+
if isinstance(first_content, dict) and first_content.get("type") == "text":
|
|
285
|
+
text = first_content.get("text", "{}")
|
|
286
|
+
try:
|
|
287
|
+
return json.loads(text)
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
return {"text": text}
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
# ==========================================================================
|
|
293
|
+
# Phase 4: Coordinator API
|
|
294
|
+
# Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
|
|
295
|
+
# ==========================================================================
|
|
296
|
+
|
|
297
|
+
async def health_check(self) -> HealthCheckResult:
|
|
298
|
+
"""Check MCP server health.
|
|
299
|
+
|
|
300
|
+
The Coordinator calls this first to verify the server is available.
|
|
301
|
+
Phase 5: Requires coordinator_token for authorization.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
HealthCheckResult with server status
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
MCPError: If server is not available or unauthorized
|
|
308
|
+
"""
|
|
309
|
+
args = {}
|
|
310
|
+
if self._coordinator_token:
|
|
311
|
+
args["coordinator_token"] = self._coordinator_token
|
|
312
|
+
result = await self._call_tool("health_check", args)
|
|
313
|
+
return HealthCheckResult(
|
|
314
|
+
status=result.get("status", "ok"),
|
|
315
|
+
version=result.get("version"),
|
|
316
|
+
timestamp=result.get("timestamp")
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def list_active_projects_with_agents(
|
|
320
|
+
self, root_agent_id: Optional[str] = None
|
|
321
|
+
) -> list[ProjectWithAgents]:
|
|
322
|
+
"""Get all active projects with their assigned agents.
|
|
323
|
+
|
|
324
|
+
The Coordinator calls this to discover what (agent_id, project_id)
|
|
325
|
+
combinations exist and need to be monitored.
|
|
326
|
+
Phase 5: Requires coordinator_token for authorization.
|
|
327
|
+
|
|
328
|
+
Multi-device operation:
|
|
329
|
+
When root_agent_id is specified, the server uses that agent's
|
|
330
|
+
working directories instead of the project defaults.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
root_agent_id: Optional human agent ID for working directory resolution
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of ProjectWithAgents
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
MCPError: If request fails or unauthorized
|
|
340
|
+
"""
|
|
341
|
+
args = {}
|
|
342
|
+
if self._coordinator_token:
|
|
343
|
+
args["coordinator_token"] = self._coordinator_token
|
|
344
|
+
logger.debug("list_active_projects_with_agents: passing coordinator_token")
|
|
345
|
+
else:
|
|
346
|
+
logger.warning("list_active_projects_with_agents: NO coordinator_token set!")
|
|
347
|
+
|
|
348
|
+
# Multi-device: Pass root_agent_id for working directory resolution
|
|
349
|
+
if root_agent_id:
|
|
350
|
+
args["root_agent_id"] = root_agent_id
|
|
351
|
+
logger.debug(f"list_active_projects_with_agents: passing root_agent_id={root_agent_id}")
|
|
352
|
+
|
|
353
|
+
result = await self._call_tool("list_active_projects_with_agents", args)
|
|
354
|
+
logger.debug(f"list_active_projects_with_agents result: {result}")
|
|
355
|
+
|
|
356
|
+
if not result.get("success", True):
|
|
357
|
+
raise MCPError(result.get("error", "Failed to list projects"))
|
|
358
|
+
|
|
359
|
+
projects = []
|
|
360
|
+
for p in result.get("projects", []):
|
|
361
|
+
projects.append(ProjectWithAgents(
|
|
362
|
+
project_id=p.get("project_id", p.get("projectId", "")),
|
|
363
|
+
project_name=p.get("project_name", p.get("projectName", p.get("name", ""))),
|
|
364
|
+
working_directory=p.get("working_directory", p.get("workingDirectory", "")),
|
|
365
|
+
agents=p.get("agents", [])
|
|
366
|
+
))
|
|
367
|
+
return projects
|
|
368
|
+
|
|
369
|
+
async def get_agent_action(self, agent_id: str, project_id: str) -> AgentActionResult:
|
|
370
|
+
"""Get the action an Agent Instance should take.
|
|
371
|
+
|
|
372
|
+
The Coordinator calls this for each (agent_id, project_id) pair
|
|
373
|
+
to determine what action to take.
|
|
374
|
+
Phase 5: Requires coordinator_token for authorization.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
agent_id: Agent ID
|
|
378
|
+
project_id: Project ID
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
AgentActionResult with action (start/hold/stop/restart), reason, provider, model, and kick_command
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
MCPError: If request fails or unauthorized
|
|
385
|
+
"""
|
|
386
|
+
args = {
|
|
387
|
+
"agent_id": agent_id,
|
|
388
|
+
"project_id": project_id
|
|
389
|
+
}
|
|
390
|
+
if self._coordinator_token:
|
|
391
|
+
args["coordinator_token"] = self._coordinator_token
|
|
392
|
+
result = await self._call_tool("get_agent_action", args)
|
|
393
|
+
|
|
394
|
+
return AgentActionResult(
|
|
395
|
+
action=result.get("action", "hold"),
|
|
396
|
+
reason=result.get("reason"),
|
|
397
|
+
provider=result.get("provider"),
|
|
398
|
+
model=result.get("model"),
|
|
399
|
+
kick_command=result.get("kick_command"),
|
|
400
|
+
task_id=result.get("task_id") # Phase 4: Coordinatorがログファイルパス登録に使用
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def register_execution_log_file(
|
|
404
|
+
self, agent_id: str, task_id: str, log_file_path: str
|
|
405
|
+
) -> bool:
|
|
406
|
+
"""Register log file path for an execution log.
|
|
407
|
+
|
|
408
|
+
Called by Coordinator after Agent Instance process completes.
|
|
409
|
+
Phase 5: Requires coordinator_token for authorization.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
agent_id: Agent ID
|
|
413
|
+
task_id: Task ID
|
|
414
|
+
log_file_path: Absolute path to the log file
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
True if successful, False otherwise
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
MCPError: If request fails or unauthorized
|
|
421
|
+
"""
|
|
422
|
+
args = {
|
|
423
|
+
"agent_id": agent_id,
|
|
424
|
+
"task_id": task_id,
|
|
425
|
+
"log_file_path": log_file_path
|
|
426
|
+
}
|
|
427
|
+
if self._coordinator_token:
|
|
428
|
+
args["coordinator_token"] = self._coordinator_token
|
|
429
|
+
result = await self._call_tool("register_execution_log_file", args)
|
|
430
|
+
|
|
431
|
+
return result.get("success", False)
|
|
432
|
+
|
|
433
|
+
async def invalidate_session(self, agent_id: str, project_id: str) -> bool:
|
|
434
|
+
"""Invalidate session for an agent-project pair.
|
|
435
|
+
|
|
436
|
+
Called by Coordinator when Agent Instance process exits.
|
|
437
|
+
This allows shouldStart to return True again for the next instance.
|
|
438
|
+
Phase 5: Requires coordinator_token for authorization.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
agent_id: Agent ID
|
|
442
|
+
project_id: Project ID
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
True if successful, False otherwise
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
MCPError: If request fails or unauthorized
|
|
449
|
+
"""
|
|
450
|
+
args = {
|
|
451
|
+
"agent_id": agent_id,
|
|
452
|
+
"project_id": project_id
|
|
453
|
+
}
|
|
454
|
+
if self._coordinator_token:
|
|
455
|
+
args["coordinator_token"] = self._coordinator_token
|
|
456
|
+
result = await self._call_tool("invalidate_session", args)
|
|
457
|
+
|
|
458
|
+
return result.get("success", False)
|
|
459
|
+
|
|
460
|
+
async def report_agent_error(
|
|
461
|
+
self, agent_id: str, project_id: str, error_message: str
|
|
462
|
+
) -> bool:
|
|
463
|
+
"""Report agent error to chat.
|
|
464
|
+
|
|
465
|
+
Called by Coordinator when Agent Instance process exits with error.
|
|
466
|
+
The error message will be displayed in the chat.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
agent_id: Agent ID
|
|
470
|
+
project_id: Project ID
|
|
471
|
+
error_message: Error message to display
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
True if successful, False otherwise
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
MCPError: If request fails or unauthorized
|
|
478
|
+
"""
|
|
479
|
+
args = {
|
|
480
|
+
"agent_id": agent_id,
|
|
481
|
+
"project_id": project_id,
|
|
482
|
+
"error_message": error_message
|
|
483
|
+
}
|
|
484
|
+
if self._coordinator_token:
|
|
485
|
+
args["coordinator_token"] = self._coordinator_token
|
|
486
|
+
result = await self._call_tool("report_agent_error", args)
|
|
487
|
+
|
|
488
|
+
return result.get("success", False)
|
|
489
|
+
|
|
490
|
+
# ==========================================================================
|
|
491
|
+
# Phase 3/4: Agent Instance API
|
|
492
|
+
# ==========================================================================
|
|
493
|
+
|
|
494
|
+
async def authenticate(self, agent_id: str, passkey: str, project_id: str) -> AuthResult:
|
|
495
|
+
"""Authenticate with the MCP server.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
agent_id: Agent ID
|
|
499
|
+
passkey: Agent passkey
|
|
500
|
+
project_id: Project ID (Phase 4: required for session management)
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
AuthResult with session token
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
AuthenticationError: If authentication fails
|
|
507
|
+
"""
|
|
508
|
+
result = await self._call_tool("authenticate", {
|
|
509
|
+
"agent_id": agent_id,
|
|
510
|
+
"passkey": passkey,
|
|
511
|
+
"project_id": project_id
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
if not result.get("success"):
|
|
515
|
+
raise AuthenticationError(result.get("error", "Authentication failed"))
|
|
516
|
+
|
|
517
|
+
self._session_token = result["session_token"]
|
|
518
|
+
return AuthResult(
|
|
519
|
+
session_token=result["session_token"],
|
|
520
|
+
expires_in=result.get("expires_in", 3600),
|
|
521
|
+
agent_name=result.get("agent_name"),
|
|
522
|
+
system_prompt=result.get("system_prompt"),
|
|
523
|
+
instruction=result.get("instruction")
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def get_pending_tasks(self) -> list[TaskInfo]:
|
|
527
|
+
"""Get pending tasks for the authenticated agent.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
List of pending TaskInfo objects
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
SessionExpiredError: If session has expired
|
|
534
|
+
MCPError: If request fails or not authenticated
|
|
535
|
+
"""
|
|
536
|
+
if not self._session_token:
|
|
537
|
+
raise MCPError("Not authenticated. Call authenticate() first.")
|
|
538
|
+
|
|
539
|
+
result = await self._call_tool("get_pending_tasks", {
|
|
540
|
+
"session_token": self._session_token
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
if not result.get("success"):
|
|
544
|
+
error = result.get("error", "")
|
|
545
|
+
if "expired" in error.lower() or "invalid" in error.lower():
|
|
546
|
+
raise SessionExpiredError(error)
|
|
547
|
+
raise MCPError(error)
|
|
548
|
+
|
|
549
|
+
tasks = []
|
|
550
|
+
for t in result.get("tasks", []):
|
|
551
|
+
tasks.append(TaskInfo(
|
|
552
|
+
task_id=t.get("task_id", t.get("taskId", t.get("id", ""))),
|
|
553
|
+
project_id=t.get("project_id", t.get("projectId", "")),
|
|
554
|
+
title=t.get("title", ""),
|
|
555
|
+
description=t.get("description", ""),
|
|
556
|
+
priority=t.get("priority", "medium"),
|
|
557
|
+
working_directory=t.get("working_directory", t.get("workingDirectory")),
|
|
558
|
+
context=t.get("context"),
|
|
559
|
+
handoff=t.get("handoff")
|
|
560
|
+
))
|
|
561
|
+
return tasks
|
|
562
|
+
|
|
563
|
+
async def report_execution_start(
|
|
564
|
+
self, task_id: str
|
|
565
|
+
) -> ExecutionStartResult:
|
|
566
|
+
"""Report that task execution has started.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
task_id: Task ID being executed
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
ExecutionStartResult with execution ID
|
|
573
|
+
|
|
574
|
+
Raises:
|
|
575
|
+
MCPError: If reporting fails or not authenticated
|
|
576
|
+
"""
|
|
577
|
+
if not self._session_token:
|
|
578
|
+
raise MCPError("Not authenticated. Call authenticate() first.")
|
|
579
|
+
|
|
580
|
+
result = await self._call_tool("report_execution_start", {
|
|
581
|
+
"session_token": self._session_token,
|
|
582
|
+
"task_id": task_id
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
if not result.get("success"):
|
|
586
|
+
raise MCPError(result.get("error", "Failed to report execution start"))
|
|
587
|
+
|
|
588
|
+
started_at_str = result.get("started_at", datetime.now().isoformat())
|
|
589
|
+
if started_at_str.endswith("Z"):
|
|
590
|
+
started_at_str = started_at_str[:-1] + "+00:00"
|
|
591
|
+
|
|
592
|
+
return ExecutionStartResult(
|
|
593
|
+
execution_id=result.get("execution_log_id", result.get("execution_id", "")),
|
|
594
|
+
started_at=datetime.fromisoformat(started_at_str)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
async def report_execution_complete(
|
|
598
|
+
self,
|
|
599
|
+
execution_id: str,
|
|
600
|
+
exit_code: int,
|
|
601
|
+
duration_seconds: float,
|
|
602
|
+
log_file_path: Optional[str] = None,
|
|
603
|
+
error_message: Optional[str] = None
|
|
604
|
+
) -> None:
|
|
605
|
+
"""Report that task execution has completed.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
execution_id: Execution log ID from report_execution_start
|
|
609
|
+
exit_code: Exit code of the CLI process
|
|
610
|
+
duration_seconds: Duration of execution in seconds
|
|
611
|
+
log_file_path: Path to log file (optional)
|
|
612
|
+
error_message: Error message if execution failed (optional)
|
|
613
|
+
|
|
614
|
+
Raises:
|
|
615
|
+
MCPError: If reporting fails or not authenticated
|
|
616
|
+
"""
|
|
617
|
+
if not self._session_token:
|
|
618
|
+
raise MCPError("Not authenticated. Call authenticate() first.")
|
|
619
|
+
|
|
620
|
+
args = {
|
|
621
|
+
"session_token": self._session_token,
|
|
622
|
+
"execution_log_id": execution_id,
|
|
623
|
+
"exit_code": exit_code,
|
|
624
|
+
"duration_seconds": duration_seconds
|
|
625
|
+
}
|
|
626
|
+
if log_file_path:
|
|
627
|
+
args["log_file_path"] = log_file_path
|
|
628
|
+
if error_message:
|
|
629
|
+
args["error_message"] = error_message
|
|
630
|
+
|
|
631
|
+
result = await self._call_tool("report_execution_complete", args)
|
|
632
|
+
|
|
633
|
+
if not result.get("success"):
|
|
634
|
+
raise MCPError(result.get("error", "Failed to report execution complete"))
|
|
635
|
+
|
|
636
|
+
async def update_task_status(
|
|
637
|
+
self, task_id: str, status: str, reason: Optional[str] = None
|
|
638
|
+
) -> None:
|
|
639
|
+
"""Update task status.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
task_id: Task ID to update
|
|
643
|
+
status: New status (todo, in_progress, done, etc.)
|
|
644
|
+
reason: Reason for status change (optional)
|
|
645
|
+
|
|
646
|
+
Raises:
|
|
647
|
+
MCPError: If update fails
|
|
648
|
+
"""
|
|
649
|
+
args = {
|
|
650
|
+
"task_id": task_id,
|
|
651
|
+
"status": status
|
|
652
|
+
}
|
|
653
|
+
if reason:
|
|
654
|
+
args["reason"] = reason
|
|
655
|
+
|
|
656
|
+
result = await self._call_tool("update_task_status", args)
|
|
657
|
+
|
|
658
|
+
if not result.get("success"):
|
|
659
|
+
raise MCPError(result.get("error", "Failed to update task status"))
|
|
660
|
+
|
|
661
|
+
async def save_context(
|
|
662
|
+
self,
|
|
663
|
+
task_id: str,
|
|
664
|
+
progress: Optional[str] = None,
|
|
665
|
+
findings: Optional[str] = None,
|
|
666
|
+
blockers: Optional[str] = None,
|
|
667
|
+
next_steps: Optional[str] = None,
|
|
668
|
+
agent_id: Optional[str] = None
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Save task context.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
task_id: Task ID
|
|
674
|
+
progress: Current progress description
|
|
675
|
+
findings: Findings or discoveries
|
|
676
|
+
blockers: Current blockers
|
|
677
|
+
next_steps: Recommended next steps
|
|
678
|
+
agent_id: Agent ID (optional)
|
|
679
|
+
|
|
680
|
+
Raises:
|
|
681
|
+
MCPError: If save fails
|
|
682
|
+
"""
|
|
683
|
+
args = {"task_id": task_id}
|
|
684
|
+
if progress:
|
|
685
|
+
args["progress"] = progress
|
|
686
|
+
if findings:
|
|
687
|
+
args["findings"] = findings
|
|
688
|
+
if blockers:
|
|
689
|
+
args["blockers"] = blockers
|
|
690
|
+
if next_steps:
|
|
691
|
+
args["next_steps"] = next_steps
|
|
692
|
+
if agent_id:
|
|
693
|
+
args["agent_id"] = agent_id
|
|
694
|
+
|
|
695
|
+
result = await self._call_tool("save_context", args)
|
|
696
|
+
|
|
697
|
+
if not result.get("success"):
|
|
698
|
+
raise MCPError(result.get("error", "Failed to save context"))
|