Cduplar commited on
Commit
8b02e7c
·
0 Parent(s):

Initial public release: Multi-Agent MCP Delegation Server

Browse files

A Model Context Protocol (MCP) server that enables intelligent task
delegation across multiple AI coding agents (Claude, Gemini, Aider).

Features:
- Capability-based routing with automatic fallback
- 10+ specialized task types (security, refactoring, code review, etc.)
- Flexible configuration via YAML and Python install.py wizard
- Hugging Face Space demo with interactive query tester
- Comprehensive test suite and example workflows
- Docker support for easy deployment

Token efficiency: <1% context overhead (1,739 tokens)

License: MIT

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +65 -0
  2. .env.example +22 -0
  3. .github/workflows/python-app.yml +39 -0
  4. .gitignore +61 -0
  5. Dockerfile +35 -0
  6. LICENSE +21 -0
  7. README.md +335 -0
  8. app.py +94 -0
  9. config/delegation_rules.yaml +141 -0
  10. config/orchestrators.yaml +25 -0
  11. examples/claude_code_usage.py +180 -0
  12. examples/example_workflow.py +72 -0
  13. install.py +10 -0
  14. install.sh +59 -0
  15. pyproject.toml +73 -0
  16. requirements.txt +12 -0
  17. src/delegation_mcp/__init__.py +15 -0
  18. src/delegation_mcp/adapters/__init__.py +15 -0
  19. src/delegation_mcp/adapters/aider.py +102 -0
  20. src/delegation_mcp/adapters/base.py +179 -0
  21. src/delegation_mcp/adapters/claude.py +69 -0
  22. src/delegation_mcp/adapters/copilot.py +70 -0
  23. src/delegation_mcp/adapters/gemini.py +79 -0
  24. src/delegation_mcp/agent_discovery.py +518 -0
  25. src/delegation_mcp/cli.py +358 -0
  26. src/delegation_mcp/config.py +253 -0
  27. src/delegation_mcp/delegation.py +461 -0
  28. src/delegation_mcp/gradio_monitor.py +329 -0
  29. src/delegation_mcp/installer/__init__.py +5 -0
  30. src/delegation_mcp/installer/agent_profiles.py +312 -0
  31. src/delegation_mcp/installer/agent_selector.py +164 -0
  32. src/delegation_mcp/installer/config_generator.py +324 -0
  33. src/delegation_mcp/installer/installer.py +268 -0
  34. src/delegation_mcp/installer/mcp_configurator.py +415 -0
  35. src/delegation_mcp/installer/system_instructions.py +195 -0
  36. src/delegation_mcp/installer/task_mapper.py +436 -0
  37. src/delegation_mcp/logging_config.py +159 -0
  38. src/delegation_mcp/orchestrator.py +250 -0
  39. src/delegation_mcp/retry.py +101 -0
  40. src/delegation_mcp/server.py +367 -0
  41. src/delegation_mcp/tool_discovery.py +243 -0
  42. src/delegation_mcp/ui/__init__.py +19 -0
  43. src/delegation_mcp/ui/app.py +216 -0
  44. src/delegation_mcp/ui/config_manager.py +396 -0
  45. src/delegation_mcp/ui/config_tab.py +334 -0
  46. src/delegation_mcp/workflow.py +300 -0
  47. tests/__init__.py +1 -0
  48. tests/test_agent_discovery.py +422 -0
  49. tests/test_config.py +411 -0
  50. tests/test_config_ui.py +77 -0
.dockerignore ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git and version control
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python
7
+ __pycache__
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ .venv
13
+ venv/
14
+ env/
15
+ ENV/
16
+ .pytest_cache
17
+ *.egg-info
18
+ dist/
19
+ build/
20
+
21
+ # IDE
22
+ .vscode
23
+ .idea
24
+ *.swp
25
+ *.swo
26
+ *~
27
+ .DS_Store
28
+
29
+ # Environment files
30
+ .env
31
+ .env.local
32
+ .env.*.local
33
+
34
+ # Logs
35
+ *.log
36
+ logs/
37
+
38
+ # Data and cache
39
+ data/
40
+ *.db
41
+ *.sqlite
42
+
43
+ # Agent-specific
44
+ .gemini
45
+ .claude
46
+ .aider*
47
+
48
+ # Lock files (we'll use requirements.txt in container)
49
+ uv.lock
50
+ poetry.lock
51
+ Pipfile.lock
52
+
53
+ # Documentation
54
+ *.md
55
+ !README.md
56
+
57
+ # Test files
58
+ tests/
59
+ .coverage
60
+ htmlcov/
61
+ .tox/
62
+
63
+ # OS
64
+ Thumbs.db
65
+ .DS_Store
.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Delegation MCP Environment Variables
2
+
3
+ # API Keys (if using cloud-hosted CLIs)
4
+ # ANTHROPIC_API_KEY=your_key_here
5
+ # GOOGLE_API_KEY=your_key_here
6
+ # OPENAI_API_KEY=your_key_here
7
+
8
+ # MCP Server Configuration
9
+ MCP_SERVER_PORT=3000
10
+ MCP_SERVER_HOST=localhost
11
+
12
+ # Gradio UI Configuration
13
+ GRADIO_SERVER_PORT=7860
14
+ GRADIO_SERVER_NAME=0.0.0.0
15
+
16
+ # Logging
17
+ LOG_LEVEL=INFO
18
+ LOG_FILE=delegation_mcp.log
19
+
20
+ # Delegation Settings
21
+ AUTO_APPROVE=false
22
+ LOG_DELEGATIONS=true
.github/workflows/python-app.yml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This workflow will install Python dependencies, run tests and lint with a single version of Python
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3
+
4
+ name: Python application
5
+
6
+ on:
7
+ push:
8
+ branches: [ "main" ]
9
+ pull_request:
10
+ branches: [ "main" ]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+
18
+ runs-on: ubuntu-latest
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - name: Set up Python 3.10
23
+ uses: actions/setup-python@v3
24
+ with:
25
+ python-version: "3.10"
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install flake8 pytest
30
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
31
+ - name: Lint with flake8
32
+ run: |
33
+ # stop the build if there are Python syntax errors or undefined names
34
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35
+ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
36
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37
+ - name: Test with pytest
38
+ run: |
39
+ pytest
.gitignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+ .venv
28
+
29
+ # IDE
30
+ .vscode/
31
+ .idea/
32
+ *.swp
33
+ *.swo
34
+ *~
35
+
36
+ # Environment
37
+ .env
38
+ .env.local
39
+ *.log
40
+
41
+ # MCP
42
+ mcp_data/
43
+ *.db
44
+ *.sqlite
45
+ *.backup
46
+
47
+ # Gradio
48
+ gradio_cached_examples/
49
+ flagged/
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Testing
56
+ .pytest_cache/
57
+ .coverage
58
+ htmlcov/
59
+ .aider*
60
+ .gemini_security/
61
+ bandit_report.json
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+
3
+ # Install Node.js and npm (for Claude/Gemini CLIs)
4
+ RUN apt-get update && apt-get install -y \
5
+ nodejs \
6
+ npm \
7
+ git \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Set working directory
11
+ WORKDIR /app
12
+
13
+ # Copy project files
14
+ COPY . .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+ RUN pip install --no-cache-dir .
19
+
20
+ # Install Agent CLIs
21
+ # Aider is installed via pip (already in requirements or setup.py, but ensuring here)
22
+ RUN pip install --no-cache-dir aider-chat
23
+
24
+ # Gemini and Claude CLIs via npm
25
+ RUN npm install -g @google/gemini-cli @anthropic-ai/claude-code
26
+
27
+ # Create data directory for persistence
28
+ RUN mkdir -p data
29
+ RUN chmod 777 data
30
+
31
+ # Expose port 7860 for Hugging Face Spaces
32
+ EXPOSE 7860
33
+
34
+ # Run the application
35
+ CMD ["python", "app.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Delegation MCP Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Delegation MCP
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: Intelligent Multi-Agent Routing & Guidance
10
+ tags:
11
+ - mcp-server
12
+ - building-mcp-track-enterprise
13
+ - multi-agent
14
+ - agent-orchestration
15
+ ---
16
+
17
+ # 🚀 Delegation MCP Server
18
+
19
+ **Intelligent Multi-Agent Routing & Guidance**
20
+
21
+ [![Tests](https://img.shields.io/badge/tests-passing-brightgreen)]() [![License](https://img.shields.io/badge/license-MIT-blue)]() [![MCP](https://img.shields.io/badge/MCP-1.0-purple)]() [![Version](https://img.shields.io/badge/version-0.4.0-orange)]() [![Anthropic](https://img.shields.io/badge/Anthropic-Compliant-green)]()
22
+
23
+ > *Built for the MCP 1st Birthday Hackathon - Winter 2025*
24
+
25
+ ## ⚡ Quick Start
26
+
27
+ ```bash
28
+ # One command to install and configure everything
29
+ python install.py
30
+ ```
31
+
32
+ **That's it!** Restart Claude Code and start delegating:
33
+
34
+ ```
35
+ "scan this codebase for security vulnerabilities"
36
+ → MCP suggests: "Delegate to Gemini"
37
+ → Claude executes: gemini scan .
38
+
39
+ "design an authentication architecture"
40
+ → MCP suggests: "Handle directly (Claude is best)"
41
+ → Claude executes: (Internal reasoning)
42
+
43
+ "refactor the delegation engine"
44
+ → MCP suggests: "Delegate to Aider"
45
+ → Claude executes: aider --message "refactor delegation engine"
46
+ ```
47
+
48
+ **Features**:
49
+ - ✅ **One-command installation** - 30 seconds to full setup
50
+ - ✅ **Intelligent Routing** - Rules + Capabilities analysis
51
+ - ✅ **Privacy-First** - Your code never passes through this server
52
+ - ✅ **Lightweight** - Minimal footprint, no heavy databases
53
+ - ✅ **Cross-platform** - Windows, Mac, Linux
54
+
55
+ ---
56
+
57
+ ## 🎮 Try the Interactive Demo
58
+
59
+ **[![Hugging Face Spaces](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/Cduplar/multi-agent-mcp)**
60
+
61
+ Experience the routing intelligence in action! Our HF Space demo lets you:
62
+
63
+ ### Interactive Features:
64
+ - 🧪 **Test Any Query** - See routing decisions in real-time
65
+ - 📊 **Routing Transparency** - View the complete decision-making process:
66
+ - Task classification (security, architecture, refactoring, etc.)
67
+ - Complexity assessment (simple/medium/complex)
68
+ - Detected keywords and routing reasoning
69
+ - CLI command that would be executed
70
+ - ⚙️ **Live Configuration** - Toggle agents and routing strategies to see how settings affect decisions
71
+ - 💡 **Example Queries** - Simple and complex multi-step scenarios
72
+
73
+ ### Try This:
74
+ 1. Visit the [HF Space](https://huggingface.co/spaces/Cduplar/multi-agent-mcp)
75
+ 2. Enter: *"Audit the authentication system for SQL injection, XSS, and CSRF vulnerabilities"*
76
+ 3. Watch it route to Gemini with full reasoning
77
+ 4. Disable Gemini in settings → See it route to Claude instead!
78
+
79
+ **Want to test with real agents?** Duplicate the Space and add your API keys!
80
+
81
+ ---
82
+
83
+ ## 🌟 What Is This?
84
+
85
+ A **lightweight MCP server** that acts as a routing intelligence layer for AI coding agents. Instead of executing tasks itself (which creates a bottleneck and security risk), it analyzes your request and **guides** your main agent (like Claude Code) on which tool to use.
86
+
87
+ **Key Insight**: This follows the **Routing Guidance** pattern:
88
+ 1. **Analyze**: The server analyzes the prompt (e.g., "audit security").
89
+ 2. **Route**: It determines the best agent based on your **presets** and **rules**.
90
+ 3. **Guide**: It returns the *exact command* to run.
91
+ 4. **Execute**: The client (Claude) executes the command directly.
92
+
93
+ This ensures **zero lock-in**, **maximum privacy**, and **native performance**.
94
+
95
+ ---
96
+
97
+ ## 🎯 The Core Value Proposition
98
+
99
+ ### Problem
100
+ Developers manually switch between AI agents, losing context and productivity:
101
+ - Claude for architecture
102
+ - Gemini for security analysis
103
+ - Aider for git operations
104
+ - Copilot for GitHub integration
105
+
106
+ ### Solution
107
+ **One MCP server that tells your agent who to call:**
108
+
109
+ ```
110
+ You → Claude Code → Delegation MCP → "Use Gemini for this" → Claude calls Gemini
111
+ ```
112
+
113
+ **You work with ONE agent, but get the power of ALL agents.**
114
+
115
+ ---
116
+
117
+ ## 📦 Installation
118
+
119
+ ### Prerequisites
120
+ - Python 3.10+
121
+ - At least one AI agent CLI installed:
122
+ - [Gemini CLI](https://github.com/google/generative-ai-cli): `npm install -g @google/gemini-cli`
123
+ - [Aider](https://aider.chat): `pip install aider-chat`
124
+ - [Claude Code](https://claude.ai/download): `npm install -g @anthropic-ai/claude-code`
125
+ - [GitHub Copilot](https://github.com/features/copilot): `npm install -g github/copilot`
126
+
127
+ ### Automated Installation (Recommended)
128
+
129
+ ```bash
130
+ # Clone repository
131
+ git clone https://github.com/carlosduplar/multi-agent-mcp.git
132
+ cd multi-agent-mcp
133
+
134
+ # One-command install
135
+ python install.py
136
+
137
+ # Or on Unix/Mac
138
+ bash install.sh
139
+ ```
140
+
141
+ The installer will:
142
+ 1. Check system requirements
143
+ 2. Discover installed agents
144
+ 3. Configure Claude Code automatically
145
+ 4. Verify everything works
146
+
147
+ **Restart Claude Code and you're ready!**
148
+
149
+ ---
150
+
151
+ ## 🎯 How It Works
152
+
153
+ ### Intelligent Routing Guidance
154
+
155
+ We use a hybrid approach to determine the best agent for the job:
156
+
157
+ 1. **Rule-Based Presets**: Your configured rules take priority (e.g., "Always use Gemini for security").
158
+ 2. **Capability Analysis**: If no rule matches, we analyze agent capabilities to find the best fit.
159
+
160
+ **Query**: "scan for vulnerabilities"
161
+
162
+ 1. **Check Rules**: Matches `security_audit` preset? -> **Gemini**
163
+ 2. **Guide**: Return guidance to use Gemini
164
+
165
+ ### Example Interaction
166
+
167
+ **User**: "Audit my authentication code for SQL injection"
168
+
169
+ **Claude Code** calls `get_routing_guidance`:
170
+ ```json
171
+ {
172
+ "query": "Audit auth.py for SQL injection"
173
+ }
174
+ ```
175
+
176
+ **MCP Server** responds:
177
+ ```json
178
+ {
179
+ "decision": "DELEGATE_TO: gemini",
180
+ "agent": "gemini",
181
+ "task_type": "security_audit",
182
+ "cli_command": "gemini \"Audit auth.py for SQL injection\""
183
+ }
184
+ ```
185
+
186
+ **Claude Code** then executes:
187
+ ```bash
188
+ gemini "Audit auth.py for SQL injection"
189
+ ```
190
+
191
+ ---
192
+
193
+ ## 🔧 MCP Tools
194
+
195
+ ### `get_routing_guidance`
196
+ Get routing guidance for a task. Returns which agent should handle it and the exact CLI command to run.
197
+
198
+ ```python
199
+ {
200
+ "query": "Audit auth.py for SQL injection"
201
+ }
202
+ ```
203
+
204
+ ### `discover_agents`
205
+ Automatically discover available CLI agents on the system and register them.
206
+
207
+ ```python
208
+ {
209
+ "force_refresh": false # Optional: force re-discovery
210
+ }
211
+ ```
212
+
213
+ ### `list_agents`
214
+ List all registered agents and their availability status.
215
+
216
+ ### ⚡ Token Overhead
217
+
218
+ One of the key advantages of this MCP server is its **minimal context footprint**. Here's the actual token usage:
219
+
220
+ ```
221
+ MCP Tools:
222
+ ├─ get_routing_guidance: 601 tokens
223
+ ├─ discover_agents: 584 tokens
224
+ └─ list_agents: 554 tokens
225
+ ─────────
226
+ Total MCP overhead: 1,739 tokens (0.9% of 200k context)
227
+ ```
228
+
229
+ **What this means**:
230
+ - ✅ Less than 1% of your context budget
231
+ - ✅ Leaves 99%+ for actual code and conversation
232
+ - ✅ No heavy prompts or bloated instructions
233
+ - ✅ Intelligent routing without sacrificing context
234
+
235
+ Compare this to running multiple agent instances or complex orchestration frameworks that can consume 10-20% of your context just for coordination overhead.
236
+
237
+ ---
238
+
239
+ ## 🏗️ Architecture
240
+
241
+ ```
242
+ ┌─────────────────────────────────────────┐
243
+ │ Claude Code (or other MCP client) │
244
+ │ - User chats here │
245
+ │ - Calls get_routing_guidance │
246
+ │ - EXECUTES the returned command │
247
+ └──────────────┬──────────────────────────┘
248
+ │ MCP Protocol (stdio)
249
+
250
+ ┌──────────────────────────────────────────┐
251
+ │ Delegation MCP Server │
252
+ │ - Analyzes task complexity & type │
253
+ │ - Checks rules & capabilities │
254
+ │ - Returns guidance (NO EXECUTION) │
255
+ └──────────────────────────────────────────┘
256
+ ```
257
+
258
+ ### v0.4.0 - Lightweight Architecture
259
+
260
+ **Privacy & Security**:
261
+ - **No Code Execution**: The server never executes code or commands. It only suggests them.
262
+ - **No Data Persistence**: No databases or logs of your code are kept by the server.
263
+ - **Direct Connection**: Your agent talks directly to the delegated tool (e.g., Claude -> Gemini).
264
+
265
+ **Agent Auto-Discovery**:
266
+ - Automatically detects installed CLI agents (Claude, Gemini, Aider, etc.)
267
+ - Verifies agent availability
268
+ - Graceful error handling
269
+
270
+ ---
271
+
272
+ ## 🗂️ Project Structure
273
+
274
+ ```
275
+ multi-agent-mcp/
276
+ ├── src/delegation_mcp/
277
+ │ ├── server.py # MCP server (Routing Guidance) ⭐
278
+ │ ├── delegation.py # Routing logic & scoring
279
+ │ ├── orchestrator.py # Agent registry
280
+ │ ├── agent_discovery.py # System scanner for agents
281
+ │ ├── tool_discovery.py # Tool definitions
282
+ │ ├── config.py # Configuration handling
283
+ │ ├── cli.py # CLI tools
284
+ │ └── adapters/ # Agent definitions
285
+ │ ├── claude.py
286
+ │ ├── gemini.py
287
+ │ ├── copilot.py
288
+ │ └── aider.py
289
+ ├── tools/ # Tool definitions (JSON)
290
+ ├── tests/ # Comprehensive tests
291
+ └── config/ # Default delegation rules
292
+ ```
293
+
294
+ ---
295
+
296
+ ## 🚀 Roadmap
297
+
298
+ ### ✅ Phase 1: Foundation (COMPLETE)
299
+ - MCP server with routing guidance
300
+ - Capability-based routing
301
+ - Agent auto-discovery
302
+ - Production-grade architecture
303
+
304
+ ### 🔜 Phase 2: Intelligence (Q1 2026)
305
+ - ML-powered routing
306
+ - Learning from user feedback
307
+ - Custom agent definitions
308
+
309
+ ### 🔮 Phase 3: Collaboration (Q2 2026)
310
+ - Complex multi-step workflows
311
+ - Parallel agent execution guidance
312
+
313
+ ---
314
+
315
+ ## 🤝 Contributing
316
+
317
+ We welcome contributions! Add new agent adapters, improve routing logic, or enhance documentation.
318
+
319
+ ---
320
+
321
+ ## 📄 License
322
+
323
+ MIT License - see [LICENSE](LICENSE)
324
+
325
+ ---
326
+
327
+ ## 🎯 The Vision
328
+
329
+ > **"You work with ONE agent, but get the power of ALL agents."**
330
+
331
+ Today's AI landscape has amazing specialists, but they work in silos. **Delegation MCP changes that.** It's the intelligence layer that lets agents collaborate, creating something greater than the sum of its parts.
332
+
333
+ ---
334
+
335
+ **Built with ❤️ for the MCP ecosystem**
app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+
6
+ import uvicorn
7
+ from fastapi import FastAPI, Request
8
+ from sse_starlette.sse import EventSourceResponse
9
+ from mcp.server.sse import SseServerTransport
10
+
11
+ import gradio as gr
12
+ from delegation_mcp.server import DelegationMCPServer
13
+ from delegation_mcp.gradio_monitor import create_monitor_ui
14
+ from mcp.server.models import InitializationOptions
15
+
16
+
17
+ # Configure logging
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Global server instance
22
+ mcp_server = None
23
+ transport = None
24
+
25
+ @asynccontextmanager
26
+ async def lifespan(app: FastAPI):
27
+ """Manage server lifecycle."""
28
+ global mcp_server
29
+
30
+ # Initialize MCP server
31
+ logger.info("Initializing Delegation MCP Server...")
32
+ mcp_server = DelegationMCPServer(enable_auto_discovery=True)
33
+
34
+ # Initialize agent discovery
35
+ if mcp_server.enable_auto_discovery:
36
+ await mcp_server._discover_and_register_agents()
37
+
38
+ logger.info("MCP Server ready!")
39
+
40
+ yield
41
+
42
+ # Cleanup
43
+ logger.info("Shutting down Delegation MCP Server...")
44
+
45
+ app = FastAPI(lifespan=lifespan)
46
+
47
+ @app.get("/sse")
48
+ async def handle_sse(request: Request):
49
+ """Handle SSE connections for MCP."""
50
+ global mcp_server, transport
51
+
52
+ transport = SseServerTransport("/messages")
53
+
54
+ async def event_generator():
55
+ async with mcp_server.server.run(
56
+ transport.read_stream,
57
+ transport.write_stream,
58
+ InitializationOptions(
59
+ server_name="delegation-mcp",
60
+ server_version="0.3.0",
61
+ capabilities=mcp_server.server.get_capabilities(
62
+ notification_options=None,
63
+ experimental_capabilities={
64
+ "tool_discovery": {},
65
+ "on_demand_loading": {},
66
+ "agent_discovery": {},
67
+ },
68
+ ),
69
+ )
70
+ ) as stream:
71
+ async for message in stream:
72
+ yield message
73
+
74
+ return EventSourceResponse(event_generator())
75
+
76
+ @app.post("/messages")
77
+ async def handle_messages(request: Request):
78
+ """Handle incoming messages for MCP."""
79
+ global transport
80
+ if transport:
81
+ return await transport.handle_post_message(request)
82
+ return {"error": "No active transport"}
83
+
84
+ # Initialize a temporary server for the Gradio UI demo
85
+ # This is separate from the MCP server instance above
86
+ temp_server_for_ui = DelegationMCPServer(enable_auto_discovery=False)
87
+
88
+ # Mount Gradio app
89
+ logger.info("Mounting Gradio monitor...")
90
+ monitor_app = create_monitor_ui(demo_server=temp_server_for_ui)
91
+ app = gr.mount_gradio_app(app, monitor_app, path="/")
92
+
93
+ if __name__ == "__main__":
94
+ uvicorn.run(app, host="0.0.0.0", port=7860)
config/delegation_rules.yaml ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Delegation MCP Configuration
2
+ # Auto-generated based on user selections
3
+
4
+ orchestrator: claude
5
+ routing_strategy: hybrid
6
+ orchestrators:
7
+ claude:
8
+ name: claude
9
+ command: claude
10
+ args: ["-p"]
11
+ enabled: true
12
+ env: {}
13
+ timeout: 300
14
+ max_retries: 3
15
+ cost_per_1k_tokens: 0.001
16
+ capabilities:
17
+ security_audit: 0.8
18
+ vulnerability_scan: 0.75
19
+ code_review: 0.9
20
+ architecture: 0.95
21
+ refactoring: 0.75
22
+ quick_fix: 0.7
23
+ documentation: 0.9
24
+ testing: 0.8
25
+ performance: 0.75
26
+ browser_interaction: 0.6
27
+ git_operations: 0.65
28
+ shell_tasks: 0.65
29
+ general: 0.85
30
+ gemini:
31
+ name: gemini
32
+ command: gemini
33
+ args: []
34
+ enabled: true
35
+ env: {}
36
+ timeout: 300
37
+ max_retries: 3
38
+ cost_per_1k_tokens: 0.001
39
+ capabilities:
40
+ security_audit: 0.85
41
+ vulnerability_scan: 0.8
42
+ code_review: 0.85
43
+ architecture: 0.8
44
+ refactoring: 0.75
45
+ quick_fix: 0.75
46
+ documentation: 0.8
47
+ testing: 0.8
48
+ performance: 0.85
49
+ browser_interaction: 0.7
50
+ git_operations: 0.7
51
+ shell_tasks: 0.7
52
+ general: 0.85
53
+ aider:
54
+ name: aider
55
+ command: aider
56
+ args: ["--yes", "--no-auto-commits"]
57
+ enabled: true
58
+ env: {}
59
+ timeout: 300
60
+ max_retries: 3
61
+ cost_per_1k_tokens: 0.001
62
+ capabilities:
63
+ security_audit: 0.6
64
+ vulnerability_scan: 0.6
65
+ code_review: 0.75
66
+ architecture: 0.65
67
+ refactoring: 0.95
68
+ quick_fix: 0.9
69
+ documentation: 0.7
70
+ testing: 0.75
71
+ performance: 0.7
72
+ browser_interaction: 0.4
73
+ git_operations: 0.95
74
+ shell_tasks: 0.8
75
+ general: 0.7
76
+ rules:
77
+ - delegate_to: gemini
78
+ description: Security audits, vulnerability scans, safety checks
79
+ pattern: security|vulnerability|audit|CVE
80
+ priority: 10
81
+ requires_approval: false
82
+ - delegate_to: gemini
83
+ description: Code quality review, best practices analysis
84
+ pattern: review|code quality|best practices
85
+ priority: 9
86
+ requires_approval: false
87
+ - delegate_to: claude
88
+ description: System design, architecture planning, complex reasoning
89
+ pattern: architecture|design|system design
90
+ priority: 8
91
+ requires_approval: false
92
+ - delegate_to: aider
93
+ description: Code refactoring, cleanup, optimization
94
+ pattern: refactor|cleanup|optimize code
95
+ priority: 7
96
+ requires_approval: false
97
+ - delegate_to: aider
98
+ description: Rapid bug fixes, small code changes
99
+ pattern: fix|bug|quick change
100
+ priority: 6
101
+ requires_approval: false
102
+ - delegate_to: claude
103
+ description: README files, API docs, code comments
104
+ pattern: documentation|docs|README|comments
105
+ priority: 5
106
+ requires_approval: false
107
+ - delegate_to: gemini
108
+ description: Unit tests, integration tests, test coverage
109
+ pattern: test|testing|coverage
110
+ priority: 4
111
+ requires_approval: false
112
+ - delegate_to: gemini
113
+ description: Performance analysis and optimization
114
+ pattern: performance|optimize|speed
115
+ priority: 3
116
+ requires_approval: false
117
+ - delegate_to: gemini
118
+ description: Browser automation, web scraping, UI testing
119
+ pattern: browser|selenium|playwright|chrome
120
+ priority: 2
121
+ requires_approval: false
122
+ - delegate_to: aider
123
+ description: Git workflows, repository management
124
+ pattern: git|commit|merge|branch
125
+ priority: 1
126
+ requires_approval: false
127
+ - delegate_to: aider
128
+ description: Shell scripting, terminal commands
129
+ pattern: shell|terminal|bash|script
130
+ priority: 1
131
+ requires_approval: false
132
+ - delegate_to: claude
133
+ description: Default for tasks that don't fit specific categories
134
+ pattern: general|misc|other
135
+ priority: 1
136
+ requires_approval: false
137
+ - delegate_to: claude
138
+ description: General queries and fallback
139
+ pattern: .*
140
+ priority: 1
141
+ requires_approval: false
config/orchestrators.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ orchestrators:
2
+ claude:
3
+ name: claude
4
+ command: claude
5
+ args: []
6
+ enabled: true
7
+ env: {}
8
+ timeout: 300
9
+ max_retries: 3
10
+ gemini:
11
+ name: gemini
12
+ command: gemini
13
+ args: []
14
+ enabled: true
15
+ env: {}
16
+ timeout: 300
17
+ max_retries: 3
18
+ aider:
19
+ name: aider
20
+ command: aider
21
+ args: []
22
+ enabled: true
23
+ env: {}
24
+ timeout: 300
25
+ max_retries: 3
examples/claude_code_usage.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Example: How Claude Code should use the Delegation MCP Server
3
+
4
+ This demonstrates the CORRECT MCP code execution pattern:
5
+ ✅ Claude Code writes code that imports and uses the MCP server
6
+ ❌ NOT a chat UI that directly calls MCP tools
7
+
8
+ When you ask Claude Code to delegate a task, it should write code similar to this.
9
+ """
10
+
11
+ import asyncio
12
+ from pathlib import Path
13
+ from delegation_mcp.server import DelegationMCPServer
14
+ from delegation_mcp.config import DelegationConfig
15
+
16
+
17
+ async def example_delegation():
18
+ """Example: Claude Code delegating a security audit to Gemini."""
19
+
20
+ print("🚀 Initializing Delegation MCP Server...")
21
+
22
+ # Initialize the server (Claude Code does this)
23
+ server = DelegationMCPServer(
24
+ config_path=Path("config/delegation_rules.yaml"),
25
+ enable_security=True,
26
+ enable_persistence=True,
27
+ )
28
+
29
+ print("✅ Server initialized\n")
30
+
31
+ # Example 1: Delegate security audit to Gemini
32
+ print("📝 Example 1: Security Audit")
33
+ print("Query: 'Audit auth.py for SQL injection vulnerabilities'")
34
+ print("Expected: Routes to Gemini (best for security analysis)\n")
35
+
36
+ # This is what Claude Code would do when you ask it to delegate:
37
+ result = await server.engine.process(
38
+ "Audit the authentication code for SQL injection vulnerabilities"
39
+ )
40
+
41
+ print(f"Orchestrator: {result.orchestrator}")
42
+ print(f"Delegated to: {result.delegated_to}")
43
+ print(f"Success: {result.success}")
44
+ print(f"Duration: {result.duration:.2f}s")
45
+ print(f"Output preview: {result.output[:200]}...\n")
46
+
47
+ print("-" * 60 + "\n")
48
+
49
+ # Example 2: Delegate refactoring to Claude
50
+ print("📝 Example 2: Code Refactoring")
51
+ print("Query: 'Refactor database connection to use connection pooling'")
52
+ print("Expected: Routes to Claude (best for architecture)\n")
53
+
54
+ result = await server.engine.process(
55
+ "Refactor the database connection code to use connection pooling"
56
+ )
57
+
58
+ print(f"Orchestrator: {result.orchestrator}")
59
+ print(f"Delegated to: {result.delegated_to}")
60
+ print(f"Success: {result.success}")
61
+ print(f"Duration: {result.duration:.2f}s")
62
+ print(f"Output preview: {result.output[:200]}...\n")
63
+
64
+ print("-" * 60 + "\n")
65
+
66
+ # Example 3: Check agent statistics
67
+ print("📊 Example 3: Get Delegation Statistics")
68
+
69
+ if server.persistence:
70
+ stats = server.persistence.get_statistics()
71
+ print(f"Total tasks: {stats.get('total_tasks', 0)}")
72
+ print(f"Success rate: {stats.get('success_rate', 0):.1%}")
73
+ print(f"Average duration: {stats.get('avg_duration', 0):.2f}s")
74
+ print(f"Agent usage: {stats.get('agent_usage', {})}\n")
75
+
76
+ print("✅ Examples complete!")
77
+ print("\n" + "=" * 60)
78
+ print("💡 Key Insight:")
79
+ print("=" * 60)
80
+ print("""
81
+ This is the CORRECT MCP pattern:
82
+ - Claude Code writes and runs this Python code
83
+ - The code imports and uses the delegation MCP server
84
+ - Tasks are routed to specialized agents automatically
85
+ - Results come back through the code
86
+
87
+ This is WRONG:
88
+ - A Gradio chat UI that directly calls MCP tools
89
+ - That violates the code execution pattern
90
+ - The UI should only monitor activity, not execute it
91
+ """)
92
+
93
+
94
+ async def example_mcp_protocol_usage():
95
+ """
96
+ Example: How an MCP client (like Claude Code) uses the server via MCP protocol.
97
+
98
+ Note: This is pseudocode showing what happens under the hood when Claude Code
99
+ uses the MCP server through the stdio protocol.
100
+ """
101
+ print("\n" + "=" * 60)
102
+ print("📡 MCP Protocol Usage (Pseudocode)")
103
+ print("=" * 60 + "\n")
104
+
105
+ print("""
106
+ When Claude Code is configured to use this MCP server:
107
+
108
+ 1. Configuration (~/.config/claude/mcp.json):
109
+ {
110
+ "mcpServers": {
111
+ "delegation": {
112
+ "command": "delegation-mcp"
113
+ }
114
+ }
115
+ }
116
+
117
+ 2. User asks Claude Code: "Audit my code for security issues"
118
+
119
+ 3. Claude Code recognizes it should use the delegation server
120
+
121
+ 4. Claude Code calls the MCP tool via stdio:
122
+ {
123
+ "jsonrpc": "2.0",
124
+ "method": "tools/call",
125
+ "params": {
126
+ "name": "delegate_task",
127
+ "arguments": {
128
+ "query": "Audit auth.py for SQL injection",
129
+ "orchestrator": "claude"
130
+ }
131
+ }
132
+ }
133
+
134
+ 5. Delegation MCP server receives the call, routes to Gemini
135
+
136
+ 6. Response comes back:
137
+ {
138
+ "orchestrator": "claude",
139
+ "delegated_to": "gemini",
140
+ "success": true,
141
+ "output": "Found 3 SQL injection vulnerabilities...",
142
+ "duration": 2.5
143
+ }
144
+
145
+ 7. Claude Code presents the result to the user
146
+
147
+ ✅ User works with Claude Code, but gets Gemini's security expertise!
148
+ """)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ print("""
153
+ ╔═══════════════════════════════════════════════════════════════╗
154
+ ║ Delegation MCP Server - Correct Usage Pattern Examples ║
155
+ ║ ║
156
+ ║ This demonstrates how Claude Code (or other MCP clients) ║
157
+ ║ should use the delegation server following Anthropic's ║
158
+ ║ code execution pattern. ║
159
+ ╚═══════════════════════════════════════════════════════════════╝
160
+ """)
161
+
162
+ # Run async examples
163
+ asyncio.run(example_delegation())
164
+
165
+ # Show MCP protocol usage
166
+ asyncio.run(example_mcp_protocol_usage())
167
+
168
+ print("\n" + "=" * 60)
169
+ print("🎯 Ready for Production!")
170
+ print("=" * 60)
171
+ print("""
172
+ To use in production:
173
+
174
+ 1. Install: pip install -e .
175
+ 2. Configure Claude Code to use delegation-mcp
176
+ 3. Chat with Claude Code naturally
177
+ 4. Watch tasks get routed to the best agent!
178
+
179
+ Optional: Run `delegation-monitor` to visualize activity for demos.
180
+ """)
examples/example_workflow.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example workflow using delegation MCP."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from delegation_mcp import DelegationConfig, OrchestratorRegistry, DelegationEngine
7
+
8
+
9
+ async def main():
10
+ """Run example delegation workflow."""
11
+
12
+ # Load configuration
13
+ config_path = Path("config/delegation_rules.yaml")
14
+ config = DelegationConfig.from_yaml(config_path)
15
+
16
+ # Setup registry and engine
17
+ registry = OrchestratorRegistry()
18
+ engine = DelegationEngine(config, registry)
19
+
20
+ # Register orchestrators
21
+ for name, orch_config in config.orchestrators.items():
22
+ registry.register(orch_config)
23
+
24
+ print("=== Delegation MCP Example Workflow ===\n")
25
+
26
+ # Example 1: Security audit (should delegate to Gemini)
27
+ print("Example 1: Security Audit")
28
+ result1 = await engine.process("Run a security audit on authentication module")
29
+ print(f" Primary: {result1.orchestrator}")
30
+ print(f" Delegated to: {result1.delegated_to}")
31
+ print(f" Rule matched: {result1.rule.pattern if result1.rule else 'None'}")
32
+ print(f" Success: {result1.success}\n")
33
+
34
+ # Example 2: Refactoring (should delegate to Aider)
35
+ print("Example 2: Refactoring")
36
+ result2 = await engine.process("Refactor the database connection code")
37
+ print(f" Primary: {result2.orchestrator}")
38
+ print(f" Delegated to: {result2.delegated_to}")
39
+ print(f" Rule matched: {result2.rule.pattern if result2.rule else 'None'}")
40
+ print(f" Success: {result2.success}\n")
41
+
42
+ # Example 3: Pull request (should delegate to Copilot if enabled)
43
+ print("Example 3: Pull Request")
44
+ result3 = await engine.process("Create a pull request for the new feature")
45
+ print(f" Primary: {result3.orchestrator}")
46
+ print(f" Delegated to: {result3.delegated_to}")
47
+ print(f" Rule matched: {result3.rule.pattern if result3.rule else 'None'}")
48
+ print(f" Success: {result3.success}\n")
49
+
50
+ # Example 4: No rule match (uses primary orchestrator)
51
+ print("Example 4: No Rule Match")
52
+ result4 = await engine.process("Explain how async/await works in Python")
53
+ print(f" Primary: {result4.orchestrator}")
54
+ print(f" Delegated to: {result4.delegated_to}")
55
+ print(f" Rule matched: {result4.rule.pattern if result4.rule else 'None'}")
56
+ print(f" Success: {result4.success}\n")
57
+
58
+ # Show statistics
59
+ print("=== Statistics ===")
60
+ stats = engine.get_statistics()
61
+ print(f"Total queries: {stats['total']}")
62
+ print(f"Delegations: {stats['delegations']}")
63
+ print(f"Delegation rate: {stats['delegation_rate']:.1f}%")
64
+ print(f"Success rate: {stats['success_rate']:.1f}%")
65
+ print(f"Avg duration: {stats['avg_duration']:.2f}s")
66
+ print(f"\nBy orchestrator:")
67
+ for orch, count in stats['by_orchestrator'].items():
68
+ print(f" {orch}: {count}")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ asyncio.run(main())
install.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Delegation MCP Installer - One-command setup."""
3
+
4
+ if __name__ == "__main__":
5
+ from src.delegation_mcp.installer import DelegationInstaller
6
+ import sys
7
+
8
+ installer = DelegationInstaller()
9
+ success = installer.install()
10
+ sys.exit(0 if success else 1)
install.sh ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Delegation MCP Installer - One-line installation script
3
+ # Usage: curl -fsSL https://raw.githubusercontent.com/USER/REPO/main/install.sh | bash
4
+
5
+ set -e
6
+
7
+ echo "=========================================="
8
+ echo "Delegation MCP Installer"
9
+ echo "=========================================="
10
+
11
+ # Check Python version
12
+ if ! command -v python3 &> /dev/null; then
13
+ echo "Error: Python 3 not found. Please install Python 3.10+"
14
+ exit 1
15
+ fi
16
+
17
+ PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
18
+ echo "Found Python $PYTHON_VERSION"
19
+
20
+ # Check minimum version
21
+ if ! python3 -c 'import sys; exit(0 if sys.version_info >= (3, 10) else 1)'; then
22
+ echo "Error: Python 3.10+ required (found $PYTHON_VERSION)"
23
+ exit 1
24
+ fi
25
+
26
+ # Clone or update repository
27
+ if [ -d "multi-agent-mcp" ]; then
28
+ echo "Updating existing installation..."
29
+ cd multi-agent-mcp
30
+ git pull
31
+ else
32
+ echo "Cloning repository..."
33
+ git clone https://github.com/carlosduplar/multi-agent-mcp.git
34
+ cd multi-agent-mcp
35
+ fi
36
+
37
+ # Install dependencies
38
+ echo "Installing dependencies..."
39
+ if command -v uv &> /dev/null; then
40
+ echo "Using uv (fast!)..."
41
+ uv sync
42
+ else
43
+ echo "Using pip..."
44
+ pip install -e .
45
+ fi
46
+
47
+ # Run installer
48
+ echo ""
49
+ echo "Running automated setup..."
50
+ python3 install.py
51
+
52
+ echo ""
53
+ echo "=========================================="
54
+ echo "Installation script complete!"
55
+ echo "=========================================="
56
+ echo ""
57
+ echo "Next: Restart Claude Code and try:"
58
+ echo " 'scan for security vulnerabilities'"
59
+ echo ""
pyproject.toml ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "delegation-mcp"
7
+ version = "0.4.0"
8
+ description = "Multi-orchestrator delegation MCP server for AI coding agents with production-grade architecture"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Your Name", email = "your.email@example.com"}
14
+ ]
15
+ keywords = ["mcp", "ai", "agents", "delegation", "orchestration"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ ]
24
+
25
+ dependencies = [
26
+ "mcp>=1.0.0",
27
+ "pydantic>=2.0.0",
28
+ "pyyaml>=6.0",
29
+ "click>=8.0.0",
30
+ "rich>=13.0.0",
31
+ "asyncio-mqtt>=0.16.0",
32
+ "python-dotenv>=1.0.0",
33
+ "psutil>=5.9.0", # For resource monitoring (CPU, memory limits)
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ ui = [
38
+ "gradio>=5.0.0",
39
+ ]
40
+ dev = [
41
+ "pytest>=7.0.0",
42
+ "pytest-asyncio>=0.21.0",
43
+ "black>=23.0.0",
44
+ "ruff>=0.1.0",
45
+ "mypy>=1.0.0",
46
+ ]
47
+
48
+ [project.scripts]
49
+ delegation-mcp = "delegation_mcp.server:main"
50
+ delegation-ui = "delegation_mcp.ui.app:main"
51
+ delegation-workflow = "delegation_mcp.cli:main"
52
+ delegation-install = "delegation_mcp.installer:main"
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["src"]
56
+
57
+ [tool.black]
58
+ line-length = 100
59
+ target-version = ["py310", "py311", "py312"]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ select = ["E", "F", "I", "N", "W"]
64
+
65
+ [tool.mypy]
66
+ python_version = "3.10"
67
+ strict = true
68
+ warn_return_any = true
69
+ warn_unused_configs = true
70
+
71
+ [tool.pytest.ini_options]
72
+ asyncio_mode = "auto"
73
+ asyncio_default_fixture_loop_scope = "function"
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mcp>=1.0.0
2
+ gradio>=5.0.0
3
+ pydantic>=2.0.0
4
+ pyyaml>=6.0
5
+ click>=8.0.0
6
+ rich>=13.0.0
7
+ asyncio-mqtt>=0.16.0
8
+ python-dotenv>=1.0.0
9
+ psutil>=5.9.0
10
+ sse-starlette>=1.8.0
11
+ uvicorn>=0.20.0
12
+ fastapi>=0.100.0
src/delegation_mcp/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Delegation MCP Server - Multi-orchestrator delegation for AI coding agents."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ from .config import DelegationConfig, OrchestratorConfig, DelegationRule
6
+ from .orchestrator import OrchestratorRegistry
7
+ from .delegation import DelegationEngine
8
+
9
+ __all__ = [
10
+ "DelegationConfig",
11
+ "OrchestratorConfig",
12
+ "DelegationRule",
13
+ "OrchestratorRegistry",
14
+ "DelegationEngine",
15
+ ]
src/delegation_mcp/adapters/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI adapters for different orchestrators."""
2
+
3
+ from .base import CLIAdapter
4
+ from .claude import ClaudeAdapter
5
+ from .gemini import GeminiAdapter
6
+ from .copilot import CopilotAdapter
7
+ from .aider import AiderAdapter
8
+
9
+ __all__ = [
10
+ "CLIAdapter",
11
+ "ClaudeAdapter",
12
+ "GeminiAdapter",
13
+ "CopilotAdapter",
14
+ "AiderAdapter",
15
+ ]
src/delegation_mcp/adapters/aider.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aider CLI adapter."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from .base import CLIAdapter
8
+
9
+
10
+ class AiderAdapter(CLIAdapter):
11
+ """Adapter for Aider CLI."""
12
+
13
+ async def execute(self, task: str, progress_callback: Any = None, timeout: int | None = None, **kwargs: Any) -> tuple[str, str, int]:
14
+ """Execute task using Aider CLI with optional streaming."""
15
+ if progress_callback:
16
+ return await self.execute_streaming(task, progress_callback, timeout, **kwargs)
17
+
18
+ cmd = self.format_task(task, **kwargs)
19
+ resolved_cmd = self.resolve_command(cmd)
20
+
21
+ # Sanitize environment for Windows/prompt_toolkit compatibility
22
+ env = {**subprocess.os.environ, **self.get_env()}
23
+ env["TERM"] = "dumb" # Prevent prompt_toolkit from seeing xterm-256color on Windows
24
+ env["PYTHONIOENCODING"] = "utf-8"
25
+
26
+ process = await asyncio.create_subprocess_exec(
27
+ *resolved_cmd,
28
+ stdout=asyncio.subprocess.PIPE,
29
+ stderr=asyncio.subprocess.PIPE,
30
+ env=env,
31
+ )
32
+
33
+ try:
34
+ effective_timeout = timeout or self.get_timeout()
35
+ stdout, stderr = await asyncio.wait_for(
36
+ process.communicate(), timeout=effective_timeout
37
+ )
38
+ return (
39
+ stdout.decode("utf-8", errors="replace"),
40
+ stderr.decode("utf-8", errors="replace"),
41
+ process.returncode or 0,
42
+ )
43
+ except asyncio.TimeoutError:
44
+ process.kill()
45
+ await process.wait()
46
+ raise TimeoutError(f"Aider CLI timed out after {effective_timeout}s")
47
+
48
+ def get_env(self) -> dict[str, str]:
49
+ """Get environment variables for Aider."""
50
+ env = super().get_env().copy()
51
+ env["TERM"] = "dumb" # Prevent prompt_toolkit from seeing xterm-256color on Windows
52
+ env["PYTHONIOENCODING"] = "utf-8"
53
+ return env
54
+
55
+ def validate(self) -> bool:
56
+ """Validate Aider is available."""
57
+ try:
58
+ subprocess.run(
59
+ ["which", "aider"] if subprocess.os.name != "nt" else ["where", "aider"],
60
+ capture_output=True,
61
+ check=True,
62
+ )
63
+ return True
64
+ except subprocess.CalledProcessError:
65
+ return False
66
+
67
+ def format_task(self, task: str, **kwargs: Any) -> list[str]:
68
+ """Format task for Aider CLI."""
69
+ cmd = ["aider"]
70
+
71
+ # Add message flag for non-interactive mode
72
+ cmd.append("--message")
73
+ cmd.append(task)
74
+
75
+ # Add model if specified
76
+ if model := kwargs.get("model"):
77
+ cmd.extend(["--model", model])
78
+
79
+ # Add mode flags
80
+ if kwargs.get("architect_mode"):
81
+ cmd.append("--architect")
82
+ elif kwargs.get("ask_mode"):
83
+ cmd.append("--ask")
84
+
85
+ # Auto-commit changes
86
+ if kwargs.get("auto_commit", True):
87
+ cmd.append("--auto-commits")
88
+
89
+ # Add optimization flags
90
+ cmd.extend([
91
+ "--no-pretty",
92
+ "--stream",
93
+ "--no-check-update",
94
+ "--no-show-release-notes",
95
+ "--verbose",
96
+ "--yes-always",
97
+ ])
98
+
99
+ # Add any custom args from config
100
+ cmd.extend(self.get_args())
101
+
102
+ return cmd
src/delegation_mcp/adapters/base.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base adapter interface for CLI orchestrators."""
2
+
3
+ import os
4
+ import shutil
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class CLIAdapter(ABC):
10
+ """Base adapter for CLI orchestrators."""
11
+
12
+ def __init__(self, name: str, config: dict[str, Any]):
13
+ self.name = name
14
+ self.config = config
15
+
16
+ @abstractmethod
17
+ async def execute(self, task: str, **kwargs: Any) -> tuple[str, str, int]:
18
+ """
19
+ Execute a task using the CLI.
20
+
21
+ Args:
22
+ task: Task description/query
23
+ **kwargs: Additional CLI-specific arguments
24
+
25
+ Returns:
26
+ tuple: (stdout, stderr, return_code)
27
+ """
28
+ pass
29
+
30
+ @abstractmethod
31
+ def validate(self) -> bool:
32
+ """Validate the CLI is installed and accessible."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def format_task(self, task: str, **kwargs: Any) -> list[str]:
37
+ """
38
+ Format task into CLI command arguments.
39
+
40
+ Args:
41
+ task: Task description
42
+ **kwargs: Additional formatting options
43
+
44
+ Returns:
45
+ list: Command arguments
46
+ """
47
+ pass
48
+
49
+ def get_command(self) -> str | list[str]:
50
+ """Get base command for this adapter."""
51
+ return self.config.get("command", self.name)
52
+
53
+ def get_args(self) -> list[str]:
54
+ """Get default arguments for this adapter."""
55
+ return self.config.get("args", [])
56
+
57
+ def get_env(self) -> dict[str, str]:
58
+ """Get environment variables for this adapter."""
59
+ return self.config.get("env", {})
60
+
61
+ def get_timeout(self) -> int:
62
+ """Get timeout in seconds."""
63
+ return self.config.get("timeout", 300)
64
+
65
+ async def execute_streaming(
66
+ self,
67
+ task: str,
68
+ progress_callback: Any = None,
69
+ timeout: int | None = None,
70
+ **kwargs: Any
71
+ ) -> tuple[str, str, int]:
72
+ """
73
+ Execute task with real-time output streaming.
74
+
75
+ Args:
76
+ task: Task description/query
77
+ progress_callback: Optional async callback for progress updates
78
+ timeout: Override timeout in seconds
79
+ **kwargs: Additional CLI-specific arguments
80
+
81
+ Returns:
82
+ tuple: (stdout, stderr, return_code)
83
+ """
84
+ import asyncio
85
+
86
+ # Get command and args
87
+ cmd_args = self.format_task(task, **kwargs)
88
+ resolved_cmd = self.resolve_command(cmd_args[0])
89
+
90
+ if isinstance(resolved_cmd, str):
91
+ full_cmd = [resolved_cmd] + cmd_args[1:]
92
+ else:
93
+ full_cmd = resolved_cmd + cmd_args[1:]
94
+
95
+ # Merge environment
96
+ env = os.environ.copy()
97
+ env.update(self.get_env())
98
+
99
+ # Start process with pipes for streaming
100
+ process = await asyncio.create_subprocess_exec(
101
+ *full_cmd,
102
+ stdout=asyncio.subprocess.PIPE,
103
+ stderr=asyncio.subprocess.PIPE,
104
+ env=env
105
+ )
106
+
107
+ stdout_lines = []
108
+ stderr_lines = []
109
+
110
+ async def read_stream(stream, line_buffer, prefix=""):
111
+ """Read stream line-by-line and call progress callback."""
112
+ while True:
113
+ line = await stream.readline()
114
+ if not line:
115
+ break
116
+ decoded = line.decode().strip()
117
+ if decoded:
118
+ line_buffer.append(decoded)
119
+ if progress_callback:
120
+ try:
121
+ await progress_callback(f"{prefix}{decoded}")
122
+ except Exception:
123
+ pass # Don't fail on callback errors
124
+
125
+ try:
126
+ # Read both streams concurrently with timeout
127
+ effective_timeout = timeout or self.get_timeout()
128
+ await asyncio.wait_for(
129
+ asyncio.gather(
130
+ read_stream(process.stdout, stdout_lines),
131
+ read_stream(process.stderr, stderr_lines, "[stderr] "),
132
+ ),
133
+ timeout=effective_timeout
134
+ )
135
+ except asyncio.TimeoutError:
136
+ process.kill()
137
+ await process.wait()
138
+ raise TimeoutError(f"{self.name} timed out after {effective_timeout}s")
139
+
140
+ # Wait for process to complete
141
+ await process.wait()
142
+
143
+ return (
144
+ "\n".join(stdout_lines),
145
+ "\n".join(stderr_lines),
146
+ process.returncode
147
+ )
148
+
149
+ @staticmethod
150
+ def resolve_command(cmd: str | list[str]) -> str | list[str]:
151
+ """
152
+ Resolve command to full path on Windows.
153
+
154
+ On Windows, asyncio.create_subprocess_exec() doesn't reliably search PATH,
155
+ so we need to resolve commands to their full paths using shutil.which().
156
+
157
+ Args:
158
+ cmd: Command string or list of command parts
159
+
160
+ Returns:
161
+ Resolved command (full path on Windows, original on Unix)
162
+ """
163
+ if os.name != "nt":
164
+ # On Unix systems, PATH search works fine
165
+ return cmd
166
+
167
+ # On Windows, resolve the executable path
168
+ if isinstance(cmd, list):
169
+ if not cmd:
170
+ return cmd
171
+ # Resolve first element (the executable)
172
+ resolved = shutil.which(cmd[0])
173
+ if resolved:
174
+ return [resolved] + cmd[1:]
175
+ return cmd
176
+ else:
177
+ # Single string command
178
+ resolved = shutil.which(cmd)
179
+ return resolved if resolved else cmd
src/delegation_mcp/adapters/claude.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Claude Code CLI adapter."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from .base import CLIAdapter
8
+
9
+
10
+ class ClaudeAdapter(CLIAdapter):
11
+ """Adapter for Claude Code CLI."""
12
+
13
+ async def execute(self, task: str, progress_callback: Any = None, timeout: int | None = None, **kwargs: Any) -> tuple[str, str, int]:
14
+ """Execute task using Claude Code with optional streaming."""
15
+ if progress_callback:
16
+ return await self.execute_streaming(task, progress_callback, timeout, **kwargs)
17
+
18
+ cmd = self.format_task(task, **kwargs)
19
+ resolved_cmd = self.resolve_command(cmd)
20
+
21
+ process = await asyncio.create_subprocess_exec(
22
+ *resolved_cmd,
23
+ stdout=asyncio.subprocess.PIPE,
24
+ stderr=asyncio.subprocess.PIPE,
25
+ env={**subprocess.os.environ, **self.get_env()},
26
+ )
27
+
28
+ try:
29
+ effective_timeout = timeout or self.get_timeout()
30
+ stdout, stderr = await asyncio.wait_for(
31
+ process.communicate(), timeout=effective_timeout
32
+ )
33
+ return (
34
+ stdout.decode("utf-8", errors="replace"),
35
+ stderr.decode("utf-8", errors="replace"),
36
+ process.returncode or 0,
37
+ )
38
+ except asyncio.TimeoutError:
39
+ process.kill()
40
+ await process.wait()
41
+ raise TimeoutError(f"Claude CLI timed out after {effective_timeout}s")
42
+
43
+ def validate(self) -> bool:
44
+ """Validate Claude CLI is available."""
45
+ try:
46
+ subprocess.run(
47
+ ["which", "claude"] if subprocess.os.name != "nt" else ["where", "claude"],
48
+ capture_output=True,
49
+ check=True,
50
+ )
51
+ return True
52
+ except subprocess.CalledProcessError:
53
+ return False
54
+
55
+ def format_task(self, task: str, **kwargs: Any) -> list[str]:
56
+ """Format task for Claude CLI."""
57
+ cmd = ["claude"]
58
+
59
+ # Add any custom args from config
60
+ cmd.extend(self.get_args())
61
+
62
+ # Add mode if specified
63
+ if mode := kwargs.get("mode"):
64
+ cmd.extend(["--mode", mode])
65
+
66
+ # Add task as final argument
67
+ cmd.append(task)
68
+
69
+ return cmd
src/delegation_mcp/adapters/copilot.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GitHub Copilot CLI adapter."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from .base import CLIAdapter
8
+
9
+
10
+ class CopilotAdapter(CLIAdapter):
11
+ """Adapter for GitHub Copilot CLI."""
12
+
13
+ async def execute(self, task: str, progress_callback: Any = None, timeout: int | None = None, **kwargs: Any) -> tuple[str, str, int]:
14
+ """Execute task using Copilot CLI with optional streaming."""
15
+ if progress_callback:
16
+ return await self.execute_streaming(task, progress_callback, timeout, **kwargs)
17
+
18
+ cmd = self.format_task(task, **kwargs)
19
+ resolved_cmd = self.resolve_command(cmd)
20
+
21
+ process = await asyncio.create_subprocess_exec(
22
+ *resolved_cmd,
23
+ stdout=asyncio.subprocess.PIPE,
24
+ stderr=asyncio.subprocess.PIPE,
25
+ env={**subprocess.os.environ, **self.get_env()},
26
+ )
27
+
28
+ try:
29
+ effective_timeout = timeout or self.get_timeout()
30
+ stdout, stderr = await asyncio.wait_for(
31
+ process.communicate(), timeout=effective_timeout
32
+ )
33
+ return (
34
+ stdout.decode("utf-8", errors="replace"),
35
+ stderr.decode("utf-8", errors="replace"),
36
+ process.returncode or 0,
37
+ )
38
+ except asyncio.TimeoutError:
39
+ process.kill()
40
+ await process.wait()
41
+ raise TimeoutError(f"Copilot CLI timed out after {effective_timeout}s")
42
+
43
+ def validate(self) -> bool:
44
+ """Validate Copilot CLI is available."""
45
+ try:
46
+ # Check for copilot CLI
47
+ subprocess.run(
48
+ ["which", "copilot"] if subprocess.os.name != "nt" else ["where", "copilot"],
49
+ capture_output=True,
50
+ check=True,
51
+ )
52
+ return True
53
+ except subprocess.CalledProcessError:
54
+ return False
55
+
56
+ def format_task(self, task: str, **kwargs: Any) -> list[str]:
57
+ """Format task for Copilot CLI."""
58
+ cmd = ["copilot"]
59
+
60
+ # Add subcommand (suggest, explain, etc.)
61
+ subcommand = kwargs.get("subcommand", "suggest")
62
+ cmd.append(subcommand)
63
+
64
+ # Add any custom args from config
65
+ cmd.extend(self.get_args())
66
+
67
+ # Add task
68
+ cmd.append(task)
69
+
70
+ return cmd
src/delegation_mcp/adapters/gemini.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini CLI adapter."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from .base import CLIAdapter
8
+
9
+
10
+ class GeminiAdapter(CLIAdapter):
11
+ """Adapter for Gemini CLI."""
12
+
13
+ async def execute(self, task: str, progress_callback: Any = None, timeout: int | None = None, **kwargs: Any) -> tuple[str, str, int]:
14
+ """Execute task using Gemini CLI with optional streaming."""
15
+ # Use streaming method from base class if progress_callback provided
16
+ if progress_callback:
17
+ return await self.execute_streaming(task, progress_callback, timeout, **kwargs)
18
+
19
+ # Otherwise use buffered execution (legacy)
20
+ cmd = self.format_task(task, **kwargs)
21
+
22
+ resolved_cmd = self.resolve_command(cmd)
23
+
24
+ process = await asyncio.create_subprocess_exec(
25
+ *resolved_cmd,
26
+ stdout=asyncio.subprocess.PIPE,
27
+ stderr=asyncio.subprocess.PIPE,
28
+ env={**subprocess.os.environ, **self.get_env()},
29
+ )
30
+
31
+ try:
32
+ effective_timeout = timeout or self.get_timeout()
33
+ stdout, stderr = await asyncio.wait_for(
34
+ process.communicate(), timeout=effective_timeout
35
+ )
36
+ return (
37
+ stdout.decode("utf-8", errors="replace"),
38
+ stderr.decode("utf-8", errors="replace"),
39
+ process.returncode or 0,
40
+ )
41
+ except asyncio.TimeoutError:
42
+ process.kill()
43
+ await process.wait()
44
+ raise TimeoutError(f"Gemini CLI timed out after {effective_timeout}s")
45
+
46
+ def validate(self) -> bool:
47
+ """Validate Gemini CLI is available."""
48
+ try:
49
+ subprocess.run(
50
+ ["which", "gemini"] if subprocess.os.name != "nt" else ["where", "gemini"],
51
+ capture_output=True,
52
+ check=True,
53
+ )
54
+ return True
55
+ except subprocess.CalledProcessError:
56
+ return False
57
+
58
+ def format_task(self, task: str, **kwargs: Any) -> list[str]:
59
+ """Format task for Gemini CLI."""
60
+ cmd = ["gemini"]
61
+
62
+ # Add model if specified in kwargs or config
63
+ if model := kwargs.get("model") or self.config.get("model"):
64
+ cmd.extend(["-m", model])
65
+
66
+ # Add allowed tools if specified
67
+ if tools := kwargs.get("allowed_tools"):
68
+ tool_list = tools if isinstance(tools, str) else ",".join(tools)
69
+ cmd.extend(["--allowed-tools", tool_list])
70
+
71
+ # Add any custom args from config
72
+ for arg in self.get_args():
73
+ if arg not in cmd: # Avoid duplicates
74
+ cmd.append(arg)
75
+
76
+ # Add task as final argument (quoted)
77
+ cmd.append(task)
78
+
79
+ return cmd
src/delegation_mcp/agent_discovery.py ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent auto-discovery system for detecting installed CLI agents.
2
+
3
+ Automatically discovers which agents (Claude Code, Gemini CLI, Aider, Copilot, etc.)
4
+ are installed and available on the user's system.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass, asdict
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class AgentMetadata:
24
+ """Metadata for a discovered agent."""
25
+
26
+ name: str
27
+ command: str | list[str]
28
+ version: str | None = None
29
+ available: bool = False
30
+ path: str | None = None
31
+ error_message: str | None = None
32
+ capabilities: list[str] | None = None
33
+ verified_at: str | None = None
34
+
35
+
36
+ class AgentDiscovery:
37
+ """Automatic discovery system for CLI agents."""
38
+
39
+ # Known agent patterns to scan for
40
+ # Note: On Windows, gemini must be checked first due to subprocess interaction issues
41
+ VERIFICATION_TIMEOUT = 30.0
42
+
43
+ KNOWN_AGENTS = {
44
+ "gemini": {
45
+ "command": "gemini",
46
+ "version_flag": "--version",
47
+ "capabilities": ["security", "vision", "fast_iteration"],
48
+ },
49
+ "claude": {
50
+ "command": "claude",
51
+ "version_flag": "--version",
52
+ "capabilities": ["reasoning", "architecture", "code_generation"],
53
+ },
54
+ "aider": {
55
+ "command": "aider",
56
+ "version_flag": "--version",
57
+ "capabilities": ["git_operations", "code_editing", "refactoring"],
58
+ },
59
+ "copilot": {
60
+ "command": "copilot",
61
+ "version_flag": "--version",
62
+ "capabilities": ["github_integration", "suggestions"],
63
+ },
64
+ "qwen": {
65
+ "command": "qwen-code",
66
+ "version_flag": "--version",
67
+ "capabilities": ["code_generation", "multilingual"],
68
+ },
69
+ }
70
+
71
+ def __init__(self, cache_file: Path | None = None):
72
+ """Initialize agent discovery system.
73
+
74
+ Args:
75
+ cache_file: Path to cache file for discovered agents
76
+ """
77
+ # Set up safe cache directory
78
+ cache_dir = Path.home() / ".cache" / "delegation-mcp"
79
+
80
+ if cache_file:
81
+ # Validate user-provided cache file path to prevent traversal
82
+ cache_file = Path(cache_file)
83
+
84
+ # Ensure it's just a filename, not a path
85
+ if len(cache_file.parts) > 1:
86
+ logger.warning(f"Cache file path contains directories, using only filename: {cache_file.name}")
87
+ cache_file = cache_dir / cache_file.name
88
+ else:
89
+ cache_file = cache_dir / cache_file
90
+
91
+ # Resolve to absolute path and check it's within cache_dir
92
+ try:
93
+ cache_file = cache_file.resolve()
94
+ if not str(cache_file).startswith(str(cache_dir.resolve())):
95
+ raise ValueError(f"Cache file path outside allowed directory: {cache_file}")
96
+ except (OSError, ValueError) as e:
97
+ logger.error(f"Invalid cache file path: {e}")
98
+ cache_file = cache_dir / "discovered_agents.json"
99
+ else:
100
+ cache_file = cache_dir / "discovered_agents.json"
101
+
102
+ self.cache_file = cache_file
103
+ self._discovered_agents: dict[str, AgentMetadata] = {}
104
+ self._load_cache()
105
+
106
+ def _load_cache(self) -> None:
107
+ """Load cached discovery results with validation."""
108
+ if self.cache_file.exists():
109
+ try:
110
+ with open(self.cache_file) as f:
111
+ data = json.load(f)
112
+
113
+ # Validate data structure
114
+ if not isinstance(data, dict):
115
+ logger.warning("Invalid cache format: expected dictionary")
116
+ return
117
+
118
+ # Validate each entry
119
+ required_fields = {'name', 'command', 'available'}
120
+ for name, metadata in data.items():
121
+ # Validate name is safe
122
+ if not isinstance(name, str) or not re.match(r'^[a-zA-Z0-9_\-]+$', name):
123
+ logger.warning(f"Skipping invalid agent name in cache: {name}")
124
+ continue
125
+
126
+ # Validate metadata structure
127
+ if not isinstance(metadata, dict):
128
+ logger.warning(f"Skipping invalid metadata for {name}")
129
+ continue
130
+
131
+ if not required_fields.issubset(metadata.keys()):
132
+ logger.warning(f"Skipping incomplete cache entry: {name}")
133
+ continue
134
+
135
+ try:
136
+ self._discovered_agents[name] = AgentMetadata(**metadata)
137
+ except Exception as e:
138
+ logger.warning(f"Failed to load agent {name} from cache: {e}")
139
+ continue
140
+
141
+ logger.info(f"Loaded {len(self._discovered_agents)} agents from cache")
142
+ except Exception as e:
143
+ logger.warning(f"Failed to load agent cache: {e}")
144
+
145
+ def _save_cache(self) -> None:
146
+ """Save discovery results to cache."""
147
+ try:
148
+ self.cache_file.parent.mkdir(parents=True, exist_ok=True)
149
+ with open(self.cache_file, "w") as f:
150
+ data = {name: asdict(agent) for name, agent in self._discovered_agents.items()}
151
+ json.dump(data, f, indent=2)
152
+ logger.info(f"Saved {len(self._discovered_agents)} agents to cache")
153
+ except Exception as e:
154
+ logger.error(f"Failed to save agent cache: {e}")
155
+
156
+ def _resolve_command_path(self, command: str | list[str]) -> str | None:
157
+ """Resolve command to full path if available.
158
+
159
+ Args:
160
+ command: Command string or list
161
+
162
+ Returns:
163
+ Full path to executable or None if not found
164
+ """
165
+ cmd = command[0] if isinstance(command, list) else command
166
+
167
+ # Use shutil.which for cross-platform command resolution
168
+ path = shutil.which(cmd)
169
+ if path:
170
+ logger.debug(f"Found {cmd} at {path}")
171
+ return path
172
+
173
+ logger.debug(f"Command {cmd} not found in PATH")
174
+ return None
175
+
176
+ def _get_node_script_path(self, cmd_path: str) -> tuple[str, list[str]] | None:
177
+ """For npm .cmd files on Windows, extract the underlying Node.js script.
178
+
179
+ Args:
180
+ cmd_path: Path to .cmd file
181
+
182
+ Returns:
183
+ Tuple of (node_executable, [script_path]) or None
184
+ """
185
+ if not cmd_path.lower().endswith('.cmd'):
186
+ return None
187
+
188
+ try:
189
+ # Read the .cmd file to find the node script
190
+ with open(cmd_path, 'r') as f:
191
+ content = f.read()
192
+
193
+ # Look for pattern like: "%_prog%" "%dp0%\node_modules\...\index.js"
194
+ import re
195
+ match = re.search(r'"%dp0%\\node_modules\\([^"]+)"', content)
196
+ if match:
197
+ script_rel_path = match.group(1)
198
+ npm_dir = Path(cmd_path).parent
199
+ script_path = npm_dir / "node_modules" / script_rel_path
200
+
201
+ if script_path.exists():
202
+ node_exe = shutil.which("node")
203
+ if node_exe:
204
+ logger.debug(f"Found node script: {script_path}")
205
+ return node_exe, [str(script_path)]
206
+ except Exception as e:
207
+ logger.debug(f"Could not extract node script from {cmd_path}: {e}")
208
+
209
+ return None
210
+
211
+ async def _verify_agent(
212
+ self,
213
+ name: str,
214
+ command: str | list[str],
215
+ version_flag: str = "--version",
216
+ ) -> tuple[bool, str | None, str | None]:
217
+ """Verify agent is working by running version command.
218
+
219
+ Args:
220
+ name: Agent name
221
+ command: Command to execute
222
+ version_flag: Flag to get version (--version or --help)
223
+
224
+ Returns:
225
+ tuple: (is_available, version_string, error_message)
226
+ """
227
+ # Build command
228
+ if isinstance(command, list):
229
+ cmd = command + [version_flag]
230
+ else:
231
+ cmd = [command, version_flag]
232
+
233
+ logger.debug(f"Verifying {name} with command: {cmd}, is_list: {isinstance(command, list)}")
234
+
235
+ try:
236
+ # Try --version first
237
+ # On Windows with string commands (not node direct calls), use cmd /c
238
+ if platform.system() == "Windows" and isinstance(command, str):
239
+ # Use cmd /c with proper quoting for Windows CMD/BAT files
240
+ cmd_str = " ".join(cmd)
241
+ process = await asyncio.create_subprocess_exec(
242
+ "cmd",
243
+ "/c",
244
+ cmd_str,
245
+ stdout=asyncio.subprocess.PIPE,
246
+ stderr=asyncio.subprocess.PIPE,
247
+ stdin=asyncio.subprocess.PIPE,
248
+ )
249
+ else:
250
+ # Direct execution (Unix or Windows with node direct call)
251
+ process = await asyncio.create_subprocess_exec(
252
+ *cmd,
253
+ stdout=asyncio.subprocess.PIPE,
254
+ stderr=asyncio.subprocess.PIPE,
255
+ stdin=asyncio.subprocess.PIPE,
256
+ )
257
+
258
+ # Close stdin to prevent processes from hanging while waiting for input
259
+ if process.stdin:
260
+ process.stdin.close()
261
+
262
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.VERIFICATION_TIMEOUT)
263
+
264
+ if process.returncode == 0:
265
+ version = stdout.decode("utf-8", errors="replace").strip()
266
+ if not version:
267
+ version = stderr.decode("utf-8", errors="replace").strip()
268
+
269
+ # Take only the first line to avoid multi-line version outputs
270
+ version = version.split('\n')[0].strip() if version else ""
271
+
272
+ logger.info(f"Agent {name} verified: {version[:100]}")
273
+ return True, version[:200], None
274
+ else:
275
+ # Try --help as fallback
276
+ if version_flag == "--version":
277
+ return await self._verify_agent(name, command, "--help")
278
+
279
+ error = stderr.decode("utf-8", errors="replace").strip()
280
+ logger.warning(f"Agent {name} verification failed: {error}")
281
+ return False, None, error[:200]
282
+
283
+ except asyncio.TimeoutError:
284
+ error = f"Agent {name} timed out during verification"
285
+ logger.warning(error)
286
+ return False, None, error
287
+ except FileNotFoundError:
288
+ error = f"Command not found: {cmd[0]}"
289
+ logger.debug(error)
290
+ return False, None, error
291
+ except Exception as e:
292
+ error = f"Failed to verify {name}: {str(e)}"
293
+ logger.warning(error)
294
+ return False, None, error
295
+
296
+ async def discover_agents(
297
+ self,
298
+ force_refresh: bool = False,
299
+ agents_to_check: list[str] | None = None,
300
+ ) -> dict[str, AgentMetadata]:
301
+ """Discover available agents on the system.
302
+
303
+ Args:
304
+ force_refresh: Force re-discovery even if cache exists
305
+ agents_to_check: Specific agents to check (default: all known agents)
306
+
307
+ Returns:
308
+ Dictionary of agent name to metadata
309
+ """
310
+ if not force_refresh and self._discovered_agents:
311
+ logger.info("Using cached agent discovery results")
312
+ return self._discovered_agents
313
+
314
+ logger.info("Starting agent discovery...")
315
+
316
+ # Determine which agents to check
317
+ agents = agents_to_check or list(self.KNOWN_AGENTS.keys())
318
+
319
+ # Use a semaphore to limit concurrency on all platforms to avoid resource spikes
320
+ # On Windows this is critical, on others it's just good practice
321
+ concurrency_limit = 5 if platform.system() == "Windows" else 10
322
+ semaphore = asyncio.Semaphore(concurrency_limit)
323
+
324
+ async def _bounded_discover(name: str, config: dict[str, Any]) -> AgentMetadata | Exception:
325
+ async with semaphore:
326
+ try:
327
+ return await self._discover_single_agent(name, config)
328
+ except Exception as e:
329
+ logger.error(f"Discovery task failed for {name}: {e}")
330
+ return e
331
+
332
+ logger.debug(f"Running agent discovery in parallel (limit={concurrency_limit})")
333
+ tasks = []
334
+ for name in agents:
335
+ if name not in self.KNOWN_AGENTS:
336
+ logger.warning(f"Unknown agent: {name}")
337
+ continue
338
+
339
+ config = self.KNOWN_AGENTS[name]
340
+ tasks.append(_bounded_discover(name, config))
341
+
342
+ results = await asyncio.gather(*tasks, return_exceptions=True)
343
+
344
+ # Process results
345
+ for result in results:
346
+ if isinstance(result, AgentMetadata):
347
+ self._discovered_agents[result.name] = result
348
+ elif isinstance(result, Exception):
349
+ # Already logged in _bounded_discover
350
+ pass
351
+
352
+ # Save to cache
353
+ self._save_cache()
354
+
355
+ logger.info(
356
+ f"Discovery complete: {sum(1 for a in self._discovered_agents.values() if a.available)}/{len(self._discovered_agents)} agents available"
357
+ )
358
+
359
+ return self._discovered_agents
360
+
361
+ async def _discover_single_agent(
362
+ self,
363
+ name: str,
364
+ config: dict[str, Any],
365
+ ) -> AgentMetadata:
366
+ """Discover a single agent.
367
+
368
+ Args:
369
+ name: Agent name
370
+ config: Agent configuration
371
+
372
+ Returns:
373
+ AgentMetadata for the agent
374
+ """
375
+ command = config["command"]
376
+ version_flag = config.get("version_flag", "--version")
377
+ capabilities = config.get("capabilities", [])
378
+
379
+ # Resolve command path
380
+ path = self._resolve_command_path(command)
381
+
382
+ if not path:
383
+ return AgentMetadata(
384
+ name=name,
385
+ command=command,
386
+ available=False,
387
+ error_message=self._get_install_message(name),
388
+ )
389
+
390
+ # On Windows, try to extract node script from .cmd files
391
+ verify_command = command
392
+ if platform.system() == "Windows" and isinstance(command, str):
393
+ node_script = self._get_node_script_path(path)
394
+ if node_script:
395
+ node_exe, script_args = node_script
396
+ verify_command = [node_exe] + script_args
397
+
398
+ # Verify agent works
399
+ available, version, error = await self._verify_agent(name, verify_command, version_flag)
400
+
401
+ from datetime import datetime
402
+
403
+ return AgentMetadata(
404
+ name=name,
405
+ command=command,
406
+ version=version,
407
+ available=available,
408
+ path=path,
409
+ error_message=error if not available else None,
410
+ capabilities=capabilities if available else None,
411
+ verified_at=datetime.utcnow().isoformat() if available else None,
412
+ )
413
+
414
+ def _get_install_message(self, agent_name: str) -> str:
415
+ """Get installation instructions for an agent.
416
+
417
+ Args:
418
+ agent_name: Name of the agent
419
+
420
+ Returns:
421
+ Installation instructions
422
+ """
423
+ install_messages = {
424
+ "claude": "Claude Code not found. Install with: npm install -g @anthropic/claude-code",
425
+ "gemini": "Gemini CLI not found. Install with: npm install -g @google/gemini-cli",
426
+ "aider": "Aider not found. Install with: pip install aider-chat",
427
+ "copilot": "GitHub Copilot CLI not found. Install with: npm install -g @github/copilot",
428
+ "qwen": "Qwen Code not found. Install with: npm install -g @qwen-code/qwen-code",
429
+ }
430
+
431
+ return install_messages.get(
432
+ agent_name,
433
+ f"{agent_name} not found. Check agent documentation for installation instructions.",
434
+ )
435
+
436
+ def get_available_agents(self) -> list[AgentMetadata]:
437
+ """Get list of available agents.
438
+
439
+ Returns:
440
+ List of available agent metadata
441
+ """
442
+ return [agent for agent in self._discovered_agents.values() if agent.available]
443
+
444
+ def get_unavailable_agents(self) -> list[AgentMetadata]:
445
+ """Get list of unavailable agents.
446
+
447
+ Returns:
448
+ List of unavailable agent metadata
449
+ """
450
+ return [agent for agent in self._discovered_agents.values() if not agent.available]
451
+
452
+ def is_agent_available(self, name: str) -> bool:
453
+ """Check if a specific agent is available.
454
+
455
+ Args:
456
+ name: Agent name
457
+
458
+ Returns:
459
+ True if agent is available
460
+ """
461
+ agent = self._discovered_agents.get(name)
462
+ return agent.available if agent else False
463
+
464
+ def get_agent_metadata(self, name: str) -> AgentMetadata | None:
465
+ """Get metadata for a specific agent.
466
+
467
+ Args:
468
+ name: Agent name
469
+
470
+ Returns:
471
+ Agent metadata or None if not found
472
+ """
473
+ return self._discovered_agents.get(name)
474
+
475
+ def get_discovery_summary(self) -> dict[str, Any]:
476
+ """Get summary of discovery results.
477
+
478
+ Returns:
479
+ Dictionary with discovery summary
480
+ """
481
+ available = self.get_available_agents()
482
+ unavailable = self.get_unavailable_agents()
483
+
484
+ return {
485
+ "total_agents": len(self._discovered_agents),
486
+ "available": len(available),
487
+ "unavailable": len(unavailable),
488
+ "available_agents": [
489
+ {"name": a.name, "version": a.version, "path": a.path} for a in available
490
+ ],
491
+ "unavailable_agents": [
492
+ {"name": a.name, "error": a.error_message} for a in unavailable
493
+ ],
494
+ "system_info": {
495
+ "platform": platform.system(),
496
+ "python_version": platform.python_version(),
497
+ },
498
+ }
499
+
500
+ def clear_cache(self) -> None:
501
+ """Clear the discovery cache."""
502
+ self._discovered_agents.clear()
503
+ if self.cache_file.exists():
504
+ self.cache_file.unlink()
505
+ logger.info("Agent discovery cache cleared")
506
+
507
+
508
+ async def discover_agents(force_refresh: bool = False) -> dict[str, AgentMetadata]:
509
+ """Convenience function to discover agents.
510
+
511
+ Args:
512
+ force_refresh: Force re-discovery even if cache exists
513
+
514
+ Returns:
515
+ Dictionary of agent name to metadata
516
+ """
517
+ discovery = AgentDiscovery()
518
+ return await discovery.discover_agents(force_refresh=force_refresh)
src/delegation_mcp/cli.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI for executing workflows."""
2
+
3
+ import asyncio
4
+ import click
5
+ import sys
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+ from rich.syntax import Syntax
12
+
13
+ from .workflow import WorkflowEngine, WorkflowDefinition
14
+ from .orchestrator import OrchestratorRegistry
15
+ from .config import DelegationConfig
16
+ from .logging_config import setup_logging
17
+ from .agent_discovery import AgentDiscovery
18
+
19
+
20
+ console = Console()
21
+
22
+
23
+ @click.group()
24
+ @click.option('--verbose', '-v', is_flag=True, help='Verbose output')
25
+ @click.option('--config', '-c', type=click.Path(exists=True), help='Config file path')
26
+ @click.pass_context
27
+ def cli(ctx, verbose, config):
28
+ """Delegation MCP - Multi-Agent Workflow Orchestration."""
29
+ ctx.ensure_object(dict)
30
+ ctx.obj['verbose'] = verbose
31
+ ctx.obj['config_path'] = Path(config) if config else Path("config/delegation_rules.yaml")
32
+
33
+ # Setup logging
34
+ import logging
35
+ setup_logging(level=logging.DEBUG if verbose else logging.INFO, verbose=verbose)
36
+
37
+
38
+ @cli.command('list')
39
+ @click.pass_context
40
+ def list_workflows(ctx):
41
+ """List all available workflows."""
42
+ workflows_dir = Path("workflows")
43
+
44
+ if not workflows_dir.exists():
45
+ console.print("[red]❌ Workflows directory not found[/red]")
46
+ sys.exit(1)
47
+
48
+ console.print("\n[bold cyan]📚 Available Workflows[/bold cyan]\n")
49
+
50
+ table = Table(show_header=True, header_style="bold magenta")
51
+ table.add_column("Name", style="cyan", no_wrap=True)
52
+ table.add_column("Steps", justify="center", style="yellow")
53
+ table.add_column("Agents", style="green")
54
+ table.add_column("Category", style="blue")
55
+ table.add_column("Difficulty", style="magenta")
56
+ table.add_column("Duration", justify="right", style="yellow")
57
+
58
+ for workflow_file in sorted(workflows_dir.glob("*.yaml")):
59
+ try:
60
+ workflow = WorkflowDefinition.from_yaml(workflow_file)
61
+ agents = ", ".join(sorted(set(step.agent for step in workflow.steps)))
62
+ category = workflow.metadata.get("category", "general")
63
+ difficulty = workflow.metadata.get("difficulty", "intermediate")
64
+ duration = workflow.metadata.get("estimated_duration", 0)
65
+
66
+ table.add_row(
67
+ workflow.name,
68
+ str(len(workflow.steps)),
69
+ agents,
70
+ category,
71
+ difficulty,
72
+ f"{duration // 60}min"
73
+ )
74
+ except Exception as e:
75
+ console.print(f"[red]⚠️ Failed to load {workflow_file.name}: {e}[/red]")
76
+
77
+ console.print(table)
78
+ console.print()
79
+
80
+
81
+ @cli.command()
82
+ @click.argument('workflow_name')
83
+ @click.pass_context
84
+ def show(ctx, workflow_name):
85
+ """Show workflow details."""
86
+ workflows_dir = Path("workflows")
87
+ workflow = _find_workflow(workflow_name, workflows_dir)
88
+
89
+ if not workflow:
90
+ sys.exit(1)
91
+
92
+ # Display workflow info
93
+ console.print()
94
+ console.print(Panel(
95
+ f"[bold]{workflow.name}[/bold]\n\n{workflow.description}",
96
+ title="📋 Workflow Details",
97
+ border_style="cyan"
98
+ ))
99
+
100
+ # Display steps
101
+ console.print("\n[bold cyan]🔄 Workflow Steps[/bold cyan]\n")
102
+
103
+ for i, step in enumerate(workflow.steps, 1):
104
+ console.print(f"[bold yellow]Step {i}:[/bold yellow] {step.id}")
105
+ console.print(f" [cyan]Agent:[/cyan] {step.agent}")
106
+ console.print(f" [green]Task:[/green] {step.task}")
107
+ if step.output:
108
+ console.print(f" [blue]Output:[/blue] {step.output}")
109
+ if step.condition:
110
+ console.print(f" [magenta]Condition:[/magenta] {step.condition}")
111
+ console.print()
112
+
113
+ # Display metadata
114
+ if workflow.metadata:
115
+ console.print("[bold cyan]📊 Metadata[/bold cyan]")
116
+ for key, value in workflow.metadata.items():
117
+ console.print(f" [cyan]{key}:[/cyan] {value}")
118
+
119
+ console.print()
120
+
121
+
122
+ @cli.command()
123
+ @click.argument('workflow_name')
124
+ @click.option('--context', '-c', multiple=True, help='Context variables (key=value)')
125
+ @click.option('--dry-run', is_flag=True, help='Show what would be executed without running')
126
+ @click.pass_context
127
+ def execute(ctx, workflow_name, context, dry_run):
128
+ """Execute a workflow."""
129
+ workflows_dir = Path("workflows")
130
+ workflow = _find_workflow(workflow_name, workflows_dir)
131
+
132
+ if not workflow:
133
+ sys.exit(1)
134
+
135
+ # Parse context
136
+ context_dict = {}
137
+ for ctx_item in context:
138
+ if '=' in ctx_item:
139
+ key, value = ctx_item.split('=', 1)
140
+ context_dict[key.strip()] = value.strip()
141
+
142
+ if dry_run:
143
+ console.print("\n[bold yellow]🔍 Dry Run Mode[/bold yellow]\n")
144
+ console.print(f"[cyan]Workflow:[/cyan] {workflow.name}")
145
+ console.print(f"[cyan]Steps:[/cyan] {len(workflow.steps)}")
146
+ console.print(f"[cyan]Context:[/cyan] {context_dict}")
147
+ console.print("\n[bold green]Would execute:[/bold green]\n")
148
+ for i, step in enumerate(workflow.steps, 1):
149
+ console.print(f" {i}. [{step.agent}] {step.task}")
150
+ console.print()
151
+ return
152
+
153
+ # Execute workflow
154
+ asyncio.run(_execute_workflow(ctx, workflow, context_dict))
155
+
156
+
157
+ async def _execute_workflow(ctx, workflow, context):
158
+ """Execute workflow with progress display."""
159
+ # Setup
160
+ config_path = ctx.obj['config_path']
161
+ if config_path.exists():
162
+ config = DelegationConfig.from_yaml(config_path)
163
+ else:
164
+ config = DelegationConfig(orchestrator="claude")
165
+
166
+ registry = OrchestratorRegistry()
167
+ for name, orch_config in config.orchestrators.items():
168
+ registry.register(orch_config)
169
+
170
+ engine = WorkflowEngine(registry)
171
+
172
+ # Display header
173
+ console.print()
174
+ console.print(Panel(
175
+ f"[bold]{workflow.name}[/bold]\n{workflow.description}",
176
+ title="🚀 Executing Workflow",
177
+ border_style="green"
178
+ ))
179
+ console.print()
180
+
181
+ # Execute with progress
182
+ with Progress(
183
+ SpinnerColumn(),
184
+ TextColumn("[progress.description]{task.description}"),
185
+ BarColumn(),
186
+ console=console,
187
+ ) as progress:
188
+
189
+ task = progress.add_task(
190
+ f"[cyan]Executing {len(workflow.steps)} steps...",
191
+ total=len(workflow.steps)
192
+ )
193
+
194
+ # We need to modify the engine to support progress callbacks
195
+ # For now, just execute
196
+ result = await engine.execute(workflow, initial_context=context)
197
+
198
+ progress.update(task, completed=len(workflow.steps))
199
+
200
+ # Display results
201
+ console.print()
202
+ if result.success:
203
+ console.print(Panel(
204
+ f"[bold green]✅ Workflow Completed Successfully[/bold green]\n\n"
205
+ f"Steps: {result.steps_completed}/{result.total_steps}\n"
206
+ f"Duration: {result.duration:.2f}s",
207
+ border_style="green"
208
+ ))
209
+ else:
210
+ console.print(Panel(
211
+ f"[bold red]❌ Workflow Failed[/bold red]\n\n"
212
+ f"Steps: {result.steps_completed}/{result.total_steps}\n"
213
+ f"Duration: {result.duration:.2f}s",
214
+ border_style="red"
215
+ ))
216
+
217
+ # Display outputs
218
+ if result.outputs:
219
+ console.print("\n[bold cyan]📤 Outputs[/bold cyan]\n")
220
+ for key, value in result.outputs.items():
221
+ console.print(f"[bold]{key}:[/bold]")
222
+ # Truncate long outputs
223
+ display_value = str(value)
224
+ if len(display_value) > 1000:
225
+ display_value = display_value[:1000] + "\n... (truncated)"
226
+ console.print(Panel(display_value, border_style="blue"))
227
+
228
+ # Display errors
229
+ if result.errors:
230
+ console.print("\n[bold red]❌ Errors[/bold red]\n")
231
+ for error in result.errors:
232
+ console.print(f" • {error}")
233
+
234
+ console.print()
235
+
236
+
237
+ @cli.command()
238
+ @click.argument('workflow_file', type=click.Path(exists=True))
239
+ @click.pass_context
240
+ def validate(ctx, workflow_file):
241
+ """Validate a workflow file."""
242
+ try:
243
+ workflow = WorkflowDefinition.from_yaml(Path(workflow_file))
244
+ console.print(f"[green]✅ Workflow '{workflow.name}' is valid[/green]")
245
+ console.print(f" Steps: {len(workflow.steps)}")
246
+ console.print(f" Agents: {', '.join(set(step.agent for step in workflow.steps))}")
247
+ except Exception as e:
248
+ console.print(f"[red]❌ Invalid workflow: {e}[/red]")
249
+ sys.exit(1)
250
+
251
+
252
+ @cli.command('discover-agents')
253
+ @click.option('--force-refresh', '-f', is_flag=True, help='Force re-discovery even if cache exists')
254
+ @click.option('--json', 'output_json', is_flag=True, help='Output results as JSON')
255
+ @click.pass_context
256
+ def discover_agents_cmd(ctx, force_refresh, output_json):
257
+ """Discover available CLI agents on the system."""
258
+ asyncio.run(_discover_agents(force_refresh, output_json))
259
+
260
+
261
+ async def _discover_agents(force_refresh, output_json):
262
+ """Execute agent discovery."""
263
+ console.print()
264
+ console.print("[bold cyan]Discovering CLI Agents...[/bold cyan]\n")
265
+
266
+ # Create discovery instance and run discovery
267
+ discovery = AgentDiscovery()
268
+
269
+ console.print("[cyan]Scanning system PATH...[/cyan]")
270
+ discovered = await discovery.discover_agents(force_refresh=force_refresh)
271
+ console.print()
272
+
273
+ summary = discovery.get_discovery_summary()
274
+
275
+ if output_json:
276
+ # Output as JSON
277
+ import json
278
+ console.print(json.dumps(summary, indent=2))
279
+ return
280
+
281
+ # Display results in table format
282
+ console.print()
283
+ console.print(Panel(
284
+ f"[bold]Agent Discovery Results[/bold]\n\n"
285
+ f"Total agents scanned: {summary['total_agents']}\n"
286
+ f"Available: [green]{summary['available']}[/green]\n"
287
+ f"Unavailable: [red]{summary['unavailable']}[/red]",
288
+ title="Summary",
289
+ border_style="cyan"
290
+ ))
291
+ console.print()
292
+
293
+ # Display available agents
294
+ if summary['available_agents']:
295
+ console.print("[bold green]Available Agents[/bold green]\n")
296
+ table = Table(show_header=True, header_style="bold magenta")
297
+ table.add_column("Agent", style="cyan", no_wrap=True)
298
+ table.add_column("Version", style="yellow")
299
+ table.add_column("Path", style="green")
300
+
301
+ for agent in summary['available_agents']:
302
+ table.add_row(
303
+ agent['name'],
304
+ agent['version'][:50] if agent['version'] else 'Unknown',
305
+ agent['path']
306
+ )
307
+
308
+ console.print(table)
309
+ console.print()
310
+
311
+ # Display unavailable agents
312
+ if summary['unavailable_agents']:
313
+ console.print("[bold red]Unavailable Agents[/bold red]\n")
314
+ for agent in summary['unavailable_agents']:
315
+ console.print(f"[red]x[/red] [bold]{agent['name']}[/bold]")
316
+ console.print(f" {agent['error']}\n")
317
+
318
+ console.print()
319
+ console.print("[dim]Tip: Use --force-refresh to re-scan, or --json for machine-readable output[/dim]")
320
+ console.print()
321
+
322
+
323
+ def _find_workflow(name, workflows_dir):
324
+ """Find workflow by name or filename."""
325
+ if not workflows_dir.exists():
326
+ console.print("[red]❌ Workflows directory not found[/red]")
327
+ return None
328
+
329
+ # Try exact name match
330
+ for workflow_file in workflows_dir.glob("*.yaml"):
331
+ try:
332
+ workflow = WorkflowDefinition.from_yaml(workflow_file)
333
+ if workflow.name.lower() == name.lower():
334
+ return workflow
335
+ except Exception:
336
+ continue
337
+
338
+ # Try filename match
339
+ workflow_path = workflows_dir / f"{name}.yaml"
340
+ if workflow_path.exists():
341
+ try:
342
+ return WorkflowDefinition.from_yaml(workflow_path)
343
+ except Exception as e:
344
+ console.print(f"[red]❌ Failed to load workflow: {e}[/red]")
345
+ return None
346
+
347
+ console.print(f"[red]❌ Workflow '{name}' not found[/red]")
348
+ console.print("\nTry: [cyan]delegation-workflow list[/cyan] to see available workflows")
349
+ return None
350
+
351
+
352
+ def main():
353
+ """Entry point."""
354
+ cli(obj={})
355
+
356
+
357
+ if __name__ == '__main__':
358
+ main()
src/delegation_mcp/config.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration models for delegation MCP server."""
2
+
3
+ from typing import Any, Literal
4
+ from pathlib import Path
5
+ from pydantic import BaseModel, Field
6
+ import yaml
7
+ import re
8
+ from collections import defaultdict
9
+
10
+
11
+ class ConfigValidationError(Exception):
12
+ """Exception raised when configuration validation fails."""
13
+
14
+ def __init__(self, errors: list[str]):
15
+ self.errors = errors
16
+ super().__init__(f"Configuration validation failed: {'; '.join(errors)}")
17
+
18
+
19
+ class AgentCapabilities(BaseModel):
20
+ """Capability scores for an agent."""
21
+
22
+ security_audit: float = 0.5
23
+ vulnerability_scan: float = 0.5
24
+ code_review: float = 0.5
25
+ architecture: float = 0.5
26
+ refactoring: float = 0.5
27
+ quick_fix: float = 0.5
28
+ documentation: float = 0.5
29
+ testing: float = 0.5
30
+ performance: float = 0.5
31
+ git_workflow: float = 0.5 # Git operations (commit, push, merge, rebase)
32
+ github_operations: float = 0.5 # GitHub operations (PR, issues, releases)
33
+ general: float = 0.5 # Fallback capability
34
+
35
+
36
+ class OrchestratorConfig(BaseModel):
37
+ """Configuration for a single orchestrator/CLI."""
38
+
39
+ name: str
40
+ command: str | list[str]
41
+ args: list[str] = Field(default_factory=list)
42
+ enabled: bool = True
43
+ env: dict[str, str] = Field(default_factory=dict)
44
+ timeout: int = 300 # seconds
45
+ max_retries: int = 3
46
+ capabilities: AgentCapabilities = Field(default_factory=AgentCapabilities)
47
+ cost_per_1k_tokens: float = 0.001 # Cost estimate for routing decisions
48
+
49
+
50
+ class DelegationRule(BaseModel):
51
+ """Rule for delegating tasks to specific orchestrators."""
52
+
53
+ pattern: str # Regex pattern to match
54
+ delegate_to: str # Target orchestrator name
55
+ priority: int = 0 # Higher priority wins
56
+ requires_approval: bool = False
57
+ description: str = ""
58
+
59
+
60
+ class DelegationConfig(BaseModel):
61
+ """Main configuration for delegation MCP server."""
62
+
63
+ orchestrator: Literal["claude"] = "claude" # Primary orchestrator
64
+ orchestrators: dict[str, OrchestratorConfig] = Field(default_factory=dict)
65
+ rules: list[DelegationRule] = Field(default_factory=list)
66
+ auto_approve: bool = False
67
+ log_delegations: bool = True
68
+ routing_strategy: Literal["capability", "pattern", "hybrid"] = "capability" # Routing approach
69
+
70
+
71
+
72
+ def to_yaml(self, path: Path) -> None:
73
+ """Save configuration to YAML file."""
74
+ with open(path, "w", encoding="utf-8") as f:
75
+ yaml.dump(self.model_dump(), f, default_flow_style=False)
76
+
77
+ def get_orchestrator(self, name: str) -> OrchestratorConfig | None:
78
+ """Get orchestrator configuration by name."""
79
+ return self.orchestrators.get(name)
80
+
81
+ def find_delegation_rule(self, query: str) -> DelegationRule | None:
82
+ """Find matching delegation rule for query."""
83
+ matching_rules = [
84
+ rule
85
+ for rule in self.rules
86
+ if re.search(rule.pattern, query, re.IGNORECASE)
87
+ ]
88
+
89
+ if not matching_rules:
90
+ return None
91
+
92
+ # Return highest priority rule
93
+ return max(matching_rules, key=lambda r: r.priority)
94
+
95
+ def _validate_minimum_agents(self) -> list[str]:
96
+ """Validate that at least 2 agents are enabled."""
97
+ errors = []
98
+ enabled_count = sum(
99
+ 1 for orch in self.orchestrators.values() if orch.enabled
100
+ )
101
+
102
+ if enabled_count < 2:
103
+ errors.append(
104
+ f"At least 2 agents must be enabled, but only {enabled_count} "
105
+ f"{'is' if enabled_count == 1 else 'are'} enabled. "
106
+ f"Enable more agents in orchestrators configuration."
107
+ )
108
+
109
+ return errors
110
+
111
+ def _validate_regex_patterns(self) -> list[str]:
112
+ """Validate YAML regex syntax in routing rules."""
113
+ errors = []
114
+
115
+ for i, rule in enumerate(self.rules):
116
+ try:
117
+ # Attempt to compile the regex pattern
118
+ re.compile(rule.pattern)
119
+ except re.error as e:
120
+ errors.append(
121
+ f"Rule #{i + 1} (delegate_to: {rule.delegate_to}): "
122
+ f"Invalid regex pattern '{rule.pattern}': {str(e)}"
123
+ )
124
+
125
+ return errors
126
+
127
+ def _validate_agent_references(self) -> list[str]:
128
+ """Validate that all referenced agents exist."""
129
+ errors = []
130
+
131
+ # Check primary orchestrator exists
132
+ if self.orchestrator not in self.orchestrators:
133
+ errors.append(
134
+ f"Primary orchestrator '{self.orchestrator}' is not defined "
135
+ f"in orchestrators configuration. Available orchestrators: "
136
+ f"{', '.join(self.orchestrators.keys())}"
137
+ )
138
+
139
+ # Check all delegation rule targets exist
140
+ for i, rule in enumerate(self.rules):
141
+ if rule.delegate_to not in self.orchestrators:
142
+ errors.append(
143
+ f"Rule #{i + 1} (pattern: '{rule.pattern}'): "
144
+ f"Target orchestrator '{rule.delegate_to}' is not defined. "
145
+ f"Available orchestrators: {', '.join(self.orchestrators.keys())}"
146
+ )
147
+
148
+ return errors
149
+
150
+ def _validate_no_circular_delegation(self) -> list[str]:
151
+ """
152
+ Validate that there are no obvious circular delegation patterns.
153
+
154
+ Note: In the current delegation system, queries are processed once and returned
155
+ to the user - there is no automatic re-delegation. Therefore, true circular
156
+ delegation is not possible. This check looks for potential issues like:
157
+ - Duplicate patterns delegating to different orchestrators (ambiguous routing)
158
+ - Rules with very similar patterns that might cause confusion
159
+
160
+ Since circular delegation is not a real concern in this architecture, this
161
+ validation performs only basic sanity checks.
162
+ """
163
+ errors = []
164
+
165
+ if not self.rules:
166
+ return errors
167
+
168
+ # Check for duplicate or highly similar patterns
169
+ # This could cause ambiguous delegation behavior
170
+ pattern_targets: dict[str, list[str]] = defaultdict(list)
171
+
172
+ for rule in self.rules:
173
+ # Normalize pattern for comparison (case-insensitive)
174
+ normalized_pattern = rule.pattern.lower().strip()
175
+ pattern_targets[normalized_pattern].append(rule.delegate_to)
176
+
177
+ # Check for exact duplicate patterns with different targets
178
+ for pattern, targets in pattern_targets.items():
179
+ unique_targets = set(targets)
180
+ if len(unique_targets) > 1:
181
+ # Same pattern delegates to multiple different orchestrators
182
+ # This is actually fine - highest priority wins
183
+ # But if priorities are the same, it could be ambiguous
184
+
185
+ # Find rules with this pattern
186
+ matching_rules = [
187
+ r for r in self.rules
188
+ if r.pattern.lower().strip() == pattern
189
+ ]
190
+
191
+ # Check if they have the same priority
192
+ priorities = {r.priority for r in matching_rules}
193
+ if len(priorities) == 1:
194
+ # All have same priority - ambiguous!
195
+ targets_str = ", ".join(sorted(unique_targets))
196
+ errors.append(
197
+ f"Ambiguous delegation: pattern '{pattern}' delegates to "
198
+ f"multiple orchestrators ({targets_str}) with the same priority. "
199
+ f"This could cause unpredictable behavior. Consider using "
200
+ f"different priorities or combining into a single rule."
201
+ )
202
+
203
+ return errors
204
+
205
+ def validate(self) -> None:
206
+ """
207
+ Validate the entire configuration.
208
+
209
+ Performs the following validations:
210
+ 1. At least 2 agents are enabled
211
+ 2. All regex patterns in routing rules are valid
212
+ 3. All referenced agents exist in orchestrators
213
+ 4. No circular delegation rules exist
214
+
215
+ Raises:
216
+ ConfigValidationError: If any validation fails, with detailed error messages.
217
+ """
218
+ all_errors: list[str] = []
219
+
220
+ # Run all validation checks
221
+ all_errors.extend(self._validate_minimum_agents())
222
+ all_errors.extend(self._validate_regex_patterns())
223
+ all_errors.extend(self._validate_agent_references())
224
+ all_errors.extend(self._validate_no_circular_delegation())
225
+
226
+ # Raise exception if any errors found
227
+ if all_errors:
228
+ raise ConfigValidationError(all_errors)
229
+
230
+ @classmethod
231
+ def from_yaml(cls, path: Path, validate: bool = True) -> "DelegationConfig":
232
+ """
233
+ Load configuration from YAML file.
234
+
235
+ Args:
236
+ path: Path to YAML configuration file
237
+ validate: Whether to validate the configuration after loading (default: True)
238
+
239
+ Returns:
240
+ DelegationConfig instance
241
+
242
+ Raises:
243
+ ConfigValidationError: If validation is enabled and fails
244
+ """
245
+ with open(path, encoding="utf-8") as f:
246
+ data = yaml.safe_load(f)
247
+
248
+ config = cls(**data)
249
+
250
+ if validate:
251
+ config.validate()
252
+
253
+ return config
src/delegation_mcp/delegation.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Delegation engine for routing tasks to orchestrators."""
2
+
3
+ import re
4
+ import logging
5
+ from typing import Any
6
+ from datetime import datetime
7
+
8
+ from .config import DelegationConfig, DelegationRule
9
+ from .orchestrator import OrchestratorRegistry
10
+ from .retry import retry_with_backoff
11
+ from .logging_config import delegation_logger
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class DelegationResult:
18
+ """Result of a delegation operation."""
19
+
20
+ def __init__(
21
+ self,
22
+ query: str,
23
+ orchestrator: str,
24
+ delegated_to: str | None,
25
+ rule: DelegationRule | None,
26
+ output: str,
27
+ error: str,
28
+ success: bool,
29
+ duration: float,
30
+ ):
31
+ self.query = query
32
+ self.orchestrator = orchestrator
33
+ self.delegated_to = delegated_to
34
+ self.rule = rule
35
+ self.output = output
36
+ self.error = error
37
+ self.success = success
38
+ self.duration = duration
39
+ self.timestamp = datetime.now()
40
+
41
+ def __repr__(self) -> str:
42
+ delegation = f" -> {self.delegated_to}" if self.delegated_to else ""
43
+ return f"<DelegationResult {self.orchestrator}{delegation}: {self.success}>"
44
+
45
+
46
+ class DelegationEngine:
47
+ """Engine for delegating tasks based on rules."""
48
+
49
+ def __init__(self, config: DelegationConfig, registry: OrchestratorRegistry):
50
+ self.config = config
51
+ self.registry = registry
52
+ self.history: list[DelegationResult] = []
53
+
54
+ async def _execute_with_fallback(
55
+ self,
56
+ query: str,
57
+ ranked_agents: list[str],
58
+ tried_agents: list[str] = None,
59
+ progress_callback: Any = None,
60
+ timeout: int | None = None,
61
+ ) -> tuple[str, str, str, int]:
62
+ """
63
+ Execute query with automatic fallback to next best agent on failure.
64
+
65
+ Returns:
66
+ tuple: (target_agent, stdout, stderr, returncode)
67
+ """
68
+ if tried_agents is None:
69
+ tried_agents = []
70
+
71
+ for agent in ranked_agents:
72
+ if agent in tried_agents:
73
+ continue
74
+
75
+ try:
76
+ logger.info(f"Executing: {agent}")
77
+ stdout, stderr, returncode = await self.registry.execute(
78
+ agent, query, timeout=timeout, progress_callback=progress_callback
79
+ )
80
+
81
+ if returncode == 0:
82
+ logger.info(f"Success: {agent} completed task")
83
+ return agent, stdout, stderr, returncode
84
+
85
+ # Failed but can try fallback
86
+ logger.warning(f"Fallback: {agent} failed (rc={returncode}) → trying next agent")
87
+ tried_agents.append(agent)
88
+
89
+ except (TimeoutError, RuntimeError, Exception) as e:
90
+ error_type = type(e).__name__
91
+ logger.warning(f"Fallback: {agent} error ({error_type}) → trying next agent")
92
+ tried_agents.append(agent)
93
+ continue
94
+
95
+ # All agents failed
96
+ raise RuntimeError(f"All agents failed. Tried: {', '.join(tried_agents)}")
97
+
98
+ async def process(
99
+ self,
100
+ query: str,
101
+ force_delegate: str | None = None,
102
+ progress_callback: Any = None,
103
+ guidance_only: bool = False,
104
+ ) -> DelegationResult:
105
+ """
106
+ Process a query with delegation logic and automatic fallback.
107
+
108
+ Args:
109
+ query: User query/task
110
+ force_delegate: Force delegation to specific orchestrator
111
+ progress_callback: Optional async callback for progress reporting
112
+ guidance_only: If True, return routing guidance without executing
113
+
114
+ Returns:
115
+ DelegationResult
116
+ """
117
+ start = datetime.now()
118
+ orchestrator = "claude"
119
+
120
+ # Determine delegation and get ranked agents
121
+ target, rule = self._determine_delegation(query, force_delegate)
122
+
123
+ # If guidance_only mode, return routing recommendation without executing
124
+ if guidance_only:
125
+ if target == orchestrator:
126
+ return DelegationResult(
127
+ query=query,
128
+ orchestrator=orchestrator,
129
+ delegated_to=None,
130
+ rule=rule,
131
+ output="HANDLE_DIRECTLY",
132
+ error="",
133
+ success=True,
134
+ duration=0.0,
135
+ )
136
+ else:
137
+ return DelegationResult(
138
+ query=query,
139
+ orchestrator=orchestrator,
140
+ delegated_to=target,
141
+ rule=rule,
142
+ output=f"DELEGATE_TO: {target}",
143
+ error="",
144
+ success=True,
145
+ duration=0.0,
146
+ )
147
+
148
+ # Get full ranking for fallback
149
+ if force_delegate:
150
+ ranked_agents = [force_delegate]
151
+ else:
152
+ ranked_agents = self._rank_by_capabilities(query)
153
+
154
+ # Ensure target is executed first
155
+ if target in ranked_agents:
156
+ ranked_agents.remove(target)
157
+ ranked_agents.insert(0, target)
158
+
159
+ if not ranked_agents:
160
+ ranked_agents = [orchestrator]
161
+
162
+ # Get recommended timeout based on task classification
163
+ _, recommended_timeout = self._classify_task(query)
164
+
165
+ # Log delegation start
166
+ delegated_to = target if target != orchestrator else None
167
+ delegation_logger.delegation_start(orchestrator, query, delegated_to)
168
+
169
+ # Execute with fallback
170
+ try:
171
+ actual_agent, stdout, stderr, returncode = await self._execute_with_fallback(
172
+ query, ranked_agents, progress_callback=progress_callback, timeout=recommended_timeout
173
+ )
174
+ success = returncode == 0
175
+
176
+ # Update delegated_to if we fell back to different agent
177
+ if actual_agent != target:
178
+ logger.info(f"Fallback chain: {target} → {actual_agent}")
179
+ delegated_to = actual_agent if actual_agent != orchestrator else None
180
+ else:
181
+ delegated_to = target if target != orchestrator else None
182
+
183
+ except Exception as e:
184
+ # All agents failed
185
+ stdout = ""
186
+ stderr = str(e)
187
+ success = False
188
+ actual_agent = target
189
+ logger.error(f"All agents failed: {e}")
190
+
191
+ duration = (datetime.now() - start).total_seconds()
192
+
193
+ result = DelegationResult(
194
+ query=query,
195
+ orchestrator=orchestrator,
196
+ delegated_to=delegated_to,
197
+ rule=rule,
198
+ output=stdout,
199
+ error=stderr,
200
+ success=success,
201
+ duration=duration,
202
+ )
203
+
204
+ # Log result
205
+ if success:
206
+ delegation_logger.delegation_success(orchestrator, delegated_to, duration)
207
+ else:
208
+ delegation_logger.delegation_failure(orchestrator, delegated_to, stderr, duration)
209
+
210
+ if self.config.log_delegations:
211
+ self.history.append(result)
212
+
213
+ return result
214
+
215
+ def _estimate_task_complexity(self, query: str) -> str:
216
+ """
217
+ Estimate task complexity to determine if delegation overhead is worth it.
218
+
219
+ Returns:
220
+ "simple" | "medium" | "complex"
221
+
222
+ Simple tasks: Claude handles directly (delegation overhead > token savings)
223
+ Medium/Complex tasks: Delegate to specialized agents (token savings > overhead)
224
+ """
225
+ query_lower = query.lower()
226
+
227
+ # SIMPLE: Read-only operations and single-step deterministic commands
228
+ # These don't benefit from AI - just execute directly
229
+ simple_patterns = [
230
+ r"^git\s+status\s*$",
231
+ r"^git\s+log",
232
+ r"^git\s+show",
233
+ r"^git\s+diff\s+[\w\./\-]+\s*$", # Single file diff
234
+ r"^git\s+branch\s*(-a|-r)?\s*$",
235
+ r"^git\s+remote",
236
+ r"^git\s+stash\s+(list|show)?\s*$",
237
+ r"^git\s+checkout\s+[\w\-/]+\s*$", # Simple branch switch
238
+ r"^git\s+checkout\s+-b\s+[\w\-/]+\s*$", # Create branch
239
+ r"^git\s+add\s+[\w\./\-]+\s*$", # Add specific files
240
+ r"^git\s+pull\s*$", # Simple pull (no conflicts mentioned)
241
+ r"^gh\s+pr\s+(view|list)",
242
+ r"^gh\s+issue\s+list",
243
+ r"^gh\s+repo\s+view",
244
+ ]
245
+
246
+ # COMPLEX: Multi-step workflows, content generation, safety-critical operations
247
+ # These have high token costs or need AI decision-making
248
+ complex_indicators = [
249
+ # Git operations requiring intelligence
250
+ "commit", # Needs message generation
251
+ "create a commit",
252
+ "commit message",
253
+ "amend",
254
+ "rebase",
255
+ "cherry-pick",
256
+ "squash",
257
+ "merge conflict",
258
+ "resolve conflict",
259
+ "git history",
260
+ "clean up",
261
+ "--force",
262
+ "force push",
263
+ "force-with-lease",
264
+
265
+ # GitHub operations requiring content generation
266
+ "create pr",
267
+ "create pull request",
268
+ "pr create",
269
+ "pull request",
270
+ "create issue",
271
+ "issue create",
272
+ "pr review",
273
+ "review pr",
274
+ "create release",
275
+ "release create",
276
+
277
+ # Multi-step workflows
278
+ "create a pr for",
279
+ "commit and push",
280
+ "push my changes",
281
+ "stage and commit",
282
+ ]
283
+
284
+ # MEDIUM: Operations that might need error handling but aren't always complex
285
+ medium_indicators = [
286
+ "push -u",
287
+ "set-upstream",
288
+ "push origin",
289
+ "push --tags",
290
+ "merge", # Might have conflicts
291
+ "revert",
292
+ "tag -a",
293
+ "checkout -b.*origin", # Track remote branch
294
+ ]
295
+
296
+ # Check simple patterns first
297
+ for pattern in simple_patterns:
298
+ if re.match(pattern, query, re.IGNORECASE):
299
+ logger.debug(f"Complexity: SIMPLE (pattern match: {pattern})")
300
+ return "simple"
301
+
302
+ # Check complex indicators
303
+ for indicator in complex_indicators:
304
+ if indicator in query_lower:
305
+ logger.debug(f"Complexity: COMPLEX (indicator: {indicator})")
306
+ return "complex"
307
+
308
+ # Check medium indicators
309
+ for indicator in medium_indicators:
310
+ if indicator in query_lower:
311
+ logger.debug(f"Complexity: MEDIUM (indicator: {indicator})")
312
+ return "medium"
313
+
314
+ # Default: if query mentions git/github at all, it's medium
315
+ # Otherwise let task classification determine routing
316
+ if "git" in query_lower or "gh " in query_lower:
317
+ logger.debug("Complexity: MEDIUM (default git/gh command)")
318
+ return "medium"
319
+
320
+ # Not a git/github command - let normal routing decide
321
+ return "medium"
322
+
323
+ def _classify_task(self, query: str) -> tuple[str, int]:
324
+ """Classify task type and return recommended timeout."""
325
+ query_lower = query.lower()
326
+
327
+ keywords = {
328
+ "security_audit": ["security", "vulnerability", "audit", "cve", "exploit", "penetration"],
329
+ "vulnerability_scan": ["scan", "vulnerability", "vuln", "security issue"],
330
+ "code_review": ["review", "code quality", "best practice", "lint"],
331
+ "architecture": ["architecture", "design", "system design", "structure"],
332
+ "refactoring": ["refactor", "restructure", "clean up", "improve code"],
333
+ "quick_fix": ["fix", "bug", "error", "issue", "broken"],
334
+ "documentation": ["document", "docs", "readme", "guide", "explain"],
335
+ "testing": ["test", "unittest", "integration test", "e2e"],
336
+ "performance": ["performance", "optimize", "speed", "latency", "benchmark"],
337
+ "git_workflow": ["commit", "push", "rebase", "merge", "cherry-pick", "squash", "git history"],
338
+ "github_operations": ["pull request", "pr create", "pr review", "issue create", "release"],
339
+ }
340
+
341
+ # Timeout presets based on task complexity
342
+ TIMEOUT_PRESETS = {
343
+ "quick_fix": 60, # 1 min - simple bug fixes
344
+ "refactoring": 300, # 5 min - code refactoring
345
+ "security_audit": 600, # 10 min - comprehensive security review
346
+ "code_review": 600, # 10 min - full code review
347
+ "performance": 900, # 15 min - profiling/optimization
348
+ "testing": 300, # 5 min - test generation
349
+ "documentation": 180, # 3 min - documentation writing
350
+ "architecture": 300, # 5 min - design work
351
+ "vulnerability_scan": 300, # 5 min - automated scanning
352
+ "git_workflow": 180, # 3 min - git operations
353
+ "github_operations": 240, # 4 min - GitHub API operations
354
+ "general": 300, # 5 min - default
355
+ }
356
+
357
+ for task_type, terms in keywords.items():
358
+ if any(term in query_lower for term in terms):
359
+ timeout = TIMEOUT_PRESETS.get(task_type, 300)
360
+ return task_type, timeout
361
+
362
+ return "general", 300
363
+
364
+ def _rank_by_capabilities(self, query: str) -> list[str]:
365
+ """Rank agents by capability scores for this query."""
366
+ task_type, _ = self._classify_task(query) # Unpack tuple, ignore timeout
367
+
368
+ scores = []
369
+ for name, config in self.config.orchestrators.items():
370
+ if not config.enabled:
371
+ continue
372
+
373
+ # Get capability score for this task type
374
+ capability_score = getattr(config.capabilities, task_type, 0.5)
375
+
376
+ # Simple scoring: capability is primary factor
377
+ score = capability_score
378
+
379
+ scores.append((name, score))
380
+
381
+ # Sort by score descending
382
+ scores.sort(key=lambda x: x[1], reverse=True)
383
+
384
+ # Log ranking for transparency
385
+ if scores:
386
+ ranking_str = ", ".join([f"{name} ({score:.2f})" for name, score in scores[:3]])
387
+ logger.info(f"Task: {task_type} | Ranked: {ranking_str}")
388
+
389
+ # Return agent names in ranked order
390
+ return [name for name, _ in scores]
391
+
392
+ def _determine_delegation(
393
+ self,
394
+ query: str,
395
+ force_delegate: str | None,
396
+ ) -> tuple[str, DelegationRule | None]:
397
+ """
398
+ Determine which orchestrator should handle the query using capability-based routing.
399
+
400
+ Returns:
401
+ tuple: (target_orchestrator, matching_rule)
402
+ """
403
+ # Force delegation overrides everything
404
+ if force_delegate:
405
+ logger.info(f"Routing: FORCED → {force_delegate}")
406
+ return force_delegate, None
407
+
408
+ # Check task complexity first - simple tasks handled directly by Claude
409
+ complexity = self._estimate_task_complexity(query)
410
+ if complexity == "simple":
411
+ logger.info(f"Routing: SIMPLE task → claude (delegation overhead not worth it)")
412
+ return "claude", None
413
+
414
+ # Check explicit delegation rules
415
+ rule = self.config.find_delegation_rule(query)
416
+ if rule:
417
+ logger.info(f"Routing: {rule.pattern} → {rule.delegate_to} (rule-based)")
418
+ return rule.delegate_to, rule
419
+
420
+ # Use capability-based routing for medium/complex tasks
421
+ if self.config.routing_strategy in ["capability", "hybrid"]:
422
+ ranked = self._rank_by_capabilities(query)
423
+ if ranked:
424
+ task_type, _ = self._classify_task(query) # Unpack tuple
425
+ # If top ranked agent is Claude, check if delegation is still worth it
426
+ if ranked[0] == "claude" and complexity == "medium":
427
+ logger.info(f"Routing: {task_type} → claude (best match, medium complexity)")
428
+ return "claude", None
429
+ logger.info(f"Routing: {task_type} [{complexity}] → {ranked[0]} (capability-based)")
430
+ return ranked[0], None
431
+
432
+ # Fallback to primary orchestrator
433
+ logger.info(f"Routing: DEFAULT → claude")
434
+ return "claude", None
435
+
436
+ def get_statistics(self) -> dict[str, Any]:
437
+ """Get delegation statistics."""
438
+ if not self.history:
439
+ return {"total": 0, "by_orchestrator": {}, "delegations": 0}
440
+
441
+ by_orchestrator: dict[str, int] = {}
442
+ delegations = 0
443
+
444
+ for result in self.history:
445
+ target = result.delegated_to or result.orchestrator
446
+ by_orchestrator[target] = by_orchestrator.get(target, 0) + 1
447
+ if result.delegated_to:
448
+ delegations += 1
449
+
450
+ return {
451
+ "total": len(self.history),
452
+ "by_orchestrator": by_orchestrator,
453
+ "delegations": delegations,
454
+ "delegation_rate": delegations / len(self.history) * 100,
455
+ "success_rate": sum(r.success for r in self.history) / len(self.history) * 100,
456
+ "avg_duration": sum(r.duration for r in self.history) / len(self.history),
457
+ }
458
+
459
+ def clear_history(self) -> None:
460
+ """Clear delegation history."""
461
+ self.history.clear()
src/delegation_mcp/gradio_monitor.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal Gradio monitor for demo videos.
3
+
4
+ This is NOT a chat interface - it's a live activity monitor that shows
5
+ delegation events happening when Claude Code (or other MCP clients) call
6
+ the delegation MCP server.
7
+
8
+ Purpose: Makes demo videos visually compelling by showing real-time delegation activity.
9
+ """
10
+
11
+ try:
12
+ import gradio as gr
13
+ GRADIO_AVAILABLE = True
14
+ except ImportError:
15
+ GRADIO_AVAILABLE = False
16
+ # Mock gr for type hinting if needed, or just handle availability check
17
+ gr = None # type: ignore
18
+
19
+ from pathlib import Path
20
+ from datetime import datetime
21
+ from collections import deque
22
+
23
+ # from .persistence import PersistenceManager
24
+
25
+
26
+ class DelegationMonitor:
27
+ """Monitors delegation activity for demo visualization."""
28
+
29
+ def __init__(self, db_path: Path = Path("data/delegation.db")):
30
+ # self.persistence = PersistenceManager(db_path)
31
+ self.recent_events = deque(maxlen=20) # Keep last 20 events
32
+
33
+ def get_recent_activity(self):
34
+ """Get recent delegation events for display."""
35
+ return []
36
+ # try:
37
+ # history = self.persistence.get_task_history(limit=20)
38
+ # return [
39
+ # [
40
+ # entry.timestamp.strftime("%H:%M:%S"),
41
+ # entry.orchestrator,
42
+ # entry.delegated_to or "N/A",
43
+ # "✅" if entry.success else "❌",
44
+ # f"{entry.duration:.2f}s"
45
+ # ]
46
+ # for entry in history
47
+ # ]
48
+ # except Exception:
49
+ # return []
50
+
51
+ def get_statistics(self):
52
+ """Get delegation statistics for charts."""
53
+ return {"total": 0, "success_rate": 0.0, "avg_duration": 0.0, "agent_usage": {}}
54
+ # try:
55
+ # stats = self.persistence.get_statistics()
56
+ # return {
57
+ # "total": stats.get("total_tasks", 0),
58
+ # "success_rate": stats.get("success_rate", 0.0),
59
+ # "avg_duration": stats.get("avg_duration", 0.0),
60
+ # "agent_usage": stats.get("agent_usage", {}),
61
+ # }
62
+ # except Exception:
63
+ # return {"total": 0, "success_rate": 0.0, "avg_duration": 0.0, "agent_usage": {}}
64
+
65
+
66
+ def create_monitor_ui(demo_server=None):
67
+ """Create minimal monitoring UI for demo videos (< 150 lines)."""
68
+ if not GRADIO_AVAILABLE:
69
+ print("Error: Gradio is not installed. Please install with `pip install .[ui]`")
70
+ return None
71
+
72
+ monitor = DelegationMonitor()
73
+
74
+ with gr.Blocks(title="Delegation MCP Monitor") as app:
75
+ gr.Markdown("""
76
+ # 🚀 Delegation MCP - Interactive Demo
77
+
78
+ **Test the intelligent routing system!** Enter a query below to see which agent
79
+ the MCP would route it to. Adjust settings to see how configuration affects routing.
80
+
81
+ ---
82
+ """)
83
+
84
+ # Configuration Section
85
+ with gr.Accordion("⚙️ Task Routing Configuration (Just like install.py!)", open=False):
86
+ gr.Markdown("### Assign Agents to Task Categories")
87
+ gr.Markdown("*Configure which agent handles each type of task - same options as install.py*")
88
+
89
+ with gr.Row():
90
+ with gr.Column():
91
+ security_agent = gr.Radio(["gemini", "claude", "aider"], value="gemini", label="🔒 Security Audits")
92
+ architecture_agent = gr.Radio(["claude", "gemini", "aider"], value="claude", label="🏗️ Architecture Design")
93
+ refactoring_agent = gr.Radio(["aider", "claude", "gemini"], value="aider", label="🔧 Refactoring")
94
+ quick_fix_agent = gr.Radio(["aider", "claude", "gemini"], value="aider", label="⚡ Quick Fixes")
95
+
96
+ with gr.Column():
97
+ code_review_agent = gr.Radio(["claude", "gemini", "aider"], value="claude", label="👀 Code Review")
98
+ performance_agent = gr.Radio(["gemini", "claude", "aider"], value="gemini", label="🚀 Performance")
99
+ testing_agent = gr.Radio(["claude", "gemini", "aider"], value="claude", label="🧪 Testing")
100
+ git_agent = gr.Radio(["aider", "claude", "gemini"], value="aider", label="📦 Git Operations")
101
+
102
+ gr.Markdown("""
103
+ **Try it:** Change "Security Audits" from Gemini to Claude, then test a security query!
104
+ **Note:** These are the "Balanced" preset defaults - experiment with different combinations!
105
+ """)
106
+
107
+ # Interactive Query Tester
108
+ with gr.Row():
109
+ with gr.Column():
110
+ gr.Markdown("### 🧪 Test Routing Intelligence")
111
+ query_input = gr.Textbox(
112
+ label="Enter your query",
113
+ placeholder="e.g., 'Scan my code for SQL injection vulnerabilities'",
114
+ lines=3
115
+ )
116
+
117
+ submit_btn = gr.Button("🔍 Get Routing Decision", variant="primary", size="lg")
118
+
119
+ # Example queries
120
+ with gr.Accordion("💡 Example Queries", open=True):
121
+ gr.Markdown("**Simple Tasks:**")
122
+ gr.Examples(
123
+ examples=[
124
+ ["Scan this codebase for security vulnerabilities"],
125
+ ["Fix the bug causing tests to fail"],
126
+ ["Create a pull request with my changes"],
127
+ ],
128
+ inputs=query_input,
129
+ )
130
+
131
+ gr.Markdown("**Multi-Agent Workflows:**")
132
+ gr.Examples(
133
+ examples=[
134
+ ["Scan the codebase for frontend optimization improvements. Generate a report and fix the critical issues. Commit, push, deploy to preview, and test improvements with browser automation"],
135
+ ["Audit the authentication system for SQL injection and XSS. Create detailed security report with CVE references. Fix all critical vulnerabilities and update tests"],
136
+ ["Analyze API performance bottlenecks using profiling. Optimize database queries and add caching. Generate benchmark report comparing before/after metrics"],
137
+ ["Review the entire codebase for code quality issues. Refactor problem areas with proper error handling. Update documentation and create comprehensive test suite"],
138
+ ["Design microservices architecture for user management. Implement the service with authentication. Set up CI/CD pipeline and deploy to staging with monitoring"],
139
+ ],
140
+ inputs=query_input,
141
+ )
142
+
143
+ with gr.Column():
144
+ gr.Markdown("### 📊 Routing Decision")
145
+ decision_output = gr.Textbox(
146
+ label="Decision",
147
+ lines=2,
148
+ interactive=False
149
+ )
150
+ task_type_output = gr.Textbox(
151
+ label="Task Classification",
152
+ lines=1,
153
+ interactive=False
154
+ )
155
+ complexity_output = gr.Textbox(
156
+ label="Complexity Assessment",
157
+ lines=1,
158
+ interactive=False
159
+ )
160
+ reasoning_output = gr.Textbox(
161
+ label="Routing Reasoning",
162
+ lines=4,
163
+ interactive=False
164
+ )
165
+ cli_command_output = gr.Textbox(
166
+ label="CLI Command (if delegated)",
167
+ lines=2,
168
+ interactive=False
169
+ )
170
+
171
+ async def test_routing(query, sec_agent, arch_agent, refactor_agent, fix_agent,
172
+ review_agent, perf_agent, test_agent, git_agent):
173
+ """Test routing for a query without executing."""
174
+ if not query.strip():
175
+ return "Please enter a query", "", "", "", ""
176
+
177
+ if not demo_server:
178
+ return "❌ Server not initialized", "", "", "", ""
179
+
180
+ try:
181
+ # Map task categories to selected agents
182
+ task_agent_map = {
183
+ "security_audit": sec_agent,
184
+ "vulnerability_scan": sec_agent,
185
+ "architecture": arch_agent,
186
+ "refactoring": refactor_agent,
187
+ "quick_fix": fix_agent,
188
+ "code_review": review_agent,
189
+ "performance": perf_agent,
190
+ "testing": test_agent,
191
+ "git_workflow": git_agent,
192
+ "github_operations": git_agent,
193
+ }
194
+
195
+ # Temporarily update routing rules based on user selection
196
+ from delegation_mcp.config import DelegationRule
197
+ demo_server.config.rules.clear()
198
+
199
+ # Add rules for each task category
200
+ for task_type, agent in task_agent_map.items():
201
+ rule = DelegationRule(
202
+ delegate_to=agent,
203
+ pattern=task_type,
204
+ description=f"User configured: {task_type} → {agent}",
205
+ priority=10
206
+ )
207
+ demo_server.config.rules.append(rule)
208
+
209
+ # Get routing guidance
210
+ result = await demo_server.engine.process(
211
+ query,
212
+ guidance_only=True
213
+ )
214
+
215
+ # Classify task type and complexity
216
+ task_type, timeout = demo_server.engine._classify_task(query)
217
+ complexity = demo_server.engine._estimate_task_complexity(query)
218
+
219
+ # Determine delegation and get reasoning
220
+ target, rule = demo_server.engine._determine_delegation(query, None)
221
+
222
+ # Build reasoning explanation
223
+ reasoning_parts = []
224
+
225
+ # Complexity assessment
226
+ reasoning_parts.append(f"📏 Complexity: {complexity.upper()}")
227
+
228
+ # Task type detection
229
+ query_lower = query.lower()
230
+ keywords = {
231
+ "security_audit": ["security", "vulnerability", "audit", "cve"],
232
+ "architecture": ["architecture", "design", "system design"],
233
+ "refactoring": ["refactor", "restructure", "clean up"],
234
+ "quick_fix": ["fix", "bug", "error"],
235
+ "performance": ["performance", "optimize", "speed"],
236
+ "code_review": ["review", "code quality", "best practice"],
237
+ }
238
+ detected_keywords = []
239
+ for task, kws in keywords.items():
240
+ if task == task_type:
241
+ detected_keywords = [kw for kw in kws if kw in query_lower]
242
+ break
243
+
244
+ if detected_keywords:
245
+ reasoning_parts.append(f"🔍 Keywords: {', '.join(detected_keywords[:3])}")
246
+
247
+ # Routing strategy
248
+ if rule:
249
+ if "User configured" in rule.description:
250
+ reasoning_parts.append(f"⚙️ User Configuration: {task_type} → {result.delegated_to or 'claude'}")
251
+ else:
252
+ reasoning_parts.append(f"📋 Matched Rule: {rule.pattern}")
253
+ elif complexity == "simple":
254
+ reasoning_parts.append("⚡ Simple task - no delegation overhead needed")
255
+ else:
256
+ reasoning_parts.append(f"🎯 Capability-based routing for {task_type}")
257
+
258
+ reasoning = "\n".join(reasoning_parts)
259
+
260
+ # Format decision
261
+ if result.delegated_to:
262
+ decision = f"✅ DELEGATE TO: {result.delegated_to.upper()}"
263
+ cli_cmd = f'{result.delegated_to} "{query}"'
264
+ else:
265
+ decision = "✅ HANDLE DIRECTLY (Claude)"
266
+ cli_cmd = "N/A - Claude handles internally"
267
+
268
+ task_info = f"{task_type.replace('_', ' ').title()}"
269
+ complexity_info = f"{complexity.title()} (timeout: {timeout}s)"
270
+
271
+ return decision, task_info, complexity_info, reasoning, cli_cmd
272
+
273
+ except Exception as e:
274
+ return f"❌ Error: {str(e)}", "", "", "", ""
275
+
276
+ submit_btn.click(
277
+ fn=test_routing,
278
+ inputs=[
279
+ query_input,
280
+ security_agent, architecture_agent, refactoring_agent, quick_fix_agent,
281
+ code_review_agent, performance_agent, testing_agent, git_agent
282
+ ],
283
+ outputs=[decision_output, task_type_output, complexity_output, reasoning_output, cli_command_output]
284
+ )
285
+
286
+ gr.Markdown("---")
287
+
288
+ gr.Markdown("""
289
+ ## 🚀 Want to Test the Full System?
290
+
291
+ This demo shows the **routing intelligence** only. To see actual task delegation with AI agents:
292
+
293
+ ### Option 1: Duplicate This Space
294
+ 1. Click the **⋮** menu (top right) → **Duplicate Space**
295
+ 2. Add your API keys in **Settings → Secrets**:
296
+ - `ANTHROPIC_API_KEY` - For Claude
297
+ - `GOOGLE_API_KEY` - For Gemini
298
+ 3. Install agent CLIs in your duplicated Space (optional for full functionality)
299
+
300
+ ### Option 2: Install Locally
301
+ ```bash
302
+ git clone https://github.com/carlosduplar/multi-agent-mcp.git
303
+ cd multi-agent-mcp
304
+ pip install -e .
305
+ python install.py
306
+ ```
307
+
308
+ Then configure Claude Code to use the MCP server and start delegating tasks!
309
+
310
+ ---
311
+
312
+ ### 📚 Learn More
313
+ - **GitHub**: [carlosduplar/multi-agent-mcp](https://github.com/carlosduplar/multi-agent-mcp)
314
+ - **Documentation**: Full setup guide in README
315
+ - **MCP Hackathon**: Built for MCP 1st Birthday (Winter 2025)
316
+ """)
317
+
318
+ return app
319
+
320
+
321
+ def main():
322
+ """Launch monitor UI."""
323
+ app = create_monitor_ui()
324
+ if app:
325
+ app.launch(server_name="0.0.0.0", server_port=7860, share=False)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
src/delegation_mcp/installer/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Automated installer for delegation-mcp."""
2
+
3
+ from .installer import DelegationInstaller, main
4
+
5
+ __all__ = ["DelegationInstaller", "main"]
src/delegation_mcp/installer/agent_profiles.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent capability profiles and routing presets.
2
+
3
+ This module defines factual capability profiles for known agents and
4
+ routing presets for different delegation strategies.
5
+ """
6
+
7
+ from typing import TypedDict, Literal, Any
8
+
9
+
10
+ class AgentAttributes(TypedDict):
11
+ """Factual attributes for an agent."""
12
+ cost_tier: Literal["free", "subscription", "pay-per-token"]
13
+ deployment: Literal["local", "cloud"]
14
+ context_window: int # approximate tokens
15
+ has_git_integration: bool
16
+ has_browser_tools: bool
17
+ response_speed: Literal["fast", "medium", "slow"]
18
+ primary_strength: str
19
+ description: str
20
+ capabilities: dict[str, float]
21
+
22
+
23
+ # Default profile for unknown agents
24
+ DEFAULT_ATTRIBUTES: AgentAttributes = {
25
+ "cost_tier": "pay-per-token",
26
+ "deployment": "cloud",
27
+ "context_window": 8192,
28
+ "has_git_integration": False,
29
+ "has_browser_tools": False,
30
+ "response_speed": "medium",
31
+ "primary_strength": "general",
32
+ "description": "General purpose agent",
33
+ "capabilities": {
34
+ "security_audit": 0.5,
35
+ "vulnerability_scan": 0.5,
36
+ "code_review": 0.5,
37
+ "architecture": 0.5,
38
+ "refactoring": 0.5,
39
+ "quick_fix": 0.5,
40
+ "documentation": 0.5,
41
+ "testing": 0.5,
42
+ "performance": 0.5,
43
+ "git_workflow": 0.5,
44
+ "github_operations": 0.5,
45
+ "general": 0.5,
46
+ },
47
+ }
48
+
49
+ # Factual profiles for known agents
50
+ AGENT_PROFILES: dict[str, AgentAttributes] = {
51
+ "claude": {
52
+ "cost_tier": "pay-per-token",
53
+ "deployment": "cloud",
54
+ "context_window": 200000,
55
+ "has_git_integration": False,
56
+ "has_browser_tools": False,
57
+ "response_speed": "medium",
58
+ "primary_strength": "complex reasoning",
59
+ "description": "Best for complex reasoning, architecture, and code review",
60
+ "capabilities": {
61
+ "security_audit": 0.8,
62
+ "vulnerability_scan": 0.7,
63
+ "code_review": 0.9,
64
+ "architecture": 0.9,
65
+ "refactoring": 0.8,
66
+ "quick_fix": 0.7,
67
+ "documentation": 0.9,
68
+ "testing": 0.8,
69
+ "performance": 0.7,
70
+ "git_workflow": 0.1,
71
+ "github_operations": 0.1,
72
+ "general": 0.9,
73
+ },
74
+ },
75
+ "gemini": {
76
+ "cost_tier": "pay-per-token",
77
+ "deployment": "cloud",
78
+ "context_window": 1000000,
79
+ "has_git_integration": False,
80
+ "has_browser_tools": True,
81
+ "response_speed": "medium",
82
+ "primary_strength": "security & performance",
83
+ "description": "Strong security analysis, performance optimization, and browser tools",
84
+ "capabilities": {
85
+ "security_audit": 0.9,
86
+ "vulnerability_scan": 0.9,
87
+ "code_review": 0.8,
88
+ "architecture": 0.8,
89
+ "refactoring": 0.7,
90
+ "quick_fix": 0.7,
91
+ "documentation": 0.8,
92
+ "testing": 0.8,
93
+ "performance": 0.9,
94
+ "git_workflow": 0.1,
95
+ "github_operations": 0.1,
96
+ "general": 0.8,
97
+ },
98
+ },
99
+ "aider": {
100
+ "cost_tier": "free", # The tool itself is free, uses BYO keys or local models
101
+ "deployment": "local",
102
+ "context_window": 32000, # Depends on model, but tool manages context
103
+ "has_git_integration": True,
104
+ "has_browser_tools": False,
105
+ "response_speed": "fast",
106
+ "primary_strength": "git & refactoring",
107
+ "description": "Excellent for rapid code editing, refactoring, and git operations",
108
+ "capabilities": {
109
+ "security_audit": 0.4,
110
+ "vulnerability_scan": 0.4,
111
+ "code_review": 0.7,
112
+ "architecture": 0.6,
113
+ "refactoring": 0.9,
114
+ "quick_fix": 0.9,
115
+ "documentation": 0.6,
116
+ "testing": 0.7,
117
+ "performance": 0.5,
118
+ "git_workflow": 0.9,
119
+ "github_operations": 0.8,
120
+ "general": 0.7,
121
+ },
122
+ },
123
+ "copilot": {
124
+ "cost_tier": "subscription",
125
+ "deployment": "cloud",
126
+ "context_window": 32000,
127
+ "has_git_integration": False,
128
+ "has_browser_tools": False,
129
+ "response_speed": "fast",
130
+ "primary_strength": "quick fixes",
131
+ "description": "Balanced capabilities with strong quick fixes and testing",
132
+ "capabilities": {
133
+ "security_audit": 0.5,
134
+ "vulnerability_scan": 0.5,
135
+ "code_review": 0.7,
136
+ "architecture": 0.6,
137
+ "refactoring": 0.8,
138
+ "quick_fix": 0.9,
139
+ "documentation": 0.7,
140
+ "testing": 0.9,
141
+ "performance": 0.6,
142
+ "git_workflow": 0.3,
143
+ "github_operations": 0.3,
144
+ "general": 0.7,
145
+ },
146
+ },
147
+ "qwen": {
148
+ "cost_tier": "free", # Usually run locally
149
+ "deployment": "local",
150
+ "context_window": 32000,
151
+ "has_git_integration": False,
152
+ "has_browser_tools": False,
153
+ "response_speed": "medium",
154
+ "primary_strength": "code review",
155
+ "description": "Code-focused with strong review and architecture capabilities",
156
+ "capabilities": {
157
+ "security_audit": 0.6,
158
+ "vulnerability_scan": 0.6,
159
+ "code_review": 0.8,
160
+ "architecture": 0.7,
161
+ "refactoring": 0.7,
162
+ "quick_fix": 0.7,
163
+ "documentation": 0.6,
164
+ "testing": 0.7,
165
+ "performance": 0.6,
166
+ "git_workflow": 0.2,
167
+ "github_operations": 0.2,
168
+ "general": 0.7,
169
+ },
170
+ },
171
+ }
172
+
173
+
174
+ class RoutingPreset(TypedDict):
175
+ """Configuration for a routing strategy."""
176
+ name: str
177
+ description: str
178
+ strategy_description: str
179
+ cost_priority: Literal["low", "medium", "high"]
180
+ quality_priority: Literal["low", "medium", "high"]
181
+
182
+
183
+ ROUTING_PRESETS: dict[str, RoutingPreset] = {
184
+ "best_in_class": {
185
+ "name": "Best in Class",
186
+ "description": "Highest quality, cost is secondary",
187
+ "strategy_description": "Prefer Claude for architecture/review, Gemini for security, Aider for git",
188
+ "cost_priority": "low",
189
+ "quality_priority": "high",
190
+ },
191
+ "cost_optimized": {
192
+ "name": "Cost Optimized",
193
+ "description": "Minimize API costs, prefer local",
194
+ "strategy_description": "Prefer local models and Aider, use cloud agents only when necessary",
195
+ "cost_priority": "high",
196
+ "quality_priority": "medium",
197
+ },
198
+ "token_saver": {
199
+ "name": "Token Saver",
200
+ "description": "Minimize token usage",
201
+ "strategy_description": "Use agents with large context windows, prefer concise responders",
202
+ "cost_priority": "high",
203
+ "quality_priority": "medium",
204
+ },
205
+ "speed_first": {
206
+ "name": "Speed First",
207
+ "description": "Fastest iteration, good for dev",
208
+ "strategy_description": "Prefer faster models like Aider and quick cloud APIs",
209
+ "cost_priority": "low",
210
+ "quality_priority": "medium",
211
+ },
212
+ "specialized": {
213
+ "name": "Specialized Routing",
214
+ "description": "Match tasks to native capabilities",
215
+ "strategy_description": "Match tasks to agents with native tool support (e.g. Browser -> Gemini)",
216
+ "cost_priority": "medium",
217
+ "quality_priority": "high",
218
+ },
219
+ "balanced": {
220
+ "name": "Balanced (Recommended)",
221
+ "description": "Good mix of quality, speed, cost",
222
+ "strategy_description": "Distribute work sensibly across all available agents",
223
+ "cost_priority": "medium",
224
+ "quality_priority": "medium",
225
+ },
226
+ }
227
+
228
+
229
+ class RoutingRule(TypedDict):
230
+ """Rule for routing a specific task category."""
231
+ preferred: list[str]
232
+ reason: str
233
+
234
+
235
+ # Default routing rules for "Best in Class" / "Balanced" baseline
236
+ # These are modified by the strategy logic in task_mapper.py
237
+ DEFAULT_ROUTING_RULES: dict[str, RoutingRule] = {
238
+ "architecture": {
239
+ "preferred": ["claude"],
240
+ "reason": "Marketed for complex reasoning",
241
+ },
242
+ "code_review": {
243
+ "preferred": ["claude", "qwen"],
244
+ "reason": "Strong reasoning capabilities",
245
+ },
246
+ "security_audit": {
247
+ "preferred": ["gemini"],
248
+ "reason": "Strong security analysis capabilities",
249
+ },
250
+ "refactoring": {
251
+ "preferred": ["aider"],
252
+ "reason": "Optimized for code editing",
253
+ },
254
+ "quick_fix": {
255
+ "preferred": ["aider", "copilot"],
256
+ "reason": "Optimized for speed and small edits",
257
+ },
258
+ "documentation": {
259
+ "preferred": ["claude"],
260
+ "reason": "Strong long-form writing capabilities",
261
+ },
262
+ "testing": {
263
+ "preferred": ["copilot", "claude"],
264
+ "reason": "Balanced testing capabilities",
265
+ },
266
+ "performance": {
267
+ "preferred": ["gemini"],
268
+ "reason": "Strong analytical capabilities",
269
+ },
270
+ "browser_interaction": {
271
+ "preferred": ["gemini"],
272
+ "reason": "Has browser automation tools",
273
+ },
274
+ "git_operations": {
275
+ "preferred": ["aider"],
276
+ "reason": "Native git integration",
277
+ },
278
+ "shell_tasks": {
279
+ "preferred": ["aider"],
280
+ "reason": "Strong command line capabilities",
281
+ },
282
+ "exploration": {
283
+ "preferred": ["claude"],
284
+ "reason": "Large context window for codebase understanding",
285
+ },
286
+ "debugging": {
287
+ "preferred": ["claude"],
288
+ "reason": "Complex reasoning for root cause analysis",
289
+ },
290
+ "impact_analysis": {
291
+ "preferred": ["claude"],
292
+ "reason": "Complex reasoning for dependency analysis",
293
+ },
294
+ "general": {
295
+ "preferred": ["claude"],
296
+ "reason": "General purpose reasoning",
297
+ },
298
+ }
299
+
300
+
301
+ def get_agent_profile(agent_name: str) -> AgentAttributes:
302
+ """
303
+ Get factual profile for an agent.
304
+
305
+ Args:
306
+ agent_name: Name of the agent (case-insensitive)
307
+
308
+ Returns:
309
+ AgentAttributes with factual metadata
310
+ """
311
+ agent_key = agent_name.lower().strip()
312
+ return AGENT_PROFILES.get(agent_key, DEFAULT_ATTRIBUTES)
src/delegation_mcp/installer/agent_selector.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive agent selection UI for installation.
2
+
3
+ This module provides an interactive interface for users to select
4
+ which agents they want to enable for delegation.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.table import Table
13
+
14
+ from ..agent_discovery import AgentMetadata
15
+
16
+ logger = logging.getLogger(__name__)
17
+ console = Console()
18
+
19
+
20
+ class AgentSelector:
21
+ """Manages interactive agent selection during installation."""
22
+
23
+ def __init__(self):
24
+ """Initialize the agent selector."""
25
+ self.selected_agents: list[str] = []
26
+
27
+ def display_agents(self, discovered_agents: dict[str, AgentMetadata]) -> None:
28
+ """
29
+ Display discovered agents in a formatted table.
30
+
31
+ Args:
32
+ discovered_agents: Dictionary of agent name to metadata
33
+ """
34
+ if not discovered_agents:
35
+ console.print("\n[yellow]No agents discovered.[/yellow]")
36
+ return
37
+
38
+ table = Table(title="Discovered Agents", show_header=True, header_style="bold magenta")
39
+ table.add_column("Agent", style="cyan", no_wrap=True)
40
+ table.add_column("Version", style="green")
41
+ table.add_column("Path", style="blue")
42
+ table.add_column("Attributes", style="yellow")
43
+
44
+ for name, metadata in discovered_agents.items():
45
+ # Get brief capabilities summary
46
+ attributes = self._get_attributes_summary(name)
47
+ version = metadata.version or "unknown"
48
+ path = str(metadata.path) if metadata.path else "system PATH"
49
+
50
+ table.add_row(name, version, path, attributes)
51
+
52
+ console.print("\n")
53
+ console.print(table)
54
+ console.print("\n")
55
+
56
+ def _get_attributes_summary(self, agent_name: str) -> str:
57
+ """
58
+ Get a brief summary of agent attributes.
59
+
60
+ Args:
61
+ agent_name: Name of the agent
62
+
63
+ Returns:
64
+ Brief attributes description
65
+ """
66
+ from .agent_profiles import get_agent_profile
67
+
68
+ profile = get_agent_profile(agent_name)
69
+
70
+ parts = []
71
+ parts.append(profile["primary_strength"])
72
+
73
+ if profile["has_git_integration"]:
74
+ parts.append("git")
75
+ if profile["has_browser_tools"]:
76
+ parts.append("browser")
77
+
78
+ return ", ".join(parts)
79
+
80
+ def prompt_selection(self, discovered_agents: dict[str, AgentMetadata]) -> list[str]:
81
+ """
82
+ Interactive prompt for agent selection.
83
+
84
+ Args:
85
+ discovered_agents: Dictionary of agent name to metadata
86
+
87
+ Returns:
88
+ List of selected agent names
89
+ """
90
+ if not discovered_agents:
91
+ return []
92
+
93
+ self.display_agents(discovered_agents)
94
+
95
+ console.print("[bold]Agent Selection[/bold]")
96
+ console.print("You can enable/disable individual agents or use all discovered agents.\n")
97
+
98
+ # Ask if user wants to customize selection
99
+ use_all = Confirm.ask(
100
+ "Enable all discovered agents?",
101
+ default=True
102
+ )
103
+
104
+ if use_all:
105
+ self.selected_agents = list(discovered_agents.keys())
106
+ console.print(f"\n[green]✓[/green] Selected all {len(self.selected_agents)} agents\n")
107
+ return self.selected_agents
108
+
109
+ # Custom selection
110
+ console.print("\nSelect agents to enable (you'll be prompted for each agent):\n")
111
+ self.selected_agents = []
112
+
113
+ for name, metadata in discovered_agents.items():
114
+ from .agent_profiles import get_agent_profile
115
+
116
+ profile = get_agent_profile(name)
117
+ description = profile["description"]
118
+
119
+ console.print(f"\n[cyan]{name}[/cyan]: {description}")
120
+
121
+ # Auto-enable Claude
122
+ if name.lower() == "claude":
123
+ self.selected_agents.append(name)
124
+ console.print(f" [green]✓[/green] Enabled (Required for orchestration)")
125
+ continue
126
+
127
+ enable = Confirm.ask(f" Enable {name}?", default=True)
128
+
129
+ if enable:
130
+ self.selected_agents.append(name)
131
+ console.print(f" [green]✓[/green] Enabled")
132
+ else:
133
+ console.print(f" [dim]✗[/dim] Disabled")
134
+
135
+ console.print(f"\n[green]✓[/green] Selected {len(self.selected_agents)} agents: {', '.join(self.selected_agents)}\n")
136
+ return self.selected_agents
137
+
138
+ def get_selected_agents(self) -> list[str]:
139
+ """
140
+ Get the list of selected agents.
141
+
142
+ Returns:
143
+ List of selected agent names
144
+ """
145
+ return self.selected_agents
146
+
147
+ def validate_selection(self, selected_agents: list[str]) -> tuple[bool, str]:
148
+ """
149
+ Validate that the selection meets requirements.
150
+
151
+ Args:
152
+ selected_agents: List of selected agent names
153
+
154
+ Returns:
155
+ Tuple of (is_valid, error_message)
156
+ """
157
+ if len(selected_agents) < 2:
158
+ return False, (
159
+ f"At least 2 agents are required for delegation, but only "
160
+ f"{len(selected_agents)} {'is' if len(selected_agents) == 1 else 'are'} selected. "
161
+ f"Please enable more agents."
162
+ )
163
+
164
+ return True, ""
src/delegation_mcp/installer/config_generator.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration file generator for orchestrators and delegation rules.
2
+
3
+ This module generates YAML configuration files based on user selections
4
+ during installation.
5
+ """
6
+
7
+ import logging
8
+ import shutil
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+ from rich.console import Console
15
+ from rich.prompt import Confirm
16
+
17
+ from ..agent_discovery import AgentMetadata
18
+ from .agent_profiles import get_agent_profile
19
+ from .task_mapper import TASK_CATEGORIES
20
+
21
+ logger = logging.getLogger(__name__)
22
+ console = Console()
23
+
24
+
25
+ class ConfigGenerator:
26
+ """Generates configuration files based on user selections."""
27
+
28
+ def __init__(self):
29
+ """Initialize the config generator."""
30
+ self.orchestrators_config: dict[str, Any] = {}
31
+ self.delegation_config: dict[str, Any] = {}
32
+
33
+ def check_existing_configs(self, project_dir: Path) -> bool:
34
+ """
35
+ Check if configuration files already exist.
36
+
37
+ Args:
38
+ project_dir: Project directory path
39
+
40
+ Returns:
41
+ True if configs exist, False otherwise
42
+ """
43
+ config_dir = project_dir / "config"
44
+ orchestrators_file = config_dir / "orchestrators.yaml"
45
+ delegation_file = config_dir / "delegation_rules.yaml"
46
+
47
+ return orchestrators_file.exists() or delegation_file.exists()
48
+
49
+ def prompt_existing_configs(self, project_dir: Path) -> str:
50
+ """
51
+ Prompt user for how to handle existing configs.
52
+
53
+ Args:
54
+ project_dir: Project directory path
55
+
56
+ Returns:
57
+ User choice: "overwrite", "backup", or "skip"
58
+ """
59
+ console.print("\n[yellow]⚠ Existing configuration detected[/yellow]")
60
+ console.print("\nConfiguration files already exist in this project.")
61
+ console.print("You can:")
62
+ console.print(" [cyan]O[/cyan]verwrite - Replace existing files with new configuration")
63
+ console.print(" [cyan]B[/cyan]ackup - Backup existing files and create new ones")
64
+ console.print(" [cyan]S[/cyan]kip - Keep existing files unchanged\n")
65
+
66
+ choice = ""
67
+ while choice not in ["o", "b", "s"]:
68
+ choice = input("Choose an option (O/B/S): ").lower().strip()
69
+
70
+ choice_map = {"o": "overwrite", "b": "backup", "s": "skip"}
71
+ return choice_map[choice]
72
+
73
+ def backup_existing_configs(self, project_dir: Path) -> None:
74
+ """
75
+ Backup existing configuration files.
76
+
77
+ Args:
78
+ project_dir: Project directory path
79
+ """
80
+ config_dir = project_dir / "config"
81
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
82
+
83
+ files_to_backup = [
84
+ "orchestrators.yaml",
85
+ "delegation_rules.yaml",
86
+ ]
87
+
88
+ for filename in files_to_backup:
89
+ source = config_dir / filename
90
+ if source.exists():
91
+ backup_name = f"{source.stem}.backup_{timestamp}{source.suffix}"
92
+ backup_path = config_dir / backup_name
93
+ shutil.copy2(source, backup_path)
94
+ console.print(f"[green]✓[/green] Backed up {filename} to {backup_name}")
95
+
96
+ def generate_orchestrators_yaml(
97
+ self,
98
+ selected_agents: list[str],
99
+ agent_metadata: dict[str, AgentMetadata],
100
+ ) -> dict[str, Any]:
101
+ """
102
+ Generate orchestrators.yaml configuration.
103
+
104
+ Args:
105
+ selected_agents: List of selected agent names
106
+ agent_metadata: Dictionary of agent name to metadata
107
+
108
+ Returns:
109
+ Orchestrators configuration dictionary
110
+ """
111
+ config: dict[str, Any] = {"orchestrators": {}}
112
+
113
+ for agent_name in selected_agents:
114
+ metadata = agent_metadata.get(agent_name)
115
+ if not metadata:
116
+ continue
117
+
118
+ # Get command and args from metadata
119
+ # command can be a string or list[str]
120
+ if isinstance(metadata.command, list):
121
+ command = metadata.command[0] if metadata.command else agent_name
122
+ args = metadata.command[1:] if len(metadata.command) > 1 else []
123
+ else:
124
+ command = metadata.command or agent_name
125
+ args = []
126
+
127
+ # Build orchestrator config
128
+ orch_config: dict[str, Any] = {
129
+ "name": agent_name,
130
+ "command": command,
131
+ "args": args,
132
+ "enabled": True,
133
+ "env": {},
134
+ "timeout": 300,
135
+ "max_retries": 3,
136
+ }
137
+
138
+ config["orchestrators"][agent_name] = orch_config
139
+
140
+ self.orchestrators_config = config
141
+ return config
142
+
143
+ def generate_delegation_rules_yaml(
144
+ self,
145
+ selected_agents: list[str],
146
+ task_mappings: dict[str, str],
147
+ agent_metadata: dict[str, AgentMetadata],
148
+ primary_orchestrator: str,
149
+ ) -> dict[str, Any]:
150
+ """
151
+ Generate delegation_rules.yaml configuration.
152
+
153
+ Args:
154
+ selected_agents: List of selected agent names
155
+ task_mappings: Dictionary of task_key -> agent_name
156
+ agent_metadata: Dictionary of agent name to metadata
157
+ primary_orchestrator: Name of primary orchestrator
158
+
159
+ Returns:
160
+ Delegation rules configuration dictionary
161
+ """
162
+ config: dict[str, Any] = {
163
+ "orchestrator": primary_orchestrator,
164
+ "routing_strategy": "hybrid",
165
+ "orchestrators": {},
166
+ "rules": [],
167
+ }
168
+
169
+ # Build orchestrators section with capabilities
170
+ for agent_name in selected_agents:
171
+ metadata = agent_metadata.get(agent_name)
172
+ if not metadata:
173
+ continue
174
+
175
+ profile = get_agent_profile(agent_name)
176
+ capabilities = profile["capabilities"]
177
+
178
+ # Get command and args from metadata
179
+ # command can be a string or list[str]
180
+ if isinstance(metadata.command, list):
181
+ command = metadata.command[0] if metadata.command else agent_name
182
+ args = metadata.command[1:] if len(metadata.command) > 1 else []
183
+ else:
184
+ command = metadata.command or agent_name
185
+ args = []
186
+
187
+ orch_config: dict[str, Any] = {
188
+ "name": agent_name,
189
+ "command": command,
190
+ "args": args,
191
+ "enabled": True,
192
+ "env": {},
193
+ "timeout": 300,
194
+ "max_retries": 3,
195
+ "cost_per_1k_tokens": 0.001,
196
+ "capabilities": dict(capabilities),
197
+ }
198
+
199
+ config["orchestrators"][agent_name] = orch_config
200
+
201
+ # Build delegation rules from task mappings
202
+ # Create task key to category mapping
203
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
204
+
205
+ priority = 10 # Start with high priority
206
+ for task_key, agent_name in task_mappings.items():
207
+ category = category_map.get(task_key)
208
+ if not category:
209
+ continue
210
+
211
+ # Build pattern from examples
212
+ pattern_parts = category["pattern_examples"]
213
+ pattern = "|".join(pattern_parts)
214
+
215
+ rule: dict[str, Any] = {
216
+ "delegate_to": agent_name,
217
+ "description": category["description"],
218
+ "pattern": pattern,
219
+ "priority": max(1, priority),
220
+ "requires_approval": False,
221
+ }
222
+
223
+ config["rules"].append(rule)
224
+ priority -= 1 # Decrease priority for next rule
225
+
226
+ # Add fallback rule (general tasks)
227
+ if "general" not in task_mappings:
228
+ # Use primary orchestrator as fallback
229
+ fallback_agent = primary_orchestrator
230
+ else:
231
+ fallback_agent = task_mappings["general"]
232
+
233
+ fallback_rule: dict[str, Any] = {
234
+ "delegate_to": fallback_agent,
235
+ "description": "General queries and fallback",
236
+ "pattern": ".*",
237
+ "priority": 1,
238
+ "requires_approval": False,
239
+ }
240
+ config["rules"].append(fallback_rule)
241
+
242
+ self.delegation_config = config
243
+ return config
244
+
245
+ def save_configs(self, project_dir: Path) -> None:
246
+ """
247
+ Save configuration files to disk.
248
+
249
+ Args:
250
+ project_dir: Project directory path
251
+ """
252
+ config_dir = project_dir / "config"
253
+ config_dir.mkdir(parents=True, exist_ok=True)
254
+
255
+ # Save orchestrators.yaml
256
+ orchestrators_file = config_dir / "orchestrators.yaml"
257
+ with open(orchestrators_file, "w", encoding="utf-8") as f:
258
+ yaml.dump(
259
+ self.orchestrators_config,
260
+ f,
261
+ default_flow_style=False,
262
+ sort_keys=False,
263
+ )
264
+ console.print(f"[green]✓[/green] Generated {orchestrators_file}")
265
+
266
+ # Save delegation_rules.yaml
267
+ delegation_file = config_dir / "delegation_rules.yaml"
268
+ with open(delegation_file, "w", encoding="utf-8") as f:
269
+ # Add header comment
270
+ f.write("# Delegation MCP Configuration\n")
271
+ f.write("# Auto-generated based on user selections\n\n")
272
+ yaml.dump(
273
+ self.delegation_config,
274
+ f,
275
+ default_flow_style=False,
276
+ sort_keys=False,
277
+ )
278
+ console.print(f"[green]✓[/green] Generated {delegation_file}")
279
+
280
+ def generate_configs(
281
+ self,
282
+ selected_agents: list[str],
283
+ task_mappings: dict[str, str],
284
+ agent_metadata: dict[str, AgentMetadata],
285
+ primary_orchestrator: str,
286
+ project_dir: Path,
287
+ scope: str = "local",
288
+ ) -> None:
289
+ """
290
+ Complete configuration generation flow.
291
+
292
+ Args:
293
+ selected_agents: List of selected agent names
294
+ task_mappings: Dictionary of task_key -> agent_name
295
+ agent_metadata: Dictionary of agent name to metadata
296
+ primary_orchestrator: Name of primary orchestrator
297
+ project_dir: Project directory path
298
+ scope: Installation scope - "local" for project-level, "user" for user-level
299
+ """
300
+ # Determine config directory based on scope
301
+ if scope == "user":
302
+ config_base_dir = Path.home() / ".delegation-mcp"
303
+ else:
304
+ config_base_dir = project_dir
305
+
306
+ # Check for existing configs
307
+ if self.check_existing_configs(config_base_dir):
308
+ choice = self.prompt_existing_configs(config_base_dir)
309
+
310
+ if choice == "skip":
311
+ console.print("\n[yellow]⚠[/yellow] Keeping existing configuration files\n")
312
+ return
313
+ elif choice == "backup":
314
+ self.backup_existing_configs(config_base_dir)
315
+
316
+ # Generate configurations
317
+ self.generate_orchestrators_yaml(selected_agents, agent_metadata)
318
+ self.generate_delegation_rules_yaml(
319
+ selected_agents, task_mappings, agent_metadata, primary_orchestrator
320
+ )
321
+
322
+ # Save to disk
323
+ self.save_configs(config_base_dir)
324
+ console.print("\n[green]✓[/green] Configuration files generated successfully\n")
src/delegation_mcp/installer/installer.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main installer logic."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import platform
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.prompt import Prompt
12
+
13
+ from ..agent_discovery import AgentDiscovery, AgentMetadata
14
+ from .agent_selector import AgentSelector
15
+ from .config_generator import ConfigGenerator
16
+ from .mcp_configurator import MCPConfigurator
17
+ from .task_mapper import TASK_CATEGORIES, TaskMapper
18
+
19
+ logger = logging.getLogger(__name__)
20
+ console = Console()
21
+
22
+
23
+ class DelegationInstaller:
24
+ """Automated installer for delegation-mcp."""
25
+
26
+ def __init__(self):
27
+ self.project_dir = Path.cwd()
28
+ self.agent_discovery = AgentDiscovery()
29
+ self.mcp_configurator = MCPConfigurator()
30
+ self.agent_selector = AgentSelector()
31
+ self.task_mapper = TaskMapper()
32
+ self.config_generator = ConfigGenerator()
33
+ self.discovered_agents: dict[str, AgentMetadata] = {}
34
+ self.selected_agents: list[str] = []
35
+ self.task_mappings: dict[str, str] = {}
36
+
37
+ def install(self) -> bool:
38
+ """Run full installation."""
39
+ try:
40
+ self._welcome()
41
+
42
+ if not self._check_system():
43
+ return False
44
+
45
+ asyncio.run(self._discover_agents())
46
+
47
+ if not self.discovered_agents:
48
+ self._no_agents_guide()
49
+ return False
50
+
51
+ # Select which agents to enable
52
+ self.selected_agents = self._select_agents()
53
+ if len(self.selected_agents) < 2:
54
+ console.print("[red]Need at least 2 agents for delegation.[/red]")
55
+ return False
56
+
57
+ # Select primary orchestrator
58
+ self.selected_orchestrator = "claude"
59
+ # Check if Claude is available
60
+ if "claude" not in self.discovered_agents:
61
+ console.print("[yellow]Claude Code not detected. Please install it first: npm install -g @anthropic/claude-code[/yellow]")
62
+ return False
63
+
64
+ # Map tasks to agents
65
+ if len(self.selected_agents) >= 2:
66
+ self.task_mappings = self._map_tasks()
67
+
68
+ # Ask about installation scope
69
+ install_project_instructions, install_user_instructions, mcp_scope = self._ask_user_level_instructions()
70
+
71
+ # Get the actual path to Claude executable
72
+ orchestrator_path = None
73
+ if "claude" in self.discovered_agents:
74
+ orchestrator_path = self.discovered_agents["claude"].path
75
+
76
+ if not self._configure_mcp(install_project_instructions, install_user_instructions, orchestrator_path, mcp_scope):
77
+ return False
78
+
79
+ self._print_success()
80
+ return True
81
+
82
+ except KeyboardInterrupt:
83
+ console.print("\n[yellow]Installation cancelled[/yellow]")
84
+ return False
85
+ except Exception as e:
86
+ console.print(f"\n[red]Installation failed: {e}[/red]")
87
+ logger.error(f"Installation error: {e}", exc_info=True)
88
+ return False
89
+
90
+ def _welcome(self):
91
+ """Print welcome message."""
92
+ console.print(Panel.fit(
93
+ "[bold blue]Delegation MCP Installer[/bold blue]\n"
94
+ "Automated multi-agent orchestration setup",
95
+ border_style="blue"
96
+ ))
97
+
98
+ def _check_system(self) -> bool:
99
+ """Check system requirements."""
100
+ console.print("\n[bold]Checking system requirements...[/bold]")
101
+
102
+ # Check Python version
103
+ if sys.version_info < (3, 10):
104
+ console.print(f"[red]X Python 3.10+ required (found {sys.version_info.major}.{sys.version_info.minor})[/red]")
105
+ return False
106
+ console.print(f"[green]OK Python {sys.version_info.major}.{sys.version_info.minor}[/green]")
107
+
108
+ # Check OS
109
+ system = platform.system()
110
+ console.print(f"[green]OK {system}[/green]")
111
+
112
+ return True
113
+
114
+ async def _discover_agents(self):
115
+ """Discover available agents."""
116
+ console.print("\n[bold]Discovering agents...[/bold]")
117
+
118
+ discovered = await self.agent_discovery.discover_agents(force_refresh=True)
119
+ self.discovered_agents = {k: v for k, v in discovered.items() if v.available}
120
+
121
+ if self.discovered_agents:
122
+ console.print(f"[green]Found {len(self.discovered_agents)} agents:[/green]")
123
+ for name, meta in self.discovered_agents.items():
124
+ console.print(f" [green]OK[/green] {name} ({meta.version})")
125
+ else:
126
+ console.print("[yellow]No agents detected[/yellow]")
127
+
128
+ def _no_agents_guide(self):
129
+ """Guide user to install agents."""
130
+ console.print("\n[bold yellow]No agents detected![/bold yellow]\n")
131
+ console.print("Install Claude Code:")
132
+ console.print(" • Claude Code: npm install -g @anthropic/claude-code\n")
133
+ console.print("Then run: [bold]python install.py[/bold]")
134
+
135
+ def _select_agents(self) -> list[str]:
136
+ """Let user select which agents to enable."""
137
+ return self.agent_selector.prompt_selection(self.discovered_agents)
138
+
139
+
140
+
141
+ def _map_tasks(self) -> dict[str, str]:
142
+ """Interactive task-to-agent mapping."""
143
+ console.print("\n[bold]Task Assignment Configuration[/bold]")
144
+ console.print("Configure which agents should handle which types of tasks.\n")
145
+ return self.task_mapper.map_tasks(self.selected_agents)
146
+
147
+ def _ask_user_level_instructions(self) -> tuple[bool, bool, str]:
148
+ """
149
+ Ask where to install system instructions and MCP server.
150
+
151
+ Returns:
152
+ Tuple of (install_project_level, install_user_level, mcp_scope)
153
+ """
154
+ console.print("\n[bold]Installation Scope:[/bold]")
155
+
156
+ console.print(" 1. Project-level only (.claude/CLAUDE.md + local MCP) - Only for this project")
157
+ console.print(" 2. User-level only (~/.claude/CLAUDE.md + global MCP) - For ALL Claude sessions")
158
+ console.print(" 3. Both project and user level (project instructions + global MCP)")
159
+
160
+ console.print(f"\n[dim]Recommended: Option 1 for project-specific setup, or 2 for global access.[/dim]")
161
+
162
+ choice = Prompt.ask(
163
+ "Choose installation scope",
164
+ choices=["1", "2", "3"],
165
+ default="1"
166
+ )
167
+
168
+ if choice == "1":
169
+ return (True, False, "local") # Project only
170
+ elif choice == "2":
171
+ return (False, True, "user") # User only
172
+ else:
173
+ return (True, True, "user") # Both (use user scope for MCP)
174
+
175
+
176
+ def _configure_mcp(self, install_project_instructions: bool = True, install_user_instructions: bool = False, orchestrator_path: Path = None, scope: str = "local") -> bool:
177
+ """Configure MCP client for Claude (the only orchestrator)."""
178
+ console.print(f"\n[bold]Configuring MCP client and delegation rules for Claude...[/bold]")
179
+
180
+ # Generate delegation configuration files
181
+ if self.task_mappings and len(self.selected_agents) >= 2:
182
+ console.print("\n[cyan]Generating delegation configuration files...[/cyan]")
183
+ self.config_generator.generate_configs(
184
+ selected_agents=self.selected_agents,
185
+ task_mappings=self.task_mappings,
186
+ agent_metadata=self.discovered_agents,
187
+ primary_orchestrator="claude",
188
+ project_dir=self.project_dir,
189
+ scope=scope,
190
+ )
191
+ console.print("[green]✓ Configuration files generated[/green]")
192
+
193
+ # Configure MCP server
194
+ results = self.mcp_configurator.inject_delegation_mcp(
195
+ self.project_dir,
196
+ "claude", # Claude is the only orchestrator
197
+ install_project_instructions,
198
+ install_user_instructions,
199
+ orchestrator_path,
200
+ scope,
201
+ task_mappings=self.task_mappings,
202
+ selected_agents=self.selected_agents,
203
+ )
204
+
205
+ configured_any = False
206
+
207
+ for client, success in results.get("clients", {}).items():
208
+ if success:
209
+ console.print(f"[green]OK {client}: delegation-mcp registered[/green]")
210
+ configured_any = True
211
+ else:
212
+ console.print(f"[yellow]! {client} not configured automatically[/yellow]")
213
+
214
+ for message in results.get("messages", []):
215
+ console.print(message)
216
+
217
+ manual_steps = results.get("manual_instructions", [])
218
+ if manual_steps:
219
+ console.print("\n[bold]Manual setup required:[/bold]")
220
+ for step in manual_steps:
221
+ console.print(f" [cyan]{step}[/cyan]")
222
+
223
+ # Claude supports auto-injection, so no manual instructions needed
224
+ if not configured_any and not results.get("allow_continue", False):
225
+ console.print("[red]X Failed to configure MCP client automatically.[/red]")
226
+ return False
227
+
228
+ return True
229
+
230
+ def _print_success(self):
231
+ """Print success message."""
232
+ console.print(f"\n[bold cyan]For Claude:[/bold cyan]")
233
+ console.print(" 1. Restart Claude Desktop app")
234
+ console.print(" 2. Open a chat and try: [cyan]'scan for security vulnerabilities'[/cyan]")
235
+
236
+ console.print("\n[bold]The system will automatically:[/bold]")
237
+
238
+ # Dynamic task routing summary grouped by agent
239
+ if self.task_mappings:
240
+ # Create a lookup for task names
241
+ task_names = {cat["key"]: cat["name"] for cat in TASK_CATEGORIES}
242
+
243
+ # Group tasks by agent
244
+ agent_tasks = {}
245
+ for task_key, agent in self.task_mappings.items():
246
+ task_name = task_names.get(task_key, task_key)
247
+ if agent not in agent_tasks:
248
+ agent_tasks[agent] = []
249
+ agent_tasks[agent].append(task_name)
250
+
251
+ # Display grouped by agent
252
+ for agent, tasks in sorted(agent_tasks.items()):
253
+ tasks_str = ", ".join(tasks)
254
+ console.print(f" • Route {tasks_str} to {agent}")
255
+
256
+ console.print(" • Fall back if agent fails")
257
+
258
+
259
+ def main():
260
+ """CLI entry point."""
261
+ logging.basicConfig(level=logging.INFO)
262
+ installer = DelegationInstaller()
263
+ success = installer.install()
264
+ sys.exit(0 if success else 1)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
src/delegation_mcp/installer/mcp_configurator.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP client auto-configuration."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import platform
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class MCPConfigurator:
14
+ """Auto-configure MCP clients (Claude Code and Claude Desktop)."""
15
+
16
+ def __init__(self, client_type: str = "auto"):
17
+ """
18
+ Initialize configurator.
19
+
20
+ Args:
21
+ client_type: "code", "desktop", or "auto" to detect
22
+ """
23
+ self.client_type = client_type
24
+ self.config_path = None
25
+ if client_type in ("desktop", "auto"):
26
+ self.desktop_config_path = self._detect_desktop_config_path()
27
+ else:
28
+ self.desktop_config_path = None
29
+
30
+ def _detect_desktop_config_path(self) -> Path:
31
+ """Detect Claude Desktop config location (platform-aware)."""
32
+ system = platform.system()
33
+
34
+ if system == "Windows":
35
+ locations = [
36
+ Path(os.environ.get("APPDATA", "")) / "Claude" / "claude_desktop_config.json",
37
+ Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
38
+ ]
39
+ elif system == "Darwin": # macOS
40
+ locations = [
41
+ Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
42
+ ]
43
+ else: # Linux
44
+ locations = [
45
+ Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
46
+ ]
47
+
48
+ for loc in locations:
49
+ if loc.exists():
50
+ logger.info(f"Found Claude Desktop config: {loc}")
51
+ return loc
52
+
53
+ # Default fallback
54
+ fallback = locations[0]
55
+ logger.info(f"Will create new Desktop config: {fallback}")
56
+ return fallback
57
+
58
+ def configure_claude_code(
59
+ self,
60
+ project_dir: Path,
61
+ system_instructions: str = None,
62
+ install_project_instructions: bool = True,
63
+ install_user_instructions: bool = False,
64
+ claude_path: Path = None,
65
+ scope: str = "local"
66
+ ) -> tuple[bool, bool]:
67
+ """
68
+ Configure MCP for Claude Code using 'claude mcp add' command and inject system instructions.
69
+
70
+ Args:
71
+ project_dir: Project directory path
72
+ system_instructions: System instructions text to inject
73
+ install_project_instructions: If True, install CLAUDE.md at project level
74
+ install_user_instructions: If True, install CLAUDE.md at user level (~/.claude/)
75
+ claude_path: Path to claude executable (if None, uses 'claude' from PATH)
76
+ scope: Configuration scope - "local" (default), "project", or "user"
77
+
78
+ Returns:
79
+ Tuple of (mcp_configured, instructions_configured)
80
+ """
81
+ import subprocess
82
+
83
+ mcp_configured = False
84
+ instructions_configured = False
85
+
86
+ # 1. Configure MCP server
87
+ try:
88
+ # Determine claude command to use
89
+ claude_cmd = str(claude_path) if claude_path else "claude"
90
+
91
+ # First, try to remove any existing delegation-mcp server to ensure clean install
92
+ try:
93
+ remove_result = subprocess.run(
94
+ [claude_cmd, "mcp", "remove", "delegation-mcp"],
95
+ cwd=str(project_dir),
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=10
99
+ )
100
+ if remove_result.returncode == 0:
101
+ logger.info("Removed existing delegation-mcp server before reinstalling")
102
+ except Exception as e:
103
+ logger.debug(f"No existing delegation-mcp to remove (or removal failed): {e}")
104
+
105
+ # Use the official claude mcp add command with scope
106
+ cmd = [
107
+ claude_cmd,
108
+ "mcp",
109
+ "add",
110
+ "--scope", scope,
111
+ "delegation-mcp",
112
+ "delegation-mcp"
113
+ ]
114
+
115
+ result = subprocess.run(
116
+ cmd,
117
+ cwd=str(project_dir),
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=30
121
+ )
122
+
123
+ if result.returncode == 0:
124
+ logger.info(f"Configured delegation-mcp for Claude Code using 'claude mcp add'")
125
+
126
+ # Verify the server was actually added
127
+ try:
128
+ verify_result = subprocess.run(
129
+ [claude_cmd, "mcp", "list"],
130
+ cwd=str(project_dir),
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=10
134
+ )
135
+
136
+ if verify_result.returncode == 0 and "delegation-mcp" in verify_result.stdout:
137
+ logger.info("Verified delegation-mcp is registered with Claude Code")
138
+ mcp_configured = True
139
+ else:
140
+ logger.warning(f"delegation-mcp not found in 'claude mcp list' output")
141
+ mcp_configured = False
142
+ except Exception as e:
143
+ logger.warning(f"Could not verify MCP server registration: {e}")
144
+ # Still mark as configured if 'claude mcp add' succeeded
145
+ mcp_configured = True
146
+ else:
147
+ logger.warning(f"'claude mcp add' failed (exit code {result.returncode}): {result.stderr}")
148
+
149
+ except FileNotFoundError:
150
+ logger.warning(f"Claude executable not found at: {claude_cmd}")
151
+ except subprocess.TimeoutExpired:
152
+ logger.error("'claude mcp add' command timed out")
153
+ except Exception as e:
154
+ logger.error(f"Failed to configure Claude Code MCP: {e}")
155
+
156
+ # 2. Inject system instructions if provided
157
+ if system_instructions:
158
+ # Helper function to safely add instructions to a file
159
+ def add_instructions_to_file(file_path: Path, backup: bool = True):
160
+ """Add or update delegation instructions using marker tags."""
161
+ import re
162
+
163
+ try:
164
+ # Define markers
165
+ BEGIN_MARKER = "<!-- BEGIN DELEGATION-MCP INSTRUCTIONS -->"
166
+ END_MARKER = "<!-- END DELEGATION-MCP INSTRUCTIONS -->"
167
+
168
+ # Wrap instructions with markers
169
+ wrapped_instructions = f"{BEGIN_MARKER}\n{system_instructions}\n{END_MARKER}"
170
+
171
+ existing_content = ""
172
+ if file_path.exists():
173
+ with open(file_path, "r", encoding="utf-8") as f:
174
+ existing_content = f.read()
175
+
176
+ # Backup existing file
177
+ if backup:
178
+ backup_path = file_path.with_suffix(".md.backup")
179
+ shutil.copy2(file_path, backup_path)
180
+ logger.info(f"Backed up existing file to: {backup_path}")
181
+
182
+ # Check if markers exist (update case)
183
+ marker_pattern = re.compile(
184
+ r"<!-- BEGIN DELEGATION-MCP INSTRUCTIONS.*?-->\n.*?\n<!-- END DELEGATION-MCP INSTRUCTIONS -->",
185
+ re.DOTALL
186
+ )
187
+
188
+ if existing_content and marker_pattern.search(existing_content):
189
+ # Replace existing wrapped section
190
+ new_content = marker_pattern.sub(wrapped_instructions, existing_content)
191
+ logger.info(f"Replaced existing delegation instructions in {file_path}")
192
+ elif existing_content.strip():
193
+ # Prepend wrapped instructions to existing content
194
+ new_content = wrapped_instructions + "\n\n" + "="*80 + "\n"
195
+ new_content += "# Existing Instructions\n"
196
+ new_content += "="*80 + "\n\n"
197
+ new_content += existing_content
198
+ logger.info(f"Prepended delegation instructions to {file_path}")
199
+ else:
200
+ # New file, just write wrapped instructions
201
+ new_content = wrapped_instructions
202
+ logger.info(f"Created new delegation instructions in {file_path}")
203
+
204
+ # Create parent directory if needed
205
+ file_path.parent.mkdir(parents=True, exist_ok=True)
206
+
207
+ # Write the file
208
+ with open(file_path, "w", encoding="utf-8") as f:
209
+ f.write(new_content)
210
+
211
+ return True
212
+
213
+ except Exception as e:
214
+ logger.warning(f"Failed to add instructions to {file_path}: {e}")
215
+ return False
216
+
217
+ # Project-level CLAUDE.md
218
+ if install_project_instructions:
219
+ project_claude_dir = project_dir / ".claude"
220
+ project_claude_md = project_claude_dir / "CLAUDE.md"
221
+ if add_instructions_to_file(project_claude_md, backup=True):
222
+ instructions_configured = True
223
+
224
+ # User-level CLAUDE.md
225
+ if install_user_instructions:
226
+ user_claude_dir = Path.home() / ".claude"
227
+ user_claude_md = user_claude_dir / "CLAUDE.md"
228
+ if add_instructions_to_file(user_claude_md, backup=True):
229
+ instructions_configured = True
230
+
231
+ return (mcp_configured, instructions_configured)
232
+
233
+ def configure_claude_desktop(self, project_dir: Path) -> bool:
234
+ """Configure MCP for Claude Desktop using global config."""
235
+ try:
236
+ # Backup existing config
237
+ if self.desktop_config_path.exists():
238
+ backup_path = self.desktop_config_path.with_suffix(".json.backup")
239
+ shutil.copy2(self.desktop_config_path, backup_path)
240
+ logger.info(f"Backed up Desktop config to: {backup_path}")
241
+
242
+ # Load existing config
243
+ if self.desktop_config_path.exists():
244
+ with open(self.desktop_config_path) as f:
245
+ config = json.load(f)
246
+ else:
247
+ config = {}
248
+
249
+ # Ensure mcpServers exists
250
+ if "mcpServers" not in config:
251
+ config["mcpServers"] = {}
252
+
253
+ # Add delegation-mcp
254
+ config["mcpServers"]["delegation-mcp"] = {
255
+ "command": "uv",
256
+ "args": [
257
+ "--directory",
258
+ str(project_dir),
259
+ "run",
260
+ "delegation-mcp"
261
+ ]
262
+ }
263
+
264
+ # Save config
265
+ self.desktop_config_path.parent.mkdir(parents=True, exist_ok=True)
266
+ with open(self.desktop_config_path, "w") as f:
267
+ json.dump(config, f, indent=2)
268
+
269
+ self.config_path = self.desktop_config_path
270
+ logger.info(f"Configured delegation-mcp for Claude Desktop: {self.desktop_config_path}")
271
+ return True
272
+
273
+ except Exception as e:
274
+ logger.error(f"Failed to configure Claude Desktop: {e}")
275
+ return False
276
+
277
+
278
+
279
+ def inject_delegation_mcp(
280
+ self,
281
+ project_dir: Path,
282
+ orchestrator: str,
283
+ install_project_instructions: bool = True,
284
+ install_user_instructions: bool = False,
285
+ orchestrator_path: Path = None,
286
+ scope: str = "local",
287
+ task_mappings: dict[str, str] | None = None,
288
+ selected_agents: list[str] | None = None,
289
+ ) -> dict[str, object]:
290
+ """
291
+ Inject delegation-mcp server into config(s).
292
+
293
+ Args:
294
+ project_dir: Project directory path
295
+ orchestrator: Name of the orchestrator (claude, gemini, etc.)
296
+ install_project_instructions: If True, install system instructions at project level
297
+ install_user_instructions: If True, install system instructions at user level
298
+ orchestrator_path: Path to orchestrator executable
299
+ scope: Configuration scope for Claude Code - "local" (default), "project", or "user"
300
+ task_mappings: Dictionary mapping task categories to agent names
301
+ selected_agents: List of selected agent names
302
+
303
+ Returns:
304
+ Dict containing client results, status, and manual instructions
305
+ """
306
+ from .system_instructions import get_system_instructions
307
+
308
+ orchestrator = (orchestrator or "").lower()
309
+
310
+ outcome: dict[str, object] = {
311
+ "orchestrator": orchestrator,
312
+ "clients": {},
313
+ "messages": [],
314
+ "manual_instructions": [],
315
+ "system_instructions": get_system_instructions(
316
+ orchestrator,
317
+ task_mappings=task_mappings,
318
+ selected_agents=selected_agents
319
+ ),
320
+ "allow_continue": False,
321
+ }
322
+
323
+ if orchestrator == "claude":
324
+ system_instructions = str(outcome["system_instructions"])
325
+
326
+ if self.client_type in ("code", "auto"):
327
+ mcp_configured, instructions_configured = self.configure_claude_code(
328
+ project_dir,
329
+ system_instructions,
330
+ install_project_instructions,
331
+ install_user_instructions,
332
+ orchestrator_path,
333
+ scope
334
+ )
335
+ code_success = mcp_configured or instructions_configured
336
+ outcome["clients"]["Claude Code"] = code_success
337
+
338
+ if code_success:
339
+ # Determine what was actually configured
340
+ configured_items = []
341
+ if mcp_configured:
342
+ configured_items.append("MCP server")
343
+ if instructions_configured:
344
+ configured_items.append("system instructions")
345
+
346
+ outcome["messages"].append(
347
+ f"[green]✓ Claude Code: {', '.join(configured_items)} configured![/green]"
348
+ )
349
+
350
+ if mcp_configured:
351
+ outcome["messages"].append(
352
+ "[dim] • MCP server verified with 'claude mcp list'[/dim]"
353
+ )
354
+
355
+ if install_project_instructions:
356
+ outcome["messages"].append(
357
+ f"[dim] • System instructions (project): {project_dir / '.claude' / 'CLAUDE.md'}[/dim]"
358
+ )
359
+
360
+ if install_user_instructions:
361
+ user_claude_md = Path.home() / ".claude" / "CLAUDE.md"
362
+ outcome["messages"].append(
363
+ f"[dim] • System instructions (user): {user_claude_md}[/dim]"
364
+ )
365
+
366
+ # Only show manual instructions if MCP server wasn't configured
367
+ if not mcp_configured:
368
+ outcome["messages"].append(
369
+ "[yellow]! MCP server not added - manual setup required:[/yellow]"
370
+ )
371
+ outcome["manual_instructions"].append("claude mcp add delegation-mcp delegation-mcp")
372
+ outcome["manual_instructions"].append(
373
+ "Then verify with: claude mcp list"
374
+ )
375
+ else:
376
+ outcome["manual_instructions"].append("claude mcp add delegation-mcp delegation-mcp")
377
+ outcome["manual_instructions"].append(
378
+ "Add system instructions from delegation_instructions.txt to .claude/CLAUDE.md"
379
+ )
380
+
381
+ if self.client_type in ("desktop", "auto"):
382
+ desktop_success = self.configure_claude_desktop(project_dir)
383
+ outcome["clients"]["Claude Desktop"] = desktop_success
384
+ if desktop_success and not self.config_path:
385
+ self.config_path = self.desktop_config_path
386
+
387
+ outcome["allow_continue"] = True
388
+ return outcome
389
+
390
+ outcome["allow_continue"] = True
391
+ return outcome
392
+
393
+ outcome["messages"].append(
394
+ f"[yellow]! Automatic MCP configuration for '{orchestrator}' is not supported yet.[/yellow]"
395
+ )
396
+ outcome["manual_instructions"].append(
397
+ "Please register the delegation-mcp server manually in your MCP client."
398
+ )
399
+ outcome["allow_continue"] = True
400
+ return outcome
401
+
402
+ def verify_config(self) -> bool:
403
+ """Verify config is valid JSON and has delegation-mcp."""
404
+ if not self.config_path or not self.config_path.exists():
405
+ return False
406
+
407
+ try:
408
+ with open(self.config_path) as f:
409
+ config = json.load(f)
410
+
411
+ return "mcpServers" in config and "delegation-mcp" in config["mcpServers"]
412
+
413
+ except Exception as e:
414
+ logger.error(f"Config verification failed: {e}")
415
+ return False
src/delegation_mcp/installer/system_instructions.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System instructions for the orchestrator agent."""
2
+
3
+ from .task_mapper import TASK_CATEGORIES
4
+
5
+
6
+ def get_system_instructions(
7
+ orchestrator_name: str = "Claude",
8
+ task_mappings: dict[str, str] | None = None,
9
+ selected_agents: list[str] | None = None,
10
+ ) -> str:
11
+ """
12
+ Generate ultra-concise system instructions for orchestrator.
13
+
14
+ Args:
15
+ orchestrator_name: The name of the orchestrator agent (e.g., "Claude", "Gemini").
16
+ task_mappings: Dictionary mapping task categories to agent names
17
+ selected_agents: List of selected agent names
18
+
19
+ Returns:
20
+ Formatted system instructions string (optimized for minimal tokens).
21
+ """
22
+ orchestrator_name = orchestrator_name.capitalize()
23
+ task_mappings = task_mappings or {}
24
+
25
+ # Build concise routing table
26
+ routing_rows = []
27
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
28
+
29
+ for task_key, agent_name in task_mappings.items():
30
+ category = category_map.get(task_key)
31
+ if category and task_key != "general":
32
+ routing_rows.append(f"- {category['description']} → **{agent_name}**")
33
+
34
+ routing_table = "\n".join(routing_rows) if routing_rows else "No specific mappings configured"
35
+
36
+ instructions = f"""# {orchestrator_name} - Orchestrator
37
+
38
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+ ⚠️ STOP - READ BEFORE ANY CODE/FILE/TECHNICAL OPERATION ⚠️
40
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
+
42
+ Before reading files, editing code, analyzing systems, or ANY technical work:
43
+
44
+ ❓ Did you call `delegate_task(query="...", guidance_only=True)` for this request?
45
+ □ YES - I called it. Server returned: ________________
46
+ □ NO - I have NOT called it yet
47
+
48
+ If you checked NO or left blank: **STOP NOW. Go back. Call the tool.**
49
+
50
+ If you checked YES: What did the server return?
51
+ □ "HANDLE_DIRECTLY" → OK to proceed
52
+ □ "DELEGATE_TO: [agent]" → MUST delegate, do NOT proceed with execution
53
+
54
+ **VIOLATION CHECK**: If you are reading code/files WITHOUT having called the tool above, you are violating the protocol. STOP IMMEDIATELY.
55
+
56
+ **MULTI-STEP TASKS**: If request has multiple distinct phases (e.g., "review + fix + commit"), call delegate_task separately for each:
57
+ 1. delegate_task("phase 1") → get results
58
+ 2. delegate_task("phase 2: [specific from results]") → execute
59
+ 3. delegate_task("phase 3") → finalize
60
+
61
+ Example: "Review backend, fix issues, commit changes"
62
+ → Call delegate_task("review backend for scalability") → Gemini analyzes
63
+ → Call delegate_task("fix critical query issues in api.ts") → Aider implements fixes
64
+ → Call delegate_task("stage, commit, push with message") → Aider handles git
65
+
66
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
67
+
68
+ **Core Function**: Delegate specialized tasks, handle architecture/planning yourself.
69
+
70
+ ## Delegation Protocol
71
+
72
+ **For ANY code/technical request**:
73
+ 1. Call `delegate_task(query=full_user_request, guidance_only=True)` FIRST
74
+ 2. Tool returns routing decision: "DELEGATE_TO: agent" or "HANDLE_DIRECTLY"
75
+ 3. Follow tool response (do NOT override it)
76
+
77
+ **Why**: Server has routing logic (keywords + capability scoring). Always consult it.
78
+
79
+ ## Routing Table
80
+
81
+ {routing_table}
82
+
83
+ ## Tools
84
+
85
+ - `delegate_task(query, guidance_only?, force_delegate?)` - Get routing guidance or execute delegation
86
+ - `list_orchestrators()` - Show available agents
87
+ - `discover_agents()` - Find new agents
88
+
89
+ ## Rules
90
+
91
+ 1. **ALWAYS call tool first**: `delegate_task(guidance_only=True)` before ANY code work
92
+ 2. **Trust server routing**: Uses keyword matching + capability scoring
93
+ 3. **Handle directly ONLY if**: Tool explicitly returns "HANDLE_DIRECTLY"
94
+ 4. **Never bypass**: Even if task seems "simple" or "quick" - ALWAYS consult tool
95
+
96
+ **Critical**: If you catch yourself thinking "this is quick, I'll just..." → STOP. Call the tool.
97
+ """
98
+
99
+ return instructions
100
+
101
+
102
+ def _build_routing_table(task_mappings: dict[str, str]) -> list[str]:
103
+ """Build routing table rows from task mappings."""
104
+ rows = []
105
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
106
+
107
+ for task_key, agent_name in task_mappings.items():
108
+ category = category_map.get(task_key)
109
+ if not category or task_key == "general":
110
+ continue
111
+
112
+ # Format examples for table
113
+ examples = ", ".join(category["pattern_examples"][:3]) # First 3 examples
114
+ row = f"| {category['description']} | **{agent_name}** | {examples} |"
115
+ rows.append(row)
116
+
117
+ return rows
118
+
119
+
120
+ def _build_agent_summary(selected_agents: list[str]) -> list[str]:
121
+ """Build agent capabilities summary.
122
+
123
+ Note: This is intentionally kept simple since actual capabilities
124
+ are shown in the routing table based on user's task mappings.
125
+ """
126
+ if not selected_agents:
127
+ return []
128
+
129
+ summary = []
130
+ for agent in selected_agents:
131
+ summary.append(f"- **{agent}**")
132
+
133
+ return summary
134
+
135
+
136
+ def _build_prohibitions(task_mappings: dict[str, str]) -> list[str]:
137
+ """Build prohibition list dynamically from task mappings.
138
+
139
+ Only prohibit tasks that the user has explicitly mapped to other agents.
140
+ """
141
+ if not task_mappings:
142
+ return []
143
+
144
+ prohibitions = []
145
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
146
+
147
+ # Map task types to prohibition descriptions
148
+ prohibition_templates = {
149
+ "git_operations": "❌ Execute git commands (git checkout, git pull, git commit, etc.)",
150
+ "shell_tasks": "❌ Run shell/terminal commands directly",
151
+ "refactoring": "❌ Perform code refactoring directly",
152
+ "security_audit": "❌ Conduct security reviews or audits",
153
+ "testing": "❌ Write or execute tests directly",
154
+ "performance": "❌ Perform performance analysis or optimization",
155
+ "browser_interaction": "❌ Execute browser automation tasks",
156
+ "code_review": "❌ Conduct code reviews",
157
+ "exploration": "❌ Explore codebase or trace implementations (delegate to save tokens)",
158
+ "debugging": "❌ Debug issues or investigate errors (delegate to save tokens)",
159
+ "impact_analysis": "❌ Analyze dependencies or find usages (delegate to save tokens)",
160
+ }
161
+
162
+ for task_key, agent_name in task_mappings.items():
163
+ if task_key == "general":
164
+ continue
165
+
166
+ # Get prohibition template for this task type
167
+ prohibition = prohibition_templates.get(task_key)
168
+ if prohibition:
169
+ prohibitions.append(f"- {prohibition} → **Delegate to {agent_name}**")
170
+
171
+ return prohibitions
172
+
173
+
174
+ def _build_routing_examples(task_mappings: dict[str, str]) -> list[str]:
175
+ """Build routing examples dynamically from actual task mappings."""
176
+ examples = []
177
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
178
+
179
+ # Build examples from actual mappings
180
+ for task_key, agent_name in task_mappings.items():
181
+ category = category_map.get(task_key)
182
+ if not category or task_key == "general":
183
+ continue
184
+
185
+ # Use first pattern example from the category
186
+ if category["pattern_examples"]:
187
+ example_query = category["pattern_examples"][0]
188
+ examples.append(f'| "{example_query}" | DELEGATE | {agent_name} |')
189
+
190
+ # Add a general handling example
191
+ examples.append('| "What\'s the best database for this use case?" | HANDLE | (yourself) |')
192
+
193
+ return examples
194
+
195
+
src/delegation_mcp/installer/task_mapper.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task-to-agent mapping with intelligent suggestions.
2
+
3
+ This module provides functionality for mapping task categories to agents
4
+ with intelligent suggestions based on agent capabilities and routing strategies.
5
+ """
6
+
7
+ import logging
8
+ from typing import TypedDict
9
+
10
+ from rich.console import Console
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.table import Table
13
+
14
+ from .agent_profiles import (
15
+ get_agent_profile,
16
+ ROUTING_PRESETS,
17
+ DEFAULT_ROUTING_RULES,
18
+ RoutingPreset
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+ console = Console()
23
+
24
+
25
+ class TaskCategory(TypedDict):
26
+ """Definition of a task category."""
27
+ key: str
28
+ name: str
29
+ description: str
30
+ pattern_examples: list[str]
31
+
32
+
33
+ # Task categories for delegation
34
+ TASK_CATEGORIES: list[TaskCategory] = [
35
+ {
36
+ "key": "security_audit",
37
+ "name": "Security Audit",
38
+ "description": "Security audits, vulnerability scans, safety checks",
39
+ "pattern_examples": [
40
+ "security", "vulnerability", "audit", "CVE", "harden", "secure", "protect",
41
+ "lock down", "access control", "permissions", "rules", "firestore rules",
42
+ "authentication", "authorization", "encrypt", "expose", "leak", "breach",
43
+ "attack", "threat", "OWASP", "XSS", "injection", "sanitize", "exploit",
44
+ ],
45
+ },
46
+ {
47
+ "key": "code_review",
48
+ "name": "Code Review",
49
+ "description": "Code quality review, best practices analysis",
50
+ "pattern_examples": [
51
+ "review", "code quality", "best practices", "lint", "improve", "clean up",
52
+ "tech debt", "smell", "anti-pattern", "convention", "standards",
53
+ "maintainability", "readability", "code analysis",
54
+ ],
55
+ },
56
+ {
57
+ "key": "architecture",
58
+ "name": "Architecture",
59
+ "description": "System design, architecture planning, complex reasoning",
60
+ "pattern_examples": [
61
+ "architecture", "design", "system design", "structure", "organize", "plan",
62
+ "approach", "strategy", "pattern", "blueprint", "diagram", "flow", "schema",
63
+ ],
64
+ },
65
+ {
66
+ "key": "refactoring",
67
+ "name": "Refactoring",
68
+ "description": "Code refactoring, cleanup, optimization",
69
+ "pattern_examples": [
70
+ "refactor", "cleanup", "optimize code", "rename", "restructure", "reorganize",
71
+ "simplify", "DRY", "extract", "inline", "consolidate", "modularize",
72
+ ],
73
+ },
74
+ {
75
+ "key": "quick_fix",
76
+ "name": "Quick Fixes",
77
+ "description": "Rapid bug fixes, small code changes",
78
+ "pattern_examples": [
79
+ "fix", "bug", "quick change", "error", "crash", "broken", "not working",
80
+ "issue", "problem", "patch", "hotfix",
81
+ ],
82
+ },
83
+ {
84
+ "key": "documentation",
85
+ "name": "Documentation",
86
+ "description": "README files, API docs, code comments",
87
+ "pattern_examples": [
88
+ "documentation", "docs", "README", "comments", "comment", "explain", "describe",
89
+ "guide", "tutorial", "how-to", "API docs", "docstring", "examples",
90
+ ],
91
+ },
92
+ {
93
+ "key": "testing",
94
+ "name": "Testing",
95
+ "description": "Unit tests, integration tests, test coverage",
96
+ "pattern_examples": [
97
+ "test", "testing", "coverage", "unit test", "integration test", "e2e",
98
+ "spec", "assertion", "mock", "stub", "test case", "test suite",
99
+ ],
100
+ },
101
+ {
102
+ "key": "performance",
103
+ "name": "Performance",
104
+ "description": "Performance analysis and optimization",
105
+ "pattern_examples": [
106
+ "performance", "optimize", "speed", "slow", "latency", "throughput",
107
+ "bottleneck", "profiling", "benchmark", "memory", "CPU", "scalability",
108
+ ],
109
+ },
110
+ {
111
+ "key": "browser_interaction",
112
+ "name": "Browser Interaction",
113
+ "description": "Browser automation, web scraping, UI testing",
114
+ "pattern_examples": ["browser", "selenium", "playwright", "chrome"],
115
+ },
116
+ {
117
+ "key": "git_operations",
118
+ "name": "Git Operations",
119
+ "description": "Git workflows, repository management",
120
+ "pattern_examples": [
121
+ "git", "commit", "merge", "branch", "push", "pull", "rebase", "cherry-pick",
122
+ "stash", "tag", "history", "checkout", "reset", "revert",
123
+ ],
124
+ },
125
+ {
126
+ "key": "shell_tasks",
127
+ "name": "Shell/Terminal",
128
+ "description": "Shell scripting, terminal commands",
129
+ "pattern_examples": [
130
+ "shell", "terminal", "bash", "script", "command", "CLI", "automation",
131
+ "cron", "env", "environment variables", "path", "execute",
132
+ ],
133
+ },
134
+ {
135
+ "key": "exploration",
136
+ "name": "Code Exploration",
137
+ "description": "Code exploration, dependency tracing, implementation analysis",
138
+ "pattern_examples": ["how does", "trace the flow", "what files implement", "understand the implementation", "map dependencies"],
139
+ },
140
+ {
141
+ "key": "debugging",
142
+ "name": "Debugging",
143
+ "description": "Bug investigation, error analysis, root cause identification",
144
+ "pattern_examples": ["debug", "why is failing", "investigate", "find the cause", "troubleshoot"],
145
+ },
146
+ {
147
+ "key": "impact_analysis",
148
+ "name": "Impact Analysis",
149
+ "description": "Dependency analysis, usage finding, breaking change assessment",
150
+ "pattern_examples": ["what would break", "find all usages", "what depends on", "impact of changing", "all references"],
151
+ },
152
+ {
153
+ "key": "general",
154
+ "name": "General Tasks",
155
+ "description": "Default for tasks that don't fit specific categories",
156
+ "pattern_examples": ["general", "misc", "other"],
157
+ },
158
+ ]
159
+
160
+
161
+ class TaskMapper:
162
+ """Manages task-to-agent mapping during installation."""
163
+
164
+ def __init__(self):
165
+ """Initialize the task mapper."""
166
+ self.task_mappings: dict[str, str] = {}
167
+ self.selected_strategy: str = "balanced"
168
+
169
+ def select_strategy(self) -> str:
170
+ """
171
+ Prompt user to select a routing strategy.
172
+
173
+ Returns:
174
+ Selected strategy key
175
+ """
176
+ console.print("\n[bold]Delegation Strategy[/bold]")
177
+ console.print("Choose how tasks should be distributed among agents:\n")
178
+
179
+ table = Table(show_header=True, header_style="bold magenta")
180
+ table.add_column("Option", style="cyan", justify="center")
181
+ table.add_column("Strategy", style="green")
182
+ table.add_column("Description", style="white")
183
+ table.add_column("Priorities", style="yellow")
184
+
185
+ strategies = list(ROUTING_PRESETS.items())
186
+
187
+ for i, (key, preset) in enumerate(strategies, 1):
188
+ priorities = f"Cost: {preset['cost_priority']}, Quality: {preset['quality_priority']}"
189
+ table.add_row(str(i), preset["name"], preset["description"], priorities)
190
+
191
+ console.print(table)
192
+ console.print("\n")
193
+
194
+ choices = [str(i) for i in range(1, len(strategies) + 1)]
195
+ default_idx = [k for k, _ in strategies].index("balanced") + 1
196
+
197
+ selection = Prompt.ask(
198
+ "Select strategy",
199
+ choices=choices,
200
+ default=str(default_idx)
201
+ )
202
+
203
+ selected_key = strategies[int(selection) - 1][0]
204
+ self.selected_strategy = selected_key
205
+
206
+ console.print(f"\n[green]✓[/green] Selected: {ROUTING_PRESETS[selected_key]['name']}\n")
207
+ return selected_key
208
+
209
+ def suggest_mappings(self, agent_names: list[str], strategy_key: str) -> dict[str, tuple[str, str]]:
210
+ """
211
+ Generate intelligent mapping suggestions based on strategy and agent capabilities.
212
+
213
+ Args:
214
+ agent_names: List of available agent names
215
+ strategy_key: Key of the selected routing strategy
216
+
217
+ Returns:
218
+ Dictionary of task_key -> (suggested_agent, reasoning)
219
+ """
220
+ suggestions: dict[str, tuple[str, str]] = {}
221
+ preset = ROUTING_PRESETS[strategy_key]
222
+
223
+ # Helper to find best agent from a list of preferred ones
224
+ def find_best_available(preferred: list[str], fallback_reason: str) -> tuple[str, str]:
225
+ for agent in preferred:
226
+ if agent in agent_names:
227
+ # Find specific reason from rules if available
228
+ return agent, fallback_reason
229
+
230
+ # Fallback logic based on strategy
231
+ if preset["cost_priority"] == "high":
232
+ # Prefer free/local agents
233
+ for agent in agent_names:
234
+ profile = get_agent_profile(agent)
235
+ if profile["cost_tier"] == "free":
236
+ return agent, "Selected for cost efficiency"
237
+
238
+ if preset["quality_priority"] == "high":
239
+ # Prefer Claude/Gemini
240
+ for agent in ["claude", "gemini"]:
241
+ if agent in agent_names:
242
+ return agent, "Selected for high quality"
243
+
244
+ # Default to first available
245
+ return agent_names[0], "Best available option"
246
+
247
+ for category in TASK_CATEGORIES:
248
+ task_key = category["key"]
249
+
250
+ # Get default rule
251
+ rule = DEFAULT_ROUTING_RULES.get(task_key)
252
+ if not rule:
253
+ suggestions[task_key] = (agent_names[0], "Default assignment")
254
+ continue
255
+
256
+ # Apply strategy overrides
257
+ preferred = rule["preferred"]
258
+ reason = rule["reason"]
259
+
260
+ if strategy_key == "cost_optimized":
261
+ # Prioritize free agents
262
+ free_agents = [a for a in agent_names if get_agent_profile(a)["cost_tier"] == "free"]
263
+ if free_agents:
264
+ preferred = free_agents + preferred
265
+ reason = "Cost optimized choice"
266
+
267
+ elif strategy_key == "speed_first":
268
+ # Prioritize fast agents
269
+ fast_agents = [a for a in agent_names if get_agent_profile(a)["response_speed"] == "fast"]
270
+ if fast_agents:
271
+ preferred = fast_agents + preferred
272
+ reason = "Optimized for speed"
273
+
274
+ elif strategy_key == "token_saver":
275
+ # Prioritize large context or concise agents
276
+ # (Simplified logic: prefer Gemini for context, Aider for conciseness)
277
+ if task_key in ["architecture", "exploration"]:
278
+ preferred = ["gemini"] + preferred
279
+ reason = "Large context window"
280
+ else:
281
+ preferred = ["aider"] + preferred
282
+ reason = "Concise responses"
283
+
284
+ # Find best agent
285
+ agent, final_reason = find_best_available(preferred, reason)
286
+ suggestions[task_key] = (agent, final_reason)
287
+
288
+ return suggestions
289
+
290
+ def display_suggestions(
291
+ self,
292
+ suggestions: dict[str, tuple[str, str]],
293
+ agent_names: list[str]
294
+ ) -> None:
295
+ """
296
+ Display mapping suggestions in a formatted table.
297
+
298
+ Args:
299
+ suggestions: Dictionary of task_key -> (agent, reasoning)
300
+ agent_names: List of available agent names for context
301
+ """
302
+ table = Table(
303
+ title=f"Suggested Mappings ({ROUTING_PRESETS[self.selected_strategy]['name']})",
304
+ show_header=True,
305
+ header_style="bold magenta"
306
+ )
307
+ table.add_column("Task Category", style="cyan", no_wrap=True)
308
+ table.add_column("Suggested Agent", style="green")
309
+ table.add_column("Reasoning", style="yellow")
310
+
311
+ # Create task key to category mapping for lookup
312
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
313
+
314
+ for task_key, (agent, reasoning) in suggestions.items():
315
+ category = category_map.get(task_key)
316
+ if category:
317
+ task_name = category["name"]
318
+ table.add_row(task_name, agent, reasoning)
319
+
320
+ console.print("\n")
321
+ console.print(table)
322
+ console.print("\n")
323
+
324
+ def prompt_task_assignments(
325
+ self,
326
+ agent_names: list[str],
327
+ suggestions: dict[str, tuple[str, str]]
328
+ ) -> dict[str, str]:
329
+ """
330
+ Interactive prompt for task-to-agent assignment.
331
+
332
+ Args:
333
+ agent_names: List of available agent names
334
+ suggestions: Pre-computed suggestions
335
+
336
+ Returns:
337
+ Dictionary of task_key -> agent_name
338
+ """
339
+ self.display_suggestions(suggestions, agent_names)
340
+
341
+ console.print("[bold]Task Assignment Configuration[/bold]")
342
+ console.print("You can accept all suggestions or customize individual mappings.\n")
343
+
344
+ # Ask if user wants to use all suggestions
345
+ accept_all = Confirm.ask(
346
+ "Accept all suggested mappings?",
347
+ default=True
348
+ )
349
+
350
+ if accept_all:
351
+ self.task_mappings = {
352
+ task_key: agent
353
+ for task_key, (agent, _) in suggestions.items()
354
+ }
355
+ console.print("\n[green]✓[/green] Using all suggested mappings\n")
356
+ return self.task_mappings
357
+
358
+ # Custom assignment
359
+ console.print("\nCustomize task assignments:\n")
360
+ self.task_mappings = {}
361
+
362
+ # Create task key to category mapping
363
+ category_map = {cat["key"]: cat for cat in TASK_CATEGORIES}
364
+
365
+ for task_key, (suggested_agent, reasoning) in suggestions.items():
366
+ category = category_map.get(task_key)
367
+ if not category:
368
+ continue
369
+
370
+ task_name = category["name"]
371
+ description = category["description"]
372
+
373
+ console.print(f"\n[cyan]{task_name}[/cyan]: {description}")
374
+ console.print(f" Suggested: [green]{suggested_agent}[/green] ({reasoning})")
375
+
376
+ # Ask if user wants to change
377
+ use_suggestion = Confirm.ask(
378
+ f" Use {suggested_agent} for {task_name}?",
379
+ default=True
380
+ )
381
+
382
+ if use_suggestion:
383
+ self.task_mappings[task_key] = suggested_agent
384
+ console.print(f" [green]✓[/green] Assigned to {suggested_agent}")
385
+ else:
386
+ # Let user pick an agent
387
+ console.print(f" Available agents: {', '.join(agent_names)}")
388
+
389
+ while True:
390
+ chosen_agent = Prompt.ask(
391
+ f" Select agent for {task_name}",
392
+ choices=agent_names,
393
+ default=suggested_agent
394
+ )
395
+
396
+ if chosen_agent in agent_names:
397
+ self.task_mappings[task_key] = chosen_agent
398
+ console.print(f" [green]✓[/green] Assigned to {chosen_agent}")
399
+ break
400
+ else:
401
+ console.print(f" [red]✗[/red] Invalid agent. Please choose from: {', '.join(agent_names)}")
402
+
403
+ console.print(f"\n[green]✓[/green] Task assignment configuration complete\n")
404
+ return self.task_mappings
405
+
406
+ def get_task_mappings(self) -> dict[str, str]:
407
+ """
408
+ Get the task-to-agent mappings.
409
+
410
+ Returns:
411
+ Dictionary of task_key -> agent_name
412
+ """
413
+ return self.task_mappings
414
+
415
+ def map_tasks(self, agent_names: list[str]) -> dict[str, str]:
416
+ """
417
+ Complete task mapping flow with suggestions and user input.
418
+
419
+ Args:
420
+ agent_names: List of available agent names
421
+
422
+ Returns:
423
+ Dictionary of task_key -> agent_name
424
+ """
425
+ if len(agent_names) < 2:
426
+ logger.warning("Need at least 2 agents for task mapping")
427
+ return {}
428
+
429
+ # Select strategy
430
+ strategy_key = self.select_strategy()
431
+
432
+ # Generate suggestions based on strategy
433
+ suggestions = self.suggest_mappings(agent_names, strategy_key)
434
+
435
+ # Get user assignments
436
+ return self.prompt_task_assignments(agent_names, suggestions)
src/delegation_mcp/logging_config.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Logging configuration for delegation MCP server."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Any
6
+ from datetime import datetime
7
+
8
+
9
+ class StructuredFormatter(logging.Formatter):
10
+ """Custom formatter for structured logging."""
11
+
12
+ def format(self, record: logging.LogRecord) -> str:
13
+ """Format log record with structured information."""
14
+ # Get timestamp
15
+ timestamp = datetime.fromtimestamp(record.created).isoformat()
16
+
17
+ # Build structured log entry
18
+ parts = [
19
+ f"[{timestamp}]",
20
+ f"[{record.levelname}]",
21
+ f"[{record.name}]",
22
+ ]
23
+
24
+ # Add extra context if available
25
+ if hasattr(record, "orchestrator"):
26
+ parts.append(f"[orchestrator={record.orchestrator}]")
27
+ if hasattr(record, "delegation_to"):
28
+ parts.append(f"[→{record.delegation_to}]")
29
+ if hasattr(record, "duration"):
30
+ parts.append(f"[{record.duration:.2f}s]")
31
+
32
+ # Add the message
33
+ parts.append(record.getMessage())
34
+
35
+ # Add exception info if present
36
+ if record.exc_info:
37
+ parts.append("\n" + self.formatException(record.exc_info))
38
+
39
+ return " ".join(parts)
40
+
41
+
42
+ def setup_logging(level: int = logging.INFO, verbose: bool = False) -> None:
43
+ """
44
+ Setup logging configuration.
45
+
46
+ Args:
47
+ level: Logging level (DEBUG, INFO, WARNING, ERROR)
48
+ verbose: Enable verbose output with structured logging
49
+ """
50
+ # Get root logger
51
+ root_logger = logging.getLogger()
52
+ root_logger.setLevel(level)
53
+
54
+ # Remove existing handlers
55
+ root_logger.handlers.clear()
56
+
57
+ # Create console handler
58
+ console_handler = logging.StreamHandler(sys.stdout)
59
+ console_handler.setLevel(level)
60
+
61
+ # Set formatter
62
+ if verbose:
63
+ formatter = StructuredFormatter()
64
+ else:
65
+ formatter = logging.Formatter(
66
+ "%(levelname)s - %(name)s - %(message)s"
67
+ )
68
+
69
+ console_handler.setFormatter(formatter)
70
+ root_logger.addHandler(console_handler)
71
+
72
+ # Set specific log levels for dependencies
73
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
74
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
75
+
76
+
77
+ class DelegationLogger:
78
+ """Logger with delegation-specific context."""
79
+
80
+ def __init__(self, name: str = "delegation_mcp"):
81
+ self.logger = logging.getLogger(name)
82
+
83
+ def delegation_start(
84
+ self,
85
+ orchestrator: str,
86
+ query: str,
87
+ delegated_to: str | None = None
88
+ ) -> None:
89
+ """Log delegation start."""
90
+ extra = {"orchestrator": orchestrator}
91
+ if delegated_to:
92
+ extra["delegation_to"] = delegated_to
93
+ msg = f"Delegating query to {delegated_to}"
94
+ else:
95
+ msg = f"Processing query with {orchestrator}"
96
+
97
+ self.logger.info(msg, extra=extra)
98
+
99
+ def delegation_success(
100
+ self,
101
+ orchestrator: str,
102
+ delegated_to: str | None,
103
+ duration: float,
104
+ ) -> None:
105
+ """Log successful delegation."""
106
+ target = delegated_to or orchestrator
107
+ extra = {
108
+ "orchestrator": orchestrator,
109
+ "duration": duration,
110
+ }
111
+ if delegated_to:
112
+ extra["delegation_to"] = delegated_to
113
+
114
+ self.logger.info(f"✓ Delegation completed successfully", extra=extra)
115
+
116
+ def delegation_failure(
117
+ self,
118
+ orchestrator: str,
119
+ delegated_to: str | None,
120
+ error: str,
121
+ duration: float,
122
+ ) -> None:
123
+ """Log failed delegation."""
124
+ target = delegated_to or orchestrator
125
+ extra = {
126
+ "orchestrator": orchestrator,
127
+ "duration": duration,
128
+ }
129
+ if delegated_to:
130
+ extra["delegation_to"] = delegated_to
131
+
132
+ self.logger.error(f"✗ Delegation failed: {error}", extra=extra)
133
+
134
+ def retry_attempt(self, attempt: int, max_retries: int, error: str) -> None:
135
+ """Log retry attempt."""
136
+ self.logger.warning(
137
+ f"Retry attempt {attempt}/{max_retries}: {error}"
138
+ )
139
+
140
+ def timeout(self, orchestrator: str, timeout_seconds: float) -> None:
141
+ """Log timeout."""
142
+ self.logger.error(
143
+ f"Timeout after {timeout_seconds}s",
144
+ extra={"orchestrator": orchestrator}
145
+ )
146
+
147
+ def rule_match(self, pattern: str, delegate_to: str, confidence: int = 100) -> None:
148
+ """Log rule match."""
149
+ self.logger.info(
150
+ f"Rule matched: '{pattern}' → {delegate_to} (confidence: {confidence}%)"
151
+ )
152
+
153
+ def no_rule_match(self, query: str) -> None:
154
+ """Log when no rule matches."""
155
+ self.logger.debug(f"No delegation rule matched for query")
156
+
157
+
158
+ # Global logger instance
159
+ delegation_logger = DelegationLogger()
src/delegation_mcp/orchestrator.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator registry and management."""
2
+
3
+ import asyncio
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+ from pathlib import Path
10
+
11
+ from .config import OrchestratorConfig
12
+
13
+
14
+ class OrchestratorRegistry:
15
+ """Registry for managing available orchestrators/CLIs."""
16
+
17
+ def __init__(self):
18
+ self.orchestrators: dict[str, OrchestratorConfig] = {}
19
+ self._active_sessions: dict[str, asyncio.subprocess.Process] = {}
20
+
21
+ def register(self, config: OrchestratorConfig) -> None:
22
+ """Register an orchestrator."""
23
+ self.orchestrators[config.name] = config
24
+
25
+ def unregister(self, name: str) -> None:
26
+ """Unregister an orchestrator."""
27
+ self.orchestrators.pop(name, None)
28
+
29
+ def get(self, name: str) -> OrchestratorConfig | None:
30
+ """Get orchestrator configuration."""
31
+ return self.orchestrators.get(name)
32
+
33
+ def list_enabled(self) -> list[str]:
34
+ """List all enabled orchestrators."""
35
+ return [name for name, config in self.orchestrators.items() if config.enabled]
36
+
37
+ @staticmethod
38
+ def _resolve_command(cmd: list[str]) -> list[str]:
39
+ """
40
+ Resolve command to full path on Windows.
41
+
42
+ On Windows, asyncio.create_subprocess_exec() doesn't reliably search PATH,
43
+ so we need to resolve commands to their full paths using shutil.which().
44
+
45
+ Args:
46
+ cmd: Command list
47
+
48
+ Returns:
49
+ Resolved command (full path on Windows, original on Unix)
50
+ """
51
+ if os.name != "nt" or not cmd:
52
+ # On Unix systems, PATH search works fine
53
+ return cmd
54
+
55
+ # On Windows, resolve the executable path
56
+ resolved = shutil.which(cmd[0])
57
+ if resolved:
58
+ return [resolved] + cmd[1:]
59
+ return cmd
60
+
61
+ async def execute(
62
+ self,
63
+ orchestrator_name: str,
64
+ task: str,
65
+ timeout: int | None = None,
66
+ progress_callback: Any = None,
67
+ ) -> tuple[str, str, int]:
68
+ """
69
+ Execute a task using specified orchestrator.
70
+
71
+ Args:
72
+ orchestrator_name: Name of orchestrator to use
73
+ task: Task description/query
74
+ timeout: Optional timeout in seconds
75
+ progress_callback: Optional async callback(line: str) for stdout streaming
76
+
77
+ Returns:
78
+ tuple: (stdout, stderr, return_code)
79
+ """
80
+ config = self.get(orchestrator_name)
81
+ if not config:
82
+ raise ValueError(f"Orchestrator '{orchestrator_name}' not found")
83
+
84
+ if not config.enabled:
85
+ raise ValueError(f"Orchestrator '{orchestrator_name}' is disabled")
86
+
87
+ # Build command
88
+ if isinstance(config.command, list):
89
+ cmd = config.command + config.args + [task]
90
+ else:
91
+ cmd = [config.command] + config.args + [task]
92
+
93
+ # Resolve command path on Windows
94
+ resolved_cmd = self._resolve_command(cmd)
95
+
96
+ # Execute with timeout
97
+ timeout_seconds = timeout or config.timeout
98
+ process = None
99
+
100
+ # Build safe environment with allowlist approach
101
+ # Only include essential environment variables
102
+ allowed_env_vars = [
103
+ 'PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'TERM',
104
+ 'PYTHONPATH', 'NODE_PATH', 'OPENROUTER_API_KEY',
105
+ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY',
106
+ 'TMPDIR', 'TEMP', 'TMP', 'USERPROFILE', 'SYSTEMROOT',
107
+ ]
108
+ safe_env = {}
109
+ for key in allowed_env_vars:
110
+ if key in os.environ:
111
+ safe_env[key] = os.environ[key]
112
+
113
+ # Add config-specified env vars with validation
114
+ for key, value in config.env.items():
115
+ # Validate env var name (alphanumeric and underscore only)
116
+ if not re.match(r'^[A-Z_][A-Z0-9_]*$', key):
117
+ import logging
118
+ logging.getLogger(__name__).warning(
119
+ f"Skipping invalid environment variable name: {key}"
120
+ )
121
+ continue
122
+ safe_env[key] = value
123
+
124
+ stdout_chunks = []
125
+ stderr_chunks = []
126
+
127
+ async def _read_stream(stream, is_stderr: bool):
128
+ while True:
129
+ line = await stream.readline()
130
+ if not line:
131
+ break
132
+ text = line.decode("utf-8", errors="replace")
133
+ if is_stderr:
134
+ stderr_chunks.append(text)
135
+ else:
136
+ stdout_chunks.append(text)
137
+
138
+ if on_output:
139
+ try:
140
+ await on_output(text, is_stderr)
141
+ except Exception:
142
+ pass # Ignore callback errors
143
+
144
+ try:
145
+ process = await asyncio.create_subprocess_exec(
146
+ *resolved_cmd,
147
+ stdout=asyncio.subprocess.PIPE,
148
+ stderr=asyncio.subprocess.PIPE,
149
+ env=safe_env,
150
+ )
151
+
152
+ stdout_chunks = []
153
+ stderr_chunks = []
154
+
155
+ async def read_stream(stream, chunks, callback=None):
156
+ while True:
157
+ line = await stream.readline()
158
+ if not line:
159
+ break
160
+ decoded_line = line.decode("utf-8", errors="replace")
161
+ chunks.append(decoded_line)
162
+ if callback:
163
+ try:
164
+ if asyncio.iscoroutinefunction(callback):
165
+ await callback(decoded_line.strip())
166
+ else:
167
+ callback(decoded_line.strip())
168
+ except Exception:
169
+ pass # Ignore callback errors to prevent crashing execution
170
+
171
+ # Create tasks for reading stdout and stderr
172
+ stdout_task = asyncio.create_task(
173
+ read_stream(process.stdout, stdout_chunks, progress_callback)
174
+ )
175
+ stderr_task = asyncio.create_task(
176
+ read_stream(process.stderr, stderr_chunks)
177
+ )
178
+
179
+ # Wait for everything to finish or timeout
180
+ try:
181
+ # We wait for the process AND the stream readers
182
+ # This ensures we don't timeout if the process is done but streams are still being read
183
+ # and conversely, we DO timeout if streams are blocked even if process is done (unlikely but possible)
184
+ # or if process is hanging.
185
+ await asyncio.wait_for(
186
+ asyncio.gather(process.wait(), stdout_task, stderr_task),
187
+ timeout=timeout_seconds
188
+ )
189
+ except asyncio.TimeoutError:
190
+ # Timeout occurred - clean up everything
191
+ if process:
192
+ try:
193
+ process.kill()
194
+ except ProcessLookupError:
195
+ pass
196
+
197
+ # Cancel stream readers
198
+ stdout_task.cancel()
199
+ stderr_task.cancel()
200
+
201
+ # Wait for cancellation to complete
202
+ try:
203
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
204
+ except Exception:
205
+ pass
206
+
207
+ raise TimeoutError(
208
+ f"Orchestrator '{orchestrator_name}' timed out after {timeout_seconds}s"
209
+ )
210
+
211
+ return (
212
+ "".join(stdout_chunks),
213
+ "".join(stderr_chunks),
214
+ process.returncode or 0,
215
+ )
216
+
217
+ except Exception as e:
218
+ if process and process.returncode is None:
219
+ try:
220
+ process.kill()
221
+ await process.wait()
222
+ except ProcessLookupError:
223
+ pass
224
+ if isinstance(e, (TimeoutError, RuntimeError)):
225
+ raise e
226
+ raise RuntimeError(
227
+ f"Orchestrator '{orchestrator_name}' failed: {str(e)}"
228
+ ) from e
229
+
230
+ def validate_all(self) -> dict[str, bool]:
231
+ """
232
+ Validate all registered orchestrators are available.
233
+
234
+ Returns:
235
+ dict: {orchestrator_name: is_available}
236
+ """
237
+ results = {}
238
+ for name, config in self.orchestrators.items():
239
+ cmd = config.command if isinstance(config.command, str) else config.command[0]
240
+ try:
241
+ subprocess.run(
242
+ ["which", cmd] if subprocess.os.name != "nt" else ["where", cmd],
243
+ capture_output=True,
244
+ check=True,
245
+ )
246
+ results[name] = True
247
+ except subprocess.CalledProcessError:
248
+ results[name] = False
249
+
250
+ return results
src/delegation_mcp/retry.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retry logic with exponential backoff."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import TypeVar, Callable, Any
6
+ from functools import wraps
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ async def retry_with_backoff(
14
+ func: Callable[..., Any],
15
+ *args: Any,
16
+ max_retries: int = 3,
17
+ initial_delay: float = 1.0,
18
+ backoff_factor: float = 2.0,
19
+ exceptions: tuple = (Exception,),
20
+ **kwargs: Any,
21
+ ) -> Any:
22
+ """
23
+ Retry an async function with exponential backoff.
24
+
25
+ Args:
26
+ func: Async function to retry
27
+ *args: Positional arguments for func
28
+ max_retries: Maximum number of retry attempts
29
+ initial_delay: Initial delay in seconds
30
+ backoff_factor: Multiplier for delay on each retry
31
+ exceptions: Tuple of exceptions to catch and retry
32
+ **kwargs: Keyword arguments for func
33
+
34
+ Returns:
35
+ Result of successful function call
36
+
37
+ Raises:
38
+ Last exception if all retries fail
39
+ """
40
+ delay = initial_delay
41
+ last_exception = None
42
+
43
+ for attempt in range(max_retries + 1):
44
+ try:
45
+ return await func(*args, **kwargs)
46
+ except exceptions as e:
47
+ last_exception = e
48
+
49
+ if attempt == max_retries:
50
+ logger.error(
51
+ f"Failed after {max_retries} retries: {str(e)}"
52
+ )
53
+ raise
54
+
55
+ logger.warning(
56
+ f"Attempt {attempt + 1}/{max_retries + 1} failed: {str(e)}. "
57
+ f"Retrying in {delay:.1f}s..."
58
+ )
59
+
60
+ await asyncio.sleep(delay)
61
+ delay *= backoff_factor
62
+
63
+ # This should never be reached, but just in case
64
+ if last_exception:
65
+ raise last_exception
66
+
67
+
68
+ def with_retry(
69
+ max_retries: int = 3,
70
+ initial_delay: float = 1.0,
71
+ backoff_factor: float = 2.0,
72
+ exceptions: tuple = (Exception,),
73
+ ):
74
+ """
75
+ Decorator for adding retry logic to async functions.
76
+
77
+ Args:
78
+ max_retries: Maximum number of retry attempts
79
+ initial_delay: Initial delay in seconds
80
+ backoff_factor: Multiplier for delay on each retry
81
+ exceptions: Tuple of exceptions to catch and retry
82
+
83
+ Example:
84
+ @with_retry(max_retries=3, initial_delay=1.0)
85
+ async def fetch_data():
86
+ return await api.get('/data')
87
+ """
88
+ def decorator(func: Callable) -> Callable:
89
+ @wraps(func)
90
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
91
+ return await retry_with_backoff(
92
+ func,
93
+ *args,
94
+ max_retries=max_retries,
95
+ initial_delay=initial_delay,
96
+ backoff_factor=backoff_factor,
97
+ exceptions=exceptions,
98
+ **kwargs,
99
+ )
100
+ return wrapper
101
+ return decorator
src/delegation_mcp/server.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main MCP server implementation.
2
+
3
+ Lightweight discovery-only service that provides:
4
+ - Routing guidance via capability-based task classification
5
+ - On-demand agent discovery and registration
6
+ - Agent availability listing
7
+ """
8
+
9
+ from mcp.server import Server
10
+ from mcp.server.stdio import stdio_server
11
+ from mcp.server.models import InitializationOptions
12
+ from mcp.server.lowlevel import NotificationOptions
13
+ from mcp.types import Tool, TextContent
14
+
15
+ import asyncio
16
+ import logging
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from .config import DelegationConfig, OrchestratorConfig
20
+ from .orchestrator import OrchestratorRegistry
21
+ from .delegation import DelegationEngine
22
+ from .agent_discovery import AgentDiscovery
23
+
24
+
25
+ logging.basicConfig(level=logging.INFO)
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class DelegationMCPServer:
30
+ """MCP server for multi-orchestrator delegation."""
31
+
32
+ def __init__(
33
+ self,
34
+ config_path: Path | None = None,
35
+ enable_auto_discovery: bool = True,
36
+ ):
37
+ """Initialize lightweight MCP server.
38
+
39
+ Args:
40
+ config_path: Path to delegation rules config
41
+ enable_auto_discovery: Enable automatic agent discovery on startup
42
+ """
43
+ self.config_path = config_path or self._resolve_config_path()
44
+ self.config = self._load_config()
45
+ self.registry = OrchestratorRegistry()
46
+ self.engine = DelegationEngine(self.config, self.registry)
47
+ self.server = Server("delegation-mcp")
48
+
49
+ # Agent discovery system for auto-detecting installed agents
50
+ self.agent_discovery = AgentDiscovery()
51
+ self.enable_auto_discovery = enable_auto_discovery
52
+
53
+ self._setup_handlers()
54
+ self._register_orchestrators()
55
+
56
+ def _resolve_config_path(self) -> Path:
57
+ """Resolve config path with priority: project → user → defaults.
58
+
59
+ Priority matches CLAUDE.md behavior:
60
+ 1. Project-level config (can override user-level)
61
+ 2. User-level config (fallback for global installs)
62
+ 3. Project path (triggers default config creation)
63
+ """
64
+ # Check project-level config first (can override user-level)
65
+ project_config = Path("config/delegation_rules.yaml")
66
+ if project_config.exists():
67
+ return project_config
68
+
69
+ # Fall back to user-level config
70
+ user_config = Path.home() / ".delegation-mcp" / "config" / "delegation_rules.yaml"
71
+ if user_config.exists():
72
+ return user_config
73
+
74
+ # Return project path (will trigger default config creation)
75
+ return project_config
76
+
77
+ def _load_config(self) -> DelegationConfig:
78
+ """Load configuration from file."""
79
+ if self.config_path.exists():
80
+ return DelegationConfig.from_yaml(self.config_path)
81
+ return self._create_default_config()
82
+
83
+ def _create_default_config(self) -> DelegationConfig:
84
+ """Create default configuration."""
85
+ return DelegationConfig(
86
+ orchestrator="claude",
87
+ orchestrators={
88
+ "claude": OrchestratorConfig(
89
+ name="claude",
90
+ command="claude",
91
+ args=["-p"], # Non-interactive mode
92
+ enabled=True
93
+ ),
94
+ "gemini": OrchestratorConfig(
95
+ name="gemini",
96
+ command="gemini",
97
+ args=[], # Gemini uses positional args by default
98
+ enabled=True,
99
+ ),
100
+ "copilot": OrchestratorConfig(
101
+ name="copilot", command="copilot", enabled=False
102
+ ),
103
+ "aider": OrchestratorConfig(
104
+ name="aider",
105
+ command="aider",
106
+ args=["--yes", "--no-auto-commits"], # Auto-approve, no commits
107
+ enabled=False
108
+ ),
109
+ },
110
+ )
111
+
112
+ async def _discover_and_register_agents(self) -> None:
113
+ """Discover available agents and register them with the registry."""
114
+ if not self.enable_auto_discovery:
115
+ logger.info("Agent auto-discovery disabled")
116
+ return
117
+
118
+ logger.info("Starting agent auto-discovery...")
119
+ discovered = await self.agent_discovery.discover_agents()
120
+
121
+ # Register discovered agents that aren't already in config
122
+ for name, metadata in discovered.items():
123
+ if metadata.available and name not in self.config.orchestrators:
124
+ # Create config from discovered metadata
125
+ agent_config = OrchestratorConfig(
126
+ name=name,
127
+ command=metadata.command,
128
+ enabled=True,
129
+ timeout=300,
130
+ )
131
+ self.config.orchestrators[name] = agent_config
132
+ self.registry.register(agent_config)
133
+ logger.info(f"Auto-registered discovered agent: {name} ({metadata.version})")
134
+
135
+ # Report discovery summary
136
+ summary = self.agent_discovery.get_discovery_summary()
137
+ logger.info(
138
+ f"Agent discovery complete: {summary['available']}/{summary['total_agents']} agents available"
139
+ )
140
+
141
+ # Log unavailable agents with install instructions
142
+ for agent in self.agent_discovery.get_unavailable_agents():
143
+ logger.info(f" {agent.name}: {agent.error_message}")
144
+
145
+ def _register_orchestrators(self) -> None:
146
+ """Register all orchestrators from config."""
147
+ for name, config in self.config.orchestrators.items():
148
+ self.registry.register(config)
149
+ logger.info(f"Registered orchestrator: {name} (enabled={config.enabled})")
150
+
151
+ # Validate availability
152
+ availability = self.registry.validate_all()
153
+ for name, available in availability.items():
154
+ if not available:
155
+ logger.warning(f"Orchestrator '{name}' not available in PATH")
156
+
157
+ def _setup_handlers(self) -> None:
158
+ """Setup MCP server handlers with on-demand tool loading."""
159
+
160
+ @self.server.list_tools()
161
+ async def list_tools() -> list[Tool]:
162
+ """List available lightweight tools for routing guidance and discovery.
163
+
164
+ Tools:
165
+ - get_routing_guidance: Returns which agent should handle a task (no execution)
166
+ - discover_agents: Discover and register available CLI agents
167
+ - list_agents: List registered agents and their availability
168
+ """
169
+ tools = [
170
+ Tool(
171
+ name="get_routing_guidance",
172
+ description="Get routing guidance for a task - returns which agent should handle it and the exact CLI command to run (guidance only, no execution)",
173
+ inputSchema={
174
+ "type": "object",
175
+ "properties": {
176
+ "query": {
177
+ "type": "string",
178
+ "description": "The task query to get routing guidance for",
179
+ },
180
+ },
181
+ "required": ["query"],
182
+ },
183
+ ),
184
+ Tool(
185
+ name="discover_agents",
186
+ description="Discover and register available CLI agents on the system",
187
+ inputSchema={
188
+ "type": "object",
189
+ "properties": {
190
+ "force_refresh": {
191
+ "type": "boolean",
192
+ "description": "Force re-discovery even if cache exists",
193
+ "default": False,
194
+ },
195
+ },
196
+ },
197
+ ),
198
+ Tool(
199
+ name="list_agents",
200
+ description="List all registered agents and their availability status",
201
+ inputSchema={
202
+ "type": "object",
203
+ "properties": {},
204
+ },
205
+ ),
206
+ ]
207
+
208
+ logger.info(f"Listed {len(tools)} lightweight tools")
209
+ return tools
210
+
211
+ @self.server.call_tool()
212
+ async def call_tool(name: str, arguments: Any) -> list[TextContent]:
213
+ """Handle lightweight tool calls for routing guidance and discovery."""
214
+ try:
215
+ if name == "get_routing_guidance":
216
+ # Get routing guidance without executing the task
217
+ query = arguments["query"]
218
+
219
+ # Classify the task to determine routing
220
+ task_info = self.engine._classify_task(query)
221
+ task_type = task_info[0] if isinstance(task_info, tuple) else task_info
222
+ timeout = task_info[1] if isinstance(task_info, tuple) and len(task_info) > 1 else 300
223
+
224
+ # Determine which agent should handle it
225
+ agent, _ = self.engine._determine_delegation(query, None)
226
+
227
+ if agent:
228
+ agent_config = self.registry.get(agent)
229
+ # Build the CLI command that should be run
230
+ cmd_parts = [agent_config.command] + agent_config.args + [query]
231
+ cli_command = " ".join(f'"{part}"' if " " in part else part for part in cmd_parts)
232
+
233
+ response = {
234
+ "decision": f"DELEGATE_TO: {agent}",
235
+ "agent": agent,
236
+ "task_type": task_type,
237
+ "timeout": timeout,
238
+ "cli_command": cli_command,
239
+ }
240
+ else:
241
+ response = {
242
+ "decision": "HANDLE_DIRECTLY",
243
+ "task_type": task_type,
244
+ "timeout": timeout,
245
+ }
246
+
247
+ import json
248
+ return [TextContent(type="text", text=json.dumps(response, indent=2))]
249
+
250
+ elif name == "discover_agents":
251
+ # Discover available agents
252
+ force_refresh = arguments.get("force_refresh", False)
253
+ discovered = await self.agent_discovery.discover_agents(force_refresh=force_refresh)
254
+
255
+ # Register newly discovered agents
256
+ registered_count = 0
257
+ for agent_name, metadata in discovered.items():
258
+ if metadata.available and agent_name not in self.config.orchestrators:
259
+ agent_config = OrchestratorConfig(
260
+ name=agent_name,
261
+ command=metadata.command,
262
+ enabled=True,
263
+ timeout=300,
264
+ )
265
+ self.config.orchestrators[agent_name] = agent_config
266
+ self.registry.register(agent_config)
267
+ registered_count += 1
268
+ logger.info(f"Registered new agent: {agent_name}")
269
+
270
+ # Build response
271
+ summary = self.agent_discovery.get_discovery_summary()
272
+ text = f"Agent Discovery Results:\n\n"
273
+ text += f"Total agents scanned: {summary['total_agents']}\n"
274
+ text += f"Available: {summary['available']}\n"
275
+ text += f"Unavailable: {summary['unavailable']}\n"
276
+ text += f"Newly registered: {registered_count}\n\n"
277
+
278
+ if summary['available_agents']:
279
+ text += "Available Agents:\n"
280
+ for agent in summary['available_agents']:
281
+ text += f" ✓ {agent['name']}: {agent['version']}\n"
282
+ text += f" Path: {agent['path']}\n"
283
+
284
+ if summary['unavailable_agents']:
285
+ text += "\nUnavailable Agents:\n"
286
+ for agent in summary['unavailable_agents']:
287
+ text += f" ✗ {agent['name']}\n"
288
+ text += f" {agent['error']}\n"
289
+
290
+ return [TextContent(type="text", text=text)]
291
+
292
+ elif name == "list_agents":
293
+ # List registered agents and their availability
294
+ enabled = self.registry.list_enabled()
295
+ all_agents = list(self.registry.orchestrators.keys())
296
+ availability = self.registry.validate_all()
297
+
298
+ text = "Registered Agents:\n\n"
299
+ for agent_name in all_agents:
300
+ config = self.registry.get(agent_name)
301
+ status = "✓ Enabled" if agent_name in enabled else "✗ Disabled"
302
+ avail = "Available" if availability.get(agent_name) else "Not found in PATH"
303
+ text += f"{agent_name}:\n"
304
+ text += f" Status: {status}\n"
305
+ text += f" Availability: {avail}\n"
306
+ text += f" Command: {config.command}\n"
307
+ if config.args:
308
+ text += f" Args: {' '.join(config.args)}\n"
309
+ text += "\n"
310
+
311
+ return [TextContent(type="text", text=text)]
312
+
313
+ else:
314
+ logger.error(f"Unknown tool: {name}")
315
+ return [TextContent(type="text", text=f"Error: Unknown tool '{name}'")]
316
+
317
+ except Exception as e:
318
+ logger.error(f"Tool call failed: {name} - {e}", exc_info=True)
319
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
320
+
321
+ async def run(self) -> None:
322
+ """Run the lightweight MCP server."""
323
+ logger.info("Starting delegation MCP server (lightweight mode)")
324
+ logger.info("- Mode: Routing guidance only (no execution)")
325
+ logger.info(f"- Agent auto-discovery: {'ON' if self.enable_auto_discovery else 'OFF'}")
326
+ logger.info("- Tools: get_routing_guidance, discover_agents, list_agents")
327
+
328
+ # Discover and register available agents
329
+ if self.enable_auto_discovery:
330
+ await self._discover_and_register_agents()
331
+
332
+ try:
333
+ async with stdio_server() as (read_stream, write_stream):
334
+ await self.server.run(
335
+ read_stream,
336
+ write_stream,
337
+ InitializationOptions(
338
+ server_name="delegation-mcp",
339
+ server_version="0.4.0", # Updated version for lightweight architecture
340
+ capabilities=self.server.get_capabilities(
341
+ notification_options=NotificationOptions(),
342
+ experimental_capabilities={
343
+ "agent_discovery": {},
344
+ "routing_guidance": {},
345
+ },
346
+ ),
347
+ ),
348
+ )
349
+ finally:
350
+ logger.info("Server stopped")
351
+
352
+
353
+ def main():
354
+ """Main entry point."""
355
+ import sys
356
+
357
+ config_path = Path(sys.argv[1]) if len(sys.argv) > 1 else None
358
+ server = DelegationMCPServer(config_path)
359
+
360
+ try:
361
+ asyncio.run(server.run())
362
+ except KeyboardInterrupt:
363
+ logger.info("Server stopped")
364
+
365
+
366
+ if __name__ == "__main__":
367
+ main()
src/delegation_mcp/tool_discovery.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File-based tool discovery system for on-demand loading.
2
+
3
+ Following Anthropic's MCP architecture recommendations:
4
+ - Organize tools in filesystem hierarchy
5
+ - Load tool definitions on-demand
6
+ - Implement search_tools capability with detail levels
7
+ - Reduce token consumption by 98.7% (150,000 → 2,000 tokens)
8
+ """
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+ from dataclasses import dataclass
14
+ from mcp.types import Tool
15
+ import json
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ DetailLevel = Literal["minimal", "standard", "full"]
21
+
22
+
23
+ @dataclass
24
+ class ToolMetadata:
25
+ """Lightweight tool metadata for search results."""
26
+
27
+ name: str
28
+ category: str
29
+ description: str
30
+ file_path: Path
31
+
32
+
33
+ class ToolDiscoverySystem:
34
+ """File-based tool discovery with on-demand loading."""
35
+
36
+ def __init__(self, tools_dir: Path | None = None):
37
+ """Initialize tool discovery system.
38
+
39
+ Args:
40
+ tools_dir: Directory containing tool definitions (default: ./tools/)
41
+ """
42
+ self.tools_dir = tools_dir or Path("tools")
43
+ self._tool_cache: dict[str, Tool] = {}
44
+ self._metadata_cache: dict[str, ToolMetadata] = {}
45
+ self._initialize_metadata()
46
+
47
+ def _initialize_metadata(self) -> None:
48
+ """Initialize lightweight metadata index for all tools."""
49
+ if not self.tools_dir.exists():
50
+ logger.warning(f"Tools directory not found: {self.tools_dir}")
51
+ self._load_default_tools()
52
+ return
53
+
54
+ for tool_file in self.tools_dir.rglob("*.json"):
55
+ try:
56
+ with open(tool_file) as f:
57
+ data = json.load(f)
58
+ metadata = ToolMetadata(
59
+ name=data["name"],
60
+ category=data.get("category", "general"),
61
+ description=data.get("description", ""),
62
+ file_path=tool_file,
63
+ )
64
+ self._metadata_cache[metadata.name] = metadata
65
+ logger.debug(f"Indexed tool: {metadata.name}")
66
+ except Exception as e:
67
+ logger.error(f"Failed to index tool {tool_file}: {e}")
68
+
69
+ def _load_default_tools(self) -> None:
70
+ """Load default tool metadata when no tools directory exists."""
71
+ default_tools = [
72
+ ToolMetadata(
73
+ name="delegate_task",
74
+ category="orchestration",
75
+ description="Delegate a coding task to appropriate AI agent",
76
+ file_path=Path("tools/orchestration/delegate_task.json"),
77
+ ),
78
+ ToolMetadata(
79
+ name="list_orchestrators",
80
+ category="orchestration",
81
+ description="List available orchestrators and their status",
82
+ file_path=Path("tools/orchestration/list_orchestrators.json"),
83
+ ),
84
+ ToolMetadata(
85
+ name="get_statistics",
86
+ category="monitoring",
87
+ description="Get delegation statistics and metrics",
88
+ file_path=Path("tools/monitoring/get_statistics.json"),
89
+ ),
90
+ ]
91
+ for metadata in default_tools:
92
+ self._metadata_cache[metadata.name] = metadata
93
+
94
+ def search_tools(
95
+ self,
96
+ query: str | None = None,
97
+ category: str | None = None,
98
+ detail: DetailLevel = "minimal",
99
+ ) -> list[dict[str, Any]]:
100
+ """Search tools with configurable detail level.
101
+
102
+ Args:
103
+ query: Search query to match against tool names/descriptions
104
+ category: Filter by tool category
105
+ detail: Level of detail to return
106
+ - minimal: name + category only (lowest token cost)
107
+ - standard: + description
108
+ - full: + complete schema (highest token cost)
109
+
110
+ Returns:
111
+ List of tool information at requested detail level
112
+ """
113
+ results = []
114
+
115
+ for name, metadata in self._metadata_cache.items():
116
+ # Apply filters
117
+ if category and metadata.category != category:
118
+ continue
119
+ if query and query.lower() not in name.lower() and query.lower() not in metadata.description.lower():
120
+ continue
121
+
122
+ # Build result based on detail level
123
+ if detail == "minimal":
124
+ results.append({
125
+ "name": name,
126
+ "category": metadata.category,
127
+ })
128
+ elif detail == "standard":
129
+ results.append({
130
+ "name": name,
131
+ "category": metadata.category,
132
+ "description": metadata.description,
133
+ })
134
+ else: # full
135
+ tool = self.load_tool(name)
136
+ if tool:
137
+ results.append({
138
+ "name": name,
139
+ "category": metadata.category,
140
+ "description": tool.description,
141
+ "inputSchema": tool.inputSchema,
142
+ })
143
+
144
+ logger.info(f"Tool search: query={query}, category={category}, detail={detail}, results={len(results)}")
145
+ return results
146
+
147
+ def load_tool(self, name: str) -> Tool | None:
148
+ """Load complete tool definition on-demand.
149
+
150
+ Args:
151
+ name: Tool name
152
+
153
+ Returns:
154
+ Complete Tool object with schema, or None if not found
155
+ """
156
+ # Check cache first
157
+ if name in self._tool_cache:
158
+ logger.debug(f"Tool cache hit: {name}")
159
+ return self._tool_cache[name]
160
+
161
+ # Load from file
162
+ metadata = self._metadata_cache.get(name)
163
+ if not metadata:
164
+ logger.warning(f"Tool not found: {name}")
165
+ return None
166
+
167
+ # If file doesn't exist, create tool from metadata (for default tools)
168
+ if not metadata.file_path.exists():
169
+ tool = self._create_default_tool(name)
170
+ if tool:
171
+ self._tool_cache[name] = tool
172
+ return tool
173
+
174
+ try:
175
+ with open(metadata.file_path) as f:
176
+ data = json.load(f)
177
+ tool = Tool(
178
+ name=data["name"],
179
+ description=data.get("description", ""),
180
+ inputSchema=data.get("inputSchema", {"type": "object", "properties": {}}),
181
+ )
182
+ self._tool_cache[name] = tool
183
+ logger.debug(f"Loaded tool from file: {name}")
184
+ return tool
185
+ except Exception as e:
186
+ logger.error(f"Failed to load tool {name}: {e}")
187
+ return None
188
+
189
+ def _create_default_tool(self, name: str) -> Tool | None:
190
+ """Create default tool definitions for backward compatibility."""
191
+ if name == "delegate_task":
192
+ return Tool(
193
+ name="delegate_task",
194
+ description="Route task to specialist agent or confirm orchestrator should handle directly. Always call BEFORE code work to get routing guidance.",
195
+ inputSchema={
196
+ "type": "object",
197
+ "properties": {
198
+ "query": {
199
+ "type": "string",
200
+ "description": "Full user request/task to route",
201
+ },
202
+ "orchestrator": {
203
+ "type": "string",
204
+ "description": "Override primary orchestrator",
205
+ },
206
+ "force_delegate": {
207
+ "type": "string",
208
+ "description": "Force delegation to specific agent",
209
+ },
210
+ "guidance_only": {
211
+ "type": "boolean",
212
+ "description": "Return routing guidance without executing (default: false)",
213
+ "default": False,
214
+ },
215
+ },
216
+ "required": ["query"],
217
+ },
218
+ )
219
+ elif name == "list_orchestrators":
220
+ return Tool(
221
+ name="list_orchestrators",
222
+ description="List available orchestrators and their status",
223
+ inputSchema={"type": "object", "properties": {}},
224
+ )
225
+ elif name == "get_statistics":
226
+ return Tool(
227
+ name="get_statistics",
228
+ description="Get delegation statistics and metrics",
229
+ inputSchema={"type": "object", "properties": {}},
230
+ )
231
+ return None
232
+
233
+ def list_categories(self) -> list[str]:
234
+ """List all available tool categories."""
235
+ categories = {metadata.category for metadata in self._metadata_cache.values()}
236
+ return sorted(categories)
237
+
238
+ def get_tool_count(self) -> dict[str, int]:
239
+ """Get tool count by category."""
240
+ counts: dict[str, int] = {}
241
+ for metadata in self._metadata_cache.values():
242
+ counts[metadata.category] = counts.get(metadata.category, 0) + 1
243
+ return counts
src/delegation_mcp/ui/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio UI for Delegation MCP.
3
+
4
+ Contains:
5
+ - Monitor tab: Live delegation activity monitoring
6
+ - Configuration tab: Runtime agent and routing rules management
7
+ - Main app: Integrated multi-tab interface
8
+ """
9
+
10
+ from .app import create_app, main
11
+ from .config_tab import create_config_tab
12
+ from .config_manager import ConfigurationManager
13
+
14
+ __all__ = [
15
+ "create_app",
16
+ "main",
17
+ "create_config_tab",
18
+ "ConfigurationManager",
19
+ ]
src/delegation_mcp/ui/app.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main Gradio application with monitoring and configuration tabs."""
2
+
3
+ try:
4
+ import gradio as gr
5
+ GRADIO_AVAILABLE = True
6
+ except ImportError:
7
+ GRADIO_AVAILABLE = False
8
+ # Mock gr for type hinting if needed, or just handle availability check
9
+ gr = None # type: ignore
10
+
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from collections import deque
14
+
15
+ # from ..persistence import PersistenceManager
16
+ from .config_tab import create_config_tab
17
+ from .config_manager import ConfigurationManager
18
+
19
+
20
+ class DelegationMonitor:
21
+ """Monitors delegation activity for demo visualization."""
22
+
23
+ def __init__(self, db_path: Path = Path("data/delegation.db")):
24
+ # self.persistence = PersistenceManager(db_path)
25
+ self.recent_events = deque(maxlen=20) # Keep last 20 events
26
+
27
+ def get_recent_activity(self):
28
+ """Get recent delegation events for display."""
29
+ return []
30
+ # try:
31
+ # history = self.persistence.get_task_history(limit=20)
32
+ # return [
33
+ # [
34
+ # entry.timestamp.strftime("%H:%M:%S"),
35
+ # entry.orchestrator,
36
+ # entry.delegated_to or "N/A",
37
+ # "✅" if entry.success else "❌",
38
+ # f"{entry.duration:.2f}s"
39
+ # ]
40
+ # for entry in history
41
+ # ]
42
+ # except Exception:
43
+ # return []
44
+
45
+ def get_statistics(self):
46
+ """Get delegation statistics for charts."""
47
+ return {"total": 0, "success_rate": 0.0, "avg_duration": 0.0, "agent_usage": {}}
48
+ # try:
49
+ # stats = self.persistence.get_statistics()
50
+ # return {
51
+ # "total": stats.get("total_tasks", 0),
52
+ # "success_rate": stats.get("success_rate", 0.0),
53
+ # "avg_duration": stats.get("avg_duration", 0.0),
54
+ # "agent_usage": stats.get("agent_usage", {}),
55
+ # }
56
+ # except Exception:
57
+ # return {"total": 0, "success_rate": 0.0, "avg_duration": 0.0, "agent_usage": {}}
58
+
59
+
60
+ def create_app(
61
+ config_manager: ConfigurationManager | None = None,
62
+ db_path: Path = Path("data/delegation.db"),
63
+ ):
64
+ """Create main Gradio application with multiple tabs.
65
+
66
+ Args:
67
+ config_manager: Optional ConfigurationManager instance
68
+ db_path: Path to delegation database for monitoring
69
+
70
+ Returns:
71
+ Gradio Blocks application with Monitor and Configuration tabs
72
+ """
73
+ if not GRADIO_AVAILABLE:
74
+ print("Error: Gradio is not installed. Please install with `pip install .[ui]`")
75
+ return None
76
+
77
+ if config_manager is None:
78
+ config_manager = ConfigurationManager()
79
+
80
+ monitor = DelegationMonitor(db_path)
81
+
82
+ # Create the main application with tabs
83
+ with gr.Blocks(
84
+ title="Delegation MCP - Monitor & Configuration",
85
+ theme=gr.themes.Soft(),
86
+ ) as app:
87
+ gr.Markdown("""
88
+ # 🚀 Delegation MCP - Multi-Agent Orchestration
89
+
90
+ **Monitor delegation activity and configure agent routing in real-time.**
91
+
92
+ This interface provides two main functions:
93
+ - **Monitor**: View live delegation activity and statistics (for demos and debugging)
94
+ - **Configuration**: Manage agents and routing rules with immediate effect
95
+ """)
96
+
97
+ with gr.Tabs() as tabs:
98
+ # Tab 1: Monitor
99
+ with gr.Tab("📊 Monitor"):
100
+ gr.Markdown("""
101
+ # 🔍 Delegation MCP - Live Activity Monitor
102
+
103
+ **This monitor shows real-time delegation activity** when Claude Code (or other MCP clients)
104
+ call the delegation MCP server.
105
+
106
+ **How to use:**
107
+ 1. Start the MCP server: `delegation-mcp`
108
+ 2. Configure Claude Code to use it (see README.md)
109
+ 3. Chat with Claude Code and ask it to delegate tasks
110
+ 4. Watch delegations appear here in real-time!
111
+
112
+ ---
113
+ """)
114
+
115
+ with gr.Row():
116
+ with gr.Column(scale=1):
117
+ gr.Markdown("### 📊 Statistics")
118
+ total_tasks = gr.Number(label="Total Tasks", value=0, interactive=False)
119
+ success_rate = gr.Number(label="Success Rate (%)", value=0, interactive=False)
120
+ avg_duration = gr.Number(label="Avg Duration (s)", value=0, interactive=False)
121
+
122
+ with gr.Column(scale=2):
123
+ gr.Markdown("### 🤖 Agent Usage")
124
+ agent_chart = gr.BarPlot(
125
+ x="agent",
126
+ y="count",
127
+ title="Delegations by Agent",
128
+ )
129
+
130
+ gr.Markdown("### 📝 Recent Delegations")
131
+ activity_table = gr.Dataframe(
132
+ headers=["Time", "From", "To", "Status", "Duration"],
133
+ label="Live Activity",
134
+ interactive=False,
135
+ wrap=True,
136
+ )
137
+
138
+ refresh_btn = gr.Button("🔄 Refresh", variant="primary")
139
+
140
+ def refresh_all():
141
+ """Refresh all monitor data."""
142
+ # Get statistics
143
+ stats = monitor.get_statistics()
144
+
145
+ # Get recent activity
146
+ activity = monitor.get_recent_activity()
147
+
148
+ # Format agent usage for chart
149
+ agent_data = []
150
+ for agent, count in stats["agent_usage"].items():
151
+ agent_data.append({"agent": agent, "count": count})
152
+
153
+ return (
154
+ stats["total"],
155
+ stats["success_rate"] * 100,
156
+ stats["avg_duration"],
157
+ {"data": agent_data} if agent_data else None,
158
+ activity,
159
+ )
160
+
161
+ # Wire up refresh button
162
+ refresh_btn.click(
163
+ fn=refresh_all,
164
+ outputs=[total_tasks, success_rate, avg_duration, agent_chart, activity_table],
165
+ )
166
+
167
+ # Auto-refresh on load
168
+ app.load(
169
+ fn=refresh_all,
170
+ outputs=[total_tasks, success_rate, avg_duration, agent_chart, activity_table],
171
+ )
172
+
173
+
174
+ # Tab 2: Configuration
175
+ with gr.Tab("⚙️ Configuration"):
176
+ config_tab = create_config_tab(config_manager)
177
+
178
+ gr.Markdown("""
179
+ ---
180
+ ### Getting Started
181
+
182
+ 1. Configure agents in the Configuration tab
183
+ 2. Set up routing rules for automatic task delegation
184
+ 3. Start the MCP server: `delegation-mcp`
185
+ 4. Connect MCP clients (Claude Code, etc.)
186
+ 5. Watch delegations in the Monitor tab
187
+
188
+ Changes take effect immediately. See [GitHub](https://github.com/carlosduplar/multi-agent-mcp) for docs.
189
+ """)
190
+
191
+ return app
192
+
193
+
194
+ def main(
195
+ server_name: str = "0.0.0.0",
196
+ server_port: int = 7860,
197
+ share: bool = False,
198
+ ):
199
+ """Launch the Gradio application.
200
+
201
+ Args:
202
+ server_name: Server hostname (default: 0.0.0.0 for all interfaces)
203
+ server_port: Server port (default: 7860)
204
+ share: Enable Gradio share link (default: False)
205
+ """
206
+ app = create_app()
207
+ if app:
208
+ app.launch(
209
+ server_name=server_name,
210
+ server_port=server_port,
211
+ share=share,
212
+ )
213
+
214
+
215
+ if __name__ == "__main__":
216
+ main()
src/delegation_mcp/ui/config_manager.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration manager for runtime agent and routing rules management."""
2
+
3
+ import yaml
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from dataclasses import dataclass
8
+
9
+ from ..config import DelegationConfig, OrchestratorConfig, DelegationRule
10
+ from ..orchestrator import OrchestratorRegistry
11
+
12
+
13
+ @dataclass
14
+ class AgentStatus:
15
+ """Agent status information."""
16
+
17
+ name: str
18
+ enabled: bool
19
+ installed: bool
20
+ config: OrchestratorConfig | None
21
+ status_text: str
22
+ status_icon: str
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ """Convert to dictionary for UI display."""
26
+ return {
27
+ "name": self.name,
28
+ "enabled": self.enabled,
29
+ "installed": self.installed,
30
+ "status": self.status_text,
31
+ "icon": self.status_icon,
32
+ }
33
+
34
+
35
+ class ConfigurationManager:
36
+ """Manages agent configuration and routing rules."""
37
+
38
+ def __init__(
39
+ self,
40
+ orchestrators_path: Path = Path("config/orchestrators.yaml"),
41
+ rules_path: Path = Path("config/delegation_rules.yaml"),
42
+ ):
43
+ """Initialize configuration manager.
44
+
45
+ Args:
46
+ orchestrators_path: Path to orchestrators YAML config
47
+ rules_path: Path to delegation rules YAML config
48
+ """
49
+ self.orchestrators_path = orchestrators_path
50
+ self.rules_path = rules_path
51
+ self.registry = OrchestratorRegistry()
52
+
53
+ # Store default configurations for reset functionality
54
+ self.default_orchestrators: dict[str, Any] = {}
55
+ self.default_rules: list[dict[str, Any]] = []
56
+
57
+ # Load configurations
58
+ self.load_configurations()
59
+
60
+ def load_configurations(self) -> None:
61
+ """Load orchestrator and delegation rule configurations."""
62
+ # Load orchestrators
63
+ with open(self.orchestrators_path) as f:
64
+ orch_data = yaml.safe_load(f)
65
+ self.orchestrators_config = orch_data.get("orchestrators", {})
66
+
67
+ # Store defaults
68
+ if not self.default_orchestrators:
69
+ self.default_orchestrators = {
70
+ k: v.copy() for k, v in self.orchestrators_config.items()
71
+ }
72
+
73
+ # Register orchestrators
74
+ for name, config in self.orchestrators_config.items():
75
+ self.registry.register(OrchestratorConfig(**config))
76
+
77
+ # Load delegation rules
78
+ with open(self.rules_path) as f:
79
+ rules_data = yaml.safe_load(f)
80
+ self.primary_orchestrator = rules_data.get("orchestrator", "claude")
81
+ self.rules_list = rules_data.get("rules", [])
82
+
83
+ # Store defaults
84
+ if not self.default_rules:
85
+ self.default_rules = [r.copy() for r in self.rules_list]
86
+
87
+ def get_agent_statuses(self) -> list[AgentStatus]:
88
+ """Get status information for all agents.
89
+
90
+ Returns:
91
+ List of AgentStatus objects with installation and enablement info
92
+ """
93
+ # Validate which agents are installed
94
+ installed_agents = self.registry.validate_all()
95
+
96
+ statuses = []
97
+ for name, config_dict in self.orchestrators_config.items():
98
+ config = OrchestratorConfig(**config_dict)
99
+ is_installed = installed_agents.get(name, False)
100
+ is_enabled = config.enabled
101
+
102
+ # Determine status
103
+ if is_enabled and is_installed:
104
+ status_text = "Active"
105
+ status_icon = "🟢"
106
+ elif is_enabled and not is_installed:
107
+ status_text = "Not Installed"
108
+ status_icon = "⚠️"
109
+ else:
110
+ status_text = "Disabled"
111
+ status_icon = "🔴"
112
+
113
+ statuses.append(AgentStatus(
114
+ name=name,
115
+ enabled=is_enabled,
116
+ installed=is_installed,
117
+ config=config,
118
+ status_text=status_text,
119
+ status_icon=status_icon,
120
+ ))
121
+
122
+ return statuses
123
+
124
+ def toggle_agent(self, agent_name: str, enabled: bool) -> tuple[bool, str]:
125
+ """Toggle an agent on or off.
126
+
127
+ Args:
128
+ agent_name: Name of the agent to toggle
129
+ enabled: True to enable, False to disable
130
+
131
+ Returns:
132
+ tuple: (success, message)
133
+ """
134
+ if agent_name not in self.orchestrators_config:
135
+ return False, f"Agent '{agent_name}' not found"
136
+
137
+ # Check if this is the primary orchestrator
138
+ if not enabled and agent_name == self.primary_orchestrator:
139
+ return False, f"Cannot disable primary orchestrator '{agent_name}'. Please select a different primary orchestrator first."
140
+
141
+ # Check if disabling would break any routing rules
142
+ if not enabled:
143
+ broken_rules = [
144
+ rule for rule in self.rules_list
145
+ if rule.get("delegate_to") == agent_name
146
+ ]
147
+ if broken_rules:
148
+ rule_descriptions = [r.get("description", r.get("pattern")) for r in broken_rules]
149
+ return False, f"Cannot disable '{agent_name}'. It is used in {len(broken_rules)} routing rule(s): {', '.join(rule_descriptions[:3])}"
150
+
151
+ # Update configuration
152
+ self.orchestrators_config[agent_name]["enabled"] = enabled
153
+
154
+ # Update registry
155
+ config = OrchestratorConfig(**self.orchestrators_config[agent_name])
156
+ self.registry.register(config)
157
+
158
+ return True, f"Agent '{agent_name}' {'enabled' if enabled else 'disabled'} successfully"
159
+
160
+ def set_primary_orchestrator(self, agent_name: str) -> tuple[bool, str]:
161
+ """Set the primary orchestrator.
162
+
163
+ Args:
164
+ agent_name: Name of the agent to set as primary
165
+
166
+ Returns:
167
+ tuple: (success, message)
168
+ """
169
+ if agent_name not in self.orchestrators_config:
170
+ return False, f"Agent '{agent_name}' not found"
171
+
172
+ config = self.orchestrators_config[agent_name]
173
+
174
+ # Validate agent is enabled
175
+ if not config.get("enabled", False):
176
+ return False, f"Cannot set '{agent_name}' as primary orchestrator because it is disabled. Please enable it first."
177
+
178
+ # Validate agent is installed
179
+ installed = self.registry.validate_all()
180
+ if not installed.get(agent_name, False):
181
+ return False, f"Cannot set '{agent_name}' as primary orchestrator because it is not installed."
182
+
183
+ self.primary_orchestrator = agent_name
184
+ return True, f"Primary orchestrator set to '{agent_name}'"
185
+
186
+ def validate_routing_rules(self, yaml_text: str) -> tuple[bool, str, list[dict[str, Any]] | None]:
187
+ """Validate routing rules YAML.
188
+
189
+ Args:
190
+ yaml_text: YAML text to validate
191
+
192
+ Returns:
193
+ tuple: (is_valid, message, parsed_rules)
194
+ """
195
+ try:
196
+ # Parse YAML
197
+ data = yaml.safe_load(yaml_text)
198
+
199
+ if not isinstance(data, list):
200
+ return False, "Rules must be a list", None
201
+
202
+ # Validate each rule
203
+ for i, rule in enumerate(data):
204
+ if not isinstance(rule, dict):
205
+ return False, f"Rule {i+1} must be a dictionary", None
206
+
207
+ # Required fields
208
+ if "pattern" not in rule:
209
+ return False, f"Rule {i+1} missing required field 'pattern'", None
210
+ if "delegate_to" not in rule:
211
+ return False, f"Rule {i+1} missing required field 'delegate_to'", None
212
+
213
+ # Validate regex pattern
214
+ try:
215
+ re.compile(rule["pattern"])
216
+ except re.error as e:
217
+ return False, f"Rule {i+1} has invalid regex pattern: {e}", None
218
+
219
+ # Validate delegate_to exists
220
+ delegate_to = rule["delegate_to"]
221
+ if delegate_to not in self.orchestrators_config:
222
+ return False, f"Rule {i+1} delegates to unknown agent '{delegate_to}'", None
223
+
224
+ # Validate delegate_to is enabled
225
+ if not self.orchestrators_config[delegate_to].get("enabled", False):
226
+ return False, f"Rule {i+1} delegates to disabled agent '{delegate_to}'", None
227
+
228
+ # Validate priority is a number
229
+ if "priority" in rule and not isinstance(rule["priority"], (int, float)):
230
+ return False, f"Rule {i+1} priority must be a number", None
231
+
232
+ return True, "✅ Routing rules are valid", data
233
+
234
+ except yaml.YAMLError as e:
235
+ return False, f"YAML parsing error: {e}", None
236
+ except Exception as e:
237
+ return False, f"Validation error: {e}", None
238
+
239
+ def preview_routing_rules(self, rules: list[dict[str, Any]]) -> str:
240
+ """Generate a preview of how routing rules will affect delegation.
241
+
242
+ Args:
243
+ rules: List of routing rules
244
+
245
+ Returns:
246
+ Formatted preview text
247
+ """
248
+ if not rules:
249
+ return "No routing rules defined. All tasks will go to the primary orchestrator."
250
+
251
+ preview = "## Routing Rules Preview\n\n"
252
+ preview += f"**Primary Orchestrator:** {self.primary_orchestrator}\n\n"
253
+
254
+ # Sort by priority (highest first)
255
+ sorted_rules = sorted(rules, key=lambda r: r.get("priority", 0), reverse=True)
256
+
257
+ preview += "**Rules (by priority):**\n\n"
258
+ for i, rule in enumerate(sorted_rules, 1):
259
+ pattern = rule.get("pattern", "")
260
+ delegate_to = rule.get("delegate_to", "")
261
+ priority = rule.get("priority", 0)
262
+ description = rule.get("description", "")
263
+
264
+ preview += f"{i}. **Pattern:** `{pattern}`\n"
265
+ preview += f" **Delegates to:** {delegate_to}\n"
266
+ preview += f" **Priority:** {priority}\n"
267
+ if description:
268
+ preview += f" **Description:** {description}\n"
269
+ preview += "\n"
270
+
271
+ # Add example queries
272
+ preview += "\n**Example Query Matching:**\n\n"
273
+ example_queries = [
274
+ "Fix this security vulnerability",
275
+ "Refactor the authentication module",
276
+ "Create a pull request for this feature",
277
+ "Run the test suite",
278
+ ]
279
+
280
+ for query in example_queries:
281
+ matched = False
282
+ for rule in sorted_rules:
283
+ if re.search(rule["pattern"], query, re.IGNORECASE):
284
+ preview += f"- \"{query}\" → **{rule['delegate_to']}** (matches: `{rule['pattern']}`)\n"
285
+ matched = True
286
+ break
287
+
288
+ if not matched:
289
+ preview += f"- \"{query}\" → **{self.primary_orchestrator}** (no rule match)\n"
290
+
291
+ return preview
292
+
293
+ def save_configurations(self, rules_yaml: str | None = None) -> tuple[bool, str]:
294
+ """Save configurations to YAML files.
295
+
296
+ Args:
297
+ rules_yaml: Optional YAML text for routing rules
298
+
299
+ Returns:
300
+ tuple: (success, message)
301
+ """
302
+ try:
303
+ # Save orchestrators
304
+ with open(self.orchestrators_path, "w") as f:
305
+ yaml.dump(
306
+ {"orchestrators": self.orchestrators_config},
307
+ f,
308
+ default_flow_style=False,
309
+ sort_keys=False,
310
+ )
311
+
312
+ # Save delegation rules
313
+ rules_data = {
314
+ "orchestrator": self.primary_orchestrator,
315
+ }
316
+
317
+ if rules_yaml:
318
+ # Validate and use provided rules
319
+ is_valid, message, parsed_rules = self.validate_routing_rules(rules_yaml)
320
+ if not is_valid:
321
+ return False, f"Cannot save: {message}"
322
+ rules_data["rules"] = parsed_rules
323
+ self.rules_list = parsed_rules or []
324
+ else:
325
+ rules_data["rules"] = self.rules_list
326
+
327
+ with open(self.rules_path, "w") as f:
328
+ # Add comment header
329
+ f.write("# Delegation MCP Configuration\n")
330
+ f.write("# Configure your primary orchestrator and delegation rules\n")
331
+ f.write("# Note: Orchestrator definitions are in config/orchestrators.yaml\n\n")
332
+ yaml.dump(rules_data, f, default_flow_style=False, sort_keys=False)
333
+
334
+ return True, "✅ Configuration saved successfully"
335
+
336
+ except Exception as e:
337
+ return False, f"Failed to save configuration: {e}"
338
+
339
+ def reset_to_defaults(self) -> tuple[bool, str]:
340
+ """Reset all configurations to defaults.
341
+
342
+ Returns:
343
+ tuple: (success, message)
344
+ """
345
+ try:
346
+ # Reset orchestrators
347
+ self.orchestrators_config = {
348
+ k: v.copy() for k, v in self.default_orchestrators.items()
349
+ }
350
+
351
+ # Reset rules
352
+ self.rules_list = [r.copy() for r in self.default_rules]
353
+ self.primary_orchestrator = "claude"
354
+
355
+ # Re-register orchestrators
356
+ for name, config in self.orchestrators_config.items():
357
+ self.registry.register(OrchestratorConfig(**config))
358
+
359
+ # Save defaults
360
+ success, message = self.save_configurations()
361
+ if success:
362
+ return True, "✅ Configuration reset to defaults"
363
+ return False, f"Failed to save defaults: {message}"
364
+
365
+ except Exception as e:
366
+ return False, f"Failed to reset configuration: {e}"
367
+
368
+ def get_rules_yaml(self) -> str:
369
+ """Get current routing rules as YAML text.
370
+
371
+ Returns:
372
+ YAML text of routing rules
373
+ """
374
+ return yaml.dump(self.rules_list, default_flow_style=False, sort_keys=False)
375
+
376
+ def get_agent_capabilities(self, agent_name: str) -> dict[str, Any]:
377
+ """Get agent capabilities from configuration.
378
+
379
+ Args:
380
+ agent_name: Name of the agent
381
+
382
+ Returns:
383
+ Dictionary of agent capabilities
384
+ """
385
+ if agent_name not in self.orchestrators_config:
386
+ return {}
387
+
388
+ config = self.orchestrators_config[agent_name]
389
+
390
+ return {
391
+ "command": config.get("command", ""),
392
+ "args": config.get("args", []),
393
+ "timeout": config.get("timeout", 300),
394
+ "max_retries": config.get("max_retries", 3),
395
+ "enabled": config.get("enabled", False),
396
+ }
src/delegation_mcp/ui/config_tab.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration tab for Gradio UI - manage agents and routing rules."""
2
+
3
+ try:
4
+ import gradio as gr
5
+ GRADIO_AVAILABLE = True
6
+ except ImportError:
7
+ GRADIO_AVAILABLE = False
8
+ # Mock gr for type hinting if needed, or just handle availability check
9
+ gr = None # type: ignore
10
+
11
+ from pathlib import Path
12
+
13
+ from .config_manager import ConfigurationManager, AgentStatus
14
+
15
+
16
+ def create_config_tab(config_manager: ConfigurationManager | None = None) -> gr.Blocks | None:
17
+ """Create configuration tab for runtime agent and routing management.
18
+
19
+ Args:
20
+ config_manager: Optional ConfigurationManager instance (creates new if None)
21
+
22
+ Returns:
23
+ Gradio Blocks interface for configuration
24
+ """
25
+ if not GRADIO_AVAILABLE:
26
+ print("Error: Gradio is not installed. Please install with `pip install .[ui]`")
27
+ return None
28
+
29
+ if config_manager is None:
30
+ config_manager = ConfigurationManager()
31
+
32
+ with gr.Blocks() as config_tab:
33
+ gr.Markdown("""
34
+ # ⚙️ Agent Configuration
35
+
36
+ Configure which agents are active and how tasks are routed between them.
37
+ Changes take effect immediately after saving.
38
+ """)
39
+
40
+ # Status message for feedback
41
+ status_msg = gr.Markdown("", visible=False)
42
+
43
+ # Main layout
44
+ with gr.Row():
45
+ # Left column: Agent Management
46
+ with gr.Column(scale=1):
47
+ gr.Markdown("## 🤖 Available Agents")
48
+
49
+ # Primary orchestrator selection
50
+ agent_statuses = config_manager.get_agent_statuses()
51
+ agent_names = [status.name for status in agent_statuses if status.installed and status.enabled]
52
+
53
+ primary_dropdown = gr.Dropdown(
54
+ choices=agent_names,
55
+ value=config_manager.primary_orchestrator,
56
+ label="Primary Orchestrator",
57
+ info="Default agent when no routing rules match",
58
+ interactive=True,
59
+ )
60
+
61
+ gr.Markdown("### Agent Status")
62
+
63
+ # Create agent toggles and status displays
64
+ agent_components = {}
65
+ for status in agent_statuses:
66
+ with gr.Row():
67
+ with gr.Column(scale=2):
68
+ # Agent name and status
69
+ gr.Markdown(f"**{status.status_icon} {status.name}**")
70
+ gr.Markdown(f"*{status.status_text}*")
71
+
72
+ with gr.Column(scale=1):
73
+ # Toggle switch
74
+ toggle = gr.Checkbox(
75
+ value=status.enabled,
76
+ label="Enabled",
77
+ interactive=status.installed, # Only allow toggle if installed
78
+ elem_id=f"agent_{status.name}_toggle",
79
+ )
80
+ agent_components[status.name] = toggle
81
+
82
+ with gr.Column(scale=1):
83
+ # Capabilities info button
84
+ with gr.Accordion(f"ℹ️", open=False):
85
+ caps = config_manager.get_agent_capabilities(status.name)
86
+ gr.Markdown(f"""
87
+ **Command:** `{caps.get('command', 'N/A')}`
88
+
89
+ **Args:** {' '.join(caps.get('args', []))}
90
+
91
+ **Timeout:** {caps.get('timeout', 300)}s
92
+
93
+ **Max Retries:** {caps.get('max_retries', 3)}
94
+ """)
95
+
96
+ # Right column: Routing Rules
97
+ with gr.Column(scale=2):
98
+ gr.Markdown("## 🔀 Routing Rules")
99
+
100
+ # Rules editor
101
+ rules_yaml = config_manager.get_rules_yaml()
102
+ rules_editor = gr.TextArea(
103
+ value=rules_yaml,
104
+ label="Routing Rules (YAML)",
105
+ placeholder="Enter routing rules in YAML format...",
106
+ lines=15,
107
+ max_lines=25,
108
+ )
109
+
110
+ # Validation message
111
+ validation_msg = gr.Markdown("", visible=False)
112
+
113
+ # Buttons row
114
+ with gr.Row():
115
+ validate_btn = gr.Button("✓ Validate Rules", variant="secondary")
116
+ preview_btn = gr.Button("👁️ Preview", variant="secondary")
117
+
118
+ # Preview panel
119
+ with gr.Accordion("📋 Routing Preview", open=False) as preview_accordion:
120
+ preview_text = gr.Markdown("")
121
+
122
+ # Bottom buttons
123
+ with gr.Row():
124
+ save_btn = gr.Button("💾 Save Configuration", variant="primary", size="lg")
125
+ reset_btn = gr.Button("🔄 Reset to Defaults", variant="stop")
126
+
127
+ # === Event Handlers ===
128
+
129
+ def update_agent_toggle(agent_name: str, enabled: bool):
130
+ """Handle agent toggle."""
131
+ success, message = config_manager.toggle_agent(agent_name, enabled)
132
+
133
+ if not success:
134
+ # Revert the toggle
135
+ return {
136
+ status_msg: gr.Markdown(
137
+ f"❌ **Error:** {message}",
138
+ visible=True
139
+ ),
140
+ agent_components[agent_name]: gr.Checkbox(value=not enabled),
141
+ }
142
+
143
+ # Update primary dropdown choices if needed
144
+ agent_statuses = config_manager.get_agent_statuses()
145
+ active_agents = [s.name for s in agent_statuses if s.installed and s.enabled]
146
+
147
+ return {
148
+ status_msg: gr.Markdown(
149
+ f"✅ {message}",
150
+ visible=True
151
+ ),
152
+ primary_dropdown: gr.Dropdown(choices=active_agents),
153
+ }
154
+
155
+ def set_primary_orchestrator(agent_name: str):
156
+ """Handle primary orchestrator selection."""
157
+ success, message = config_manager.set_primary_orchestrator(agent_name)
158
+
159
+ if not success:
160
+ return {
161
+ status_msg: gr.Markdown(
162
+ f"❌ **Error:** {message}",
163
+ visible=True
164
+ ),
165
+ primary_dropdown: gr.Dropdown(value=config_manager.primary_orchestrator),
166
+ }
167
+
168
+ return status_msg.update(
169
+ value=f"✅ {message}",
170
+ visible=True
171
+ )
172
+
173
+ def validate_rules(yaml_text: str):
174
+ """Validate routing rules."""
175
+ is_valid, message, _ = config_manager.validate_routing_rules(yaml_text)
176
+
177
+ if is_valid:
178
+ return validation_msg.update(
179
+ value=f"✅ {message}",
180
+ visible=True
181
+ )
182
+ else:
183
+ return validation_msg.update(
184
+ value=f"❌ **Validation Error:** {message}",
185
+ visible=True
186
+ )
187
+
188
+ def preview_rules(yaml_text: str):
189
+ """Generate routing rules preview."""
190
+ is_valid, message, parsed_rules = config_manager.validate_routing_rules(yaml_text)
191
+
192
+ if not is_valid:
193
+ return {
194
+ preview_text: gr.Markdown(f"❌ **Cannot preview:** {message}"),
195
+ preview_accordion: gr.Accordion(open=True),
196
+ }
197
+
198
+ preview = config_manager.preview_routing_rules(parsed_rules or [])
199
+
200
+ return {
201
+ preview_text: gr.Markdown(preview),
202
+ preview_accordion: gr.Accordion(open=True),
203
+ }
204
+
205
+ def save_configuration(yaml_text: str):
206
+ """Save all configuration changes."""
207
+ # Validate rules first
208
+ is_valid, message, _ = config_manager.validate_routing_rules(yaml_text)
209
+ if not is_valid:
210
+ return status_msg.update(
211
+ value=f"❌ **Cannot save:** {message}",
212
+ visible=True
213
+ )
214
+
215
+ # Save configuration
216
+ success, message = config_manager.save_configurations(rules_yaml=yaml_text)
217
+
218
+ if success:
219
+ return status_msg.update(
220
+ value=f"✅ **Configuration saved successfully!** Changes are now active.",
221
+ visible=True
222
+ )
223
+ else:
224
+ return status_msg.update(
225
+ value=f"❌ **Save failed:** {message}",
226
+ visible=True
227
+ )
228
+
229
+ def reset_configuration():
230
+ """Reset configuration to defaults."""
231
+ success, message = config_manager.reset_to_defaults()
232
+
233
+ if not success:
234
+ return {
235
+ status_msg: gr.Markdown(
236
+ f"❌ **Reset failed:** {message}",
237
+ visible=True
238
+ ),
239
+ }
240
+
241
+ # Reload UI with defaults
242
+ agent_statuses = config_manager.get_agent_statuses()
243
+ active_agents = [s.name for s in agent_statuses if s.installed and s.enabled]
244
+ rules_yaml = config_manager.get_rules_yaml()
245
+
246
+ # Build update dictionary
247
+ updates = {
248
+ status_msg: gr.Markdown(
249
+ f"✅ {message}",
250
+ visible=True
251
+ ),
252
+ primary_dropdown: gr.Dropdown(
253
+ value=config_manager.primary_orchestrator,
254
+ choices=active_agents,
255
+ ),
256
+ rules_editor: gr.TextArea(value=rules_yaml),
257
+ validation_msg: gr.Markdown(visible=False),
258
+ preview_text: gr.Markdown(""),
259
+ }
260
+
261
+ # Update agent toggles
262
+ for status in agent_statuses:
263
+ updates[agent_components[status.name]] = gr.Checkbox(value=status.enabled)
264
+
265
+ return updates
266
+
267
+ # Wire up event handlers
268
+ primary_dropdown.change(
269
+ fn=set_primary_orchestrator,
270
+ inputs=[primary_dropdown],
271
+ outputs=[status_msg],
272
+ )
273
+
274
+ # Wire up agent toggles
275
+ for agent_name, toggle in agent_components.items():
276
+ toggle.change(
277
+ fn=lambda enabled, name=agent_name: update_agent_toggle(name, enabled),
278
+ inputs=[toggle],
279
+ outputs=[status_msg, agent_components[agent_name], primary_dropdown],
280
+ )
281
+
282
+ validate_btn.click(
283
+ fn=validate_rules,
284
+ inputs=[rules_editor],
285
+ outputs=[validation_msg],
286
+ )
287
+
288
+ preview_btn.click(
289
+ fn=preview_rules,
290
+ inputs=[rules_editor],
291
+ outputs=[preview_text, preview_accordion],
292
+ )
293
+
294
+ save_btn.click(
295
+ fn=save_configuration,
296
+ inputs=[rules_editor],
297
+ outputs=[status_msg],
298
+ )
299
+
300
+ # Reset button
301
+ all_outputs = [
302
+ status_msg,
303
+ primary_dropdown,
304
+ rules_editor,
305
+ validation_msg,
306
+ preview_text,
307
+ ] + list(agent_components.values())
308
+
309
+ reset_btn.click(
310
+ fn=reset_configuration,
311
+ outputs=all_outputs,
312
+ )
313
+
314
+ # Add helpful tooltips
315
+ gr.Markdown("""
316
+ ---
317
+ ### 💡 Tips
318
+
319
+ - **Primary Orchestrator**: Handles all tasks unless a routing rule matches
320
+ - **Routing Rules**: Use regex patterns to delegate specific tasks to appropriate agents
321
+ - **Pattern Examples**:
322
+ - `security|audit|vulnerability` - Security-related tasks
323
+ - `refactor|redesign` - Code refactoring
324
+ - `test|pytest|jest` - Testing tasks
325
+ - **Priority**: Higher priority rules are evaluated first (0-10)
326
+ - **Agent Status**:
327
+ - 🟢 Active - Enabled and installed
328
+ - 🔴 Disabled - Toggled off
329
+ - ⚠️ Not Installed - Command not found in PATH
330
+
331
+ Changes take effect immediately. See [GitHub](https://github.com/carlosduplar/multi-agent-mcp) for docs.
332
+ """)
333
+
334
+ return config_tab
src/delegation_mcp/workflow.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Workflow engine for multi-agent collaboration."""
2
+
3
+ import re
4
+ import logging
5
+ from typing import Any
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from pydantic import BaseModel, Field
9
+ import yaml
10
+
11
+ from .orchestrator import OrchestratorRegistry
12
+ from .logging_config import delegation_logger
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class WorkflowStep(BaseModel):
19
+ """A single step in a workflow."""
20
+
21
+ id: str
22
+ agent: str
23
+ task: str
24
+ output: str | None = None # Variable name to store output
25
+ condition: str | None = None # Conditional execution
26
+ description: str = ""
27
+
28
+
29
+ class WorkflowDefinition(BaseModel):
30
+ """Definition of a multi-agent workflow."""
31
+
32
+ name: str
33
+ description: str = ""
34
+ steps: list[WorkflowStep]
35
+ metadata: dict[str, Any] = Field(default_factory=dict)
36
+
37
+ @classmethod
38
+ def from_yaml(cls, path: Path) -> "WorkflowDefinition":
39
+ """Load workflow from YAML file."""
40
+ with open(path) as f:
41
+ data = yaml.safe_load(f)
42
+ return cls(**data)
43
+
44
+ def to_yaml(self, path: Path) -> None:
45
+ """Save workflow to YAML file."""
46
+ with open(path, "w") as f:
47
+ yaml.dump(self.model_dump(), f, default_flow_style=False)
48
+
49
+
50
+ class WorkflowContext:
51
+ """Context for workflow execution with variable storage."""
52
+
53
+ def __init__(self):
54
+ self.variables: dict[str, Any] = {}
55
+ self.history: list[dict[str, Any]] = []
56
+
57
+ def set(self, name: str, value: Any) -> None:
58
+ """Set a variable in context."""
59
+ self.variables[name] = value
60
+
61
+ def get(self, name: str, default: Any = None) -> Any:
62
+ """Get a variable from context."""
63
+ return self.variables.get(name, default)
64
+
65
+ def interpolate(self, template: str) -> str:
66
+ """
67
+ Interpolate variables in template string with safe escaping.
68
+
69
+ Supports: {{ variable_name }}
70
+
71
+ Note: Values are NOT shell-escaped to allow for flexible use cases.
72
+ If the interpolated string will be passed to shell commands, the caller
73
+ must handle escaping appropriately.
74
+ """
75
+ def replace_var(match):
76
+ var_name = match.group(1).strip()
77
+
78
+ # Validate variable name (only allow alphanumeric and underscore)
79
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', var_name):
80
+ logger.warning(f"Invalid variable name in template: {var_name}")
81
+ return f"{{{{ {var_name} }}}}"
82
+
83
+ value = self.get(var_name)
84
+ if value is None:
85
+ return f"{{{{ {var_name} }}}}"
86
+
87
+ # Convert to string and sanitize dangerous characters
88
+ str_value = str(value)
89
+
90
+ # Log warning if potentially dangerous characters detected
91
+ dangerous_chars = [';', '&&', '||', '`', '$', '|', '>', '<', '\n', '\r']
92
+ if any(char in str_value for char in dangerous_chars):
93
+ logger.warning(f"Variable '{var_name}' contains potentially dangerous characters: {str_value[:50]}")
94
+
95
+ return str_value
96
+
97
+ return re.sub(r'\{\{\s*([^}]+)\s*\}\}', replace_var, template)
98
+
99
+ def evaluate_condition(self, condition: str) -> bool:
100
+ """
101
+ Evaluate a simple condition.
102
+
103
+ Supports:
104
+ - {{ var_name }}: Check if variable exists and is truthy
105
+ - {{ var_name | length > 0 }}: Check list/string length
106
+ """
107
+ if not condition:
108
+ return True
109
+
110
+ # Extract variable name
111
+ var_match = re.search(r'\{\{\s*([^}|]+)', condition)
112
+ if not var_match:
113
+ return True
114
+
115
+ var_name = var_match.group(1).strip()
116
+ value = self.get(var_name)
117
+
118
+ # Check for length filter
119
+ if '| length' in condition:
120
+ if isinstance(value, (list, str, dict)):
121
+ length = len(value)
122
+ # Extract comparison - with null checks to prevent ReDoS
123
+ if '>' in condition:
124
+ match = re.search(r'>\s*(\d+)', condition)
125
+ if not match:
126
+ logger.warning(f"Invalid condition format: {condition}")
127
+ return False
128
+ threshold = int(match.group(1))
129
+ return length > threshold
130
+ elif '<' in condition:
131
+ match = re.search(r'<\s*(\d+)', condition)
132
+ if not match:
133
+ logger.warning(f"Invalid condition format: {condition}")
134
+ return False
135
+ threshold = int(match.group(1))
136
+ return length < threshold
137
+ elif '==' in condition or '=' in condition:
138
+ match = re.search(r'==?\s*(\d+)', condition)
139
+ if not match:
140
+ logger.warning(f"Invalid condition format: {condition}")
141
+ return False
142
+ threshold = int(match.group(1))
143
+ return length == threshold
144
+ return False
145
+
146
+ # Default: check truthiness
147
+ return bool(value)
148
+
149
+ def add_to_history(self, step_id: str, result: dict[str, Any]) -> None:
150
+ """Add step result to history."""
151
+ self.history.append({
152
+ "step_id": step_id,
153
+ "timestamp": datetime.now().isoformat(),
154
+ **result
155
+ })
156
+
157
+
158
+ class WorkflowResult(BaseModel):
159
+ """Result of workflow execution."""
160
+
161
+ workflow_name: str
162
+ success: bool
163
+ steps_completed: int
164
+ total_steps: int
165
+ duration: float
166
+ outputs: dict[str, Any]
167
+ errors: list[str] = Field(default_factory=list)
168
+
169
+
170
+ class WorkflowEngine:
171
+ """Engine for executing multi-agent workflows."""
172
+
173
+ def __init__(self, registry: OrchestratorRegistry):
174
+ self.registry = registry
175
+
176
+ async def execute(
177
+ self,
178
+ workflow: WorkflowDefinition,
179
+ initial_context: dict[str, Any] | None = None
180
+ ) -> WorkflowResult:
181
+ """
182
+ Execute a workflow.
183
+
184
+ Args:
185
+ workflow: Workflow definition
186
+ initial_context: Initial variables for context
187
+
188
+ Returns:
189
+ WorkflowResult with execution details
190
+ """
191
+ logger.info(f"Starting workflow: {workflow.name}")
192
+ start = datetime.now()
193
+
194
+ # Initialize context
195
+ context = WorkflowContext()
196
+ if initial_context:
197
+ for key, value in initial_context.items():
198
+ context.set(key, value)
199
+
200
+ steps_completed = 0
201
+ errors = []
202
+
203
+ # Execute steps sequentially
204
+ for step in workflow.steps:
205
+ logger.info(f"Executing step: {step.id} (agent: {step.agent})")
206
+
207
+ # Check condition
208
+ if step.condition and not context.evaluate_condition(step.condition):
209
+ logger.info(f"Skipping step {step.id}: condition not met")
210
+ continue
211
+
212
+ # Interpolate task with context variables
213
+ task = context.interpolate(step.task)
214
+ logger.debug(f"Task after interpolation: {task}")
215
+
216
+ # Execute step
217
+ try:
218
+ stdout, stderr, returncode = await self.registry.execute(
219
+ step.agent,
220
+ task
221
+ )
222
+
223
+ success = returncode == 0
224
+
225
+ if success:
226
+ steps_completed += 1
227
+
228
+ # Store output in context
229
+ if step.output:
230
+ # Parse output - for now just store stdout
231
+ context.set(step.output, stdout.strip())
232
+ logger.info(f"Stored output in variable: {step.output}")
233
+
234
+ # Add to history
235
+ context.add_to_history(step.id, {
236
+ "agent": step.agent,
237
+ "success": True,
238
+ "output": stdout,
239
+ })
240
+ else:
241
+ error_msg = f"Step {step.id} failed: {stderr}"
242
+ logger.error(error_msg)
243
+ errors.append(error_msg)
244
+
245
+ context.add_to_history(step.id, {
246
+ "agent": step.agent,
247
+ "success": False,
248
+ "error": stderr,
249
+ })
250
+
251
+ # Stop on first error (can be made configurable)
252
+ break
253
+
254
+ except Exception as e:
255
+ error_msg = f"Step {step.id} error: {str(e)}"
256
+ logger.error(error_msg)
257
+ errors.append(error_msg)
258
+
259
+ context.add_to_history(step.id, {
260
+ "agent": step.agent,
261
+ "success": False,
262
+ "error": str(e),
263
+ })
264
+
265
+ # Stop on error
266
+ break
267
+
268
+ duration = (datetime.now() - start).total_seconds()
269
+
270
+ result = WorkflowResult(
271
+ workflow_name=workflow.name,
272
+ success=steps_completed == len(workflow.steps) and not errors,
273
+ steps_completed=steps_completed,
274
+ total_steps=len(workflow.steps),
275
+ duration=duration,
276
+ outputs=context.variables,
277
+ errors=errors,
278
+ )
279
+
280
+ logger.info(
281
+ f"Workflow {workflow.name} completed: "
282
+ f"{steps_completed}/{len(workflow.steps)} steps in {duration:.2f}s"
283
+ )
284
+
285
+ return result
286
+
287
+ def load_workflow(self, path: Path) -> WorkflowDefinition:
288
+ """Load workflow from file."""
289
+ return WorkflowDefinition.from_yaml(path)
290
+
291
+ def list_workflows(self, directory: Path) -> list[WorkflowDefinition]:
292
+ """List all workflows in a directory."""
293
+ workflows = []
294
+ for yaml_file in directory.glob("*.yaml"):
295
+ try:
296
+ workflow = self.load_workflow(yaml_file)
297
+ workflows.append(workflow)
298
+ except Exception as e:
299
+ logger.warning(f"Failed to load workflow {yaml_file}: {e}")
300
+ return workflows
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for delegation MCP server."""
tests/test_agent_discovery.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agent discovery module."""
2
+
3
+ import asyncio
4
+ import json
5
+ import pytest
6
+ from pathlib import Path
7
+ import tempfile
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
+
10
+ from delegation_mcp.agent_discovery import AgentDiscovery, AgentMetadata
11
+
12
+
13
+ @pytest.fixture
14
+ def temp_cache_file():
15
+ """Create a temporary cache file with correct structure."""
16
+ with tempfile.TemporaryDirectory() as tmpdir:
17
+ home = Path(tmpdir)
18
+ cache_dir = home / ".cache" / "delegation-mcp"
19
+ cache_dir.mkdir(parents=True, exist_ok=True)
20
+ cache_file = cache_dir / "test_cache.json"
21
+ yield cache_file
22
+
23
+
24
+ @pytest.fixture
25
+ def discovery(temp_cache_file):
26
+ """Create an AgentDiscovery instance with temporary cache."""
27
+ # Patch Path.home() to return the temp directory so validation passes
28
+ # temp_cache_file is inside temp_home/.cache/delegation-mcp
29
+ temp_home = temp_cache_file.parent.parent.parent
30
+
31
+ with patch("pathlib.Path.home", return_value=temp_home):
32
+ # We need to mock the cache_dir construction in __init__ to match
33
+ with patch("delegation_mcp.agent_discovery.Path.home", return_value=temp_home):
34
+ return AgentDiscovery(cache_file=temp_cache_file)
35
+
36
+
37
+ def test_agent_metadata_creation():
38
+ """Test creating agent metadata."""
39
+ metadata = AgentMetadata(
40
+ name="claude",
41
+ command="claude",
42
+ version="1.0.0",
43
+ available=True,
44
+ path="/usr/local/bin/claude",
45
+ capabilities=["reasoning", "code_generation"],
46
+ )
47
+
48
+ assert metadata.name == "claude"
49
+ assert metadata.command == "claude"
50
+ assert metadata.version == "1.0.0"
51
+ assert metadata.available is True
52
+ assert metadata.path == "/usr/local/bin/claude"
53
+ assert "reasoning" in metadata.capabilities
54
+
55
+
56
+ def test_discovery_initialization(discovery, temp_cache_file):
57
+ """Test AgentDiscovery initialization."""
58
+ assert discovery.cache_file == temp_cache_file
59
+ assert isinstance(discovery._discovered_agents, dict)
60
+ assert len(AgentDiscovery.KNOWN_AGENTS) > 0
61
+
62
+
63
+ def test_cache_save_and_load(discovery, temp_cache_file):
64
+ """Test saving and loading discovery cache."""
65
+ # Add some test data
66
+ discovery._discovered_agents["test_agent"] = AgentMetadata(
67
+ name="test_agent",
68
+ command="test",
69
+ version="1.0.0",
70
+ available=True,
71
+ path="/usr/bin/test",
72
+ )
73
+
74
+ # Save cache
75
+ discovery._save_cache()
76
+
77
+ # Verify file exists
78
+ assert temp_cache_file.exists()
79
+
80
+ # Load cache in new instance
81
+ # We need to patch here too for the new instance
82
+ temp_home = temp_cache_file.parent.parent.parent
83
+ with patch("pathlib.Path.home", return_value=temp_home):
84
+ with patch("delegation_mcp.agent_discovery.Path.home", return_value=temp_home):
85
+ new_discovery = AgentDiscovery(cache_file=temp_cache_file)
86
+ assert "test_agent" in new_discovery._discovered_agents
87
+ assert new_discovery._discovered_agents["test_agent"].name == "test_agent"
88
+ assert new_discovery._discovered_agents["test_agent"].version == "1.0.0"
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_resolve_command_path(discovery):
93
+ """Test resolving command paths."""
94
+ # Mock shutil.which to return a path
95
+ with patch("shutil.which", return_value="/usr/bin/python3"):
96
+ path = discovery._resolve_command_path("python3")
97
+ assert path == "/usr/bin/python3"
98
+
99
+ # Test with command not found
100
+ with patch("shutil.which", return_value=None):
101
+ path = discovery._resolve_command_path("nonexistent_command")
102
+ assert path is None
103
+
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_verify_agent_success(discovery):
107
+ """Test successful agent verification."""
108
+ # Mock subprocess for successful verification
109
+ mock_process = AsyncMock()
110
+ mock_process.returncode = 0
111
+ mock_process.communicate = AsyncMock(
112
+ return_value=(b"claude version 1.0.0\n", b"")
113
+ )
114
+ mock_process.stdin = MagicMock() # Mock stdin as non-async
115
+
116
+ with patch("asyncio.create_subprocess_exec", return_value=mock_process):
117
+ available, version, error = await discovery._verify_agent(
118
+ "claude", "claude", "--version"
119
+ )
120
+
121
+ assert available is True
122
+ assert "1.0.0" in version
123
+ assert error is None
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_verify_agent_failure(discovery):
128
+ """Test failed agent verification."""
129
+ # Mock subprocess for failed verification
130
+ mock_process = AsyncMock()
131
+ mock_process.returncode = 1
132
+ mock_process.communicate = AsyncMock(
133
+ return_value=(b"", b"command not found\n")
134
+ )
135
+ mock_process.stdin = MagicMock() # Mock stdin as non-async
136
+
137
+ with patch("asyncio.create_subprocess_exec", return_value=mock_process):
138
+ # First call will fail, second with --help should also fail
139
+ available, version, error = await discovery._verify_agent(
140
+ "nonexistent", "nonexistent", "--version"
141
+ )
142
+
143
+ # The function retries with --help, so we need to mock that too
144
+ assert available is False or error is not None
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_verify_agent_timeout(discovery):
149
+ """Test agent verification timeout."""
150
+ # Mock subprocess that times out
151
+ mock_process = AsyncMock()
152
+ mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
153
+ mock_process.stdin = MagicMock() # Mock stdin as non-async
154
+
155
+ with patch("asyncio.create_subprocess_exec", return_value=mock_process):
156
+ available, version, error = await discovery._verify_agent(
157
+ "slow_agent", "slow_agent", "--version"
158
+ )
159
+
160
+ assert available is False
161
+ assert version is None
162
+ assert "timed out" in error.lower()
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_verify_agent_not_found(discovery):
167
+ """Test agent verification when command not found."""
168
+ with patch(
169
+ "asyncio.create_subprocess_exec",
170
+ side_effect=FileNotFoundError("Command not found"),
171
+ ):
172
+ available, version, error = await discovery._verify_agent(
173
+ "missing", "missing", "--version"
174
+ )
175
+
176
+ assert available is False
177
+ assert version is None
178
+ assert "not found" in error.lower()
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_discover_single_agent(discovery):
183
+ """Test discovering a single agent."""
184
+ config = {
185
+ "command": "python3",
186
+ "version_flag": "--version",
187
+ "capabilities": ["scripting", "general"],
188
+ }
189
+
190
+ # Mock shutil.which to return a path
191
+ with patch("shutil.which", return_value="/usr/bin/python3"):
192
+ # Mock verify_agent
193
+ with patch.object(
194
+ discovery,
195
+ "_verify_agent",
196
+ return_value=(True, "Python 3.9.0", None),
197
+ ):
198
+ metadata = await discovery._discover_single_agent("python", config)
199
+
200
+ assert metadata.name == "python"
201
+ assert metadata.available is True
202
+ assert metadata.version == "Python 3.9.0"
203
+ assert metadata.path == "/usr/bin/python3"
204
+ assert "scripting" in metadata.capabilities
205
+
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_discover_agents_with_cache(discovery, temp_cache_file):
209
+ """Test agent discovery with caching."""
210
+ # Add cached data
211
+ discovery._discovered_agents["cached_agent"] = AgentMetadata(
212
+ name="cached_agent",
213
+ command="cached",
214
+ version="1.0.0",
215
+ available=True,
216
+ )
217
+
218
+ # First call should use cache
219
+ result = await discovery.discover_agents(force_refresh=False)
220
+ assert "cached_agent" in result
221
+
222
+ # Force refresh should re-discover
223
+ with patch.object(discovery, "_discover_single_agent") as mock_discover:
224
+ mock_discover.return_value = AgentMetadata(
225
+ name="test",
226
+ command="test",
227
+ version="2.0.0",
228
+ available=True,
229
+ )
230
+ result = await discovery.discover_agents(force_refresh=True)
231
+ # Should have called discover for all known agents
232
+ assert mock_discover.called
233
+
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_discover_agents_parallel(discovery):
237
+ """Test that agent discovery runs in parallel."""
238
+ # Mock _discover_single_agent to track calls
239
+ call_count = 0
240
+
241
+ async def mock_discover(name, config):
242
+ nonlocal call_count
243
+ call_count += 1
244
+ await asyncio.sleep(0.01) # Simulate some work
245
+ return AgentMetadata(
246
+ name=name,
247
+ command=config["command"],
248
+ available=False,
249
+ error_message="Not found",
250
+ )
251
+
252
+ with patch.object(discovery, "_discover_single_agent", side_effect=mock_discover):
253
+ await discovery.discover_agents(force_refresh=True)
254
+
255
+ # Should have discovered multiple agents
256
+ assert call_count == len(AgentDiscovery.KNOWN_AGENTS)
257
+
258
+
259
+ def test_get_available_agents(discovery):
260
+ """Test getting list of available agents."""
261
+ discovery._discovered_agents = {
262
+ "agent1": AgentMetadata(name="agent1", command="a1", available=True),
263
+ "agent2": AgentMetadata(name="agent2", command="a2", available=False),
264
+ "agent3": AgentMetadata(name="agent3", command="a3", available=True),
265
+ }
266
+
267
+ available = discovery.get_available_agents()
268
+ assert len(available) == 2
269
+ assert all(agent.available for agent in available)
270
+
271
+
272
+ def test_get_unavailable_agents(discovery):
273
+ """Test getting list of unavailable agents."""
274
+ discovery._discovered_agents = {
275
+ "agent1": AgentMetadata(name="agent1", command="a1", available=True),
276
+ "agent2": AgentMetadata(name="agent2", command="a2", available=False),
277
+ "agent3": AgentMetadata(name="agent3", command="a3", available=False),
278
+ }
279
+
280
+ unavailable = discovery.get_unavailable_agents()
281
+ assert len(unavailable) == 2
282
+ assert all(not agent.available for agent in unavailable)
283
+
284
+
285
+ def test_is_agent_available(discovery):
286
+ """Test checking if specific agent is available."""
287
+ discovery._discovered_agents = {
288
+ "claude": AgentMetadata(name="claude", command="claude", available=True),
289
+ "gemini": AgentMetadata(name="gemini", command="gemini", available=False),
290
+ }
291
+
292
+ assert discovery.is_agent_available("claude") is True
293
+ assert discovery.is_agent_available("gemini") is False
294
+ assert discovery.is_agent_available("nonexistent") is False
295
+
296
+
297
+ def test_get_agent_metadata(discovery):
298
+ """Test getting metadata for specific agent."""
299
+ metadata = AgentMetadata(
300
+ name="claude",
301
+ command="claude",
302
+ version="1.0.0",
303
+ available=True,
304
+ )
305
+ discovery._discovered_agents["claude"] = metadata
306
+
307
+ result = discovery.get_agent_metadata("claude")
308
+ assert result == metadata
309
+
310
+ result = discovery.get_agent_metadata("nonexistent")
311
+ assert result is None
312
+
313
+
314
+ def test_get_discovery_summary(discovery):
315
+ """Test getting discovery summary."""
316
+ discovery._discovered_agents = {
317
+ "agent1": AgentMetadata(
318
+ name="agent1",
319
+ command="a1",
320
+ version="1.0.0",
321
+ available=True,
322
+ path="/usr/bin/a1",
323
+ ),
324
+ "agent2": AgentMetadata(
325
+ name="agent2",
326
+ command="a2",
327
+ available=False,
328
+ error_message="Not found",
329
+ ),
330
+ }
331
+
332
+ summary = discovery.get_discovery_summary()
333
+
334
+ assert summary["total_agents"] == 2
335
+ assert summary["available"] == 1
336
+ assert summary["unavailable"] == 1
337
+ assert len(summary["available_agents"]) == 1
338
+ assert len(summary["unavailable_agents"]) == 1
339
+ assert summary["available_agents"][0]["name"] == "agent1"
340
+ assert summary["unavailable_agents"][0]["name"] == "agent2"
341
+ assert "system_info" in summary
342
+
343
+
344
+ def test_clear_cache(discovery, temp_cache_file):
345
+ """Test clearing the discovery cache."""
346
+ # Add some data and save
347
+ discovery._discovered_agents["test"] = AgentMetadata(
348
+ name="test",
349
+ command="test",
350
+ available=True,
351
+ )
352
+ discovery._save_cache()
353
+
354
+ assert temp_cache_file.exists()
355
+ assert len(discovery._discovered_agents) > 0
356
+
357
+ # Clear cache
358
+ discovery.clear_cache()
359
+
360
+ assert len(discovery._discovered_agents) == 0
361
+ assert not temp_cache_file.exists()
362
+
363
+
364
+ def test_get_install_message(discovery):
365
+ """Test getting installation instructions."""
366
+ message = discovery._get_install_message("claude")
367
+ assert "Claude Code" in message
368
+ assert "install" in message.lower()
369
+
370
+ message = discovery._get_install_message("gemini")
371
+ assert "Gemini" in message
372
+ assert "npm install" in message.lower()
373
+
374
+ message = discovery._get_install_message("unknown_agent")
375
+ assert "unknown_agent" in message
376
+ assert "documentation" in message.lower()
377
+
378
+
379
+ @pytest.mark.asyncio
380
+ async def test_discover_agents_convenience_function():
381
+ """Test the convenience function for discovering agents."""
382
+ from delegation_mcp.agent_discovery import discover_agents
383
+
384
+ with patch("delegation_mcp.agent_discovery.AgentDiscovery") as mock_class:
385
+ mock_instance = MagicMock()
386
+ mock_instance.discover_agents = AsyncMock(return_value={})
387
+ mock_class.return_value = mock_instance
388
+
389
+ result = await discover_agents(force_refresh=True)
390
+
391
+ mock_instance.discover_agents.assert_called_once_with(force_refresh=True)
392
+
393
+
394
+ @pytest.mark.asyncio
395
+ async def test_discover_specific_agents(discovery):
396
+ """Test discovering only specific agents."""
397
+ agents_to_check = ["claude", "gemini"]
398
+
399
+ with patch.object(discovery, "_discover_single_agent") as mock_discover:
400
+ mock_discover.return_value = AgentMetadata(
401
+ name="test",
402
+ command="test",
403
+ available=False,
404
+ )
405
+
406
+ await discovery.discover_agents(
407
+ force_refresh=True,
408
+ agents_to_check=agents_to_check,
409
+ )
410
+
411
+ # Should only call discover for specified agents
412
+ assert mock_discover.call_count == len(agents_to_check)
413
+
414
+
415
+ def test_known_agents_structure():
416
+ """Test that KNOWN_AGENTS has expected structure."""
417
+ for name, config in AgentDiscovery.KNOWN_AGENTS.items():
418
+ assert "command" in config
419
+ assert "version_flag" in config
420
+ assert "capabilities" in config
421
+ assert isinstance(config["capabilities"], list)
422
+ assert len(config["capabilities"]) > 0
tests/test_config.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for configuration module."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ from delegation_mcp.config import (
8
+ DelegationConfig,
9
+ DelegationRule,
10
+ OrchestratorConfig,
11
+ ConfigValidationError,
12
+ )
13
+
14
+
15
+ def test_delegation_rule_creation():
16
+ """Test creating a delegation rule."""
17
+ rule = DelegationRule(
18
+ pattern="security|audit",
19
+ delegate_to="gemini",
20
+ priority=5,
21
+ requires_approval=False,
22
+ description="Security tasks",
23
+ )
24
+
25
+ assert rule.pattern == "security|audit"
26
+ assert rule.delegate_to == "gemini"
27
+ assert rule.priority == 5
28
+
29
+
30
+ def test_orchestrator_config_creation():
31
+ """Test creating orchestrator configuration."""
32
+ config = OrchestratorConfig(
33
+ name="claude",
34
+ command="claude",
35
+ enabled=True,
36
+ )
37
+
38
+ assert config.name == "claude"
39
+ assert config.command == "claude"
40
+ assert config.enabled is True
41
+ assert config.timeout == 300 # default
42
+
43
+
44
+ def test_delegation_config_find_rule():
45
+ """Test finding matching delegation rule."""
46
+ config = DelegationConfig(
47
+ orchestrator="claude",
48
+ rules=[
49
+ DelegationRule(
50
+ pattern="security|audit",
51
+ delegate_to="gemini",
52
+ priority=5,
53
+ ),
54
+ DelegationRule(
55
+ pattern="refactor",
56
+ delegate_to="aider",
57
+ priority=3,
58
+ ),
59
+ ],
60
+ )
61
+
62
+ # Should match security rule
63
+ rule = config.find_delegation_rule("Run a security audit")
64
+ assert rule is not None
65
+ assert rule.delegate_to == "gemini"
66
+
67
+ # Should match refactor rule
68
+ rule = config.find_delegation_rule("Refactor the code")
69
+ assert rule is not None
70
+ assert rule.delegate_to == "aider"
71
+
72
+ # Should not match any rule
73
+ rule = config.find_delegation_rule("Explain Python")
74
+ assert rule is None
75
+
76
+
77
+ def test_config_save_and_load():
78
+ """Test saving and loading configuration."""
79
+ with tempfile.TemporaryDirectory() as tmpdir:
80
+ config_path = Path(tmpdir) / "test_config.yaml"
81
+
82
+ # Create and save config
83
+ config = DelegationConfig(
84
+ orchestrator="claude",
85
+ rules=[
86
+ DelegationRule(
87
+ pattern="test",
88
+ delegate_to="gemini",
89
+ priority=1,
90
+ ),
91
+ ],
92
+ )
93
+ config.to_yaml(config_path)
94
+
95
+ # Load config
96
+ loaded_config = DelegationConfig.from_yaml(config_path, validate=False)
97
+
98
+ assert loaded_config.orchestrator == "claude"
99
+ assert len(loaded_config.rules) == 1
100
+ assert loaded_config.rules[0].pattern == "test"
101
+
102
+
103
+ # ============================================================================
104
+ # Validation Tests
105
+ # ============================================================================
106
+
107
+
108
+ def test_validate_minimum_agents_success():
109
+ """Test validation passes with 2 or more enabled agents."""
110
+ config = DelegationConfig(
111
+ orchestrator="claude",
112
+ orchestrators={
113
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
114
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
115
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=False),
116
+ },
117
+ rules=[],
118
+ )
119
+
120
+ # Should not raise exception
121
+ config.validate()
122
+
123
+
124
+ def test_validate_minimum_agents_failure_zero():
125
+ """Test validation fails with no enabled agents."""
126
+ config = DelegationConfig(
127
+ orchestrator="claude",
128
+ orchestrators={
129
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=False),
130
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=False),
131
+ },
132
+ rules=[],
133
+ )
134
+
135
+ with pytest.raises(ConfigValidationError) as exc_info:
136
+ config.validate()
137
+
138
+ assert "At least 2 agents must be enabled" in str(exc_info.value)
139
+ assert "only 0 are enabled" in str(exc_info.value)
140
+
141
+
142
+ def test_validate_minimum_agents_failure_one():
143
+ """Test validation fails with only one enabled agent."""
144
+ config = DelegationConfig(
145
+ orchestrator="claude",
146
+ orchestrators={
147
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
148
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=False),
149
+ },
150
+ rules=[],
151
+ )
152
+
153
+ with pytest.raises(ConfigValidationError) as exc_info:
154
+ config.validate()
155
+
156
+ assert "At least 2 agents must be enabled" in str(exc_info.value)
157
+ assert "only 1 is enabled" in str(exc_info.value)
158
+
159
+
160
+ def test_validate_regex_patterns_success():
161
+ """Test validation passes with valid regex patterns."""
162
+ config = DelegationConfig(
163
+ orchestrator="claude",
164
+ orchestrators={
165
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
166
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
167
+ },
168
+ rules=[
169
+ DelegationRule(pattern="security|audit", delegate_to="gemini", priority=5),
170
+ DelegationRule(pattern="refactor.*code", delegate_to="claude", priority=3),
171
+ DelegationRule(pattern="^test", delegate_to="gemini", priority=2),
172
+ ],
173
+ )
174
+
175
+ # Should not raise exception
176
+ config.validate()
177
+
178
+
179
+ def test_validate_regex_patterns_failure():
180
+ """Test validation fails with invalid regex patterns."""
181
+ config = DelegationConfig(
182
+ orchestrator="claude",
183
+ orchestrators={
184
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
185
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
186
+ },
187
+ rules=[
188
+ DelegationRule(
189
+ pattern="valid_pattern", delegate_to="gemini", priority=5
190
+ ),
191
+ DelegationRule(
192
+ pattern="[invalid(regex", delegate_to="claude", priority=3
193
+ ), # Invalid regex
194
+ DelegationRule(
195
+ pattern="(?P<incomplete", delegate_to="gemini", priority=2
196
+ ), # Invalid regex
197
+ ],
198
+ )
199
+
200
+ with pytest.raises(ConfigValidationError) as exc_info:
201
+ config.validate()
202
+
203
+ error_msg = str(exc_info.value)
204
+ assert "Invalid regex pattern" in error_msg
205
+ assert "[invalid(regex" in error_msg or "(?P<incomplete" in error_msg
206
+
207
+
208
+ def test_validate_agent_references_success():
209
+ """Test validation passes when all referenced agents exist."""
210
+ config = DelegationConfig(
211
+ orchestrator="claude",
212
+ orchestrators={
213
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
214
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
215
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=True),
216
+ },
217
+ rules=[
218
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
219
+ DelegationRule(pattern="refactor", delegate_to="claude", priority=3),
220
+ DelegationRule(pattern="git", delegate_to="aider", priority=4),
221
+ ],
222
+ )
223
+
224
+ # Should not raise exception
225
+ config.validate()
226
+
227
+
228
+
229
+
230
+
231
+ def test_validate_agent_references_failure_rule_target():
232
+ """Test validation fails when rule references non-existent agent."""
233
+ config = DelegationConfig(
234
+ orchestrator="claude",
235
+ orchestrators={
236
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
237
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
238
+ },
239
+ rules=[
240
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
241
+ DelegationRule(
242
+ pattern="git", delegate_to="nonexistent_agent", priority=3
243
+ ), # Invalid reference
244
+ ],
245
+ )
246
+
247
+ with pytest.raises(ConfigValidationError) as exc_info:
248
+ config.validate()
249
+
250
+ error_msg = str(exc_info.value)
251
+ assert "Target orchestrator 'nonexistent_agent' is not defined" in error_msg
252
+ assert "pattern: 'git'" in error_msg
253
+
254
+
255
+ def test_validate_no_circular_delegation_success():
256
+ """Test validation passes with normal delegation rules."""
257
+ config = DelegationConfig(
258
+ orchestrator="claude",
259
+ orchestrators={
260
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
261
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
262
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=True),
263
+ },
264
+ rules=[
265
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
266
+ DelegationRule(pattern="refactor", delegate_to="claude", priority=3),
267
+ DelegationRule(pattern="git", delegate_to="aider", priority=4),
268
+ ],
269
+ )
270
+
271
+ # Should not raise exception - multiple different patterns to different targets is fine
272
+ config.validate()
273
+
274
+
275
+ def test_validate_no_ambiguous_delegation():
276
+ """Test validation detects ambiguous delegation patterns."""
277
+ config = DelegationConfig(
278
+ orchestrator="claude",
279
+ orchestrators={
280
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
281
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
282
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=True),
283
+ },
284
+ rules=[
285
+ # Same pattern, same priority, different targets - ambiguous!
286
+ DelegationRule(pattern="security", delegate_to="claude", priority=5),
287
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
288
+ ],
289
+ )
290
+
291
+ with pytest.raises(ConfigValidationError) as exc_info:
292
+ config.validate()
293
+
294
+ error_msg = str(exc_info.value)
295
+ assert "ambiguous delegation" in error_msg.lower()
296
+ assert "security" in error_msg.lower()
297
+
298
+
299
+ def test_validate_same_pattern_different_priority_ok():
300
+ """Test validation allows same pattern with different priorities."""
301
+ config = DelegationConfig(
302
+ orchestrator="claude",
303
+ orchestrators={
304
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
305
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
306
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=True),
307
+ },
308
+ rules=[
309
+ # Same pattern but different priorities - OK (higher priority wins)
310
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
311
+ DelegationRule(pattern="security", delegate_to="claude", priority=3),
312
+ ],
313
+ )
314
+
315
+ # Should not raise exception - different priorities resolve ambiguity
316
+ config.validate()
317
+
318
+
319
+ def test_validate_multiple_errors():
320
+ """Test validation reports multiple errors at once."""
321
+ config = DelegationConfig(
322
+ orchestrator="claude",
323
+ orchestrators={
324
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=False),
325
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=False),
326
+ # Error 1: less than 2 enabled agents
327
+ },
328
+ rules=[
329
+ DelegationRule(
330
+ pattern="[invalid(", delegate_to="claude", priority=5
331
+ ), # Error 2: invalid regex
332
+ DelegationRule(
333
+ pattern="test", delegate_to="missing_agent", priority=3
334
+ ), # Error 3: invalid reference
335
+ ],
336
+ )
337
+
338
+ with pytest.raises(ConfigValidationError) as exc_info:
339
+ config.validate()
340
+
341
+ error_msg = str(exc_info.value)
342
+ # Should contain multiple errors
343
+ assert "At least 2 agents must be enabled" in error_msg
344
+ assert "Invalid regex pattern" in error_msg
345
+ assert "Target orchestrator 'missing_agent' is not defined" in error_msg
346
+
347
+
348
+ def test_from_yaml_with_validation():
349
+ """Test loading config from YAML with validation enabled."""
350
+ with tempfile.TemporaryDirectory() as tmpdir:
351
+ config_path = Path(tmpdir) / "test_config.yaml"
352
+
353
+ # Create invalid config
354
+ config = DelegationConfig(
355
+ orchestrator="claude",
356
+ orchestrators={
357
+ "claude": OrchestratorConfig(
358
+ name="claude", command="claude", enabled=False
359
+ ),
360
+ "gemini": OrchestratorConfig(
361
+ name="gemini", command="gemini", enabled=False
362
+ ),
363
+ },
364
+ rules=[],
365
+ )
366
+ config.to_yaml(config_path)
367
+
368
+ # Try to load with validation - should fail
369
+ with pytest.raises(ConfigValidationError):
370
+ DelegationConfig.from_yaml(config_path, validate=True)
371
+
372
+ # Load without validation - should succeed
373
+ loaded = DelegationConfig.from_yaml(config_path, validate=False)
374
+ assert loaded.orchestrator == "claude"
375
+
376
+
377
+ def test_validate_empty_orchestrators():
378
+ """Test validation fails with no orchestrators defined."""
379
+ config = DelegationConfig(
380
+ orchestrator="claude",
381
+ orchestrators={}, # Empty orchestrators
382
+ rules=[],
383
+ )
384
+
385
+ with pytest.raises(ConfigValidationError) as exc_info:
386
+ config.validate()
387
+
388
+ error_msg = str(exc_info.value)
389
+ assert "At least 2 agents must be enabled" in error_msg
390
+ assert "Primary orchestrator 'claude' is not defined" in error_msg
391
+
392
+
393
+ def test_validate_with_three_enabled_agents():
394
+ """Test validation passes with more than minimum enabled agents."""
395
+ config = DelegationConfig(
396
+ orchestrator="claude",
397
+ orchestrators={
398
+ "claude": OrchestratorConfig(name="claude", command="claude", enabled=True),
399
+ "gemini": OrchestratorConfig(name="gemini", command="gemini", enabled=True),
400
+ "aider": OrchestratorConfig(name="aider", command="aider", enabled=True),
401
+ "copilot": OrchestratorConfig(
402
+ name="copilot", command="copilot", enabled=False
403
+ ),
404
+ },
405
+ rules=[
406
+ DelegationRule(pattern="security", delegate_to="gemini", priority=5),
407
+ ],
408
+ )
409
+
410
+ # Should not raise exception
411
+ config.validate()
tests/test_config_ui.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test script for configuration UI - verifies imports and basic functionality."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # Test imports
7
+ try:
8
+ from src.delegation_mcp.ui import create_app, ConfigurationManager
9
+ print("✅ Successfully imported create_app and ConfigurationManager")
10
+ except ImportError as e:
11
+ print(f"❌ Import error: {e}")
12
+ sys.exit(1)
13
+
14
+ # Test ConfigurationManager initialization
15
+ try:
16
+ config_manager = ConfigurationManager()
17
+ print("✅ ConfigurationManager initialized successfully")
18
+ except Exception as e:
19
+ print(f"❌ ConfigurationManager initialization failed: {e}")
20
+ sys.exit(1)
21
+
22
+ # Test agent status retrieval
23
+ try:
24
+ statuses = config_manager.get_agent_statuses()
25
+ print(f"✅ Retrieved {len(statuses)} agent statuses")
26
+ for status in statuses:
27
+ print(f" {status.status_icon} {status.name}: {status.status_text}")
28
+ except Exception as e:
29
+ print(f"❌ Failed to get agent statuses: {e}")
30
+ sys.exit(1)
31
+
32
+ # Test YAML validation
33
+ try:
34
+ test_yaml = """
35
+ - pattern: test
36
+ delegate_to: claude
37
+ priority: 5
38
+ """
39
+ is_valid, message, rules = config_manager.validate_routing_rules(test_yaml)
40
+ if is_valid:
41
+ print(f"✅ YAML validation works: {message}")
42
+ else:
43
+ print(f"⚠️ YAML validation returned: {message}")
44
+ except Exception as e:
45
+ print(f"❌ YAML validation failed: {e}")
46
+ sys.exit(1)
47
+
48
+ # Test preview generation
49
+ try:
50
+ preview = config_manager.preview_routing_rules(rules if rules else [])
51
+ print(f"✅ Preview generation works (length: {len(preview)} chars)")
52
+ except Exception as e:
53
+ print(f"❌ Preview generation failed: {e}")
54
+ sys.exit(1)
55
+
56
+ # Test primary orchestrator setting
57
+ try:
58
+ current_primary = config_manager.primary_orchestrator
59
+ print(f"✅ Current primary orchestrator: {current_primary}")
60
+ except Exception as e:
61
+ print(f"❌ Failed to get primary orchestrator: {e}")
62
+ sys.exit(1)
63
+
64
+ # Test app creation (don't launch)
65
+ try:
66
+ app = create_app(config_manager)
67
+ print("✅ Gradio app created successfully")
68
+ except Exception as e:
69
+ print(f"❌ App creation failed: {e}")
70
+ sys.exit(1)
71
+
72
+ print("\n🎉 All tests passed! The configuration UI is ready to use.")
73
+ print("\nTo launch the UI, run:")
74
+ print(" python -m src.delegation_mcp.ui.app")
75
+ print("or use:")
76
+ print(" from src.delegation_mcp.ui import main")
77
+ print(" main()")