stravinsky 0.2.7__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 -1
- mcp_bridge/auth/cli.py +84 -46
- mcp_bridge/auth/oauth.py +88 -63
- mcp_bridge/hooks/__init__.py +29 -8
- mcp_bridge/hooks/agent_reminder.py +61 -0
- mcp_bridge/hooks/auto_slash_command.py +186 -0
- mcp_bridge/hooks/comment_checker.py +136 -0
- mcp_bridge/hooks/context_monitor.py +58 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
- mcp_bridge/hooks/keyword_detector.py +122 -0
- mcp_bridge/hooks/manager.py +27 -8
- 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 +1 -1
- mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
- mcp_bridge/native_hooks/truncator.py +1 -1
- mcp_bridge/prompts/delphi.py +3 -2
- mcp_bridge/prompts/dewey.py +105 -21
- mcp_bridge/prompts/stravinsky.py +451 -127
- mcp_bridge/server.py +304 -38
- mcp_bridge/server_tools.py +21 -3
- mcp_bridge/tools/__init__.py +2 -1
- mcp_bridge/tools/agent_manager.py +307 -230
- mcp_bridge/tools/init.py +1 -1
- mcp_bridge/tools/model_invoke.py +534 -52
- mcp_bridge/tools/skill_loader.py +51 -47
- mcp_bridge/tools/task_runner.py +74 -30
- mcp_bridge/tools/templates.py +101 -12
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/METADATA +6 -12
- stravinsky-0.2.38.dist-info/RECORD +57 -0
- stravinsky-0.2.7.dist-info/RECORD +0 -47
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
- {stravinsky-0.2.7.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
|
|
@@ -43,6 +44,7 @@ class AgentTask:
|
|
|
43
44
|
@dataclass
|
|
44
45
|
class AgentProgress:
|
|
45
46
|
"""Progress tracking for a running agent."""
|
|
47
|
+
|
|
46
48
|
tool_calls: int = 0
|
|
47
49
|
last_tool: Optional[str] = None
|
|
48
50
|
last_message: Optional[str] = None
|
|
@@ -52,70 +54,76 @@ class AgentProgress:
|
|
|
52
54
|
class AgentManager:
|
|
53
55
|
"""
|
|
54
56
|
Manages background agent execution using Claude Code CLI.
|
|
55
|
-
|
|
57
|
+
|
|
56
58
|
Key features:
|
|
57
59
|
- Spawns agents with full tool access via `claude -p`
|
|
58
60
|
- Tracks task status and progress
|
|
59
61
|
- Persists state to .stravinsky/agents.json
|
|
60
62
|
- Provides notification mechanism for task completion
|
|
61
63
|
"""
|
|
62
|
-
|
|
64
|
+
|
|
63
65
|
CLAUDE_CLI = "/opt/homebrew/bin/claude"
|
|
64
|
-
|
|
66
|
+
|
|
65
67
|
def __init__(self, base_dir: Optional[str] = None):
|
|
66
68
|
if base_dir:
|
|
67
69
|
self.base_dir = Path(base_dir)
|
|
68
70
|
else:
|
|
69
71
|
self.base_dir = Path.cwd() / ".stravinsky"
|
|
70
|
-
|
|
72
|
+
|
|
71
73
|
self.agents_dir = self.base_dir / "agents"
|
|
72
74
|
self.state_file = self.base_dir / "agents.json"
|
|
73
|
-
|
|
75
|
+
|
|
74
76
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
75
77
|
self.agents_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
-
|
|
78
|
+
|
|
77
79
|
if not self.state_file.exists():
|
|
78
80
|
self._save_tasks({})
|
|
79
|
-
|
|
81
|
+
|
|
80
82
|
# In-memory tracking for running processes
|
|
81
83
|
self._processes: Dict[str, subprocess.Popen] = {}
|
|
82
84
|
self._notification_queue: Dict[str, List[AgentTask]] = {}
|
|
83
|
-
|
|
85
|
+
self._lock = threading.RLock()
|
|
86
|
+
|
|
84
87
|
def _load_tasks(self) -> Dict[str, Any]:
|
|
85
88
|
"""Load tasks from persistent storage."""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
|
|
92
98
|
def _save_tasks(self, tasks: Dict[str, Any]):
|
|
93
99
|
"""Save tasks to persistent storage."""
|
|
94
|
-
with
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
with self._lock:
|
|
101
|
+
with open(self.state_file, "w") as f:
|
|
102
|
+
json.dump(tasks, f, indent=2)
|
|
103
|
+
|
|
97
104
|
def _update_task(self, task_id: str, **kwargs):
|
|
98
105
|
"""Update a task's fields."""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
tasks
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
|
|
104
112
|
def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
|
105
113
|
"""Get a task by ID."""
|
|
106
114
|
tasks = self._load_tasks()
|
|
107
115
|
return tasks.get(task_id)
|
|
108
|
-
|
|
116
|
+
|
|
109
117
|
def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
110
118
|
"""List all tasks, optionally filtered by parent session."""
|
|
111
119
|
tasks = self._load_tasks()
|
|
112
120
|
task_list = list(tasks.values())
|
|
113
|
-
|
|
121
|
+
|
|
114
122
|
if parent_session_id:
|
|
115
123
|
task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
|
|
116
|
-
|
|
124
|
+
|
|
117
125
|
return task_list
|
|
118
|
-
|
|
126
|
+
|
|
119
127
|
def spawn(
|
|
120
128
|
self,
|
|
121
129
|
token_store: Any,
|
|
@@ -130,7 +138,7 @@ class AgentManager:
|
|
|
130
138
|
) -> str:
|
|
131
139
|
"""
|
|
132
140
|
Spawn a new background agent.
|
|
133
|
-
|
|
141
|
+
|
|
134
142
|
Args:
|
|
135
143
|
prompt: The task prompt for the agent
|
|
136
144
|
agent_type: Type of agent (explore, dewey, frontend, delphi)
|
|
@@ -139,12 +147,12 @@ class AgentManager:
|
|
|
139
147
|
system_prompt: Optional custom system prompt
|
|
140
148
|
model: Model to use (gemini-3-flash, claude, etc.)
|
|
141
149
|
timeout: Maximum execution time in seconds
|
|
142
|
-
|
|
150
|
+
|
|
143
151
|
Returns:
|
|
144
152
|
Task ID for tracking
|
|
145
153
|
"""
|
|
146
154
|
task_id = f"agent_{uuid.uuid4().hex[:8]}"
|
|
147
|
-
|
|
155
|
+
|
|
148
156
|
task = AgentTask(
|
|
149
157
|
id=task_id,
|
|
150
158
|
prompt=prompt,
|
|
@@ -155,17 +163,20 @@ class AgentManager:
|
|
|
155
163
|
parent_session_id=parent_session_id,
|
|
156
164
|
timeout=timeout,
|
|
157
165
|
)
|
|
158
|
-
|
|
166
|
+
|
|
159
167
|
# Persist task
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
with self._lock:
|
|
169
|
+
tasks = self._load_tasks()
|
|
170
|
+
tasks[task_id] = asdict(task)
|
|
171
|
+
self._save_tasks(tasks)
|
|
172
|
+
|
|
164
173
|
# Start background execution
|
|
165
|
-
self._execute_agent(
|
|
166
|
-
|
|
174
|
+
self._execute_agent(
|
|
175
|
+
task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
|
|
176
|
+
)
|
|
177
|
+
|
|
167
178
|
return task_id
|
|
168
|
-
|
|
179
|
+
|
|
169
180
|
def _execute_agent(
|
|
170
181
|
self,
|
|
171
182
|
task_id: str,
|
|
@@ -177,187 +188,158 @@ class AgentManager:
|
|
|
177
188
|
thinking_budget: int = 0,
|
|
178
189
|
timeout: int = 300,
|
|
179
190
|
):
|
|
180
|
-
"""Execute agent
|
|
181
|
-
|
|
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
|
+
|
|
182
197
|
def run_agent():
|
|
183
198
|
log_file = self.agents_dir / f"{task_id}.log"
|
|
184
199
|
output_file = self.agents_dir / f"{task_id}.out"
|
|
185
|
-
|
|
186
|
-
self._update_task(
|
|
187
|
-
|
|
188
|
-
status="running",
|
|
189
|
-
started_at=datetime.now().isoformat()
|
|
190
|
-
)
|
|
191
|
-
|
|
200
|
+
|
|
201
|
+
self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
|
|
202
|
+
|
|
192
203
|
try:
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
task_id,
|
|
225
|
-
status="failed",
|
|
226
|
-
error=f"Task timed out after {timeout} seconds",
|
|
227
|
-
completed_at=datetime.now().isoformat()
|
|
228
|
-
)
|
|
229
|
-
logger.error(f"[AgentManager] Gemini agent {task_id} timed out")
|
|
230
|
-
return
|
|
231
|
-
finally:
|
|
232
|
-
loop.close()
|
|
233
|
-
|
|
234
|
-
# Write output
|
|
235
|
-
with open(output_file, "w") as f:
|
|
236
|
-
f.write(result)
|
|
237
|
-
|
|
238
|
-
self._update_task(
|
|
239
|
-
task_id,
|
|
240
|
-
status="completed",
|
|
241
|
-
result=result,
|
|
242
|
-
completed_at=datetime.now().isoformat()
|
|
243
|
-
)
|
|
244
|
-
logger.info(f"[AgentManager] Gemini agent {task_id} completed successfully")
|
|
245
|
-
|
|
246
|
-
else:
|
|
247
|
-
# Use Claude CLI for Claude models
|
|
248
|
-
cmd = [
|
|
249
|
-
self.CLAUDE_CLI,
|
|
250
|
-
"-p", # Non-interactive print mode
|
|
251
|
-
]
|
|
252
|
-
|
|
253
|
-
# Add system prompt if provided
|
|
254
|
-
if system_prompt:
|
|
255
|
-
cmd.extend(["--system-prompt", system_prompt])
|
|
256
|
-
|
|
257
|
-
# Add the prompt
|
|
258
|
-
cmd.append(prompt)
|
|
259
|
-
|
|
260
|
-
logger.info(f"[AgentManager] Spawning Claude agent {task_id}: {' '.join(cmd[:5])}...")
|
|
261
|
-
|
|
262
|
-
# 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:
|
|
263
235
|
process = subprocess.Popen(
|
|
264
236
|
cmd,
|
|
265
|
-
stdout=
|
|
266
|
-
stderr=
|
|
237
|
+
stdout=subprocess.PIPE,
|
|
238
|
+
stderr=log_f,
|
|
239
|
+
text=True,
|
|
267
240
|
cwd=str(Path.cwd()),
|
|
268
|
-
|
|
241
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
|
|
242
|
+
start_new_session=True, # Allow process group management
|
|
269
243
|
)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
self._update_task(
|
|
284
|
-
task_id,
|
|
285
|
-
status="failed",
|
|
286
|
-
error=f"Task timed out after {timeout} seconds",
|
|
287
|
-
completed_at=datetime.now().isoformat()
|
|
288
|
-
)
|
|
289
|
-
logger.error(f"[AgentManager] Claude agent {task_id} timed out")
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
# Read output
|
|
293
|
-
result = ""
|
|
294
|
-
if output_file.exists():
|
|
295
|
-
result = output_file.read_text()
|
|
296
|
-
|
|
297
|
-
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)
|
|
298
256
|
self._update_task(
|
|
299
257
|
task_id,
|
|
300
258
|
status="completed",
|
|
301
259
|
result=result,
|
|
302
|
-
completed_at=datetime.now().isoformat()
|
|
260
|
+
completed_at=datetime.now().isoformat(),
|
|
303
261
|
)
|
|
304
262
|
logger.info(f"[AgentManager] Agent {task_id} completed successfully")
|
|
305
263
|
else:
|
|
306
|
-
|
|
264
|
+
error_msg = f"Claude CLI exited with code {process.returncode}"
|
|
307
265
|
if log_file.exists():
|
|
308
|
-
|
|
266
|
+
error_msg += f"\n{log_file.read_text()}"
|
|
309
267
|
self._update_task(
|
|
310
268
|
task_id,
|
|
311
269
|
status="failed",
|
|
312
|
-
error=
|
|
313
|
-
completed_at=datetime.now().isoformat()
|
|
270
|
+
error=error_msg,
|
|
271
|
+
completed_at=datetime.now().isoformat(),
|
|
314
272
|
)
|
|
315
|
-
logger.error(f"[AgentManager] Agent {task_id} failed: {
|
|
316
|
-
|
|
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
|
+
|
|
317
296
|
except Exception as e:
|
|
297
|
+
error_msg = str(e)
|
|
298
|
+
log_file.write_text(error_msg)
|
|
318
299
|
self._update_task(
|
|
319
300
|
task_id,
|
|
320
301
|
status="failed",
|
|
321
|
-
error=
|
|
322
|
-
completed_at=datetime.now().isoformat()
|
|
302
|
+
error=error_msg,
|
|
303
|
+
completed_at=datetime.now().isoformat(),
|
|
323
304
|
)
|
|
324
305
|
logger.exception(f"[AgentManager] Agent {task_id} exception")
|
|
306
|
+
|
|
325
307
|
finally:
|
|
326
308
|
self._processes.pop(task_id, None)
|
|
327
309
|
self._notify_completion(task_id)
|
|
328
|
-
|
|
310
|
+
|
|
329
311
|
# Run in background thread
|
|
330
312
|
thread = threading.Thread(target=run_agent, daemon=True)
|
|
331
313
|
thread.start()
|
|
332
|
-
|
|
314
|
+
|
|
333
315
|
def _notify_completion(self, task_id: str):
|
|
334
316
|
"""Queue notification for parent session."""
|
|
335
317
|
task = self.get_task(task_id)
|
|
336
318
|
if not task:
|
|
337
319
|
return
|
|
338
|
-
|
|
320
|
+
|
|
339
321
|
parent_id = task.get("parent_session_id")
|
|
340
322
|
if parent_id:
|
|
341
323
|
if parent_id not in self._notification_queue:
|
|
342
324
|
self._notification_queue[parent_id] = []
|
|
343
|
-
|
|
325
|
+
|
|
344
326
|
self._notification_queue[parent_id].append(task)
|
|
345
327
|
logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
|
|
346
|
-
|
|
328
|
+
|
|
347
329
|
def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
|
|
348
330
|
"""Get and clear pending notifications for a session."""
|
|
349
331
|
notifications = self._notification_queue.pop(session_id, [])
|
|
350
332
|
return notifications
|
|
351
|
-
|
|
333
|
+
|
|
352
334
|
def cancel(self, task_id: str) -> bool:
|
|
353
335
|
"""Cancel a running agent task."""
|
|
354
336
|
task = self.get_task(task_id)
|
|
355
337
|
if not task:
|
|
356
338
|
return False
|
|
357
|
-
|
|
339
|
+
|
|
358
340
|
if task["status"] != "running":
|
|
359
341
|
return False
|
|
360
|
-
|
|
342
|
+
|
|
361
343
|
process = self._processes.get(task_id)
|
|
362
344
|
if process:
|
|
363
345
|
try:
|
|
@@ -369,31 +351,56 @@ class AgentManager:
|
|
|
369
351
|
process.kill()
|
|
370
352
|
except:
|
|
371
353
|
pass
|
|
372
|
-
|
|
373
|
-
self._update_task(
|
|
374
|
-
|
|
375
|
-
status="cancelled",
|
|
376
|
-
completed_at=datetime.now().isoformat()
|
|
377
|
-
)
|
|
378
|
-
|
|
354
|
+
|
|
355
|
+
self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
|
|
356
|
+
|
|
379
357
|
return True
|
|
380
|
-
|
|
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
|
+
|
|
381
388
|
def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
|
|
382
389
|
"""
|
|
383
390
|
Get output from an agent task.
|
|
384
|
-
|
|
391
|
+
|
|
385
392
|
Args:
|
|
386
393
|
task_id: The task ID
|
|
387
394
|
block: If True, wait for completion
|
|
388
395
|
timeout: Max seconds to wait if blocking
|
|
389
|
-
|
|
396
|
+
|
|
390
397
|
Returns:
|
|
391
398
|
Formatted task output/status
|
|
392
399
|
"""
|
|
393
400
|
task = self.get_task(task_id)
|
|
394
401
|
if not task:
|
|
395
402
|
return f"Task {task_id} not found."
|
|
396
|
-
|
|
403
|
+
|
|
397
404
|
if block and task["status"] == "running":
|
|
398
405
|
# Poll for completion
|
|
399
406
|
start = datetime.now()
|
|
@@ -402,11 +409,11 @@ class AgentManager:
|
|
|
402
409
|
if task["status"] != "running":
|
|
403
410
|
break
|
|
404
411
|
asyncio.sleep(0.5)
|
|
405
|
-
|
|
412
|
+
|
|
406
413
|
status = task["status"]
|
|
407
414
|
description = task.get("description", "")
|
|
408
415
|
agent_type = task.get("agent_type", "unknown")
|
|
409
|
-
|
|
416
|
+
|
|
410
417
|
if status == "completed":
|
|
411
418
|
result = task.get("result", "(no output)")
|
|
412
419
|
return f"""✅ Agent Task Completed
|
|
@@ -417,7 +424,7 @@ class AgentManager:
|
|
|
417
424
|
|
|
418
425
|
**Result**:
|
|
419
426
|
{result}"""
|
|
420
|
-
|
|
427
|
+
|
|
421
428
|
elif status == "failed":
|
|
422
429
|
error = task.get("error", "(no error details)")
|
|
423
430
|
return f"""❌ Agent Task Failed
|
|
@@ -428,14 +435,14 @@ class AgentManager:
|
|
|
428
435
|
|
|
429
436
|
**Error**:
|
|
430
437
|
{error}"""
|
|
431
|
-
|
|
438
|
+
|
|
432
439
|
elif status == "cancelled":
|
|
433
440
|
return f"""⚠️ Agent Task Cancelled
|
|
434
441
|
|
|
435
442
|
**Task ID**: {task_id}
|
|
436
443
|
**Agent**: {agent_type}
|
|
437
444
|
**Description**: {description}"""
|
|
438
|
-
|
|
445
|
+
|
|
439
446
|
else: # pending or running
|
|
440
447
|
pid = task.get("pid", "N/A")
|
|
441
448
|
started = task.get("started_at", "N/A")
|
|
@@ -448,46 +455,47 @@ class AgentManager:
|
|
|
448
455
|
**Started**: {started}
|
|
449
456
|
|
|
450
457
|
Use `agent_output` with block=true to wait for completion."""
|
|
451
|
-
|
|
458
|
+
|
|
452
459
|
def get_progress(self, task_id: str, lines: int = 20) -> str:
|
|
453
460
|
"""
|
|
454
461
|
Get real-time progress from a running agent's output.
|
|
455
|
-
|
|
462
|
+
|
|
456
463
|
Args:
|
|
457
464
|
task_id: The task ID
|
|
458
465
|
lines: Number of lines to show from the end
|
|
459
|
-
|
|
466
|
+
|
|
460
467
|
Returns:
|
|
461
468
|
Recent output lines and status
|
|
462
469
|
"""
|
|
463
470
|
task = self.get_task(task_id)
|
|
464
471
|
if not task:
|
|
465
472
|
return f"Task {task_id} not found."
|
|
466
|
-
|
|
473
|
+
|
|
467
474
|
output_file = self.agents_dir / f"{task_id}.out"
|
|
468
475
|
log_file = self.agents_dir / f"{task_id}.log"
|
|
469
|
-
|
|
476
|
+
|
|
470
477
|
status = task["status"]
|
|
471
478
|
description = task.get("description", "")
|
|
472
479
|
agent_type = task.get("agent_type", "unknown")
|
|
473
480
|
pid = task.get("pid")
|
|
474
|
-
|
|
481
|
+
|
|
475
482
|
# Zombie Detection: If running but process is gone
|
|
476
483
|
if status == "running" and pid:
|
|
477
484
|
try:
|
|
478
485
|
import psutil
|
|
486
|
+
|
|
479
487
|
if not psutil.pid_exists(pid):
|
|
480
488
|
status = "failed"
|
|
481
489
|
self._update_task(
|
|
482
|
-
task_id,
|
|
483
|
-
status="failed",
|
|
490
|
+
task_id,
|
|
491
|
+
status="failed",
|
|
484
492
|
error="Agent process died unexpectedly (Zombie detected)",
|
|
485
|
-
completed_at=datetime.now().isoformat()
|
|
493
|
+
completed_at=datetime.now().isoformat(),
|
|
486
494
|
)
|
|
487
495
|
logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
|
|
488
496
|
except ImportError:
|
|
489
|
-
pass
|
|
490
|
-
|
|
497
|
+
pass
|
|
498
|
+
|
|
491
499
|
# Read recent output
|
|
492
500
|
output_content = ""
|
|
493
501
|
if output_file.exists():
|
|
@@ -499,7 +507,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
499
507
|
output_content = "\n".join(recent)
|
|
500
508
|
except Exception:
|
|
501
509
|
pass
|
|
502
|
-
|
|
510
|
+
|
|
503
511
|
# Check log for errors
|
|
504
512
|
log_content = ""
|
|
505
513
|
if log_file.exists():
|
|
@@ -507,7 +515,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
507
515
|
log_content = log_file.read_text().strip()
|
|
508
516
|
except Exception:
|
|
509
517
|
pass
|
|
510
|
-
|
|
518
|
+
|
|
511
519
|
# Status emoji
|
|
512
520
|
status_emoji = {
|
|
513
521
|
"pending": "⏳",
|
|
@@ -516,7 +524,7 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
516
524
|
"failed": "❌",
|
|
517
525
|
"cancelled": "⚠️",
|
|
518
526
|
}.get(status, "❓")
|
|
519
|
-
|
|
527
|
+
|
|
520
528
|
result = f"""{status_emoji} **Agent Progress**
|
|
521
529
|
|
|
522
530
|
**Task ID**: {task_id}
|
|
@@ -524,35 +532,40 @@ Use `agent_output` with block=true to wait for completion."""
|
|
|
524
532
|
**Description**: {description}
|
|
525
533
|
**Status**: {status}
|
|
526
534
|
"""
|
|
527
|
-
|
|
535
|
+
|
|
528
536
|
if output_content:
|
|
529
537
|
result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
|
|
530
538
|
elif status == "running":
|
|
531
539
|
result += "\n*Agent is working... no output yet.*"
|
|
532
|
-
|
|
540
|
+
|
|
533
541
|
if log_content and status == "failed":
|
|
534
542
|
# Truncate log if too long
|
|
535
543
|
if len(log_content) > 500:
|
|
536
544
|
log_content = log_content[:500] + "..."
|
|
537
545
|
result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
|
|
538
|
-
|
|
546
|
+
|
|
539
547
|
return result
|
|
540
548
|
|
|
541
549
|
|
|
542
550
|
# Global manager instance
|
|
543
551
|
_manager: Optional[AgentManager] = None
|
|
552
|
+
_manager_lock = threading.Lock()
|
|
544
553
|
|
|
545
554
|
|
|
546
555
|
def get_manager() -> AgentManager:
|
|
547
556
|
"""Get or create the global AgentManager instance."""
|
|
548
557
|
global _manager
|
|
549
558
|
if _manager is None:
|
|
550
|
-
|
|
559
|
+
with _manager_lock:
|
|
560
|
+
# Double-check pattern to avoid race condition
|
|
561
|
+
if _manager is None:
|
|
562
|
+
_manager = AgentManager()
|
|
551
563
|
return _manager
|
|
552
564
|
|
|
553
565
|
|
|
554
566
|
# Tool interface functions
|
|
555
567
|
|
|
568
|
+
|
|
556
569
|
async def agent_spawn(
|
|
557
570
|
prompt: str,
|
|
558
571
|
agent_type: str = "explore",
|
|
@@ -563,7 +576,7 @@ async def agent_spawn(
|
|
|
563
576
|
) -> str:
|
|
564
577
|
"""
|
|
565
578
|
Spawn a background agent.
|
|
566
|
-
|
|
579
|
+
|
|
567
580
|
Args:
|
|
568
581
|
prompt: The task for the agent to perform
|
|
569
582
|
agent_type: Type of agent (explore, dewey, frontend, delphi)
|
|
@@ -571,12 +584,12 @@ async def agent_spawn(
|
|
|
571
584
|
model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
|
|
572
585
|
thinking_budget: Reserved reasoning tokens
|
|
573
586
|
timeout: Execution timeout in seconds
|
|
574
|
-
|
|
587
|
+
|
|
575
588
|
Returns:
|
|
576
589
|
Task ID and instructions
|
|
577
590
|
"""
|
|
578
591
|
manager = get_manager()
|
|
579
|
-
|
|
592
|
+
|
|
580
593
|
# Map agent types to system prompts
|
|
581
594
|
system_prompts = {
|
|
582
595
|
"explore": "You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions efficiently.",
|
|
@@ -607,26 +620,90 @@ ULTRATHINK MODE (when user says "ULTRATHINK" or "think harder"):
|
|
|
607
620
|
2. Edge Case Analysis: What could go wrong and how we prevented it
|
|
608
621
|
3. The Code: Optimized, bespoke, production-ready, utilizing existing libraries""",
|
|
609
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""",
|
|
610
662
|
}
|
|
611
|
-
|
|
663
|
+
|
|
612
664
|
system_prompt = system_prompts.get(agent_type, None)
|
|
613
|
-
|
|
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
|
+
|
|
614
690
|
task_id = manager.spawn(
|
|
691
|
+
token_store=token_store,
|
|
615
692
|
prompt=prompt,
|
|
616
693
|
agent_type=agent_type,
|
|
617
694
|
description=description or prompt[:50],
|
|
618
695
|
system_prompt=system_prompt,
|
|
619
|
-
model=
|
|
620
|
-
thinking_budget=
|
|
696
|
+
model=actual_model,
|
|
697
|
+
thinking_budget=actual_thinking_budget,
|
|
621
698
|
timeout=timeout,
|
|
622
699
|
)
|
|
623
|
-
|
|
700
|
+
|
|
624
701
|
return f"""🚀 Background agent spawned successfully.
|
|
625
702
|
|
|
626
703
|
**Task ID**: {task_id}
|
|
627
704
|
**Agent Type**: {agent_type}
|
|
628
|
-
**Model**: {
|
|
629
|
-
**Thinking Budget**: {
|
|
705
|
+
**Model**: {actual_model}
|
|
706
|
+
**Thinking Budget**: {actual_thinking_budget if actual_thinking_budget > 0 else "N/A"}
|
|
630
707
|
**Description**: {description or prompt[:50]}
|
|
631
708
|
|
|
632
709
|
The agent is now running. Use:
|
|
@@ -638,11 +715,11 @@ The agent is now running. Use:
|
|
|
638
715
|
async def agent_output(task_id: str, block: bool = False) -> str:
|
|
639
716
|
"""
|
|
640
717
|
Get output from a background agent task.
|
|
641
|
-
|
|
718
|
+
|
|
642
719
|
Args:
|
|
643
720
|
task_id: The task ID from agent_spawn
|
|
644
721
|
block: If True, wait for the task to complete (up to 30s)
|
|
645
|
-
|
|
722
|
+
|
|
646
723
|
Returns:
|
|
647
724
|
Task status and output
|
|
648
725
|
"""
|
|
@@ -657,48 +734,48 @@ async def agent_retry(
|
|
|
657
734
|
) -> str:
|
|
658
735
|
"""
|
|
659
736
|
Retry a failed or timed-out background agent.
|
|
660
|
-
|
|
737
|
+
|
|
661
738
|
Args:
|
|
662
739
|
task_id: The ID of the task to retry
|
|
663
740
|
new_prompt: Optional refined prompt for the retry
|
|
664
741
|
new_timeout: Optional new timeout in seconds
|
|
665
|
-
|
|
742
|
+
|
|
666
743
|
Returns:
|
|
667
744
|
New Task ID and status
|
|
668
745
|
"""
|
|
669
746
|
manager = get_manager()
|
|
670
747
|
task = manager.get_task(task_id)
|
|
671
|
-
|
|
748
|
+
|
|
672
749
|
if not task:
|
|
673
750
|
return f"❌ Task {task_id} not found."
|
|
674
|
-
|
|
751
|
+
|
|
675
752
|
if task["status"] in ["running", "pending"]:
|
|
676
753
|
return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
|
|
677
|
-
|
|
754
|
+
|
|
678
755
|
prompt = new_prompt or task["prompt"]
|
|
679
756
|
timeout = new_timeout or task.get("timeout", 300)
|
|
680
|
-
|
|
757
|
+
|
|
681
758
|
return await agent_spawn(
|
|
682
759
|
prompt=prompt,
|
|
683
760
|
agent_type=task["agent_type"],
|
|
684
761
|
description=f"Retry of {task_id}: {task['description']}",
|
|
685
|
-
timeout=timeout
|
|
762
|
+
timeout=timeout,
|
|
686
763
|
)
|
|
687
764
|
|
|
688
765
|
|
|
689
766
|
async def agent_cancel(task_id: str) -> str:
|
|
690
767
|
"""
|
|
691
768
|
Cancel a running background agent.
|
|
692
|
-
|
|
769
|
+
|
|
693
770
|
Args:
|
|
694
771
|
task_id: The task ID to cancel
|
|
695
|
-
|
|
772
|
+
|
|
696
773
|
Returns:
|
|
697
774
|
Cancellation result
|
|
698
775
|
"""
|
|
699
776
|
manager = get_manager()
|
|
700
777
|
success = manager.cancel(task_id)
|
|
701
|
-
|
|
778
|
+
|
|
702
779
|
if success:
|
|
703
780
|
return f"✅ Agent task {task_id} has been cancelled."
|
|
704
781
|
else:
|
|
@@ -712,18 +789,18 @@ async def agent_cancel(task_id: str) -> str:
|
|
|
712
789
|
async def agent_list() -> str:
|
|
713
790
|
"""
|
|
714
791
|
List all background agent tasks.
|
|
715
|
-
|
|
792
|
+
|
|
716
793
|
Returns:
|
|
717
794
|
Formatted list of tasks
|
|
718
795
|
"""
|
|
719
796
|
manager = get_manager()
|
|
720
797
|
tasks = manager.list_tasks()
|
|
721
|
-
|
|
798
|
+
|
|
722
799
|
if not tasks:
|
|
723
800
|
return "No background agent tasks found."
|
|
724
|
-
|
|
801
|
+
|
|
725
802
|
lines = ["**Background Agent Tasks**", ""]
|
|
726
|
-
|
|
803
|
+
|
|
727
804
|
for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
|
|
728
805
|
status_emoji = {
|
|
729
806
|
"pending": "⏳",
|
|
@@ -732,24 +809,24 @@ async def agent_list() -> str:
|
|
|
732
809
|
"failed": "❌",
|
|
733
810
|
"cancelled": "⚠️",
|
|
734
811
|
}.get(t["status"], "❓")
|
|
735
|
-
|
|
812
|
+
|
|
736
813
|
desc = t.get("description", t.get("prompt", "")[:40])
|
|
737
814
|
lines.append(f"- {status_emoji} [{t['id']}] {t['agent_type']}: {desc}")
|
|
738
|
-
|
|
815
|
+
|
|
739
816
|
return "\n".join(lines)
|
|
740
817
|
|
|
741
818
|
|
|
742
819
|
async def agent_progress(task_id: str, lines: int = 20) -> str:
|
|
743
820
|
"""
|
|
744
821
|
Get real-time progress from a running background agent.
|
|
745
|
-
|
|
822
|
+
|
|
746
823
|
Shows the most recent output lines from the agent, useful for
|
|
747
824
|
monitoring what the agent is currently doing.
|
|
748
|
-
|
|
825
|
+
|
|
749
826
|
Args:
|
|
750
827
|
task_id: The task ID from agent_spawn
|
|
751
828
|
lines: Number of recent output lines to show (default 20)
|
|
752
|
-
|
|
829
|
+
|
|
753
830
|
Returns:
|
|
754
831
|
Recent agent output and status
|
|
755
832
|
"""
|