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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .env
@@ -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,3 @@
1
+ from .client import MCPConnection, StdioServer, SSEServer, MCPServerConfig
2
+
3
+ __all__ = ["MCPConnection", "StdioServer", "SSEServer", "MCPServerConfig"]
@@ -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