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.

Files changed (42) hide show
  1. mcp_bridge/__init__.py +1 -5
  2. mcp_bridge/auth/cli.py +89 -44
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +49 -0
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/budget_optimizer.py +38 -0
  8. mcp_bridge/hooks/comment_checker.py +136 -0
  9. mcp_bridge/hooks/compaction.py +32 -0
  10. mcp_bridge/hooks/context_monitor.py +58 -0
  11. mcp_bridge/hooks/directory_context.py +40 -0
  12. mcp_bridge/hooks/edit_recovery.py +41 -0
  13. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  14. mcp_bridge/hooks/keyword_detector.py +122 -0
  15. mcp_bridge/hooks/manager.py +96 -0
  16. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  17. mcp_bridge/hooks/session_recovery.py +186 -0
  18. mcp_bridge/hooks/todo_enforcer.py +75 -0
  19. mcp_bridge/hooks/truncator.py +19 -0
  20. mcp_bridge/native_hooks/context.py +38 -0
  21. mcp_bridge/native_hooks/edit_recovery.py +46 -0
  22. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  23. mcp_bridge/native_hooks/truncator.py +23 -0
  24. mcp_bridge/prompts/delphi.py +3 -2
  25. mcp_bridge/prompts/dewey.py +105 -21
  26. mcp_bridge/prompts/stravinsky.py +452 -118
  27. mcp_bridge/server.py +491 -668
  28. mcp_bridge/server_tools.py +547 -0
  29. mcp_bridge/tools/__init__.py +13 -3
  30. mcp_bridge/tools/agent_manager.py +359 -190
  31. mcp_bridge/tools/continuous_loop.py +67 -0
  32. mcp_bridge/tools/init.py +50 -0
  33. mcp_bridge/tools/lsp/tools.py +15 -15
  34. mcp_bridge/tools/model_invoke.py +594 -48
  35. mcp_bridge/tools/skill_loader.py +51 -47
  36. mcp_bridge/tools/task_runner.py +141 -0
  37. mcp_bridge/tools/templates.py +175 -0
  38. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/METADATA +55 -10
  39. stravinsky-0.2.38.dist-info/RECORD +57 -0
  40. stravinsky-0.1.2.dist-info/RECORD +0 -32
  41. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
  42. {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, librarian, frontend, etc.
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
- try:
86
- with open(self.state_file, "r") as f:
87
- return json.load(f)
88
- except (json.JSONDecodeError, FileNotFoundError):
89
- return {}
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 open(self.state_file, "w") as f:
94
- json.dump(tasks, f, indent=2)
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
- tasks = self._load_tasks()
99
- if task_id in tasks:
100
- tasks[task_id].update(kwargs)
101
- self._save_tasks(tasks)
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
- tasks = self._load_tasks()
156
- tasks[task_id] = asdict(task)
157
- self._save_tasks(tasks)
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(task_id, prompt, agent_type, system_prompt, model, thinking_budget)
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 in background thread."""
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
- task_id,
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
- # Route based on model type
187
- if model.startswith("gemini"):
188
- # Use Gemini via invoke_gemini
189
- logger.info(f"[AgentManager] Spawning Gemini agent {task_id} with model {model}")
190
-
191
- # Build full prompt with system context
192
- full_prompt = prompt
193
- if system_prompt:
194
- full_prompt = f"{system_prompt}\n\n---\n\n{full_prompt}"
195
-
196
- # Import and call invoke_gemini
197
- import asyncio
198
- from .model_invoke import invoke_gemini
199
-
200
- # Run async in thread
201
- loop = asyncio.new_event_loop()
202
- asyncio.set_event_loop(loop)
203
- try:
204
- result = loop.run_until_complete(
205
- invoke_gemini(
206
- prompt=full_prompt,
207
- model=model,
208
- thinking_budget=thinking_budget
209
- )
210
- )
211
- finally:
212
- loop.close()
213
-
214
- # Write output
215
- with open(output_file, "w") as f:
216
- f.write(result)
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=open(output_file, "w"),
246
- stderr=open(log_file, "w"),
237
+ stdout=subprocess.PIPE,
238
+ stderr=log_f,
239
+ text=True,
247
240
  cwd=str(Path.cwd()),
248
- start_new_session=True,
241
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
242
+ start_new_session=True, # Allow process group management
249
243
  )
250
-
251
- self._processes[task_id] = process
252
- self._update_task(task_id, pid=process.pid)
253
-
254
- # Wait for completion
255
- return_code = process.wait()
256
-
257
- # Read output
258
- result = ""
259
- if output_file.exists():
260
- result = output_file.read_text()
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
- error_log = ""
264
+ error_msg = f"Claude CLI exited with code {process.returncode}"
272
265
  if log_file.exists():
273
- error_log = log_file.read_text()
266
+ error_msg += f"\n{log_file.read_text()}"
274
267
  self._update_task(
275
268
  task_id,
276
269
  status="failed",
277
- error=f"Exit code {return_code}: {error_log}",
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: {error_log[:200]}")
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=str(e),
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
- task_id,
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
- _manager = AgentManager()
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=model,
565
- thinking_budget=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**: {model}
573
- **Thinking Budget**: {thinking_budget if thinking_budget > 0 else "N/A"}
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
  """