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.

Files changed (34) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/cli.py +84 -46
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +29 -8
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/comment_checker.py +136 -0
  8. mcp_bridge/hooks/context_monitor.py +58 -0
  9. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  10. mcp_bridge/hooks/keyword_detector.py +122 -0
  11. mcp_bridge/hooks/manager.py +27 -8
  12. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  13. mcp_bridge/hooks/session_recovery.py +186 -0
  14. mcp_bridge/hooks/todo_enforcer.py +75 -0
  15. mcp_bridge/hooks/truncator.py +1 -1
  16. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  17. mcp_bridge/native_hooks/truncator.py +1 -1
  18. mcp_bridge/prompts/delphi.py +3 -2
  19. mcp_bridge/prompts/dewey.py +105 -21
  20. mcp_bridge/prompts/stravinsky.py +451 -127
  21. mcp_bridge/server.py +304 -38
  22. mcp_bridge/server_tools.py +21 -3
  23. mcp_bridge/tools/__init__.py +2 -1
  24. mcp_bridge/tools/agent_manager.py +307 -230
  25. mcp_bridge/tools/init.py +1 -1
  26. mcp_bridge/tools/model_invoke.py +534 -52
  27. mcp_bridge/tools/skill_loader.py +51 -47
  28. mcp_bridge/tools/task_runner.py +74 -30
  29. mcp_bridge/tools/templates.py +101 -12
  30. {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/METADATA +6 -12
  31. stravinsky-0.2.38.dist-info/RECORD +57 -0
  32. stravinsky-0.2.7.dist-info/RECORD +0 -47
  33. {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
  34. {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, 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
@@ -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
- try:
87
- with open(self.state_file, "r") as f:
88
- return json.load(f)
89
- except (json.JSONDecodeError, FileNotFoundError):
90
- return {}
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 open(self.state_file, "w") as f:
95
- json.dump(tasks, f, indent=2)
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
- tasks = self._load_tasks()
100
- if task_id in tasks:
101
- tasks[task_id].update(kwargs)
102
- self._save_tasks(tasks)
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
- tasks = self._load_tasks()
161
- tasks[task_id] = asdict(task)
162
- self._save_tasks(tasks)
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(task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout)
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 in background thread."""
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
- task_id,
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
- # Route based on model type
194
- if model.startswith("gemini"):
195
- # Use Gemini via invoke_gemini
196
- logger.info(f"[AgentManager] Spawning Gemini agent {task_id} with model {model}")
197
-
198
- # Build full prompt with system context
199
- full_prompt = prompt
200
- if system_prompt:
201
- full_prompt = f"{system_prompt}\n\n---\n\n{full_prompt}"
202
-
203
- # Import and call invoke_gemini
204
- import asyncio
205
- from .model_invoke import invoke_gemini
206
-
207
- # Run async in thread
208
- loop = asyncio.new_event_loop()
209
- asyncio.set_event_loop(loop)
210
- try:
211
- result = loop.run_until_complete(
212
- asyncio.wait_for(
213
- invoke_gemini(
214
- token_store=token_store,
215
- prompt=full_prompt,
216
- model=model,
217
- thinking_budget=thinking_budget
218
- ),
219
- timeout=timeout
220
- )
221
- )
222
- except asyncio.TimeoutError:
223
- self._update_task(
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=open(output_file, "w"),
266
- stderr=open(log_file, "w"),
237
+ stdout=subprocess.PIPE,
238
+ stderr=log_f,
239
+ text=True,
267
240
  cwd=str(Path.cwd()),
268
- start_new_session=True,
241
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
242
+ start_new_session=True, # Allow process group management
269
243
  )
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:
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
- error_log = ""
264
+ error_msg = f"Claude CLI exited with code {process.returncode}"
307
265
  if log_file.exists():
308
- error_log = log_file.read_text()
266
+ error_msg += f"\n{log_file.read_text()}"
309
267
  self._update_task(
310
268
  task_id,
311
269
  status="failed",
312
- error=f"Exit code {return_code}: {error_log}",
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: {error_log[:200]}")
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=str(e),
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
- task_id,
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
- _manager = AgentManager()
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=model,
620
- thinking_budget=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**: {model}
629
- **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"}
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
  """