stravinsky 0.1.2__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.

@@ -0,0 +1,28 @@
1
+ # Tools module
2
+ from .model_invoke import invoke_gemini, invoke_openai
3
+ from .code_search import lsp_diagnostics, ast_grep_search, grep_search, glob_files
4
+ from .session_manager import list_sessions, read_session, search_sessions, get_session_info
5
+ from .skill_loader import list_skills, get_skill, create_skill
6
+ from .agent_manager import agent_spawn, agent_output, agent_cancel, agent_list, agent_progress
7
+
8
+ __all__ = [
9
+ "invoke_gemini",
10
+ "invoke_openai",
11
+ "lsp_diagnostics",
12
+ "ast_grep_search",
13
+ "grep_search",
14
+ "glob_files",
15
+ "list_sessions",
16
+ "read_session",
17
+ "search_sessions",
18
+ "get_session_info",
19
+ "list_skills",
20
+ "get_skill",
21
+ "create_skill",
22
+ "agent_spawn",
23
+ "agent_output",
24
+ "agent_cancel",
25
+ "agent_list",
26
+ "agent_progress",
27
+ ]
28
+
@@ -0,0 +1,665 @@
1
+ """
2
+ Agent Manager for Stravinsky.
3
+
4
+ Spawns background agents using Claude Code CLI with full tool access.
5
+ This replaces the simple model-only invocation with true agentic execution.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import signal
13
+ import uuid
14
+ from dataclasses import asdict, dataclass, field
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+ import threading
19
+ import logging
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class AgentTask:
26
+ """Represents a background agent task with full tool access."""
27
+ id: str
28
+ prompt: str
29
+ agent_type: str # explore, librarian, frontend, etc.
30
+ description: str
31
+ status: str # pending, running, completed, failed, cancelled
32
+ created_at: str
33
+ parent_session_id: Optional[str] = None
34
+ started_at: Optional[str] = None
35
+ completed_at: Optional[str] = None
36
+ result: Optional[str] = None
37
+ error: Optional[str] = None
38
+ pid: Optional[int] = None
39
+ progress: Optional[Dict[str, Any]] = None # tool calls, last update
40
+
41
+
42
+ @dataclass
43
+ class AgentProgress:
44
+ """Progress tracking for a running agent."""
45
+ tool_calls: int = 0
46
+ last_tool: Optional[str] = None
47
+ last_message: Optional[str] = None
48
+ last_update: Optional[str] = None
49
+
50
+
51
+ class AgentManager:
52
+ """
53
+ Manages background agent execution using Claude Code CLI.
54
+
55
+ Key features:
56
+ - Spawns agents with full tool access via `claude -p`
57
+ - Tracks task status and progress
58
+ - Persists state to .stravinsky/agents.json
59
+ - Provides notification mechanism for task completion
60
+ """
61
+
62
+ CLAUDE_CLI = "/opt/homebrew/bin/claude"
63
+
64
+ def __init__(self, base_dir: Optional[str] = None):
65
+ if base_dir:
66
+ self.base_dir = Path(base_dir)
67
+ else:
68
+ self.base_dir = Path.cwd() / ".stravinsky"
69
+
70
+ self.agents_dir = self.base_dir / "agents"
71
+ self.state_file = self.base_dir / "agents.json"
72
+
73
+ self.base_dir.mkdir(parents=True, exist_ok=True)
74
+ self.agents_dir.mkdir(parents=True, exist_ok=True)
75
+
76
+ if not self.state_file.exists():
77
+ self._save_tasks({})
78
+
79
+ # In-memory tracking for running processes
80
+ self._processes: Dict[str, subprocess.Popen] = {}
81
+ self._notification_queue: Dict[str, List[AgentTask]] = {}
82
+
83
+ def _load_tasks(self) -> Dict[str, Any]:
84
+ """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
+
91
+ def _save_tasks(self, tasks: Dict[str, Any]):
92
+ """Save tasks to persistent storage."""
93
+ with open(self.state_file, "w") as f:
94
+ json.dump(tasks, f, indent=2)
95
+
96
+ def _update_task(self, task_id: str, **kwargs):
97
+ """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
+
103
+ def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
104
+ """Get a task by ID."""
105
+ tasks = self._load_tasks()
106
+ return tasks.get(task_id)
107
+
108
+ def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
109
+ """List all tasks, optionally filtered by parent session."""
110
+ tasks = self._load_tasks()
111
+ task_list = list(tasks.values())
112
+
113
+ if parent_session_id:
114
+ task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
115
+
116
+ return task_list
117
+
118
+ def spawn(
119
+ self,
120
+ prompt: str,
121
+ agent_type: str = "explore",
122
+ description: str = "",
123
+ parent_session_id: Optional[str] = None,
124
+ system_prompt: Optional[str] = None,
125
+ model: str = "gemini-3-flash",
126
+ thinking_budget: int = 0,
127
+ ) -> str:
128
+ """
129
+ Spawn a new background agent.
130
+
131
+ Args:
132
+ prompt: The task prompt for the agent
133
+ agent_type: Type of agent (explore, dewey, frontend, delphi)
134
+ description: Short description for status display
135
+ parent_session_id: Optional parent session for notifications
136
+ system_prompt: Optional custom system prompt
137
+ model: Model to use (gemini-3-flash, claude, etc.)
138
+
139
+ Returns:
140
+ Task ID for tracking
141
+ """
142
+ task_id = f"agent_{uuid.uuid4().hex[:8]}"
143
+
144
+ task = AgentTask(
145
+ id=task_id,
146
+ prompt=prompt,
147
+ agent_type=agent_type,
148
+ description=description or prompt[:50],
149
+ status="pending",
150
+ created_at=datetime.now().isoformat(),
151
+ parent_session_id=parent_session_id,
152
+ )
153
+
154
+ # Persist task
155
+ tasks = self._load_tasks()
156
+ tasks[task_id] = asdict(task)
157
+ self._save_tasks(tasks)
158
+
159
+ # Start background execution
160
+ self._execute_agent(task_id, prompt, agent_type, system_prompt, model, thinking_budget)
161
+
162
+ return task_id
163
+
164
+ def _execute_agent(
165
+ self,
166
+ task_id: str,
167
+ prompt: str,
168
+ agent_type: str,
169
+ system_prompt: Optional[str] = None,
170
+ model: str = "gemini-3-flash",
171
+ thinking_budget: int = 0,
172
+ ):
173
+ """Execute agent in background thread."""
174
+
175
+ def run_agent():
176
+ log_file = self.agents_dir / f"{task_id}.log"
177
+ 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
+
185
+ 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
243
+ process = subprocess.Popen(
244
+ cmd,
245
+ stdout=open(output_file, "w"),
246
+ stderr=open(log_file, "w"),
247
+ cwd=str(Path.cwd()),
248
+ start_new_session=True,
249
+ )
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:
263
+ self._update_task(
264
+ task_id,
265
+ status="completed",
266
+ result=result,
267
+ completed_at=datetime.now().isoformat()
268
+ )
269
+ logger.info(f"[AgentManager] Agent {task_id} completed successfully")
270
+ else:
271
+ error_log = ""
272
+ if log_file.exists():
273
+ error_log = log_file.read_text()
274
+ self._update_task(
275
+ task_id,
276
+ status="failed",
277
+ error=f"Exit code {return_code}: {error_log}",
278
+ completed_at=datetime.now().isoformat()
279
+ )
280
+ logger.error(f"[AgentManager] Agent {task_id} failed: {error_log[:200]}")
281
+
282
+ except Exception as e:
283
+ self._update_task(
284
+ task_id,
285
+ status="failed",
286
+ error=str(e),
287
+ completed_at=datetime.now().isoformat()
288
+ )
289
+ logger.exception(f"[AgentManager] Agent {task_id} exception")
290
+ finally:
291
+ self._processes.pop(task_id, None)
292
+ self._notify_completion(task_id)
293
+
294
+ # Run in background thread
295
+ thread = threading.Thread(target=run_agent, daemon=True)
296
+ thread.start()
297
+
298
+ def _notify_completion(self, task_id: str):
299
+ """Queue notification for parent session."""
300
+ task = self.get_task(task_id)
301
+ if not task:
302
+ return
303
+
304
+ parent_id = task.get("parent_session_id")
305
+ if parent_id:
306
+ if parent_id not in self._notification_queue:
307
+ self._notification_queue[parent_id] = []
308
+
309
+ self._notification_queue[parent_id].append(task)
310
+ logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
311
+
312
+ def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
313
+ """Get and clear pending notifications for a session."""
314
+ notifications = self._notification_queue.pop(session_id, [])
315
+ return notifications
316
+
317
+ def cancel(self, task_id: str) -> bool:
318
+ """Cancel a running agent task."""
319
+ task = self.get_task(task_id)
320
+ if not task:
321
+ return False
322
+
323
+ if task["status"] != "running":
324
+ return False
325
+
326
+ process = self._processes.get(task_id)
327
+ if process:
328
+ try:
329
+ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
330
+ process.wait(timeout=5)
331
+ except Exception as e:
332
+ logger.warning(f"[AgentManager] Failed to kill process for {task_id}: {e}")
333
+ try:
334
+ process.kill()
335
+ except:
336
+ pass
337
+
338
+ self._update_task(
339
+ task_id,
340
+ status="cancelled",
341
+ completed_at=datetime.now().isoformat()
342
+ )
343
+
344
+ return True
345
+
346
+ def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
347
+ """
348
+ Get output from an agent task.
349
+
350
+ Args:
351
+ task_id: The task ID
352
+ block: If True, wait for completion
353
+ timeout: Max seconds to wait if blocking
354
+
355
+ Returns:
356
+ Formatted task output/status
357
+ """
358
+ task = self.get_task(task_id)
359
+ if not task:
360
+ return f"Task {task_id} not found."
361
+
362
+ if block and task["status"] == "running":
363
+ # Poll for completion
364
+ start = datetime.now()
365
+ while (datetime.now() - start).total_seconds() < timeout:
366
+ task = self.get_task(task_id)
367
+ if task["status"] != "running":
368
+ break
369
+ asyncio.sleep(0.5)
370
+
371
+ status = task["status"]
372
+ description = task.get("description", "")
373
+ agent_type = task.get("agent_type", "unknown")
374
+
375
+ if status == "completed":
376
+ result = task.get("result", "(no output)")
377
+ return f"""✅ Agent Task Completed
378
+
379
+ **Task ID**: {task_id}
380
+ **Agent**: {agent_type}
381
+ **Description**: {description}
382
+
383
+ **Result**:
384
+ {result}"""
385
+
386
+ elif status == "failed":
387
+ error = task.get("error", "(no error details)")
388
+ return f"""❌ Agent Task Failed
389
+
390
+ **Task ID**: {task_id}
391
+ **Agent**: {agent_type}
392
+ **Description**: {description}
393
+
394
+ **Error**:
395
+ {error}"""
396
+
397
+ elif status == "cancelled":
398
+ return f"""⚠️ Agent Task Cancelled
399
+
400
+ **Task ID**: {task_id}
401
+ **Agent**: {agent_type}
402
+ **Description**: {description}"""
403
+
404
+ else: # pending or running
405
+ pid = task.get("pid", "N/A")
406
+ started = task.get("started_at", "N/A")
407
+ return f"""⏳ Agent Task Running
408
+
409
+ **Task ID**: {task_id}
410
+ **Agent**: {agent_type}
411
+ **Description**: {description}
412
+ **PID**: {pid}
413
+ **Started**: {started}
414
+
415
+ Use `agent_output` with block=true to wait for completion."""
416
+
417
+ def get_progress(self, task_id: str, lines: int = 20) -> str:
418
+ """
419
+ Get real-time progress from a running agent's output.
420
+
421
+ Args:
422
+ task_id: The task ID
423
+ lines: Number of lines to show from the end
424
+
425
+ Returns:
426
+ Recent output lines and status
427
+ """
428
+ task = self.get_task(task_id)
429
+ if not task:
430
+ return f"Task {task_id} not found."
431
+
432
+ output_file = self.agents_dir / f"{task_id}.out"
433
+ log_file = self.agents_dir / f"{task_id}.log"
434
+
435
+ status = task["status"]
436
+ description = task.get("description", "")
437
+ agent_type = task.get("agent_type", "unknown")
438
+
439
+ # Read recent output
440
+ output_content = ""
441
+ if output_file.exists():
442
+ try:
443
+ full_content = output_file.read_text()
444
+ if full_content:
445
+ output_lines = full_content.strip().split("\n")
446
+ recent = output_lines[-lines:] if len(output_lines) > lines else output_lines
447
+ output_content = "\n".join(recent)
448
+ except Exception:
449
+ pass
450
+
451
+ # Check log for errors
452
+ log_content = ""
453
+ if log_file.exists():
454
+ try:
455
+ log_content = log_file.read_text().strip()
456
+ except Exception:
457
+ pass
458
+
459
+ # Status emoji
460
+ status_emoji = {
461
+ "pending": "⏳",
462
+ "running": "🔄",
463
+ "completed": "✅",
464
+ "failed": "❌",
465
+ "cancelled": "⚠️",
466
+ }.get(status, "❓")
467
+
468
+ result = f"""{status_emoji} **Agent Progress**
469
+
470
+ **Task ID**: {task_id}
471
+ **Agent**: {agent_type}
472
+ **Description**: {description}
473
+ **Status**: {status}
474
+ """
475
+
476
+ if output_content:
477
+ result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
478
+ elif status == "running":
479
+ result += "\n*Agent is working... no output yet.*"
480
+
481
+ if log_content and status == "failed":
482
+ # Truncate log if too long
483
+ if len(log_content) > 500:
484
+ log_content = log_content[:500] + "..."
485
+ result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
486
+
487
+ return result
488
+
489
+
490
+ # Global manager instance
491
+ _manager: Optional[AgentManager] = None
492
+
493
+
494
+ def get_manager() -> AgentManager:
495
+ """Get or create the global AgentManager instance."""
496
+ global _manager
497
+ if _manager is None:
498
+ _manager = AgentManager()
499
+ return _manager
500
+
501
+
502
+ # Tool interface functions
503
+
504
+ async def agent_spawn(
505
+ prompt: str,
506
+ agent_type: str = "explore",
507
+ description: str = "",
508
+ model: str = "gemini-3-flash",
509
+ thinking_budget: int = 0,
510
+ ) -> str:
511
+ """
512
+ Spawn a background agent.
513
+
514
+ Args:
515
+ prompt: The task for the agent to perform
516
+ agent_type: Type of agent (explore, dewey, frontend, delphi)
517
+ description: Short description shown in status
518
+ model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
519
+
520
+ Returns:
521
+ Task ID and instructions
522
+ """
523
+ manager = get_manager()
524
+
525
+ # Map agent types to system prompts
526
+ system_prompts = {
527
+ "explore": "You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions efficiently.",
528
+ "dewey": "You are a documentation and research specialist. Find implementation examples, official docs, and provide evidence-based answers.",
529
+ "frontend": """You are a Senior Frontend Architect & Avant-Garde UI Designer with 15+ years experience.
530
+
531
+ OPERATIONAL DIRECTIVES:
532
+ - Follow instructions. Execute immediately. No fluff.
533
+ - Output First: Prioritize code and visual solutions.
534
+
535
+ DESIGN PHILOSOPHY - "INTENTIONAL MINIMALISM":
536
+ - Anti-Generic: Reject standard "bootstrapped" layouts. If it looks like a template, it's wrong.
537
+ - Bespoke layouts, asymmetry, distinctive typography.
538
+ - Before placing any element, calculate its purpose. No purpose = delete it.
539
+
540
+ FRONTEND CODING STANDARDS:
541
+ - Library Discipline: If a UI library (Shadcn, Radix, MUI) is detected, YOU MUST USE IT.
542
+ - Do NOT build custom components if the library provides them.
543
+ - Stack: Modern (React/Vue/Svelte), Tailwind/Custom CSS, semantic HTML5.
544
+ - Focus on micro-interactions, perfect spacing, "invisible" UX.
545
+
546
+ RESPONSE FORMAT:
547
+ 1. Rationale: (1 sentence on why elements were placed there)
548
+ 2. The Code.
549
+
550
+ ULTRATHINK MODE (when user says "ULTRATHINK" or "think harder"):
551
+ 1. Deep Reasoning Chain: Detailed breakdown of architectural and design decisions
552
+ 2. Edge Case Analysis: What could go wrong and how we prevented it
553
+ 3. The Code: Optimized, bespoke, production-ready, utilizing existing libraries""",
554
+ "delphi": "You are a strategic advisor. Provide architecture guidance, debugging assistance, and code review.",
555
+ }
556
+
557
+ system_prompt = system_prompts.get(agent_type, None)
558
+
559
+ task_id = manager.spawn(
560
+ prompt=prompt,
561
+ agent_type=agent_type,
562
+ description=description or prompt[:50],
563
+ system_prompt=system_prompt,
564
+ model=model,
565
+ thinking_budget=thinking_budget,
566
+ )
567
+
568
+ return f"""🚀 Background agent spawned successfully.
569
+
570
+ **Task ID**: {task_id}
571
+ **Agent Type**: {agent_type}
572
+ **Model**: {model}
573
+ **Thinking Budget**: {thinking_budget if thinking_budget > 0 else "N/A"}
574
+ **Description**: {description or prompt[:50]}
575
+
576
+ The agent is now running. Use:
577
+ - `agent_progress(task_id="{task_id}")` to monitor real-time progress
578
+ - `agent_output(task_id="{task_id}")` to get final result
579
+ - `agent_cancel(task_id="{task_id}")` to stop the agent"""
580
+
581
+
582
+ async def agent_output(task_id: str, block: bool = False) -> str:
583
+ """
584
+ Get output from a background agent task.
585
+
586
+ Args:
587
+ task_id: The task ID from agent_spawn
588
+ block: If True, wait for the task to complete (up to 30s)
589
+
590
+ Returns:
591
+ Task status and output
592
+ """
593
+ manager = get_manager()
594
+ return manager.get_output(task_id, block=block)
595
+
596
+
597
+ async def agent_cancel(task_id: str) -> str:
598
+ """
599
+ Cancel a running background agent.
600
+
601
+ Args:
602
+ task_id: The task ID to cancel
603
+
604
+ Returns:
605
+ Cancellation result
606
+ """
607
+ manager = get_manager()
608
+ success = manager.cancel(task_id)
609
+
610
+ if success:
611
+ return f"✅ Agent task {task_id} has been cancelled."
612
+ else:
613
+ task = manager.get_task(task_id)
614
+ if not task:
615
+ return f"❌ Task {task_id} not found."
616
+ else:
617
+ return f"⚠️ Task {task_id} is not running (status: {task['status']}). Cannot cancel."
618
+
619
+
620
+ async def agent_list() -> str:
621
+ """
622
+ List all background agent tasks.
623
+
624
+ Returns:
625
+ Formatted list of tasks
626
+ """
627
+ manager = get_manager()
628
+ tasks = manager.list_tasks()
629
+
630
+ if not tasks:
631
+ return "No background agent tasks found."
632
+
633
+ lines = ["**Background Agent Tasks**", ""]
634
+
635
+ for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
636
+ status_emoji = {
637
+ "pending": "⏳",
638
+ "running": "🔄",
639
+ "completed": "✅",
640
+ "failed": "❌",
641
+ "cancelled": "⚠️",
642
+ }.get(t["status"], "❓")
643
+
644
+ desc = t.get("description", t.get("prompt", "")[:40])
645
+ lines.append(f"- {status_emoji} [{t['id']}] {t['agent_type']}: {desc}")
646
+
647
+ return "\n".join(lines)
648
+
649
+
650
+ async def agent_progress(task_id: str, lines: int = 20) -> str:
651
+ """
652
+ Get real-time progress from a running background agent.
653
+
654
+ Shows the most recent output lines from the agent, useful for
655
+ monitoring what the agent is currently doing.
656
+
657
+ Args:
658
+ task_id: The task ID from agent_spawn
659
+ lines: Number of recent output lines to show (default 20)
660
+
661
+ Returns:
662
+ Recent agent output and status
663
+ """
664
+ manager = get_manager()
665
+ return manager.get_progress(task_id, lines=lines)