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