agentropic 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentropic-0.1.0/.gitignore +9 -0
- agentropic-0.1.0/LICENSE +21 -0
- agentropic-0.1.0/PKG-INFO +170 -0
- agentropic-0.1.0/README.md +144 -0
- agentropic-0.1.0/examples/basic_agent.py +47 -0
- agentropic-0.1.0/pyproject.toml +34 -0
- agentropic-0.1.0/src/agentropic/__init__.py +22 -0
- agentropic-0.1.0/src/agentropic/agent.py +159 -0
- agentropic-0.1.0/src/agentropic/llm.py +84 -0
- agentropic-0.1.0/src/agentropic/mcp/__init__.py +3 -0
- agentropic-0.1.0/src/agentropic/mcp/client.py +103 -0
- agentropic-0.1.0/src/agentropic/memory.py +23 -0
- agentropic-0.1.0/src/agentropic/tools.py +192 -0
- agentropic-0.1.0/tests/test_agent.py +40 -0
agentropic-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentropic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support.
|
|
5
|
+
Author-email: Vishal Tiwari <vishaltiwari.up2019@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agent,agents,ai,litellm,llm,mcp,tools
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: litellm>=1.40.0
|
|
17
|
+
Requires-Dist: pydantic>=2.0.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
22
|
+
Requires-Dist: twine; extra == 'dev'
|
|
23
|
+
Provides-Extra: mcp
|
|
24
|
+
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# agentropic
|
|
28
|
+
|
|
29
|
+
A small, hackable agentic framework in ~500 lines you fully own and can read
|
|
30
|
+
top to bottom in an afternoon:
|
|
31
|
+
|
|
32
|
+
- **LLM calls** go through [litellm](https://github.com/BerriAI/litellm), so you get
|
|
33
|
+
every provider (OpenAI, Anthropic, Gemini, Ollama, Bedrock, 100+ more) for free.
|
|
34
|
+
- **`@tool` decorator** turns any python function into an LLM-callable tool with
|
|
35
|
+
an auto-generated JSON schema (from type hints + docstring).
|
|
36
|
+
- **MCP support** — connect to any Model Context Protocol server (stdio or SSE)
|
|
37
|
+
and its tools show up in your agent automatically, indistinguishable from
|
|
38
|
+
local tools.
|
|
39
|
+
- **Agent loop** handles the call → tool-call → tool-result → call cycle for you,
|
|
40
|
+
with memory, streaming, and a max-iteration safety valve.
|
|
41
|
+
|
|
42
|
+
## Install (once published)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install agentropic
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from agentropic import Agent, tool
|
|
53
|
+
|
|
54
|
+
@tool()
|
|
55
|
+
def get_weather(city: str) -> str:
|
|
56
|
+
"""Get the current weather for a city."""
|
|
57
|
+
return f"It's sunny in {city}."
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
agent = Agent(
|
|
61
|
+
name="assistant",
|
|
62
|
+
model="gpt-4o-mini", # any litellm model string
|
|
63
|
+
instructions="You are helpful and concise.",
|
|
64
|
+
tools=[get_weather],
|
|
65
|
+
)
|
|
66
|
+
print(await agent.arun("What's the weather in Paris?"))
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Sync usage: `agent.run("...")` (wraps `arun` in `asyncio.run` for you).
|
|
72
|
+
|
|
73
|
+
## Using MCP servers
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from agentropic import Agent
|
|
77
|
+
from agentropic.mcp import StdioServer
|
|
78
|
+
|
|
79
|
+
agent = Agent(
|
|
80
|
+
name="fs-agent",
|
|
81
|
+
model="gpt-4o-mini",
|
|
82
|
+
mcp_servers=[
|
|
83
|
+
StdioServer(command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "."]),
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
print(await agent.arun("List the files in the current directory."))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires the optional MCP dependency: `pip install "agentropic[mcp]"`.
|
|
90
|
+
|
|
91
|
+
## Architecture
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
src/agentropic/
|
|
95
|
+
├── __init__.py # public API surface
|
|
96
|
+
├── agent.py # the run loop: LLM <-> tools <-> memory
|
|
97
|
+
├── llm.py # thin litellm wrapper (swap providers here)
|
|
98
|
+
├── tools.py # @tool decorator, Tool, ToolRegistry, schema generation
|
|
99
|
+
├── memory.py # in-memory conversation history (swap for your own store)
|
|
100
|
+
└── mcp/
|
|
101
|
+
└── client.py # MCP stdio/SSE client -> Tool adapter
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Everything is a plain, editable python file — no hidden magic, no plugin
|
|
105
|
+
registry you can't inspect. Read `agent.py` first; that's the whole loop.
|
|
106
|
+
|
|
107
|
+
## Extending it
|
|
108
|
+
|
|
109
|
+
- **Custom memory / persistence**: implement `.add()`, `.get()`, `.clear()`
|
|
110
|
+
matching `Memory` and pass `memory=YourMemory()` to `Agent`.
|
|
111
|
+
- **Streaming with tool calls**: `astream()` currently falls back to a
|
|
112
|
+
non-streamed `arun()` when tools are registered — extend it if you need
|
|
113
|
+
token-level streaming mid-tool-loop.
|
|
114
|
+
- **Multi-agent handoff**: compose multiple `Agent` instances and route
|
|
115
|
+
between them yourself, or wrap one agent's `arun` as a tool for another.
|
|
116
|
+
|
|
117
|
+
## Publishing your fork to PyPI
|
|
118
|
+
|
|
119
|
+
1. **Pick a unique name** and check it's free:
|
|
120
|
+
`pip index versions your-name` or just search on https://pypi.org.
|
|
121
|
+
Update `name` in `pyproject.toml` (and rename the `src/agentropic` folder
|
|
122
|
+
if you want the import name to match).
|
|
123
|
+
|
|
124
|
+
2. **Bump the version** in `pyproject.toml` and `src/agentropic/__init__.py`
|
|
125
|
+
(`__version__`).
|
|
126
|
+
|
|
127
|
+
3. **Install build tooling**:
|
|
128
|
+
```bash
|
|
129
|
+
pip install build twine
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
4. **Build**:
|
|
133
|
+
```bash
|
|
134
|
+
python -m build
|
|
135
|
+
```
|
|
136
|
+
This produces `dist/agentropic-0.1.0.tar.gz` and `dist/agentropic-0.1.0-py3-none-any.whl`.
|
|
137
|
+
|
|
138
|
+
5. **Test on TestPyPI first** (highly recommended):
|
|
139
|
+
```bash
|
|
140
|
+
python -m twine upload --repository testpypi dist/*
|
|
141
|
+
pip install --index-url https://test.pypi.org/simple/ agentropic
|
|
142
|
+
```
|
|
143
|
+
You'll need a free account at https://test.pypi.org and an API token
|
|
144
|
+
(Account Settings → API tokens). Use `__token__` as the username and the
|
|
145
|
+
token (starting `pypi-...`) as the password, or store it in `~/.pypirc`:
|
|
146
|
+
```ini
|
|
147
|
+
[testpypi]
|
|
148
|
+
username = __token__
|
|
149
|
+
password = pypi-...your-token...
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
6. **Publish for real** once it installs and works:
|
|
153
|
+
```bash
|
|
154
|
+
python -m twine upload dist/*
|
|
155
|
+
```
|
|
156
|
+
(needs an API token from https://pypi.org/manage/account/ the same way).
|
|
157
|
+
|
|
158
|
+
7. **Every future release**: bump the version number, `python -m build` again
|
|
159
|
+
(delete the old `dist/` first), `twine upload dist/*`.
|
|
160
|
+
|
|
161
|
+
## Running tests
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install -e ".[dev]"
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT — do whatever you want with it.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# agentropic
|
|
2
|
+
|
|
3
|
+
A small, hackable agentic framework in ~500 lines you fully own and can read
|
|
4
|
+
top to bottom in an afternoon:
|
|
5
|
+
|
|
6
|
+
- **LLM calls** go through [litellm](https://github.com/BerriAI/litellm), so you get
|
|
7
|
+
every provider (OpenAI, Anthropic, Gemini, Ollama, Bedrock, 100+ more) for free.
|
|
8
|
+
- **`@tool` decorator** turns any python function into an LLM-callable tool with
|
|
9
|
+
an auto-generated JSON schema (from type hints + docstring).
|
|
10
|
+
- **MCP support** — connect to any Model Context Protocol server (stdio or SSE)
|
|
11
|
+
and its tools show up in your agent automatically, indistinguishable from
|
|
12
|
+
local tools.
|
|
13
|
+
- **Agent loop** handles the call → tool-call → tool-result → call cycle for you,
|
|
14
|
+
with memory, streaming, and a max-iteration safety valve.
|
|
15
|
+
|
|
16
|
+
## Install (once published)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install agentropic
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from agentropic import Agent, tool
|
|
27
|
+
|
|
28
|
+
@tool()
|
|
29
|
+
def get_weather(city: str) -> str:
|
|
30
|
+
"""Get the current weather for a city."""
|
|
31
|
+
return f"It's sunny in {city}."
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
agent = Agent(
|
|
35
|
+
name="assistant",
|
|
36
|
+
model="gpt-4o-mini", # any litellm model string
|
|
37
|
+
instructions="You are helpful and concise.",
|
|
38
|
+
tools=[get_weather],
|
|
39
|
+
)
|
|
40
|
+
print(await agent.arun("What's the weather in Paris?"))
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Sync usage: `agent.run("...")` (wraps `arun` in `asyncio.run` for you).
|
|
46
|
+
|
|
47
|
+
## Using MCP servers
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from agentropic import Agent
|
|
51
|
+
from agentropic.mcp import StdioServer
|
|
52
|
+
|
|
53
|
+
agent = Agent(
|
|
54
|
+
name="fs-agent",
|
|
55
|
+
model="gpt-4o-mini",
|
|
56
|
+
mcp_servers=[
|
|
57
|
+
StdioServer(command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "."]),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
print(await agent.arun("List the files in the current directory."))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires the optional MCP dependency: `pip install "agentropic[mcp]"`.
|
|
64
|
+
|
|
65
|
+
## Architecture
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
src/agentropic/
|
|
69
|
+
├── __init__.py # public API surface
|
|
70
|
+
├── agent.py # the run loop: LLM <-> tools <-> memory
|
|
71
|
+
├── llm.py # thin litellm wrapper (swap providers here)
|
|
72
|
+
├── tools.py # @tool decorator, Tool, ToolRegistry, schema generation
|
|
73
|
+
├── memory.py # in-memory conversation history (swap for your own store)
|
|
74
|
+
└── mcp/
|
|
75
|
+
└── client.py # MCP stdio/SSE client -> Tool adapter
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Everything is a plain, editable python file — no hidden magic, no plugin
|
|
79
|
+
registry you can't inspect. Read `agent.py` first; that's the whole loop.
|
|
80
|
+
|
|
81
|
+
## Extending it
|
|
82
|
+
|
|
83
|
+
- **Custom memory / persistence**: implement `.add()`, `.get()`, `.clear()`
|
|
84
|
+
matching `Memory` and pass `memory=YourMemory()` to `Agent`.
|
|
85
|
+
- **Streaming with tool calls**: `astream()` currently falls back to a
|
|
86
|
+
non-streamed `arun()` when tools are registered — extend it if you need
|
|
87
|
+
token-level streaming mid-tool-loop.
|
|
88
|
+
- **Multi-agent handoff**: compose multiple `Agent` instances and route
|
|
89
|
+
between them yourself, or wrap one agent's `arun` as a tool for another.
|
|
90
|
+
|
|
91
|
+
## Publishing your fork to PyPI
|
|
92
|
+
|
|
93
|
+
1. **Pick a unique name** and check it's free:
|
|
94
|
+
`pip index versions your-name` or just search on https://pypi.org.
|
|
95
|
+
Update `name` in `pyproject.toml` (and rename the `src/agentropic` folder
|
|
96
|
+
if you want the import name to match).
|
|
97
|
+
|
|
98
|
+
2. **Bump the version** in `pyproject.toml` and `src/agentropic/__init__.py`
|
|
99
|
+
(`__version__`).
|
|
100
|
+
|
|
101
|
+
3. **Install build tooling**:
|
|
102
|
+
```bash
|
|
103
|
+
pip install build twine
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
4. **Build**:
|
|
107
|
+
```bash
|
|
108
|
+
python -m build
|
|
109
|
+
```
|
|
110
|
+
This produces `dist/agentropic-0.1.0.tar.gz` and `dist/agentropic-0.1.0-py3-none-any.whl`.
|
|
111
|
+
|
|
112
|
+
5. **Test on TestPyPI first** (highly recommended):
|
|
113
|
+
```bash
|
|
114
|
+
python -m twine upload --repository testpypi dist/*
|
|
115
|
+
pip install --index-url https://test.pypi.org/simple/ agentropic
|
|
116
|
+
```
|
|
117
|
+
You'll need a free account at https://test.pypi.org and an API token
|
|
118
|
+
(Account Settings → API tokens). Use `__token__` as the username and the
|
|
119
|
+
token (starting `pypi-...`) as the password, or store it in `~/.pypirc`:
|
|
120
|
+
```ini
|
|
121
|
+
[testpypi]
|
|
122
|
+
username = __token__
|
|
123
|
+
password = pypi-...your-token...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
6. **Publish for real** once it installs and works:
|
|
127
|
+
```bash
|
|
128
|
+
python -m twine upload dist/*
|
|
129
|
+
```
|
|
130
|
+
(needs an API token from https://pypi.org/manage/account/ the same way).
|
|
131
|
+
|
|
132
|
+
7. **Every future release**: bump the version number, `python -m build` again
|
|
133
|
+
(delete the old `dist/` first), `twine upload dist/*`.
|
|
134
|
+
|
|
135
|
+
## Running tests
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install -e ".[dev]"
|
|
139
|
+
pytest
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT — do whatever you want with it.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run with: OPENAI_API_KEY=sk-... python examples/basic_agent.py
|
|
3
|
+
(or set whichever env var litellm expects for your chosen model provider)
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from agentropic import Agent, tool
|
|
8
|
+
from agentropic.mcp import StdioServer
|
|
9
|
+
import os
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@tool()
|
|
15
|
+
def get_weather(city: str) -> str:
|
|
16
|
+
"""Get the current weather for a given city."""
|
|
17
|
+
# Pretend implementation — replace with a real API call.
|
|
18
|
+
return f"It's sunny and 28C in {city}."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@tool(name="add_numbers", description="Add two numbers together.")
|
|
22
|
+
def add(a: int, b: int) -> int:
|
|
23
|
+
return a + b
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
agent = Agent(
|
|
28
|
+
name="assistant",
|
|
29
|
+
model="gemini/gemini-2.5-flash-lite",
|
|
30
|
+
api_key=os.getenv("api_key"),
|
|
31
|
+
instructions="You are a concise, helpful assistant. Use tools when needed.",
|
|
32
|
+
tools=[get_weather, add],
|
|
33
|
+
# Optional: pull in tools from a live MCP server too.
|
|
34
|
+
# mcp_servers=[StdioServer(command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "."])],
|
|
35
|
+
max_iterations=6,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
reply = await agent.arun("What's the weather in Tokyo, and what's 12 + 30?")
|
|
39
|
+
print(reply)
|
|
40
|
+
|
|
41
|
+
# Multi-turn: memory carries over automatically.
|
|
42
|
+
reply2 = await agent.arun("Say that last sum again, but doubled.")
|
|
43
|
+
print(reply2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentropic"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vishal Tiwari", email = "vishaltiwari.up2019@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["llm", "agent", "agents", "litellm", "mcp", "tools", "ai"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"litellm>=1.40.0",
|
|
26
|
+
"pydantic>=2.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
mcp = ["mcp>=1.0.0"]
|
|
31
|
+
dev = ["pytest", "pytest-asyncio", "build", "twine"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/agentropic"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .agent import Agent, AgentError
|
|
2
|
+
from .tools import tool, Tool, ToolRegistry
|
|
3
|
+
from .llm import LLMClient, LLMResponse
|
|
4
|
+
from .memory import Memory
|
|
5
|
+
from .mcp.client import MCPConnection, StdioServer, SSEServer, MCPServerConfig
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Agent",
|
|
11
|
+
"AgentError",
|
|
12
|
+
"tool",
|
|
13
|
+
"Tool",
|
|
14
|
+
"ToolRegistry",
|
|
15
|
+
"LLMClient",
|
|
16
|
+
"LLMResponse",
|
|
17
|
+
"Memory",
|
|
18
|
+
"MCPConnection",
|
|
19
|
+
"StdioServer",
|
|
20
|
+
"SSEServer",
|
|
21
|
+
"MCPServerConfig",
|
|
22
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The Agent: ties together an LLMClient, a ToolRegistry, memory, and
|
|
2
|
+
optional MCP servers into a single run()/arun() loop that:
|
|
3
|
+
1. sends messages + tool schemas to the LLM
|
|
4
|
+
2. if the LLM asks for tool calls, executes them and feeds results back
|
|
5
|
+
3. repeats until the LLM returns a final answer or max_iterations hits
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
from .llm import LLMClient
|
|
13
|
+
from .memory import Memory
|
|
14
|
+
from .tools import Tool, ToolRegistry
|
|
15
|
+
from .mcp.client import MCPConnection, MCPServerConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Agent:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
name: str,
|
|
26
|
+
model: str,
|
|
27
|
+
instructions: str = "You are a helpful assistant.",
|
|
28
|
+
tools: t.Optional[list] = None,
|
|
29
|
+
mcp_servers: t.Optional[list[MCPServerConfig]] = None,
|
|
30
|
+
memory: t.Optional[Memory] = None,
|
|
31
|
+
max_iterations: int = 10,
|
|
32
|
+
api_key: t.Optional[str] = None,
|
|
33
|
+
api_base: t.Optional[str] = None,
|
|
34
|
+
temperature: float = 0.7,
|
|
35
|
+
**llm_kwargs,
|
|
36
|
+
):
|
|
37
|
+
self.name = name
|
|
38
|
+
self.instructions = instructions
|
|
39
|
+
self.registry = ToolRegistry(tools)
|
|
40
|
+
self.mcp_servers = mcp_servers or []
|
|
41
|
+
self.memory = memory or Memory()
|
|
42
|
+
self.max_iterations = max_iterations
|
|
43
|
+
self.llm = LLMClient(
|
|
44
|
+
model=model,
|
|
45
|
+
api_key=api_key,
|
|
46
|
+
api_base=api_base,
|
|
47
|
+
temperature=temperature,
|
|
48
|
+
extra_params=llm_kwargs,
|
|
49
|
+
)
|
|
50
|
+
self._mcp_tools_loaded = False
|
|
51
|
+
|
|
52
|
+
# -- tool management -------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def add_tool(self, item: t.Union[Tool, t.Callable]) -> Tool:
|
|
55
|
+
return self.registry.add(item)
|
|
56
|
+
|
|
57
|
+
async def _load_mcp_tools(self) -> None:
|
|
58
|
+
if self._mcp_tools_loaded or not self.mcp_servers:
|
|
59
|
+
return
|
|
60
|
+
for server_config in self.mcp_servers:
|
|
61
|
+
async with MCPConnection(server_config) as conn:
|
|
62
|
+
for mcp_tool in await conn.list_tools():
|
|
63
|
+
self.registry.add(mcp_tool)
|
|
64
|
+
self._mcp_tools_loaded = True
|
|
65
|
+
|
|
66
|
+
# -- message building --------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _build_messages(self, user_input: str) -> list[dict]:
|
|
69
|
+
messages = [{"role": "system", "content": self.instructions}]
|
|
70
|
+
messages.extend(self.memory.get())
|
|
71
|
+
messages.append({"role": "user", "content": user_input})
|
|
72
|
+
return messages
|
|
73
|
+
|
|
74
|
+
async def _execute_tool_call(self, tool_call) -> dict:
|
|
75
|
+
name = tool_call.function.name
|
|
76
|
+
try:
|
|
77
|
+
args = json.loads(tool_call.function.arguments or "{}")
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
args = {}
|
|
80
|
+
|
|
81
|
+
tl = self.registry.get(name)
|
|
82
|
+
if tl is None:
|
|
83
|
+
result = f"Error: tool '{name}' is not registered."
|
|
84
|
+
else:
|
|
85
|
+
try:
|
|
86
|
+
result = await tl.acall(**args)
|
|
87
|
+
except Exception as exc: # tool errors go back to the LLM, not raised
|
|
88
|
+
result = f"Error calling tool '{name}': {exc}"
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"role": "tool",
|
|
92
|
+
"tool_call_id": tool_call.id,
|
|
93
|
+
"name": name,
|
|
94
|
+
"content": str(result),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# -- public API ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def run(self, user_input: str) -> str:
|
|
100
|
+
import asyncio
|
|
101
|
+
|
|
102
|
+
return asyncio.run(self.arun(user_input))
|
|
103
|
+
|
|
104
|
+
async def arun(self, user_input: str) -> str:
|
|
105
|
+
await self._load_mcp_tools()
|
|
106
|
+
|
|
107
|
+
messages = self._build_messages(user_input)
|
|
108
|
+
self.memory.add("user", user_input)
|
|
109
|
+
|
|
110
|
+
tools_schema = self.registry.to_openai_tools() or None
|
|
111
|
+
|
|
112
|
+
for _ in range(self.max_iterations):
|
|
113
|
+
response = await self.llm.acomplete(messages, tools=tools_schema)
|
|
114
|
+
|
|
115
|
+
if response.tool_calls:
|
|
116
|
+
assistant_msg = {
|
|
117
|
+
"role": "assistant",
|
|
118
|
+
"content": response.content or "",
|
|
119
|
+
"tool_calls": response.tool_calls,
|
|
120
|
+
}
|
|
121
|
+
messages.append(assistant_msg)
|
|
122
|
+
self.memory.add("assistant", response.content or "", tool_calls=response.tool_calls)
|
|
123
|
+
|
|
124
|
+
for tool_call in response.tool_calls:
|
|
125
|
+
tool_msg = await self._execute_tool_call(tool_call)
|
|
126
|
+
messages.append(tool_msg)
|
|
127
|
+
self.memory.add(
|
|
128
|
+
"tool", tool_msg["content"], tool_call_id=tool_msg["tool_call_id"], name=tool_msg["name"]
|
|
129
|
+
)
|
|
130
|
+
continue # let the LLM see tool results and respond again
|
|
131
|
+
|
|
132
|
+
# No tool calls -> final answer
|
|
133
|
+
self.memory.add("assistant", response.content or "")
|
|
134
|
+
return response.content or ""
|
|
135
|
+
|
|
136
|
+
raise AgentError(
|
|
137
|
+
f"Agent '{self.name}' hit max_iterations ({self.max_iterations}) without a final answer."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def astream(self, user_input: str) -> t.AsyncIterator[str]:
|
|
141
|
+
"""Simple streaming for the common case of no tool calls needed.
|
|
142
|
+
For tool-using turns, falls back to a non-streamed arun() call."""
|
|
143
|
+
await self._load_mcp_tools()
|
|
144
|
+
messages = self._build_messages(user_input)
|
|
145
|
+
tools_schema = self.registry.to_openai_tools() or None
|
|
146
|
+
|
|
147
|
+
# Peek: if tools exist, we can't safely stream+detect tool_calls with
|
|
148
|
+
# this minimal implementation, so just delegate to arun().
|
|
149
|
+
if tools_schema:
|
|
150
|
+
result = await self.arun(user_input)
|
|
151
|
+
yield result
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
self.memory.add("user", user_input)
|
|
155
|
+
full = ""
|
|
156
|
+
async for chunk in self.llm.astream(messages):
|
|
157
|
+
full += chunk
|
|
158
|
+
yield chunk
|
|
159
|
+
self.memory.add("assistant", full)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin wrapper around litellm so the rest of the library never talks to
|
|
3
|
+
litellm directly. Swapping the underlying call layer later only means
|
|
4
|
+
touching this one file.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typing as t
|
|
9
|
+
|
|
10
|
+
import litellm
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMResponse(t.NamedTuple):
|
|
14
|
+
content: t.Optional[str]
|
|
15
|
+
tool_calls: list # list of litellm/OpenAI-style tool call objects
|
|
16
|
+
raw: t.Any
|
|
17
|
+
finish_reason: t.Optional[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model: str,
|
|
24
|
+
api_key: t.Optional[str] = None,
|
|
25
|
+
api_base: t.Optional[str] = None,
|
|
26
|
+
temperature: float = 0.7,
|
|
27
|
+
max_tokens: t.Optional[int] = None,
|
|
28
|
+
extra_params: t.Optional[dict] = None,
|
|
29
|
+
):
|
|
30
|
+
self.model = model
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.api_base = api_base
|
|
33
|
+
self.temperature = temperature
|
|
34
|
+
self.max_tokens = max_tokens
|
|
35
|
+
self.extra_params = extra_params or {}
|
|
36
|
+
|
|
37
|
+
def _base_kwargs(self, messages: list, tools: t.Optional[list], stream: bool) -> dict:
|
|
38
|
+
kwargs: dict = dict(
|
|
39
|
+
model=self.model,
|
|
40
|
+
messages=messages,
|
|
41
|
+
temperature=self.temperature,
|
|
42
|
+
stream=stream,
|
|
43
|
+
**self.extra_params,
|
|
44
|
+
)
|
|
45
|
+
if self.api_key:
|
|
46
|
+
kwargs["api_key"] = self.api_key
|
|
47
|
+
if self.api_base:
|
|
48
|
+
kwargs["api_base"] = self.api_base
|
|
49
|
+
if self.max_tokens:
|
|
50
|
+
kwargs["max_tokens"] = self.max_tokens
|
|
51
|
+
if tools:
|
|
52
|
+
kwargs["tools"] = tools
|
|
53
|
+
kwargs["tool_choice"] = "auto"
|
|
54
|
+
return kwargs
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _parse(response) -> LLMResponse:
|
|
58
|
+
choice = response.choices[0]
|
|
59
|
+
message = choice.message
|
|
60
|
+
return LLMResponse(
|
|
61
|
+
content=message.content,
|
|
62
|
+
tool_calls=message.tool_calls or [],
|
|
63
|
+
raw=response,
|
|
64
|
+
finish_reason=choice.finish_reason,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def complete(self, messages: list, tools: t.Optional[list] = None) -> LLMResponse:
|
|
68
|
+
kwargs = self._base_kwargs(messages, tools, stream=False)
|
|
69
|
+
response = litellm.completion(**kwargs)
|
|
70
|
+
return self._parse(response)
|
|
71
|
+
|
|
72
|
+
async def acomplete(self, messages: list, tools: t.Optional[list] = None) -> LLMResponse:
|
|
73
|
+
kwargs = self._base_kwargs(messages, tools, stream=False)
|
|
74
|
+
response = await litellm.acompletion(**kwargs)
|
|
75
|
+
return self._parse(response)
|
|
76
|
+
|
|
77
|
+
async def astream(self, messages: list, tools: t.Optional[list] = None):
|
|
78
|
+
"""Yields raw chunk deltas (str) as they arrive."""
|
|
79
|
+
kwargs = self._base_kwargs(messages, tools, stream=True)
|
|
80
|
+
response = await litellm.acompletion(**kwargs)
|
|
81
|
+
async for chunk in response:
|
|
82
|
+
delta = chunk.choices[0].delta
|
|
83
|
+
if delta and delta.content:
|
|
84
|
+
yield delta.content
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP integration. Wraps the official `mcp` python SDK so that any tool
|
|
3
|
+
exposed by an MCP server (stdio or SSE/HTTP) shows up as a normal
|
|
4
|
+
agentum Tool, callable exactly like a local @tool function.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typing as t
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ..tools import Tool # populated by package __init__ patch below (see note)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StdioServer:
|
|
17
|
+
"""Launch a local MCP server as a subprocess, e.g. `npx some-mcp-server`."""
|
|
18
|
+
|
|
19
|
+
command: str
|
|
20
|
+
args: list[str] = None # type: ignore[assignment]
|
|
21
|
+
env: t.Optional[dict] = None
|
|
22
|
+
name: str = "stdio-server"
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
self.args = self.args or []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SSEServer:
|
|
30
|
+
"""Connect to a remote MCP server over SSE/HTTP."""
|
|
31
|
+
|
|
32
|
+
url: str
|
|
33
|
+
headers: t.Optional[dict] = None
|
|
34
|
+
name: str = "sse-server"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
MCPServerConfig = t.Union[StdioServer, SSEServer]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCPConnection:
|
|
41
|
+
"""
|
|
42
|
+
One live connection to one MCP server. Use as an async context manager:
|
|
43
|
+
|
|
44
|
+
async with MCPConnection(StdioServer("npx", ["-y", "some-mcp"])) as conn:
|
|
45
|
+
tools = await conn.list_tools()
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: MCPServerConfig):
|
|
49
|
+
self.config = config
|
|
50
|
+
self._stack = AsyncExitStack()
|
|
51
|
+
self._session = None
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self) -> "MCPConnection":
|
|
54
|
+
# Imported lazily so `agentum` works even if the optional `mcp`
|
|
55
|
+
# package isn't installed unless MCP features are actually used.
|
|
56
|
+
from mcp import ClientSession
|
|
57
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
58
|
+
|
|
59
|
+
if isinstance(self.config, StdioServer):
|
|
60
|
+
params = StdioServerParameters(
|
|
61
|
+
command=self.config.command, args=self.config.args, env=self.config.env
|
|
62
|
+
)
|
|
63
|
+
read, write = await self._stack.enter_async_context(stdio_client(params))
|
|
64
|
+
else:
|
|
65
|
+
from mcp.client.sse import sse_client
|
|
66
|
+
|
|
67
|
+
read, write = await self._stack.enter_async_context(
|
|
68
|
+
sse_client(self.config.url, headers=self.config.headers)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._session = await self._stack.enter_async_context(ClientSession(read, write))
|
|
72
|
+
await self._session.initialize()
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
76
|
+
await self._stack.aclose()
|
|
77
|
+
|
|
78
|
+
async def list_tools(self) -> list[Tool]:
|
|
79
|
+
result = await self._session.list_tools()
|
|
80
|
+
tools = []
|
|
81
|
+
for mcp_tool in result.tools:
|
|
82
|
+
tools.append(self._to_agentum_tool(mcp_tool))
|
|
83
|
+
return tools
|
|
84
|
+
|
|
85
|
+
def _to_agentum_tool(self, mcp_tool) -> Tool:
|
|
86
|
+
async def _call(**kwargs):
|
|
87
|
+
res = await self._session.call_tool(mcp_tool.name, arguments=kwargs)
|
|
88
|
+
# MCP returns a list of content blocks; flatten text blocks.
|
|
89
|
+
parts = []
|
|
90
|
+
for block in res.content:
|
|
91
|
+
text = getattr(block, "text", None)
|
|
92
|
+
parts.append(text if text is not None else str(block))
|
|
93
|
+
return "\n".join(parts)
|
|
94
|
+
|
|
95
|
+
return Tool(
|
|
96
|
+
name=mcp_tool.name,
|
|
97
|
+
description=mcp_tool.description or f"MCP tool {mcp_tool.name}",
|
|
98
|
+
parameters=mcp_tool.inputSchema or {"type": "object", "properties": {}},
|
|
99
|
+
func=None, # not used; calls are routed through _mcp_call
|
|
100
|
+
is_async=True,
|
|
101
|
+
source="mcp",
|
|
102
|
+
_mcp_call=_call,
|
|
103
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Minimal conversation memory. Swap out with your own by implementing
|
|
2
|
+
the same three methods (add, get, clear) if you need persistence."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing as t
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Memory:
|
|
9
|
+
def __init__(self, max_messages: t.Optional[int] = None):
|
|
10
|
+
self._messages: list[dict] = []
|
|
11
|
+
self.max_messages = max_messages
|
|
12
|
+
|
|
13
|
+
def add(self, role: str, content: t.Any, **extra) -> None:
|
|
14
|
+
msg = {"role": role, "content": content, **extra}
|
|
15
|
+
self._messages.append(msg)
|
|
16
|
+
if self.max_messages and len(self._messages) > self.max_messages:
|
|
17
|
+
self._messages = self._messages[-self.max_messages :]
|
|
18
|
+
|
|
19
|
+
def get(self) -> list[dict]:
|
|
20
|
+
return list(self._messages)
|
|
21
|
+
|
|
22
|
+
def clear(self) -> None:
|
|
23
|
+
self._messages.clear()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool system: turns plain python functions into LLM-callable tools with
|
|
3
|
+
auto-generated JSON-schema, via a simple @tool decorator.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import inspect
|
|
9
|
+
import typing as t
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
except ImportError: # pydantic is a hard dependency, but keep this defensive
|
|
15
|
+
BaseModel = None # type: ignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_PY_TO_JSON_TYPE = {
|
|
19
|
+
str: "string",
|
|
20
|
+
int: "integer",
|
|
21
|
+
float: "number",
|
|
22
|
+
bool: "boolean",
|
|
23
|
+
list: "array",
|
|
24
|
+
dict: "object",
|
|
25
|
+
type(None): "null",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _annotation_to_schema(annotation: t.Any) -> dict:
|
|
30
|
+
"""Best-effort conversion of a python type hint to a JSON schema fragment."""
|
|
31
|
+
if annotation is inspect._empty:
|
|
32
|
+
return {"type": "string"}
|
|
33
|
+
|
|
34
|
+
origin = t.get_origin(annotation)
|
|
35
|
+
|
|
36
|
+
# Optional[X] / Union[X, None]
|
|
37
|
+
if origin is t.Union:
|
|
38
|
+
args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
39
|
+
if len(args) == 1:
|
|
40
|
+
return _annotation_to_schema(args[0])
|
|
41
|
+
return {"anyOf": [_annotation_to_schema(a) for a in args]}
|
|
42
|
+
|
|
43
|
+
# list[X] / List[X]
|
|
44
|
+
if origin in (list, t.List):
|
|
45
|
+
args = t.get_args(annotation)
|
|
46
|
+
item_schema = _annotation_to_schema(args[0]) if args else {"type": "string"}
|
|
47
|
+
return {"type": "array", "items": item_schema}
|
|
48
|
+
|
|
49
|
+
# Literal["a", "b"]
|
|
50
|
+
if origin is t.Literal:
|
|
51
|
+
values = list(t.get_args(annotation))
|
|
52
|
+
py_type = type(values[0]) if values else str
|
|
53
|
+
schema = {"type": _PY_TO_JSON_TYPE.get(py_type, "string")}
|
|
54
|
+
schema["enum"] = values
|
|
55
|
+
return schema
|
|
56
|
+
|
|
57
|
+
# Pydantic model as a parameter -> nested object schema
|
|
58
|
+
if BaseModel is not None and inspect.isclass(annotation) and issubclass(annotation, BaseModel):
|
|
59
|
+
return annotation.model_json_schema()
|
|
60
|
+
|
|
61
|
+
if annotation in _PY_TO_JSON_TYPE:
|
|
62
|
+
return {"type": _PY_TO_JSON_TYPE[annotation]}
|
|
63
|
+
|
|
64
|
+
# Fallback: treat as string so the LLM always has *something* to work with
|
|
65
|
+
return {"type": "string"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_parameters_schema(func: t.Callable) -> dict:
|
|
69
|
+
sig = inspect.signature(func)
|
|
70
|
+
hints = t.get_type_hints(func)
|
|
71
|
+
properties: dict = {}
|
|
72
|
+
required: list[str] = []
|
|
73
|
+
|
|
74
|
+
for name, param in sig.parameters.items():
|
|
75
|
+
if name in ("self", "cls"):
|
|
76
|
+
continue
|
|
77
|
+
annotation = hints.get(name, param.annotation)
|
|
78
|
+
schema = _annotation_to_schema(annotation)
|
|
79
|
+
if param.default is inspect._empty:
|
|
80
|
+
required.append(name)
|
|
81
|
+
properties[name] = schema
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": properties,
|
|
86
|
+
"required": required,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Tool:
|
|
92
|
+
"""A callable tool exposed to the agent/LLM."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
description: str
|
|
96
|
+
parameters: dict
|
|
97
|
+
func: t.Callable
|
|
98
|
+
is_async: bool = False
|
|
99
|
+
# Set for tools that come from an MCP server rather than local python.
|
|
100
|
+
source: str = "local"
|
|
101
|
+
_mcp_call: t.Optional[t.Callable[..., t.Awaitable]] = field(default=None, repr=False)
|
|
102
|
+
|
|
103
|
+
def to_openai_schema(self) -> dict:
|
|
104
|
+
"""The format litellm/OpenAI-style function calling expects."""
|
|
105
|
+
return {
|
|
106
|
+
"type": "function",
|
|
107
|
+
"function": {
|
|
108
|
+
"name": self.name,
|
|
109
|
+
"description": self.description,
|
|
110
|
+
"parameters": self.parameters,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async def acall(self, **kwargs) -> t.Any:
|
|
115
|
+
if self._mcp_call is not None:
|
|
116
|
+
return await self._mcp_call(**kwargs)
|
|
117
|
+
if self.is_async:
|
|
118
|
+
return await self.func(**kwargs)
|
|
119
|
+
return await asyncio.to_thread(self.func, **kwargs)
|
|
120
|
+
|
|
121
|
+
def call(self, **kwargs) -> t.Any:
|
|
122
|
+
if self.is_async or self._mcp_call is not None:
|
|
123
|
+
return asyncio.run(self.acall(**kwargs))
|
|
124
|
+
return self.func(**kwargs)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def tool(name: t.Optional[str] = None, description: t.Optional[str] = None):
|
|
128
|
+
"""
|
|
129
|
+
Decorator that turns a function into a Tool.
|
|
130
|
+
|
|
131
|
+
@tool()
|
|
132
|
+
def get_weather(city: str) -> str:
|
|
133
|
+
'''Get the current weather for a city.'''
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
Decorated functions keep working as normal python functions; the Tool
|
|
137
|
+
metadata is attached as `func._tool`.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def decorator(func: t.Callable) -> t.Callable:
|
|
141
|
+
tool_name = name or func.__name__
|
|
142
|
+
doc = (description or inspect.getdoc(func) or "").strip()
|
|
143
|
+
params_schema = _build_parameters_schema(func)
|
|
144
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
145
|
+
|
|
146
|
+
built = Tool(
|
|
147
|
+
name=tool_name,
|
|
148
|
+
description=doc or f"Tool named {tool_name}",
|
|
149
|
+
parameters=params_schema,
|
|
150
|
+
func=func,
|
|
151
|
+
is_async=is_async,
|
|
152
|
+
)
|
|
153
|
+
func._tool = built # type: ignore[attr-defined]
|
|
154
|
+
return func
|
|
155
|
+
|
|
156
|
+
return decorator
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ToolRegistry:
|
|
160
|
+
"""Holds every tool an agent can use, local or MCP-sourced."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, tools: t.Optional[list] = None):
|
|
163
|
+
self._tools: dict[str, Tool] = {}
|
|
164
|
+
for item in tools or []:
|
|
165
|
+
self.add(item)
|
|
166
|
+
|
|
167
|
+
def add(self, item: t.Union[Tool, t.Callable]) -> Tool:
|
|
168
|
+
resolved = item._tool if hasattr(item, "_tool") else item
|
|
169
|
+
if not isinstance(resolved, Tool):
|
|
170
|
+
raise TypeError(
|
|
171
|
+
"add() expects a Tool, or a function decorated with @tool()"
|
|
172
|
+
)
|
|
173
|
+
self._tools[resolved.name] = resolved
|
|
174
|
+
return resolved
|
|
175
|
+
|
|
176
|
+
def remove(self, name: str) -> None:
|
|
177
|
+
self._tools.pop(name, None)
|
|
178
|
+
|
|
179
|
+
def get(self, name: str) -> t.Optional[Tool]:
|
|
180
|
+
return self._tools.get(name)
|
|
181
|
+
|
|
182
|
+
def list(self) -> list[Tool]:
|
|
183
|
+
return list(self._tools.values())
|
|
184
|
+
|
|
185
|
+
def to_openai_tools(self) -> list[dict]:
|
|
186
|
+
return [tl.to_openai_schema() for tl in self._tools.values()]
|
|
187
|
+
|
|
188
|
+
def __len__(self) -> int:
|
|
189
|
+
return len(self._tools)
|
|
190
|
+
|
|
191
|
+
def __contains__(self, name: str) -> bool:
|
|
192
|
+
return name in self._tools
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from agentropic.tools import tool, ToolRegistry, Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_tool_decorator_builds_schema():
|
|
7
|
+
@tool()
|
|
8
|
+
def greet(name: str, excited: bool = False) -> str:
|
|
9
|
+
"""Say hello to someone."""
|
|
10
|
+
return f"Hello {name}!" + ("!!!" if excited else "")
|
|
11
|
+
|
|
12
|
+
built: Tool = greet._tool
|
|
13
|
+
assert built.name == "greet"
|
|
14
|
+
assert "name" in built.parameters["properties"]
|
|
15
|
+
assert "name" in built.parameters["required"]
|
|
16
|
+
assert "excited" not in built.parameters["required"]
|
|
17
|
+
assert built.call(name="Ada") == "Hello Ada!"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_registry_openai_schema():
|
|
21
|
+
@tool()
|
|
22
|
+
def noop() -> str:
|
|
23
|
+
"""Does nothing."""
|
|
24
|
+
return "ok"
|
|
25
|
+
|
|
26
|
+
registry = ToolRegistry([noop])
|
|
27
|
+
schema = registry.to_openai_tools()
|
|
28
|
+
assert schema[0]["function"]["name"] == "noop"
|
|
29
|
+
assert registry.get("noop") is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_async_tool_call():
|
|
34
|
+
@tool()
|
|
35
|
+
async def slow_add(a: int, b: int) -> int:
|
|
36
|
+
"""Add two numbers asynchronously."""
|
|
37
|
+
return a + b
|
|
38
|
+
|
|
39
|
+
result = await slow_add._tool.acall(a=2, b=3)
|
|
40
|
+
assert result == 5
|