stravinsky 0.2.67__py3-none-any.whl → 0.4.66__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +112 -11
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
- mcp_bridge/config/README.md +276 -0
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +247 -0
- mcp_bridge/config/hooks_manifest.json +138 -0
- mcp_bridge/config/rate_limits.py +317 -0
- mcp_bridge/config/skills_manifest.json +128 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +19 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +43 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/tool_messaging.py +113 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +150 -0
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +542 -59
- mcp_bridge/server_tools.py +738 -6
- mcp_bridge/tools/__init__.py +40 -25
- mcp_bridge/tools/agent_manager.py +616 -697
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +70 -53
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +12 -5
- mcp_bridge/tools/lsp/manager.py +471 -0
- mcp_bridge/tools/lsp/tools.py +723 -207
- mcp_bridge/tools/model_invoke.py +1195 -273
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +406 -0
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +3627 -0
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +585 -0
- mcp_bridge/update_manager_pypi.py +297 -0
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- stravinsky-0.4.66.dist-info/METADATA +517 -0
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.2.67.dist-info/METADATA +0 -284
- stravinsky-0.2.67.dist-info/RECORD +0 -76
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
|
@@ -5,123 +5,303 @@ Spawns background agents using Claude Code CLI with full tool access.
|
|
|
5
5
|
This replaces the simple model-only invocation with true agentic execution.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import asyncio
|
|
9
8
|
import json
|
|
9
|
+
import logging
|
|
10
10
|
import os
|
|
11
11
|
import shutil
|
|
12
|
-
import subprocess
|
|
13
12
|
import signal
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
14
16
|
import time
|
|
15
|
-
import
|
|
16
|
-
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
17
18
|
from datetime import datetime
|
|
19
|
+
from enum import Enum
|
|
18
20
|
from pathlib import Path
|
|
19
|
-
from typing import Any,
|
|
20
|
-
import
|
|
21
|
-
import
|
|
21
|
+
from typing import Any, Optional, List, Dict
|
|
22
|
+
import subprocess
|
|
23
|
+
from .mux_client import get_mux, MuxClient
|
|
24
|
+
try:
|
|
25
|
+
from . import semantic_search
|
|
26
|
+
except ImportError:
|
|
27
|
+
# Fallback or lazy import
|
|
28
|
+
semantic_search = None
|
|
22
29
|
|
|
23
30
|
logger = logging.getLogger(__name__)
|
|
24
31
|
|
|
32
|
+
|
|
33
|
+
# Output formatting modes
|
|
34
|
+
class OutputMode(Enum):
|
|
35
|
+
"""Control verbosity of agent spawn output."""
|
|
36
|
+
|
|
37
|
+
CLEAN = "clean" # Concise single-line output
|
|
38
|
+
VERBOSE = "verbose" # Full details with colors
|
|
39
|
+
SILENT = "silent" # No output to stdout (logs only)
|
|
40
|
+
|
|
41
|
+
|
|
25
42
|
# Model routing configuration
|
|
26
|
-
# Specialized agents call external models via MCP tools:
|
|
27
|
-
# explore/dewey/document_writer/multimodal → invoke_gemini(gemini-3-flash)
|
|
28
|
-
# frontend → invoke_gemini(gemini-3-pro-high)
|
|
29
|
-
# delphi → invoke_openai(gpt-5.2)
|
|
30
|
-
# Non-specialized coding tasks use Claude CLI with --model sonnet
|
|
31
43
|
AGENT_MODEL_ROUTING = {
|
|
32
|
-
# Specialized agents - no CLI model flag, they call invoke_* tools
|
|
33
44
|
"explore": None,
|
|
34
45
|
"dewey": None,
|
|
35
46
|
"document_writer": None,
|
|
36
47
|
"multimodal": None,
|
|
37
48
|
"frontend": None,
|
|
38
49
|
"delphi": None,
|
|
39
|
-
|
|
50
|
+
"research-lead": None,
|
|
51
|
+
"implementation-lead": "sonnet",
|
|
52
|
+
"momus": None,
|
|
53
|
+
"comment_checker": None,
|
|
54
|
+
"debugger": "sonnet",
|
|
55
|
+
"code-reviewer": None,
|
|
40
56
|
"planner": "opus",
|
|
41
|
-
# Default for unknown agent types (coding tasks) - use Sonnet 4.5
|
|
42
57
|
"_default": "sonnet",
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
# Cost tier classification (from oh-my-opencode pattern)
|
|
46
60
|
AGENT_COST_TIERS = {
|
|
47
|
-
"explore": "CHEAP",
|
|
48
|
-
"dewey": "CHEAP",
|
|
49
|
-
"document_writer": "CHEAP",
|
|
50
|
-
"multimodal": "CHEAP",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
61
|
+
"explore": "CHEAP",
|
|
62
|
+
"dewey": "CHEAP",
|
|
63
|
+
"document_writer": "CHEAP",
|
|
64
|
+
"multimodal": "CHEAP",
|
|
65
|
+
"research-lead": "CHEAP",
|
|
66
|
+
"implementation-lead": "MEDIUM",
|
|
67
|
+
"momus": "CHEAP",
|
|
68
|
+
"comment_checker": "CHEAP",
|
|
69
|
+
"debugger": "MEDIUM",
|
|
70
|
+
"code-reviewer": "CHEAP",
|
|
71
|
+
"frontend": "MEDIUM",
|
|
72
|
+
"delphi": "EXPENSIVE",
|
|
73
|
+
"planner": "EXPENSIVE",
|
|
74
|
+
"_default": "EXPENSIVE",
|
|
55
75
|
}
|
|
56
76
|
|
|
57
|
-
# Display model names for output formatting (user-visible)
|
|
58
77
|
AGENT_DISPLAY_MODELS = {
|
|
59
78
|
"explore": "gemini-3-flash",
|
|
60
79
|
"dewey": "gemini-3-flash",
|
|
61
80
|
"document_writer": "gemini-3-flash",
|
|
62
81
|
"multimodal": "gemini-3-flash",
|
|
82
|
+
"research-lead": "gemini-3-flash",
|
|
83
|
+
"implementation-lead": "claude-sonnet-4.5",
|
|
84
|
+
"momus": "gemini-3-flash",
|
|
85
|
+
"comment_checker": "gemini-3-flash",
|
|
86
|
+
"debugger": "claude-sonnet-4.5",
|
|
87
|
+
"code-reviewer": "gemini-3-flash",
|
|
63
88
|
"frontend": "gemini-3-pro-high",
|
|
64
89
|
"delphi": "gpt-5.2",
|
|
65
90
|
"planner": "opus-4.5",
|
|
66
91
|
"_default": "sonnet-4.5",
|
|
67
92
|
}
|
|
68
93
|
|
|
94
|
+
COST_TIER_EMOJI = {
|
|
95
|
+
"CHEAP": "🟢",
|
|
96
|
+
"MEDIUM": "🔵",
|
|
97
|
+
"EXPENSIVE": "🟣",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
MODEL_FAMILY_EMOJI = {
|
|
101
|
+
"gemini-3-flash": "🟢",
|
|
102
|
+
"gemini-3-pro-high": "🔵",
|
|
103
|
+
"haiku": "🟢",
|
|
104
|
+
"sonnet-4.5": "🟠",
|
|
105
|
+
"opus-4.5": "🟣",
|
|
106
|
+
"gpt-5.2": "🟣",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Colors:
|
|
111
|
+
"""ANSI color codes for colorized terminal output."""
|
|
112
|
+
|
|
113
|
+
RESET = "\033[0m"
|
|
114
|
+
BOLD = "\033[1m"
|
|
115
|
+
DIM = "\033[2m"
|
|
116
|
+
BLACK = "\033[30m"
|
|
117
|
+
RED = "\033[31m"
|
|
118
|
+
GREEN = "\033[32m"
|
|
119
|
+
YELLOW = "\033[33m"
|
|
120
|
+
BLUE = "\033[34m"
|
|
121
|
+
MAGENTA = "\033[35m"
|
|
122
|
+
CYAN = "\033[36m"
|
|
123
|
+
WHITE = "\033[37m"
|
|
124
|
+
BRIGHT_BLACK = "\033[90m"
|
|
125
|
+
BRIGHT_RED = "\033[91m"
|
|
126
|
+
BRIGHT_GREEN = "\033[92m"
|
|
127
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
128
|
+
BRIGHT_BLUE = "\033[94m"
|
|
129
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
130
|
+
BRIGHT_CYAN = "\033[96m"
|
|
131
|
+
BRIGHT_WHITE = "\033[97m"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_agent_emoji(agent_type: str) -> str:
|
|
135
|
+
"""Get the colored emoji indicator for an agent based on its cost tier."""
|
|
136
|
+
cost_tier = AGENT_COST_TIERS.get(agent_type, AGENT_COST_TIERS["_default"])
|
|
137
|
+
return COST_TIER_EMOJI.get(cost_tier, "⚪")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_model_emoji(model_name: str) -> str:
|
|
141
|
+
"""Get the colored emoji indicator for a model."""
|
|
142
|
+
return MODEL_FAMILY_EMOJI.get(model_name, "⚪")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
ORCHESTRATOR_AGENTS = ["stravinsky", "research-lead", "implementation-lead"]
|
|
146
|
+
WORKER_AGENTS = [
|
|
147
|
+
"explore",
|
|
148
|
+
"dewey",
|
|
149
|
+
"delphi",
|
|
150
|
+
"frontend",
|
|
151
|
+
"debugger",
|
|
152
|
+
"code-reviewer",
|
|
153
|
+
"momus",
|
|
154
|
+
"comment_checker",
|
|
155
|
+
"document_writer",
|
|
156
|
+
"multimodal",
|
|
157
|
+
"planner",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
AGENT_TOOLS = {
|
|
161
|
+
"stravinsky": ["all"],
|
|
162
|
+
"research-lead": ["agent_spawn", "agent_output", "invoke_gemini", "Read", "Grep", "Glob"],
|
|
163
|
+
"implementation-lead": [
|
|
164
|
+
"agent_spawn",
|
|
165
|
+
"agent_output",
|
|
166
|
+
"lsp_diagnostics",
|
|
167
|
+
"Read",
|
|
168
|
+
"Edit",
|
|
169
|
+
"Write",
|
|
170
|
+
"Grep",
|
|
171
|
+
"Glob",
|
|
172
|
+
],
|
|
173
|
+
"explore": [
|
|
174
|
+
"Read",
|
|
175
|
+
"Grep",
|
|
176
|
+
"Glob",
|
|
177
|
+
"Bash",
|
|
178
|
+
"semantic_search",
|
|
179
|
+
"ast_grep_search",
|
|
180
|
+
"lsp_workspace_symbols",
|
|
181
|
+
],
|
|
182
|
+
"dewey": ["Read", "Grep", "Glob", "Bash", "WebSearch", "WebFetch"],
|
|
183
|
+
"frontend": ["Read", "Edit", "Write", "Grep", "Glob", "Bash", "invoke_gemini"],
|
|
184
|
+
"delphi": ["Read", "Grep", "Glob", "Bash", "invoke_openai"],
|
|
185
|
+
"debugger": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "lsp_hover", "ast_grep_search"],
|
|
186
|
+
"code-reviewer": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "ast_grep_search"],
|
|
187
|
+
"momus": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "ast_grep_search"],
|
|
188
|
+
"comment_checker": ["Read", "Grep", "Glob", "Bash", "ast_grep_search", "lsp_document_symbols"],
|
|
189
|
+
# Specialized agents
|
|
190
|
+
"document_writer": ["Read", "Write", "Grep", "Glob", "Bash", "invoke_gemini"],
|
|
191
|
+
"multimodal": ["Read", "invoke_gemini"],
|
|
192
|
+
"planner": ["Read", "Grep", "Glob", "Bash"],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_agent_tools(agent_type: str, required_tools: list[str]) -> None:
|
|
197
|
+
if agent_type not in AGENT_TOOLS:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Unknown agent_type '{agent_type}'. Valid types: {list(AGENT_TOOLS.keys())}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
allowed_tools = AGENT_TOOLS[agent_type]
|
|
203
|
+
if "all" in allowed_tools:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
missing_tools = [tool for tool in required_tools if tool not in allowed_tools]
|
|
207
|
+
if missing_tools:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Agent type '{agent_type}' does not have access to required tools: {missing_tools}\n"
|
|
210
|
+
f"Allowed tools for {agent_type}: {allowed_tools}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_agent_hierarchy(spawning_agent: str, target_agent: str) -> None:
|
|
215
|
+
if spawning_agent in ORCHESTRATOR_AGENTS:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
if spawning_agent in WORKER_AGENTS and target_agent in ORCHESTRATOR_AGENTS:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"Worker agent '{spawning_agent}' cannot spawn orchestrator agent '{target_agent}'."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if spawning_agent in WORKER_AGENTS and target_agent in WORKER_AGENTS:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Worker agent '{spawning_agent}' cannot spawn another worker agent '{target_agent}'."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def colorize_agent_spawn_message(
|
|
230
|
+
cost_emoji: str,
|
|
231
|
+
agent_type: str,
|
|
232
|
+
display_model: str,
|
|
233
|
+
description: str,
|
|
234
|
+
task_id: str,
|
|
235
|
+
) -> str:
|
|
236
|
+
short_desc = (description or "")[:50].strip()
|
|
237
|
+
colored_message = (
|
|
238
|
+
f"{cost_emoji} "
|
|
239
|
+
f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
|
|
240
|
+
f"{Colors.YELLOW}{display_model}{Colors.RESET}"
|
|
241
|
+
f"('{Colors.BOLD}{short_desc}{Colors.RESET}') "
|
|
242
|
+
f"{Colors.BRIGHT_GREEN}⏳{Colors.RESET}\n"
|
|
243
|
+
f"task_id={Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}"
|
|
244
|
+
)
|
|
245
|
+
return colored_message
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def format_spawn_output(
|
|
249
|
+
agent_type: str,
|
|
250
|
+
display_model: str,
|
|
251
|
+
task_id: str,
|
|
252
|
+
mode: OutputMode = OutputMode.CLEAN,
|
|
253
|
+
) -> str:
|
|
254
|
+
if mode == OutputMode.SILENT:
|
|
255
|
+
return ""
|
|
256
|
+
|
|
257
|
+
cost_emoji = get_agent_emoji(agent_type)
|
|
258
|
+
if mode == OutputMode.CLEAN:
|
|
259
|
+
return (
|
|
260
|
+
f"{Colors.GREEN}✓{Colors.RESET} "
|
|
261
|
+
f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
|
|
262
|
+
f"{Colors.YELLOW}{display_model}{Colors.RESET} "
|
|
263
|
+
f"→ {Colors.CYAN}{task_id}{Colors.RESET}"
|
|
264
|
+
)
|
|
265
|
+
return ""
|
|
266
|
+
|
|
69
267
|
|
|
70
268
|
@dataclass
|
|
71
269
|
class AgentTask:
|
|
72
|
-
"""Represents a background agent task with full tool access."""
|
|
73
|
-
|
|
74
270
|
id: str
|
|
75
271
|
prompt: str
|
|
76
|
-
agent_type: str
|
|
272
|
+
agent_type: str
|
|
77
273
|
description: str
|
|
78
|
-
status: str
|
|
274
|
+
status: str
|
|
79
275
|
created_at: str
|
|
80
|
-
parent_session_id:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass
|
|
91
|
-
class AgentProgress:
|
|
92
|
-
"""Progress tracking for a running agent."""
|
|
93
|
-
|
|
94
|
-
tool_calls: int = 0
|
|
95
|
-
last_tool: Optional[str] = None
|
|
96
|
-
last_message: Optional[str] = None
|
|
97
|
-
last_update: Optional[str] = None
|
|
276
|
+
parent_session_id: str | None = None
|
|
277
|
+
terminal_session_id: str | None = None
|
|
278
|
+
started_at: str | None = None
|
|
279
|
+
completed_at: str | None = None
|
|
280
|
+
result: str | None = None
|
|
281
|
+
error: str | None = None
|
|
282
|
+
pid: int | None = None
|
|
283
|
+
timeout: int = 300
|
|
284
|
+
progress: dict[str, Any] | None = None
|
|
98
285
|
|
|
99
286
|
|
|
100
287
|
class AgentManager:
|
|
101
|
-
"""
|
|
102
|
-
Manages background agent execution using Claude Code CLI.
|
|
103
|
-
|
|
104
|
-
Key features:
|
|
105
|
-
- Spawns agents with full tool access via `claude -p`
|
|
106
|
-
- Tracks task status and progress
|
|
107
|
-
- Persists state to .stravinsky/agents.json
|
|
108
|
-
- Provides notification mechanism for task completion
|
|
109
|
-
"""
|
|
110
|
-
|
|
111
|
-
# Dynamic CLI path - find claude in PATH, fallback to common locations
|
|
112
288
|
CLAUDE_CLI = shutil.which("claude") or "/opt/homebrew/bin/claude"
|
|
113
289
|
|
|
114
|
-
def __init__(self, base_dir:
|
|
115
|
-
# Initialize lock FIRST - used by _save_tasks and _load_tasks
|
|
290
|
+
def __init__(self, base_dir: str | None = None):
|
|
116
291
|
self._lock = threading.RLock()
|
|
292
|
+
import uuid as uuid_module
|
|
117
293
|
|
|
294
|
+
self.session_id = os.environ.get(
|
|
295
|
+
"CLAUDE_CODE_SESSION_ID", f"pid_{os.getpid()}_{uuid_module.uuid4().hex[:8]}"
|
|
296
|
+
)
|
|
297
|
+
|
|
118
298
|
if base_dir:
|
|
119
299
|
self.base_dir = Path(base_dir)
|
|
120
300
|
else:
|
|
121
301
|
self.base_dir = Path.cwd() / ".stravinsky"
|
|
122
302
|
|
|
123
303
|
self.agents_dir = self.base_dir / "agents"
|
|
124
|
-
self.state_file = self.base_dir / "
|
|
304
|
+
self.state_file = self.base_dir / f"agents_{self.session_id}.json"
|
|
125
305
|
|
|
126
306
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
127
307
|
self.agents_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -129,79 +309,148 @@ class AgentManager:
|
|
|
129
309
|
if not self.state_file.exists():
|
|
130
310
|
self._save_tasks({})
|
|
131
311
|
|
|
132
|
-
|
|
133
|
-
self.
|
|
134
|
-
self.
|
|
312
|
+
self._processes: dict[str, Any] = {}
|
|
313
|
+
self._notification_queue: dict[str, list[dict[str, Any]]] = {}
|
|
314
|
+
self._tasks: dict[str, asyncio.Task] = {}
|
|
315
|
+
self._progress_monitors: dict[str, asyncio.Task] = {}
|
|
316
|
+
self._stop_monitors = asyncio.Event()
|
|
317
|
+
|
|
318
|
+
# Orchestrator Integration
|
|
319
|
+
self.orchestrator = None # Type: Optional[OrchestratorState]
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
self._sync_cleanup(max_age_minutes=30)
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
self._ensure_sidecar_running()
|
|
327
|
+
|
|
328
|
+
def _ensure_sidecar_running(self):
|
|
329
|
+
"""Start the Go sidecar if not running."""
|
|
330
|
+
# Simple check: is socket present?
|
|
331
|
+
if os.path.exists("/tmp/stravinsky.sock"):
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
mux_path = Path.cwd() / "dist" / "stravinsky-mux"
|
|
335
|
+
if mux_path.exists():
|
|
336
|
+
try:
|
|
337
|
+
subprocess.Popen(
|
|
338
|
+
[str(mux_path)],
|
|
339
|
+
stdout=subprocess.DEVNULL,
|
|
340
|
+
stderr=subprocess.DEVNULL,
|
|
341
|
+
start_new_session=True
|
|
342
|
+
)
|
|
343
|
+
logger.info("Started stravinsky-mux sidecar")
|
|
344
|
+
# Wait briefly for socket
|
|
345
|
+
time.sleep(0.5)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.error(f"Failed to start sidecar: {e}")
|
|
348
|
+
|
|
349
|
+
def _sync_cleanup(self, max_age_minutes: int = 30):
|
|
350
|
+
tasks = self._load_tasks()
|
|
351
|
+
now = datetime.now()
|
|
352
|
+
removed_ids = []
|
|
353
|
+
for task_id, task in list(tasks.items()):
|
|
354
|
+
if task.get("status") in ["completed", "failed", "cancelled"]:
|
|
355
|
+
completed_at = task.get("completed_at")
|
|
356
|
+
if completed_at:
|
|
357
|
+
try:
|
|
358
|
+
completed_time = datetime.fromisoformat(completed_at)
|
|
359
|
+
if (now - completed_time).total_seconds() / 60 > max_age_minutes:
|
|
360
|
+
removed_ids.append(task_id)
|
|
361
|
+
del tasks[task_id]
|
|
362
|
+
except: continue
|
|
363
|
+
if removed_ids:
|
|
364
|
+
self._save_tasks(tasks)
|
|
135
365
|
|
|
136
|
-
def _load_tasks(self) ->
|
|
137
|
-
"""Load tasks from persistent storage."""
|
|
366
|
+
def _load_tasks(self) -> dict[str, Any]:
|
|
138
367
|
with self._lock:
|
|
139
368
|
try:
|
|
140
369
|
if not self.state_file.exists():
|
|
141
370
|
return {}
|
|
142
|
-
with open(self.state_file
|
|
371
|
+
with open(self.state_file) as f:
|
|
143
372
|
return json.load(f)
|
|
144
373
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
145
374
|
return {}
|
|
146
375
|
|
|
147
|
-
def _save_tasks(self, tasks:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
with open(self.state_file, "w") as f:
|
|
151
|
-
json.dump(tasks, f, indent=2)
|
|
376
|
+
def _save_tasks(self, tasks: dict[str, Any]):
|
|
377
|
+
with self._lock, open(self.state_file, "w") as f:
|
|
378
|
+
json.dump(tasks, f, indent=2)
|
|
152
379
|
|
|
153
380
|
def _update_task(self, task_id: str, **kwargs):
|
|
154
|
-
"""Update a task's fields."""
|
|
155
381
|
with self._lock:
|
|
156
382
|
tasks = self._load_tasks()
|
|
157
383
|
if task_id in tasks:
|
|
158
384
|
tasks[task_id].update(kwargs)
|
|
159
385
|
self._save_tasks(tasks)
|
|
160
386
|
|
|
161
|
-
def get_task(self, task_id: str) ->
|
|
162
|
-
"""Get a task by ID."""
|
|
387
|
+
def get_task(self, task_id: str) -> dict[str, Any] | None:
|
|
163
388
|
tasks = self._load_tasks()
|
|
164
389
|
return tasks.get(task_id)
|
|
165
390
|
|
|
166
|
-
def list_tasks(
|
|
167
|
-
|
|
391
|
+
def list_tasks(
|
|
392
|
+
self,
|
|
393
|
+
parent_session_id: str | None = None,
|
|
394
|
+
show_all: bool = True,
|
|
395
|
+
current_session_only: bool = True,
|
|
396
|
+
) -> list[dict[str, Any]]:
|
|
168
397
|
tasks = self._load_tasks()
|
|
169
398
|
task_list = list(tasks.values())
|
|
170
|
-
|
|
399
|
+
if current_session_only:
|
|
400
|
+
task_list = [t for t in task_list if t.get("terminal_session_id") == self.session_id]
|
|
171
401
|
if parent_session_id:
|
|
172
402
|
task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
|
|
173
|
-
|
|
403
|
+
if not show_all:
|
|
404
|
+
task_list = [t for t in task_list if t.get("status") in ["running", "pending"]]
|
|
174
405
|
return task_list
|
|
175
406
|
|
|
176
|
-
def
|
|
407
|
+
async def spawn_async(
|
|
177
408
|
self,
|
|
178
409
|
token_store: Any,
|
|
179
410
|
prompt: str,
|
|
180
411
|
agent_type: str = "explore",
|
|
181
412
|
description: str = "",
|
|
182
|
-
parent_session_id:
|
|
183
|
-
system_prompt:
|
|
413
|
+
parent_session_id: str | None = None,
|
|
414
|
+
system_prompt: str | None = None,
|
|
184
415
|
model: str = "gemini-3-flash",
|
|
185
416
|
thinking_budget: int = 0,
|
|
186
417
|
timeout: int = 300,
|
|
418
|
+
semantic_first: bool = False,
|
|
187
419
|
) -> str:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
420
|
+
# Orchestrator Logic
|
|
421
|
+
if self.orchestrator:
|
|
422
|
+
logger.info(f"Spawning agent {agent_type} in phase {self.orchestrator.current_phase}")
|
|
423
|
+
# Example: If in PLAN phase, inject wisdom automatically
|
|
424
|
+
from ..orchestrator.enums import OrchestrationPhase
|
|
425
|
+
if self.orchestrator.current_phase == OrchestrationPhase.PLAN:
|
|
426
|
+
from ..orchestrator.wisdom import WisdomLoader
|
|
427
|
+
wisdom = WisdomLoader().load_wisdom()
|
|
428
|
+
if wisdom:
|
|
429
|
+
prompt = f"## PROJECT WISDOM\n{wisdom}\n\n---\n\n{prompt}"
|
|
430
|
+
|
|
431
|
+
# Semantic First Context Injection
|
|
432
|
+
if semantic_first and semantic_search:
|
|
433
|
+
try:
|
|
434
|
+
# Run search in thread to avoid blocking loop
|
|
435
|
+
results = await asyncio.to_thread(
|
|
436
|
+
semantic_search.search,
|
|
437
|
+
query=prompt,
|
|
438
|
+
n_results=5,
|
|
439
|
+
project_path=str(self.base_dir.parent)
|
|
440
|
+
)
|
|
441
|
+
if results and "No results" not in results and "Error" not in results:
|
|
442
|
+
prompt = (
|
|
443
|
+
f"## 🧠 SEMANTIC CONTEXT (AUTO-INJECTED)\n"
|
|
444
|
+
f"The following code snippets were found in the vector index based on your task:\n\n"
|
|
445
|
+
f"{results}\n\n"
|
|
446
|
+
f"---\n\n"
|
|
447
|
+
f"## 📋 YOUR TASK\n"
|
|
448
|
+
f"{prompt}"
|
|
449
|
+
)
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.error(f"Semantic context injection failed: {e}")
|
|
204
452
|
|
|
453
|
+
import uuid as uuid_module
|
|
205
454
|
task_id = f"agent_{uuid_module.uuid4().hex[:8]}"
|
|
206
455
|
|
|
207
456
|
task = AgentTask(
|
|
@@ -212,730 +461,400 @@ class AgentManager:
|
|
|
212
461
|
status="pending",
|
|
213
462
|
created_at=datetime.now().isoformat(),
|
|
214
463
|
parent_session_id=parent_session_id,
|
|
464
|
+
terminal_session_id=self.session_id,
|
|
215
465
|
timeout=timeout,
|
|
216
466
|
)
|
|
217
467
|
|
|
218
|
-
# Persist task
|
|
219
468
|
with self._lock:
|
|
220
469
|
tasks = self._load_tasks()
|
|
221
470
|
tasks[task_id] = asdict(task)
|
|
222
471
|
self._save_tasks(tasks)
|
|
223
472
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
473
|
+
task_obj = asyncio.create_task(
|
|
474
|
+
self._execute_agent_async(
|
|
475
|
+
task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
|
|
476
|
+
)
|
|
227
477
|
)
|
|
478
|
+
self._tasks[task_id] = task_obj
|
|
228
479
|
|
|
229
480
|
return task_id
|
|
230
481
|
|
|
231
|
-
def
|
|
482
|
+
def spawn(self, *args, **kwargs) -> str:
|
|
483
|
+
try:
|
|
484
|
+
loop = asyncio.get_running_loop()
|
|
485
|
+
task_id_ref = [None]
|
|
486
|
+
async def wrap():
|
|
487
|
+
task_id_ref[0] = await self.spawn_async(*args, **kwargs)
|
|
488
|
+
|
|
489
|
+
thread = threading.Thread(target=lambda: asyncio.run(wrap()))
|
|
490
|
+
thread.start()
|
|
491
|
+
thread.join()
|
|
492
|
+
return task_id_ref[0]
|
|
493
|
+
except RuntimeError:
|
|
494
|
+
return asyncio.run(self.spawn_async(*args, **kwargs))
|
|
495
|
+
|
|
496
|
+
async def _execute_agent_async(
|
|
232
497
|
self,
|
|
233
498
|
task_id: str,
|
|
234
499
|
token_store: Any,
|
|
235
500
|
prompt: str,
|
|
236
501
|
agent_type: str,
|
|
237
|
-
system_prompt:
|
|
502
|
+
system_prompt: str | None = None,
|
|
238
503
|
model: str = "gemini-3-flash",
|
|
239
504
|
thinking_budget: int = 0,
|
|
240
505
|
timeout: int = 300,
|
|
241
506
|
):
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
just like oh-my-opencode's Sisyphus implementation.
|
|
246
|
-
"""
|
|
247
|
-
|
|
248
|
-
def run_agent():
|
|
249
|
-
log_file = self.agents_dir / f"{task_id}.log"
|
|
250
|
-
output_file = self.agents_dir / f"{task_id}.out"
|
|
251
|
-
|
|
252
|
-
self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
|
|
507
|
+
self.agents_dir.mkdir(parents=True, exist_ok=True)
|
|
508
|
+
log_file = self.agents_dir / f"{task_id}.log"
|
|
509
|
+
output_file = self.agents_dir / f"{task_id}.out"
|
|
253
510
|
|
|
511
|
+
self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
full_prompt = prompt
|
|
515
|
+
if system_prompt:
|
|
516
|
+
full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
|
|
517
|
+
|
|
518
|
+
cmd = [
|
|
519
|
+
self.CLAUDE_CLI,
|
|
520
|
+
"-p",
|
|
521
|
+
full_prompt,
|
|
522
|
+
"--output-format",
|
|
523
|
+
"text",
|
|
524
|
+
"--dangerously-skip-permissions",
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
cli_model = AGENT_MODEL_ROUTING.get(agent_type, AGENT_MODEL_ROUTING.get("_default", "sonnet"))
|
|
528
|
+
if cli_model:
|
|
529
|
+
cmd.extend(["--model", cli_model])
|
|
530
|
+
|
|
531
|
+
if thinking_budget and thinking_budget > 0:
|
|
532
|
+
cmd.extend(["--thinking-budget", str(thinking_budget)])
|
|
533
|
+
|
|
534
|
+
if system_prompt:
|
|
535
|
+
system_file = self.agents_dir / f"{task_id}.system"
|
|
536
|
+
system_file.write_text(system_prompt)
|
|
537
|
+
cmd.extend(["--system-prompt", str(system_file)])
|
|
538
|
+
|
|
539
|
+
logger.info(f"[AgentManager] Spawning {task_id}: {' '.join(cmd[:3])}...")
|
|
540
|
+
|
|
541
|
+
process = await asyncio.create_subprocess_exec(
|
|
542
|
+
*cmd,
|
|
543
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
544
|
+
stdout=asyncio.subprocess.PIPE,
|
|
545
|
+
stderr=asyncio.subprocess.PIPE,
|
|
546
|
+
cwd=str(Path.cwd()),
|
|
547
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
|
|
548
|
+
start_new_session=True,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
self._processes[task_id] = process
|
|
552
|
+
self._update_task(task_id, pid=process.pid)
|
|
553
|
+
|
|
554
|
+
# Streaming read loop for Mux
|
|
555
|
+
stdout_buffer = []
|
|
556
|
+
stderr_buffer = []
|
|
557
|
+
mux = MuxClient(task_id)
|
|
558
|
+
mux.connect()
|
|
559
|
+
|
|
560
|
+
async def read_stream(stream, buffer, stream_name):
|
|
561
|
+
while True:
|
|
562
|
+
line = await stream.readline()
|
|
563
|
+
if not line:
|
|
564
|
+
break
|
|
565
|
+
decoded = line.decode('utf-8', errors='replace')
|
|
566
|
+
buffer.append(decoded)
|
|
567
|
+
mux.log(decoded.strip(), stream_name)
|
|
568
|
+
|
|
254
569
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
# Build Claude CLI command with full tool access
|
|
263
|
-
# Using `claude -p` for non-interactive mode with prompt
|
|
264
|
-
cmd = [
|
|
265
|
-
self.CLAUDE_CLI,
|
|
266
|
-
"-p",
|
|
267
|
-
full_prompt,
|
|
268
|
-
"--output-format",
|
|
269
|
-
"text",
|
|
270
|
-
"--dangerously-skip-permissions", # Critical: bypass permission prompts
|
|
271
|
-
]
|
|
272
|
-
|
|
273
|
-
# Model routing:
|
|
274
|
-
# - Specialized agents (explore/dewey/etc): None = use CLI default, they call invoke_*
|
|
275
|
-
# - Unknown agent types (coding tasks): Use Sonnet 4.5
|
|
276
|
-
if agent_type in AGENT_MODEL_ROUTING:
|
|
277
|
-
cli_model = AGENT_MODEL_ROUTING[agent_type] # None for specialized
|
|
278
|
-
else:
|
|
279
|
-
cli_model = AGENT_MODEL_ROUTING.get("_default", "sonnet")
|
|
280
|
-
|
|
281
|
-
if cli_model:
|
|
282
|
-
cmd.extend(["--model", cli_model])
|
|
283
|
-
logger.info(f"[AgentManager] Using --model {cli_model} for {agent_type} agent")
|
|
284
|
-
|
|
285
|
-
# Add system prompt file if we have one
|
|
286
|
-
if system_prompt:
|
|
287
|
-
system_file = self.agents_dir / f"{task_id}.system"
|
|
288
|
-
system_file.write_text(system_prompt)
|
|
289
|
-
cmd.extend(["--system-prompt", str(system_file)])
|
|
290
|
-
|
|
291
|
-
# Execute Claude CLI as subprocess with full tool access
|
|
292
|
-
logger.info(f"[AgentManager] Running: {' '.join(cmd[:3])}...")
|
|
293
|
-
|
|
294
|
-
# Use PIPE for stderr to capture it properly
|
|
295
|
-
# (Previously used file handle which was closed before process finished)
|
|
296
|
-
process = subprocess.Popen(
|
|
297
|
-
cmd,
|
|
298
|
-
stdin=subprocess.DEVNULL, # Critical: prevent stdin blocking
|
|
299
|
-
stdout=subprocess.PIPE,
|
|
300
|
-
stderr=subprocess.PIPE,
|
|
301
|
-
text=True,
|
|
302
|
-
cwd=str(Path.cwd()),
|
|
303
|
-
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
|
|
304
|
-
start_new_session=True, # Allow process group management
|
|
570
|
+
await asyncio.wait_for(
|
|
571
|
+
asyncio.gather(
|
|
572
|
+
read_stream(process.stdout, stdout_buffer, "stdout"),
|
|
573
|
+
read_stream(process.stderr, stderr_buffer, "stderr"),
|
|
574
|
+
process.wait()
|
|
575
|
+
),
|
|
576
|
+
timeout=timeout
|
|
305
577
|
)
|
|
306
|
-
|
|
307
|
-
# Track the process
|
|
308
|
-
self._processes[task_id] = process
|
|
309
|
-
self._update_task(task_id, pid=process.pid)
|
|
310
|
-
|
|
311
|
-
# Wait for completion with timeout
|
|
578
|
+
except asyncio.TimeoutError:
|
|
312
579
|
try:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
error_msg = f"Claude CLI exited with code {process.returncode}"
|
|
331
|
-
if stderr:
|
|
332
|
-
error_msg += f"\n{stderr}"
|
|
333
|
-
self._update_task(
|
|
334
|
-
task_id,
|
|
335
|
-
status="failed",
|
|
336
|
-
error=error_msg,
|
|
337
|
-
completed_at=datetime.now().isoformat(),
|
|
338
|
-
)
|
|
339
|
-
logger.error(f"[AgentManager] Agent {task_id} failed: {error_msg}")
|
|
340
|
-
|
|
341
|
-
except subprocess.TimeoutExpired:
|
|
342
|
-
process.kill()
|
|
343
|
-
self._update_task(
|
|
344
|
-
task_id,
|
|
345
|
-
status="failed",
|
|
346
|
-
error=f"Agent timed out after {timeout}s",
|
|
347
|
-
completed_at=datetime.now().isoformat(),
|
|
348
|
-
)
|
|
349
|
-
logger.warning(f"[AgentManager] Agent {task_id} timed out")
|
|
350
|
-
|
|
351
|
-
except FileNotFoundError:
|
|
352
|
-
error_msg = f"Claude CLI not found at {self.CLAUDE_CLI}. Install with: npm install -g @anthropic-ai/claude-code"
|
|
353
|
-
log_file.write_text(error_msg)
|
|
580
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
581
|
+
except: pass
|
|
582
|
+
# Clean up streams
|
|
583
|
+
await process.wait()
|
|
584
|
+
error_msg = f"Timed out after {timeout}s"
|
|
585
|
+
output_file.write_text(f"❌ TIMEOUT: {error_msg}")
|
|
586
|
+
self._update_task(task_id, status="failed", error=error_msg, completed_at=datetime.now().isoformat())
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
stdout = "".join(stdout_buffer)
|
|
590
|
+
stderr = "".join(stderr_buffer)
|
|
591
|
+
|
|
592
|
+
if stderr:
|
|
593
|
+
log_file.write_text(stderr)
|
|
594
|
+
|
|
595
|
+
if process.returncode == 0:
|
|
596
|
+
output_file.write_text(stdout)
|
|
354
597
|
self._update_task(
|
|
355
598
|
task_id,
|
|
356
|
-
status="
|
|
357
|
-
|
|
599
|
+
status="completed",
|
|
600
|
+
result=stdout.strip(),
|
|
358
601
|
completed_at=datetime.now().isoformat(),
|
|
359
602
|
)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
error_msg = str(e)
|
|
364
|
-
log_file.write_text(error_msg)
|
|
603
|
+
else:
|
|
604
|
+
error_msg = f"Exit code {process.returncode}\n{stderr}"
|
|
605
|
+
output_file.write_text(f"❌ ERROR: {error_msg}")
|
|
365
606
|
self._update_task(
|
|
366
607
|
task_id,
|
|
367
608
|
status="failed",
|
|
368
609
|
error=error_msg,
|
|
369
610
|
completed_at=datetime.now().isoformat(),
|
|
370
611
|
)
|
|
371
|
-
logger.exception(f"[AgentManager] Agent {task_id} exception")
|
|
372
612
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
613
|
+
except asyncio.CancelledError:
|
|
614
|
+
|
|
615
|
+
|
|
376
616
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
if task_id in self._processes:
|
|
620
|
+
proc = self._processes[task_id]
|
|
621
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
622
|
+
await proc.wait()
|
|
623
|
+
except: pass
|
|
624
|
+
raise
|
|
625
|
+
except Exception as e:
|
|
626
|
+
error_msg = str(e)
|
|
627
|
+
output_file.write_text(f"❌ EXCEPTION: {error_msg}")
|
|
628
|
+
self._update_task(task_id, status="failed", error=error_msg, completed_at=datetime.now().isoformat())
|
|
629
|
+
finally:
|
|
630
|
+
self._processes.pop(task_id, None)
|
|
631
|
+
self._tasks.pop(task_id, None)
|
|
632
|
+
self._notify_completion(task_id)
|
|
380
633
|
|
|
381
634
|
def _notify_completion(self, task_id: str):
|
|
382
|
-
"""Queue notification for parent session."""
|
|
383
635
|
task = self.get_task(task_id)
|
|
384
|
-
if
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
parent_id = task.get("parent_session_id")
|
|
388
|
-
if parent_id:
|
|
636
|
+
if task and task.get("parent_session_id"):
|
|
637
|
+
parent_id = task["parent_session_id"]
|
|
389
638
|
if parent_id not in self._notification_queue:
|
|
390
639
|
self._notification_queue[parent_id] = []
|
|
391
|
-
|
|
392
640
|
self._notification_queue[parent_id].append(task)
|
|
393
|
-
logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
|
|
394
641
|
|
|
395
|
-
def
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
642
|
+
async def _monitor_progress_async(self, task_id: str, interval: int = 10):
|
|
643
|
+
task = self.get_task(task_id)
|
|
644
|
+
if not task: return
|
|
645
|
+
start_time = datetime.fromisoformat(task.get("started_at") or datetime.now().isoformat())
|
|
646
|
+
|
|
647
|
+
while not self._stop_monitors.is_set():
|
|
648
|
+
task = self.get_task(task_id)
|
|
649
|
+
if not task or task["status"] not in ["running", "pending"]:
|
|
650
|
+
# Final status reporting...
|
|
651
|
+
break
|
|
652
|
+
|
|
653
|
+
elapsed = int((datetime.now() - start_time).total_seconds())
|
|
654
|
+
sys.stderr.write(f"{Colors.YELLOW}⏳{Colors.RESET} {Colors.CYAN}{task_id}{Colors.RESET} running ({elapsed}s)...\n")
|
|
655
|
+
sys.stderr.flush()
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
await asyncio.wait_for(self._stop_monitors.wait(), timeout=interval)
|
|
659
|
+
break
|
|
660
|
+
except asyncio.TimeoutError:
|
|
661
|
+
continue
|
|
399
662
|
|
|
400
663
|
def cancel(self, task_id: str) -> bool:
|
|
401
|
-
"""Cancel a running agent task."""
|
|
402
664
|
task = self.get_task(task_id)
|
|
403
|
-
if not task:
|
|
404
|
-
return False
|
|
405
|
-
|
|
406
|
-
if task["status"] != "running":
|
|
665
|
+
if not task or task["status"] not in ["pending", "running"]:
|
|
407
666
|
return False
|
|
408
667
|
|
|
409
668
|
process = self._processes.get(task_id)
|
|
410
669
|
if process:
|
|
411
670
|
try:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
except
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
671
|
+
if hasattr(process, 'pid'):
|
|
672
|
+
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
673
|
+
except: pass
|
|
674
|
+
|
|
675
|
+
async_task = self._tasks.get(task_id)
|
|
676
|
+
if async_task:
|
|
677
|
+
async_task.cancel()
|
|
678
|
+
|
|
421
679
|
self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
|
|
422
|
-
|
|
423
680
|
return True
|
|
424
681
|
|
|
425
|
-
def
|
|
426
|
-
"""
|
|
427
|
-
Stop all running agents and optionally clear task history.
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
clear_history: If True, also remove completed/failed tasks from history
|
|
431
|
-
|
|
432
|
-
Returns:
|
|
433
|
-
Number of tasks stopped/cleared
|
|
434
|
-
"""
|
|
682
|
+
async def stop_all_async(self, clear_history: bool = False) -> int:
|
|
435
683
|
tasks = self._load_tasks()
|
|
436
684
|
stopped_count = 0
|
|
437
|
-
|
|
438
|
-
# Stop running tasks
|
|
439
685
|
for task_id, task in list(tasks.items()):
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
686
|
+
status = task.get("status")
|
|
687
|
+
if status in ["pending", "running"]:
|
|
688
|
+
if self.cancel(task_id):
|
|
689
|
+
stopped_count += 1
|
|
690
|
+
|
|
691
|
+
self._stop_monitors.set()
|
|
692
|
+
|
|
693
|
+
if self._tasks:
|
|
694
|
+
await asyncio.gather(*self._tasks.values(), return_exceptions=True)
|
|
695
|
+
if self._progress_monitors:
|
|
696
|
+
await asyncio.gather(*self._progress_monitors.values(), return_exceptions=True)
|
|
697
|
+
|
|
445
698
|
if clear_history:
|
|
446
699
|
cleared = len(tasks)
|
|
447
700
|
self._save_tasks({})
|
|
448
701
|
self._processes.clear()
|
|
449
|
-
|
|
702
|
+
self._tasks.clear()
|
|
703
|
+
self._progress_monitors.clear()
|
|
450
704
|
return cleared
|
|
451
|
-
|
|
452
705
|
return stopped_count
|
|
453
706
|
|
|
454
|
-
def
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
707
|
+
def stop_all(self, clear_history: bool = False) -> int:
|
|
708
|
+
try:
|
|
709
|
+
return asyncio.run(self.stop_all_async(clear_history))
|
|
710
|
+
except RuntimeError:
|
|
711
|
+
# Loop already running, use a thread
|
|
712
|
+
res = [0]
|
|
713
|
+
def wrap(): res[0] = asyncio.run(self.stop_all_async(clear_history))
|
|
714
|
+
t = threading.Thread(target=wrap)
|
|
715
|
+
t.start()
|
|
716
|
+
t.join()
|
|
717
|
+
return res[0]
|
|
718
|
+
|
|
719
|
+
def cleanup(self, max_age_minutes: int = 30, statuses: list[str] | None = None) -> dict:
|
|
720
|
+
if statuses is None: statuses = ["completed", "failed", "cancelled"]
|
|
721
|
+
tasks = self._load_tasks()
|
|
722
|
+
now = datetime.now()
|
|
723
|
+
removed_ids = []
|
|
724
|
+
for task_id, task in list(tasks.items()):
|
|
725
|
+
if task.get("status") in statuses:
|
|
726
|
+
completed_at = task.get("completed_at")
|
|
727
|
+
if completed_at:
|
|
728
|
+
try:
|
|
729
|
+
completed_time = datetime.fromisoformat(completed_at)
|
|
730
|
+
if (now - completed_time).total_seconds() / 60 > max_age_minutes:
|
|
731
|
+
removed_ids.append(task_id)
|
|
732
|
+
del tasks[task_id]
|
|
733
|
+
for ext in [".log", ".out", ".system"]:
|
|
734
|
+
(self.agents_dir / f"{task_id}{ext}").unlink(missing_ok=True)
|
|
735
|
+
except: continue
|
|
736
|
+
if removed_ids: self._save_tasks(tasks)
|
|
737
|
+
return {"removed": len(removed_ids), "task_ids": removed_ids, "summary": f"Removed {len(removed_ids)} agents"}
|
|
738
|
+
|
|
739
|
+
async def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0, auto_cleanup: bool = False) -> str:
|
|
466
740
|
task = self.get_task(task_id)
|
|
467
|
-
if not task:
|
|
468
|
-
return f"Task {task_id} not found."
|
|
741
|
+
if not task: return f"Task {task_id} not found."
|
|
469
742
|
|
|
470
|
-
if block and task["status"]
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
while (datetime.now() - start).total_seconds() < timeout:
|
|
743
|
+
if block and task["status"] in ["pending", "running"]:
|
|
744
|
+
start = time.time()
|
|
745
|
+
while (time.time() - start) < timeout:
|
|
474
746
|
task = self.get_task(task_id)
|
|
475
|
-
if not task or task["status"]
|
|
476
|
-
|
|
477
|
-
time.sleep(0.5)
|
|
478
|
-
|
|
479
|
-
# Refresh task state after potential blocking wait
|
|
480
|
-
if not task:
|
|
481
|
-
return f"Task {task_id} not found."
|
|
747
|
+
if not task or task["status"] not in ["pending", "running"]: break
|
|
748
|
+
await asyncio.sleep(0.5)
|
|
482
749
|
|
|
750
|
+
task = self.get_task(task_id)
|
|
483
751
|
status = task["status"]
|
|
484
|
-
description = task.get("description", "")
|
|
485
752
|
agent_type = task.get("agent_type", "unknown")
|
|
753
|
+
cost_emoji = get_agent_emoji(agent_type)
|
|
754
|
+
display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
|
|
486
755
|
|
|
487
756
|
if status == "completed":
|
|
488
|
-
|
|
489
|
-
return f"
|
|
490
|
-
|
|
491
|
-
**Task ID**: {task_id}
|
|
492
|
-
**Agent**: {agent_type}
|
|
493
|
-
**Description**: {description}
|
|
494
|
-
|
|
495
|
-
**Result**:
|
|
496
|
-
{result}"""
|
|
497
|
-
|
|
757
|
+
res = task.get("result", "")
|
|
758
|
+
return f"{cost_emoji} {Colors.BRIGHT_GREEN}✅ Completed{Colors.RESET}\n\n**ID**: {task_id}\n**Result**:\n{res}"
|
|
498
759
|
elif status == "failed":
|
|
499
|
-
|
|
500
|
-
return f"
|
|
501
|
-
|
|
502
|
-
**
|
|
503
|
-
**Agent**: {agent_type}
|
|
504
|
-
**Description**: {description}
|
|
505
|
-
|
|
506
|
-
**Error**:
|
|
507
|
-
{error}"""
|
|
508
|
-
|
|
509
|
-
elif status == "cancelled":
|
|
510
|
-
return f"""⚠️ Agent Task Cancelled
|
|
511
|
-
|
|
512
|
-
**Task ID**: {task_id}
|
|
513
|
-
**Agent**: {agent_type}
|
|
514
|
-
**Description**: {description}"""
|
|
515
|
-
|
|
516
|
-
else: # pending or running
|
|
517
|
-
pid = task.get("pid", "N/A")
|
|
518
|
-
started = task.get("started_at", "N/A")
|
|
519
|
-
return f"""⏳ Agent Task Running
|
|
520
|
-
|
|
521
|
-
**Task ID**: {task_id}
|
|
522
|
-
**Agent**: {agent_type}
|
|
523
|
-
**Description**: {description}
|
|
524
|
-
**PID**: {pid}
|
|
525
|
-
**Started**: {started}
|
|
526
|
-
|
|
527
|
-
Use `agent_output` with block=true to wait for completion."""
|
|
760
|
+
err = task.get("error", "")
|
|
761
|
+
return f"{cost_emoji} {Colors.BRIGHT_RED}❌ Failed{Colors.RESET}\n\n**ID**: {task_id}\n**Error**:\n{err}"
|
|
762
|
+
else:
|
|
763
|
+
return f"{cost_emoji} {Colors.BRIGHT_YELLOW}⏳ Running{Colors.RESET}\n\n**ID**: {task_id}\nStatus: {status}"
|
|
528
764
|
|
|
529
765
|
def get_progress(self, task_id: str, lines: int = 20) -> str:
|
|
530
|
-
"""
|
|
531
|
-
Get real-time progress from a running agent's output.
|
|
532
|
-
|
|
533
|
-
Args:
|
|
534
|
-
task_id: The task ID
|
|
535
|
-
lines: Number of lines to show from the end
|
|
536
|
-
|
|
537
|
-
Returns:
|
|
538
|
-
Recent output lines and status
|
|
539
|
-
"""
|
|
540
766
|
task = self.get_task(task_id)
|
|
541
|
-
if not task:
|
|
542
|
-
return f"Task {task_id} not found."
|
|
543
|
-
|
|
767
|
+
if not task: return f"Task {task_id} not found."
|
|
544
768
|
output_file = self.agents_dir / f"{task_id}.out"
|
|
545
|
-
log_file = self.agents_dir / f"{task_id}.log"
|
|
546
|
-
|
|
547
|
-
status = task["status"]
|
|
548
|
-
description = task.get("description", "")
|
|
549
|
-
agent_type = task.get("agent_type", "unknown")
|
|
550
|
-
pid = task.get("pid")
|
|
551
|
-
|
|
552
|
-
# Zombie Detection: If running but process is gone
|
|
553
|
-
if status == "running" and pid:
|
|
554
|
-
try:
|
|
555
|
-
import psutil
|
|
556
|
-
|
|
557
|
-
if not psutil.pid_exists(pid):
|
|
558
|
-
status = "failed"
|
|
559
|
-
self._update_task(
|
|
560
|
-
task_id,
|
|
561
|
-
status="failed",
|
|
562
|
-
error="Agent process died unexpectedly (Zombie detected)",
|
|
563
|
-
completed_at=datetime.now().isoformat(),
|
|
564
|
-
)
|
|
565
|
-
logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
|
|
566
|
-
except ImportError:
|
|
567
|
-
pass
|
|
568
|
-
|
|
569
|
-
# Read recent output
|
|
570
769
|
output_content = ""
|
|
571
770
|
if output_file.exists():
|
|
572
771
|
try:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
output_content = "\n".join(recent)
|
|
578
|
-
except Exception:
|
|
579
|
-
pass
|
|
580
|
-
|
|
581
|
-
# Check log for errors
|
|
582
|
-
log_content = ""
|
|
583
|
-
if log_file.exists():
|
|
584
|
-
try:
|
|
585
|
-
log_content = log_file.read_text().strip()
|
|
586
|
-
except Exception:
|
|
587
|
-
pass
|
|
588
|
-
|
|
589
|
-
# Status emoji
|
|
590
|
-
status_emoji = {
|
|
591
|
-
"pending": "⏳",
|
|
592
|
-
"running": "🔄",
|
|
593
|
-
"completed": "✅",
|
|
594
|
-
"failed": "❌",
|
|
595
|
-
"cancelled": "⚠️",
|
|
596
|
-
}.get(status, "❓")
|
|
597
|
-
|
|
598
|
-
result = f"""{status_emoji} **Agent Progress**
|
|
599
|
-
|
|
600
|
-
**Task ID**: {task_id}
|
|
601
|
-
**Agent**: {agent_type}
|
|
602
|
-
**Description**: {description}
|
|
603
|
-
**Status**: {status}
|
|
604
|
-
"""
|
|
772
|
+
text = output_file.read_text()
|
|
773
|
+
output_content = "\n".join(text.strip().split("\n")[-lines:])
|
|
774
|
+
except: pass
|
|
775
|
+
return f"**Agent Progress**\nID: {task_id}\nStatus: {task['status']}\n\nOutput:\n```\n{output_content}\n```"
|
|
605
776
|
|
|
606
|
-
if output_content:
|
|
607
|
-
result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
|
|
608
|
-
elif status == "running":
|
|
609
|
-
result += "\n*Agent is working... no output yet.*"
|
|
610
777
|
|
|
611
|
-
|
|
612
|
-
# Truncate log if too long
|
|
613
|
-
if len(log_content) > 500:
|
|
614
|
-
log_content = log_content[:500] + "..."
|
|
615
|
-
result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
|
|
616
|
-
|
|
617
|
-
return result
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
# Global manager instance
|
|
621
|
-
_manager: Optional[AgentManager] = None
|
|
778
|
+
_manager: AgentManager | None = None
|
|
622
779
|
_manager_lock = threading.Lock()
|
|
623
780
|
|
|
624
|
-
|
|
625
781
|
def get_manager() -> AgentManager:
|
|
626
|
-
"""Get or create the global AgentManager instance."""
|
|
627
782
|
global _manager
|
|
628
783
|
if _manager is None:
|
|
629
784
|
with _manager_lock:
|
|
630
|
-
# Double-check pattern to avoid race condition
|
|
631
785
|
if _manager is None:
|
|
632
786
|
_manager = AgentManager()
|
|
633
787
|
return _manager
|
|
634
788
|
|
|
635
789
|
|
|
636
|
-
# Tool interface functions
|
|
637
|
-
|
|
638
|
-
|
|
639
790
|
async def agent_spawn(
|
|
640
791
|
prompt: str,
|
|
641
792
|
agent_type: str = "explore",
|
|
642
793
|
description: str = "",
|
|
794
|
+
delegation_reason: str | None = None,
|
|
795
|
+
expected_outcome: str | None = None,
|
|
796
|
+
required_tools: list[str] | None = None,
|
|
643
797
|
model: str = "gemini-3-flash",
|
|
644
798
|
thinking_budget: int = 0,
|
|
645
799
|
timeout: int = 300,
|
|
646
800
|
blocking: bool = False,
|
|
801
|
+
spawning_agent: str | None = None,
|
|
802
|
+
semantic_first: bool = False,
|
|
647
803
|
) -> str:
|
|
648
|
-
"""
|
|
649
|
-
Spawn a background agent.
|
|
650
|
-
|
|
651
|
-
Args:
|
|
652
|
-
prompt: The task for the agent to perform
|
|
653
|
-
agent_type: Type of agent (explore, dewey, frontend, delphi)
|
|
654
|
-
description: Short description shown in status
|
|
655
|
-
model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
|
|
656
|
-
thinking_budget: Reserved reasoning tokens
|
|
657
|
-
timeout: Execution timeout in seconds
|
|
658
|
-
blocking: If True, wait for completion and return result directly (use for delphi)
|
|
659
|
-
|
|
660
|
-
Returns:
|
|
661
|
-
Task ID and instructions, or full result if blocking=True
|
|
662
|
-
"""
|
|
663
804
|
manager = get_manager()
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
"explore": """You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions.
|
|
671
|
-
|
|
672
|
-
MODEL ROUTING (MANDATORY):
|
|
673
|
-
You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis and reasoning.
|
|
674
|
-
Use Claude's native tools (Read, Grep, Glob) ONLY for file access, then pass content to invoke_gemini.
|
|
675
|
-
|
|
676
|
-
WORKFLOW:
|
|
677
|
-
1. Use Read/Grep/Glob to get file contents
|
|
678
|
-
2. Call invoke_gemini(prompt="Analyze this: <content>", model="gemini-3-flash", agent_context={"agent_type": "explore"}) for analysis
|
|
679
|
-
3. Return the Gemini response""",
|
|
680
|
-
"dewey": """You are a documentation and research specialist. Find implementation examples and official docs.
|
|
681
|
-
|
|
682
|
-
MODEL ROUTING (MANDATORY):
|
|
683
|
-
You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis, summarization, and reasoning.
|
|
684
|
-
|
|
685
|
-
WORKFLOW:
|
|
686
|
-
1. Gather information using available tools
|
|
687
|
-
2. Call invoke_gemini(prompt="<task>", model="gemini-3-flash", agent_context={"agent_type": "dewey"}) for processing
|
|
688
|
-
3. Return the Gemini response""",
|
|
689
|
-
"frontend": """You are a Senior Frontend Architect & UI Designer.
|
|
690
|
-
|
|
691
|
-
MODEL ROUTING (MANDATORY):
|
|
692
|
-
You MUST use invoke_gemini with model="gemini-3-pro-high" for ALL code generation and design work.
|
|
693
|
-
|
|
694
|
-
DESIGN PHILOSOPHY:
|
|
695
|
-
- Anti-Generic: Reject standard layouts. Bespoke, asymmetric, distinctive.
|
|
696
|
-
- Library Discipline: Use existing UI libraries (Shadcn, Radix, MUI) if detected.
|
|
697
|
-
- Stack: React/Vue/Svelte, Tailwind/Custom CSS, semantic HTML5.
|
|
698
|
-
|
|
699
|
-
WORKFLOW:
|
|
700
|
-
1. Analyze requirements
|
|
701
|
-
2. Call invoke_gemini(prompt="Generate frontend code for: <task>", model="gemini-3-pro-high", agent_context={"agent_type": "frontend"})
|
|
702
|
-
3. Return the code""",
|
|
703
|
-
"delphi": """You are a strategic technical advisor for architecture and hard debugging.
|
|
704
|
-
|
|
705
|
-
MODEL ROUTING (MANDATORY):
|
|
706
|
-
You MUST use invoke_openai with model="gpt-5.2" for ALL strategic advice and analysis.
|
|
707
|
-
|
|
708
|
-
WORKFLOW:
|
|
709
|
-
1. Gather context about the problem
|
|
710
|
-
2. Call invoke_openai(prompt="<problem description>", model="gpt-5.2", agent_context={"agent_type": "delphi"})
|
|
711
|
-
3. Return the GPT response""",
|
|
712
|
-
"document_writer": """You are a Technical Documentation Specialist.
|
|
713
|
-
|
|
714
|
-
MODEL ROUTING (MANDATORY):
|
|
715
|
-
You MUST use invoke_gemini with model="gemini-3-flash" for ALL documentation generation.
|
|
716
|
-
|
|
717
|
-
DOCUMENT TYPES: README, API docs, ADRs, user guides, inline docs.
|
|
718
|
-
|
|
719
|
-
WORKFLOW:
|
|
720
|
-
1. Gather context about what to document
|
|
721
|
-
2. Call invoke_gemini(prompt="Write documentation for: <topic>", model="gemini-3-flash", agent_context={"agent_type": "document_writer"})
|
|
722
|
-
3. Return the documentation""",
|
|
723
|
-
"multimodal": """You interpret media files (PDFs, images, diagrams, screenshots).
|
|
724
|
-
|
|
725
|
-
MODEL ROUTING (MANDATORY):
|
|
726
|
-
You MUST use invoke_gemini with model="gemini-3-flash" for ALL visual analysis.
|
|
727
|
-
|
|
728
|
-
WORKFLOW:
|
|
729
|
-
1. Receive file path and extraction goal
|
|
730
|
-
2. Call invoke_gemini(prompt="Analyze this file: <path>. Extract: <goal>", model="gemini-3-flash", agent_context={"agent_type": "multimodal"})
|
|
731
|
-
3. Return extracted information only""",
|
|
732
|
-
"planner": """You are a pre-implementation planning specialist. You analyze requests and produce structured implementation plans BEFORE any code changes begin.
|
|
733
|
-
|
|
734
|
-
PURPOSE:
|
|
735
|
-
- Analyze requests and produce actionable implementation plans
|
|
736
|
-
- Identify dependencies and parallelization opportunities
|
|
737
|
-
- Enable efficient parallel execution by the orchestrator
|
|
738
|
-
- Prevent wasted effort through upfront planning
|
|
739
|
-
|
|
740
|
-
METHODOLOGY:
|
|
741
|
-
1. EXPLORE FIRST: Spawn explore agents IN PARALLEL to understand the codebase
|
|
742
|
-
2. DECOMPOSE: Break request into atomic, single-purpose tasks
|
|
743
|
-
3. ANALYZE DEPENDENCIES: What blocks what? What can run in parallel?
|
|
744
|
-
4. ASSIGN AGENTS: Map each task to the right specialist (explore/dewey/frontend/delphi)
|
|
745
|
-
5. OUTPUT STRUCTURED PLAN: Use the required format below
|
|
746
|
-
|
|
747
|
-
REQUIRED OUTPUT FORMAT:
|
|
748
|
-
```
|
|
749
|
-
## PLAN: [Brief title]
|
|
750
|
-
|
|
751
|
-
### ANALYSIS
|
|
752
|
-
- **Request**: [One sentence summary]
|
|
753
|
-
- **Scope**: [What's in/out of scope]
|
|
754
|
-
- **Risk Level**: [Low/Medium/High]
|
|
755
|
-
|
|
756
|
-
### EXECUTION PHASES
|
|
757
|
-
|
|
758
|
-
#### Phase 1: [Name] (PARALLEL)
|
|
759
|
-
| Task | Agent | Files | Est |
|
|
760
|
-
|------|-------|-------|-----|
|
|
761
|
-
| [description] | explore | file.py | S/M/L |
|
|
762
|
-
|
|
763
|
-
#### Phase 2: [Name] (SEQUENTIAL after Phase 1)
|
|
764
|
-
| Task | Agent | Files | Est |
|
|
765
|
-
|------|-------|-------|-----|
|
|
766
|
-
|
|
767
|
-
### AGENT SPAWN COMMANDS
|
|
768
|
-
```python
|
|
769
|
-
# Phase 1 - Fire all in parallel
|
|
770
|
-
agent_spawn(prompt="...", agent_type="explore", description="...")
|
|
771
|
-
```
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
CONSTRAINTS:
|
|
775
|
-
- You ONLY plan. You NEVER execute code changes.
|
|
776
|
-
- Every task must have a clear agent assignment
|
|
777
|
-
- Parallel phases must be truly independent
|
|
778
|
-
- Include ready-to-use agent_spawn commands""",
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
system_prompt = system_prompts.get(agent_type, None)
|
|
782
|
-
|
|
783
|
-
# Model routing (MANDATORY - enforced in system prompts):
|
|
784
|
-
# - explore, dewey, document_writer, multimodal → invoke_gemini(gemini-3-flash)
|
|
785
|
-
# - frontend → invoke_gemini(gemini-3-pro-high)
|
|
786
|
-
# - delphi → invoke_openai(gpt-5.2)
|
|
787
|
-
# - Unknown agent types (coding tasks) → Claude CLI --model sonnet
|
|
788
|
-
|
|
789
|
-
# Get token store for authentication
|
|
805
|
+
if spawning_agent in ORCHESTRATOR_AGENTS:
|
|
806
|
+
if not delegation_reason or not expected_outcome or not required_tools:
|
|
807
|
+
raise ValueError("Orchestrators must provide delegation metadata")
|
|
808
|
+
if required_tools: validate_agent_tools(agent_type, required_tools)
|
|
809
|
+
if spawning_agent: validate_agent_hierarchy(spawning_agent, agent_type)
|
|
810
|
+
system_prompt = f"You are a {agent_type} specialist."
|
|
790
811
|
from ..auth.token_store import TokenStore
|
|
791
|
-
|
|
792
812
|
token_store = TokenStore()
|
|
793
|
-
|
|
794
|
-
task_id = manager.spawn(
|
|
813
|
+
task_id = await manager.spawn_async(
|
|
795
814
|
token_store=token_store,
|
|
796
815
|
prompt=prompt,
|
|
797
816
|
agent_type=agent_type,
|
|
798
|
-
description=description
|
|
817
|
+
description=description,
|
|
799
818
|
system_prompt=system_prompt,
|
|
800
|
-
model=model, # Not used for Claude CLI, kept for API compatibility
|
|
801
|
-
thinking_budget=thinking_budget, # Not used for Claude CLI, kept for API compatibility
|
|
802
819
|
timeout=timeout,
|
|
820
|
+
semantic_first=semantic_first,
|
|
803
821
|
)
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
short_desc = (description or prompt[:50]).strip()
|
|
808
|
-
|
|
809
|
-
# If blocking mode (recommended for delphi), wait for completion
|
|
822
|
+
if not blocking:
|
|
823
|
+
monitor_task = asyncio.create_task(manager._monitor_progress_async(task_id))
|
|
824
|
+
manager._progress_monitors[task_id] = monitor_task
|
|
810
825
|
if blocking:
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
# Concise format: AgentType:model('description')
|
|
815
|
-
return f"""{agent_type}:{display_model}('{short_desc}')
|
|
816
|
-
task_id={task_id}"""
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
async def agent_output(task_id: str, block: bool = False) -> str:
|
|
820
|
-
"""
|
|
821
|
-
Get output from a background agent task.
|
|
826
|
+
return await manager.get_output(task_id, block=True, timeout=timeout)
|
|
827
|
+
display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
|
|
828
|
+
return format_spawn_output(agent_type, display_model, task_id)
|
|
822
829
|
|
|
823
|
-
Args:
|
|
824
|
-
task_id: The task ID from agent_spawn
|
|
825
|
-
block: If True, wait for the task to complete (up to 30s)
|
|
826
830
|
|
|
827
|
-
|
|
828
|
-
Task status and output
|
|
829
|
-
"""
|
|
831
|
+
async def agent_output(task_id: str, block: bool = False, auto_cleanup: bool = False) -> str:
|
|
830
832
|
manager = get_manager()
|
|
831
|
-
return manager.get_output(task_id, block=block)
|
|
832
|
-
|
|
833
|
+
return await manager.get_output(task_id, block=block, auto_cleanup=auto_cleanup)
|
|
833
834
|
|
|
834
|
-
async def agent_retry(
|
|
835
|
-
task_id: str,
|
|
836
|
-
new_prompt: Optional[str] = None,
|
|
837
|
-
new_timeout: Optional[int] = None,
|
|
838
|
-
) -> str:
|
|
839
|
-
"""
|
|
840
|
-
Retry a failed or timed-out background agent.
|
|
841
|
-
|
|
842
|
-
Args:
|
|
843
|
-
task_id: The ID of the task to retry
|
|
844
|
-
new_prompt: Optional refined prompt for the retry
|
|
845
|
-
new_timeout: Optional new timeout in seconds
|
|
846
|
-
|
|
847
|
-
Returns:
|
|
848
|
-
New Task ID and status
|
|
849
|
-
"""
|
|
835
|
+
async def agent_retry(task_id: str, new_prompt: str = None, new_timeout: int = None) -> str:
|
|
850
836
|
manager = get_manager()
|
|
851
837
|
task = manager.get_task(task_id)
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
return f"❌ Task {task_id} not found."
|
|
855
|
-
|
|
856
|
-
if task["status"] in ["running", "pending"]:
|
|
857
|
-
return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
|
|
858
|
-
|
|
859
|
-
prompt = new_prompt or task["prompt"]
|
|
860
|
-
timeout = new_timeout or task.get("timeout", 300)
|
|
861
|
-
|
|
862
|
-
return await agent_spawn(
|
|
863
|
-
prompt=prompt,
|
|
864
|
-
agent_type=task["agent_type"],
|
|
865
|
-
description=f"Retry of {task_id}: {task['description']}",
|
|
866
|
-
timeout=timeout,
|
|
867
|
-
)
|
|
868
|
-
|
|
838
|
+
if not task: return f"❌ Task {task_id} not found."
|
|
839
|
+
return await agent_spawn(prompt=new_prompt or task["prompt"], agent_type=task["agent_type"], timeout=new_timeout or task["timeout"])
|
|
869
840
|
|
|
870
841
|
async def agent_cancel(task_id: str) -> str:
|
|
871
|
-
"""
|
|
872
|
-
Cancel a running background agent.
|
|
873
|
-
|
|
874
|
-
Args:
|
|
875
|
-
task_id: The task ID to cancel
|
|
876
|
-
|
|
877
|
-
Returns:
|
|
878
|
-
Cancellation result
|
|
879
|
-
"""
|
|
880
842
|
manager = get_manager()
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
return f"✅ Agent task {task_id} has been cancelled."
|
|
885
|
-
else:
|
|
886
|
-
task = manager.get_task(task_id)
|
|
887
|
-
if not task:
|
|
888
|
-
return f"❌ Task {task_id} not found."
|
|
889
|
-
else:
|
|
890
|
-
return f"⚠️ Task {task_id} is not running (status: {task['status']}). Cannot cancel."
|
|
891
|
-
|
|
843
|
+
if not manager.get_task(task_id): return f"❌ Task {task_id} not found."
|
|
844
|
+
if manager.cancel(task_id): return f"✅ Cancelled {task_id}."
|
|
845
|
+
return f"❌ Could not cancel {task_id}."
|
|
892
846
|
|
|
893
|
-
async def
|
|
894
|
-
"""
|
|
895
|
-
List all background agent tasks.
|
|
896
|
-
|
|
897
|
-
Returns:
|
|
898
|
-
Formatted list of tasks
|
|
899
|
-
"""
|
|
847
|
+
async def agent_cleanup(max_age_minutes: int = 30, statuses: list[str] = None) -> str:
|
|
900
848
|
manager = get_manager()
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if not tasks:
|
|
904
|
-
return "No background agent tasks found."
|
|
905
|
-
|
|
906
|
-
lines = []
|
|
907
|
-
|
|
908
|
-
for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
|
|
909
|
-
status_emoji = {
|
|
910
|
-
"pending": "⏳",
|
|
911
|
-
"running": "🔄",
|
|
912
|
-
"completed": "✅",
|
|
913
|
-
"failed": "❌",
|
|
914
|
-
"cancelled": "⚠️",
|
|
915
|
-
}.get(t["status"], "❓")
|
|
916
|
-
|
|
917
|
-
agent_type = t.get("agent_type", "unknown")
|
|
918
|
-
display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
|
|
919
|
-
desc = t.get("description", t.get("prompt", "")[:40])
|
|
920
|
-
# Concise format: status agent:model('desc') id=xxx
|
|
921
|
-
lines.append(f"{status_emoji} {agent_type}:{display_model}('{desc}') id={t['id']}")
|
|
922
|
-
|
|
923
|
-
return "\n".join(lines)
|
|
849
|
+
res = manager.cleanup(max_age_minutes, statuses)
|
|
850
|
+
return res["summary"]
|
|
924
851
|
|
|
852
|
+
async def agent_list(show_all: bool = False, all_sessions: bool = False) -> str:
|
|
853
|
+
manager = get_manager()
|
|
854
|
+
tasks = manager.list_tasks(show_all=show_all, current_session_only=not all_sessions)
|
|
855
|
+
if not tasks: return "No tasks found."
|
|
856
|
+
return "\n".join([f"• {t['id']} ({t['status']}) - {t['agent_type']}" for t in tasks])
|
|
925
857
|
|
|
926
858
|
async def agent_progress(task_id: str, lines: int = 20) -> str:
|
|
927
|
-
"""
|
|
928
|
-
Get real-time progress from a running background agent.
|
|
929
|
-
|
|
930
|
-
Shows the most recent output lines from the agent, useful for
|
|
931
|
-
monitoring what the agent is currently doing.
|
|
932
|
-
|
|
933
|
-
Args:
|
|
934
|
-
task_id: The task ID from agent_spawn
|
|
935
|
-
lines: Number of recent output lines to show (default 20)
|
|
936
|
-
|
|
937
|
-
Returns:
|
|
938
|
-
Recent agent output and status
|
|
939
|
-
"""
|
|
940
859
|
manager = get_manager()
|
|
941
|
-
return manager.get_progress(task_id, lines
|
|
860
|
+
return manager.get_progress(task_id, lines)
|