monoco-toolkit 0.3.2__py3-none-any.whl → 0.3.5__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 (36) hide show
  1. monoco/core/config.py +35 -0
  2. monoco/core/integrations.py +0 -6
  3. monoco/core/sync.py +6 -19
  4. monoco/features/issue/commands.py +24 -16
  5. monoco/features/issue/core.py +90 -39
  6. monoco/features/issue/domain/models.py +1 -0
  7. monoco/features/issue/domain_commands.py +47 -0
  8. monoco/features/issue/domain_service.py +69 -0
  9. monoco/features/issue/linter.py +153 -50
  10. monoco/features/issue/resolver.py +177 -0
  11. monoco/features/issue/resources/en/AGENTS.md +6 -4
  12. monoco/features/issue/resources/zh/AGENTS.md +6 -4
  13. monoco/features/issue/test_priority_integration.py +102 -0
  14. monoco/features/issue/test_resolver.py +83 -0
  15. monoco/features/issue/validator.py +97 -21
  16. monoco/features/scheduler/__init__.py +19 -0
  17. monoco/features/scheduler/cli.py +285 -0
  18. monoco/features/scheduler/config.py +68 -0
  19. monoco/features/scheduler/defaults.py +54 -0
  20. monoco/features/scheduler/engines.py +149 -0
  21. monoco/features/scheduler/manager.py +49 -0
  22. monoco/features/scheduler/models.py +24 -0
  23. monoco/features/scheduler/reliability.py +106 -0
  24. monoco/features/scheduler/session.py +87 -0
  25. monoco/features/scheduler/worker.py +133 -0
  26. monoco/main.py +5 -0
  27. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/METADATA +37 -46
  28. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/RECORD +31 -21
  29. monoco/core/agent/__init__.py +0 -3
  30. monoco/core/agent/action.py +0 -168
  31. monoco/core/agent/adapters.py +0 -133
  32. monoco/core/agent/protocol.py +0 -32
  33. monoco/core/agent/state.py +0 -106
  34. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/WHEEL +0 -0
  35. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/entry_points.txt +0 -0
  36. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,149 @@
1
+ """
2
+ Agent Engine Adapters for Monoco Scheduler.
3
+
4
+ This module provides a unified interface for different AI agent execution engines,
5
+ allowing the Worker to seamlessly switch between Gemini, Claude, and future engines.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import List
10
+
11
+
12
+ class EngineAdapter(ABC):
13
+ """
14
+ Abstract base class for agent engine adapters.
15
+
16
+ Each adapter is responsible for:
17
+ 1. Constructing the correct CLI command for its engine
18
+ 2. Handling engine-specific error scenarios
19
+ 3. Providing metadata about the engine's capabilities
20
+ """
21
+
22
+ @abstractmethod
23
+ def build_command(self, prompt: str) -> List[str]:
24
+ """
25
+ Build the CLI command to execute the agent with the given prompt.
26
+
27
+ Args:
28
+ prompt: The instruction/context to send to the agent
29
+
30
+ Returns:
31
+ List of command arguments (e.g., ["gemini", "-y", "prompt text"])
32
+ """
33
+ pass
34
+
35
+ @property
36
+ @abstractmethod
37
+ def name(self) -> str:
38
+ """Return the canonical name of this engine."""
39
+ pass
40
+
41
+ @property
42
+ def supports_yolo_mode(self) -> bool:
43
+ """Whether this engine supports auto-approval mode."""
44
+ return False
45
+
46
+
47
+ class GeminiAdapter(EngineAdapter):
48
+ """
49
+ Adapter for Google Gemini CLI.
50
+
51
+ Command format: gemini -y <prompt>
52
+ The -y flag enables "YOLO mode" (auto-approval of actions).
53
+ """
54
+
55
+ def build_command(self, prompt: str) -> List[str]:
56
+ return ["gemini", "-y", prompt]
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return "gemini"
61
+
62
+ @property
63
+ def supports_yolo_mode(self) -> bool:
64
+ return True
65
+
66
+
67
+ class ClaudeAdapter(EngineAdapter):
68
+ """
69
+ Adapter for Anthropic Claude CLI.
70
+
71
+ Command format: claude -p <prompt>
72
+ The -p/--print flag enables non-interactive mode (print response and exit).
73
+ """
74
+
75
+ def build_command(self, prompt: str) -> List[str]:
76
+ return ["claude", "-p", prompt]
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return "claude"
81
+
82
+ @property
83
+ def supports_yolo_mode(self) -> bool:
84
+ # Claude uses -p for non-interactive mode, similar concept
85
+ return True
86
+
87
+
88
+ class QwenAdapter(EngineAdapter):
89
+ """
90
+ Adapter for Qwen Code CLI.
91
+
92
+ Command format: qwen -y <prompt>
93
+ The -y flag enables "YOLO mode" (auto-approval of actions).
94
+ """
95
+
96
+ def build_command(self, prompt: str) -> List[str]:
97
+ return ["qwen", "-y", prompt]
98
+
99
+ @property
100
+ def name(self) -> str:
101
+ return "qwen"
102
+
103
+ @property
104
+ def supports_yolo_mode(self) -> bool:
105
+ return True
106
+
107
+
108
+ class EngineFactory:
109
+ """
110
+ Factory for creating engine adapter instances.
111
+
112
+ Usage:
113
+ adapter = EngineFactory.create("gemini")
114
+ command = adapter.build_command("Write a test")
115
+ """
116
+
117
+ _adapters = {
118
+ "gemini": GeminiAdapter,
119
+ "claude": ClaudeAdapter,
120
+ "qwen": QwenAdapter,
121
+ }
122
+
123
+ @classmethod
124
+ def create(cls, engine_name: str) -> EngineAdapter:
125
+ """
126
+ Create an adapter instance for the specified engine.
127
+
128
+ Args:
129
+ engine_name: Name of the engine (e.g., "gemini", "claude")
130
+
131
+ Returns:
132
+ An instance of the appropriate EngineAdapter
133
+
134
+ Raises:
135
+ ValueError: If the engine is not supported
136
+ """
137
+ adapter_class = cls._adapters.get(engine_name.lower())
138
+ if not adapter_class:
139
+ supported = ", ".join(cls._adapters.keys())
140
+ raise ValueError(
141
+ f"Unsupported engine: '{engine_name}'. "
142
+ f"Supported engines: {supported}"
143
+ )
144
+ return adapter_class()
145
+
146
+ @classmethod
147
+ def supported_engines(cls) -> List[str]:
148
+ """Return a list of all supported engine names."""
149
+ return list(cls._adapters.keys())
@@ -0,0 +1,49 @@
1
+ from typing import Dict, List, Optional
2
+ import uuid
3
+ from .models import RoleTemplate
4
+ from .worker import Worker
5
+ from .session import Session, RuntimeSession
6
+
7
+
8
+ class SessionManager:
9
+ """
10
+ Manages the lifecycle of sessions.
11
+ Responsible for creating, tracking, and retrieving sessions.
12
+ """
13
+
14
+ def __init__(self):
15
+ # In-memory storage for now. In prod, this might be a DB or file-backed.
16
+ self._sessions: Dict[str, RuntimeSession] = {}
17
+
18
+ def create_session(self, issue_id: str, role: RoleTemplate) -> RuntimeSession:
19
+ session_id = str(uuid.uuid4())
20
+ branch_name = (
21
+ f"agent/{issue_id}/{session_id[:8]}" # Simple branch naming strategy
22
+ )
23
+
24
+ session_model = Session(
25
+ id=session_id,
26
+ issue_id=issue_id,
27
+ role_name=role.name,
28
+ branch_name=branch_name,
29
+ )
30
+
31
+ worker = Worker(role, issue_id)
32
+ runtime = RuntimeSession(session_model, worker)
33
+ self._sessions[session_id] = runtime
34
+ return runtime
35
+
36
+ def get_session(self, session_id: str) -> Optional[RuntimeSession]:
37
+ return self._sessions.get(session_id)
38
+
39
+ def list_sessions(self, issue_id: Optional[str] = None) -> List[RuntimeSession]:
40
+ if issue_id:
41
+ return [s for s in self._sessions.values() if s.model.issue_id == issue_id]
42
+ return list(self._sessions.values())
43
+
44
+ def terminate_session(self, session_id: str):
45
+ session = self.get_session(session_id)
46
+ if session:
47
+ session.terminate()
48
+ # We might want to keep the record for a while, so don't delete immediately
49
+ # del self._sessions[session_id]
@@ -0,0 +1,24 @@
1
+ from typing import List
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class RoleTemplate(BaseModel):
6
+ name: str = Field(
7
+ ..., description="Unique identifier for the role (e.g., 'crafter')"
8
+ )
9
+ description: str = Field(..., description="Human-readable description of the role")
10
+ trigger: str = Field(
11
+ ..., description="Event that triggers this agent (e.g., 'issue.created')"
12
+ )
13
+ goal: str = Field(..., description="The primary goal/output of this agent")
14
+ tools: List[str] = Field(default_factory=list, description="List of allowed tools")
15
+ system_prompt: str = Field(
16
+ ..., description="The system prompt template for this agent"
17
+ )
18
+ engine: str = Field(
19
+ default="gemini", description="CLI agent engine (gemini/claude)"
20
+ )
21
+
22
+
23
+ class SchedulerConfig(BaseModel):
24
+ roles: List[RoleTemplate] = Field(default_factory=list)
@@ -0,0 +1,106 @@
1
+ from pathlib import Path
2
+ from monoco.core.config import get_config
3
+ from .manager import SessionManager
4
+ from .session import RuntimeSession
5
+ from .config import load_scheduler_config
6
+
7
+
8
+ class ApoptosisManager:
9
+ """
10
+ Handles the 'Apoptosis' (Programmed Cell Death) lifecycle for agents.
11
+ Ensures that failing agents are killed, analyzed, and the environment is reset.
12
+ """
13
+
14
+ def __init__(self, session_manager: SessionManager):
15
+ self.session_manager = session_manager
16
+
17
+ # Load roles dynamically based on current project context
18
+ settings = get_config()
19
+ project_root = Path(settings.paths.root).resolve()
20
+ roles = load_scheduler_config(project_root)
21
+
22
+ # Find coroner role
23
+ self.coroner_role = roles.get("coroner")
24
+
25
+ if not self.coroner_role:
26
+ raise ValueError("Coroner role not defined!")
27
+
28
+ def check_health(self, session: RuntimeSession) -> bool:
29
+ """
30
+ Check if a session is healthy.
31
+ In a real implementation, this would check heartbeat, CPU usage, or token limits.
32
+ """
33
+ # Placeholder logic: Random failure or external flag?
34
+ # For now, always healthy unless explicitly marked 'crashed' (which we can simulate)
35
+ if hasattr(session, "simulate_crash") and session.simulate_crash:
36
+ return False
37
+ return True
38
+
39
+ def trigger_apoptosis(self, session_id: str):
40
+ """
41
+ Execute the full death and rebirth cycle.
42
+ """
43
+ session = self.session_manager.get_session(session_id)
44
+ if not session:
45
+ print(f"Session {session_id} not found for apoptosis.")
46
+ return
47
+
48
+ print(f"💀 [Apoptosis] Starting lifecycle for Session {session_id}")
49
+
50
+ # 1. Kill
51
+ self._kill(session)
52
+
53
+ # 2. Autopsy
54
+ try:
55
+ self._perform_autopsy(session)
56
+ except Exception as e:
57
+ print(f"⚠️ Autopsy failed: {e}")
58
+
59
+ # 3. Reset
60
+ self._reset_environment(session)
61
+
62
+ print(
63
+ f"✅ [Apoptosis] Task {session.model.issue_id} has been reset and analyzed."
64
+ )
65
+
66
+ def _kill(self, session: RuntimeSession):
67
+ print(f"🔪 Killing worker process for {session.model.id}...")
68
+ session.terminate()
69
+ session.model.status = "crashed"
70
+
71
+ def _perform_autopsy(self, victim_session: RuntimeSession):
72
+ print(
73
+ f"🔍 Performing autopsy on {victim_session.model.id} via Coroner agent..."
74
+ )
75
+
76
+ # Start a Coroner session
77
+ coroner_session = self.session_manager.create_session(
78
+ victim_session.model.issue_id, self.coroner_role
79
+ )
80
+
81
+ # Context for the coroner
82
+ context = {
83
+ "description": f"The previous agent session ({victim_session.model.id}) for role '{victim_session.model.role_name}' crashed. Please analyze the environment and the Issue {victim_session.model.issue_id}, then write a ## Post-mortem section in the issue file."
84
+ }
85
+
86
+ coroner_session.start(context=context)
87
+ print("📄 Coroner agent finished analysis.")
88
+
89
+ def _reset_environment(self, session: RuntimeSession):
90
+ print("🧹 Resetting environment (simulated git reset --hard)...")
91
+ # In real impl:
92
+ # import subprocess
93
+ # subprocess.run(["git", "reset", "--hard"], check=True)
94
+ pass
95
+
96
+ def _retry(self, session: RuntimeSession):
97
+ print("🔄 Reincarnating session...")
98
+ # Create a new session with the same role and issue
99
+ new_session = self.session_manager.create_session(
100
+ session.model.issue_id,
101
+ # We need to find the original role object.
102
+ # Simplified: assuming we can find it by name or pass it.
103
+ # For now, just placeholder.
104
+ session.worker.role,
105
+ )
106
+ new_session.start()
@@ -0,0 +1,87 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from pydantic import BaseModel, Field
4
+ from .worker import Worker
5
+
6
+
7
+ class Session(BaseModel):
8
+ """
9
+ Represents a runtime session of a worker.
10
+ Persisted state of the session.
11
+ """
12
+
13
+ id: str = Field(..., description="Unique session ID (likely UUID)")
14
+ issue_id: str = Field(..., description="The Issue ID this session is working on")
15
+ role_name: str = Field(..., description="Name of the role employed")
16
+ status: str = Field(
17
+ default="pending", description="pending, running, suspended, terminated"
18
+ )
19
+ branch_name: str = Field(
20
+ ..., description="Git branch name associated with this session"
21
+ )
22
+ created_at: datetime = Field(default_factory=datetime.now)
23
+ updated_at: datetime = Field(default_factory=datetime.now)
24
+ # History could be a list of logs or pointers to git commits
25
+ # For now, let's keep it simple. The git log IS the history.
26
+
27
+ class Config:
28
+ arbitrary_types_allowed = True
29
+
30
+
31
+ class RuntimeSession:
32
+ """
33
+ The in-memory wrapper around the Session model and the active Worker.
34
+ """
35
+
36
+ def __init__(self, session_model: Session, worker: Worker):
37
+ self.model = session_model
38
+ self.worker = worker
39
+
40
+ def start(self, context: Optional[dict] = None):
41
+ print(
42
+ f"Session {self.model.id}: Starting worker on branch {self.model.branch_name}"
43
+ )
44
+ # In real impl, checking out branch happening here
45
+ self.model.status = "running"
46
+ self.model.updated_at = datetime.now()
47
+
48
+ try:
49
+ self.worker.start(context)
50
+ # Async mode: we assume it started running.
51
+ # Use poll or refresh_status to check later.
52
+ self.model.status = "running"
53
+ except Exception:
54
+ self.model.status = "failed"
55
+ raise
56
+ finally:
57
+ self.model.updated_at = datetime.now()
58
+
59
+ def refresh_status(self) -> str:
60
+ """
61
+ Polls the worker and updates the session model status.
62
+ """
63
+ worker_status = self.worker.poll()
64
+ self.model.status = worker_status
65
+ self.model.updated_at = datetime.now()
66
+ return worker_status
67
+
68
+ def suspend(self):
69
+ print(f"Session {self.model.id}: Suspending worker")
70
+ self.worker.stop()
71
+ self.model.status = "suspended"
72
+ self.model.updated_at = datetime.now()
73
+ # In real impl, ensure git commit of current state?
74
+
75
+ def resume(self):
76
+ print(f"Session {self.model.id}: Resuming worker")
77
+ self.worker.start() # In real impl, might need to re-init process
78
+
79
+ # Async mode: assume running
80
+ self.model.status = "running"
81
+ self.model.updated_at = datetime.now()
82
+
83
+ def terminate(self):
84
+ print(f"Session {self.model.id}: Terminating")
85
+ self.worker.stop()
86
+ self.model.status = "terminated"
87
+ self.model.updated_at = datetime.now()
@@ -0,0 +1,133 @@
1
+ from typing import Optional
2
+ from .models import RoleTemplate
3
+ from .engines import EngineFactory
4
+
5
+
6
+ class Worker:
7
+ """
8
+ Represents an active or pending agent session assigned to a specific role and issue.
9
+ """
10
+
11
+ def __init__(self, role: RoleTemplate, issue_id: str):
12
+ self.role = role
13
+ self.issue_id = issue_id
14
+ self.status = "pending" # pending, running, suspended, terminated
15
+ self.process_id: Optional[int] = None
16
+ self._process = None
17
+
18
+ def start(self, context: Optional[dict] = None):
19
+ """
20
+ Start the worker session asynchronously.
21
+ """
22
+ # Allow restart if not currently running
23
+ if self.status == "running":
24
+ return
25
+
26
+ print(f"Starting worker {self.role.name} for issue {self.issue_id}")
27
+
28
+ try:
29
+ self._execute_work(context)
30
+ self.status = "running"
31
+ except Exception as e:
32
+ print(f"Worker failed to start: {e}")
33
+ self.status = "failed"
34
+ raise
35
+
36
+ def _execute_work(self, context: Optional[dict] = None):
37
+ import subprocess
38
+ import sys
39
+
40
+ # Prepare the prompt
41
+ # We treat 'crafter' as a drafter when context is provided (Draft Mode)
42
+ if (self.role.name == "drafter" or self.role.name == "crafter") and context:
43
+ issue_type = context.get("type", "feature")
44
+ description = context.get("description", "No description")
45
+ prompt = (
46
+ f"You are a Drafter in the Monoco project.\n\n"
47
+ f"Task: Create a new {issue_type} issue based on this request: {description}\n\n"
48
+ "Constraints:\n"
49
+ "1. Use 'monoco issue create' to generate the file.\n"
50
+ "2. Use 'monoco issue update' or direct file editing to enrich Objective and Tasks.\n"
51
+ "3. IMPORTANT: Once the issue file is created and filled with high-quality content, EXIT search or interactive mode immediately.\n"
52
+ "4. Do not perform any other development tasks."
53
+ )
54
+ else:
55
+ prompt = (
56
+ f"{self.role.system_prompt}\n\n"
57
+ f"Issue context: {self.issue_id}\n"
58
+ f"Goal: {self.role.goal}\n"
59
+ )
60
+ if context and "description" in context:
61
+ prompt += f"Specific Task: {context['description']}"
62
+
63
+ engine = self.role.engine
64
+
65
+ print(f"[{self.role.name}] Engine: {engine}")
66
+ print(f"[{self.role.name}] Goal: {self.role.goal}")
67
+
68
+ try:
69
+ # Use factory to get the appropriate engine adapter
70
+ adapter = EngineFactory.create(engine)
71
+ engine_args = adapter.build_command(prompt)
72
+
73
+ self._process = subprocess.Popen(
74
+ engine_args, stdout=sys.stdout, stderr=sys.stderr, text=True
75
+ )
76
+ self.process_id = self._process.pid
77
+
78
+ # DO NOT WAIT HERE.
79
+ # The scheduler/monitoring loop is responsible for checking status.
80
+
81
+ except ValueError as e:
82
+ # Engine not supported by factory
83
+ raise RuntimeError(f"Unsupported engine '{engine}'. {str(e)}")
84
+ except FileNotFoundError:
85
+ raise RuntimeError(
86
+ f"Agent engine '{engine}' not found. Please ensure it is installed and in PATH."
87
+ )
88
+ except Exception as e:
89
+ print(f"[{self.role.name}] Process Error: {e}")
90
+ raise
91
+
92
+ def poll(self) -> str:
93
+ """
94
+ Check process status. Returns current worker status.
95
+ Updates self.status if process has finished.
96
+ """
97
+ if not self._process:
98
+ return self.status
99
+
100
+ returncode = self._process.poll()
101
+ if returncode is None:
102
+ return "running"
103
+
104
+ if returncode == 0:
105
+ self.status = "completed"
106
+ else:
107
+ self.status = "failed"
108
+
109
+ return self.status
110
+
111
+ def wait(self):
112
+ """
113
+ Block until process finishes.
114
+ """
115
+ if self._process:
116
+ self._process.wait()
117
+ self.poll() # Update status
118
+
119
+ def stop(self):
120
+ """
121
+ Stop the worker session.
122
+ """
123
+ if self.status == "terminated":
124
+ return
125
+
126
+ print(f"Stopping worker {self.role.name} for issue {self.issue_id}")
127
+ self.status = "terminated"
128
+ self.process_id = None
129
+
130
+ def __repr__(self):
131
+ return (
132
+ f"<Worker role={self.role.name} issue={self.issue_id} status={self.status}>"
133
+ )
monoco/main.py CHANGED
@@ -166,6 +166,11 @@ app.add_typer(config_cmd.app, name="config", help="Manage configuration")
166
166
  app.add_typer(project_cmd.app, name="project", help="Manage projects")
167
167
  app.add_typer(workspace_cmd.app, name="workspace", help="Manage workspace")
168
168
 
169
+ from monoco.features.scheduler import cli as scheduler_cmd
170
+
171
+ app.add_typer(scheduler_cmd.app, name="agent", help="Manage agent sessions")
172
+ app.add_typer(scheduler_cmd.role_app, name="role", help="Manage agent roles")
173
+
169
174
 
170
175
  from monoco.daemon.commands import serve
171
176