Spaces:
Configuration error
Configuration error
clean up
Browse filesSigned-off-by: Jintao Zhang <zhangjintao9020@gmail.com>
- .github/ISSUE_TEMPLATE/bug_report.md +34 -0
- .github/ISSUE_TEMPLATE/feature_request.md +22 -0
- .github/PULL_REQUEST_TEMPLATE.md +33 -0
- .github/workflows/ci.yml +44 -0
- .pre-commit-config.yaml +15 -0
- .ruff.toml +9 -0
- CONTRIBUTING.md +64 -0
- Dockerfile +2 -5
- Makefile +23 -0
- README.md +50 -18
- app.py +0 -37
- docs/PROJECT_STRUCTURE.md +90 -0
- docs/QUICK_START.md +157 -0
- pyproject.toml +20 -1
- pytest.ini +6 -0
- requirements.txt +0 -9
- src/amcp/agent.py +133 -115
- src/amcp/agent_spec.py +26 -32
- src/amcp/chat.py +151 -91
- src/amcp/cli.py +90 -67
- src/amcp/config.py +20 -21
- src/amcp/mcp_client.py +47 -51
- src/amcp/readfile.py +1 -1
- src/amcp/tools.py +155 -259
- tests/__init__.py +1 -0
- tests/conftest.py +25 -0
- tests/test_agent_spec.py +7 -0
- tests/test_config.py +12 -0
- tests/test_tools.py +25 -0
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug Report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: '[BUG] '
|
| 5 |
+
labels: bug
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Bug Description
|
| 10 |
+
A clear and concise description of what the bug is.
|
| 11 |
+
|
| 12 |
+
## To Reproduce
|
| 13 |
+
Steps to reproduce the behavior:
|
| 14 |
+
1. Run command '...'
|
| 15 |
+
2. See error
|
| 16 |
+
|
| 17 |
+
## Expected Behavior
|
| 18 |
+
A clear and concise description of what you expected to happen.
|
| 19 |
+
|
| 20 |
+
## Actual Behavior
|
| 21 |
+
What actually happened.
|
| 22 |
+
|
| 23 |
+
## Environment
|
| 24 |
+
- OS: [e.g. Ubuntu 22.04]
|
| 25 |
+
- Python Version: [e.g. 3.11.5]
|
| 26 |
+
- AMCP Version: [e.g. 0.1.0]
|
| 27 |
+
|
| 28 |
+
## Additional Context
|
| 29 |
+
Add any other context about the problem here.
|
| 30 |
+
|
| 31 |
+
## Logs
|
| 32 |
+
```
|
| 33 |
+
Paste relevant logs here
|
| 34 |
+
```
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature Request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: '[FEATURE] '
|
| 5 |
+
labels: enhancement
|
| 6 |
+
assignees: ''
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Feature Description
|
| 10 |
+
A clear and concise description of what you want to happen.
|
| 11 |
+
|
| 12 |
+
## Use Case
|
| 13 |
+
Describe the use case or problem this feature would solve.
|
| 14 |
+
|
| 15 |
+
## Proposed Solution
|
| 16 |
+
Describe how you envision this feature working.
|
| 17 |
+
|
| 18 |
+
## Alternatives Considered
|
| 19 |
+
A clear and concise description of any alternative solutions or features you've considered.
|
| 20 |
+
|
| 21 |
+
## Additional Context
|
| 22 |
+
Add any other context or screenshots about the feature request here.
|
.github/PULL_REQUEST_TEMPLATE.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pull Request
|
| 2 |
+
|
| 3 |
+
## Description
|
| 4 |
+
<!-- Describe your changes in detail -->
|
| 5 |
+
|
| 6 |
+
## Type of Change
|
| 7 |
+
- [ ] Bug fix (non-breaking change which fixes an issue)
|
| 8 |
+
- [ ] New feature (non-breaking change which adds functionality)
|
| 9 |
+
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
| 10 |
+
- [ ] Documentation update
|
| 11 |
+
|
| 12 |
+
## Checklist
|
| 13 |
+
- [ ] My code follows the style guidelines of this project
|
| 14 |
+
- [ ] I have performed a self-review of my own code
|
| 15 |
+
- [ ] I have commented my code, particularly in hard-to-understand areas
|
| 16 |
+
- [ ] I have made corresponding changes to the documentation
|
| 17 |
+
- [ ] My changes generate no new warnings
|
| 18 |
+
- [ ] I have added tests that prove my fix is effective or that my feature works
|
| 19 |
+
- [ ] New and existing unit tests pass locally with my changes
|
| 20 |
+
- [ ] Any dependent changes have been merged and published
|
| 21 |
+
|
| 22 |
+
## Testing
|
| 23 |
+
<!-- Describe the tests you ran to verify your changes -->
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Example test commands
|
| 27 |
+
make test
|
| 28 |
+
make lint
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Related Issues
|
| 32 |
+
<!-- Link to related issues -->
|
| 33 |
+
Closes #
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
strategy:
|
| 13 |
+
matrix:
|
| 14 |
+
python-version: ["3.11", "3.12", "3.13"]
|
| 15 |
+
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v4
|
| 18 |
+
|
| 19 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 20 |
+
uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: ${{ matrix.python-version }}
|
| 23 |
+
|
| 24 |
+
- name: Install dependencies
|
| 25 |
+
run: |
|
| 26 |
+
python -m pip install --upgrade pip
|
| 27 |
+
pip install -e ".[dev]"
|
| 28 |
+
|
| 29 |
+
- name: Lint with ruff
|
| 30 |
+
run: ruff check src/
|
| 31 |
+
|
| 32 |
+
- name: Type check with mypy
|
| 33 |
+
run: mypy src/amcp --ignore-missing-imports
|
| 34 |
+
continue-on-error: true
|
| 35 |
+
|
| 36 |
+
- name: Test with pytest
|
| 37 |
+
run: pytest --cov --cov-report=xml
|
| 38 |
+
|
| 39 |
+
- name: Upload coverage
|
| 40 |
+
uses: codecov/codecov-action@v4
|
| 41 |
+
if: matrix.python-version == '3.11'
|
| 42 |
+
with:
|
| 43 |
+
file: ./coverage.xml
|
| 44 |
+
fail_ci_if_error: false
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 3 |
+
rev: v0.3.0
|
| 4 |
+
hooks:
|
| 5 |
+
- id: ruff
|
| 6 |
+
args: [--fix]
|
| 7 |
+
- id: ruff-format
|
| 8 |
+
|
| 9 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 10 |
+
rev: v4.5.0
|
| 11 |
+
hooks:
|
| 12 |
+
- id: trailing-whitespace
|
| 13 |
+
- id: end-of-file-fixer
|
| 14 |
+
- id: check-yaml
|
| 15 |
+
- id: check-added-large-files
|
.ruff.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ruff configuration
|
| 2 |
+
line-length = 120
|
| 3 |
+
|
| 4 |
+
[lint]
|
| 5 |
+
select = ["E", "F", "UP", "B", "SIM", "I"]
|
| 6 |
+
ignore = ["E501"] # Ignore line length for now
|
| 7 |
+
|
| 8 |
+
[lint.per-file-ignores]
|
| 9 |
+
"tests/*" = ["F401", "F811"]
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to AMCP
|
| 2 |
+
|
| 3 |
+
## Development Setup
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
# Clone the repository
|
| 7 |
+
git clone <repo-url>
|
| 8 |
+
cd amcp
|
| 9 |
+
|
| 10 |
+
# Install in editable mode with dev dependencies
|
| 11 |
+
pip install -e ".[dev]"
|
| 12 |
+
|
| 13 |
+
# Or using uv
|
| 14 |
+
uv pip install -e ".[dev]"
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
## Running Tests
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
# Run all tests
|
| 21 |
+
make test
|
| 22 |
+
|
| 23 |
+
# Run with coverage
|
| 24 |
+
make test-cov
|
| 25 |
+
|
| 26 |
+
# Run specific test
|
| 27 |
+
pytest tests/test_config.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Code Quality
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
# Lint code
|
| 34 |
+
make lint
|
| 35 |
+
|
| 36 |
+
# Format code
|
| 37 |
+
make format
|
| 38 |
+
|
| 39 |
+
# Type check
|
| 40 |
+
make type-check
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Pre-commit Hooks
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
pip install pre-commit
|
| 47 |
+
pre-commit install
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Project Structure
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
amcp/
|
| 54 |
+
├── src/amcp/ # Main package
|
| 55 |
+
│ ├── __init__.py
|
| 56 |
+
│ ├── cli.py # CLI entry point
|
| 57 |
+
│ ├── agent.py # Agent logic
|
| 58 |
+
│ ├── tools.py # Built-in tools
|
| 59 |
+
│ ├── config.py # Configuration
|
| 60 |
+
│ └── mcp_client.py # MCP integration
|
| 61 |
+
├── tests/ # Test suite
|
| 62 |
+
├── .github/workflows/ # CI/CD
|
| 63 |
+
└── pyproject.toml # Project metadata
|
| 64 |
+
```
|
Dockerfile
CHANGED
|
@@ -20,8 +20,5 @@ RUN mkdir -p /root/.config/amcp
|
|
| 20 |
# Set environment variables
|
| 21 |
ENV PYTHONUNBUFFERED=1
|
| 22 |
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# Run Gradio app
|
| 27 |
-
CMD ["python", "app.py"]
|
|
|
|
| 20 |
# Set environment variables
|
| 21 |
ENV PYTHONUNBUFFERED=1
|
| 22 |
|
| 23 |
+
# Default command
|
| 24 |
+
CMD ["amcp", "--help"]
|
|
|
|
|
|
|
|
|
Makefile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: install test lint format clean
|
| 2 |
+
|
| 3 |
+
install:
|
| 4 |
+
pip install -e ".[dev]"
|
| 5 |
+
|
| 6 |
+
test:
|
| 7 |
+
pytest
|
| 8 |
+
|
| 9 |
+
test-cov:
|
| 10 |
+
pytest --cov --cov-report=html --cov-report=term
|
| 11 |
+
|
| 12 |
+
lint:
|
| 13 |
+
ruff check src/ tests/
|
| 14 |
+
|
| 15 |
+
format:
|
| 16 |
+
ruff format src/ tests/
|
| 17 |
+
|
| 18 |
+
type-check:
|
| 19 |
+
mypy src/amcp --ignore-missing-imports
|
| 20 |
+
|
| 21 |
+
clean:
|
| 22 |
+
rm -rf build/ dist/ *.egg-info .pytest_cache .coverage htmlcov/
|
| 23 |
+
find . -type d -name __pycache__ -exec rm -rf {} +
|
README.md
CHANGED
|
@@ -1,21 +1,9 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: AMCP
|
| 3 |
-
emoji: 🤖
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.0.1
|
| 8 |
-
app_file: app.py
|
| 9 |
-
python_version: "3.11"
|
| 10 |
-
pinned: false
|
| 11 |
-
license: apache-2.0
|
| 12 |
-
tags:
|
| 13 |
-
- building-mcp-track-creative
|
| 14 |
-
- mcp-in-action-track-consumer
|
| 15 |
-
- mcp-in-action-track-creative
|
| 16 |
-
---
|
| 17 |
-
|
| 18 |
# AMCP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
tags:
|
| 20 |
- building-mcp-track-creative
|
| 21 |
- mcp-in-action-track-consumer
|
|
@@ -107,10 +95,54 @@ write_tool_enabled = true # Enable/disable built-in write_file tool
|
|
| 107 |
edit_tool_enabled = true # Enable/disable built-in edit_file tool
|
| 108 |
```
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
## Notes
|
| 111 |
- `rg` (ripgrep) must be installed and on PATH for the grep tool.
|
| 112 |
- MCP servers must be installed separately and runnable (stdio transport).
|
| 113 |
|
| 114 |
## License
|
| 115 |
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# AMCP
|
| 2 |
+
|
| 3 |
+
[](https://github.com/tao12345666333/amcp/actions)
|
| 4 |
+
[](https://www.python.org/downloads/)
|
| 5 |
+
[](https://opensource.org/licenses/Apache-2.0)
|
| 6 |
+
|
| 7 |
tags:
|
| 8 |
- building-mcp-track-creative
|
| 9 |
- mcp-in-action-track-consumer
|
|
|
|
| 95 |
edit_tool_enabled = true # Enable/disable built-in edit_file tool
|
| 96 |
```
|
| 97 |
|
| 98 |
+
## Development
|
| 99 |
+
|
| 100 |
+
### Setup Development Environment
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
# Clone the repository
|
| 104 |
+
git clone <repo-url>
|
| 105 |
+
cd AMCP
|
| 106 |
+
|
| 107 |
+
# Install with development dependencies
|
| 108 |
+
pip install -e ".[dev]"
|
| 109 |
+
|
| 110 |
+
# Install pre-commit hooks
|
| 111 |
+
pre-commit install
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Running Tests
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
# Run all tests
|
| 118 |
+
make test
|
| 119 |
+
|
| 120 |
+
# Run with coverage
|
| 121 |
+
make test-cov
|
| 122 |
+
|
| 123 |
+
# Run specific test
|
| 124 |
+
pytest tests/test_tools.py -v
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
### Code Quality
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
# Lint code
|
| 131 |
+
make lint
|
| 132 |
+
|
| 133 |
+
# Format code
|
| 134 |
+
make format
|
| 135 |
+
|
| 136 |
+
# Type check
|
| 137 |
+
make type-check
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development guidelines.
|
| 141 |
+
|
| 142 |
## Notes
|
| 143 |
- `rg` (ripgrep) must be installed and on PATH for the grep tool.
|
| 144 |
- MCP servers must be installed separately and runnable (stdio transport).
|
| 145 |
|
| 146 |
## License
|
| 147 |
|
| 148 |
+
Apache-2.0
|
app.py
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
import gradio as gr
|
| 2 |
-
import asyncio
|
| 3 |
-
from src.amcp.agent import Agent
|
| 4 |
-
from src.amcp.agent_spec import get_default_agent_spec
|
| 5 |
-
|
| 6 |
-
async def chat(message, history):
|
| 7 |
-
"""Process chat message with AMCP agent."""
|
| 8 |
-
agent = Agent(agent_spec=get_default_agent_spec())
|
| 9 |
-
|
| 10 |
-
try:
|
| 11 |
-
response = await agent.run(
|
| 12 |
-
user_input=message,
|
| 13 |
-
stream=False,
|
| 14 |
-
show_progress=False
|
| 15 |
-
)
|
| 16 |
-
return response
|
| 17 |
-
except Exception as e:
|
| 18 |
-
return f"Error: {str(e)}"
|
| 19 |
-
|
| 20 |
-
def chat_wrapper(message, history):
|
| 21 |
-
"""Sync wrapper for async chat function."""
|
| 22 |
-
return asyncio.run(chat(message, history))
|
| 23 |
-
|
| 24 |
-
# Create Gradio interface
|
| 25 |
-
demo = gr.ChatInterface(
|
| 26 |
-
fn=chat_wrapper,
|
| 27 |
-
title="AMCP - Agent CLI",
|
| 28 |
-
description="A Lego-style coding agent with built-in tools (read_file, grep, bash, think)",
|
| 29 |
-
examples=[
|
| 30 |
-
"List files in the current directory",
|
| 31 |
-
"Search for 'def' in Python files",
|
| 32 |
-
"What tools do you have available?"
|
| 33 |
-
]
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
if __name__ == "__main__":
|
| 37 |
-
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/PROJECT_STRUCTURE.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Structure
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
AMCP follows Python best practices with a clear separation of concerns:
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
AMCP/
|
| 9 |
+
├── src/amcp/ # Main package source code
|
| 10 |
+
│ ├── __init__.py # Package initialization
|
| 11 |
+
│ ├── __main__.py # Entry point for python -m amcp
|
| 12 |
+
│ ├── cli.py # CLI interface (Typer)
|
| 13 |
+
│ ├── agent.py # Agent orchestration logic
|
| 14 |
+
│ ├── agent_spec.py # Agent specification handling
|
| 15 |
+
│ ├── tools.py # Built-in tools (read, grep, bash, etc.)
|
| 16 |
+
│ ├── config.py # Configuration management
|
| 17 |
+
│ ├── mcp_client.py # MCP server integration
|
| 18 |
+
│ ├── chat.py # Chat/LLM interaction
|
| 19 |
+
│ └── readfile.py # File reading utilities
|
| 20 |
+
│
|
| 21 |
+
├── tests/ # Test suite
|
| 22 |
+
│ ├── __init__.py
|
| 23 |
+
│ ├── conftest.py # Pytest fixtures
|
| 24 |
+
│ ├── test_agent_spec.py
|
| 25 |
+
│ ├── test_config.py
|
| 26 |
+
│ └── test_tools.py
|
| 27 |
+
│
|
| 28 |
+
├── .github/
|
| 29 |
+
│ └── workflows/
|
| 30 |
+
│ └── ci.yml # GitHub Actions CI/CD
|
| 31 |
+
│
|
| 32 |
+
├── docs/ # Documentation
|
| 33 |
+
│ └── PROJECT_STRUCTURE.md
|
| 34 |
+
│
|
| 35 |
+
├── pyproject.toml # Project metadata & dependencies
|
| 36 |
+
├── pytest.ini # Pytest configuration
|
| 37 |
+
├── Makefile # Common development tasks
|
| 38 |
+
├── .pre-commit-config.yaml # Pre-commit hooks
|
| 39 |
+
├── .ruff.toml # Ruff linter configuration
|
| 40 |
+
├── .gitignore
|
| 41 |
+
├── README.md
|
| 42 |
+
├── CONTRIBUTING.md
|
| 43 |
+
├── CHANGELOG.md
|
| 44 |
+
└── Dockerfile
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Key Design Decisions
|
| 48 |
+
|
| 49 |
+
### 1. Source Layout (`src/` layout)
|
| 50 |
+
- Prevents accidental imports of uninstalled code
|
| 51 |
+
- Clear separation between source and tests
|
| 52 |
+
- Recommended by PyPA
|
| 53 |
+
|
| 54 |
+
### 2. Testing
|
| 55 |
+
- Uses pytest for testing framework
|
| 56 |
+
- Fixtures in `conftest.py` for reusability
|
| 57 |
+
- Coverage reporting with pytest-cov
|
| 58 |
+
- Target: >80% code coverage
|
| 59 |
+
|
| 60 |
+
### 3. Code Quality
|
| 61 |
+
- Ruff for linting and formatting
|
| 62 |
+
- Type hints encouraged (mypy for type checking)
|
| 63 |
+
- Pre-commit hooks for automated checks
|
| 64 |
+
|
| 65 |
+
### 4. CI/CD
|
| 66 |
+
- GitHub Actions for automated testing
|
| 67 |
+
- Matrix testing across Python 3.11, 3.12, 3.13
|
| 68 |
+
- Automated coverage reporting
|
| 69 |
+
|
| 70 |
+
### 5. Configuration
|
| 71 |
+
- pyproject.toml as single source of truth
|
| 72 |
+
- Tool configurations centralized
|
| 73 |
+
- Optional dependencies for development
|
| 74 |
+
|
| 75 |
+
## Development Workflow
|
| 76 |
+
|
| 77 |
+
1. **Setup**: `make install` or `pip install -e ".[dev]"`
|
| 78 |
+
2. **Test**: `make test` or `pytest`
|
| 79 |
+
3. **Lint**: `make lint` or `ruff check src/ tests/`
|
| 80 |
+
4. **Format**: `make format` or `ruff format src/ tests/`
|
| 81 |
+
5. **Coverage**: `make test-cov`
|
| 82 |
+
|
| 83 |
+
## Module Responsibilities
|
| 84 |
+
|
| 85 |
+
- **cli.py**: Command-line interface, argument parsing
|
| 86 |
+
- **agent.py**: Core agent logic, tool orchestration
|
| 87 |
+
- **tools.py**: Built-in tool implementations
|
| 88 |
+
- **config.py**: Configuration loading/saving
|
| 89 |
+
- **mcp_client.py**: MCP protocol communication
|
| 90 |
+
- **chat.py**: LLM interaction, streaming responses
|
docs/QUICK_START.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Start Guide
|
| 2 |
+
|
| 3 |
+
## For Contributors
|
| 4 |
+
|
| 5 |
+
### 1. Clone and Setup
|
| 6 |
+
```bash
|
| 7 |
+
git clone <repo-url>
|
| 8 |
+
cd AMCP
|
| 9 |
+
|
| 10 |
+
# Install with development dependencies
|
| 11 |
+
pip install -e ".[dev]"
|
| 12 |
+
|
| 13 |
+
# Or using uv (recommended)
|
| 14 |
+
uv pip install -e ".[dev]"
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
### 2. Install Pre-commit Hooks
|
| 18 |
+
```bash
|
| 19 |
+
pre-commit install
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 3. Run Tests
|
| 23 |
+
```bash
|
| 24 |
+
# Quick test
|
| 25 |
+
make test
|
| 26 |
+
|
| 27 |
+
# With coverage report
|
| 28 |
+
make test-cov
|
| 29 |
+
|
| 30 |
+
# Specific test file
|
| 31 |
+
pytest tests/test_tools.py -v
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 4. Code Quality Checks
|
| 35 |
+
```bash
|
| 36 |
+
# Lint code
|
| 37 |
+
make lint
|
| 38 |
+
|
| 39 |
+
# Auto-format code
|
| 40 |
+
make format
|
| 41 |
+
|
| 42 |
+
# Type check
|
| 43 |
+
make type-check
|
| 44 |
+
|
| 45 |
+
# Run all checks
|
| 46 |
+
make lint && make format && make test
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 5. Development Workflow
|
| 50 |
+
```bash
|
| 51 |
+
# 1. Create a feature branch
|
| 52 |
+
git checkout -b feature/my-feature
|
| 53 |
+
|
| 54 |
+
# 2. Make changes and test
|
| 55 |
+
make test
|
| 56 |
+
|
| 57 |
+
# 3. Format and lint
|
| 58 |
+
make format
|
| 59 |
+
make lint
|
| 60 |
+
|
| 61 |
+
# 4. Commit (pre-commit hooks will run automatically)
|
| 62 |
+
git add .
|
| 63 |
+
git commit -m "feat: add new feature"
|
| 64 |
+
|
| 65 |
+
# 5. Push and create PR
|
| 66 |
+
git push origin feature/my-feature
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## For Users
|
| 70 |
+
|
| 71 |
+
### Installation
|
| 72 |
+
```bash
|
| 73 |
+
pip install amcp
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Basic Usage
|
| 77 |
+
```bash
|
| 78 |
+
# Interactive mode
|
| 79 |
+
amcp
|
| 80 |
+
|
| 81 |
+
# Single command
|
| 82 |
+
amcp --once "create a hello.py file"
|
| 83 |
+
|
| 84 |
+
# List available agents
|
| 85 |
+
amcp --list
|
| 86 |
+
|
| 87 |
+
# Use specific agent
|
| 88 |
+
amcp --agent path/to/agent.yaml
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Common Tasks
|
| 92 |
+
|
| 93 |
+
### Adding a New Test
|
| 94 |
+
```python
|
| 95 |
+
# tests/test_myfeature.py
|
| 96 |
+
import pytest
|
| 97 |
+
from amcp.myfeature import MyClass
|
| 98 |
+
|
| 99 |
+
def test_my_feature():
|
| 100 |
+
obj = MyClass()
|
| 101 |
+
result = obj.do_something()
|
| 102 |
+
assert result == expected_value
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Adding a New Tool
|
| 106 |
+
```python
|
| 107 |
+
# src/amcp/tools.py
|
| 108 |
+
class MyNewTool(BaseTool):
|
| 109 |
+
@property
|
| 110 |
+
def name(self) -> str:
|
| 111 |
+
return "my_tool"
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def description(self) -> str:
|
| 115 |
+
return "Description of what this tool does"
|
| 116 |
+
|
| 117 |
+
def execute(self, **kwargs) -> ToolResult:
|
| 118 |
+
# Implementation
|
| 119 |
+
return ToolResult(success=True, content="result")
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Running CI Locally
|
| 123 |
+
```bash
|
| 124 |
+
# Run the same checks as CI
|
| 125 |
+
make lint
|
| 126 |
+
make type-check
|
| 127 |
+
make test-cov
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Troubleshooting
|
| 131 |
+
|
| 132 |
+
### Tests Failing
|
| 133 |
+
```bash
|
| 134 |
+
# Run with verbose output
|
| 135 |
+
pytest -vv
|
| 136 |
+
|
| 137 |
+
# Run specific test
|
| 138 |
+
pytest tests/test_tools.py::test_read_file_tool -v
|
| 139 |
+
|
| 140 |
+
# Show print statements
|
| 141 |
+
pytest -s
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Import Errors
|
| 145 |
+
```bash
|
| 146 |
+
# Reinstall in editable mode
|
| 147 |
+
pip install -e .
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Pre-commit Issues
|
| 151 |
+
```bash
|
| 152 |
+
# Update hooks
|
| 153 |
+
pre-commit autoupdate
|
| 154 |
+
|
| 155 |
+
# Run manually
|
| 156 |
+
pre-commit run --all-files
|
| 157 |
+
```
|
pyproject.toml
CHANGED
|
@@ -27,7 +27,26 @@ requires = ["hatchling>=1.25.0"]
|
|
| 27 |
build-backend = "hatchling.build"
|
| 28 |
|
| 29 |
[tool.ruff]
|
| 30 |
-
line-length =
|
| 31 |
|
| 32 |
[tool.ruff.lint]
|
| 33 |
select = ["E", "F", "UP", "B", "SIM", "I"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
build-backend = "hatchling.build"
|
| 28 |
|
| 29 |
[tool.ruff]
|
| 30 |
+
line-length = 120
|
| 31 |
|
| 32 |
[tool.ruff.lint]
|
| 33 |
select = ["E", "F", "UP", "B", "SIM", "I"]
|
| 34 |
+
ignore = ["E501"]
|
| 35 |
+
|
| 36 |
+
[project.optional-dependencies]
|
| 37 |
+
dev = [
|
| 38 |
+
"pytest>=8.0.0",
|
| 39 |
+
"pytest-cov>=4.1.0",
|
| 40 |
+
"ruff>=0.3.0",
|
| 41 |
+
"mypy>=1.8.0",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
[tool.pytest.ini_options]
|
| 45 |
+
testpaths = ["tests"]
|
| 46 |
+
addopts = "-v --cov=src/amcp --cov-report=term-missing"
|
| 47 |
+
|
| 48 |
+
[tool.mypy]
|
| 49 |
+
python_version = "3.11"
|
| 50 |
+
warn_return_any = true
|
| 51 |
+
warn_unused_configs = true
|
| 52 |
+
disallow_untyped_defs = false
|
pytest.ini
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
python_classes = Test*
|
| 5 |
+
python_functions = test_*
|
| 6 |
+
addopts = -v --tb=short
|
requirements.txt
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
gradio>=4.0.0
|
| 2 |
-
openai>=1.0.0
|
| 3 |
-
pydantic>=2.0.0
|
| 4 |
-
pyyaml>=6.0.0
|
| 5 |
-
typer>=0.9.0
|
| 6 |
-
rich>=13.0.0
|
| 7 |
-
mcp>=1.0.0
|
| 8 |
-
tomli>=2.0.0
|
| 9 |
-
tomli-w>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/amcp/agent.py
CHANGED
|
@@ -1,32 +1,31 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import asyncio
|
| 4 |
import json
|
| 5 |
-
import os
|
| 6 |
from datetime import datetime
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import Any
|
| 9 |
|
| 10 |
import typer
|
| 11 |
from rich.console import Console
|
| 12 |
from rich.panel import Panel
|
| 13 |
-
from rich.progress import Progress, SpinnerColumn, TextColumn
|
| 14 |
from rich.status import Status
|
| 15 |
|
| 16 |
from .agent_spec import ResolvedAgentSpec, get_default_agent_spec
|
| 17 |
-
from .chat import
|
| 18 |
-
from .config import
|
| 19 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 20 |
-
from .tools import ToolRegistry
|
| 21 |
|
| 22 |
|
| 23 |
class AgentExecutionError(Exception):
|
| 24 |
"""Raised when agent execution fails."""
|
|
|
|
| 25 |
pass
|
| 26 |
|
| 27 |
|
| 28 |
class MaxStepsReached(Exception):
|
| 29 |
"""Raised when agent reaches maximum execution steps."""
|
|
|
|
| 30 |
pass
|
| 31 |
|
| 32 |
|
|
@@ -42,21 +41,21 @@ class Agent:
|
|
| 42 |
- Conversation history persistence
|
| 43 |
"""
|
| 44 |
|
| 45 |
-
def __init__(self, agent_spec:
|
| 46 |
self.agent_spec = agent_spec or get_default_agent_spec()
|
| 47 |
self.console = Console()
|
| 48 |
self.tool_registry = ToolRegistry()
|
| 49 |
-
self.execution_context:
|
| 50 |
self.step_count = 0
|
| 51 |
-
self.tool_calls_history:
|
| 52 |
|
| 53 |
# Conversation history management
|
| 54 |
self.session_id = session_id or self._generate_session_id()
|
| 55 |
-
self.conversation_history:
|
| 56 |
self.session_file = Path.home() / ".config" / "amcp" / "sessions" / f"{self.session_id}.json"
|
| 57 |
|
| 58 |
# Tool call tracking for per-conversation and per-session limits
|
| 59 |
-
self.current_conversation_tool_calls:
|
| 60 |
|
| 61 |
# Load existing conversation history if available
|
| 62 |
self._load_conversation_history()
|
|
@@ -68,6 +67,7 @@ class Agent:
|
|
| 68 |
def _generate_session_id(self) -> str:
|
| 69 |
"""Generate a unique session ID."""
|
| 70 |
import uuid
|
|
|
|
| 71 |
return str(uuid.uuid4())[:8]
|
| 72 |
|
| 73 |
def _ensure_sessions_dir(self) -> None:
|
|
@@ -80,12 +80,14 @@ class Agent:
|
|
| 80 |
try:
|
| 81 |
if self.session_file.exists():
|
| 82 |
self._ensure_sessions_dir()
|
| 83 |
-
with open(self.session_file,
|
| 84 |
data = json.load(f)
|
| 85 |
-
self.conversation_history = data.get(
|
| 86 |
-
self.tool_calls_history = data.get(
|
| 87 |
-
self.current_conversation_tool_calls = data.get(
|
| 88 |
-
self.console.print(
|
|
|
|
|
|
|
| 89 |
except Exception as e:
|
| 90 |
self.console.print(f"[yellow]Warning: Could not load conversation history: {e}[/yellow]")
|
| 91 |
self.conversation_history = []
|
|
@@ -96,14 +98,14 @@ class Agent:
|
|
| 96 |
try:
|
| 97 |
self._ensure_sessions_dir()
|
| 98 |
data = {
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
}
|
| 106 |
-
with open(self.session_file,
|
| 107 |
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 108 |
except Exception as e:
|
| 109 |
self.console.print(f"[yellow]Warning: Could not save conversation history: {e}[/yellow]")
|
|
@@ -119,15 +121,15 @@ class Agent:
|
|
| 119 |
except Exception as e:
|
| 120 |
self.console.print(f"[yellow]Warning: Could not delete session file: {e}[/yellow]")
|
| 121 |
|
| 122 |
-
def get_conversation_summary(self) ->
|
| 123 |
"""Get summary of the conversation."""
|
| 124 |
return {
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
}
|
| 132 |
|
| 133 |
@property
|
|
@@ -138,7 +140,7 @@ class Agent:
|
|
| 138 |
def max_steps(self) -> int:
|
| 139 |
return self.agent_spec.max_steps
|
| 140 |
|
| 141 |
-
def _get_system_prompt(self, work_dir:
|
| 142 |
"""Get resolved system prompt with template variables."""
|
| 143 |
current_time = datetime.now().isoformat()
|
| 144 |
work_dir_str = str(work_dir.resolve()) if work_dir else str(Path.cwd())
|
|
@@ -159,7 +161,7 @@ class Agent:
|
|
| 159 |
self.console.print(f"[yellow]Warning: Missing template variable {e}[/yellow]")
|
| 160 |
return self.agent_spec.system_prompt
|
| 161 |
|
| 162 |
-
async def _get_mcp_tools_info(self, cfg) ->
|
| 163 |
"""Get information about available MCP tools."""
|
| 164 |
tools_info = []
|
| 165 |
|
|
@@ -167,11 +169,13 @@ class Agent:
|
|
| 167 |
try:
|
| 168 |
tools = await list_mcp_tools(server)
|
| 169 |
for tool in tools:
|
| 170 |
-
tools_info.append(
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
except Exception as e:
|
| 176 |
self.console.print(f"[yellow]Warning: Could not load tools from {server_name}: {e}[/yellow]")
|
| 177 |
|
|
@@ -180,7 +184,9 @@ class Agent:
|
|
| 180 |
def _should_limit_tool_calls(self, tool_name: str) -> bool:
|
| 181 |
"""Check if a tool should be limited to prevent infinite loops."""
|
| 182 |
# Per-tool limits (each tool tracked separately)
|
| 183 |
-
current_conversation_calls = sum(
|
|
|
|
|
|
|
| 184 |
|
| 185 |
# read_file: 10 per conversation, 600 per session
|
| 186 |
if tool_name == "read_file":
|
|
@@ -209,7 +215,7 @@ class Agent:
|
|
| 209 |
"""Add context information for tool execution."""
|
| 210 |
self.execution_context[key] = value
|
| 211 |
|
| 212 |
-
def _get_context_vars(self) ->
|
| 213 |
"""Get context variables for system prompt."""
|
| 214 |
return {
|
| 215 |
"step_count": str(self.step_count),
|
|
@@ -219,11 +225,7 @@ class Agent:
|
|
| 219 |
}
|
| 220 |
|
| 221 |
async def run(
|
| 222 |
-
self,
|
| 223 |
-
user_input: str,
|
| 224 |
-
work_dir: Optional[Path] = None,
|
| 225 |
-
stream: bool = True,
|
| 226 |
-
show_progress: bool = True
|
| 227 |
) -> str:
|
| 228 |
"""
|
| 229 |
Run the agent with the given user input.
|
|
@@ -254,7 +256,11 @@ class Agent:
|
|
| 254 |
messages = [{"role": "system", "content": system_prompt}]
|
| 255 |
|
| 256 |
# Add conversation history (limit to last 20 messages to avoid context overflow)
|
| 257 |
-
history_to_add =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
messages.extend(history_to_add)
|
| 259 |
|
| 260 |
# Add current user input
|
|
@@ -266,11 +272,7 @@ class Agent:
|
|
| 266 |
|
| 267 |
# Run chat with tools
|
| 268 |
result = await self._run_with_tools(
|
| 269 |
-
messages=messages,
|
| 270 |
-
tools=tools,
|
| 271 |
-
tool_registry=tool_registry,
|
| 272 |
-
stream=stream,
|
| 273 |
-
status=status
|
| 274 |
)
|
| 275 |
|
| 276 |
# Save conversation exchange
|
|
@@ -286,16 +288,17 @@ class Agent:
|
|
| 286 |
self.console.print(f"[red]Agent execution failed:[/red] {e}")
|
| 287 |
raise AgentExecutionError(f"Agent execution failed: {e}") from e
|
| 288 |
|
| 289 |
-
async def _build_tools(self) ->
|
| 290 |
"""Build list of available tools."""
|
| 291 |
tools = []
|
| 292 |
|
| 293 |
# Add all built-in tools from registry
|
| 294 |
from .tools import get_tool_registry
|
|
|
|
| 295 |
registry = get_tool_registry()
|
| 296 |
for tool_name in registry.list_tools():
|
| 297 |
tool = registry.get_tool(tool_name)
|
| 298 |
-
if tool and hasattr(tool,
|
| 299 |
tools.append(tool.get_spec())
|
| 300 |
|
| 301 |
# Load MCP tools
|
|
@@ -321,20 +324,22 @@ class Agent:
|
|
| 321 |
tname = info.get("name") or "tool"
|
| 322 |
oname = f"mcp.{name}.{tname}"
|
| 323 |
params = info.get("inputSchema") or {"type": "object"}
|
| 324 |
-
tools.append(
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
"
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
|
|
|
|
|
|
| 332 |
except Exception as e:
|
| 333 |
self.console.print(f"[yellow]MCP tool discovery failed for server {name}:[/yellow] {e}")
|
| 334 |
|
| 335 |
return tools
|
| 336 |
|
| 337 |
-
async def _build_tool_registry(self) ->
|
| 338 |
"""Build tool registry for MCP tool dispatch."""
|
| 339 |
registry = {}
|
| 340 |
|
|
@@ -360,12 +365,12 @@ class Agent:
|
|
| 360 |
tname = info.get("name") or "tool"
|
| 361 |
oname = f"mcp.{name}.{tname}"
|
| 362 |
registry[oname] = (name, tname)
|
| 363 |
-
except Exception
|
| 364 |
pass # Already logged in _build_tools
|
| 365 |
|
| 366 |
return registry
|
| 367 |
|
| 368 |
-
def _get_read_file_tool_spec(self) ->
|
| 369 |
"""Get read_file tool specification."""
|
| 370 |
return {
|
| 371 |
"type": "function",
|
|
@@ -377,18 +382,18 @@ class Agent:
|
|
| 377 |
"properties": {
|
| 378 |
"path": {
|
| 379 |
"type": "string",
|
| 380 |
-
"description": "Path to a specific FILE (not directory). Use relative paths like 'src/amcp/readfile.py', NEVER directories like 'src/amcp'. COMMON FILES: 'src/amcp/readfile.py', 'src/amcp/rg.py', 'src/amcp/cli.py', 'src/amcp/chat.py', 'README.md', 'pyproject.toml'. Always include the file extension (.py, .md, .toml, etc)."
|
| 381 |
},
|
| 382 |
"ranges": {
|
| 383 |
"type": "array",
|
| 384 |
"items": {"type": "string", "pattern": "^\\d+-\\d+$"},
|
| 385 |
-
"description": "Optional list of line ranges like '1-200'. Use only if you need specific line ranges. For general file analysis, omit this to get the full file."
|
| 386 |
},
|
| 387 |
"max_lines": {
|
| 388 |
"type": "integer",
|
| 389 |
"minimum": 1,
|
| 390 |
"maximum": 5000,
|
| 391 |
-
"description": "Safety cap for lines returned per block (default 400)"
|
| 392 |
},
|
| 393 |
},
|
| 394 |
"required": ["path"],
|
|
@@ -399,11 +404,11 @@ class Agent:
|
|
| 399 |
|
| 400 |
async def _run_with_tools(
|
| 401 |
self,
|
| 402 |
-
messages:
|
| 403 |
-
tools:
|
| 404 |
-
tool_registry:
|
| 405 |
stream: bool,
|
| 406 |
-
status: Status
|
| 407 |
) -> str:
|
| 408 |
"""Run chat with tools and enhanced tracking."""
|
| 409 |
cfg = load_config()
|
|
@@ -420,22 +425,22 @@ class Agent:
|
|
| 420 |
tools=tools,
|
| 421 |
tool_registry=tool_registry,
|
| 422 |
stream=stream,
|
| 423 |
-
status=status
|
| 424 |
)
|
| 425 |
|
| 426 |
async def _enhanced_chat_with_tools(
|
| 427 |
self,
|
| 428 |
client,
|
| 429 |
model: str,
|
| 430 |
-
messages:
|
| 431 |
-
tools:
|
| 432 |
-
tool_registry:
|
| 433 |
stream: bool,
|
| 434 |
status: Status,
|
| 435 |
-
max_steps:
|
| 436 |
) -> str:
|
| 437 |
"""Enhanced version of _chat_with_tools with better tracking."""
|
| 438 |
-
from .chat import _get_chat_runtime_settings
|
| 439 |
|
| 440 |
max_steps = max_steps or self.max_steps
|
| 441 |
|
|
@@ -471,13 +476,17 @@ class Agent:
|
|
| 471 |
limited_tools.append(tool_name)
|
| 472 |
|
| 473 |
if limited_tools:
|
| 474 |
-
status.update(
|
|
|
|
|
|
|
| 475 |
self.console.print(f"[yellow]Tools {limited_tools} limited, forcing response[/yellow]")
|
| 476 |
# Add system message to force response
|
| 477 |
-
messages.append(
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
|
|
|
|
|
|
| 481 |
# Get a final response from the LLM with the current messages
|
| 482 |
try:
|
| 483 |
final_resp = client.chat.completions.create(
|
|
@@ -529,11 +538,13 @@ class Agent:
|
|
| 529 |
parts.append(c.get("text", ""))
|
| 530 |
tool_result_text = "\\n\\n".join(parts) or json.dumps(mcp_resp, ensure_ascii=False)
|
| 531 |
|
| 532 |
-
self.console.print(
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
| 537 |
else:
|
| 538 |
tool_result_text = f"Error: Unknown MCP server {server_name}"
|
| 539 |
else:
|
|
@@ -541,6 +552,7 @@ class Agent:
|
|
| 541 |
else:
|
| 542 |
# Handle built-in tools
|
| 543 |
from .tools import get_tool_registry
|
|
|
|
| 544 |
registry = get_tool_registry()
|
| 545 |
args = json.loads(tc.function.arguments or "{}")
|
| 546 |
|
|
@@ -549,42 +561,48 @@ class Agent:
|
|
| 549 |
if tool_result.success:
|
| 550 |
tool_result_text = tool_result.content
|
| 551 |
preview = tool_result_text[:200] if len(tool_result_text) > 200 else tool_result_text
|
| 552 |
-
self.console.print(Panel(
|
| 553 |
-
preview,
|
| 554 |
-
title=f"Tool: {tool_name}",
|
| 555 |
-
border_style="blue"
|
| 556 |
-
))
|
| 557 |
else:
|
| 558 |
tool_result_text = f"Error: {tool_result.error}"
|
| 559 |
-
self.console.print(
|
| 560 |
-
tool_result_text,
|
| 561 |
-
|
| 562 |
-
border_style="red"
|
| 563 |
-
))
|
| 564 |
|
| 565 |
# Add tool result to messages
|
| 566 |
-
messages.append(
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
except Exception as e:
|
| 579 |
error_msg = f"Tool {tool_name} error: {type(e).__name__}: {e}"
|
| 580 |
self.console.print(f"[red]{error_msg}[/red]")
|
| 581 |
|
| 582 |
-
messages.append(
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
|
|
|
|
|
|
| 588 |
|
| 589 |
continue
|
| 590 |
else:
|
|
@@ -608,7 +626,7 @@ class Agent:
|
|
| 608 |
else:
|
| 609 |
return typer.ctx.obj or typer.Context.NULL
|
| 610 |
|
| 611 |
-
def get_execution_summary(self) ->
|
| 612 |
"""Get summary of agent execution."""
|
| 613 |
return {
|
| 614 |
"agent_name": self.name,
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
from datetime import datetime
|
| 5 |
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
|
| 8 |
import typer
|
| 9 |
from rich.console import Console
|
| 10 |
from rich.panel import Panel
|
|
|
|
| 11 |
from rich.status import Status
|
| 12 |
|
| 13 |
from .agent_spec import ResolvedAgentSpec, get_default_agent_spec
|
| 14 |
+
from .chat import _make_client, _resolve_api_key, _resolve_base_url
|
| 15 |
+
from .config import load_config
|
| 16 |
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 17 |
+
from .tools import ToolRegistry
|
| 18 |
|
| 19 |
|
| 20 |
class AgentExecutionError(Exception):
|
| 21 |
"""Raised when agent execution fails."""
|
| 22 |
+
|
| 23 |
pass
|
| 24 |
|
| 25 |
|
| 26 |
class MaxStepsReached(Exception):
|
| 27 |
"""Raised when agent reaches maximum execution steps."""
|
| 28 |
+
|
| 29 |
pass
|
| 30 |
|
| 31 |
|
|
|
|
| 41 |
- Conversation history persistence
|
| 42 |
"""
|
| 43 |
|
| 44 |
+
def __init__(self, agent_spec: ResolvedAgentSpec | None = None, session_id: str | None = None):
|
| 45 |
self.agent_spec = agent_spec or get_default_agent_spec()
|
| 46 |
self.console = Console()
|
| 47 |
self.tool_registry = ToolRegistry()
|
| 48 |
+
self.execution_context: dict[str, Any] = {}
|
| 49 |
self.step_count = 0
|
| 50 |
+
self.tool_calls_history: list[dict[str, Any]] = []
|
| 51 |
|
| 52 |
# Conversation history management
|
| 53 |
self.session_id = session_id or self._generate_session_id()
|
| 54 |
+
self.conversation_history: list[dict[str, Any]] = []
|
| 55 |
self.session_file = Path.home() / ".config" / "amcp" / "sessions" / f"{self.session_id}.json"
|
| 56 |
|
| 57 |
# Tool call tracking for per-conversation and per-session limits
|
| 58 |
+
self.current_conversation_tool_calls: list[dict[str, Any]] = []
|
| 59 |
|
| 60 |
# Load existing conversation history if available
|
| 61 |
self._load_conversation_history()
|
|
|
|
| 67 |
def _generate_session_id(self) -> str:
|
| 68 |
"""Generate a unique session ID."""
|
| 69 |
import uuid
|
| 70 |
+
|
| 71 |
return str(uuid.uuid4())[:8]
|
| 72 |
|
| 73 |
def _ensure_sessions_dir(self) -> None:
|
|
|
|
| 80 |
try:
|
| 81 |
if self.session_file.exists():
|
| 82 |
self._ensure_sessions_dir()
|
| 83 |
+
with open(self.session_file, encoding="utf-8") as f:
|
| 84 |
data = json.load(f)
|
| 85 |
+
self.conversation_history = data.get("conversation_history", [])
|
| 86 |
+
self.tool_calls_history = data.get("tool_calls_history", [])
|
| 87 |
+
self.current_conversation_tool_calls = data.get("current_conversation_tool_calls", [])
|
| 88 |
+
self.console.print(
|
| 89 |
+
f"[dim]Loaded conversation history: {len(self.conversation_history)} messages, {len(self.tool_calls_history)} total tool calls[/dim]"
|
| 90 |
+
)
|
| 91 |
except Exception as e:
|
| 92 |
self.console.print(f"[yellow]Warning: Could not load conversation history: {e}[/yellow]")
|
| 93 |
self.conversation_history = []
|
|
|
|
| 98 |
try:
|
| 99 |
self._ensure_sessions_dir()
|
| 100 |
data = {
|
| 101 |
+
"session_id": self.session_id,
|
| 102 |
+
"agent_name": self.name,
|
| 103 |
+
"created_at": datetime.now().isoformat(),
|
| 104 |
+
"conversation_history": self.conversation_history,
|
| 105 |
+
"tool_calls_history": self.tool_calls_history,
|
| 106 |
+
"current_conversation_tool_calls": self.current_conversation_tool_calls,
|
| 107 |
}
|
| 108 |
+
with open(self.session_file, "w", encoding="utf-8") as f:
|
| 109 |
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 110 |
except Exception as e:
|
| 111 |
self.console.print(f"[yellow]Warning: Could not save conversation history: {e}[/yellow]")
|
|
|
|
| 121 |
except Exception as e:
|
| 122 |
self.console.print(f"[yellow]Warning: Could not delete session file: {e}[/yellow]")
|
| 123 |
|
| 124 |
+
def get_conversation_summary(self) -> dict[str, Any]:
|
| 125 |
"""Get summary of the conversation."""
|
| 126 |
return {
|
| 127 |
+
"session_id": self.session_id,
|
| 128 |
+
"agent_name": self.name,
|
| 129 |
+
"message_count": len(self.conversation_history),
|
| 130 |
+
"tool_calls_count": len(self.tool_calls_history),
|
| 131 |
+
"current_conversation_tool_calls": len(self.current_conversation_tool_calls),
|
| 132 |
+
"session_file": str(self.session_file),
|
| 133 |
}
|
| 134 |
|
| 135 |
@property
|
|
|
|
| 140 |
def max_steps(self) -> int:
|
| 141 |
return self.agent_spec.max_steps
|
| 142 |
|
| 143 |
+
def _get_system_prompt(self, work_dir: Path | None = None) -> str:
|
| 144 |
"""Get resolved system prompt with template variables."""
|
| 145 |
current_time = datetime.now().isoformat()
|
| 146 |
work_dir_str = str(work_dir.resolve()) if work_dir else str(Path.cwd())
|
|
|
|
| 161 |
self.console.print(f"[yellow]Warning: Missing template variable {e}[/yellow]")
|
| 162 |
return self.agent_spec.system_prompt
|
| 163 |
|
| 164 |
+
async def _get_mcp_tools_info(self, cfg) -> list[dict[str, Any]]:
|
| 165 |
"""Get information about available MCP tools."""
|
| 166 |
tools_info = []
|
| 167 |
|
|
|
|
| 169 |
try:
|
| 170 |
tools = await list_mcp_tools(server)
|
| 171 |
for tool in tools:
|
| 172 |
+
tools_info.append(
|
| 173 |
+
{
|
| 174 |
+
"name": f"mcp.{server_name}.{tool['name']}",
|
| 175 |
+
"description": tool.get("description", ""),
|
| 176 |
+
"server": server_name,
|
| 177 |
+
}
|
| 178 |
+
)
|
| 179 |
except Exception as e:
|
| 180 |
self.console.print(f"[yellow]Warning: Could not load tools from {server_name}: {e}[/yellow]")
|
| 181 |
|
|
|
|
| 184 |
def _should_limit_tool_calls(self, tool_name: str) -> bool:
|
| 185 |
"""Check if a tool should be limited to prevent infinite loops."""
|
| 186 |
# Per-tool limits (each tool tracked separately)
|
| 187 |
+
current_conversation_calls = sum(
|
| 188 |
+
1 for call in self.current_conversation_tool_calls if call.get("tool") == tool_name
|
| 189 |
+
)
|
| 190 |
|
| 191 |
# read_file: 10 per conversation, 600 per session
|
| 192 |
if tool_name == "read_file":
|
|
|
|
| 215 |
"""Add context information for tool execution."""
|
| 216 |
self.execution_context[key] = value
|
| 217 |
|
| 218 |
+
def _get_context_vars(self) -> dict[str, str]:
|
| 219 |
"""Get context variables for system prompt."""
|
| 220 |
return {
|
| 221 |
"step_count": str(self.step_count),
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
async def run(
|
| 228 |
+
self, user_input: str, work_dir: Path | None = None, stream: bool = True, show_progress: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
) -> str:
|
| 230 |
"""
|
| 231 |
Run the agent with the given user input.
|
|
|
|
| 256 |
messages = [{"role": "system", "content": system_prompt}]
|
| 257 |
|
| 258 |
# Add conversation history (limit to last 20 messages to avoid context overflow)
|
| 259 |
+
history_to_add = (
|
| 260 |
+
self.conversation_history[-20:]
|
| 261 |
+
if len(self.conversation_history) > 20
|
| 262 |
+
else self.conversation_history
|
| 263 |
+
)
|
| 264 |
messages.extend(history_to_add)
|
| 265 |
|
| 266 |
# Add current user input
|
|
|
|
| 272 |
|
| 273 |
# Run chat with tools
|
| 274 |
result = await self._run_with_tools(
|
| 275 |
+
messages=messages, tools=tools, tool_registry=tool_registry, stream=stream, status=status
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
)
|
| 277 |
|
| 278 |
# Save conversation exchange
|
|
|
|
| 288 |
self.console.print(f"[red]Agent execution failed:[/red] {e}")
|
| 289 |
raise AgentExecutionError(f"Agent execution failed: {e}") from e
|
| 290 |
|
| 291 |
+
async def _build_tools(self) -> list[dict[str, Any]]:
|
| 292 |
"""Build list of available tools."""
|
| 293 |
tools = []
|
| 294 |
|
| 295 |
# Add all built-in tools from registry
|
| 296 |
from .tools import get_tool_registry
|
| 297 |
+
|
| 298 |
registry = get_tool_registry()
|
| 299 |
for tool_name in registry.list_tools():
|
| 300 |
tool = registry.get_tool(tool_name)
|
| 301 |
+
if tool and hasattr(tool, "get_spec"):
|
| 302 |
tools.append(tool.get_spec())
|
| 303 |
|
| 304 |
# Load MCP tools
|
|
|
|
| 324 |
tname = info.get("name") or "tool"
|
| 325 |
oname = f"mcp.{name}.{tname}"
|
| 326 |
params = info.get("inputSchema") or {"type": "object"}
|
| 327 |
+
tools.append(
|
| 328 |
+
{
|
| 329 |
+
"type": "function",
|
| 330 |
+
"function": {
|
| 331 |
+
"name": oname,
|
| 332 |
+
"description": info.get("description", ""),
|
| 333 |
+
"parameters": params,
|
| 334 |
+
},
|
| 335 |
+
}
|
| 336 |
+
)
|
| 337 |
except Exception as e:
|
| 338 |
self.console.print(f"[yellow]MCP tool discovery failed for server {name}:[/yellow] {e}")
|
| 339 |
|
| 340 |
return tools
|
| 341 |
|
| 342 |
+
async def _build_tool_registry(self) -> dict[str, Any]:
|
| 343 |
"""Build tool registry for MCP tool dispatch."""
|
| 344 |
registry = {}
|
| 345 |
|
|
|
|
| 365 |
tname = info.get("name") or "tool"
|
| 366 |
oname = f"mcp.{name}.{tname}"
|
| 367 |
registry[oname] = (name, tname)
|
| 368 |
+
except Exception:
|
| 369 |
pass # Already logged in _build_tools
|
| 370 |
|
| 371 |
return registry
|
| 372 |
|
| 373 |
+
def _get_read_file_tool_spec(self) -> dict[str, Any]:
|
| 374 |
"""Get read_file tool specification."""
|
| 375 |
return {
|
| 376 |
"type": "function",
|
|
|
|
| 382 |
"properties": {
|
| 383 |
"path": {
|
| 384 |
"type": "string",
|
| 385 |
+
"description": "Path to a specific FILE (not directory). Use relative paths like 'src/amcp/readfile.py', NEVER directories like 'src/amcp'. COMMON FILES: 'src/amcp/readfile.py', 'src/amcp/rg.py', 'src/amcp/cli.py', 'src/amcp/chat.py', 'README.md', 'pyproject.toml'. Always include the file extension (.py, .md, .toml, etc).",
|
| 386 |
},
|
| 387 |
"ranges": {
|
| 388 |
"type": "array",
|
| 389 |
"items": {"type": "string", "pattern": "^\\d+-\\d+$"},
|
| 390 |
+
"description": "Optional list of line ranges like '1-200'. Use only if you need specific line ranges. For general file analysis, omit this to get the full file.",
|
| 391 |
},
|
| 392 |
"max_lines": {
|
| 393 |
"type": "integer",
|
| 394 |
"minimum": 1,
|
| 395 |
"maximum": 5000,
|
| 396 |
+
"description": "Safety cap for lines returned per block (default 400)",
|
| 397 |
},
|
| 398 |
},
|
| 399 |
"required": ["path"],
|
|
|
|
| 404 |
|
| 405 |
async def _run_with_tools(
|
| 406 |
self,
|
| 407 |
+
messages: list[dict[str, Any]],
|
| 408 |
+
tools: list[dict[str, Any]],
|
| 409 |
+
tool_registry: dict[str, Any],
|
| 410 |
stream: bool,
|
| 411 |
+
status: Status,
|
| 412 |
) -> str:
|
| 413 |
"""Run chat with tools and enhanced tracking."""
|
| 414 |
cfg = load_config()
|
|
|
|
| 425 |
tools=tools,
|
| 426 |
tool_registry=tool_registry,
|
| 427 |
stream=stream,
|
| 428 |
+
status=status,
|
| 429 |
)
|
| 430 |
|
| 431 |
async def _enhanced_chat_with_tools(
|
| 432 |
self,
|
| 433 |
client,
|
| 434 |
model: str,
|
| 435 |
+
messages: list[dict[str, Any]],
|
| 436 |
+
tools: list[dict[str, Any]],
|
| 437 |
+
tool_registry: dict[str, Any],
|
| 438 |
stream: bool,
|
| 439 |
status: Status,
|
| 440 |
+
max_steps: int | None = None,
|
| 441 |
) -> str:
|
| 442 |
"""Enhanced version of _chat_with_tools with better tracking."""
|
| 443 |
+
from .chat import _get_chat_runtime_settings
|
| 444 |
|
| 445 |
max_steps = max_steps or self.max_steps
|
| 446 |
|
|
|
|
| 476 |
limited_tools.append(tool_name)
|
| 477 |
|
| 478 |
if limited_tools:
|
| 479 |
+
status.update(
|
| 480 |
+
f"[bold]Agent {self.name}[/bold] - Tools {limited_tools} limited, forcing response..."
|
| 481 |
+
)
|
| 482 |
self.console.print(f"[yellow]Tools {limited_tools} limited, forcing response[/yellow]")
|
| 483 |
# Add system message to force response
|
| 484 |
+
messages.append(
|
| 485 |
+
{
|
| 486 |
+
"role": "system",
|
| 487 |
+
"content": f"You have already called the following tools too many times: {', '.join(limited_tools)}. Please analyze the information you have and provide your response without calling these tools again.",
|
| 488 |
+
}
|
| 489 |
+
)
|
| 490 |
# Get a final response from the LLM with the current messages
|
| 491 |
try:
|
| 492 |
final_resp = client.chat.completions.create(
|
|
|
|
| 538 |
parts.append(c.get("text", ""))
|
| 539 |
tool_result_text = "\\n\\n".join(parts) or json.dumps(mcp_resp, ensure_ascii=False)
|
| 540 |
|
| 541 |
+
self.console.print(
|
| 542 |
+
Panel(
|
| 543 |
+
f"✅ MCP tool {tool_name} executed successfully",
|
| 544 |
+
title="Tool Result",
|
| 545 |
+
border_style="green",
|
| 546 |
+
)
|
| 547 |
+
)
|
| 548 |
else:
|
| 549 |
tool_result_text = f"Error: Unknown MCP server {server_name}"
|
| 550 |
else:
|
|
|
|
| 552 |
else:
|
| 553 |
# Handle built-in tools
|
| 554 |
from .tools import get_tool_registry
|
| 555 |
+
|
| 556 |
registry = get_tool_registry()
|
| 557 |
args = json.loads(tc.function.arguments or "{}")
|
| 558 |
|
|
|
|
| 561 |
if tool_result.success:
|
| 562 |
tool_result_text = tool_result.content
|
| 563 |
preview = tool_result_text[:200] if len(tool_result_text) > 200 else tool_result_text
|
| 564 |
+
self.console.print(Panel(preview, title=f"Tool: {tool_name}", border_style="blue"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
else:
|
| 566 |
tool_result_text = f"Error: {tool_result.error}"
|
| 567 |
+
self.console.print(
|
| 568 |
+
Panel(tool_result_text, title=f"Tool Error: {tool_name}", border_style="red")
|
| 569 |
+
)
|
|
|
|
|
|
|
| 570 |
|
| 571 |
# Add tool result to messages
|
| 572 |
+
messages.append(
|
| 573 |
+
{
|
| 574 |
+
"role": "assistant",
|
| 575 |
+
"content": msg.content or "",
|
| 576 |
+
"tool_calls": [
|
| 577 |
+
{
|
| 578 |
+
"id": tc.id,
|
| 579 |
+
"type": "function",
|
| 580 |
+
"function": {"name": tool_name, "arguments": tc.function.arguments or "{}"},
|
| 581 |
+
}
|
| 582 |
+
],
|
| 583 |
+
}
|
| 584 |
+
)
|
| 585 |
+
messages.append(
|
| 586 |
+
{
|
| 587 |
+
"role": "tool",
|
| 588 |
+
"tool_call_id": tc.id,
|
| 589 |
+
"name": tool_name,
|
| 590 |
+
"content": tool_result_text,
|
| 591 |
+
}
|
| 592 |
+
)
|
| 593 |
|
| 594 |
except Exception as e:
|
| 595 |
error_msg = f"Tool {tool_name} error: {type(e).__name__}: {e}"
|
| 596 |
self.console.print(f"[red]{error_msg}[/red]")
|
| 597 |
|
| 598 |
+
messages.append(
|
| 599 |
+
{
|
| 600 |
+
"role": "tool",
|
| 601 |
+
"tool_call_id": tc.id,
|
| 602 |
+
"name": tool_name,
|
| 603 |
+
"content": error_msg,
|
| 604 |
+
}
|
| 605 |
+
)
|
| 606 |
|
| 607 |
continue
|
| 608 |
else:
|
|
|
|
| 626 |
else:
|
| 627 |
return typer.ctx.obj or typer.Context.NULL
|
| 628 |
|
| 629 |
+
def get_execution_summary(self) -> dict[str, Any]:
|
| 630 |
"""Get summary of agent execution."""
|
| 631 |
return {
|
| 632 |
"agent_name": self.name,
|
src/amcp/agent_spec.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
from dataclasses import dataclass, field
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Any, Dict, List
|
| 7 |
|
|
|
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
|
| 10 |
from .config import load_config as load_app_config
|
|
@@ -12,29 +11,24 @@ from .config import load_config as load_app_config
|
|
| 12 |
|
| 13 |
class AgentSpecError(Exception):
|
| 14 |
"""Raised when agent specification is invalid."""
|
|
|
|
| 15 |
pass
|
| 16 |
|
| 17 |
|
| 18 |
class AgentSpec(BaseModel):
|
| 19 |
"""Agent specification model."""
|
| 20 |
-
|
| 21 |
name: str = Field(description="Agent name")
|
| 22 |
description: str = Field(default="", description="Agent description")
|
| 23 |
system_prompt: str = Field(description="System prompt for the agent")
|
| 24 |
-
system_prompt_template: str = Field(
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
)
|
| 28 |
-
system_prompt_vars: Dict[str, str] = Field(
|
| 29 |
-
default_factory=dict,
|
| 30 |
-
description="Variables for system prompt template"
|
| 31 |
-
)
|
| 32 |
-
tools: List[str] = Field(default_factory=list, description="Available tools")
|
| 33 |
-
exclude_tools: List[str] = Field(default_factory=list, description="Tools to exclude")
|
| 34 |
max_steps: int = Field(default=5, description="Maximum tool execution steps")
|
| 35 |
model: str = Field(default="", description="Preferred model name")
|
| 36 |
base_url: str = Field(default="", description="Preferred base URL")
|
| 37 |
-
|
| 38 |
class Config:
|
| 39 |
extra = "allow"
|
| 40 |
|
|
@@ -42,12 +36,12 @@ class AgentSpec(BaseModel):
|
|
| 42 |
@dataclass
|
| 43 |
class ResolvedAgentSpec:
|
| 44 |
"""Resolved agent specification with all defaults applied."""
|
| 45 |
-
|
| 46 |
name: str
|
| 47 |
description: str
|
| 48 |
system_prompt: str
|
| 49 |
-
tools:
|
| 50 |
-
exclude_tools:
|
| 51 |
max_steps: int
|
| 52 |
model: str
|
| 53 |
base_url: str
|
|
@@ -56,45 +50,45 @@ class ResolvedAgentSpec:
|
|
| 56 |
def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
|
| 57 |
"""
|
| 58 |
Load agent specification from YAML file.
|
| 59 |
-
|
| 60 |
Args:
|
| 61 |
agent_file: Path to agent specification file
|
| 62 |
-
|
| 63 |
Returns:
|
| 64 |
Resolved agent specification
|
| 65 |
-
|
| 66 |
Raises:
|
| 67 |
AgentSpecError: If file is invalid or cannot be loaded
|
| 68 |
"""
|
| 69 |
if not agent_file.exists():
|
| 70 |
raise AgentSpecError(f"Agent spec file not found: {agent_file}")
|
| 71 |
-
|
| 72 |
try:
|
| 73 |
-
with open(agent_file,
|
| 74 |
data = yaml.safe_load(f)
|
| 75 |
except yaml.YAMLError as e:
|
| 76 |
raise AgentSpecError(f"Invalid YAML in agent spec file: {e}") from e
|
| 77 |
-
|
| 78 |
if not data:
|
| 79 |
raise AgentSpecError(f"Empty agent spec file: {agent_file}")
|
| 80 |
-
|
| 81 |
try:
|
| 82 |
spec = AgentSpec(**data)
|
| 83 |
except Exception as e:
|
| 84 |
raise AgentSpecError(f"Invalid agent spec format: {e}") from e
|
| 85 |
-
|
| 86 |
# Apply defaults from global config
|
| 87 |
cfg = load_app_config()
|
| 88 |
default_model = cfg.chat.model if cfg.chat and cfg.chat.model else ""
|
| 89 |
default_base_url = cfg.chat.base_url if cfg.chat and cfg.chat.base_url else ""
|
| 90 |
-
|
| 91 |
# Resolve system prompt
|
| 92 |
system_prompt = spec.system_prompt
|
| 93 |
if spec.system_prompt_template and spec.system_prompt_vars:
|
| 94 |
system_prompt = spec.system_prompt_template.format(**spec.system_prompt_vars)
|
| 95 |
elif spec.system_prompt_template:
|
| 96 |
system_prompt = spec.system_prompt_template
|
| 97 |
-
|
| 98 |
return ResolvedAgentSpec(
|
| 99 |
name=spec.name,
|
| 100 |
description=spec.description,
|
|
@@ -134,18 +128,18 @@ Current time: {current_time}""",
|
|
| 134 |
exclude_tools=[],
|
| 135 |
max_steps=10,
|
| 136 |
model="",
|
| 137 |
-
base_url=""
|
| 138 |
)
|
| 139 |
|
| 140 |
|
| 141 |
-
def list_available_agents(agents_dir: Path) ->
|
| 142 |
"""List all available agent specification files."""
|
| 143 |
if not agents_dir.exists():
|
| 144 |
return []
|
| 145 |
-
|
| 146 |
agent_files = []
|
| 147 |
for file_path in agents_dir.rglob("*.yaml"):
|
| 148 |
if file_path.is_file():
|
| 149 |
agent_files.append(file_path)
|
| 150 |
-
|
| 151 |
return sorted(agent_files)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from dataclasses import dataclass
|
|
|
|
| 4 |
from pathlib import Path
|
|
|
|
| 5 |
|
| 6 |
+
import yaml
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
from .config import load_config as load_app_config
|
|
|
|
| 11 |
|
| 12 |
class AgentSpecError(Exception):
|
| 13 |
"""Raised when agent specification is invalid."""
|
| 14 |
+
|
| 15 |
pass
|
| 16 |
|
| 17 |
|
| 18 |
class AgentSpec(BaseModel):
|
| 19 |
"""Agent specification model."""
|
| 20 |
+
|
| 21 |
name: str = Field(description="Agent name")
|
| 22 |
description: str = Field(default="", description="Agent description")
|
| 23 |
system_prompt: str = Field(description="System prompt for the agent")
|
| 24 |
+
system_prompt_template: str = Field(default="", description="System prompt template with variables")
|
| 25 |
+
system_prompt_vars: dict[str, str] = Field(default_factory=dict, description="Variables for system prompt template")
|
| 26 |
+
tools: list[str] = Field(default_factory=list, description="Available tools")
|
| 27 |
+
exclude_tools: list[str] = Field(default_factory=list, description="Tools to exclude")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
max_steps: int = Field(default=5, description="Maximum tool execution steps")
|
| 29 |
model: str = Field(default="", description="Preferred model name")
|
| 30 |
base_url: str = Field(default="", description="Preferred base URL")
|
| 31 |
+
|
| 32 |
class Config:
|
| 33 |
extra = "allow"
|
| 34 |
|
|
|
|
| 36 |
@dataclass
|
| 37 |
class ResolvedAgentSpec:
|
| 38 |
"""Resolved agent specification with all defaults applied."""
|
| 39 |
+
|
| 40 |
name: str
|
| 41 |
description: str
|
| 42 |
system_prompt: str
|
| 43 |
+
tools: list[str]
|
| 44 |
+
exclude_tools: list[str]
|
| 45 |
max_steps: int
|
| 46 |
model: str
|
| 47 |
base_url: str
|
|
|
|
| 50 |
def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
|
| 51 |
"""
|
| 52 |
Load agent specification from YAML file.
|
| 53 |
+
|
| 54 |
Args:
|
| 55 |
agent_file: Path to agent specification file
|
| 56 |
+
|
| 57 |
Returns:
|
| 58 |
Resolved agent specification
|
| 59 |
+
|
| 60 |
Raises:
|
| 61 |
AgentSpecError: If file is invalid or cannot be loaded
|
| 62 |
"""
|
| 63 |
if not agent_file.exists():
|
| 64 |
raise AgentSpecError(f"Agent spec file not found: {agent_file}")
|
| 65 |
+
|
| 66 |
try:
|
| 67 |
+
with open(agent_file, encoding="utf-8") as f:
|
| 68 |
data = yaml.safe_load(f)
|
| 69 |
except yaml.YAMLError as e:
|
| 70 |
raise AgentSpecError(f"Invalid YAML in agent spec file: {e}") from e
|
| 71 |
+
|
| 72 |
if not data:
|
| 73 |
raise AgentSpecError(f"Empty agent spec file: {agent_file}")
|
| 74 |
+
|
| 75 |
try:
|
| 76 |
spec = AgentSpec(**data)
|
| 77 |
except Exception as e:
|
| 78 |
raise AgentSpecError(f"Invalid agent spec format: {e}") from e
|
| 79 |
+
|
| 80 |
# Apply defaults from global config
|
| 81 |
cfg = load_app_config()
|
| 82 |
default_model = cfg.chat.model if cfg.chat and cfg.chat.model else ""
|
| 83 |
default_base_url = cfg.chat.base_url if cfg.chat and cfg.chat.base_url else ""
|
| 84 |
+
|
| 85 |
# Resolve system prompt
|
| 86 |
system_prompt = spec.system_prompt
|
| 87 |
if spec.system_prompt_template and spec.system_prompt_vars:
|
| 88 |
system_prompt = spec.system_prompt_template.format(**spec.system_prompt_vars)
|
| 89 |
elif spec.system_prompt_template:
|
| 90 |
system_prompt = spec.system_prompt_template
|
| 91 |
+
|
| 92 |
return ResolvedAgentSpec(
|
| 93 |
name=spec.name,
|
| 94 |
description=spec.description,
|
|
|
|
| 128 |
exclude_tools=[],
|
| 129 |
max_steps=10,
|
| 130 |
model="",
|
| 131 |
+
base_url="",
|
| 132 |
)
|
| 133 |
|
| 134 |
|
| 135 |
+
def list_available_agents(agents_dir: Path) -> list[Path]:
|
| 136 |
"""List all available agent specification files."""
|
| 137 |
if not agents_dir.exists():
|
| 138 |
return []
|
| 139 |
+
|
| 140 |
agent_files = []
|
| 141 |
for file_path in agents_dir.rglob("*.yaml"):
|
| 142 |
if file_path.is_file():
|
| 143 |
agent_files.append(file_path)
|
| 144 |
+
|
| 145 |
return sorted(agent_files)
|
src/amcp/chat.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
import json
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from rich.console import Console
|
| 9 |
-
from rich.panel import Panel
|
| 10 |
from rich.live import Live
|
| 11 |
from rich.markdown import Markdown
|
|
|
|
| 12 |
|
| 13 |
-
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 14 |
from .config import AMCPConfig, ChatConfig, load_config
|
| 15 |
-
from
|
| 16 |
-
import re
|
| 17 |
from .readfile import read_file_with_ranges
|
| 18 |
|
| 19 |
console = Console()
|
|
@@ -24,9 +23,9 @@ def _run_quietly(coro):
|
|
| 24 |
This helps hide noisy MCP server startup logs in chat mode.
|
| 25 |
"""
|
| 26 |
import contextlib
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
|
| 32 |
def _resolve_base_url(cli_base: str | None, cfg: ChatConfig | None) -> str:
|
|
@@ -42,7 +41,7 @@ def _resolve_base_url(cli_base: str | None, cfg: ChatConfig | None) -> str:
|
|
| 42 |
return base
|
| 43 |
|
| 44 |
|
| 45 |
-
def _resolve_api_key(cli_key:
|
| 46 |
# CLI > config > env
|
| 47 |
if cli_key:
|
| 48 |
return cli_key
|
|
@@ -51,10 +50,10 @@ def _resolve_api_key(cli_key: Optional[str], cfg: ChatConfig | None) -> Optional
|
|
| 51 |
return os.environ.get("SAMBANOVA_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
| 52 |
|
| 53 |
|
| 54 |
-
def _make_client(base_url: str, api_key:
|
| 55 |
try:
|
| 56 |
from openai import OpenAI
|
| 57 |
-
except Exception
|
| 58 |
console.print("[red]openai package not installed. Please install dependencies.[/red]")
|
| 59 |
raise
|
| 60 |
return OpenAI(base_url=base_url, api_key=api_key or "")
|
|
@@ -102,7 +101,7 @@ def _stream_chat(client, model: str, messages: list[dict], stream: bool = True)
|
|
| 102 |
if item.type == "output_text":
|
| 103 |
parts.append(item.text)
|
| 104 |
return "".join(parts)
|
| 105 |
-
except Exception
|
| 106 |
raise
|
| 107 |
|
| 108 |
|
|
@@ -132,13 +131,18 @@ def _attach_file_context(path: Path, ranges: Iterable[str] | None, max_lines: in
|
|
| 132 |
lines = b["lines"]
|
| 133 |
if not ranges and len(lines) > max_lines:
|
| 134 |
lines = lines[:max_lines]
|
| 135 |
-
llm_context.append(
|
|
|
|
|
|
|
| 136 |
if not ranges and len(b["lines"]) > max_lines:
|
| 137 |
llm_context.append("[...truncated...]")
|
| 138 |
return "\n\n".join(rendered_parts), "\n\n".join(llm_context)
|
| 139 |
|
| 140 |
|
| 141 |
-
_READ_CMD = re.compile(
|
|
|
|
|
|
|
|
|
|
| 142 |
_PATH_RANGE_INLINE = re.compile(r"(?P<path>\S+?):(?P<s>\d+)-(?P<e>\d+)")
|
| 143 |
|
| 144 |
|
|
@@ -175,13 +179,21 @@ def _builtin_read_tool_spec() -> dict:
|
|
| 175 |
"parameters": {
|
| 176 |
"type": "object",
|
| 177 |
"properties": {
|
| 178 |
-
|
| 179 |
-
"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
},
|
| 186 |
"required": ["path"],
|
| 187 |
"additionalProperties": False,
|
|
@@ -193,8 +205,8 @@ def _builtin_read_tool_spec() -> dict:
|
|
| 193 |
def _get_chat_runtime_settings(override: dict | None = None) -> dict:
|
| 194 |
cfg: AMCPConfig = load_config()
|
| 195 |
chat_cfg = cfg.chat
|
| 196 |
-
tool_loop_limit =
|
| 197 |
-
default_max_lines =
|
| 198 |
roots: list[Path]
|
| 199 |
if chat_cfg and chat_cfg.read_roots:
|
| 200 |
roots = [Path(r).expanduser().resolve() for r in chat_cfg.read_roots]
|
|
@@ -225,12 +237,14 @@ def _dispatch_tool_call(name: str, arguments: dict, *, settings: dict) -> tuple[
|
|
| 225 |
allowed_roots: list[Path] = settings["allowed_roots"]
|
| 226 |
if not any(_is_within_root(p, root) for root in allowed_roots):
|
| 227 |
raise ValueError(f"Path {p} is outside allowed roots: {allowed_roots}")
|
| 228 |
-
|
| 229 |
# Check if path exists and is a file
|
| 230 |
if not p.exists():
|
| 231 |
raise FileNotFoundError(f"File not found: {p}")
|
| 232 |
if not p.is_file():
|
| 233 |
-
raise ValueError(
|
|
|
|
|
|
|
| 234 |
|
| 235 |
rendered, llm = _attach_file_context(p, ranges, max_lines=max_lines)
|
| 236 |
# content for model
|
|
@@ -246,7 +260,9 @@ def _is_within_root(path: Path, root: Path) -> bool:
|
|
| 246 |
return False
|
| 247 |
|
| 248 |
|
| 249 |
-
def _build_mcp_tools_and_registry(
|
|
|
|
|
|
|
| 250 |
# Decide which servers to include
|
| 251 |
if chat_cfg and chat_cfg.mcp_tools_enabled is False:
|
| 252 |
return [], {}
|
|
@@ -268,22 +284,33 @@ def _build_mcp_tools_and_registry(cfg: AMCPConfig, chat_cfg: ChatConfig | None,
|
|
| 268 |
oname = f"mcp.{name}.{tname}"
|
| 269 |
# Parameters: best effort; prefer server-provided schema when available
|
| 270 |
params = info.get("inputSchema") or {"type": "object"}
|
| 271 |
-
tools.append(
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
| 275 |
reg[oname] = (name, tname)
|
| 276 |
except Exception as e:
|
| 277 |
console.print(f"[yellow]MCP tool discovery failed for server {name}:[/yellow] {e}")
|
| 278 |
return tools, reg
|
| 279 |
|
| 280 |
|
| 281 |
-
def _chat_with_tools(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
messages = list(base_messages)
|
| 283 |
used_tools = False
|
| 284 |
settings = _get_chat_runtime_settings(settings_override)
|
| 285 |
max_steps = settings["tool_loop_limit"]
|
| 286 |
-
tools = [
|
| 287 |
if extra_tools:
|
| 288 |
tools.extend(extra_tools)
|
| 289 |
registry = tool_registry or {}
|
|
@@ -309,10 +336,12 @@ def _chat_with_tools(client, model: str, base_messages: list[dict], stream: bool
|
|
| 309 |
read_file_call_count += 1
|
| 310 |
if read_file_call_count >= 2:
|
| 311 |
# Force the model to respond by adding a system message
|
| 312 |
-
messages.append(
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
| 316 |
break
|
| 317 |
# append assistant message with tool calls
|
| 318 |
assistant_msg = {"role": "assistant", "content": msg.content or "", "tool_calls": []}
|
|
@@ -323,11 +352,13 @@ def _chat_with_tools(client, model: str, base_messages: list[dict], stream: bool
|
|
| 323 |
args = json.loads(fn.arguments or "{}")
|
| 324 |
except Exception:
|
| 325 |
pass
|
| 326 |
-
assistant_msg["tool_calls"].append(
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
| 331 |
try:
|
| 332 |
if fn.name.startswith("mcp.") and registry:
|
| 333 |
server_name, inner_name = registry.get(fn.name, (None, None))
|
|
@@ -360,15 +391,19 @@ def _chat_with_tools(client, model: str, base_messages: list[dict], stream: bool
|
|
| 360 |
console.print(f"[red]Tool {fn.name} error:[/red] {e}")
|
| 361 |
# Add more specific error info for TaskGroup issues
|
| 362 |
if "TaskGroup" in str(e) and "exa" in fn.name:
|
| 363 |
-
console.print(
|
|
|
|
|
|
|
| 364 |
# append tool result
|
| 365 |
messages.append(assistant_msg)
|
| 366 |
-
messages.append(
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
| 372 |
continue # back to the loop
|
| 373 |
else:
|
| 374 |
final_text = msg.content or ""
|
|
@@ -380,6 +415,7 @@ def _chat_with_tools(client, model: str, base_messages: list[dict], stream: bool
|
|
| 380 |
return final_text
|
| 381 |
return "[Tool loop limit reached]"
|
| 382 |
|
|
|
|
| 383 |
def _normalize_exa_web_search_args(args: dict) -> dict:
|
| 384 |
out = dict(args or {})
|
| 385 |
# Map possible synonyms
|
|
@@ -406,11 +442,17 @@ def do_exa_search(server_name: str, query: str, num_results: int = 4) -> str:
|
|
| 406 |
raise RuntimeError(f"Unknown MCP server '{server_name}'. Use 'amcp mcp tools -s ...' to verify.")
|
| 407 |
result = console.status("Calling MCP web_search_exa...")
|
| 408 |
with result:
|
| 409 |
-
resp = __import__("asyncio").run(
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
# Render
|
| 415 |
out_lines = [f"MCP search results for: {query}"]
|
| 416 |
for block in resp.get("content", []):
|
|
@@ -424,16 +466,16 @@ cfg_global: AMCPConfig = load_config()
|
|
| 424 |
|
| 425 |
|
| 426 |
def chat_once(
|
| 427 |
-
model:
|
| 428 |
user_text: str,
|
| 429 |
-
base_url:
|
| 430 |
-
system_prompt:
|
| 431 |
mcp_server: str = "exa",
|
| 432 |
stream: bool = True,
|
| 433 |
-
api_key:
|
| 434 |
-
work_dir:
|
| 435 |
-
mcp_servers_override:
|
| 436 |
-
mcp_tools_enabled:
|
| 437 |
) -> str:
|
| 438 |
cfg: AMCPConfig = load_config()
|
| 439 |
chat_cfg = cfg.chat
|
|
@@ -451,36 +493,44 @@ def chat_once(
|
|
| 451 |
messages.append({"role": "system", "content": system_prompt})
|
| 452 |
messages.append({"role": "user", "content": user_text})
|
| 453 |
overrides = {"read_roots": [str(work_dir.resolve())]} if work_dir else None
|
| 454 |
-
|
| 455 |
# Build MCP tools
|
| 456 |
extra_tools = []
|
| 457 |
registry = {}
|
| 458 |
enabled = True
|
| 459 |
-
|
| 460 |
# Check if MCP tools are disabled in config
|
| 461 |
-
if hasattr(chat_cfg,
|
| 462 |
enabled = False
|
| 463 |
-
|
| 464 |
# Override with command line flag
|
| 465 |
if mcp_tools_enabled is not None:
|
| 466 |
enabled = bool(mcp_tools_enabled)
|
| 467 |
-
|
| 468 |
if enabled:
|
| 469 |
extra_tools, registry = _build_mcp_tools_and_registry(cfg, chat_cfg, mcp_servers_override)
|
| 470 |
-
|
| 471 |
-
return _chat_with_tools(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
|
| 474 |
def chat_repl(
|
| 475 |
-
model:
|
| 476 |
-
base_url:
|
| 477 |
-
system_prompt:
|
| 478 |
mcp_server: str = "exa",
|
| 479 |
stream: bool = True,
|
| 480 |
-
api_key:
|
| 481 |
-
work_dir:
|
| 482 |
-
mcp_servers_override:
|
| 483 |
-
mcp_tools_enabled:
|
| 484 |
) -> None:
|
| 485 |
cfg: AMCPConfig = load_config()
|
| 486 |
chat_cfg = cfg.chat
|
|
@@ -501,7 +551,7 @@ def chat_repl(
|
|
| 501 |
cfg = load_config()
|
| 502 |
chat_cfg = cfg.chat
|
| 503 |
enabled = True
|
| 504 |
-
if hasattr(chat_cfg,
|
| 505 |
enabled = False
|
| 506 |
if mcp_tools_enabled is not None:
|
| 507 |
enabled = bool(mcp_tools_enabled)
|
|
@@ -509,19 +559,21 @@ def chat_repl(
|
|
| 509 |
registry = {}
|
| 510 |
if enabled:
|
| 511 |
extra_tools, registry = _build_mcp_tools_and_registry(cfg, chat_cfg, mcp_servers_override)
|
| 512 |
-
enabled_servers = sorted(set(name.split(
|
| 513 |
mcp_line = f"MCP tools: {'on' if extra_tools else 'off'}; servers: {', '.join(enabled_servers) if enabled_servers else '-'}"
|
| 514 |
-
console.print(
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
|
|
|
|
|
|
| 525 |
|
| 526 |
messages: list[dict[str, str]] = []
|
| 527 |
if system_prompt:
|
|
@@ -562,7 +614,7 @@ def chat_repl(
|
|
| 562 |
console.print(f"[yellow]File intent parse/read warning:[/yellow] {e}")
|
| 563 |
|
| 564 |
if text.startswith("/search "):
|
| 565 |
-
q = text[len("/search "):].strip()
|
| 566 |
try:
|
| 567 |
result = do_exa_search(mcp_server, q)
|
| 568 |
console.print(Panel(Markdown(result), title="exa search", border_style="magenta"))
|
|
@@ -573,7 +625,15 @@ def chat_repl(
|
|
| 573 |
messages.append({"role": "user", "content": text})
|
| 574 |
try:
|
| 575 |
extra_tools, registry = _build_mcp_tools_and_registry(load_config(), load_config().chat, None)
|
| 576 |
-
reply = _chat_with_tools(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
messages.append({"role": "assistant", "content": reply})
|
| 578 |
except Exception as e:
|
| 579 |
console.print(f"[red]Chat error:[/red] {e}")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from pathlib import Path
|
| 8 |
|
| 9 |
from rich.console import Console
|
|
|
|
| 10 |
from rich.live import Live
|
| 11 |
from rich.markdown import Markdown
|
| 12 |
+
from rich.panel import Panel
|
| 13 |
|
|
|
|
| 14 |
from .config import AMCPConfig, ChatConfig, load_config
|
| 15 |
+
from .mcp_client import call_mcp_tool, list_mcp_tools
|
|
|
|
| 16 |
from .readfile import read_file_with_ranges
|
| 17 |
|
| 18 |
console = Console()
|
|
|
|
| 23 |
This helps hide noisy MCP server startup logs in chat mode.
|
| 24 |
"""
|
| 25 |
import contextlib
|
| 26 |
+
|
| 27 |
+
with open(os.devnull, "w") as devnull, contextlib.redirect_stderr(devnull):
|
| 28 |
+
return __import__("asyncio").run(coro)
|
| 29 |
|
| 30 |
|
| 31 |
def _resolve_base_url(cli_base: str | None, cfg: ChatConfig | None) -> str:
|
|
|
|
| 41 |
return base
|
| 42 |
|
| 43 |
|
| 44 |
+
def _resolve_api_key(cli_key: str | None, cfg: ChatConfig | None) -> str | None:
|
| 45 |
# CLI > config > env
|
| 46 |
if cli_key:
|
| 47 |
return cli_key
|
|
|
|
| 50 |
return os.environ.get("SAMBANOVA_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
| 51 |
|
| 52 |
|
| 53 |
+
def _make_client(base_url: str, api_key: str | None):
|
| 54 |
try:
|
| 55 |
from openai import OpenAI
|
| 56 |
+
except Exception: # pragma: no cover
|
| 57 |
console.print("[red]openai package not installed. Please install dependencies.[/red]")
|
| 58 |
raise
|
| 59 |
return OpenAI(base_url=base_url, api_key=api_key or "")
|
|
|
|
| 101 |
if item.type == "output_text":
|
| 102 |
parts.append(item.text)
|
| 103 |
return "".join(parts)
|
| 104 |
+
except Exception:
|
| 105 |
raise
|
| 106 |
|
| 107 |
|
|
|
|
| 131 |
lines = b["lines"]
|
| 132 |
if not ranges and len(lines) > max_lines:
|
| 133 |
lines = lines[:max_lines]
|
| 134 |
+
llm_context.append(
|
| 135 |
+
f"FILE: {path} ({b['start']}-{b['start'] + len(lines) - 1})\n" + "\n".join(l for _, l in lines)
|
| 136 |
+
)
|
| 137 |
if not ranges and len(b["lines"]) > max_lines:
|
| 138 |
llm_context.append("[...truncated...]")
|
| 139 |
return "\n\n".join(rendered_parts), "\n\n".join(llm_context)
|
| 140 |
|
| 141 |
|
| 142 |
+
_READ_CMD = re.compile(
|
| 143 |
+
r"^\s*(?:/read|read|open|查看|读取|打开)\s+(?P<path>\S+)(?:\s+(?:lines?|行|第)\s*(?P<s>\d+)\s*[-~]\s*(?P<e>\d+))?\s*$",
|
| 144 |
+
re.I,
|
| 145 |
+
)
|
| 146 |
_PATH_RANGE_INLINE = re.compile(r"(?P<path>\S+?):(?P<s>\d+)-(?P<e>\d+)")
|
| 147 |
|
| 148 |
|
|
|
|
| 179 |
"parameters": {
|
| 180 |
"type": "object",
|
| 181 |
"properties": {
|
| 182 |
+
"path": {
|
| 183 |
+
"type": "string",
|
| 184 |
+
"description": "Path to a specific FILE (not directory). Use relative paths like 'src/amcp/readfile.py', NEVER directories like 'src/amcp'. COMMON FILES: 'src/amcp/readfile.py', 'src/amcp/rg.py', 'src/amcp/cli.py', 'src/amcp/chat.py', 'README.md', 'pyproject.toml'. Always include the file extension (.py, .md, .toml, etc).",
|
| 185 |
+
},
|
| 186 |
+
"ranges": {
|
| 187 |
+
"type": "array",
|
| 188 |
+
"items": {"type": "string", "pattern": "^\\d+-\\d+$"},
|
| 189 |
+
"description": "Optional list of line ranges like '1-200'. Use only if you need specific line ranges. For general file analysis, omit this to get the full file.",
|
| 190 |
+
},
|
| 191 |
+
"max_lines": {
|
| 192 |
+
"type": "integer",
|
| 193 |
+
"minimum": 1,
|
| 194 |
+
"maximum": 5000,
|
| 195 |
+
"description": "Safety cap for lines returned per block (default 400)",
|
| 196 |
+
},
|
| 197 |
},
|
| 198 |
"required": ["path"],
|
| 199 |
"additionalProperties": False,
|
|
|
|
| 205 |
def _get_chat_runtime_settings(override: dict | None = None) -> dict:
|
| 206 |
cfg: AMCPConfig = load_config()
|
| 207 |
chat_cfg = cfg.chat
|
| 208 |
+
tool_loop_limit = chat_cfg.tool_loop_limit if chat_cfg and chat_cfg.tool_loop_limit else 5
|
| 209 |
+
default_max_lines = chat_cfg.default_max_lines if chat_cfg and chat_cfg.default_max_lines else 400
|
| 210 |
roots: list[Path]
|
| 211 |
if chat_cfg and chat_cfg.read_roots:
|
| 212 |
roots = [Path(r).expanduser().resolve() for r in chat_cfg.read_roots]
|
|
|
|
| 237 |
allowed_roots: list[Path] = settings["allowed_roots"]
|
| 238 |
if not any(_is_within_root(p, root) for root in allowed_roots):
|
| 239 |
raise ValueError(f"Path {p} is outside allowed roots: {allowed_roots}")
|
| 240 |
+
|
| 241 |
# Check if path exists and is a file
|
| 242 |
if not p.exists():
|
| 243 |
raise FileNotFoundError(f"File not found: {p}")
|
| 244 |
if not p.is_file():
|
| 245 |
+
raise ValueError(
|
| 246 |
+
f"Path is a directory, not a file: {p}. Use a specific file like 'src/amcp/readfile.py' instead of just 'src/amcp'."
|
| 247 |
+
)
|
| 248 |
|
| 249 |
rendered, llm = _attach_file_context(p, ranges, max_lines=max_lines)
|
| 250 |
# content for model
|
|
|
|
| 260 |
return False
|
| 261 |
|
| 262 |
|
| 263 |
+
def _build_mcp_tools_and_registry(
|
| 264 |
+
cfg: AMCPConfig, chat_cfg: ChatConfig | None, servers_override: list[str] | None
|
| 265 |
+
) -> tuple[list[dict], dict]:
|
| 266 |
# Decide which servers to include
|
| 267 |
if chat_cfg and chat_cfg.mcp_tools_enabled is False:
|
| 268 |
return [], {}
|
|
|
|
| 284 |
oname = f"mcp.{name}.{tname}"
|
| 285 |
# Parameters: best effort; prefer server-provided schema when available
|
| 286 |
params = info.get("inputSchema") or {"type": "object"}
|
| 287 |
+
tools.append(
|
| 288 |
+
{
|
| 289 |
+
"type": "function",
|
| 290 |
+
"function": {"name": oname, "description": info.get("description", ""), "parameters": params},
|
| 291 |
+
}
|
| 292 |
+
)
|
| 293 |
reg[oname] = (name, tname)
|
| 294 |
except Exception as e:
|
| 295 |
console.print(f"[yellow]MCP tool discovery failed for server {name}:[/yellow] {e}")
|
| 296 |
return tools, reg
|
| 297 |
|
| 298 |
|
| 299 |
+
def _chat_with_tools(
|
| 300 |
+
client,
|
| 301 |
+
model: str,
|
| 302 |
+
base_messages: list[dict],
|
| 303 |
+
stream: bool,
|
| 304 |
+
settings_override: dict | None = None,
|
| 305 |
+
*,
|
| 306 |
+
extra_tools: list[dict] | None = None,
|
| 307 |
+
tool_registry: dict | None = None,
|
| 308 |
+
) -> str:
|
| 309 |
messages = list(base_messages)
|
| 310 |
used_tools = False
|
| 311 |
settings = _get_chat_runtime_settings(settings_override)
|
| 312 |
max_steps = settings["tool_loop_limit"]
|
| 313 |
+
tools = [_builtin_read_tool_spec()]
|
| 314 |
if extra_tools:
|
| 315 |
tools.extend(extra_tools)
|
| 316 |
registry = tool_registry or {}
|
|
|
|
| 336 |
read_file_call_count += 1
|
| 337 |
if read_file_call_count >= 2:
|
| 338 |
# Force the model to respond by adding a system message
|
| 339 |
+
messages.append(
|
| 340 |
+
{
|
| 341 |
+
"role": "system",
|
| 342 |
+
"content": "You have already read the file content. Please analyze the information you have and provide your response without calling the read_file tool again.",
|
| 343 |
+
}
|
| 344 |
+
)
|
| 345 |
break
|
| 346 |
# append assistant message with tool calls
|
| 347 |
assistant_msg = {"role": "assistant", "content": msg.content or "", "tool_calls": []}
|
|
|
|
| 352 |
args = json.loads(fn.arguments or "{}")
|
| 353 |
except Exception:
|
| 354 |
pass
|
| 355 |
+
assistant_msg["tool_calls"].append(
|
| 356 |
+
{
|
| 357 |
+
"id": tc.id,
|
| 358 |
+
"type": "function",
|
| 359 |
+
"function": {"name": fn.name, "arguments": fn.arguments or "{}"},
|
| 360 |
+
}
|
| 361 |
+
)
|
| 362 |
try:
|
| 363 |
if fn.name.startswith("mcp.") and registry:
|
| 364 |
server_name, inner_name = registry.get(fn.name, (None, None))
|
|
|
|
| 391 |
console.print(f"[red]Tool {fn.name} error:[/red] {e}")
|
| 392 |
# Add more specific error info for TaskGroup issues
|
| 393 |
if "TaskGroup" in str(e) and "exa" in fn.name:
|
| 394 |
+
console.print(
|
| 395 |
+
"[yellow]Hint: Exa MCP server may be experiencing connectivity issues. Try rephrasing your request or using local tools instead.[/yellow]"
|
| 396 |
+
)
|
| 397 |
# append tool result
|
| 398 |
messages.append(assistant_msg)
|
| 399 |
+
messages.append(
|
| 400 |
+
{
|
| 401 |
+
"role": "tool",
|
| 402 |
+
"tool_call_id": tc.id,
|
| 403 |
+
"name": fn.name,
|
| 404 |
+
"content": tool_result_text,
|
| 405 |
+
}
|
| 406 |
+
)
|
| 407 |
continue # back to the loop
|
| 408 |
else:
|
| 409 |
final_text = msg.content or ""
|
|
|
|
| 415 |
return final_text
|
| 416 |
return "[Tool loop limit reached]"
|
| 417 |
|
| 418 |
+
|
| 419 |
def _normalize_exa_web_search_args(args: dict) -> dict:
|
| 420 |
out = dict(args or {})
|
| 421 |
# Map possible synonyms
|
|
|
|
| 442 |
raise RuntimeError(f"Unknown MCP server '{server_name}'. Use 'amcp mcp tools -s ...' to verify.")
|
| 443 |
result = console.status("Calling MCP web_search_exa...")
|
| 444 |
with result:
|
| 445 |
+
resp = __import__("asyncio").run(
|
| 446 |
+
call_mcp_tool(
|
| 447 |
+
cfg.servers[server_name],
|
| 448 |
+
"web_search_exa",
|
| 449 |
+
{
|
| 450 |
+
"query": query,
|
| 451 |
+
"numResults": num_results,
|
| 452 |
+
"type": "fast",
|
| 453 |
+
},
|
| 454 |
+
)
|
| 455 |
+
)
|
| 456 |
# Render
|
| 457 |
out_lines = [f"MCP search results for: {query}"]
|
| 458 |
for block in resp.get("content", []):
|
|
|
|
| 466 |
|
| 467 |
|
| 468 |
def chat_once(
|
| 469 |
+
model: str | None,
|
| 470 |
user_text: str,
|
| 471 |
+
base_url: str | None = None,
|
| 472 |
+
system_prompt: str | None = None,
|
| 473 |
mcp_server: str = "exa",
|
| 474 |
stream: bool = True,
|
| 475 |
+
api_key: str | None = None,
|
| 476 |
+
work_dir: Path | None = None,
|
| 477 |
+
mcp_servers_override: list[str] | None = None,
|
| 478 |
+
mcp_tools_enabled: bool | None = None,
|
| 479 |
) -> str:
|
| 480 |
cfg: AMCPConfig = load_config()
|
| 481 |
chat_cfg = cfg.chat
|
|
|
|
| 493 |
messages.append({"role": "system", "content": system_prompt})
|
| 494 |
messages.append({"role": "user", "content": user_text})
|
| 495 |
overrides = {"read_roots": [str(work_dir.resolve())]} if work_dir else None
|
| 496 |
+
|
| 497 |
# Build MCP tools
|
| 498 |
extra_tools = []
|
| 499 |
registry = {}
|
| 500 |
enabled = True
|
| 501 |
+
|
| 502 |
# Check if MCP tools are disabled in config
|
| 503 |
+
if hasattr(chat_cfg, "mcp_tools_enabled") and chat_cfg and chat_cfg.mcp_tools_enabled is False:
|
| 504 |
enabled = False
|
| 505 |
+
|
| 506 |
# Override with command line flag
|
| 507 |
if mcp_tools_enabled is not None:
|
| 508 |
enabled = bool(mcp_tools_enabled)
|
| 509 |
+
|
| 510 |
if enabled:
|
| 511 |
extra_tools, registry = _build_mcp_tools_and_registry(cfg, chat_cfg, mcp_servers_override)
|
| 512 |
+
|
| 513 |
+
return _chat_with_tools(
|
| 514 |
+
client,
|
| 515 |
+
resolved_model,
|
| 516 |
+
messages,
|
| 517 |
+
stream=stream,
|
| 518 |
+
settings_override=overrides,
|
| 519 |
+
extra_tools=extra_tools,
|
| 520 |
+
tool_registry=registry,
|
| 521 |
+
)
|
| 522 |
|
| 523 |
|
| 524 |
def chat_repl(
|
| 525 |
+
model: str | None,
|
| 526 |
+
base_url: str | None = None,
|
| 527 |
+
system_prompt: str | None = None,
|
| 528 |
mcp_server: str = "exa",
|
| 529 |
stream: bool = True,
|
| 530 |
+
api_key: str | None = None,
|
| 531 |
+
work_dir: Path | None = None,
|
| 532 |
+
mcp_servers_override: list[str] | None = None,
|
| 533 |
+
mcp_tools_enabled: bool | None = None,
|
| 534 |
) -> None:
|
| 535 |
cfg: AMCPConfig = load_config()
|
| 536 |
chat_cfg = cfg.chat
|
|
|
|
| 551 |
cfg = load_config()
|
| 552 |
chat_cfg = cfg.chat
|
| 553 |
enabled = True
|
| 554 |
+
if hasattr(chat_cfg, "mcp_tools_enabled") and chat_cfg and chat_cfg.mcp_tools_enabled is False:
|
| 555 |
enabled = False
|
| 556 |
if mcp_tools_enabled is not None:
|
| 557 |
enabled = bool(mcp_tools_enabled)
|
|
|
|
| 559 |
registry = {}
|
| 560 |
if enabled:
|
| 561 |
extra_tools, registry = _build_mcp_tools_and_registry(cfg, chat_cfg, mcp_servers_override)
|
| 562 |
+
enabled_servers = sorted(set(name.split(".")[1] for name in registry.keys())) if registry else []
|
| 563 |
mcp_line = f"MCP tools: {'on' if extra_tools else 'off'}; servers: {', '.join(enabled_servers) if enabled_servers else '-'}"
|
| 564 |
+
console.print(
|
| 565 |
+
Panel(
|
| 566 |
+
f"Chat model: [bold]{resolved_model}[/bold]\n"
|
| 567 |
+
f"Base: {base}\n"
|
| 568 |
+
f"Tool loop limit: {settings['tool_loop_limit']}\n"
|
| 569 |
+
f"Default max lines: {settings['default_max_lines']}\n"
|
| 570 |
+
f"Allowed read roots:\n{roots_str}\n"
|
| 571 |
+
f"{mcp_line}\n\n"
|
| 572 |
+
f"Commands: /read <path> [lines A-B], /search <q>, /quit",
|
| 573 |
+
title="amcp chat",
|
| 574 |
+
border_style="green",
|
| 575 |
+
)
|
| 576 |
+
)
|
| 577 |
|
| 578 |
messages: list[dict[str, str]] = []
|
| 579 |
if system_prompt:
|
|
|
|
| 614 |
console.print(f"[yellow]File intent parse/read warning:[/yellow] {e}")
|
| 615 |
|
| 616 |
if text.startswith("/search "):
|
| 617 |
+
q = text[len("/search ") :].strip()
|
| 618 |
try:
|
| 619 |
result = do_exa_search(mcp_server, q)
|
| 620 |
console.print(Panel(Markdown(result), title="exa search", border_style="magenta"))
|
|
|
|
| 625 |
messages.append({"role": "user", "content": text})
|
| 626 |
try:
|
| 627 |
extra_tools, registry = _build_mcp_tools_and_registry(load_config(), load_config().chat, None)
|
| 628 |
+
reply = _chat_with_tools(
|
| 629 |
+
client,
|
| 630 |
+
resolved_model,
|
| 631 |
+
messages,
|
| 632 |
+
stream=stream,
|
| 633 |
+
settings_override=overrides,
|
| 634 |
+
extra_tools=extra_tools,
|
| 635 |
+
tool_registry=registry,
|
| 636 |
+
)
|
| 637 |
messages.append({"role": "assistant", "content": reply})
|
| 638 |
except Exception as e:
|
| 639 |
console.print(f"[red]Chat error:[/red] {e}")
|
src/amcp/cli.py
CHANGED
|
@@ -9,14 +9,13 @@ from typing import Annotated
|
|
| 9 |
import typer
|
| 10 |
from rich.console import Console
|
| 11 |
from rich.json import JSON
|
| 12 |
-
from rich.panel import Panel
|
| 13 |
from rich.markdown import Markdown
|
| 14 |
-
|
| 15 |
-
from .config import AMCPConfig, load_config, save_default_config, save_config, Server
|
| 16 |
-
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 17 |
|
| 18 |
from .agent import Agent
|
| 19 |
-
from .agent_spec import
|
|
|
|
|
|
|
| 20 |
|
| 21 |
app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
|
| 22 |
console = Console()
|
|
@@ -45,7 +44,7 @@ def mcp_tools(server: Annotated[str, typer.Option("--server", "-s")]):
|
|
| 45 |
def mcp_call(
|
| 46 |
server: Annotated[str, typer.Option("--server", "-s")],
|
| 47 |
tool: Annotated[str, typer.Option("--tool", "-t")],
|
| 48 |
-
args: Annotated[str | None, typer.Option("--args", help="JSON-encoded arguments")]=None,
|
| 49 |
):
|
| 50 |
cfg: AMCPConfig = load_config()
|
| 51 |
if server not in cfg.servers:
|
|
@@ -60,35 +59,44 @@ def main(
|
|
| 60 |
ctx: typer.Context,
|
| 61 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 62 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 63 |
-
work_dir: Annotated[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 65 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 66 |
-
session_id: Annotated[
|
|
|
|
|
|
|
| 67 |
clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
|
| 68 |
-
list_sessions: Annotated[
|
|
|
|
|
|
|
| 69 |
) -> None:
|
| 70 |
"""Enhanced agent chat with improved tool management and context awareness."""
|
| 71 |
-
|
| 72 |
# If a subcommand is invoked, don't run the agent
|
| 73 |
if ctx.invoked_subcommand is not None:
|
| 74 |
return
|
| 75 |
-
|
| 76 |
# Handle session listing
|
| 77 |
if list_sessions:
|
| 78 |
sessions_dir = Path.home() / ".config" / "amcp" / "sessions"
|
| 79 |
if not sessions_dir.exists():
|
| 80 |
console.print("[yellow]No sessions directory found[/yellow]")
|
| 81 |
return
|
| 82 |
-
|
| 83 |
session_files = list(sessions_dir.glob("*.json"))
|
| 84 |
if not session_files:
|
| 85 |
console.print("[yellow]No conversation sessions found[/yellow]")
|
| 86 |
return
|
| 87 |
-
|
| 88 |
console.print("[bold]Available Conversation Sessions:[/bold]")
|
| 89 |
for session_file in sorted(session_files, key=lambda f: f.stat().st_mtime, reverse=True):
|
| 90 |
try:
|
| 91 |
-
with open(session_file,
|
| 92 |
data = json.load(f)
|
| 93 |
console.print(f"📄 {data.get('session_id', session_file.stem)}")
|
| 94 |
console.print(f" Agent: {data.get('agent_name', 'Unknown')}")
|
|
@@ -99,22 +107,24 @@ def main(
|
|
| 99 |
except Exception as e:
|
| 100 |
console.print(f"❌ {session_file.name}: {e}")
|
| 101 |
return
|
| 102 |
-
|
| 103 |
if list_agents:
|
| 104 |
# Check both global config dir and local agents dir
|
| 105 |
agents_dir = Path(os.path.expanduser("~/.config/amcp/agents"))
|
| 106 |
local_agents_dir = Path("agents")
|
| 107 |
-
|
| 108 |
agent_files = list_available_agents(agents_dir)
|
| 109 |
local_agent_files = list_available_agents(local_agents_dir)
|
| 110 |
-
|
| 111 |
# Combine both lists
|
| 112 |
all_agent_files = agent_files + local_agent_files
|
| 113 |
-
|
| 114 |
if not all_agent_files:
|
| 115 |
-
console.print(
|
|
|
|
|
|
|
| 116 |
return
|
| 117 |
-
|
| 118 |
console.print("[bold]Available Agent Specifications:[/bold]")
|
| 119 |
for agent_file in all_agent_files:
|
| 120 |
try:
|
|
@@ -126,7 +136,7 @@ def main(
|
|
| 126 |
console.print()
|
| 127 |
except Exception as e:
|
| 128 |
console.print(f"❌ {agent_file.name}: {e}")
|
| 129 |
-
|
| 130 |
# Also show default agent
|
| 131 |
default_spec = get_default_agent_spec()
|
| 132 |
console.print("[bold]Default Agent:[/bold]")
|
|
@@ -134,7 +144,7 @@ def main(
|
|
| 134 |
console.print(f" Description: {default_spec.description}")
|
| 135 |
console.print(f" Tools: {len(default_spec.tools)}")
|
| 136 |
return
|
| 137 |
-
|
| 138 |
try:
|
| 139 |
# Load agent specification
|
| 140 |
if agent_file:
|
|
@@ -147,62 +157,61 @@ def main(
|
|
| 147 |
else:
|
| 148 |
agent_spec = get_default_agent_spec()
|
| 149 |
console.print(f"[green]Using default agent: {agent_spec.name}[/green]")
|
| 150 |
-
|
| 151 |
# Create agent with session management
|
| 152 |
agent = Agent(agent_spec, session_id=session_id)
|
| 153 |
-
|
| 154 |
# Handle session clearing
|
| 155 |
if clear_session:
|
| 156 |
agent.clear_conversation_history()
|
| 157 |
console.print(f"[green]Cleared conversation history for session: {agent.session_id}[/green]")
|
| 158 |
-
|
| 159 |
# Show session info
|
| 160 |
session_info = agent.get_conversation_summary()
|
| 161 |
-
if session_info[
|
| 162 |
console.print(f"[dim]Session {agent.session_id}: {session_info['message_count']} messages in history[/dim]")
|
| 163 |
else:
|
| 164 |
console.print(f"[dim]New session started: {agent.session_id}[/dim]")
|
| 165 |
-
|
| 166 |
if message is not None:
|
| 167 |
# Single message mode
|
| 168 |
console.print(f"[bold]🤖 Agent {agent.name}[/bold] - Processing...")
|
| 169 |
-
response = asyncio.run(
|
| 170 |
-
user_input=message,
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
show_progress=not no_progress
|
| 174 |
-
))
|
| 175 |
-
|
| 176 |
console.print(Panel(Markdown(response), title=f"Agent {agent.name}", border_style="cyan"))
|
| 177 |
-
|
| 178 |
# Show execution summary
|
| 179 |
summary = agent.get_execution_summary()
|
| 180 |
-
console.print(
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
else:
|
| 183 |
# Interactive mode
|
| 184 |
console.print(f"[bold]🤖 Agent {agent.name} - Interactive Mode[/bold]")
|
| 185 |
console.print(f"[dim]Description: {agent_spec.description}[/dim]")
|
| 186 |
console.print(f"[dim]Max steps: {agent_spec.max_steps} | Session: {agent.session_id}[/dim]")
|
| 187 |
-
console.print(
|
| 188 |
console.print()
|
| 189 |
-
|
| 190 |
while True:
|
| 191 |
try:
|
| 192 |
user_input = console.input("[bold]You:[/bold] ").strip()
|
| 193 |
-
|
| 194 |
-
if user_input.lower() in [
|
| 195 |
console.print("[green]Goodbye! 👋[/green]")
|
| 196 |
break
|
| 197 |
-
|
| 198 |
-
if user_input.lower() ==
|
| 199 |
agent.clear_conversation_history()
|
| 200 |
console.print(f"[green]Conversation history cleared for session: {agent.session_id}[/green]")
|
| 201 |
continue
|
| 202 |
-
|
| 203 |
-
if user_input.lower() ==
|
| 204 |
session_info = agent.get_conversation_summary()
|
| 205 |
-
console.print(
|
| 206 |
console.print(f"Session ID: {session_info['session_id']}")
|
| 207 |
console.print(f"Agent: {session_info['agent_name']}")
|
| 208 |
console.print(f"Messages: {session_info['message_count']}")
|
|
@@ -210,25 +219,24 @@ def main(
|
|
| 210 |
console.print(f"Session file: {session_info['session_file']}")
|
| 211 |
console.print()
|
| 212 |
continue
|
| 213 |
-
|
| 214 |
if not user_input:
|
| 215 |
continue
|
| 216 |
-
|
| 217 |
console.print(f"[bold]🤖 Agent {agent.name}[/bold] - Processing...")
|
| 218 |
-
response = asyncio.run(
|
| 219 |
-
user_input=user_input,
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
show_progress=not no_progress
|
| 223 |
-
))
|
| 224 |
-
|
| 225 |
console.print(Panel(Markdown(response), title=f"Agent {agent.name}", border_style="cyan"))
|
| 226 |
-
|
| 227 |
# Show execution summary
|
| 228 |
summary = agent.get_execution_summary()
|
| 229 |
-
console.print(
|
|
|
|
|
|
|
| 230 |
console.print()
|
| 231 |
-
|
| 232 |
except EOFError:
|
| 233 |
console.print("[green]Goodbye! 👋[/green]")
|
| 234 |
break
|
|
@@ -237,31 +245,46 @@ def main(
|
|
| 237 |
except Exception as e:
|
| 238 |
console.print(f"[red]Error: {e}[/red]")
|
| 239 |
console.print()
|
| 240 |
-
|
| 241 |
except Exception as e:
|
| 242 |
console.print(f"[red]Agent failed:[/red] {e}")
|
| 243 |
raise typer.Exit(code=1)
|
| 244 |
|
| 245 |
|
| 246 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 247 |
-
|
| 248 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 249 |
def agent(
|
| 250 |
ctx: typer.Context,
|
| 251 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 252 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 253 |
-
work_dir: Annotated[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 255 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 256 |
-
session_id: Annotated[
|
|
|
|
|
|
|
| 257 |
clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
|
| 258 |
-
list_sessions: Annotated[
|
|
|
|
|
|
|
| 259 |
) -> None:
|
| 260 |
"""Enhanced agent chat (alias for default command)."""
|
| 261 |
-
ctx.invoke(
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
if __name__ == "__main__":
|
|
|
|
| 9 |
import typer
|
| 10 |
from rich.console import Console
|
| 11 |
from rich.json import JSON
|
|
|
|
| 12 |
from rich.markdown import Markdown
|
| 13 |
+
from rich.panel import Panel
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from .agent import Agent
|
| 16 |
+
from .agent_spec import get_default_agent_spec, list_available_agents, load_agent_spec
|
| 17 |
+
from .config import AMCPConfig, load_config, save_default_config
|
| 18 |
+
from .mcp_client import call_mcp_tool, list_mcp_tools
|
| 19 |
|
| 20 |
app = typer.Typer(add_completion=False, context_settings={"help_option_names": ["-h", "--help"]})
|
| 21 |
console = Console()
|
|
|
|
| 44 |
def mcp_call(
|
| 45 |
server: Annotated[str, typer.Option("--server", "-s")],
|
| 46 |
tool: Annotated[str, typer.Option("--tool", "-t")],
|
| 47 |
+
args: Annotated[str | None, typer.Option("--args", help="JSON-encoded arguments")] = None,
|
| 48 |
):
|
| 49 |
cfg: AMCPConfig = load_config()
|
| 50 |
if server not in cfg.servers:
|
|
|
|
| 59 |
ctx: typer.Context,
|
| 60 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 61 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 62 |
+
work_dir: Annotated[
|
| 63 |
+
Path | None,
|
| 64 |
+
typer.Option(
|
| 65 |
+
"--work-dir", "-w", help="Set working directory", exists=True, file_okay=False, dir_okay=True, readable=True
|
| 66 |
+
),
|
| 67 |
+
] = None,
|
| 68 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 69 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 70 |
+
session_id: Annotated[
|
| 71 |
+
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 72 |
+
] = None,
|
| 73 |
clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
|
| 74 |
+
list_sessions: Annotated[
|
| 75 |
+
bool, typer.Option("--list-sessions", help="List available conversation sessions")
|
| 76 |
+
] = False,
|
| 77 |
) -> None:
|
| 78 |
"""Enhanced agent chat with improved tool management and context awareness."""
|
| 79 |
+
|
| 80 |
# If a subcommand is invoked, don't run the agent
|
| 81 |
if ctx.invoked_subcommand is not None:
|
| 82 |
return
|
| 83 |
+
|
| 84 |
# Handle session listing
|
| 85 |
if list_sessions:
|
| 86 |
sessions_dir = Path.home() / ".config" / "amcp" / "sessions"
|
| 87 |
if not sessions_dir.exists():
|
| 88 |
console.print("[yellow]No sessions directory found[/yellow]")
|
| 89 |
return
|
| 90 |
+
|
| 91 |
session_files = list(sessions_dir.glob("*.json"))
|
| 92 |
if not session_files:
|
| 93 |
console.print("[yellow]No conversation sessions found[/yellow]")
|
| 94 |
return
|
| 95 |
+
|
| 96 |
console.print("[bold]Available Conversation Sessions:[/bold]")
|
| 97 |
for session_file in sorted(session_files, key=lambda f: f.stat().st_mtime, reverse=True):
|
| 98 |
try:
|
| 99 |
+
with open(session_file, encoding="utf-8") as f:
|
| 100 |
data = json.load(f)
|
| 101 |
console.print(f"📄 {data.get('session_id', session_file.stem)}")
|
| 102 |
console.print(f" Agent: {data.get('agent_name', 'Unknown')}")
|
|
|
|
| 107 |
except Exception as e:
|
| 108 |
console.print(f"❌ {session_file.name}: {e}")
|
| 109 |
return
|
| 110 |
+
|
| 111 |
if list_agents:
|
| 112 |
# Check both global config dir and local agents dir
|
| 113 |
agents_dir = Path(os.path.expanduser("~/.config/amcp/agents"))
|
| 114 |
local_agents_dir = Path("agents")
|
| 115 |
+
|
| 116 |
agent_files = list_available_agents(agents_dir)
|
| 117 |
local_agent_files = list_available_agents(local_agents_dir)
|
| 118 |
+
|
| 119 |
# Combine both lists
|
| 120 |
all_agent_files = agent_files + local_agent_files
|
| 121 |
+
|
| 122 |
if not all_agent_files:
|
| 123 |
+
console.print(
|
| 124 |
+
"[yellow]No agent specifications found. Create one in ~/.config/amcp/agents/ or local agents/[/yellow]"
|
| 125 |
+
)
|
| 126 |
return
|
| 127 |
+
|
| 128 |
console.print("[bold]Available Agent Specifications:[/bold]")
|
| 129 |
for agent_file in all_agent_files:
|
| 130 |
try:
|
|
|
|
| 136 |
console.print()
|
| 137 |
except Exception as e:
|
| 138 |
console.print(f"❌ {agent_file.name}: {e}")
|
| 139 |
+
|
| 140 |
# Also show default agent
|
| 141 |
default_spec = get_default_agent_spec()
|
| 142 |
console.print("[bold]Default Agent:[/bold]")
|
|
|
|
| 144 |
console.print(f" Description: {default_spec.description}")
|
| 145 |
console.print(f" Tools: {len(default_spec.tools)}")
|
| 146 |
return
|
| 147 |
+
|
| 148 |
try:
|
| 149 |
# Load agent specification
|
| 150 |
if agent_file:
|
|
|
|
| 157 |
else:
|
| 158 |
agent_spec = get_default_agent_spec()
|
| 159 |
console.print(f"[green]Using default agent: {agent_spec.name}[/green]")
|
| 160 |
+
|
| 161 |
# Create agent with session management
|
| 162 |
agent = Agent(agent_spec, session_id=session_id)
|
| 163 |
+
|
| 164 |
# Handle session clearing
|
| 165 |
if clear_session:
|
| 166 |
agent.clear_conversation_history()
|
| 167 |
console.print(f"[green]Cleared conversation history for session: {agent.session_id}[/green]")
|
| 168 |
+
|
| 169 |
# Show session info
|
| 170 |
session_info = agent.get_conversation_summary()
|
| 171 |
+
if session_info["message_count"] > 0:
|
| 172 |
console.print(f"[dim]Session {agent.session_id}: {session_info['message_count']} messages in history[/dim]")
|
| 173 |
else:
|
| 174 |
console.print(f"[dim]New session started: {agent.session_id}[/dim]")
|
| 175 |
+
|
| 176 |
if message is not None:
|
| 177 |
# Single message mode
|
| 178 |
console.print(f"[bold]🤖 Agent {agent.name}[/bold] - Processing...")
|
| 179 |
+
response = asyncio.run(
|
| 180 |
+
agent.run(user_input=message, work_dir=work_dir, stream=False, show_progress=not no_progress)
|
| 181 |
+
)
|
| 182 |
+
|
|
|
|
|
|
|
|
|
|
| 183 |
console.print(Panel(Markdown(response), title=f"Agent {agent.name}", border_style="cyan"))
|
| 184 |
+
|
| 185 |
# Show execution summary
|
| 186 |
summary = agent.get_execution_summary()
|
| 187 |
+
console.print(
|
| 188 |
+
f"[dim]Steps: {summary['steps_taken']}/{summary['max_steps']} | Tools called: {summary['tools_called']}[/dim]"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
else:
|
| 192 |
# Interactive mode
|
| 193 |
console.print(f"[bold]🤖 Agent {agent.name} - Interactive Mode[/bold]")
|
| 194 |
console.print(f"[dim]Description: {agent_spec.description}[/dim]")
|
| 195 |
console.print(f"[dim]Max steps: {agent_spec.max_steps} | Session: {agent.session_id}[/dim]")
|
| 196 |
+
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear history, 'info' for session info[/dim]")
|
| 197 |
console.print()
|
| 198 |
+
|
| 199 |
while True:
|
| 200 |
try:
|
| 201 |
user_input = console.input("[bold]You:[/bold] ").strip()
|
| 202 |
+
|
| 203 |
+
if user_input.lower() in ["exit", "quit", "q"]:
|
| 204 |
console.print("[green]Goodbye! 👋[/green]")
|
| 205 |
break
|
| 206 |
+
|
| 207 |
+
if user_input.lower() == "clear":
|
| 208 |
agent.clear_conversation_history()
|
| 209 |
console.print(f"[green]Conversation history cleared for session: {agent.session_id}[/green]")
|
| 210 |
continue
|
| 211 |
+
|
| 212 |
+
if user_input.lower() == "info":
|
| 213 |
session_info = agent.get_conversation_summary()
|
| 214 |
+
console.print("[bold]Session Info:[/bold]")
|
| 215 |
console.print(f"Session ID: {session_info['session_id']}")
|
| 216 |
console.print(f"Agent: {session_info['agent_name']}")
|
| 217 |
console.print(f"Messages: {session_info['message_count']}")
|
|
|
|
| 219 |
console.print(f"Session file: {session_info['session_file']}")
|
| 220 |
console.print()
|
| 221 |
continue
|
| 222 |
+
|
| 223 |
if not user_input:
|
| 224 |
continue
|
| 225 |
+
|
| 226 |
console.print(f"[bold]🤖 Agent {agent.name}[/bold] - Processing...")
|
| 227 |
+
response = asyncio.run(
|
| 228 |
+
agent.run(user_input=user_input, work_dir=work_dir, stream=False, show_progress=not no_progress)
|
| 229 |
+
)
|
| 230 |
+
|
|
|
|
|
|
|
|
|
|
| 231 |
console.print(Panel(Markdown(response), title=f"Agent {agent.name}", border_style="cyan"))
|
| 232 |
+
|
| 233 |
# Show execution summary
|
| 234 |
summary = agent.get_execution_summary()
|
| 235 |
+
console.print(
|
| 236 |
+
f"[dim]Steps: {summary['steps_taken']}/{summary['max_steps']} | Tools called: {summary['tools_called']} | Session: {agent.session_id}[/dim]"
|
| 237 |
+
)
|
| 238 |
console.print()
|
| 239 |
+
|
| 240 |
except EOFError:
|
| 241 |
console.print("[green]Goodbye! 👋[/green]")
|
| 242 |
break
|
|
|
|
| 245 |
except Exception as e:
|
| 246 |
console.print(f"[red]Error: {e}[/red]")
|
| 247 |
console.print()
|
| 248 |
+
|
| 249 |
except Exception as e:
|
| 250 |
console.print(f"[red]Agent failed:[/red] {e}")
|
| 251 |
raise typer.Exit(code=1)
|
| 252 |
|
| 253 |
|
| 254 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
|
|
|
| 255 |
@app.command(help="Enhanced agent chat (alias for default command)")
|
| 256 |
def agent(
|
| 257 |
ctx: typer.Context,
|
| 258 |
message: Annotated[str | None, typer.Option("--once", help="Send one message and exit")] = None,
|
| 259 |
agent_file: Annotated[str | None, typer.Option("--agent", help="Path to agent specification file")] = None,
|
| 260 |
+
work_dir: Annotated[
|
| 261 |
+
Path | None,
|
| 262 |
+
typer.Option(
|
| 263 |
+
"--work-dir", "-w", help="Set working directory", exists=True, file_okay=False, dir_okay=True, readable=True
|
| 264 |
+
),
|
| 265 |
+
] = None,
|
| 266 |
no_progress: Annotated[bool, typer.Option("--no-progress", help="Disable progress indicators")] = False,
|
| 267 |
list_agents: Annotated[bool, typer.Option("--list", help="List available agent specifications")] = False,
|
| 268 |
+
session_id: Annotated[
|
| 269 |
+
str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")
|
| 270 |
+
] = None,
|
| 271 |
clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
|
| 272 |
+
list_sessions: Annotated[
|
| 273 |
+
bool, typer.Option("--list-sessions", help="List available conversation sessions")
|
| 274 |
+
] = False,
|
| 275 |
) -> None:
|
| 276 |
"""Enhanced agent chat (alias for default command)."""
|
| 277 |
+
ctx.invoke(
|
| 278 |
+
main,
|
| 279 |
+
message=message,
|
| 280 |
+
agent_file=agent_file,
|
| 281 |
+
work_dir=work_dir,
|
| 282 |
+
no_progress=no_progress,
|
| 283 |
+
list_agents=list_agents,
|
| 284 |
+
session_id=session_id,
|
| 285 |
+
clear_session=clear_session,
|
| 286 |
+
list_sessions=list_sessions,
|
| 287 |
+
)
|
| 288 |
|
| 289 |
|
| 290 |
if __name__ == "__main__":
|
src/amcp/config.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Dict, List, Mapping, Optional
|
| 7 |
|
| 8 |
try:
|
| 9 |
import tomllib # py311+
|
|
@@ -12,7 +12,6 @@ except ModuleNotFoundError: # pragma: no cover
|
|
| 12 |
|
| 13 |
import tomli_w # type: ignore
|
| 14 |
|
| 15 |
-
|
| 16 |
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "amcp"
|
| 17 |
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
| 18 |
|
|
@@ -20,35 +19,35 @@ CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
| 20 |
@dataclass
|
| 21 |
class Server:
|
| 22 |
# stdio transport fields
|
| 23 |
-
command:
|
| 24 |
-
args:
|
| 25 |
-
env:
|
| 26 |
# http(sse) transport fields
|
| 27 |
-
url:
|
| 28 |
-
headers:
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
| 32 |
class ChatConfig:
|
| 33 |
-
base_url:
|
| 34 |
-
model:
|
| 35 |
-
api_key:
|
| 36 |
# Tool calling settings
|
| 37 |
-
tool_loop_limit:
|
| 38 |
-
default_max_lines:
|
| 39 |
-
read_roots:
|
| 40 |
# MCP tool exposure
|
| 41 |
-
mcp_tools_enabled:
|
| 42 |
-
mcp_servers:
|
| 43 |
# Built-in file modification tools
|
| 44 |
-
write_tool_enabled:
|
| 45 |
-
edit_tool_enabled:
|
| 46 |
|
| 47 |
|
| 48 |
@dataclass
|
| 49 |
class AMCPConfig:
|
| 50 |
-
servers:
|
| 51 |
-
chat:
|
| 52 |
|
| 53 |
|
| 54 |
_DEFAULT = {
|
|
@@ -68,7 +67,7 @@ _DEFAULT = {
|
|
| 68 |
# "mcp_servers": ["exa"] # optional; if unset, expose all configured servers
|
| 69 |
"write_tool_enabled": True,
|
| 70 |
"edit_tool_enabled": True,
|
| 71 |
-
}
|
| 72 |
}
|
| 73 |
|
| 74 |
|
|
@@ -83,7 +82,7 @@ def _decode_server(name: str, raw: Mapping[str, object]) -> Server:
|
|
| 83 |
return Server(command=command_s, args=args, env=env, url=url_s, headers=headers)
|
| 84 |
|
| 85 |
|
| 86 |
-
def _decode_chat(raw: Mapping[str, object] | None) ->
|
| 87 |
if not raw:
|
| 88 |
return None
|
| 89 |
base_url = raw.get("base_url")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
| 4 |
+
from collections.abc import Mapping
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
|
| 8 |
try:
|
| 9 |
import tomllib # py311+
|
|
|
|
| 12 |
|
| 13 |
import tomli_w # type: ignore
|
| 14 |
|
|
|
|
| 15 |
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "amcp"
|
| 16 |
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
| 17 |
|
|
|
|
| 19 |
@dataclass
|
| 20 |
class Server:
|
| 21 |
# stdio transport fields
|
| 22 |
+
command: str | None = None
|
| 23 |
+
args: list[str] = field(default_factory=list)
|
| 24 |
+
env: dict[str, str] = field(default_factory=dict)
|
| 25 |
# http(sse) transport fields
|
| 26 |
+
url: str | None = None
|
| 27 |
+
headers: dict[str, str] = field(default_factory=dict)
|
| 28 |
|
| 29 |
|
| 30 |
@dataclass
|
| 31 |
class ChatConfig:
|
| 32 |
+
base_url: str | None = None
|
| 33 |
+
model: str | None = None
|
| 34 |
+
api_key: str | None = None
|
| 35 |
# Tool calling settings
|
| 36 |
+
tool_loop_limit: int | None = None
|
| 37 |
+
default_max_lines: int | None = None
|
| 38 |
+
read_roots: list[str] | None = None # list of allowed root paths for read_file
|
| 39 |
# MCP tool exposure
|
| 40 |
+
mcp_tools_enabled: bool | None = None
|
| 41 |
+
mcp_servers: list[str] | None = None # which servers' tools to expose; if unset, expose all configured servers
|
| 42 |
# Built-in file modification tools
|
| 43 |
+
write_tool_enabled: bool | None = None
|
| 44 |
+
edit_tool_enabled: bool | None = None
|
| 45 |
|
| 46 |
|
| 47 |
@dataclass
|
| 48 |
class AMCPConfig:
|
| 49 |
+
servers: dict[str, Server]
|
| 50 |
+
chat: ChatConfig | None = None
|
| 51 |
|
| 52 |
|
| 53 |
_DEFAULT = {
|
|
|
|
| 67 |
# "mcp_servers": ["exa"] # optional; if unset, expose all configured servers
|
| 68 |
"write_tool_enabled": True,
|
| 69 |
"edit_tool_enabled": True,
|
| 70 |
+
},
|
| 71 |
}
|
| 72 |
|
| 73 |
|
|
|
|
| 82 |
return Server(command=command_s, args=args, env=env, url=url_s, headers=headers)
|
| 83 |
|
| 84 |
|
| 85 |
+
def _decode_chat(raw: Mapping[str, object] | None) -> ChatConfig | None:
|
| 86 |
if not raw:
|
| 87 |
return None
|
| 88 |
base_url = raw.get("base_url")
|
src/amcp/mcp_client.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
| 4 |
-
from
|
|
|
|
| 5 |
|
| 6 |
from mcp import ClientSession
|
| 7 |
from mcp.client.stdio import StdioServerParameters, stdio_client
|
| 8 |
-
from mcp.client.sse import sse_client
|
| 9 |
from mcp.client.streamable_http import streamablehttp_client
|
| 10 |
|
| 11 |
from .config import Server
|
| 12 |
-
import os
|
| 13 |
|
| 14 |
|
| 15 |
def _expand_env(value: str) -> str:
|
|
@@ -17,13 +16,10 @@ def _expand_env(value: str) -> str:
|
|
| 17 |
return os.path.expandvars(value)
|
| 18 |
|
| 19 |
|
| 20 |
-
def _headers_with_env(headers:
|
| 21 |
return {k: _expand_env(v) for k, v in headers.items()}
|
| 22 |
|
| 23 |
|
| 24 |
-
from contextlib import asynccontextmanager
|
| 25 |
-
|
| 26 |
-
|
| 27 |
@asynccontextmanager
|
| 28 |
async def _open_transport(server: Server):
|
| 29 |
"""Yield (read, write) streams for the given server, regardless of transport."""
|
|
@@ -39,63 +35,63 @@ async def _open_transport(server: Server):
|
|
| 39 |
merged = dict(base_env)
|
| 40 |
for k, v in (server.env or {}).items():
|
| 41 |
merged[str(k)] = _expand_env(str(v))
|
| 42 |
-
|
| 43 |
params = StdioServerParameters(command=server.command or "", args=server.args, env=merged)
|
| 44 |
-
|
| 45 |
# Suppress MCP server stderr by redirecting to devnull
|
| 46 |
-
with open(os.devnull,
|
| 47 |
async with stdio_client(params, errlog=devnull) as (read, write):
|
| 48 |
yield read, write
|
| 49 |
|
| 50 |
|
| 51 |
-
async def list_mcp_tools(server: Server) ->
|
| 52 |
-
async with _open_transport(server) as (read, write):
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
schema = None
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
if raw_schema is not None:
|
| 62 |
-
if hasattr(raw_schema, "model_dump"):
|
| 63 |
-
schema = raw_schema.model_dump(by_alias=True, exclude_none=True)
|
| 64 |
-
elif hasattr(raw_schema, "to_dict"):
|
| 65 |
-
schema = raw_schema.to_dict()
|
| 66 |
-
else:
|
| 67 |
-
schema = raw_schema
|
| 68 |
-
except Exception:
|
| 69 |
-
schema = None
|
| 70 |
-
tools.append({
|
| 71 |
"name": t.name,
|
| 72 |
"description": (t.description or ""),
|
| 73 |
"inputSchema": schema,
|
| 74 |
-
}
|
| 75 |
-
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
-
async def call_mcp_tool(server: Server, tool_name: str, arguments:
|
| 79 |
try:
|
| 80 |
-
async with _open_transport(server) as (read, write):
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
}
|
| 99 |
except* Exception as eg:
|
| 100 |
# Handle ExceptionGroup from TaskGroup
|
| 101 |
errors = [str(e) for e in eg.exceptions]
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import os
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
from typing import Any
|
| 6 |
|
| 7 |
from mcp import ClientSession
|
| 8 |
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
|
|
| 9 |
from mcp.client.streamable_http import streamablehttp_client
|
| 10 |
|
| 11 |
from .config import Server
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def _expand_env(value: str) -> str:
|
|
|
|
| 16 |
return os.path.expandvars(value)
|
| 17 |
|
| 18 |
|
| 19 |
+
def _headers_with_env(headers: dict[str, str]) -> dict[str, str]:
|
| 20 |
return {k: _expand_env(v) for k, v in headers.items()}
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
@asynccontextmanager
|
| 24 |
async def _open_transport(server: Server):
|
| 25 |
"""Yield (read, write) streams for the given server, regardless of transport."""
|
|
|
|
| 35 |
merged = dict(base_env)
|
| 36 |
for k, v in (server.env or {}).items():
|
| 37 |
merged[str(k)] = _expand_env(str(v))
|
| 38 |
+
|
| 39 |
params = StdioServerParameters(command=server.command or "", args=server.args, env=merged)
|
| 40 |
+
|
| 41 |
# Suppress MCP server stderr by redirecting to devnull
|
| 42 |
+
with open(os.devnull, "w") as devnull:
|
| 43 |
async with stdio_client(params, errlog=devnull) as (read, write):
|
| 44 |
yield read, write
|
| 45 |
|
| 46 |
|
| 47 |
+
async def list_mcp_tools(server: Server) -> list[dict[str, Any]]:
|
| 48 |
+
async with _open_transport(server) as (read, write), ClientSession(read, write) as session:
|
| 49 |
+
await session.initialize()
|
| 50 |
+
result = await session.list_tools()
|
| 51 |
+
tools: list[dict[str, Any]] = []
|
| 52 |
+
for t in result.tools:
|
| 53 |
+
schema = None
|
| 54 |
+
try:
|
| 55 |
+
raw_schema = getattr(t, "inputSchema", None) or getattr(t, "input_schema", None)
|
| 56 |
+
if raw_schema is not None:
|
| 57 |
+
if hasattr(raw_schema, "model_dump"):
|
| 58 |
+
schema = raw_schema.model_dump(by_alias=True, exclude_none=True)
|
| 59 |
+
elif hasattr(raw_schema, "to_dict"):
|
| 60 |
+
schema = raw_schema.to_dict()
|
| 61 |
+
else:
|
| 62 |
+
schema = raw_schema
|
| 63 |
+
except Exception:
|
| 64 |
schema = None
|
| 65 |
+
tools.append(
|
| 66 |
+
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
"name": t.name,
|
| 68 |
"description": (t.description or ""),
|
| 69 |
"inputSchema": schema,
|
| 70 |
+
}
|
| 71 |
+
)
|
| 72 |
+
return tools
|
| 73 |
|
| 74 |
|
| 75 |
+
async def call_mcp_tool(server: Server, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
| 76 |
try:
|
| 77 |
+
async with _open_transport(server) as (read, write), ClientSession(read, write) as session:
|
| 78 |
+
await session.initialize()
|
| 79 |
+
result = await session.call_tool(tool_name, arguments)
|
| 80 |
+
|
| 81 |
+
def _to_dict(x: Any) -> Any:
|
| 82 |
+
if hasattr(x, "model_dump"):
|
| 83 |
+
return x.model_dump(by_alias=True, exclude_none=True)
|
| 84 |
+
if hasattr(x, "to_dict"):
|
| 85 |
+
return x.to_dict()
|
| 86 |
+
return x
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"tool": tool_name,
|
| 90 |
+
"is_error": bool(getattr(result, "isError", False)),
|
| 91 |
+
"content": [_to_dict(c) for c in (result.content or [])],
|
| 92 |
+
"structuredContent": getattr(result, "structuredContent", None),
|
| 93 |
+
"metadata": getattr(result, "meta", None) or {},
|
| 94 |
+
}
|
|
|
|
| 95 |
except* Exception as eg:
|
| 96 |
# Handle ExceptionGroup from TaskGroup
|
| 97 |
errors = [str(e) for e in eg.exceptions]
|
src/amcp/readfile.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from dataclasses import dataclass
|
| 4 |
from pathlib import Path
|
| 5 |
-
from typing import Iterable
|
| 6 |
|
| 7 |
|
| 8 |
@dataclass
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@dataclass
|
src/amcp/tools.py
CHANGED
|
@@ -2,21 +2,20 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from abc import ABC, abstractmethod
|
| 4 |
from dataclasses import dataclass
|
| 5 |
-
from typing import Any,
|
| 6 |
|
| 7 |
-
import typer
|
| 8 |
from rich.console import Console
|
| 9 |
|
| 10 |
|
| 11 |
@dataclass
|
| 12 |
class ToolResult:
|
| 13 |
"""Result of tool execution."""
|
| 14 |
-
|
| 15 |
success: bool
|
| 16 |
content: str
|
| 17 |
-
metadata:
|
| 18 |
-
error:
|
| 19 |
-
|
| 20 |
def __post_init__(self):
|
| 21 |
if self.metadata is None:
|
| 22 |
self.metadata = {}
|
|
@@ -24,33 +23,36 @@ class ToolResult:
|
|
| 24 |
|
| 25 |
class ToolError(Exception):
|
| 26 |
"""Base exception for tool errors."""
|
|
|
|
| 27 |
pass
|
| 28 |
|
| 29 |
|
| 30 |
class ToolExecutionError(ToolError):
|
| 31 |
"""Raised when tool execution fails."""
|
|
|
|
| 32 |
pass
|
| 33 |
|
| 34 |
|
| 35 |
class ToolValidationError(ToolError):
|
| 36 |
"""Raised when tool parameters are invalid."""
|
|
|
|
| 37 |
pass
|
| 38 |
|
| 39 |
|
| 40 |
@runtime_checkable
|
| 41 |
class Tool(Protocol):
|
| 42 |
"""Protocol for tool implementations."""
|
| 43 |
-
|
| 44 |
@property
|
| 45 |
def name(self) -> str:
|
| 46 |
"""Tool name."""
|
| 47 |
...
|
| 48 |
-
|
| 49 |
@property
|
| 50 |
def description(self) -> str:
|
| 51 |
"""Tool description."""
|
| 52 |
...
|
| 53 |
-
|
| 54 |
def execute(self, **kwargs) -> ToolResult:
|
| 55 |
"""Execute the tool with given parameters."""
|
| 56 |
...
|
|
@@ -58,32 +60,32 @@ class Tool(Protocol):
|
|
| 58 |
|
| 59 |
class BaseTool(ABC):
|
| 60 |
"""Base class for tool implementations."""
|
| 61 |
-
|
| 62 |
def __init__(self):
|
| 63 |
self.console = Console()
|
| 64 |
-
|
| 65 |
@property
|
| 66 |
@abstractmethod
|
| 67 |
def name(self) -> str:
|
| 68 |
"""Tool name."""
|
| 69 |
pass
|
| 70 |
-
|
| 71 |
@property
|
| 72 |
@abstractmethod
|
| 73 |
def description(self) -> str:
|
| 74 |
"""Tool description."""
|
| 75 |
pass
|
| 76 |
-
|
| 77 |
@abstractmethod
|
| 78 |
def execute(self, **kwargs) -> ToolResult:
|
| 79 |
"""Execute the tool with given parameters."""
|
| 80 |
pass
|
| 81 |
-
|
| 82 |
def validate_parameters(self, **kwargs) -> None:
|
| 83 |
"""Validate tool parameters. Override in subclasses."""
|
| 84 |
pass
|
| 85 |
-
|
| 86 |
-
def get_spec(self) ->
|
| 87 |
"""Get tool specification for LLM."""
|
| 88 |
return {
|
| 89 |
"type": "function",
|
|
@@ -93,8 +95,8 @@ class BaseTool(ABC):
|
|
| 93 |
"parameters": self.get_parameters_schema(),
|
| 94 |
},
|
| 95 |
}
|
| 96 |
-
|
| 97 |
-
def get_parameters_schema(self) ->
|
| 98 |
"""Get JSON schema for tool parameters. Override in subclasses."""
|
| 99 |
return {
|
| 100 |
"type": "object",
|
|
@@ -106,147 +108,128 @@ class BaseTool(ABC):
|
|
| 106 |
|
| 107 |
class ToolRegistry:
|
| 108 |
"""Registry for managing tools."""
|
| 109 |
-
|
| 110 |
def __init__(self):
|
| 111 |
-
self._tools:
|
| 112 |
-
self._tool_specs:
|
| 113 |
-
|
| 114 |
def register(self, tool: Tool) -> None:
|
| 115 |
"""Register a tool."""
|
| 116 |
self._tools[tool.name] = tool
|
| 117 |
-
self._tool_specs[tool.name] = tool.get_spec() if hasattr(tool,
|
| 118 |
-
|
| 119 |
def unregister(self, name: str) -> None:
|
| 120 |
"""Unregister a tool."""
|
| 121 |
self._tools.pop(name, None)
|
| 122 |
self._tool_specs.pop(name, None)
|
| 123 |
-
|
| 124 |
-
def get_tool(self, name: str) ->
|
| 125 |
"""Get a tool by name."""
|
| 126 |
return self._tools.get(name)
|
| 127 |
-
|
| 128 |
-
def list_tools(self) ->
|
| 129 |
"""List all registered tool names."""
|
| 130 |
return list(self._tools.keys())
|
| 131 |
-
|
| 132 |
-
def get_tool_specs(self) ->
|
| 133 |
"""Get all tool specifications."""
|
| 134 |
return self._tool_specs.copy()
|
| 135 |
-
|
| 136 |
def execute_tool(self, name: str, **kwargs) -> ToolResult:
|
| 137 |
"""Execute a tool by name."""
|
| 138 |
tool = self.get_tool(name)
|
| 139 |
if not tool:
|
| 140 |
-
return ToolResult(
|
| 141 |
-
|
| 142 |
-
content="",
|
| 143 |
-
error=f"Tool '{name}' not found"
|
| 144 |
-
)
|
| 145 |
-
|
| 146 |
try:
|
| 147 |
# Validate parameters
|
| 148 |
-
if hasattr(tool,
|
| 149 |
tool.validate_parameters(**kwargs)
|
| 150 |
-
|
| 151 |
# Execute tool
|
| 152 |
result = tool.execute(**kwargs)
|
| 153 |
return result
|
| 154 |
-
|
| 155 |
except Exception as e:
|
| 156 |
-
return ToolResult(
|
| 157 |
-
success=False,
|
| 158 |
-
content="",
|
| 159 |
-
error=f"Tool execution failed: {type(e).__name__}: {e}"
|
| 160 |
-
)
|
| 161 |
|
| 162 |
|
| 163 |
# Built-in tools
|
| 164 |
class ReadFileTool(BaseTool):
|
| 165 |
"""Tool for reading files."""
|
| 166 |
-
|
| 167 |
@property
|
| 168 |
def name(self) -> str:
|
| 169 |
return "read_file"
|
| 170 |
-
|
| 171 |
@property
|
| 172 |
def description(self) -> str:
|
| 173 |
return "Read a text file from the local workspace. Use relative paths from current working directory."
|
| 174 |
-
|
| 175 |
-
def execute(self, path: str, ranges:
|
| 176 |
"""Execute the read file tool."""
|
| 177 |
from pathlib import Path
|
|
|
|
| 178 |
from .readfile import read_file_with_ranges
|
| 179 |
-
|
| 180 |
try:
|
| 181 |
file_path = Path(path).expanduser().resolve()
|
| 182 |
-
|
| 183 |
if not file_path.exists():
|
| 184 |
-
return ToolResult(
|
| 185 |
-
|
| 186 |
-
content="",
|
| 187 |
-
error=f"File not found: {file_path}"
|
| 188 |
-
)
|
| 189 |
-
|
| 190 |
if not file_path.is_file():
|
| 191 |
-
return ToolResult(
|
| 192 |
-
|
| 193 |
-
content="",
|
| 194 |
-
error=f"Path is a directory, not a file: {file_path}"
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
# Read file with ranges
|
| 198 |
blocks = read_file_with_ranges(file_path, ranges or [])
|
| 199 |
-
|
| 200 |
# Format result
|
| 201 |
content_parts = []
|
| 202 |
for block in blocks:
|
| 203 |
header = f"{file_path}:{block['start']}-{block['end']}"
|
| 204 |
content_parts.append(f"**{header}**")
|
| 205 |
-
|
| 206 |
-
for lineno, line in block["lines"][:max_lines or 400]:
|
| 207 |
content_parts.append(f"{lineno:>6} | {line}")
|
| 208 |
-
|
| 209 |
if len(block["lines"]) > (max_lines or 400):
|
| 210 |
content_parts.append("... (truncated)")
|
| 211 |
-
|
| 212 |
content = "\\n".join(content_parts)
|
| 213 |
-
|
| 214 |
return ToolResult(
|
| 215 |
success=True,
|
| 216 |
content=content,
|
| 217 |
metadata={
|
| 218 |
"file_path": str(file_path),
|
| 219 |
"blocks_read": len(blocks),
|
| 220 |
-
"total_lines": sum(len(block["lines"]) for block in blocks)
|
| 221 |
-
}
|
| 222 |
)
|
| 223 |
-
|
| 224 |
except Exception as e:
|
| 225 |
-
return ToolResult(
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
error=f"Failed to read file: {type(e).__name__}: {e}"
|
| 229 |
-
)
|
| 230 |
-
|
| 231 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 232 |
return {
|
| 233 |
"type": "object",
|
| 234 |
"properties": {
|
| 235 |
"path": {
|
| 236 |
"type": "string",
|
| 237 |
-
"description": "Path to the file to read (relative to current working directory)"
|
| 238 |
},
|
| 239 |
"ranges": {
|
| 240 |
"type": "array",
|
| 241 |
"items": {"type": "string", "pattern": "^\\d+-\\d+$"},
|
| 242 |
-
"description": "Optional list of line ranges like '1-200'"
|
| 243 |
},
|
| 244 |
"max_lines": {
|
| 245 |
"type": "integer",
|
| 246 |
"minimum": 1,
|
| 247 |
"maximum": 5000,
|
| 248 |
-
"description": "Maximum lines to return per block (default 400)"
|
| 249 |
-
}
|
| 250 |
},
|
| 251 |
"required": ["path"],
|
| 252 |
"additionalProperties": False,
|
|
@@ -255,32 +238,23 @@ class ReadFileTool(BaseTool):
|
|
| 255 |
|
| 256 |
class ThinkTool(BaseTool):
|
| 257 |
"""Tool for internal reasoning and planning."""
|
| 258 |
-
|
| 259 |
@property
|
| 260 |
def name(self) -> str:
|
| 261 |
return "think"
|
| 262 |
-
|
| 263 |
@property
|
| 264 |
def description(self) -> str:
|
| 265 |
return "Use this tool for internal reasoning, planning, and organizing your thoughts before taking action."
|
| 266 |
-
|
| 267 |
def execute(self, thought: str) -> ToolResult:
|
| 268 |
"""Execute thinking process."""
|
| 269 |
-
return ToolResult(
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
metadata={"thought": thought}
|
| 273 |
-
)
|
| 274 |
-
|
| 275 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 276 |
return {
|
| 277 |
"type": "object",
|
| 278 |
-
"properties": {
|
| 279 |
-
"thought": {
|
| 280 |
-
"type": "string",
|
| 281 |
-
"description": "Your thoughts, plans, or reasoning"
|
| 282 |
-
}
|
| 283 |
-
},
|
| 284 |
"required": ["thought"],
|
| 285 |
"additionalProperties": False,
|
| 286 |
}
|
|
@@ -288,69 +262,49 @@ class ThinkTool(BaseTool):
|
|
| 288 |
|
| 289 |
class BashTool(BaseTool):
|
| 290 |
"""Tool for executing bash commands."""
|
| 291 |
-
|
| 292 |
@property
|
| 293 |
def name(self) -> str:
|
| 294 |
return "bash"
|
| 295 |
-
|
| 296 |
@property
|
| 297 |
def description(self) -> str:
|
| 298 |
return "Execute bash commands. Use for file operations, running scripts, or system commands. Returns stdout and stderr."
|
| 299 |
-
|
| 300 |
def execute(self, command: str, timeout: int = 30) -> ToolResult:
|
| 301 |
"""Execute bash command."""
|
| 302 |
import subprocess
|
| 303 |
-
|
| 304 |
try:
|
| 305 |
-
result = subprocess.run(
|
| 306 |
-
|
| 307 |
-
shell=True,
|
| 308 |
-
capture_output=True,
|
| 309 |
-
text=True,
|
| 310 |
-
timeout=timeout
|
| 311 |
-
)
|
| 312 |
-
|
| 313 |
output = result.stdout
|
| 314 |
if result.stderr:
|
| 315 |
output += f"\n[stderr]\n{result.stderr}"
|
| 316 |
-
|
| 317 |
return ToolResult(
|
| 318 |
success=result.returncode == 0,
|
| 319 |
content=output or "(no output)",
|
| 320 |
-
metadata={
|
| 321 |
-
|
| 322 |
-
"exit_code": result.returncode
|
| 323 |
-
},
|
| 324 |
-
error=None if result.returncode == 0 else f"Command exited with code {result.returncode}"
|
| 325 |
)
|
| 326 |
-
|
| 327 |
except subprocess.TimeoutExpired:
|
| 328 |
-
return ToolResult(
|
| 329 |
-
success=False,
|
| 330 |
-
content="",
|
| 331 |
-
error=f"Command timed out after {timeout} seconds"
|
| 332 |
-
)
|
| 333 |
except Exception as e:
|
| 334 |
-
return ToolResult(
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
error=f"Command failed: {type(e).__name__}: {e}"
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 341 |
return {
|
| 342 |
"type": "object",
|
| 343 |
"properties": {
|
| 344 |
-
"command": {
|
| 345 |
-
"type": "string",
|
| 346 |
-
"description": "Bash command to execute"
|
| 347 |
-
},
|
| 348 |
"timeout": {
|
| 349 |
"type": "integer",
|
| 350 |
"minimum": 1,
|
| 351 |
"maximum": 300,
|
| 352 |
-
"description": "Timeout in seconds (default: 30)"
|
| 353 |
-
}
|
| 354 |
},
|
| 355 |
"required": ["command"],
|
| 356 |
"additionalProperties": False,
|
|
@@ -359,35 +313,33 @@ class BashTool(BaseTool):
|
|
| 359 |
|
| 360 |
class GrepTool(BaseTool):
|
| 361 |
"""Tool for searching files using ripgrep."""
|
| 362 |
-
|
| 363 |
@property
|
| 364 |
def name(self) -> str:
|
| 365 |
return "grep"
|
| 366 |
-
|
| 367 |
@property
|
| 368 |
def description(self) -> str:
|
| 369 |
return "Search for patterns in files using ripgrep. Returns matching lines with file paths and line numbers."
|
| 370 |
-
|
| 371 |
def execute(
|
| 372 |
self,
|
| 373 |
pattern: str,
|
| 374 |
-
paths:
|
| 375 |
ignore_case: bool = False,
|
| 376 |
hidden: bool = False,
|
| 377 |
context: int = 0,
|
| 378 |
-
globs:
|
| 379 |
) -> ToolResult:
|
| 380 |
"""Execute grep search."""
|
| 381 |
import shutil
|
| 382 |
import subprocess
|
| 383 |
-
|
| 384 |
if shutil.which("rg") is None:
|
| 385 |
return ToolResult(
|
| 386 |
-
success=False,
|
| 387 |
-
content="",
|
| 388 |
-
error="ripgrep (rg) not found on PATH. Please install ripgrep."
|
| 389 |
)
|
| 390 |
-
|
| 391 |
try:
|
| 392 |
cmd = ["rg", pattern, *(paths or ["."]), "-n"]
|
| 393 |
if ignore_case:
|
|
@@ -396,16 +348,11 @@ class GrepTool(BaseTool):
|
|
| 396 |
cmd.append("--hidden")
|
| 397 |
if context:
|
| 398 |
cmd.extend(["-C", str(context)])
|
| 399 |
-
for g in
|
| 400 |
cmd.extend(["-g", g])
|
| 401 |
-
|
| 402 |
-
result = subprocess.run(
|
| 403 |
-
|
| 404 |
-
capture_output=True,
|
| 405 |
-
text=True,
|
| 406 |
-
timeout=30
|
| 407 |
-
)
|
| 408 |
-
|
| 409 |
if result.returncode == 0:
|
| 410 |
return ToolResult(
|
| 411 |
success=True,
|
|
@@ -413,67 +360,48 @@ class GrepTool(BaseTool):
|
|
| 413 |
metadata={
|
| 414 |
"pattern": pattern,
|
| 415 |
"paths": paths or ["."],
|
| 416 |
-
"match_count": len(result.stdout.splitlines())
|
| 417 |
-
}
|
| 418 |
)
|
| 419 |
elif result.returncode == 1:
|
| 420 |
# No matches found
|
| 421 |
return ToolResult(
|
| 422 |
-
success=True,
|
| 423 |
-
content="No matches found.",
|
| 424 |
-
metadata={"pattern": pattern, "match_count": 0}
|
| 425 |
)
|
| 426 |
else:
|
| 427 |
return ToolResult(
|
| 428 |
success=False,
|
| 429 |
content=result.stdout,
|
| 430 |
-
error=result.stderr or f"ripgrep exited with code {result.returncode}"
|
| 431 |
)
|
| 432 |
-
|
| 433 |
except subprocess.TimeoutExpired:
|
| 434 |
-
return ToolResult(
|
| 435 |
-
success=False,
|
| 436 |
-
content="",
|
| 437 |
-
error="Search timed out after 30 seconds"
|
| 438 |
-
)
|
| 439 |
except Exception as e:
|
| 440 |
-
return ToolResult(
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
error=f"Search failed: {type(e).__name__}: {e}"
|
| 444 |
-
)
|
| 445 |
-
|
| 446 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 447 |
return {
|
| 448 |
"type": "object",
|
| 449 |
"properties": {
|
| 450 |
-
"pattern": {
|
| 451 |
-
"type": "string",
|
| 452 |
-
"description": "Pattern to search for (regex supported)"
|
| 453 |
-
},
|
| 454 |
"paths": {
|
| 455 |
"type": "array",
|
| 456 |
"items": {"type": "string"},
|
| 457 |
-
"description": "Paths to search in (default: current directory)"
|
| 458 |
-
},
|
| 459 |
-
"ignore_case": {
|
| 460 |
-
"type": "boolean",
|
| 461 |
-
"description": "Case-insensitive search"
|
| 462 |
-
},
|
| 463 |
-
"hidden": {
|
| 464 |
-
"type": "boolean",
|
| 465 |
-
"description": "Search hidden files and directories"
|
| 466 |
},
|
|
|
|
|
|
|
| 467 |
"context": {
|
| 468 |
"type": "integer",
|
| 469 |
"minimum": 0,
|
| 470 |
-
"description": "Number of context lines to show around matches"
|
| 471 |
},
|
| 472 |
"globs": {
|
| 473 |
"type": "array",
|
| 474 |
"items": {"type": "string"},
|
| 475 |
-
"description": "File glob patterns to filter (e.g., '*.py')"
|
| 476 |
-
}
|
| 477 |
},
|
| 478 |
"required": ["pattern"],
|
| 479 |
"additionalProperties": False,
|
|
@@ -482,48 +410,38 @@ class GrepTool(BaseTool):
|
|
| 482 |
|
| 483 |
class WriteFileTool(BaseTool):
|
| 484 |
"""Tool for writing content to files."""
|
| 485 |
-
|
| 486 |
@property
|
| 487 |
def name(self) -> str:
|
| 488 |
return "write_file"
|
| 489 |
-
|
| 490 |
@property
|
| 491 |
def description(self) -> str:
|
| 492 |
return "Write content to a file. Creates new file or overwrites existing file."
|
| 493 |
-
|
| 494 |
def execute(self, path: str, content: str) -> ToolResult:
|
| 495 |
"""Execute the write file tool."""
|
| 496 |
from pathlib import Path
|
| 497 |
-
|
| 498 |
try:
|
| 499 |
file_path = Path(path).expanduser().resolve()
|
| 500 |
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 501 |
file_path.write_text(content, encoding="utf-8")
|
| 502 |
-
|
| 503 |
return ToolResult(
|
| 504 |
success=True,
|
| 505 |
content=f"Successfully wrote {len(content)} characters to {file_path}",
|
| 506 |
-
metadata={"file_path": str(file_path), "size": len(content)}
|
| 507 |
)
|
| 508 |
except Exception as e:
|
| 509 |
-
return ToolResult(
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
error=f"Failed to write file: {type(e).__name__}: {e}"
|
| 513 |
-
)
|
| 514 |
-
|
| 515 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 516 |
return {
|
| 517 |
"type": "object",
|
| 518 |
"properties": {
|
| 519 |
-
"path": {
|
| 520 |
-
|
| 521 |
-
"description": "Path to the file to write"
|
| 522 |
-
},
|
| 523 |
-
"content": {
|
| 524 |
-
"type": "string",
|
| 525 |
-
"description": "Content to write to the file"
|
| 526 |
-
}
|
| 527 |
},
|
| 528 |
"required": ["path", "content"],
|
| 529 |
"additionalProperties": False,
|
|
@@ -532,69 +450,46 @@ class WriteFileTool(BaseTool):
|
|
| 532 |
|
| 533 |
class EditFileTool(BaseTool):
|
| 534 |
"""Tool for editing files with search and replace."""
|
| 535 |
-
|
| 536 |
@property
|
| 537 |
def name(self) -> str:
|
| 538 |
return "edit_file"
|
| 539 |
-
|
| 540 |
@property
|
| 541 |
def description(self) -> str:
|
| 542 |
return "Edit a file by replacing old_text with new_text. The old_text must match exactly."
|
| 543 |
-
|
| 544 |
def execute(self, path: str, old_text: str, new_text: str) -> ToolResult:
|
| 545 |
"""Execute the edit file tool."""
|
| 546 |
from pathlib import Path
|
| 547 |
-
|
| 548 |
try:
|
| 549 |
file_path = Path(path).expanduser().resolve()
|
| 550 |
-
|
| 551 |
if not file_path.exists():
|
| 552 |
-
return ToolResult(
|
| 553 |
-
|
| 554 |
-
content="",
|
| 555 |
-
error=f"File not found: {file_path}"
|
| 556 |
-
)
|
| 557 |
-
|
| 558 |
content = file_path.read_text(encoding="utf-8")
|
| 559 |
-
|
| 560 |
if old_text not in content:
|
| 561 |
-
return ToolResult(
|
| 562 |
-
|
| 563 |
-
content="",
|
| 564 |
-
error="old_text not found in file"
|
| 565 |
-
)
|
| 566 |
-
|
| 567 |
new_content = content.replace(old_text, new_text, 1)
|
| 568 |
file_path.write_text(new_content, encoding="utf-8")
|
| 569 |
-
|
| 570 |
return ToolResult(
|
| 571 |
-
success=True,
|
| 572 |
-
content=f"Successfully edited {file_path}",
|
| 573 |
-
metadata={"file_path": str(file_path)}
|
| 574 |
)
|
| 575 |
except Exception as e:
|
| 576 |
-
return ToolResult(
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
error=f"Failed to edit file: {type(e).__name__}: {e}"
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
-
def get_parameters_schema(self) -> Dict[str, Any]:
|
| 583 |
return {
|
| 584 |
"type": "object",
|
| 585 |
"properties": {
|
| 586 |
-
"path": {
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
},
|
| 590 |
-
"old_text": {
|
| 591 |
-
"type": "string",
|
| 592 |
-
"description": "Text to search for (must match exactly)"
|
| 593 |
-
},
|
| 594 |
-
"new_text": {
|
| 595 |
-
"type": "string",
|
| 596 |
-
"description": "Text to replace with"
|
| 597 |
-
}
|
| 598 |
},
|
| 599 |
"required": ["path", "old_text", "new_text"],
|
| 600 |
"additionalProperties": False,
|
|
@@ -605,40 +500,41 @@ class EditFileTool(BaseTool):
|
|
| 605 |
def create_default_tool_registry(enable_write: bool = True, enable_edit: bool = True) -> ToolRegistry:
|
| 606 |
"""Create a tool registry with default tools."""
|
| 607 |
registry = ToolRegistry()
|
| 608 |
-
|
| 609 |
# Register built-in tools
|
| 610 |
registry.register(ReadFileTool())
|
| 611 |
registry.register(GrepTool())
|
| 612 |
registry.register(ThinkTool())
|
| 613 |
registry.register(BashTool())
|
| 614 |
-
|
| 615 |
if enable_write:
|
| 616 |
registry.register(WriteFileTool())
|
| 617 |
if enable_edit:
|
| 618 |
registry.register(EditFileTool())
|
| 619 |
-
|
| 620 |
return registry
|
| 621 |
|
| 622 |
|
| 623 |
# Global tool registry instance
|
| 624 |
-
_default_registry:
|
| 625 |
|
| 626 |
|
| 627 |
-
def get_tool_registry(enable_write:
|
| 628 |
"""Get the global tool registry instance."""
|
| 629 |
global _default_registry
|
| 630 |
if _default_registry is None:
|
| 631 |
# Load config to determine defaults
|
| 632 |
from .config import load_config
|
|
|
|
| 633 |
cfg = load_config()
|
| 634 |
chat_cfg = cfg.chat
|
| 635 |
-
|
| 636 |
# Use config values if not explicitly provided
|
| 637 |
if enable_write is None:
|
| 638 |
enable_write = chat_cfg.write_tool_enabled if chat_cfg and chat_cfg.write_tool_enabled is not None else True
|
| 639 |
if enable_edit is None:
|
| 640 |
enable_edit = chat_cfg.edit_tool_enabled if chat_cfg and chat_cfg.edit_tool_enabled is not None else True
|
| 641 |
-
|
| 642 |
_default_registry = create_default_tool_registry(enable_write=enable_write, enable_edit=enable_edit)
|
| 643 |
return _default_registry
|
| 644 |
|
|
|
|
| 2 |
|
| 3 |
from abc import ABC, abstractmethod
|
| 4 |
from dataclasses import dataclass
|
| 5 |
+
from typing import Any, Protocol, runtime_checkable
|
| 6 |
|
|
|
|
| 7 |
from rich.console import Console
|
| 8 |
|
| 9 |
|
| 10 |
@dataclass
|
| 11 |
class ToolResult:
|
| 12 |
"""Result of tool execution."""
|
| 13 |
+
|
| 14 |
success: bool
|
| 15 |
content: str
|
| 16 |
+
metadata: dict[str, Any] = None
|
| 17 |
+
error: str | None = None
|
| 18 |
+
|
| 19 |
def __post_init__(self):
|
| 20 |
if self.metadata is None:
|
| 21 |
self.metadata = {}
|
|
|
|
| 23 |
|
| 24 |
class ToolError(Exception):
|
| 25 |
"""Base exception for tool errors."""
|
| 26 |
+
|
| 27 |
pass
|
| 28 |
|
| 29 |
|
| 30 |
class ToolExecutionError(ToolError):
|
| 31 |
"""Raised when tool execution fails."""
|
| 32 |
+
|
| 33 |
pass
|
| 34 |
|
| 35 |
|
| 36 |
class ToolValidationError(ToolError):
|
| 37 |
"""Raised when tool parameters are invalid."""
|
| 38 |
+
|
| 39 |
pass
|
| 40 |
|
| 41 |
|
| 42 |
@runtime_checkable
|
| 43 |
class Tool(Protocol):
|
| 44 |
"""Protocol for tool implementations."""
|
| 45 |
+
|
| 46 |
@property
|
| 47 |
def name(self) -> str:
|
| 48 |
"""Tool name."""
|
| 49 |
...
|
| 50 |
+
|
| 51 |
@property
|
| 52 |
def description(self) -> str:
|
| 53 |
"""Tool description."""
|
| 54 |
...
|
| 55 |
+
|
| 56 |
def execute(self, **kwargs) -> ToolResult:
|
| 57 |
"""Execute the tool with given parameters."""
|
| 58 |
...
|
|
|
|
| 60 |
|
| 61 |
class BaseTool(ABC):
|
| 62 |
"""Base class for tool implementations."""
|
| 63 |
+
|
| 64 |
def __init__(self):
|
| 65 |
self.console = Console()
|
| 66 |
+
|
| 67 |
@property
|
| 68 |
@abstractmethod
|
| 69 |
def name(self) -> str:
|
| 70 |
"""Tool name."""
|
| 71 |
pass
|
| 72 |
+
|
| 73 |
@property
|
| 74 |
@abstractmethod
|
| 75 |
def description(self) -> str:
|
| 76 |
"""Tool description."""
|
| 77 |
pass
|
| 78 |
+
|
| 79 |
@abstractmethod
|
| 80 |
def execute(self, **kwargs) -> ToolResult:
|
| 81 |
"""Execute the tool with given parameters."""
|
| 82 |
pass
|
| 83 |
+
|
| 84 |
def validate_parameters(self, **kwargs) -> None:
|
| 85 |
"""Validate tool parameters. Override in subclasses."""
|
| 86 |
pass
|
| 87 |
+
|
| 88 |
+
def get_spec(self) -> dict[str, Any]:
|
| 89 |
"""Get tool specification for LLM."""
|
| 90 |
return {
|
| 91 |
"type": "function",
|
|
|
|
| 95 |
"parameters": self.get_parameters_schema(),
|
| 96 |
},
|
| 97 |
}
|
| 98 |
+
|
| 99 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
| 100 |
"""Get JSON schema for tool parameters. Override in subclasses."""
|
| 101 |
return {
|
| 102 |
"type": "object",
|
|
|
|
| 108 |
|
| 109 |
class ToolRegistry:
|
| 110 |
"""Registry for managing tools."""
|
| 111 |
+
|
| 112 |
def __init__(self):
|
| 113 |
+
self._tools: dict[str, Tool] = {}
|
| 114 |
+
self._tool_specs: dict[str, dict[str, Any]] = {}
|
| 115 |
+
|
| 116 |
def register(self, tool: Tool) -> None:
|
| 117 |
"""Register a tool."""
|
| 118 |
self._tools[tool.name] = tool
|
| 119 |
+
self._tool_specs[tool.name] = tool.get_spec() if hasattr(tool, "get_spec") else {}
|
| 120 |
+
|
| 121 |
def unregister(self, name: str) -> None:
|
| 122 |
"""Unregister a tool."""
|
| 123 |
self._tools.pop(name, None)
|
| 124 |
self._tool_specs.pop(name, None)
|
| 125 |
+
|
| 126 |
+
def get_tool(self, name: str) -> Tool | None:
|
| 127 |
"""Get a tool by name."""
|
| 128 |
return self._tools.get(name)
|
| 129 |
+
|
| 130 |
+
def list_tools(self) -> list[str]:
|
| 131 |
"""List all registered tool names."""
|
| 132 |
return list(self._tools.keys())
|
| 133 |
+
|
| 134 |
+
def get_tool_specs(self) -> dict[str, dict[str, Any]]:
|
| 135 |
"""Get all tool specifications."""
|
| 136 |
return self._tool_specs.copy()
|
| 137 |
+
|
| 138 |
def execute_tool(self, name: str, **kwargs) -> ToolResult:
|
| 139 |
"""Execute a tool by name."""
|
| 140 |
tool = self.get_tool(name)
|
| 141 |
if not tool:
|
| 142 |
+
return ToolResult(success=False, content="", error=f"Tool '{name}' not found")
|
| 143 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
try:
|
| 145 |
# Validate parameters
|
| 146 |
+
if hasattr(tool, "validate_parameters"):
|
| 147 |
tool.validate_parameters(**kwargs)
|
| 148 |
+
|
| 149 |
# Execute tool
|
| 150 |
result = tool.execute(**kwargs)
|
| 151 |
return result
|
| 152 |
+
|
| 153 |
except Exception as e:
|
| 154 |
+
return ToolResult(success=False, content="", error=f"Tool execution failed: {type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
# Built-in tools
|
| 158 |
class ReadFileTool(BaseTool):
|
| 159 |
"""Tool for reading files."""
|
| 160 |
+
|
| 161 |
@property
|
| 162 |
def name(self) -> str:
|
| 163 |
return "read_file"
|
| 164 |
+
|
| 165 |
@property
|
| 166 |
def description(self) -> str:
|
| 167 |
return "Read a text file from the local workspace. Use relative paths from current working directory."
|
| 168 |
+
|
| 169 |
+
def execute(self, path: str, ranges: list[str] | None = None, max_lines: int | None = None) -> ToolResult:
|
| 170 |
"""Execute the read file tool."""
|
| 171 |
from pathlib import Path
|
| 172 |
+
|
| 173 |
from .readfile import read_file_with_ranges
|
| 174 |
+
|
| 175 |
try:
|
| 176 |
file_path = Path(path).expanduser().resolve()
|
| 177 |
+
|
| 178 |
if not file_path.exists():
|
| 179 |
+
return ToolResult(success=False, content="", error=f"File not found: {file_path}")
|
| 180 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
if not file_path.is_file():
|
| 182 |
+
return ToolResult(success=False, content="", error=f"Path is a directory, not a file: {file_path}")
|
| 183 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
# Read file with ranges
|
| 185 |
blocks = read_file_with_ranges(file_path, ranges or [])
|
| 186 |
+
|
| 187 |
# Format result
|
| 188 |
content_parts = []
|
| 189 |
for block in blocks:
|
| 190 |
header = f"{file_path}:{block['start']}-{block['end']}"
|
| 191 |
content_parts.append(f"**{header}**")
|
| 192 |
+
|
| 193 |
+
for lineno, line in block["lines"][: max_lines or 400]:
|
| 194 |
content_parts.append(f"{lineno:>6} | {line}")
|
| 195 |
+
|
| 196 |
if len(block["lines"]) > (max_lines or 400):
|
| 197 |
content_parts.append("... (truncated)")
|
| 198 |
+
|
| 199 |
content = "\\n".join(content_parts)
|
| 200 |
+
|
| 201 |
return ToolResult(
|
| 202 |
success=True,
|
| 203 |
content=content,
|
| 204 |
metadata={
|
| 205 |
"file_path": str(file_path),
|
| 206 |
"blocks_read": len(blocks),
|
| 207 |
+
"total_lines": sum(len(block["lines"]) for block in blocks),
|
| 208 |
+
},
|
| 209 |
)
|
| 210 |
+
|
| 211 |
except Exception as e:
|
| 212 |
+
return ToolResult(success=False, content="", error=f"Failed to read file: {type(e).__name__}: {e}")
|
| 213 |
+
|
| 214 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
return {
|
| 216 |
"type": "object",
|
| 217 |
"properties": {
|
| 218 |
"path": {
|
| 219 |
"type": "string",
|
| 220 |
+
"description": "Path to the file to read (relative to current working directory)",
|
| 221 |
},
|
| 222 |
"ranges": {
|
| 223 |
"type": "array",
|
| 224 |
"items": {"type": "string", "pattern": "^\\d+-\\d+$"},
|
| 225 |
+
"description": "Optional list of line ranges like '1-200'",
|
| 226 |
},
|
| 227 |
"max_lines": {
|
| 228 |
"type": "integer",
|
| 229 |
"minimum": 1,
|
| 230 |
"maximum": 5000,
|
| 231 |
+
"description": "Maximum lines to return per block (default 400)",
|
| 232 |
+
},
|
| 233 |
},
|
| 234 |
"required": ["path"],
|
| 235 |
"additionalProperties": False,
|
|
|
|
| 238 |
|
| 239 |
class ThinkTool(BaseTool):
|
| 240 |
"""Tool for internal reasoning and planning."""
|
| 241 |
+
|
| 242 |
@property
|
| 243 |
def name(self) -> str:
|
| 244 |
return "think"
|
| 245 |
+
|
| 246 |
@property
|
| 247 |
def description(self) -> str:
|
| 248 |
return "Use this tool for internal reasoning, planning, and organizing your thoughts before taking action."
|
| 249 |
+
|
| 250 |
def execute(self, thought: str) -> ToolResult:
|
| 251 |
"""Execute thinking process."""
|
| 252 |
+
return ToolResult(success=True, content=f"🤔 Thinking: {thought}", metadata={"thought": thought})
|
| 253 |
+
|
| 254 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
return {
|
| 256 |
"type": "object",
|
| 257 |
+
"properties": {"thought": {"type": "string", "description": "Your thoughts, plans, or reasoning"}},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
"required": ["thought"],
|
| 259 |
"additionalProperties": False,
|
| 260 |
}
|
|
|
|
| 262 |
|
| 263 |
class BashTool(BaseTool):
|
| 264 |
"""Tool for executing bash commands."""
|
| 265 |
+
|
| 266 |
@property
|
| 267 |
def name(self) -> str:
|
| 268 |
return "bash"
|
| 269 |
+
|
| 270 |
@property
|
| 271 |
def description(self) -> str:
|
| 272 |
return "Execute bash commands. Use for file operations, running scripts, or system commands. Returns stdout and stderr."
|
| 273 |
+
|
| 274 |
def execute(self, command: str, timeout: int = 30) -> ToolResult:
|
| 275 |
"""Execute bash command."""
|
| 276 |
import subprocess
|
| 277 |
+
|
| 278 |
try:
|
| 279 |
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=timeout)
|
| 280 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
output = result.stdout
|
| 282 |
if result.stderr:
|
| 283 |
output += f"\n[stderr]\n{result.stderr}"
|
| 284 |
+
|
| 285 |
return ToolResult(
|
| 286 |
success=result.returncode == 0,
|
| 287 |
content=output or "(no output)",
|
| 288 |
+
metadata={"command": command, "exit_code": result.returncode},
|
| 289 |
+
error=None if result.returncode == 0 else f"Command exited with code {result.returncode}",
|
|
|
|
|
|
|
|
|
|
| 290 |
)
|
| 291 |
+
|
| 292 |
except subprocess.TimeoutExpired:
|
| 293 |
+
return ToolResult(success=False, content="", error=f"Command timed out after {timeout} seconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
except Exception as e:
|
| 295 |
+
return ToolResult(success=False, content="", error=f"Command failed: {type(e).__name__}: {e}")
|
| 296 |
+
|
| 297 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
return {
|
| 299 |
"type": "object",
|
| 300 |
"properties": {
|
| 301 |
+
"command": {"type": "string", "description": "Bash command to execute"},
|
|
|
|
|
|
|
|
|
|
| 302 |
"timeout": {
|
| 303 |
"type": "integer",
|
| 304 |
"minimum": 1,
|
| 305 |
"maximum": 300,
|
| 306 |
+
"description": "Timeout in seconds (default: 30)",
|
| 307 |
+
},
|
| 308 |
},
|
| 309 |
"required": ["command"],
|
| 310 |
"additionalProperties": False,
|
|
|
|
| 313 |
|
| 314 |
class GrepTool(BaseTool):
|
| 315 |
"""Tool for searching files using ripgrep."""
|
| 316 |
+
|
| 317 |
@property
|
| 318 |
def name(self) -> str:
|
| 319 |
return "grep"
|
| 320 |
+
|
| 321 |
@property
|
| 322 |
def description(self) -> str:
|
| 323 |
return "Search for patterns in files using ripgrep. Returns matching lines with file paths and line numbers."
|
| 324 |
+
|
| 325 |
def execute(
|
| 326 |
self,
|
| 327 |
pattern: str,
|
| 328 |
+
paths: list[str] | None = None,
|
| 329 |
ignore_case: bool = False,
|
| 330 |
hidden: bool = False,
|
| 331 |
context: int = 0,
|
| 332 |
+
globs: list[str] | None = None,
|
| 333 |
) -> ToolResult:
|
| 334 |
"""Execute grep search."""
|
| 335 |
import shutil
|
| 336 |
import subprocess
|
| 337 |
+
|
| 338 |
if shutil.which("rg") is None:
|
| 339 |
return ToolResult(
|
| 340 |
+
success=False, content="", error="ripgrep (rg) not found on PATH. Please install ripgrep."
|
|
|
|
|
|
|
| 341 |
)
|
| 342 |
+
|
| 343 |
try:
|
| 344 |
cmd = ["rg", pattern, *(paths or ["."]), "-n"]
|
| 345 |
if ignore_case:
|
|
|
|
| 348 |
cmd.append("--hidden")
|
| 349 |
if context:
|
| 350 |
cmd.extend(["-C", str(context)])
|
| 351 |
+
for g in globs or []:
|
| 352 |
cmd.extend(["-g", g])
|
| 353 |
+
|
| 354 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
| 355 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
if result.returncode == 0:
|
| 357 |
return ToolResult(
|
| 358 |
success=True,
|
|
|
|
| 360 |
metadata={
|
| 361 |
"pattern": pattern,
|
| 362 |
"paths": paths or ["."],
|
| 363 |
+
"match_count": len(result.stdout.splitlines()),
|
| 364 |
+
},
|
| 365 |
)
|
| 366 |
elif result.returncode == 1:
|
| 367 |
# No matches found
|
| 368 |
return ToolResult(
|
| 369 |
+
success=True, content="No matches found.", metadata={"pattern": pattern, "match_count": 0}
|
|
|
|
|
|
|
| 370 |
)
|
| 371 |
else:
|
| 372 |
return ToolResult(
|
| 373 |
success=False,
|
| 374 |
content=result.stdout,
|
| 375 |
+
error=result.stderr or f"ripgrep exited with code {result.returncode}",
|
| 376 |
)
|
| 377 |
+
|
| 378 |
except subprocess.TimeoutExpired:
|
| 379 |
+
return ToolResult(success=False, content="", error="Search timed out after 30 seconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
except Exception as e:
|
| 381 |
+
return ToolResult(success=False, content="", error=f"Search failed: {type(e).__name__}: {e}")
|
| 382 |
+
|
| 383 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
return {
|
| 385 |
"type": "object",
|
| 386 |
"properties": {
|
| 387 |
+
"pattern": {"type": "string", "description": "Pattern to search for (regex supported)"},
|
|
|
|
|
|
|
|
|
|
| 388 |
"paths": {
|
| 389 |
"type": "array",
|
| 390 |
"items": {"type": "string"},
|
| 391 |
+
"description": "Paths to search in (default: current directory)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
},
|
| 393 |
+
"ignore_case": {"type": "boolean", "description": "Case-insensitive search"},
|
| 394 |
+
"hidden": {"type": "boolean", "description": "Search hidden files and directories"},
|
| 395 |
"context": {
|
| 396 |
"type": "integer",
|
| 397 |
"minimum": 0,
|
| 398 |
+
"description": "Number of context lines to show around matches",
|
| 399 |
},
|
| 400 |
"globs": {
|
| 401 |
"type": "array",
|
| 402 |
"items": {"type": "string"},
|
| 403 |
+
"description": "File glob patterns to filter (e.g., '*.py')",
|
| 404 |
+
},
|
| 405 |
},
|
| 406 |
"required": ["pattern"],
|
| 407 |
"additionalProperties": False,
|
|
|
|
| 410 |
|
| 411 |
class WriteFileTool(BaseTool):
|
| 412 |
"""Tool for writing content to files."""
|
| 413 |
+
|
| 414 |
@property
|
| 415 |
def name(self) -> str:
|
| 416 |
return "write_file"
|
| 417 |
+
|
| 418 |
@property
|
| 419 |
def description(self) -> str:
|
| 420 |
return "Write content to a file. Creates new file or overwrites existing file."
|
| 421 |
+
|
| 422 |
def execute(self, path: str, content: str) -> ToolResult:
|
| 423 |
"""Execute the write file tool."""
|
| 424 |
from pathlib import Path
|
| 425 |
+
|
| 426 |
try:
|
| 427 |
file_path = Path(path).expanduser().resolve()
|
| 428 |
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 429 |
file_path.write_text(content, encoding="utf-8")
|
| 430 |
+
|
| 431 |
return ToolResult(
|
| 432 |
success=True,
|
| 433 |
content=f"Successfully wrote {len(content)} characters to {file_path}",
|
| 434 |
+
metadata={"file_path": str(file_path), "size": len(content)},
|
| 435 |
)
|
| 436 |
except Exception as e:
|
| 437 |
+
return ToolResult(success=False, content="", error=f"Failed to write file: {type(e).__name__}: {e}")
|
| 438 |
+
|
| 439 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
return {
|
| 441 |
"type": "object",
|
| 442 |
"properties": {
|
| 443 |
+
"path": {"type": "string", "description": "Path to the file to write"},
|
| 444 |
+
"content": {"type": "string", "description": "Content to write to the file"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
},
|
| 446 |
"required": ["path", "content"],
|
| 447 |
"additionalProperties": False,
|
|
|
|
| 450 |
|
| 451 |
class EditFileTool(BaseTool):
|
| 452 |
"""Tool for editing files with search and replace."""
|
| 453 |
+
|
| 454 |
@property
|
| 455 |
def name(self) -> str:
|
| 456 |
return "edit_file"
|
| 457 |
+
|
| 458 |
@property
|
| 459 |
def description(self) -> str:
|
| 460 |
return "Edit a file by replacing old_text with new_text. The old_text must match exactly."
|
| 461 |
+
|
| 462 |
def execute(self, path: str, old_text: str, new_text: str) -> ToolResult:
|
| 463 |
"""Execute the edit file tool."""
|
| 464 |
from pathlib import Path
|
| 465 |
+
|
| 466 |
try:
|
| 467 |
file_path = Path(path).expanduser().resolve()
|
| 468 |
+
|
| 469 |
if not file_path.exists():
|
| 470 |
+
return ToolResult(success=False, content="", error=f"File not found: {file_path}")
|
| 471 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
content = file_path.read_text(encoding="utf-8")
|
| 473 |
+
|
| 474 |
if old_text not in content:
|
| 475 |
+
return ToolResult(success=False, content="", error="old_text not found in file")
|
| 476 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
new_content = content.replace(old_text, new_text, 1)
|
| 478 |
file_path.write_text(new_content, encoding="utf-8")
|
| 479 |
+
|
| 480 |
return ToolResult(
|
| 481 |
+
success=True, content=f"Successfully edited {file_path}", metadata={"file_path": str(file_path)}
|
|
|
|
|
|
|
| 482 |
)
|
| 483 |
except Exception as e:
|
| 484 |
+
return ToolResult(success=False, content="", error=f"Failed to edit file: {type(e).__name__}: {e}")
|
| 485 |
+
|
| 486 |
+
def get_parameters_schema(self) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
return {
|
| 488 |
"type": "object",
|
| 489 |
"properties": {
|
| 490 |
+
"path": {"type": "string", "description": "Path to the file to edit"},
|
| 491 |
+
"old_text": {"type": "string", "description": "Text to search for (must match exactly)"},
|
| 492 |
+
"new_text": {"type": "string", "description": "Text to replace with"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
},
|
| 494 |
"required": ["path", "old_text", "new_text"],
|
| 495 |
"additionalProperties": False,
|
|
|
|
| 500 |
def create_default_tool_registry(enable_write: bool = True, enable_edit: bool = True) -> ToolRegistry:
|
| 501 |
"""Create a tool registry with default tools."""
|
| 502 |
registry = ToolRegistry()
|
| 503 |
+
|
| 504 |
# Register built-in tools
|
| 505 |
registry.register(ReadFileTool())
|
| 506 |
registry.register(GrepTool())
|
| 507 |
registry.register(ThinkTool())
|
| 508 |
registry.register(BashTool())
|
| 509 |
+
|
| 510 |
if enable_write:
|
| 511 |
registry.register(WriteFileTool())
|
| 512 |
if enable_edit:
|
| 513 |
registry.register(EditFileTool())
|
| 514 |
+
|
| 515 |
return registry
|
| 516 |
|
| 517 |
|
| 518 |
# Global tool registry instance
|
| 519 |
+
_default_registry: ToolRegistry | None = None
|
| 520 |
|
| 521 |
|
| 522 |
+
def get_tool_registry(enable_write: bool | None = None, enable_edit: bool | None = None) -> ToolRegistry:
|
| 523 |
"""Get the global tool registry instance."""
|
| 524 |
global _default_registry
|
| 525 |
if _default_registry is None:
|
| 526 |
# Load config to determine defaults
|
| 527 |
from .config import load_config
|
| 528 |
+
|
| 529 |
cfg = load_config()
|
| 530 |
chat_cfg = cfg.chat
|
| 531 |
+
|
| 532 |
# Use config values if not explicitly provided
|
| 533 |
if enable_write is None:
|
| 534 |
enable_write = chat_cfg.write_tool_enabled if chat_cfg and chat_cfg.write_tool_enabled is not None else True
|
| 535 |
if enable_edit is None:
|
| 536 |
enable_edit = chat_cfg.edit_tool_enabled if chat_cfg and chat_cfg.edit_tool_enabled is not None else True
|
| 537 |
+
|
| 538 |
_default_registry = create_default_tool_registry(enable_write=enable_write, enable_edit=enable_edit)
|
| 539 |
return _default_registry
|
| 540 |
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
@pytest.fixture
|
| 5 |
+
def temp_workspace(tmp_path):
|
| 6 |
+
"""Create a temporary workspace directory."""
|
| 7 |
+
workspace = tmp_path / "workspace"
|
| 8 |
+
workspace.mkdir()
|
| 9 |
+
return workspace
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def sample_config(tmp_path):
|
| 14 |
+
"""Create a sample config file."""
|
| 15 |
+
config_file = tmp_path / "config.toml"
|
| 16 |
+
config_file.write_text("""
|
| 17 |
+
[chat]
|
| 18 |
+
base_url = "https://api.example.com/v1"
|
| 19 |
+
model = "test-model"
|
| 20 |
+
api_key = "test-key"
|
| 21 |
+
|
| 22 |
+
[servers.test]
|
| 23 |
+
url = "https://test.mcp.server"
|
| 24 |
+
""")
|
| 25 |
+
return config_file
|
tests/test_agent_spec.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from amcp.agent_spec import get_default_agent_spec
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_agent_spec_default():
|
| 5 |
+
spec = get_default_agent_spec()
|
| 6 |
+
assert spec.name == "default"
|
| 7 |
+
assert len(spec.system_prompt) > 0
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from amcp.config import CONFIG_DIR, AMCPConfig, load_config
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_config_dir_default():
|
| 5 |
+
assert CONFIG_DIR.name == "amcp"
|
| 6 |
+
assert "config" in str(CONFIG_DIR).lower()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_load_config():
|
| 10 |
+
config = load_config()
|
| 11 |
+
assert isinstance(config, AMCPConfig)
|
| 12 |
+
assert isinstance(config.servers, dict)
|
tests/test_tools.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from amcp.tools import BashTool, ReadFileTool, ThinkTool
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_read_file_tool(tmp_path):
|
| 5 |
+
test_file = tmp_path / "test.txt"
|
| 6 |
+
test_file.write_text("hello world")
|
| 7 |
+
|
| 8 |
+
tool = ReadFileTool()
|
| 9 |
+
result = tool.execute(path=str(test_file))
|
| 10 |
+
assert result.success
|
| 11 |
+
assert "hello world" in result.content
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_bash_tool_simple():
|
| 15 |
+
tool = BashTool()
|
| 16 |
+
result = tool.execute(command="echo test")
|
| 17 |
+
assert result.success
|
| 18 |
+
assert "test" in result.content
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_think_tool():
|
| 22 |
+
tool = ThinkTool()
|
| 23 |
+
result = tool.execute(thought="test reasoning")
|
| 24 |
+
assert result.success
|
| 25 |
+
assert "test reasoning" in result.content
|