monoco-toolkit 0.3.11__py3-none-any.whl → 0.3.12__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.
Files changed (44) hide show
  1. monoco/core/automation/__init__.py +51 -0
  2. monoco/core/automation/config.py +338 -0
  3. monoco/core/automation/field_watcher.py +296 -0
  4. monoco/core/automation/handlers.py +723 -0
  5. monoco/core/config.py +1 -1
  6. monoco/core/executor/__init__.py +38 -0
  7. monoco/core/executor/agent_action.py +254 -0
  8. monoco/core/executor/git_action.py +303 -0
  9. monoco/core/executor/im_action.py +309 -0
  10. monoco/core/executor/pytest_action.py +218 -0
  11. monoco/core/git.py +15 -0
  12. monoco/core/hooks/context.py +74 -13
  13. monoco/core/router/__init__.py +55 -0
  14. monoco/core/router/action.py +341 -0
  15. monoco/core/router/router.py +392 -0
  16. monoco/core/scheduler/__init__.py +63 -0
  17. monoco/core/scheduler/base.py +152 -0
  18. monoco/core/scheduler/engines.py +175 -0
  19. monoco/core/scheduler/events.py +171 -0
  20. monoco/core/scheduler/local.py +377 -0
  21. monoco/core/watcher/__init__.py +57 -0
  22. monoco/core/watcher/base.py +365 -0
  23. monoco/core/watcher/dropzone.py +152 -0
  24. monoco/core/watcher/issue.py +303 -0
  25. monoco/core/watcher/memo.py +200 -0
  26. monoco/core/watcher/task.py +238 -0
  27. monoco/daemon/events.py +34 -0
  28. monoco/daemon/scheduler.py +172 -201
  29. monoco/daemon/services.py +27 -243
  30. monoco/features/agent/__init__.py +25 -7
  31. monoco/features/agent/cli.py +91 -57
  32. monoco/features/agent/engines.py +31 -170
  33. monoco/features/agent/worker.py +1 -1
  34. monoco/features/issue/commands.py +90 -32
  35. monoco/features/issue/core.py +249 -4
  36. monoco/features/spike/commands.py +5 -3
  37. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +1 -1
  38. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/RECORD +41 -20
  39. monoco/features/agent/apoptosis.py +0 -44
  40. monoco/features/agent/manager.py +0 -127
  41. monoco/features/agent/session.py +0 -169
  42. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
  43. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
  44. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
monoco/core/config.py CHANGED
@@ -160,7 +160,7 @@ class AgentConcurrencyConfig(BaseModel):
160
160
  engineer: int = Field(default=1, description="Maximum concurrent Engineer agents")
161
161
  architect: int = Field(default=1, description="Maximum concurrent Architect agents")
162
162
  reviewer: int = Field(default=1, description="Maximum concurrent Reviewer agents")
163
- planner: int = Field(default=1, description="Maximum concurrent Planner agents")
163
+ # Note: Planner role removed in FEAT-0155 (was never used)
164
164
  # Cool-down configuration
165
165
  failure_cooldown_seconds: int = Field(default=60, description="Cooldown period after a failure before retrying")
166
166
 
@@ -0,0 +1,38 @@
1
+ """
2
+ Executor Module - Layer 3 of the Event Automation Framework.
3
+
4
+ This module provides concrete Action implementations for various execution types.
5
+ It is part of the three-layer architecture:
6
+ - Layer 1: File Watcher
7
+ - Layer 2: Action Router
8
+ - Layer 3: Action Executor (this module)
9
+
10
+ Available Actions:
11
+ - SpawnAgentAction: Spawn agent sessions
12
+ - RunPytestAction: Execute pytest tests
13
+ - GitCommitAction: Perform git commits
14
+ - GitPushAction: Push to remote
15
+ - SendIMAction: Send notifications
16
+
17
+ Example Usage:
18
+ >>> from monoco.core.executor import SpawnAgentAction
19
+ >>> from monoco.core.scheduler import AgentEventType
20
+ >>> from monoco.core.router import ActionRouter
21
+ >>>
22
+ >>> action = SpawnAgentAction(role="Engineer")
23
+ >>> router = ActionRouter()
24
+ >>> router.register(AgentEventType.ISSUE_STAGE_CHANGED, action)
25
+ """
26
+
27
+ from .agent_action import SpawnAgentAction
28
+ from .pytest_action import RunPytestAction
29
+ from .git_action import GitCommitAction, GitPushAction
30
+ from .im_action import SendIMAction
31
+
32
+ __all__ = [
33
+ "SpawnAgentAction",
34
+ "RunPytestAction",
35
+ "GitCommitAction",
36
+ "GitPushAction",
37
+ "SendIMAction",
38
+ ]
@@ -0,0 +1,254 @@
1
+ """
2
+ SpawnAgentAction - Action for spawning agent sessions.
3
+
4
+ Part of Layer 3 (Action Executor) in the event automation framework.
5
+ Uses AgentScheduler to spawn and manage agent sessions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from monoco.core.scheduler import AgentEvent, AgentEventType, AgentScheduler, AgentTask
14
+ from monoco.core.router import Action, ActionResult
15
+ from monoco.features.agent.models import RoleTemplate
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SpawnAgentAction(Action):
21
+ """
22
+ Action that spawns an agent session.
23
+
24
+ This action creates and starts a new agent session based on the event.
25
+ It supports different roles (Architect, Engineer, Reviewer, etc.)
26
+ and integrates with the AgentScheduler for lifecycle management.
27
+
28
+ Example:
29
+ >>> action = SpawnAgentAction(
30
+ ... role="Engineer",
31
+ ... scheduler=scheduler,
32
+ ... )
33
+ >>> result = await action(event)
34
+ """
35
+
36
+ ROLE_TEMPLATES = {
37
+ "Architect": {
38
+ "description": "System Architect",
39
+ "trigger": "memo.accumulation",
40
+ "goal": "Process memo inbox and create issues.",
41
+ "system_prompt": "You are the Architect. Process the Memo inbox.",
42
+ "engine": "gemini",
43
+ },
44
+ "Engineer": {
45
+ "description": "Software Engineer",
46
+ "trigger": "issue.stage_changed",
47
+ "goal": "Implement feature requirements.",
48
+ "system_prompt": "You are a Software Engineer. Read the issue and implement requirements.",
49
+ "engine": "gemini",
50
+ },
51
+ "Reviewer": {
52
+ "description": "Code Reviewer",
53
+ "trigger": "pr.created",
54
+ "goal": "Review code changes thoroughly.",
55
+ "system_prompt": "You are a Code Reviewer. Review the code changes thoroughly.",
56
+ "engine": "gemini",
57
+ },
58
+ "Coroner": {
59
+ "description": "Session Autopsy",
60
+ "trigger": "session.failed",
61
+ "goal": "Analyze failed session and create incident report.",
62
+ "system_prompt": "You are the Coroner. Analyze the failed session.",
63
+ "engine": "gemini",
64
+ },
65
+ }
66
+
67
+ def __init__(
68
+ self,
69
+ role: str,
70
+ scheduler: AgentScheduler,
71
+ config: Optional[Dict[str, Any]] = None,
72
+ custom_role_template: Optional[Dict[str, str]] = None,
73
+ ):
74
+ super().__init__(config)
75
+ self.role = role
76
+ self.scheduler = scheduler
77
+ self.custom_role_template = custom_role_template
78
+ self._spawned_sessions: List[str] = []
79
+
80
+ @property
81
+ def name(self) -> str:
82
+ return f"SpawnAgentAction({self.role})"
83
+
84
+ async def can_execute(self, event: AgentEvent) -> bool:
85
+ """
86
+ Check if we should spawn an agent.
87
+
88
+ Conditions:
89
+ - Scheduler has capacity for new tasks
90
+ """
91
+ # Check scheduler capacity
92
+ stats = self.scheduler.get_stats()
93
+ active_tasks = stats.get("active_tasks", 0)
94
+ max_concurrent = stats.get("max_concurrent", 5)
95
+
96
+ if active_tasks >= max_concurrent:
97
+ logger.warning(f"Scheduler at capacity ({active_tasks}/{max_concurrent}), skipping")
98
+ return False
99
+
100
+ return True
101
+
102
+ async def execute(self, event: AgentEvent) -> ActionResult:
103
+ """Spawn an agent session."""
104
+ issue_id = event.payload.get("issue_id", "unknown")
105
+ issue_title = event.payload.get("issue_title", "Unknown")
106
+
107
+ logger.info(f"Spawning {self.role} agent for {issue_id}")
108
+
109
+ try:
110
+ # Create AgentTask
111
+ task = AgentTask(
112
+ task_id=f"{self.role.lower()}-{issue_id}-{event.timestamp.timestamp()}",
113
+ role_name=self.role,
114
+ issue_id=issue_id,
115
+ prompt=self._build_prompt(issue_id, issue_title),
116
+ engine=self._get_engine(),
117
+ timeout=self.config.get("timeout", 1800),
118
+ metadata={
119
+ "trigger": event.type.value,
120
+ "issue_title": issue_title,
121
+ },
122
+ )
123
+
124
+ # Schedule task
125
+ session_id = await self.scheduler.schedule(task)
126
+
127
+ # Track spawned session
128
+ self._spawned_sessions.append(session_id)
129
+
130
+ logger.info(f"{self.role} scheduled: session={session_id}")
131
+
132
+ return ActionResult.success_result(
133
+ output={
134
+ "session_id": session_id,
135
+ "issue_id": issue_id,
136
+ "role": self.role,
137
+ },
138
+ metadata={
139
+ "task_id": task.task_id,
140
+ },
141
+ )
142
+
143
+ except Exception as e:
144
+ logger.error(f"Failed to spawn {self.role} for {issue_id}: {e}")
145
+ return ActionResult.failure_result(
146
+ error=str(e),
147
+ metadata={
148
+ "issue_id": issue_id,
149
+ "role": self.role,
150
+ },
151
+ )
152
+
153
+ def _build_prompt(self, issue_id: str, issue_title: str) -> str:
154
+ """Build the prompt for the agent."""
155
+ template = self.ROLE_TEMPLATES.get(self.role, self.ROLE_TEMPLATES["Engineer"])
156
+
157
+ return f"""You are a {template['description']}.
158
+
159
+ Issue: {issue_id} - {issue_title}
160
+
161
+ Goal: {template['goal']}
162
+
163
+ {template['system_prompt']}
164
+ """
165
+
166
+ def _get_engine(self) -> str:
167
+ """Get the engine for this role."""
168
+ if self.custom_role_template:
169
+ return self.custom_role_template.get("engine", "gemini")
170
+ template = self.ROLE_TEMPLATES.get(self.role, self.ROLE_TEMPLATES["Engineer"])
171
+ return template["engine"]
172
+
173
+ def get_spawned_sessions(self) -> List[str]:
174
+ """Get list of session IDs spawned by this action."""
175
+ return self._spawned_sessions.copy()
176
+
177
+ def get_stats(self) -> Dict[str, Any]:
178
+ """Get action statistics."""
179
+ stats = super().get_stats()
180
+ stats.update({
181
+ "role": self.role,
182
+ "spawned_sessions": len(self._spawned_sessions),
183
+ })
184
+ return stats
185
+
186
+
187
+ class SpawnArchitectAction(SpawnAgentAction):
188
+ """Convenience action for spawning Architect agents."""
189
+
190
+ def __init__(
191
+ self,
192
+ scheduler: AgentScheduler,
193
+ config: Optional[Dict[str, Any]] = None,
194
+ ):
195
+ super().__init__(
196
+ role="Architect",
197
+ scheduler=scheduler,
198
+ config=config,
199
+ )
200
+
201
+ async def can_execute(self, event: AgentEvent) -> bool:
202
+ """Only execute for MEMO_THRESHOLD events."""
203
+ if event.type != AgentEventType.MEMO_THRESHOLD:
204
+ return False
205
+ return await super().can_execute(event)
206
+
207
+
208
+ class SpawnEngineerAction(SpawnAgentAction):
209
+ """Convenience action for spawning Engineer agents."""
210
+
211
+ def __init__(
212
+ self,
213
+ scheduler: AgentScheduler,
214
+ config: Optional[Dict[str, Any]] = None,
215
+ ):
216
+ super().__init__(
217
+ role="Engineer",
218
+ scheduler=scheduler,
219
+ config=config,
220
+ )
221
+
222
+ async def can_execute(self, event: AgentEvent) -> bool:
223
+ """Only execute for ISSUE_STAGE_CHANGED to 'doing' events."""
224
+ if event.type != AgentEventType.ISSUE_STAGE_CHANGED:
225
+ return False
226
+
227
+ new_stage = event.payload.get("new_stage")
228
+ issue_status = event.payload.get("issue_status")
229
+
230
+ if new_stage != "doing" or issue_status != "open":
231
+ return False
232
+
233
+ return await super().can_execute(event)
234
+
235
+
236
+ class SpawnReviewerAction(SpawnAgentAction):
237
+ """Convenience action for spawning Reviewer agents."""
238
+
239
+ def __init__(
240
+ self,
241
+ scheduler: AgentScheduler,
242
+ config: Optional[Dict[str, Any]] = None,
243
+ ):
244
+ super().__init__(
245
+ role="Reviewer",
246
+ scheduler=scheduler,
247
+ config=config,
248
+ )
249
+
250
+ async def can_execute(self, event: AgentEvent) -> bool:
251
+ """Only execute for PR_CREATED events."""
252
+ if event.type != AgentEventType.PR_CREATED:
253
+ return False
254
+ return await super().can_execute(event)
@@ -0,0 +1,303 @@
1
+ """
2
+ Git Actions - Actions for git operations.
3
+
4
+ Part of Layer 3 (Action Executor) in the event automation framework.
5
+ Provides actions for git commit and push operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from monoco.core.scheduler import AgentEvent
16
+ from monoco.core.router import Action, ActionResult
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class GitResult:
22
+ """Result of a git command execution."""
23
+
24
+ def __init__(
25
+ self,
26
+ returncode: int,
27
+ stdout: str,
28
+ stderr: str,
29
+ ):
30
+ self.returncode = returncode
31
+ self.stdout = stdout
32
+ self.stderr = stderr
33
+
34
+ @property
35
+ def success(self) -> bool:
36
+ return self.returncode == 0
37
+
38
+
39
+ class GitCommitAction(Action):
40
+ """
41
+ Action that performs git commit.
42
+
43
+ This action stages files and creates a git commit with a message.
44
+
45
+ Example:
46
+ >>> action = GitCommitAction(
47
+ ... message="Auto-commit: {issue_id}",
48
+ ... files=["*.py"],
49
+ ... add_all=False,
50
+ ... )
51
+ >>> result = await action(event)
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ message: str,
57
+ files: Optional[List[str]] = None,
58
+ add_all: bool = False,
59
+ working_dir: Optional[Path] = None,
60
+ timeout: int = 30,
61
+ config: Optional[Dict[str, Any]] = None,
62
+ ):
63
+ super().__init__(config)
64
+ self.message = message
65
+ self.files = files or []
66
+ self.add_all = add_all
67
+ self.working_dir = working_dir or Path.cwd()
68
+ self.timeout = timeout
69
+ self._last_result: Optional[GitResult] = None
70
+
71
+ @property
72
+ def name(self) -> str:
73
+ return "GitCommitAction"
74
+
75
+ async def can_execute(self, event: AgentEvent) -> bool:
76
+ """Check if we're in a git repository."""
77
+ git_dir = self.working_dir / ".git"
78
+ return git_dir.exists()
79
+
80
+ async def execute(self, event: AgentEvent) -> ActionResult:
81
+ """Perform git commit."""
82
+ # Format message with event data
83
+ formatted_message = self._format_message(event)
84
+
85
+ logger.info(f"Performing git commit: {formatted_message[:50]}...")
86
+
87
+ try:
88
+ # Stage files
89
+ if self.add_all:
90
+ await self._run_git_command(["git", "add", "-A"])
91
+ elif self.files:
92
+ for file_pattern in self.files:
93
+ await self._run_git_command(["git", "add", file_pattern])
94
+
95
+ # Check if there are changes to commit
96
+ status_result = await self._run_git_command(
97
+ ["git", "status", "--porcelain"]
98
+ )
99
+
100
+ if not status_result.stdout.strip():
101
+ logger.info("No changes to commit")
102
+ return ActionResult.success_result(
103
+ output={"committed": False, "reason": "no_changes"},
104
+ )
105
+
106
+ # Commit
107
+ commit_result = await self._run_git_command(
108
+ ["git", "commit", "-m", formatted_message]
109
+ )
110
+ self._last_result = commit_result
111
+
112
+ if commit_result.success:
113
+ # Get commit hash
114
+ hash_result = await self._run_git_command(
115
+ ["git", "rev-parse", "HEAD"]
116
+ )
117
+ commit_hash = hash_result.stdout.strip()
118
+
119
+ return ActionResult.success_result(
120
+ output={
121
+ "committed": True,
122
+ "commit_hash": commit_hash,
123
+ "message": formatted_message,
124
+ },
125
+ )
126
+ else:
127
+ return ActionResult.failure_result(
128
+ error=f"Git commit failed: {commit_result.stderr}",
129
+ )
130
+
131
+ except Exception as e:
132
+ logger.error(f"Git commit failed: {e}")
133
+ return ActionResult.failure_result(error=str(e))
134
+
135
+ def _format_message(self, event: AgentEvent) -> str:
136
+ """Format commit message with event data."""
137
+ try:
138
+ return self.message.format(**event.payload)
139
+ except (KeyError, ValueError):
140
+ # If formatting fails, return original message
141
+ return self.message
142
+
143
+ async def _run_git_command(self, cmd: List[str]) -> GitResult:
144
+ """Execute a git command."""
145
+ process = await asyncio.create_subprocess_exec(
146
+ *cmd,
147
+ stdout=asyncio.subprocess.PIPE,
148
+ stderr=asyncio.subprocess.PIPE,
149
+ cwd=self.working_dir,
150
+ )
151
+
152
+ try:
153
+ stdout, stderr = await asyncio.wait_for(
154
+ process.communicate(),
155
+ timeout=self.timeout,
156
+ )
157
+ except asyncio.TimeoutError:
158
+ process.kill()
159
+ raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
160
+
161
+ return GitResult(
162
+ returncode=process.returncode,
163
+ stdout=stdout.decode("utf-8", errors="replace"),
164
+ stderr=stderr.decode("utf-8", errors="replace"),
165
+ )
166
+
167
+ def get_stats(self) -> Dict[str, Any]:
168
+ """Get action statistics."""
169
+ stats = super().get_stats()
170
+ stats.update({
171
+ "working_dir": str(self.working_dir),
172
+ "message_template": self.message,
173
+ })
174
+ return stats
175
+
176
+
177
+ class GitPushAction(Action):
178
+ """
179
+ Action that performs git push.
180
+
181
+ This action pushes commits to a remote repository.
182
+
183
+ Example:
184
+ >>> action = GitPushAction(
185
+ ... remote="origin",
186
+ ... branch="main",
187
+ ... )
188
+ >>> result = await action(event)
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ remote: str = "origin",
194
+ branch: Optional[str] = None,
195
+ force: bool = False,
196
+ working_dir: Optional[Path] = None,
197
+ timeout: int = 60,
198
+ config: Optional[Dict[str, Any]] = None,
199
+ ):
200
+ super().__init__(config)
201
+ self.remote = remote
202
+ self.branch = branch
203
+ self.force = force
204
+ self.working_dir = working_dir or Path.cwd()
205
+ self.timeout = timeout
206
+ self._last_result: Optional[GitResult] = None
207
+
208
+ @property
209
+ def name(self) -> str:
210
+ return "GitPushAction"
211
+
212
+ async def can_execute(self, event: AgentEvent) -> bool:
213
+ """Check if we're in a git repository with a remote."""
214
+ git_dir = self.working_dir / ".git"
215
+ if not git_dir.exists():
216
+ return False
217
+
218
+ # Check if remote exists
219
+ try:
220
+ result = await self._run_git_command(
221
+ ["git", "remote", "get-url", self.remote]
222
+ )
223
+ return result.success
224
+ except Exception:
225
+ return False
226
+
227
+ async def execute(self, event: AgentEvent) -> ActionResult:
228
+ """Perform git push."""
229
+ # Determine branch
230
+ branch = self.branch
231
+ if not branch:
232
+ # Get current branch
233
+ result = await self._run_git_command(
234
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"]
235
+ )
236
+ if result.success:
237
+ branch = result.stdout.strip()
238
+ else:
239
+ return ActionResult.failure_result(
240
+ error="Could not determine current branch"
241
+ )
242
+
243
+ logger.info(f"Pushing to {self.remote}/{branch}")
244
+
245
+ try:
246
+ # Build command
247
+ cmd = ["git", "push", self.remote, branch]
248
+ if self.force:
249
+ cmd.append("--force-with-lease")
250
+
251
+ result = await self._run_git_command(cmd)
252
+ self._last_result = result
253
+
254
+ if result.success:
255
+ return ActionResult.success_result(
256
+ output={
257
+ "pushed": True,
258
+ "remote": self.remote,
259
+ "branch": branch,
260
+ },
261
+ )
262
+ else:
263
+ return ActionResult.failure_result(
264
+ error=f"Git push failed: {result.stderr}",
265
+ )
266
+
267
+ except Exception as e:
268
+ logger.error(f"Git push failed: {e}")
269
+ return ActionResult.failure_result(error=str(e))
270
+
271
+ async def _run_git_command(self, cmd: List[str]) -> GitResult:
272
+ """Execute a git command."""
273
+ process = await asyncio.create_subprocess_exec(
274
+ *cmd,
275
+ stdout=asyncio.subprocess.PIPE,
276
+ stderr=asyncio.subprocess.PIPE,
277
+ cwd=self.working_dir,
278
+ )
279
+
280
+ try:
281
+ stdout, stderr = await asyncio.wait_for(
282
+ process.communicate(),
283
+ timeout=self.timeout,
284
+ )
285
+ except asyncio.TimeoutError:
286
+ process.kill()
287
+ raise RuntimeError(f"Git command timed out: {' '.join(cmd)}")
288
+
289
+ return GitResult(
290
+ returncode=process.returncode,
291
+ stdout=stdout.decode("utf-8", errors="replace"),
292
+ stderr=stderr.decode("utf-8", errors="replace"),
293
+ )
294
+
295
+ def get_stats(self) -> Dict[str, Any]:
296
+ """Get action statistics."""
297
+ stats = super().get_stats()
298
+ stats.update({
299
+ "working_dir": str(self.working_dir),
300
+ "remote": self.remote,
301
+ "branch": self.branch or "auto",
302
+ })
303
+ return stats