stravinsky 0.1.2__py3-none-any.whl → 0.2.38__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.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -5
- mcp_bridge/auth/cli.py +89 -44
- mcp_bridge/auth/oauth.py +88 -63
- mcp_bridge/hooks/__init__.py +49 -0
- mcp_bridge/hooks/agent_reminder.py +61 -0
- mcp_bridge/hooks/auto_slash_command.py +186 -0
- mcp_bridge/hooks/budget_optimizer.py +38 -0
- mcp_bridge/hooks/comment_checker.py +136 -0
- mcp_bridge/hooks/compaction.py +32 -0
- mcp_bridge/hooks/context_monitor.py +58 -0
- mcp_bridge/hooks/directory_context.py +40 -0
- mcp_bridge/hooks/edit_recovery.py +41 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
- mcp_bridge/hooks/keyword_detector.py +122 -0
- mcp_bridge/hooks/manager.py +96 -0
- mcp_bridge/hooks/preemptive_compaction.py +157 -0
- mcp_bridge/hooks/session_recovery.py +186 -0
- mcp_bridge/hooks/todo_enforcer.py +75 -0
- mcp_bridge/hooks/truncator.py +19 -0
- mcp_bridge/native_hooks/context.py +38 -0
- mcp_bridge/native_hooks/edit_recovery.py +46 -0
- mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
- mcp_bridge/native_hooks/truncator.py +23 -0
- mcp_bridge/prompts/delphi.py +3 -2
- mcp_bridge/prompts/dewey.py +105 -21
- mcp_bridge/prompts/stravinsky.py +452 -118
- mcp_bridge/server.py +491 -668
- mcp_bridge/server_tools.py +547 -0
- mcp_bridge/tools/__init__.py +13 -3
- mcp_bridge/tools/agent_manager.py +359 -190
- mcp_bridge/tools/continuous_loop.py +67 -0
- mcp_bridge/tools/init.py +50 -0
- mcp_bridge/tools/lsp/tools.py +15 -15
- mcp_bridge/tools/model_invoke.py +594 -48
- mcp_bridge/tools/skill_loader.py +51 -47
- mcp_bridge/tools/task_runner.py +141 -0
- mcp_bridge/tools/templates.py +175 -0
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/METADATA +55 -10
- stravinsky-0.2.38.dist-info/RECORD +57 -0
- stravinsky-0.1.2.dist-info/RECORD +0 -32
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
|
@@ -24,9 +24,10 @@ logger = logging.getLogger(__name__)
|
|
|
24
24
|
@dataclass
|
|
25
25
|
class AgentTask:
|
|
26
26
|
"""Represents a background agent task with full tool access."""
|
|
27
|
+
|
|
27
28
|
id: str
|
|
28
29
|
prompt: str
|
|
29
|
-
agent_type: str # explore,
|
|
30
|
+
agent_type: str # explore, dewey, frontend, delphi, etc.
|
|
30
31
|
description: str
|
|
31
32
|
status: str # pending, running, completed, failed, cancelled
|
|
32
33
|
created_at: str
|
|
@@ -36,12 +37,14 @@ class AgentTask:
|
|
|
36
37
|
result: Optional[str] = None
|
|
37
38
|
error: Optional[str] = None
|
|
38
39
|
pid: Optional[int] = None
|
|
40
|
+
timeout: int = 300 # Default 5 minutes
|
|
39
41
|
progress: Optional[Dict[str, Any]] = None # tool calls, last update
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
@dataclass
|
|
43
45
|
class AgentProgress:
|
|
44
46
|
"""Progress tracking for a running agent."""
|
|
47
|
+
|
|
45
48
|
tool_calls: int = 0
|
|
46
49
|
last_tool: Optional[str] = None
|
|
47
50
|
last_message: Optional[str] = None
|
|
@@ -51,72 +54,79 @@ class AgentProgress:
|
|
|
51
54
|
class AgentManager:
|
|
52
55
|
"""
|
|
53
56
|
Manages background agent execution using Claude Code CLI.
|
|
54
|
-
|
|
57
|
+
|
|
55
58
|
Key features:
|
|
56
59
|
- Spawns agents with full tool access via `claude -p`
|
|
57
60
|
- Tracks task status and progress
|
|
58
61
|
- Persists state to .stravinsky/agents.json
|
|
59
62
|
- Provides notification mechanism for task completion
|
|
60
63
|
"""
|
|
61
|
-
|
|
64
|
+
|
|
62
65
|
CLAUDE_CLI = "/opt/homebrew/bin/claude"
|
|
63
|
-
|
|
66
|
+
|
|
64
67
|
def __init__(self, base_dir: Optional[str] = None):
|
|
65
68
|
if base_dir:
|
|
66
69
|
self.base_dir = Path(base_dir)
|
|
67
70
|
else:
|
|
68
71
|
self.base_dir = Path.cwd() / ".stravinsky"
|
|
69
|
-
|
|
72
|
+
|
|
70
73
|
self.agents_dir = self.base_dir / "agents"
|
|
71
74
|
self.state_file = self.base_dir / "agents.json"
|
|
72
|
-
|
|
75
|
+
|
|
73
76
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
74
77
|
self.agents_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
-
|
|
78
|
+
|
|
76
79
|
if not self.state_file.exists():
|
|
77
80
|
self._save_tasks({})
|
|
78
|
-
|
|
81
|
+
|
|
79
82
|
# In-memory tracking for running processes
|
|
80
83
|
self._processes: Dict[str, subprocess.Popen] = {}
|
|
81
84
|
self._notification_queue: Dict[str, List[AgentTask]] = {}
|
|
82
|
-
|
|
85
|
+
self._lock = threading.RLock()
|
|
86
|
+
|
|
83
87
|
def _load_tasks(self) -> Dict[str, Any]:
|
|
84
88
|
"""Load tasks from persistent storage."""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
with self._lock:
|
|
90
|
+
try:
|
|
91
|
+
if not self.state_file.exists():
|
|
92
|
+
return {}
|
|
93
|
+
with open(self.state_file, "r") as f:
|
|
94
|
+
return json.load(f)
|
|
95
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
96
|
+
return {}
|
|
97
|
+
|
|
91
98
|
def _save_tasks(self, tasks: Dict[str, Any]):
|
|
92
99
|
"""Save tasks to persistent storage."""
|
|
93
|
-
with
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
with self._lock:
|
|
101
|
+
with open(self.state_file, "w") as f:
|
|
102
|
+
json.dump(tasks, f, indent=2)
|
|
103
|
+
|
|
96
104
|
def _update_task(self, task_id: str, **kwargs):
|
|
97
105
|
"""Update a task's fields."""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
tasks
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
with self._lock:
|
|
107
|
+
tasks = self._load_tasks()
|
|
108
|
+
if task_id in tasks:
|
|
109
|
+
tasks[task_id].update(kwargs)
|
|
110
|
+
self._save_tasks(tasks)
|
|
111
|
+
|
|
103
112
|
def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
|
104
113
|
"""Get a task by ID."""
|
|
105
114
|
tasks = self._load_tasks()
|
|
106
115
|
return tasks.get(task_id)
|
|
107
|
-
|
|
116
|
+
|
|
108
117
|
def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
109
118
|
"""List all tasks, optionally filtered by parent session."""
|
|
110
119
|
tasks = self._load_tasks()
|
|
111
120
|
task_list = list(tasks.values())
|
|
112
|
-
|
|
121
|
+
|
|
113
122
|
if parent_session_id:
|
|
114
123
|
task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
|
|
115
|
-
|
|
124
|
+
|
|
116
125
|
return task_list
|
|
117
|
-
|
|
126
|
+
|
|
118
127
|
def spawn(
|
|
119
128
|
self,
|
|
129
|
+
token_store: Any,
|
|
120
130
|
prompt: str,
|
|
121
131
|
agent_type: str = "explore",
|
|
122
132
|
description: str = "",
|
|
@@ -124,10 +134,11 @@ class AgentManager:
|
|
|
124
134
|
system_prompt: Optional[str] = None,
|
|
125
135
|
model: str = "gemini-3-flash",
|
|
126
136
|
thinking_budget: int = 0,
|
|
137
|
+
timeout: int = 300,
|
|
127
138
|
) -> str:
|
|
128
139
|
"""
|
|
129
140
|
Spawn a new background agent.
|
|
130
|
-
|
|
141
|
+
|
|
131
142
|
Args:
|
|
132
143
|
prompt: The task prompt for the agent
|
|
133
144
|
agent_type: Type of agent (explore, dewey, frontend, delphi)
|
|
@@ -135,12 +146,13 @@ class AgentManager:
|
|
|
135
146
|
parent_session_id: Optional parent session for notifications
|
|
136
147
|
system_prompt: Optional custom system prompt
|
|
137
148
|
model: Model to use (gemini-3-flash, claude, etc.)
|
|
138
|
-
|
|
149
|
+
timeout: Maximum execution time in seconds
|
|
150
|
+
|
|
139
151
|
Returns:
|
|
140
152
|
Task ID for tracking
|
|
141
153
|
"""
|
|
142
154
|
task_id = f"agent_{uuid.uuid4().hex[:8]}"
|
|
143
|
-
|
|
155
|
+
|
|
144
156
|
task = AgentTask(
|
|
145
157
|
id=task_id,
|
|
146
158
|
prompt=prompt,
|
|
@@ -149,180 +161,185 @@ class AgentManager:
|
|
|
149
161
|
status="pending",
|
|
150
162
|
created_at=datetime.now().isoformat(),
|
|
151
163
|
parent_session_id=parent_session_id,
|
|
164
|
+
timeout=timeout,
|
|
152
165
|
)
|
|
153
|
-
|
|
166
|
+
|
|
154
167
|
# Persist task
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
with self._lock:
|
|
169
|
+
tasks = self._load_tasks()
|
|
170
|
+
tasks[task_id] = asdict(task)
|
|
171
|
+
self._save_tasks(tasks)
|
|
172
|
+
|
|
159
173
|
# Start background execution
|
|
160
|
-
self._execute_agent(
|
|
161
|
-
|
|
174
|
+
self._execute_agent(
|
|
175
|
+
task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
|
|
176
|
+
)
|
|
177
|
+
|
|
162
178
|
return task_id
|
|
163
|
-
|
|
179
|
+
|
|
164
180
|
def _execute_agent(
|
|
165
181
|
self,
|
|
166
182
|
task_id: str,
|
|
183
|
+
token_store: Any,
|
|
167
184
|
prompt: str,
|
|
168
185
|
agent_type: str,
|
|
169
186
|
system_prompt: Optional[str] = None,
|
|
170
187
|
model: str = "gemini-3-flash",
|
|
171
188
|
thinking_budget: int = 0,
|
|
189
|
+
timeout: int = 300,
|
|
172
190
|
):
|
|
173
|
-
"""Execute agent
|
|
174
|
-
|
|
191
|
+
"""Execute agent using Claude CLI with full tool access.
|
|
192
|
+
|
|
193
|
+
Uses `claude -p` to spawn a background agent with complete tool access,
|
|
194
|
+
just like oh-my-opencode's Sisyphus implementation.
|
|
195
|
+
"""
|
|
196
|
+
|
|
175
197
|
def run_agent():
|
|
176
198
|
log_file = self.agents_dir / f"{task_id}.log"
|
|
177
199
|
output_file = self.agents_dir / f"{task_id}.out"
|
|
178
|
-
|
|
179
|
-
self._update_task(
|
|
180
|
-
|
|
181
|
-
status="running",
|
|
182
|
-
started_at=datetime.now().isoformat()
|
|
183
|
-
)
|
|
184
|
-
|
|
200
|
+
|
|
201
|
+
self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
|
|
202
|
+
|
|
185
203
|
try:
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
self._update_task(
|
|
219
|
-
task_id,
|
|
220
|
-
status="completed",
|
|
221
|
-
result=result,
|
|
222
|
-
completed_at=datetime.now().isoformat()
|
|
223
|
-
)
|
|
224
|
-
logger.info(f"[AgentManager] Gemini agent {task_id} completed successfully")
|
|
225
|
-
|
|
226
|
-
else:
|
|
227
|
-
# Use Claude CLI for Claude models
|
|
228
|
-
cmd = [
|
|
229
|
-
self.CLAUDE_CLI,
|
|
230
|
-
"-p", # Non-interactive print mode
|
|
231
|
-
]
|
|
232
|
-
|
|
233
|
-
# Add system prompt if provided
|
|
234
|
-
if system_prompt:
|
|
235
|
-
cmd.extend(["--system-prompt", system_prompt])
|
|
236
|
-
|
|
237
|
-
# Add the prompt
|
|
238
|
-
cmd.append(prompt)
|
|
239
|
-
|
|
240
|
-
logger.info(f"[AgentManager] Spawning Claude agent {task_id}: {' '.join(cmd[:5])}...")
|
|
241
|
-
|
|
242
|
-
# Run Claude CLI
|
|
204
|
+
# Prepare full prompt with system prompt if provided
|
|
205
|
+
full_prompt = prompt
|
|
206
|
+
if system_prompt:
|
|
207
|
+
full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
|
|
208
|
+
|
|
209
|
+
logger.info(f"[AgentManager] Spawning Claude CLI agent {task_id} ({agent_type})")
|
|
210
|
+
|
|
211
|
+
# Build Claude CLI command with full tool access
|
|
212
|
+
# Using `claude -p` for non-interactive mode with prompt
|
|
213
|
+
cmd = [
|
|
214
|
+
self.CLAUDE_CLI,
|
|
215
|
+
"-p",
|
|
216
|
+
full_prompt,
|
|
217
|
+
"--output-format",
|
|
218
|
+
"text",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Add model selection if specified
|
|
222
|
+
if model:
|
|
223
|
+
cmd.extend(["--model", model])
|
|
224
|
+
|
|
225
|
+
# Add system prompt file if we have one
|
|
226
|
+
if system_prompt:
|
|
227
|
+
system_file = self.agents_dir / f"{task_id}.system"
|
|
228
|
+
system_file.write_text(system_prompt)
|
|
229
|
+
cmd.extend(["--system-prompt", str(system_file)])
|
|
230
|
+
|
|
231
|
+
# Execute Claude CLI as subprocess with full tool access
|
|
232
|
+
logger.info(f"[AgentManager] Running: {' '.join(cmd[:3])}...")
|
|
233
|
+
|
|
234
|
+
with open(log_file, "w") as log_f:
|
|
243
235
|
process = subprocess.Popen(
|
|
244
236
|
cmd,
|
|
245
|
-
stdout=
|
|
246
|
-
stderr=
|
|
237
|
+
stdout=subprocess.PIPE,
|
|
238
|
+
stderr=log_f,
|
|
239
|
+
text=True,
|
|
247
240
|
cwd=str(Path.cwd()),
|
|
248
|
-
|
|
241
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
|
|
242
|
+
start_new_session=True, # Allow process group management
|
|
249
243
|
)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
result = ""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if return_code == 0:
|
|
244
|
+
|
|
245
|
+
# Track the process
|
|
246
|
+
self._processes[task_id] = process
|
|
247
|
+
self._update_task(task_id, pid=process.pid)
|
|
248
|
+
|
|
249
|
+
# Wait for completion with timeout
|
|
250
|
+
try:
|
|
251
|
+
stdout, _ = process.communicate(timeout=timeout)
|
|
252
|
+
result = stdout.strip() if stdout else ""
|
|
253
|
+
|
|
254
|
+
if process.returncode == 0:
|
|
255
|
+
output_file.write_text(result)
|
|
263
256
|
self._update_task(
|
|
264
257
|
task_id,
|
|
265
258
|
status="completed",
|
|
266
259
|
result=result,
|
|
267
|
-
completed_at=datetime.now().isoformat()
|
|
260
|
+
completed_at=datetime.now().isoformat(),
|
|
268
261
|
)
|
|
269
262
|
logger.info(f"[AgentManager] Agent {task_id} completed successfully")
|
|
270
263
|
else:
|
|
271
|
-
|
|
264
|
+
error_msg = f"Claude CLI exited with code {process.returncode}"
|
|
272
265
|
if log_file.exists():
|
|
273
|
-
|
|
266
|
+
error_msg += f"\n{log_file.read_text()}"
|
|
274
267
|
self._update_task(
|
|
275
268
|
task_id,
|
|
276
269
|
status="failed",
|
|
277
|
-
error=
|
|
278
|
-
completed_at=datetime.now().isoformat()
|
|
270
|
+
error=error_msg,
|
|
271
|
+
completed_at=datetime.now().isoformat(),
|
|
279
272
|
)
|
|
280
|
-
logger.error(f"[AgentManager] Agent {task_id} failed: {
|
|
281
|
-
|
|
273
|
+
logger.error(f"[AgentManager] Agent {task_id} failed: {error_msg}")
|
|
274
|
+
|
|
275
|
+
except subprocess.TimeoutExpired:
|
|
276
|
+
process.kill()
|
|
277
|
+
self._update_task(
|
|
278
|
+
task_id,
|
|
279
|
+
status="failed",
|
|
280
|
+
error=f"Agent timed out after {timeout}s",
|
|
281
|
+
completed_at=datetime.now().isoformat(),
|
|
282
|
+
)
|
|
283
|
+
logger.warning(f"[AgentManager] Agent {task_id} timed out")
|
|
284
|
+
|
|
285
|
+
except FileNotFoundError:
|
|
286
|
+
error_msg = f"Claude CLI not found at {self.CLAUDE_CLI}. Install with: npm install -g @anthropic-ai/claude-code"
|
|
287
|
+
log_file.write_text(error_msg)
|
|
288
|
+
self._update_task(
|
|
289
|
+
task_id,
|
|
290
|
+
status="failed",
|
|
291
|
+
error=error_msg,
|
|
292
|
+
completed_at=datetime.now().isoformat(),
|
|
293
|
+
)
|
|
294
|
+
logger.error(f"[AgentManager] {error_msg}")
|
|
295
|
+
|
|
282
296
|
except Exception as e:
|
|
297
|
+
error_msg = str(e)
|
|
298
|
+
log_file.write_text(error_msg)
|
|
283
299
|
self._update_task(
|
|
284
300
|
task_id,
|
|
285
301
|
status="failed",
|
|
286
|
-
error=
|
|
287
|
-
completed_at=datetime.now().isoformat()
|
|
302
|
+
error=error_msg,
|
|
303
|
+
completed_at=datetime.now().isoformat(),
|
|
288
304
|
)
|
|
289
305
|
logger.exception(f"[AgentManager] Agent {task_id} exception")
|
|
306
|
+
|
|
290
307
|
finally:
|
|
291
308
|
self._processes.pop(task_id, None)
|
|
292
309
|
self._notify_completion(task_id)
|
|
293
|
-
|
|
310
|
+
|
|
294
311
|
# Run in background thread
|
|
295
312
|
thread = threading.Thread(target=run_agent, daemon=True)
|
|
296
313
|
thread.start()
|
|
297
|
-
|
|
314
|
+
|
|
298
315
|
def _notify_completion(self, task_id: str):
|
|
299
316
|
"""Queue notification for parent session."""
|
|
300
317
|
task = self.get_task(task_id)
|
|
301
318
|
if not task:
|
|
302
319
|
return
|
|
303
|
-
|
|
320
|
+
|
|
304
321
|
parent_id = task.get("parent_session_id")
|
|
305
322
|
if parent_id:
|
|
306
323
|
if parent_id not in self._notification_queue:
|
|
307
324
|
self._notification_queue[parent_id] = []
|
|
308
|
-
|
|
325
|
+
|
|
309
326
|
self._notification_queue[parent_id].append(task)
|
|
310
327
|
logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
|
|
311
|
-
|
|
328
|
+
|
|
312
329
|
def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
|
|
313
330
|
"""Get and clear pending notifications for a session."""
|
|
314
331
|
notifications = self._notification_queue.pop(session_id, [])
|
|
315
332
|
return notifications
|
|
316
|
-
|
|
333
|
+
|
|
317
334
|
def cancel(self, task_id: str) -> bool:
|
|
318
335
|
"""Cancel a running agent task."""
|
|
319
336
|
task = self.get_task(task_id)
|
|
320
337
|
if not task:
|
|
321
338
|
return False
|
|
322
|
-
|
|
339
|
+
|
|
323
340
|
if task["status"] != "running":
|
|
324
341
|
return False
|
|
325
|
-
|
|
342
|
+
|
|
326
343
|
process = self._processes.get(task_id)
|
|
327
344
|
if process:
|
|
328
345
|
try:
|
|
@@ -334,31 +351,56 @@ class AgentManager:
|
|
|
334
351
|
process.kill()
|
|
335
352
|
except:
|
|
336
353
|
pass
|
|
337
|
-
|
|
338
|
-
self._update_task(
|
|
339
|
-
|
|
340
|
-
status="cancelled",
|
|
341
|
-
completed_at=datetime.now().isoformat()
|
|
342
|
-
)
|
|
343
|
-
|
|
354
|
+
|
|
355
|
+
self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
|
|
356
|
+
|
|
344
357
|
return True
|
|
345
|
-
|
|
358
|
+
|
|
359
|
+
def stop_all(self, clear_history: bool = False) -> int:
|
|
360
|
+
"""
|
|
361
|
+
Stop all running agents and optionally clear task history.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
clear_history: If True, also remove completed/failed tasks from history
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Number of tasks stopped/cleared
|
|
368
|
+
"""
|
|
369
|
+
tasks = self._load_tasks()
|
|
370
|
+
stopped_count = 0
|
|
371
|
+
|
|
372
|
+
# Stop running tasks
|
|
373
|
+
for task_id, task in list(tasks.items()):
|
|
374
|
+
if task.get("status") == "running":
|
|
375
|
+
self.cancel(task_id)
|
|
376
|
+
stopped_count += 1
|
|
377
|
+
|
|
378
|
+
# Optionally clear history
|
|
379
|
+
if clear_history:
|
|
380
|
+
cleared = len(tasks)
|
|
381
|
+
self._save_tasks({})
|
|
382
|
+
self._processes.clear()
|
|
383
|
+
logger.info(f"[AgentManager] Cleared all {cleared} agent tasks")
|
|
384
|
+
return cleared
|
|
385
|
+
|
|
386
|
+
return stopped_count
|
|
387
|
+
|
|
346
388
|
def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
|
|
347
389
|
"""
|
|
348
390
|
Get output from an agent task.
|
|
349
|
-
|
|
391
|
+
|
|
350
392
|
Args:
|
|
351
393
|
task_id: The task ID
|
|
352
394
|
block: If True, wait for completion
|
|
353
395
|
timeout: Max seconds to wait if blocking
|
|
354
|
-
|
|
396
|
+
|
|
355
397
|
Returns:
|
|
356
398
|
Formatted task output/status
|
|
357
399
|
"""
|
|
358
400
|
task = self.get_task(task_id)
|
|
359
401
|
if not task:
|
|
360
402
|
return f"Task {task_id} not found."
|
|
361
|
-
|
|
403
|
+
|
|
362
404
|
if block and task["status"] == "running":
|
|
363
405
|
# Poll for completion
|
|
364
406
|
start = datetime.now()
|
|
@@ -367,11 +409,11 @@ class AgentManager:
|
|
|
367
409
|
if task["status"] != "running":
|
|
368
410
|
break
|
|
369
411
|
asyncio.sleep(0.5)
|
|
370
|
-
|
|
412
|
+
|
|
371
413
|
status = task["status"]
|
|
372
414
|
description = task.get("description", "")
|
|
373
415
|
agent_type = task.get("agent_type", "unknown")
|
|
374
|
-
|
|
416
|
+
|
|
375
417
|
if status == "completed":
|
|
376
418
|
result = task.get("result", "(no output)")
|
|
377
419
|
return f"""✅ Agent Task Completed
|
|
@@ -382,7 +424,7 @@ class AgentManager:
|
|
|
382
424
|
|
|
383
425
|
**Result**:
|
|
384
426
|
{result}"""
|
|
385
|
-
|
|
427
|
+
|
|
386
428
|
elif status == "failed":
|
|
387
429
|
error = task.get("error", "(no error details)")
|
|
388
430
|
return f"""❌ Agent Task Failed
|
|
@@ -393,14 +435,14 @@ class AgentManager:
|
|
|
393
435
|
|
|
394
436
|
**Error**:
|
|
395
437
|
{error}"""
|
|
396
|
-
|
|
438
|
+
|
|
397
439
|
elif status == "cancelled":
|
|
398
440
|
return f"""⚠️ Agent Task Cancelled
|
|
399
441
|
|
|
400
442
|
**Task ID**: {task_id}
|
|
401
443
|
**Agent**: {agent_type}
|
|
402
444
|
**Description**: {description}"""
|
|
403
|
-
|
|
445
|
+
|
|
404
446
|
else: # pending or running
|
|
405
447
|
pid = task.get("pid", "N/A")
|
|
406
448
|
started = task.get("started_at", "N/A")
|
|
@@ -413,29 +455,47 @@ class AgentManager:
|
|
|
413
455
|
**Started**: {started}
|
|
414
456
|
|
|
415
457
|
Use `agent_output` with block=true to wait for completion."""
|
|
416
|
-
|
|
458
|
+
|
|
417
459
|
def get_progress(self, task_id: str, lines: int = 20) -> str:
|
|
418
460
|
"""
|
|
419
461
|
Get real-time progress from a running agent's output.
|
|
420
|
-
|
|
462
|
+
|
|
421
463
|
Args:
|
|
422
464
|
task_id: The task ID
|
|
423
465
|
lines: Number of lines to show from the end
|
|
424
|
-
|
|
466
|
+
|
|
425
467
|
Returns:
|
|
426
468
|
Recent output lines and status
|
|
427
469
|
"""
|
|
428
470
|
task = self.get_task(task_id)
|
|
429
471
|
if not task:
|
|
430
472
|
return f"Task {task_id} not found."
|
|
431
|
-
|
|
473
|
+
|
|
432
474
|
output_file = self.agents_dir / f"{task_id}.out"
|
|
433
475
|
log_file = self.agents_dir / f"{task_id}.log"
|
|
434
|
-
|
|
476
|
+
|
|
435
477
|
status = task["status"]
|
|
436
478
|
description = task.get("description", "")
|
|
437
479
|
agent_type = task.get("agent_type", "unknown")
|
|
438
|
-
|
|
480
|
+
pid = task.get("pid")
|
|
481
|
+
|
|
482
|
+
# Zombie Detection: If running but process is gone
|
|
483
|
+
if status == "running" and pid:
|
|
484
|
+
try:
|
|
485
|
+
import psutil
|
|
486
|
+
|
|
487
|
+
if not psutil.pid_exists(pid):
|
|
488
|
+
status = "failed"
|
|
489
|
+
self._update_task(
|
|
490
|
+
task_id,
|
|
491
|
+
status="failed",
|
|
492
|
+
error="Agent process died unexpectedly (Zombie detected)",
|
|
493
|
+
completed_at=datetime.now().isoformat(),
|
|
494
|
+
)
|
|
495
|
+
logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
|
|
496
|
+
except ImportError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
439
499
|
# Read recent output
|
|
440
500
|
output_content = ""
|
|
441
501
|
if output_file.exists():
|
|
@@ -447,7 +507,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
447
507
|
output_content = "\n".join(recent)
|
|
448
508
|
except Exception:
|
|
449
509
|
pass
|
|
450
|
-
|
|
510
|
+
|
|
451
511
|
# Check log for errors
|
|
452
512
|
log_content = ""
|
|
453
513
|
if log_file.exists():
|
|
@@ -455,7 +515,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
455
515
|
log_content = log_file.read_text().strip()
|
|
456
516
|
except Exception:
|
|
457
517
|
pass
|
|
458
|
-
|
|
518
|
+
|
|
459
519
|
# Status emoji
|
|
460
520
|
status_emoji = {
|
|
461
521
|
"pending": "⏳",
|
|
@@ -464,7 +524,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
464
524
|
"failed": "❌",
|
|
465
525
|
"cancelled": "⚠️",
|
|
466
526
|
}.get(status, "❓")
|
|
467
|
-
|
|
527
|
+
|
|
468
528
|
result = f"""{status_emoji} **Agent Progress**
|
|
469
529
|
|
|
470
530
|
**Task ID**: {task_id}
|
|
@@ -472,56 +532,64 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
472
532
|
**Description**: {description}
|
|
473
533
|
**Status**: {status}
|
|
474
534
|
"""
|
|
475
|
-
|
|
535
|
+
|
|
476
536
|
if output_content:
|
|
477
537
|
result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
|
|
478
538
|
elif status == "running":
|
|
479
539
|
result += "\n*Agent is working... no output yet.*"
|
|
480
|
-
|
|
540
|
+
|
|
481
541
|
if log_content and status == "failed":
|
|
482
542
|
# Truncate log if too long
|
|
483
543
|
if len(log_content) > 500:
|
|
484
544
|
log_content = log_content[:500] + "..."
|
|
485
545
|
result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
|
|
486
|
-
|
|
546
|
+
|
|
487
547
|
return result
|
|
488
548
|
|
|
489
549
|
|
|
490
550
|
# Global manager instance
|
|
491
551
|
_manager: Optional[AgentManager] = None
|
|
552
|
+
_manager_lock = threading.Lock()
|
|
492
553
|
|
|
493
554
|
|
|
494
555
|
def get_manager() -> AgentManager:
|
|
495
556
|
"""Get or create the global AgentManager instance."""
|
|
496
557
|
global _manager
|
|
497
558
|
if _manager is None:
|
|
498
|
-
|
|
559
|
+
with _manager_lock:
|
|
560
|
+
# Double-check pattern to avoid race condition
|
|
561
|
+
if _manager is None:
|
|
562
|
+
_manager = AgentManager()
|
|
499
563
|
return _manager
|
|
500
564
|
|
|
501
565
|
|
|
502
566
|
# Tool interface functions
|
|
503
567
|
|
|
568
|
+
|
|
504
569
|
async def agent_spawn(
|
|
505
570
|
prompt: str,
|
|
506
571
|
agent_type: str = "explore",
|
|
507
572
|
description: str = "",
|
|
508
573
|
model: str = "gemini-3-flash",
|
|
509
574
|
thinking_budget: int = 0,
|
|
575
|
+
timeout: int = 300,
|
|
510
576
|
) -> str:
|
|
511
577
|
"""
|
|
512
578
|
Spawn a background agent.
|
|
513
|
-
|
|
579
|
+
|
|
514
580
|
Args:
|
|
515
581
|
prompt: The task for the agent to perform
|
|
516
582
|
agent_type: Type of agent (explore, dewey, frontend, delphi)
|
|
517
583
|
description: Short description shown in status
|
|
518
584
|
model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
|
|
519
|
-
|
|
585
|
+
thinking_budget: Reserved reasoning tokens
|
|
586
|
+
timeout: Execution timeout in seconds
|
|
587
|
+
|
|
520
588
|
Returns:
|
|
521
589
|
Task ID and instructions
|
|
522
590
|
"""
|
|
523
591
|
manager = get_manager()
|
|
524
|
-
|
|
592
|
+
|
|
525
593
|
# Map agent types to system prompts
|
|
526
594
|
system_prompts = {
|
|
527
595
|
"explore": "You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions efficiently.",
|
|
@@ -552,25 +620,90 @@ ULTRATHINK MODE (when user says "ULTRATHINK" or "think harder"):
|
|
|
552
620
|
2. Edge Case Analysis: What could go wrong and how we prevented it
|
|
553
621
|
3. The Code: Optimized, bespoke, production-ready, utilizing existing libraries""",
|
|
554
622
|
"delphi": "You are a strategic advisor. Provide architecture guidance, debugging assistance, and code review.",
|
|
623
|
+
"document_writer": """You are a Technical Documentation Specialist. Your expertise is creating clear, comprehensive documentation.
|
|
624
|
+
|
|
625
|
+
DOCUMENT TYPES YOU EXCEL AT:
|
|
626
|
+
- README files with proper structure
|
|
627
|
+
- API documentation with examples
|
|
628
|
+
- Architecture decision records (ADRs)
|
|
629
|
+
- User guides and tutorials
|
|
630
|
+
- Inline code documentation
|
|
631
|
+
|
|
632
|
+
DOCUMENTATION PRINCIPLES:
|
|
633
|
+
- Audience-first: Know who's reading and what they need
|
|
634
|
+
- Progressive disclosure: Overview → Details → Edge cases
|
|
635
|
+
- Examples over explanations: Show, don't just tell
|
|
636
|
+
- Keep it DRY: Reference rather than repeat
|
|
637
|
+
- Version awareness: Note when behavior differs across versions
|
|
638
|
+
|
|
639
|
+
RESPONSE FORMAT:
|
|
640
|
+
1. Document type and target audience identified
|
|
641
|
+
2. The documentation, properly formatted in markdown""",
|
|
642
|
+
"multimodal": """You interpret media files that cannot be read as plain text.
|
|
643
|
+
|
|
644
|
+
Your job: examine the attached file and extract ONLY what was requested.
|
|
645
|
+
|
|
646
|
+
CAPABILITIES:
|
|
647
|
+
- PDFs: extract text, structure, tables, data from specific sections
|
|
648
|
+
- Images: describe layouts, UI elements, text, diagrams, charts
|
|
649
|
+
- Diagrams: explain relationships, flows, architecture depicted
|
|
650
|
+
- Screenshots: analyze UI/UX, identify components, extract text
|
|
651
|
+
|
|
652
|
+
HOW YOU WORK:
|
|
653
|
+
1. Receive a file path and a goal describing what to extract
|
|
654
|
+
2. Read and analyze the file deeply using Gemini's vision capabilities
|
|
655
|
+
3. Return ONLY the relevant extracted information
|
|
656
|
+
4. The main agent never processes the raw file - you save context tokens
|
|
657
|
+
|
|
658
|
+
RESPONSE RULES:
|
|
659
|
+
- Return extracted information directly, no preamble
|
|
660
|
+
- If info not found, state clearly what's missing
|
|
661
|
+
- Be thorough on the goal, concise on everything else""",
|
|
555
662
|
}
|
|
556
|
-
|
|
663
|
+
|
|
557
664
|
system_prompt = system_prompts.get(agent_type, None)
|
|
558
|
-
|
|
665
|
+
|
|
666
|
+
# Override model and thinking_budget based on agent type for optimal performance
|
|
667
|
+
# Per project requirements:
|
|
668
|
+
# - Gemini tiers are controlled by thinking_budget, NOT model name
|
|
669
|
+
# - high: 32000, medium: 16000, low: 8000 thinking tokens
|
|
670
|
+
# - "gemini-3-pro-high" = gemini-3-pro-low + thinking_budget=32000
|
|
671
|
+
# - "gemini-3-flash" equivalent = gemini-3-pro-low + thinking_budget=0
|
|
672
|
+
agent_configs = {
|
|
673
|
+
"stravinsky": {"model": "claude-opus-4-5", "thinking_budget": 0},
|
|
674
|
+
"frontend": {"model": "gemini-3-pro-low", "thinking_budget": 32000}, # HIGH tier
|
|
675
|
+
"document_writer": {"model": "gemini-3-pro-low", "thinking_budget": 0}, # Flash equiv
|
|
676
|
+
"multimodal": {"model": "gemini-3-pro-low", "thinking_budget": 0}, # Flash equiv
|
|
677
|
+
"explore": {"model": "gemini-3-pro-low", "thinking_budget": 0}, # Flash equiv
|
|
678
|
+
"delphi": {"model": "gpt-5.2", "thinking_budget": 0}, # GPT-5.2 strategic
|
|
679
|
+
"dewey": {"model": "gemini-3-pro-low", "thinking_budget": 0}, # Flash equiv
|
|
680
|
+
}
|
|
681
|
+
config = agent_configs.get(agent_type, {"model": model, "thinking_budget": thinking_budget})
|
|
682
|
+
actual_model = config["model"]
|
|
683
|
+
actual_thinking_budget = config["thinking_budget"]
|
|
684
|
+
|
|
685
|
+
# Get token store for authentication
|
|
686
|
+
from ..auth.token_store import TokenStore
|
|
687
|
+
|
|
688
|
+
token_store = TokenStore()
|
|
689
|
+
|
|
559
690
|
task_id = manager.spawn(
|
|
691
|
+
token_store=token_store,
|
|
560
692
|
prompt=prompt,
|
|
561
693
|
agent_type=agent_type,
|
|
562
694
|
description=description or prompt[:50],
|
|
563
695
|
system_prompt=system_prompt,
|
|
564
|
-
model=
|
|
565
|
-
thinking_budget=
|
|
696
|
+
model=actual_model,
|
|
697
|
+
thinking_budget=actual_thinking_budget,
|
|
698
|
+
timeout=timeout,
|
|
566
699
|
)
|
|
567
|
-
|
|
700
|
+
|
|
568
701
|
return f"""🚀 Background agent spawned successfully.
|
|
569
702
|
|
|
570
703
|
**Task ID**: {task_id}
|
|
571
704
|
**Agent Type**: {agent_type}
|
|
572
|
-
**Model**: {
|
|
573
|
-
**Thinking Budget**: {
|
|
705
|
+
**Model**: {actual_model}
|
|
706
|
+
**Thinking Budget**: {actual_thinking_budget if actual_thinking_budget > 0 else "N/A"}
|
|
574
707
|
**Description**: {description or prompt[:50]}
|
|
575
708
|
|
|
576
709
|
The agent is now running. Use:
|
|
@@ -582,11 +715,11 @@ The agent is now running. Use:
|
|
|
582
715
|
async def agent_output(task_id: str, block: bool = False) -> str:
|
|
583
716
|
"""
|
|
584
717
|
Get output from a background agent task.
|
|
585
|
-
|
|
718
|
+
|
|
586
719
|
Args:
|
|
587
720
|
task_id: The task ID from agent_spawn
|
|
588
721
|
block: If True, wait for the task to complete (up to 30s)
|
|
589
|
-
|
|
722
|
+
|
|
590
723
|
Returns:
|
|
591
724
|
Task status and output
|
|
592
725
|
"""
|
|
@@ -594,19 +727,55 @@ async def agent_output(task_id: str, block: bool = False) -> str:
|
|
|
594
727
|
return manager.get_output(task_id, block=block)
|
|
595
728
|
|
|
596
729
|
|
|
730
|
+
async def agent_retry(
|
|
731
|
+
task_id: str,
|
|
732
|
+
new_prompt: Optional[str] = None,
|
|
733
|
+
new_timeout: Optional[int] = None,
|
|
734
|
+
) -> str:
|
|
735
|
+
"""
|
|
736
|
+
Retry a failed or timed-out background agent.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
task_id: The ID of the task to retry
|
|
740
|
+
new_prompt: Optional refined prompt for the retry
|
|
741
|
+
new_timeout: Optional new timeout in seconds
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
New Task ID and status
|
|
745
|
+
"""
|
|
746
|
+
manager = get_manager()
|
|
747
|
+
task = manager.get_task(task_id)
|
|
748
|
+
|
|
749
|
+
if not task:
|
|
750
|
+
return f"❌ Task {task_id} not found."
|
|
751
|
+
|
|
752
|
+
if task["status"] in ["running", "pending"]:
|
|
753
|
+
return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
|
|
754
|
+
|
|
755
|
+
prompt = new_prompt or task["prompt"]
|
|
756
|
+
timeout = new_timeout or task.get("timeout", 300)
|
|
757
|
+
|
|
758
|
+
return await agent_spawn(
|
|
759
|
+
prompt=prompt,
|
|
760
|
+
agent_type=task["agent_type"],
|
|
761
|
+
description=f"Retry of {task_id}: {task['description']}",
|
|
762
|
+
timeout=timeout,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
597
766
|
async def agent_cancel(task_id: str) -> str:
|
|
598
767
|
"""
|
|
599
768
|
Cancel a running background agent.
|
|
600
|
-
|
|
769
|
+
|
|
601
770
|
Args:
|
|
602
771
|
task_id: The task ID to cancel
|
|
603
|
-
|
|
772
|
+
|
|
604
773
|
Returns:
|
|
605
774
|
Cancellation result
|
|
606
775
|
"""
|
|
607
776
|
manager = get_manager()
|
|
608
777
|
success = manager.cancel(task_id)
|
|
609
|
-
|
|
778
|
+
|
|
610
779
|
if success:
|
|
611
780
|
return f"✅ Agent task {task_id} has been cancelled."
|
|
612
781
|
else:
|
|
@@ -620,18 +789,18 @@ async def agent_cancel(task_id: str) -> str:
|
|
|
620
789
|
async def agent_list() -> str:
|
|
621
790
|
"""
|
|
622
791
|
List all background agent tasks.
|
|
623
|
-
|
|
792
|
+
|
|
624
793
|
Returns:
|
|
625
794
|
Formatted list of tasks
|
|
626
795
|
"""
|
|
627
796
|
manager = get_manager()
|
|
628
797
|
tasks = manager.list_tasks()
|
|
629
|
-
|
|
798
|
+
|
|
630
799
|
if not tasks:
|
|
631
800
|
return "No background agent tasks found."
|
|
632
|
-
|
|
801
|
+
|
|
633
802
|
lines = ["**Background Agent Tasks**", ""]
|
|
634
|
-
|
|
803
|
+
|
|
635
804
|
for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
|
|
636
805
|
status_emoji = {
|
|
637
806
|
"pending": "⏳",
|
|
@@ -640,24 +809,24 @@ async def agent_list() -> str:
|
|
|
640
809
|
"failed": "❌",
|
|
641
810
|
"cancelled": "⚠️",
|
|
642
811
|
}.get(t["status"], "❓")
|
|
643
|
-
|
|
812
|
+
|
|
644
813
|
desc = t.get("description", t.get("prompt", "")[:40])
|
|
645
814
|
lines.append(f"- {status_emoji} [{t['id']}] {t['agent_type']}: {desc}")
|
|
646
|
-
|
|
815
|
+
|
|
647
816
|
return "\n".join(lines)
|
|
648
817
|
|
|
649
818
|
|
|
650
819
|
async def agent_progress(task_id: str, lines: int = 20) -> str:
|
|
651
820
|
"""
|
|
652
821
|
Get real-time progress from a running background agent.
|
|
653
|
-
|
|
822
|
+
|
|
654
823
|
Shows the most recent output lines from the agent, useful for
|
|
655
824
|
monitoring what the agent is currently doing.
|
|
656
|
-
|
|
825
|
+
|
|
657
826
|
Args:
|
|
658
827
|
task_id: The task ID from agent_spawn
|
|
659
828
|
lines: Number of recent output lines to show (default 20)
|
|
660
|
-
|
|
829
|
+
|
|
661
830
|
Returns:
|
|
662
831
|
Recent agent output and status
|
|
663
832
|
"""
|