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,203 @@
|
|
|
1
|
+
# src/aiagent_runner/coordinator_config.py
|
|
2
|
+
# Coordinator configuration management
|
|
3
|
+
# Reference: docs/plan/PHASE4_COORDINATOR_ARCHITECTURE.md
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AIProviderConfig:
|
|
15
|
+
"""AI provider configuration."""
|
|
16
|
+
cli_command: str
|
|
17
|
+
cli_args: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AgentConfig:
|
|
22
|
+
"""Agent configuration (passkey only, other info from MCP)."""
|
|
23
|
+
passkey: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CoordinatorConfig:
|
|
28
|
+
"""Coordinator configuration.
|
|
29
|
+
|
|
30
|
+
The Coordinator is a single orchestrator that:
|
|
31
|
+
1. Polls MCP server for active projects and their assigned agents
|
|
32
|
+
2. Calls get_agent_action(agent_id, project_id) for each pair
|
|
33
|
+
3. Spawns Agent Instances (Claude Code processes) as needed
|
|
34
|
+
|
|
35
|
+
Unlike the old Runner which was tied to a single (agent_id, project_id),
|
|
36
|
+
the Coordinator manages ALL agent-project combinations dynamically.
|
|
37
|
+
|
|
38
|
+
Multi-device operation:
|
|
39
|
+
- When root_agent_id is set, the Coordinator authenticates as that human agent
|
|
40
|
+
- This enables per-agent working directory resolution for remote operation
|
|
41
|
+
- See: docs/design/MULTI_DEVICE_IMPLEMENTATION_PLAN.md
|
|
42
|
+
"""
|
|
43
|
+
# Polling settings
|
|
44
|
+
polling_interval: int = 10
|
|
45
|
+
max_concurrent: int = 3
|
|
46
|
+
|
|
47
|
+
# MCP connection (Unix socket or HTTP URL - used by both Coordinator and Agent Instances)
|
|
48
|
+
# Unix socket: ~/Library/Application Support/AIAgentPM/mcp.sock
|
|
49
|
+
# HTTP URL: http://hostname:port/mcp
|
|
50
|
+
mcp_socket_path: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
# Phase 5: Coordinator token for Coordinator-only API authorization
|
|
53
|
+
# Reference: Sources/MCPServer/Authorization/ToolAuthorization.swift
|
|
54
|
+
coordinator_token: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
# Multi-device: Root agent ID for authentication
|
|
57
|
+
# When set, Coordinator authenticates as this human agent to get proper working directories
|
|
58
|
+
root_agent_id: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
# AI providers (how to launch each AI type)
|
|
61
|
+
ai_providers: dict[str, AIProviderConfig] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
# Agents (passkey only - ai_type, system_prompt come from MCP)
|
|
64
|
+
agents: dict[str, AgentConfig] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
# Logging
|
|
67
|
+
log_directory: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
# Debug mode (adds --verbose to CLI commands)
|
|
70
|
+
debug_mode: bool = True
|
|
71
|
+
|
|
72
|
+
def __post_init__(self):
|
|
73
|
+
"""Validate configuration after initialization."""
|
|
74
|
+
if self.polling_interval <= 0:
|
|
75
|
+
raise ValueError("polling_interval must be positive")
|
|
76
|
+
if self.max_concurrent <= 0:
|
|
77
|
+
raise ValueError("max_concurrent must be positive")
|
|
78
|
+
|
|
79
|
+
# Set default MCP socket path if not specified
|
|
80
|
+
if self.mcp_socket_path is None:
|
|
81
|
+
self.mcp_socket_path = os.path.expanduser(
|
|
82
|
+
"~/Library/Application Support/AIAgentPM/mcp.sock"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Phase 5: Set coordinator token from environment if not specified
|
|
86
|
+
if self.coordinator_token is None:
|
|
87
|
+
self.coordinator_token = os.environ.get("MCP_COORDINATOR_TOKEN")
|
|
88
|
+
|
|
89
|
+
# Ensure default Claude provider exists
|
|
90
|
+
if "claude" not in self.ai_providers:
|
|
91
|
+
self.ai_providers["claude"] = AIProviderConfig(
|
|
92
|
+
cli_command="claude",
|
|
93
|
+
cli_args=["--dangerously-skip-permissions"]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_yaml(cls, path: Path) -> "CoordinatorConfig":
|
|
98
|
+
"""Load configuration from YAML file.
|
|
99
|
+
|
|
100
|
+
Example YAML:
|
|
101
|
+
```yaml
|
|
102
|
+
polling_interval: 10
|
|
103
|
+
max_concurrent: 3
|
|
104
|
+
|
|
105
|
+
# MCP connection: Unix socket (local) or HTTP URL (remote)
|
|
106
|
+
# Local: ~/Library/Application Support/AIAgentPM/mcp.sock
|
|
107
|
+
# Remote: http://192.168.1.100:8080/mcp
|
|
108
|
+
mcp_socket_path: ~/Library/Application Support/AIAgentPM/mcp.sock
|
|
109
|
+
|
|
110
|
+
# Phase 5: Coordinator token for Coordinator-only API calls
|
|
111
|
+
# Can also be set via MCP_COORDINATOR_TOKEN environment variable
|
|
112
|
+
coordinator_token: ${MCP_COORDINATOR_TOKEN}
|
|
113
|
+
|
|
114
|
+
# Multi-device operation: Root agent for authentication
|
|
115
|
+
# When set, Coordinator authenticates as this human agent
|
|
116
|
+
# This enables per-agent working directory resolution
|
|
117
|
+
root_agent_id: human-frontend-lead
|
|
118
|
+
|
|
119
|
+
ai_providers:
|
|
120
|
+
claude:
|
|
121
|
+
cli_command: claude
|
|
122
|
+
cli_args: ["--dangerously-skip-permissions"]
|
|
123
|
+
gemini:
|
|
124
|
+
cli_command: gemini-cli
|
|
125
|
+
cli_args: ["--project", "my-project"]
|
|
126
|
+
|
|
127
|
+
agents:
|
|
128
|
+
agt_developer:
|
|
129
|
+
passkey: secret123
|
|
130
|
+
agt_reviewer:
|
|
131
|
+
passkey: secret456
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path: Path to YAML configuration file
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
CoordinatorConfig instance
|
|
139
|
+
"""
|
|
140
|
+
with open(path) as f:
|
|
141
|
+
data = yaml.safe_load(f)
|
|
142
|
+
|
|
143
|
+
# Parse AI providers
|
|
144
|
+
ai_providers = {}
|
|
145
|
+
for name, provider_data in data.get("ai_providers", {}).items():
|
|
146
|
+
cli_args = provider_data.get("cli_args", [])
|
|
147
|
+
if isinstance(cli_args, str):
|
|
148
|
+
cli_args = cli_args.split()
|
|
149
|
+
ai_providers[name] = AIProviderConfig(
|
|
150
|
+
cli_command=provider_data.get("cli_command", name),
|
|
151
|
+
cli_args=cli_args
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Parse agents
|
|
155
|
+
agents = {}
|
|
156
|
+
for agent_id, agent_data in data.get("agents", {}).items():
|
|
157
|
+
passkey = agent_data.get("passkey", "")
|
|
158
|
+
# Support environment variable expansion
|
|
159
|
+
if passkey.startswith("${") and passkey.endswith("}"):
|
|
160
|
+
env_var = passkey[2:-1]
|
|
161
|
+
passkey = os.environ.get(env_var, "")
|
|
162
|
+
agents[agent_id] = AgentConfig(passkey=passkey)
|
|
163
|
+
|
|
164
|
+
# Parse coordinator_token (supports environment variable expansion)
|
|
165
|
+
coordinator_token = data.get("coordinator_token")
|
|
166
|
+
if coordinator_token and coordinator_token.startswith("${") and coordinator_token.endswith("}"):
|
|
167
|
+
env_var = coordinator_token[2:-1]
|
|
168
|
+
coordinator_token = os.environ.get(env_var)
|
|
169
|
+
|
|
170
|
+
return cls(
|
|
171
|
+
polling_interval=data.get("polling_interval", 10),
|
|
172
|
+
max_concurrent=data.get("max_concurrent", 3),
|
|
173
|
+
mcp_socket_path=data.get("mcp_socket_path"),
|
|
174
|
+
coordinator_token=coordinator_token,
|
|
175
|
+
root_agent_id=data.get("root_agent_id"),
|
|
176
|
+
ai_providers=ai_providers,
|
|
177
|
+
agents=agents,
|
|
178
|
+
log_directory=data.get("log_directory"),
|
|
179
|
+
debug_mode=data.get("debug_mode", True),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def get_provider(self, ai_type: str) -> AIProviderConfig:
|
|
183
|
+
"""Get AI provider configuration.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
ai_type: AI type (e.g., "claude", "gemini")
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
AIProviderConfig for the specified type, or default Claude config
|
|
190
|
+
"""
|
|
191
|
+
return self.ai_providers.get(ai_type, self.ai_providers.get("claude"))
|
|
192
|
+
|
|
193
|
+
def get_agent_passkey(self, agent_id: str) -> Optional[str]:
|
|
194
|
+
"""Get passkey for an agent.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
agent_id: Agent ID
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Passkey if configured, None otherwise
|
|
201
|
+
"""
|
|
202
|
+
agent = self.agents.get(agent_id)
|
|
203
|
+
return agent.passkey if agent else None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# src/aiagent_runner/executor.py
|
|
2
|
+
# CLI executor for running AI assistants
|
|
3
|
+
# Reference: docs/plan/PHASE3_PULL_ARCHITECTURE.md - Phase 3-5
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ExecutionResult:
|
|
14
|
+
"""Result of CLI execution."""
|
|
15
|
+
exit_code: int
|
|
16
|
+
duration_seconds: float
|
|
17
|
+
log_file: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CLIExecutor:
|
|
21
|
+
"""Executes CLI tools (claude, gemini, etc.) with prompts."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
cli_command: str = "claude",
|
|
26
|
+
cli_args: Optional[list[str]] = None
|
|
27
|
+
):
|
|
28
|
+
"""Initialize CLI executor.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cli_command: CLI command to run (claude, gemini, etc.)
|
|
32
|
+
cli_args: Additional arguments for the CLI
|
|
33
|
+
"""
|
|
34
|
+
self.cli_command = cli_command
|
|
35
|
+
self.cli_args = cli_args or ["--dangerously-skip-permissions"]
|
|
36
|
+
|
|
37
|
+
def execute(
|
|
38
|
+
self,
|
|
39
|
+
prompt: str,
|
|
40
|
+
working_directory: str,
|
|
41
|
+
log_file: str
|
|
42
|
+
) -> ExecutionResult:
|
|
43
|
+
"""Execute CLI with the given prompt.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
prompt: Prompt string to pass to CLI
|
|
47
|
+
working_directory: Directory to run CLI in
|
|
48
|
+
log_file: Path to log file for output
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
ExecutionResult with exit code, duration, and log path
|
|
52
|
+
"""
|
|
53
|
+
# Ensure log directory exists
|
|
54
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
# Build command
|
|
57
|
+
cmd = [self.cli_command] + self.cli_args + ["-p", prompt]
|
|
58
|
+
|
|
59
|
+
start_time = time.time()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with open(log_file, "w") as log:
|
|
63
|
+
# Write prompt to log for reference
|
|
64
|
+
log.write(f"=== PROMPT ===\n{prompt}\n\n=== OUTPUT ===\n")
|
|
65
|
+
log.flush()
|
|
66
|
+
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
cmd,
|
|
69
|
+
cwd=working_directory,
|
|
70
|
+
stdout=log,
|
|
71
|
+
stderr=subprocess.STDOUT,
|
|
72
|
+
text=True
|
|
73
|
+
)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
# CLI command not found
|
|
76
|
+
with open(log_file, "a") as log:
|
|
77
|
+
log.write(f"\nERROR: Command '{self.cli_command}' not found\n")
|
|
78
|
+
return ExecutionResult(
|
|
79
|
+
exit_code=127,
|
|
80
|
+
duration_seconds=time.time() - start_time,
|
|
81
|
+
log_file=log_file
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
# Other execution error
|
|
85
|
+
with open(log_file, "a") as log:
|
|
86
|
+
log.write(f"\nERROR: {e}\n")
|
|
87
|
+
return ExecutionResult(
|
|
88
|
+
exit_code=1,
|
|
89
|
+
duration_seconds=time.time() - start_time,
|
|
90
|
+
log_file=log_file
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
end_time = time.time()
|
|
94
|
+
|
|
95
|
+
return ExecutionResult(
|
|
96
|
+
exit_code=result.returncode,
|
|
97
|
+
duration_seconds=end_time - start_time,
|
|
98
|
+
log_file=log_file
|
|
99
|
+
)
|