Spaces:
Sleeping
Sleeping
Commit ·
8b02e7c
0
Parent(s):
Initial public release: Multi-Agent MCP Delegation Server
Browse filesA 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
- .dockerignore +65 -0
- .env.example +22 -0
- .github/workflows/python-app.yml +39 -0
- .gitignore +61 -0
- Dockerfile +35 -0
- LICENSE +21 -0
- README.md +335 -0
- app.py +94 -0
- config/delegation_rules.yaml +141 -0
- config/orchestrators.yaml +25 -0
- examples/claude_code_usage.py +180 -0
- examples/example_workflow.py +72 -0
- install.py +10 -0
- install.sh +59 -0
- pyproject.toml +73 -0
- requirements.txt +12 -0
- src/delegation_mcp/__init__.py +15 -0
- src/delegation_mcp/adapters/__init__.py +15 -0
- src/delegation_mcp/adapters/aider.py +102 -0
- src/delegation_mcp/adapters/base.py +179 -0
- src/delegation_mcp/adapters/claude.py +69 -0
- src/delegation_mcp/adapters/copilot.py +70 -0
- src/delegation_mcp/adapters/gemini.py +79 -0
- src/delegation_mcp/agent_discovery.py +518 -0
- src/delegation_mcp/cli.py +358 -0
- src/delegation_mcp/config.py +253 -0
- src/delegation_mcp/delegation.py +461 -0
- src/delegation_mcp/gradio_monitor.py +329 -0
- src/delegation_mcp/installer/__init__.py +5 -0
- src/delegation_mcp/installer/agent_profiles.py +312 -0
- src/delegation_mcp/installer/agent_selector.py +164 -0
- src/delegation_mcp/installer/config_generator.py +324 -0
- src/delegation_mcp/installer/installer.py +268 -0
- src/delegation_mcp/installer/mcp_configurator.py +415 -0
- src/delegation_mcp/installer/system_instructions.py +195 -0
- src/delegation_mcp/installer/task_mapper.py +436 -0
- src/delegation_mcp/logging_config.py +159 -0
- src/delegation_mcp/orchestrator.py +250 -0
- src/delegation_mcp/retry.py +101 -0
- src/delegation_mcp/server.py +367 -0
- src/delegation_mcp/tool_discovery.py +243 -0
- src/delegation_mcp/ui/__init__.py +19 -0
- src/delegation_mcp/ui/app.py +216 -0
- src/delegation_mcp/ui/config_manager.py +396 -0
- src/delegation_mcp/ui/config_tab.py +334 -0
- src/delegation_mcp/workflow.py +300 -0
- tests/__init__.py +1 -0
- tests/test_agent_discovery.py +422 -0
- tests/test_config.py +411 -0
- 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 |
+
[]() []() []() []() []()
|
| 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 |
+
**[](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()")
|