monoco-toolkit 0.3.2__py3-none-any.whl → 0.3.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.
- monoco/core/config.py +35 -0
- monoco/core/integrations.py +0 -6
- monoco/core/sync.py +6 -19
- monoco/features/issue/commands.py +24 -1
- monoco/features/issue/core.py +90 -39
- monoco/features/issue/domain/models.py +1 -0
- monoco/features/issue/domain_commands.py +47 -0
- monoco/features/issue/domain_service.py +69 -0
- monoco/features/issue/linter.py +119 -11
- monoco/features/issue/validator.py +47 -0
- monoco/features/scheduler/__init__.py +19 -0
- monoco/features/scheduler/cli.py +204 -0
- monoco/features/scheduler/config.py +32 -0
- monoco/features/scheduler/defaults.py +54 -0
- monoco/features/scheduler/manager.py +49 -0
- monoco/features/scheduler/models.py +24 -0
- monoco/features/scheduler/reliability.py +99 -0
- monoco/features/scheduler/session.py +87 -0
- monoco/features/scheduler/worker.py +129 -0
- monoco/main.py +4 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/RECORD +25 -19
- monoco/core/agent/__init__.py +0 -3
- monoco/core/agent/action.py +0 -168
- monoco/core/agent/adapters.py +0 -133
- monoco/core/agent/protocol.py +0 -32
- monoco/core/agent/state.py +0 -106
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -444,6 +444,17 @@ class IssueValidator:
|
|
|
444
444
|
if not all_ids:
|
|
445
445
|
return diagnostics
|
|
446
446
|
|
|
447
|
+
# Logic: Epics must have a parent (unless it is the Sink Root EPIC-0000)
|
|
448
|
+
if meta.type == "epic" and meta.id != "EPIC-0000" and not meta.parent:
|
|
449
|
+
line = self._get_field_line(content, "parent")
|
|
450
|
+
diagnostics.append(
|
|
451
|
+
self._create_diagnostic(
|
|
452
|
+
"Hierarchy Violation: Epics must have a parent (e.g., 'EPIC-0000').",
|
|
453
|
+
DiagnosticSeverity.Error,
|
|
454
|
+
line=line,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
447
458
|
if (
|
|
448
459
|
meta.parent
|
|
449
460
|
and meta.parent not in all_ids
|
|
@@ -575,6 +586,7 @@ class IssueValidator:
|
|
|
575
586
|
has_domains_field = False
|
|
576
587
|
lines = content.splitlines()
|
|
577
588
|
in_fm = False
|
|
589
|
+
field_line = 0
|
|
578
590
|
for i, line_content in enumerate(lines):
|
|
579
591
|
stripped = line_content.strip()
|
|
580
592
|
if stripped == "---":
|
|
@@ -585,6 +597,7 @@ class IssueValidator:
|
|
|
585
597
|
elif in_fm:
|
|
586
598
|
if stripped.startswith("domains:"):
|
|
587
599
|
has_domains_field = True
|
|
600
|
+
field_line = i
|
|
588
601
|
break
|
|
589
602
|
|
|
590
603
|
# Governance Maturity Check
|
|
@@ -607,6 +620,40 @@ class IssueValidator:
|
|
|
607
620
|
)
|
|
608
621
|
)
|
|
609
622
|
|
|
623
|
+
# Domain Content Validation
|
|
624
|
+
from .domain_service import DomainService
|
|
625
|
+
|
|
626
|
+
service = DomainService()
|
|
627
|
+
|
|
628
|
+
if hasattr(meta, "domains") and meta.domains:
|
|
629
|
+
for domain in meta.domains:
|
|
630
|
+
if service.is_alias(domain):
|
|
631
|
+
canonical = service.get_canonical(domain)
|
|
632
|
+
diagnostics.append(
|
|
633
|
+
self._create_diagnostic(
|
|
634
|
+
f"Domain Alias: '{domain}' is an alias for '{canonical}'. Preference: Canonical.",
|
|
635
|
+
DiagnosticSeverity.Warning,
|
|
636
|
+
line=field_line,
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
elif not service.is_defined(domain):
|
|
640
|
+
if service.config.strict:
|
|
641
|
+
diagnostics.append(
|
|
642
|
+
self._create_diagnostic(
|
|
643
|
+
f"Unknown Domain: '{domain}' is not defined in domain ontology.",
|
|
644
|
+
DiagnosticSeverity.Error,
|
|
645
|
+
line=field_line,
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
else:
|
|
649
|
+
diagnostics.append(
|
|
650
|
+
self._create_diagnostic(
|
|
651
|
+
f"Unknown Domain: '{domain}' is not defined in domain ontology.",
|
|
652
|
+
DiagnosticSeverity.Warning,
|
|
653
|
+
line=field_line,
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
|
|
610
657
|
return diagnostics
|
|
611
658
|
|
|
612
659
|
def _validate_checkbox_logic_blocks(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .models import RoleTemplate, SchedulerConfig
|
|
2
|
+
from .worker import Worker
|
|
3
|
+
from .config import load_scheduler_config
|
|
4
|
+
from .defaults import DEFAULT_ROLES
|
|
5
|
+
from .session import Session, RuntimeSession
|
|
6
|
+
from .manager import SessionManager
|
|
7
|
+
from .reliability import ApoptosisManager
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RoleTemplate",
|
|
11
|
+
"SchedulerConfig",
|
|
12
|
+
"Worker",
|
|
13
|
+
"load_scheduler_config",
|
|
14
|
+
"DEFAULT_ROLES",
|
|
15
|
+
"Session",
|
|
16
|
+
"RuntimeSession",
|
|
17
|
+
"SessionManager",
|
|
18
|
+
"ApoptosisManager",
|
|
19
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from monoco.core.output import print_output
|
|
6
|
+
from monoco.core.config import get_config
|
|
7
|
+
from monoco.features.scheduler import SessionManager, load_scheduler_config
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(name="agent", help="Manage agent sessions")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def run(
|
|
14
|
+
target: str = typer.Argument(
|
|
15
|
+
..., help="Issue ID (e.g. FEAT-101) or a Task Description in quotes."
|
|
16
|
+
),
|
|
17
|
+
role: Optional[str] = typer.Option(
|
|
18
|
+
None,
|
|
19
|
+
help="Specific role to use (crafter/builder/auditor). Default: intelligent selection.",
|
|
20
|
+
),
|
|
21
|
+
detach: bool = typer.Option(
|
|
22
|
+
False, "--detach", "-d", help="Run in background (Daemon)"
|
|
23
|
+
),
|
|
24
|
+
fail: bool = typer.Option(
|
|
25
|
+
False, "--fail", help="Simulate a crash for testing Apoptosis."
|
|
26
|
+
),
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Start an agent session.
|
|
30
|
+
- If TARGET is an Issue ID: Work on that issue.
|
|
31
|
+
- If TARGET is a text description: Create a new issue (Crafter).
|
|
32
|
+
"""
|
|
33
|
+
settings = get_config()
|
|
34
|
+
project_root = Path(settings.paths.root).resolve()
|
|
35
|
+
|
|
36
|
+
# 1. Smart Intent Recognition
|
|
37
|
+
import re
|
|
38
|
+
|
|
39
|
+
is_id = re.match(r"^[a-zA-Z]+-\d+$", target)
|
|
40
|
+
|
|
41
|
+
if is_id:
|
|
42
|
+
issue_id = target.upper()
|
|
43
|
+
role_name = role or "builder"
|
|
44
|
+
description = None
|
|
45
|
+
else:
|
|
46
|
+
issue_id = "NEW_TASK"
|
|
47
|
+
role_name = role or "crafter"
|
|
48
|
+
description = target
|
|
49
|
+
|
|
50
|
+
# 2. Load Roles
|
|
51
|
+
roles = load_scheduler_config(project_root)
|
|
52
|
+
selected_role = roles.get(role_name)
|
|
53
|
+
|
|
54
|
+
if not selected_role:
|
|
55
|
+
from monoco.core.output import print_error
|
|
56
|
+
|
|
57
|
+
print_error(f"Role '{role_name}' not found. Available: {list(roles.keys())}")
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
print_output(
|
|
61
|
+
f"Starting Agent Session for '{target}' as {role_name}...",
|
|
62
|
+
title="Agent Scheduler",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 3. Initialize Session
|
|
66
|
+
manager = SessionManager()
|
|
67
|
+
session = manager.create_session(issue_id, selected_role)
|
|
68
|
+
|
|
69
|
+
if detach:
|
|
70
|
+
print_output(
|
|
71
|
+
"Background mode not fully implemented yet. Running in foreground."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# Pass description if it's a new task
|
|
76
|
+
context = {"description": description} if description else None
|
|
77
|
+
|
|
78
|
+
if fail:
|
|
79
|
+
from monoco.core.output import rprint
|
|
80
|
+
|
|
81
|
+
rprint("[bold yellow]DEBUG: Simulating immediate crash...[/bold yellow]")
|
|
82
|
+
session.model.status = "failed"
|
|
83
|
+
else:
|
|
84
|
+
session.start(context=context)
|
|
85
|
+
|
|
86
|
+
# Monitoring Loop
|
|
87
|
+
while session.model.status == "running":
|
|
88
|
+
time.sleep(1)
|
|
89
|
+
|
|
90
|
+
if session.model.status == "failed":
|
|
91
|
+
from monoco.core.output import print_error
|
|
92
|
+
|
|
93
|
+
print_error(
|
|
94
|
+
f"Session {session.model.id} FAILED. Use 'monoco agent autopsy {session.model.id}' for analysis."
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
print_output(
|
|
98
|
+
f"Session finished with status: {session.model.status}",
|
|
99
|
+
title="Agent Scheduler",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
print("\nStopping...")
|
|
104
|
+
session.terminate()
|
|
105
|
+
print_output("Session terminated.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def kill(session_id: str):
|
|
110
|
+
"""
|
|
111
|
+
Terminate a session.
|
|
112
|
+
"""
|
|
113
|
+
manager = SessionManager()
|
|
114
|
+
session = manager.get_session(session_id)
|
|
115
|
+
if session:
|
|
116
|
+
session.terminate()
|
|
117
|
+
print_output(f"Session {session_id} terminated.")
|
|
118
|
+
else:
|
|
119
|
+
print_output(f"Session {session_id} not found.", style="red")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def autopsy(
|
|
124
|
+
target: str = typer.Argument(..., help="Session ID or Issue ID to analyze."),
|
|
125
|
+
):
|
|
126
|
+
"""
|
|
127
|
+
Execute Post-Mortem analysis on a failed session or target Issue.
|
|
128
|
+
"""
|
|
129
|
+
from .reliability import ApoptosisManager
|
|
130
|
+
|
|
131
|
+
manager = SessionManager()
|
|
132
|
+
|
|
133
|
+
print_output(f"Initiating Autopsy for '{target}'...", title="Coroner")
|
|
134
|
+
|
|
135
|
+
# Try to find session
|
|
136
|
+
session = manager.get_session(target)
|
|
137
|
+
if not session:
|
|
138
|
+
# Fallback: Treat target as Issue ID and create a dummy failed session context
|
|
139
|
+
import re
|
|
140
|
+
|
|
141
|
+
if re.match(r"^[a-zA-Z]+-\d+$", target):
|
|
142
|
+
print_output(f"Session not in memory. Analyzing Issue {target} directly.")
|
|
143
|
+
# We create a transient session just to trigger the coroner
|
|
144
|
+
from .defaults import DEFAULT_ROLES
|
|
145
|
+
|
|
146
|
+
builder_role = next(r for r in DEFAULT_ROLES if r.name == "builder")
|
|
147
|
+
session = manager.create_session(target.upper(), builder_role)
|
|
148
|
+
session.model.status = "failed"
|
|
149
|
+
else:
|
|
150
|
+
print_output(
|
|
151
|
+
f"Could not find session or valid Issue ID for '{target}'", style="red"
|
|
152
|
+
)
|
|
153
|
+
raise typer.Exit(code=1)
|
|
154
|
+
|
|
155
|
+
apoptosis = ApoptosisManager(manager)
|
|
156
|
+
apoptosis.trigger_apoptosis(session.model.id)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command(name="list")
|
|
160
|
+
def list_sessions():
|
|
161
|
+
"""
|
|
162
|
+
List active agent sessions.
|
|
163
|
+
"""
|
|
164
|
+
manager = SessionManager()
|
|
165
|
+
sessions = manager.list_sessions()
|
|
166
|
+
|
|
167
|
+
output = []
|
|
168
|
+
for s in sessions:
|
|
169
|
+
output.append(
|
|
170
|
+
{
|
|
171
|
+
"id": s.model.id,
|
|
172
|
+
"issue": s.model.issue_id,
|
|
173
|
+
"role": s.model.role_name,
|
|
174
|
+
"status": s.model.status,
|
|
175
|
+
"branch": s.model.branch_name,
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
print_output(
|
|
180
|
+
output
|
|
181
|
+
or "No active sessions found (Note: Persistence not implemented in CLI list yet).",
|
|
182
|
+
title="Active Sessions",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def logs(session_id: str):
|
|
188
|
+
"""
|
|
189
|
+
Stream logs for a session.
|
|
190
|
+
"""
|
|
191
|
+
print_output(f"Streaming logs for {session_id}...", title="Session Logs")
|
|
192
|
+
# Placeholder
|
|
193
|
+
print("[12:00:00] Session started")
|
|
194
|
+
print("[12:00:01] Worker initialized")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def kill(session_id: str):
|
|
199
|
+
"""
|
|
200
|
+
Terminate a session.
|
|
201
|
+
"""
|
|
202
|
+
print_output(f"Killing session {session_id}...", title="Kill Session")
|
|
203
|
+
# Placeholder
|
|
204
|
+
print("Signal sent.")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from .models import RoleTemplate, SchedulerConfig
|
|
5
|
+
from .defaults import DEFAULT_ROLES
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_scheduler_config(project_root: Path) -> Dict[str, RoleTemplate]:
|
|
9
|
+
"""
|
|
10
|
+
Load scheduler configuration from .monoco/scheduler.yaml
|
|
11
|
+
Merges with default roles.
|
|
12
|
+
"""
|
|
13
|
+
roles = {role.name: role for role in DEFAULT_ROLES}
|
|
14
|
+
|
|
15
|
+
config_path = project_root / ".monoco" / "scheduler.yaml"
|
|
16
|
+
if config_path.exists():
|
|
17
|
+
try:
|
|
18
|
+
with open(config_path, "r") as f:
|
|
19
|
+
data = yaml.safe_load(f) or {}
|
|
20
|
+
|
|
21
|
+
# Use Pydantic to validate the whole config if possible, or just the roles list
|
|
22
|
+
# Depending on file structure. Assuming the file has a 'roles' key.
|
|
23
|
+
if "roles" in data:
|
|
24
|
+
# We can validate using SchedulerConfig
|
|
25
|
+
config = SchedulerConfig(roles=data["roles"])
|
|
26
|
+
for role in config.roles:
|
|
27
|
+
roles[role.name] = role
|
|
28
|
+
except Exception as e:
|
|
29
|
+
# For now, just log or print. Ideally use a logger.
|
|
30
|
+
print(f"Warning: Failed to load scheduler config: {e}")
|
|
31
|
+
|
|
32
|
+
return roles
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from .models import RoleTemplate
|
|
2
|
+
|
|
3
|
+
DEFAULT_ROLES = [
|
|
4
|
+
RoleTemplate(
|
|
5
|
+
name="crafter",
|
|
6
|
+
description="Responsible for initial design, research, and drafting issues from descriptions.",
|
|
7
|
+
trigger="task.received",
|
|
8
|
+
goal="Produce a structured Issue file and/or detailed design document.",
|
|
9
|
+
tools=[
|
|
10
|
+
"create_issue_file",
|
|
11
|
+
"read_file",
|
|
12
|
+
"search_web",
|
|
13
|
+
"view_file_outline",
|
|
14
|
+
"write_to_file",
|
|
15
|
+
],
|
|
16
|
+
system_prompt=(
|
|
17
|
+
"You are a Crafter agent. Your goal is to turn vague ideas into structured engineering plans.\n"
|
|
18
|
+
"If the user provides a description, use 'monoco issue create' and 'monoco issue update' to build the task.\n"
|
|
19
|
+
"If the user provides an existing Issue, analyze the context and provide a detailed design or implementation plan."
|
|
20
|
+
),
|
|
21
|
+
engine="gemini",
|
|
22
|
+
),
|
|
23
|
+
RoleTemplate(
|
|
24
|
+
name="builder",
|
|
25
|
+
description="Responsible for implementation.",
|
|
26
|
+
trigger="design.approved",
|
|
27
|
+
goal="Implement code and tests",
|
|
28
|
+
tools=["read_file", "write_to_file", "run_command", "git"],
|
|
29
|
+
system_prompt="You are a Builder agent. Your job is to implement the code based on the design.",
|
|
30
|
+
engine="gemini",
|
|
31
|
+
),
|
|
32
|
+
RoleTemplate(
|
|
33
|
+
name="auditor",
|
|
34
|
+
description="Responsible for code review.",
|
|
35
|
+
trigger="implementation.submitted",
|
|
36
|
+
goal="Review code and provide feedback",
|
|
37
|
+
tools=[
|
|
38
|
+
"read_file",
|
|
39
|
+
"read_terminal",
|
|
40
|
+
"run_command",
|
|
41
|
+
], # Assumed read_diff and lint are via run_command
|
|
42
|
+
system_prompt="You are an Auditor agent. Your job is to review the code for quality and correctness.",
|
|
43
|
+
engine="gemini",
|
|
44
|
+
),
|
|
45
|
+
RoleTemplate(
|
|
46
|
+
name="coroner",
|
|
47
|
+
description="Responsible for analyzing failure root causes (Autopsy).",
|
|
48
|
+
trigger="session.crashed",
|
|
49
|
+
goal="Produce a post-mortem report",
|
|
50
|
+
tools=["read_file", "read_terminal", "git_log"],
|
|
51
|
+
system_prompt="You are a Coroner agent. Your job is to analyze why the previous session failed and write a post-mortem report.",
|
|
52
|
+
engine="gemini",
|
|
53
|
+
),
|
|
54
|
+
]
|
|
@@ -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,99 @@
|
|
|
1
|
+
from .manager import SessionManager
|
|
2
|
+
from .session import RuntimeSession
|
|
3
|
+
from .defaults import DEFAULT_ROLES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApoptosisManager:
|
|
7
|
+
"""
|
|
8
|
+
Handles the 'Apoptosis' (Programmed Cell Death) lifecycle for agents.
|
|
9
|
+
Ensures that failing agents are killed, analyzed, and the environment is reset.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, session_manager: SessionManager):
|
|
13
|
+
self.session_manager = session_manager
|
|
14
|
+
# Find coroner role
|
|
15
|
+
self.coroner_role = next(
|
|
16
|
+
(r for r in DEFAULT_ROLES if r.name == "coroner"), None
|
|
17
|
+
)
|
|
18
|
+
if not self.coroner_role:
|
|
19
|
+
raise ValueError("Coroner role not defined!")
|
|
20
|
+
|
|
21
|
+
def check_health(self, session: RuntimeSession) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if a session is healthy.
|
|
24
|
+
In a real implementation, this would check heartbeat, CPU usage, or token limits.
|
|
25
|
+
"""
|
|
26
|
+
# Placeholder logic: Random failure or external flag?
|
|
27
|
+
# For now, always healthy unless explicitly marked 'crashed' (which we can simulate)
|
|
28
|
+
if hasattr(session, "simulate_crash") and session.simulate_crash:
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def trigger_apoptosis(self, session_id: str):
|
|
33
|
+
"""
|
|
34
|
+
Execute the full death and rebirth cycle.
|
|
35
|
+
"""
|
|
36
|
+
session = self.session_manager.get_session(session_id)
|
|
37
|
+
if not session:
|
|
38
|
+
print(f"Session {session_id} not found for apoptosis.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
print(f"💀 [Apoptosis] Starting lifecycle for Session {session_id}")
|
|
42
|
+
|
|
43
|
+
# 1. Kill
|
|
44
|
+
self._kill(session)
|
|
45
|
+
|
|
46
|
+
# 2. Autopsy
|
|
47
|
+
try:
|
|
48
|
+
self._perform_autopsy(session)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f"⚠️ Autopsy failed: {e}")
|
|
51
|
+
|
|
52
|
+
# 3. Reset
|
|
53
|
+
self._reset_environment(session)
|
|
54
|
+
|
|
55
|
+
print(
|
|
56
|
+
f"✅ [Apoptosis] Task {session.model.issue_id} has been reset and analyzed."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _kill(self, session: RuntimeSession):
|
|
60
|
+
print(f"🔪 Killing worker process for {session.model.id}...")
|
|
61
|
+
session.terminate()
|
|
62
|
+
session.model.status = "crashed"
|
|
63
|
+
|
|
64
|
+
def _perform_autopsy(self, victim_session: RuntimeSession):
|
|
65
|
+
print(
|
|
66
|
+
f"🔍 Performing autopsy on {victim_session.model.id} via Coroner agent..."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Start a Coroner session
|
|
70
|
+
coroner_session = self.session_manager.create_session(
|
|
71
|
+
victim_session.model.issue_id, self.coroner_role
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Context for the coroner
|
|
75
|
+
context = {
|
|
76
|
+
"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."
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
coroner_session.start(context=context)
|
|
80
|
+
print("📄 Coroner agent finished analysis.")
|
|
81
|
+
|
|
82
|
+
def _reset_environment(self, session: RuntimeSession):
|
|
83
|
+
print("🧹 Resetting environment (simulated git reset --hard)...")
|
|
84
|
+
# In real impl:
|
|
85
|
+
# import subprocess
|
|
86
|
+
# subprocess.run(["git", "reset", "--hard"], check=True)
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def _retry(self, session: RuntimeSession):
|
|
90
|
+
print("🔄 Reincarnating session...")
|
|
91
|
+
# Create a new session with the same role and issue
|
|
92
|
+
new_session = self.session_manager.create_session(
|
|
93
|
+
session.model.issue_id,
|
|
94
|
+
# We need to find the original role object.
|
|
95
|
+
# Simplified: assuming we can find it by name or pass it.
|
|
96
|
+
# For now, just placeholder.
|
|
97
|
+
session.worker.role,
|
|
98
|
+
)
|
|
99
|
+
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()
|