moelove commited on
Commit
f679000
·
1 Parent(s): 8a173aa

Signed-off-by: Jintao Zhang <zhangjintao9020@gmail.com>

.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
- # Expose port for Gradio
24
- EXPOSE 7860
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
- MIT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # AMCP
2
+
3
+ [![CI](https://github.com/tao12345666333/amcp/workflows/CI/badge.svg)](https://github.com/tao12345666333/amcp/actions)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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 = 100
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, Dict, List, Optional, Union
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 _chat_with_tools, _make_client, _resolve_base_url, _resolve_api_key
18
- from .config import AMCPConfig, load_config
19
  from .mcp_client import call_mcp_tool, list_mcp_tools
20
- from .tools import ToolRegistry, ToolResult
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: Optional[ResolvedAgentSpec] = None, session_id: Optional[str] = None):
46
  self.agent_spec = agent_spec or get_default_agent_spec()
47
  self.console = Console()
48
  self.tool_registry = ToolRegistry()
49
- self.execution_context: Dict[str, Any] = {}
50
  self.step_count = 0
51
- self.tool_calls_history: List[Dict[str, Any]] = []
52
 
53
  # Conversation history management
54
  self.session_id = session_id or self._generate_session_id()
55
- self.conversation_history: List[Dict[str, Any]] = []
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: List[Dict[str, Any]] = []
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, 'r', 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(f"[dim]Loaded conversation history: {len(self.conversation_history)} messages, {len(self.tool_calls_history)} total tool calls[/dim]")
 
 
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
- 'session_id': self.session_id,
100
- 'agent_name': self.name,
101
- 'created_at': datetime.now().isoformat(),
102
- 'conversation_history': self.conversation_history,
103
- 'tool_calls_history': self.tool_calls_history,
104
- 'current_conversation_tool_calls': self.current_conversation_tool_calls,
105
  }
106
- with open(self.session_file, 'w', encoding='utf-8') as f:
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) -> Dict[str, Any]:
123
  """Get summary of the conversation."""
124
  return {
125
- 'session_id': self.session_id,
126
- 'agent_name': self.name,
127
- 'message_count': len(self.conversation_history),
128
- 'tool_calls_count': len(self.tool_calls_history),
129
- 'current_conversation_tool_calls': len(self.current_conversation_tool_calls),
130
- 'session_file': str(self.session_file),
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: Optional[Path] = None) -> str:
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) -> List[Dict[str, Any]]:
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
- "name": f"mcp.{server_name}.{tool['name']}",
172
- "description": tool.get("description", ""),
173
- "server": server_name,
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(1 for call in self.current_conversation_tool_calls if call.get("tool") == tool_name)
 
 
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) -> Dict[str, str]:
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 = self.conversation_history[-20:] if len(self.conversation_history) > 20 else self.conversation_history
 
 
 
 
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) -> List[Dict[str, Any]]:
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, 'get_spec'):
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
- "type": "function",
326
- "function": {
327
- "name": oname,
328
- "description": info.get("description", ""),
329
- "parameters": params
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) -> Dict[str, Any]:
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 as e:
364
  pass # Already logged in _build_tools
365
 
366
  return registry
367
 
368
- def _get_read_file_tool_spec(self) -> Dict[str, Any]:
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: List[Dict[str, Any]],
403
- tools: List[Dict[str, Any]],
404
- tool_registry: Dict[str, Any],
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: List[Dict[str, Any]],
431
- tools: List[Dict[str, Any]],
432
- tool_registry: Dict[str, Any],
433
  stream: bool,
434
  status: Status,
435
- max_steps: Optional[int] = None
436
  ) -> str:
437
  """Enhanced version of _chat_with_tools with better tracking."""
438
- from .chat import _get_chat_runtime_settings, _dispatch_tool_call
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(f"[bold]Agent {self.name}[/bold] - Tools {limited_tools} limited, forcing response...")
 
 
475
  self.console.print(f"[yellow]Tools {limited_tools} limited, forcing response[/yellow]")
476
  # Add system message to force response
477
- messages.append({
478
- "role": "system",
479
- "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."
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(Panel(
533
- f"✅ MCP tool {tool_name} executed successfully",
534
- title="Tool Result",
535
- border_style="green"
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(Panel(
560
- tool_result_text,
561
- title=f"Tool Error: {tool_name}",
562
- border_style="red"
563
- ))
564
 
565
  # Add tool result to messages
566
- messages.append({
567
- "role": "assistant",
568
- "content": msg.content or "",
569
- "tool_calls": [{"id": tc.id, "type": "function", "function": {"name": tool_name, "arguments": tc.function.arguments or "{}"}}]
570
- })
571
- messages.append({
572
- "role": "tool",
573
- "tool_call_id": tc.id,
574
- "name": tool_name,
575
- "content": tool_result_text,
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
- "role": "tool",
584
- "tool_call_id": tc.id,
585
- "name": tool_name,
586
- "content": error_msg,
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) -> Dict[str, Any]:
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 yaml
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
- default="",
26
- description="System prompt template with variables"
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: List[str]
50
- exclude_tools: List[str]
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, 'r', encoding='utf-8') as f:
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) -> List[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
- from typing import Annotated, Optional, Iterable
 
 
 
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 pathlib import Path
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
- with open(os.devnull, "w") as devnull:
28
- with contextlib.redirect_stderr(devnull):
29
- return __import__("asyncio").run(coro)
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: Optional[str], cfg: ChatConfig | None) -> Optional[str]:
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: Optional[str]):
55
  try:
56
  from openai import OpenAI
57
- except Exception as e: # pragma: no cover
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 as e:
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(f"FILE: {path} ({b['start']}-{b['start'] + len(lines) - 1})\n" + "\n".join(l for _, l in lines))
 
 
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(r"^\s*(?:/read|read|open|查看|读取|打开)\s+(?P<path>\S+)(?:\s+(?:lines?|行|第)\s*(?P<s>\d+)\s*[-~]\s*(?P<e>\d+))?\s*$", re.I)
 
 
 
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
- "path": {"type": "string", "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)."},
179
- "ranges": {
180
- "type": "array",
181
- "items": {"type": "string", "pattern": "^\\d+-\\d+$"},
182
- "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."
183
- },
184
- "max_lines": {"type": "integer", "minimum": 1, "maximum": 5000, "description": "Safety cap for lines returned per block (default 400)"},
 
 
 
 
 
 
 
 
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 = (chat_cfg.tool_loop_limit if chat_cfg and chat_cfg.tool_loop_limit else 5)
197
- default_max_lines = (chat_cfg.default_max_lines if chat_cfg and chat_cfg.default_max_lines else 400)
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(f"Path is a directory, not a file: {p}. Use a specific file like 'src/amcp/readfile.py' instead of just 'src/amcp'.")
 
 
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(cfg: AMCPConfig, chat_cfg: ChatConfig | None, servers_override: list[str] | None) -> tuple[list[dict], dict]:
 
 
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
- "type": "function",
273
- "function": {"name": oname, "description": info.get("description", ""), "parameters": params},
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(client, model: str, base_messages: list[dict], stream: bool, settings_override: dict | None = None, *, extra_tools: list[dict] | None = None, tool_registry: dict | None = None) -> str:
 
 
 
 
 
 
 
 
 
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 = [ _builtin_read_tool_spec() ]
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
- "role": "system",
314
- "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."
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
- "id": tc.id,
328
- "type": "function",
329
- "function": {"name": fn.name, "arguments": fn.arguments or "{}"},
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("[yellow]Hint: Exa MCP server may be experiencing connectivity issues. Try rephrasing your request or using local tools instead.[/yellow]")
 
 
364
  # append tool result
365
  messages.append(assistant_msg)
366
- messages.append({
367
- "role": "tool",
368
- "tool_call_id": tc.id,
369
- "name": fn.name,
370
- "content": tool_result_text,
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(call_mcp_tool(cfg.servers[server_name], "web_search_exa", {
410
- "query": query,
411
- "numResults": num_results,
412
- "type": "fast",
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: Optional[str],
428
  user_text: str,
429
- base_url: Optional[str] = None,
430
- system_prompt: Optional[str] = None,
431
  mcp_server: str = "exa",
432
  stream: bool = True,
433
- api_key: Optional[str] = None,
434
- work_dir: Optional[Path] = None,
435
- mcp_servers_override: Optional[list[str]] = None,
436
- mcp_tools_enabled: Optional[bool] = None,
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, 'mcp_tools_enabled') and chat_cfg and chat_cfg.mcp_tools_enabled is False:
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(client, resolved_model, messages, stream=stream, settings_override=overrides, extra_tools=extra_tools, tool_registry=registry)
 
 
 
 
 
 
 
 
472
 
473
 
474
  def chat_repl(
475
- model: Optional[str],
476
- base_url: Optional[str] = None,
477
- system_prompt: Optional[str] = None,
478
  mcp_server: str = "exa",
479
  stream: bool = True,
480
- api_key: Optional[str] = None,
481
- work_dir: Optional[Path] = None,
482
- mcp_servers_override: Optional[list[str]] = None,
483
- mcp_tools_enabled: Optional[bool] = None,
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, 'mcp_tools_enabled') and chat_cfg and chat_cfg.mcp_tools_enabled is False:
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('.')[1] for name in registry.keys())) if registry else []
513
  mcp_line = f"MCP tools: {'on' if extra_tools else 'off'}; servers: {', '.join(enabled_servers) if enabled_servers else '-'}"
514
- console.print(Panel(
515
- f"Chat model: [bold]{resolved_model}[/bold]\n"
516
- f"Base: {base}\n"
517
- f"Tool loop limit: {settings['tool_loop_limit']}\n"
518
- f"Default max lines: {settings['default_max_lines']}\n"
519
- f"Allowed read roots:\n{roots_str}\n"
520
- f"{mcp_line}\n\n"
521
- f"Commands: /read <path> [lines A-B], /search <q>, /quit",
522
- title="amcp chat",
523
- border_style="green",
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(client, resolved_model, messages, stream=stream, settings_override=overrides, extra_tools=extra_tools, tool_registry=registry)
 
 
 
 
 
 
 
 
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 load_agent_spec, get_default_agent_spec, list_available_agents
 
 
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[Path | None, typer.Option("--work-dir", "-w", help="Set working directory", exists=True, file_okay=False, dir_okay=True, readable=True)] = None,
 
 
 
 
 
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[str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")] = None,
 
 
67
  clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
68
- list_sessions: Annotated[bool, typer.Option("--list-sessions", help="List available conversation sessions")] = False,
 
 
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, 'r', encoding='utf-8') as f:
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("[yellow]No agent specifications found. Create one in ~/.config/amcp/agents/ or local agents/[/yellow]")
 
 
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['message_count'] > 0:
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(agent.run(
170
- user_input=message,
171
- work_dir=work_dir,
172
- stream=False,
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(f"[dim]Steps: {summary['steps_taken']}/{summary['max_steps']} | Tools called: {summary['tools_called']}[/dim]")
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(f"[dim]Commands: 'exit' to quit, 'clear' to clear history, 'info' for session info[/dim]")
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 ['exit', 'quit', 'q']:
195
  console.print("[green]Goodbye! 👋[/green]")
196
  break
197
-
198
- if user_input.lower() == 'clear':
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() == 'info':
204
  session_info = agent.get_conversation_summary()
205
- console.print(f"[bold]Session Info:[/bold]")
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(agent.run(
219
- user_input=user_input,
220
- work_dir=work_dir,
221
- stream=False,
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(f"[dim]Steps: {summary['steps_taken']}/{summary['max_steps']} | Tools called: {summary['tools_called']} | Session: {agent.session_id}[/dim]")
 
 
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[Path | None, typer.Option("--work-dir", "-w", help="Set working directory", exists=True, file_okay=False, dir_okay=True, readable=True)] = None,
 
 
 
 
 
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[str | None, typer.Option("--session", help="Use specific session ID for conversation continuity")] = None,
 
 
257
  clear_session: Annotated[bool, typer.Option("--clear", help="Clear conversation history for the session")] = False,
258
- list_sessions: Annotated[bool, typer.Option("--list-sessions", help="List available conversation sessions")] = False,
 
 
259
  ) -> None:
260
  """Enhanced agent chat (alias for default command)."""
261
- ctx.invoke(main, message=message, agent_file=agent_file, work_dir=work_dir,
262
- no_progress=no_progress, list_agents=list_agents, session_id=session_id,
263
- clear_session=clear_session, list_sessions=list_sessions)
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: Optional[str] = None
24
- args: List[str] = field(default_factory=list)
25
- env: Dict[str, str] = field(default_factory=dict)
26
  # http(sse) transport fields
27
- url: Optional[str] = None
28
- headers: Dict[str, str] = field(default_factory=dict)
29
 
30
 
31
  @dataclass
32
  class ChatConfig:
33
- base_url: Optional[str] = None
34
- model: Optional[str] = None
35
- api_key: Optional[str] = None
36
  # Tool calling settings
37
- tool_loop_limit: Optional[int] = None
38
- default_max_lines: Optional[int] = None
39
- read_roots: Optional[list[str]] = None # list of allowed root paths for read_file
40
  # MCP tool exposure
41
- mcp_tools_enabled: Optional[bool] = None
42
- mcp_servers: Optional[list[str]] = None # which servers' tools to expose; if unset, expose all configured servers
43
  # Built-in file modification tools
44
- write_tool_enabled: Optional[bool] = None
45
- edit_tool_enabled: Optional[bool] = None
46
 
47
 
48
  @dataclass
49
  class AMCPConfig:
50
- servers: Dict[str, Server]
51
- chat: Optional[ChatConfig] = None
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) -> Optional[ChatConfig]:
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 typing import Any, Dict, List, Tuple
 
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: Dict[str, str]) -> Dict[str, str]:
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, 'w') as 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) -> List[Dict[str, Any]]:
52
- async with _open_transport(server) as (read, write):
53
- async with ClientSession(read, write) as session:
54
- await session.initialize()
55
- result = await session.list_tools()
56
- tools: List[Dict[str, Any]] = []
57
- for t in result.tools:
 
 
 
 
 
 
 
 
 
 
58
  schema = None
59
- try:
60
- raw_schema = getattr(t, "inputSchema", None) or getattr(t, "input_schema", None)
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
- return tools
 
76
 
77
 
78
- async def call_mcp_tool(server: Server, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
79
  try:
80
- async with _open_transport(server) as (read, write):
81
- async with ClientSession(read, write) as session:
82
- await session.initialize()
83
- result = await session.call_tool(tool_name, arguments)
84
-
85
- def _to_dict(x: Any) -> Any:
86
- if hasattr(x, "model_dump"):
87
- return x.model_dump(by_alias=True, exclude_none=True)
88
- if hasattr(x, "to_dict"):
89
- return x.to_dict()
90
- return x
91
-
92
- return {
93
- "tool": tool_name,
94
- "is_error": bool(getattr(result, "isError", False)),
95
- "content": [_to_dict(c) for c in (result.content or [])],
96
- "structuredContent": getattr(result, "structuredContent", None),
97
- "metadata": getattr(result, "meta", None) or {},
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, Dict, List, Optional, Protocol, runtime_checkable
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: Dict[str, Any] = None
18
- error: Optional[str] = None
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) -> Dict[str, Any]:
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) -> Dict[str, Any]:
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: Dict[str, Tool] = {}
112
- self._tool_specs: Dict[str, Dict[str, Any]] = {}
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, 'get_spec') else {}
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) -> Optional[Tool]:
125
  """Get a tool by name."""
126
  return self._tools.get(name)
127
-
128
- def list_tools(self) -> List[str]:
129
  """List all registered tool names."""
130
  return list(self._tools.keys())
131
-
132
- def get_tool_specs(self) -> Dict[str, Dict[str, Any]]:
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
- success=False,
142
- content="",
143
- error=f"Tool '{name}' not found"
144
- )
145
-
146
  try:
147
  # Validate parameters
148
- if hasattr(tool, 'validate_parameters'):
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: Optional[List[str]] = None, max_lines: Optional[int] = None) -> ToolResult:
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
- success=False,
186
- content="",
187
- error=f"File not found: {file_path}"
188
- )
189
-
190
  if not file_path.is_file():
191
- return ToolResult(
192
- success=False,
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
- success=False,
227
- content="",
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
- success=True,
271
- content=f"🤔 Thinking: {thought}",
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
- command,
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
- "command": command,
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
- success=False,
336
- content="",
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: Optional[List[str]] = None,
375
  ignore_case: bool = False,
376
  hidden: bool = False,
377
  context: int = 0,
378
- globs: Optional[List[str]] = None
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 (globs or []):
400
  cmd.extend(["-g", g])
401
-
402
- result = subprocess.run(
403
- cmd,
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
- success=False,
442
- content="",
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
- success=False,
511
- content="",
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
- "type": "string",
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
- success=False,
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
- success=False,
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
- success=False,
578
- content="",
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
- "type": "string",
588
- "description": "Path to the file to edit"
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: Optional[ToolRegistry] = None
625
 
626
 
627
- def get_tool_registry(enable_write: Optional[bool] = None, enable_edit: Optional[bool] = None) -> ToolRegistry:
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