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.
@@ -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
+ )