Build session-based agent tool loop
Some checks failed
Test and publish / publish (push) Failing after 2s

This commit is contained in:
Azimkin
2026-09-13 22:51:27 +02:00
parent 4ae48659f3
commit 00ae37a369
27 changed files with 1662 additions and 103 deletions

View File

@@ -0,0 +1,38 @@
name: Test and publish
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
container: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install workspace
run: uv sync --all-packages
- name: Run core tests
run: uv run python -m unittest discover -s tests
- name: Run tools tests
run: uv run python -m unittest discover -s packages/micro-agents-tools/tests
- name: Build distributions
run: |
uv build --no-sources
uv build --package micro-agents-tools --no-sources
- name: Publish to Gitea PyPI registry
env:
TWINE_USERNAME: Azimkin
TWINE_PASSWORD: ${{ secrets.PACKAGE_TOKEN }}
run: >-
uvx --from twine twine upload
--repository-url https://git.azimkin.dev/api/packages/Azimkin/pypi
dist/*

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Runtime workspace used by the example.
/test/*
!/test/.gitkeep

11
.idea/micro-agents-tools.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$/packages/micro-agents-tools">
<sourceFolder url="file://$MODULE_DIR$/packages/micro-agents-tools/src" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="micro-agents" exported="" />
<orderEntry type="jdk" jdkName="uv (micro-agents)" jdkType="Python SDK" />
</component>
</module>

View File

@@ -2,7 +2,7 @@
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4"> <module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="uv (micro-agents)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="uv (micro-agents)" jdkType="Python SDK" />

1
.idea/modules.xml generated
View File

@@ -3,6 +3,7 @@
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/micro-agents.iml" filepath="$PROJECT_DIR$/.idea/micro-agents.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/micro-agents.iml" filepath="$PROJECT_DIR$/.idea/micro-agents.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/micro-agents-tools.iml" filepath="$PROJECT_DIR$/.idea/micro-agents-tools.iml" />
</modules> </modules>
</component> </component>
</project> </project>

118
README.md
View File

@@ -0,0 +1,118 @@
# micro-agents
Small async building blocks for embedding an LLM tool-loop in a bot or service.
```python
from micro_agents import Aggregator, OpenAIBasedLLM, Tool, ToolManager
def current_weather(city: str) -> str:
"""Return the current weather for a city."""
return f"Sunny in {city}"
class BotTools(ToolManager):
async def is_allowed(self, tool, context: dict[str, object]) -> bool:
return context.get("role") == "member"
def master_prompt(context: dict[str, object]) -> str:
return f"You are a concise support bot. Reply in {context.get('language', 'en')}."
llm = OpenAIBasedLLM(api_url="https://api.openai.com/v1", api_key="...", model="gpt-5")
agent = Aggregator(
llm,
BotTools([Tool(current_weather)]),
master_prompt=master_prompt,
)
session = agent.create_session(context={"user_id": 42, "role": "member", "language": "ru"})
result = await session.run("Какая погода в Варшаве?")
if result.succeeded:
print(result.final_message.message())
```
Use `async for event in session.stream(message)` when the embedding needs tool-progress or message events. A session retains its conversation and a live application-owned context dict. `ToolManager.is_allowed()` is checked when tools are shown to the model and again immediately before execution.
An individual session can override the aggregator's defaults:
```python
session = agent.create_session(llm=another_llm, thinking=True)
```
If `llm` is omitted, the aggregator's LLM is used. `thinking=False` disables reasoning, `thinking=True` requests medium reasoning, and the default `None` leaves the provider's behavior unchanged.
Attach a local image, document, or MP3/WAV file with `Media.from_path()`. Images and files may also use a URL or a provider file ID:
```python
from micro_agents import Media
result = await session.run(
"Что изображено и что написано в документе?",
media=[Media.from_path("photo.png"), Media.from_path("notes.pdf")],
)
```
Do not interpolate secrets, API keys, or internal-only values into `master_prompt`: it receives the full live context dict.
## Dynamic tools
Use `DynamicToolManager` when integrations can be connected or disconnected while the application is running:
```python
from micro_agents import DynamicToolManager, Tool
tools = DynamicToolManager()
agent = Aggregator(llm, tools)
tools.add_tool(Tool(current_weather))
tools.remove_tool("current_weather")
```
Existing sessions read the current registry before every LLM round. Duplicate names are rejected unless `add_tool(tool, replace=True)` is used.
## Context injection for tools
Mark a tool argument with `Injected` to receive the current session context. That argument is not included in the tool schema sent to the LLM, so the model cannot provide or override it.
```python
from pathlib import Path
from typing import Annotated
from micro_agents import Injected, SessionContext
def read_note(path: str, context: Annotated[SessionContext, Injected]) -> str:
return (Path(context["base_dir"].__str__()) / path).read_text()
```
The same aggregator may then create isolated sessions with different roots:
```python
agent.create_session(context={"base_dir": Path("test/alice")})
agent.create_session(context={"base_dir": Path("test/bob")})
```
## DeepSeek filesystem example
Install the optional tool package first:
```powershell
uv add micro-agents-tools
```
The runnable module below reads `DEEPSEEK_API_KEY`, uses DeepSeek's OpenAI-compatible Responses API, and can only read or write files inside the current directory's [`test`](test) folder. Edit `PROMPT` in the file, then run it:
```powershell
$env:DEEPSEEK_API_KEY = "..."
uv run --package micro-agents-tools -m micro_agents_tools.examples.deepseek_filesystem
```
Set `DEEPSEEK_MODEL` to override the default `deepseek-v4-flash`. Filesystem and opt-in Python tools are in the separate `micro-agents-tools` package. Filesystem tools deliberately have no delete operation, and reject absolute paths, `..` escapes, and symlinks that resolve outside `test`.
`PythonTools` is opt-in: add `*PythonTools().tools()` to the manager only when the application trusts the code being generated. It runs a subprocess in `base_dir` with a timeout and bounded output, but it is not a security sandbox; use a container or VM for untrusted code.
`FilesystemTools` also exposes `attach_files(paths)`. The model can select images or documents under the session's `base_dir`; they are returned as real tool-output attachments and become available to the model on the next tool-loop round. Paths remain confined to `base_dir`, and the combined attachment size is limited to 50 MiB by default.
`BraveSearchTools().tools()` adds a `web_search` tool backed by Brave Search. Set `BRAVE_SEARCH_API_KEY` in the application environment; the key never becomes a model argument or a session-context value.

View File

@@ -0,0 +1,9 @@
# micro-agents-tools
Optional filesystem and Python-execution tools for `micro-agents`.
`FilesystemTools` includes `attach_files(paths)`, which lets the model select images or documents under the session `base_dir` and add them to its conversation as real attachments. The same path-containment checks used by the other filesystem tools apply.
`PythonTools` executes arbitrary code. It has a timeout and per-session working directory, but is not a security sandbox; use a container or VM for untrusted code.
`BraveSearchTools` reads its token from `BRAVE_SEARCH_API_KEY` and exposes a `web_search` tool. The token is not passed to the model.

View File

@@ -0,0 +1,20 @@
[project]
name = "micro-agents-tools"
version = "0.1.0"
description = "Basic opt-in tools for micro-agents"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"micro-agents>=0.1.0",
]
[project.urls]
Repository = "https://git.azimkin.dev/Azimkin/micro-agents"
Issues = "https://git.azimkin.dev/Azimkin/micro-agents/issues"
[tool.uv.sources]
micro-agents = { workspace = true }
[build-system]
requires = ["uv_build>=0.12.2,<0.13.0"]
build-backend = "uv_build"

View File

@@ -0,0 +1,7 @@
"""Optional, basic tools for micro-agents."""
from micro_agents_tools.brave import BraveSearchTools
from micro_agents_tools.filesystem import FilesystemTools
from micro_agents_tools.python import PythonTools
__all__ = ["BraveSearchTools", "FilesystemTools", "PythonTools"]

View File

@@ -0,0 +1,92 @@
"""Brave Search API tools."""
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from micro_agents import Tool
class BraveSearchTools:
"""Opt-in web search backed by the Brave Search API.
The API token comes from ``BRAVE_SEARCH_API_KEY`` unless supplied directly.
It is retained by this object only and is never exposed to the LLM.
"""
_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
def __init__(self, *, api_key: str | None = None, timeout_seconds: float = 10):
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
resolved_api_key = api_key or os.environ.get("BRAVE_SEARCH_API_KEY")
if not resolved_api_key:
raise ValueError("Set BRAVE_SEARCH_API_KEY before creating BraveSearchTools")
self._api_key: str = resolved_api_key
self._timeout_seconds = timeout_seconds
def tools(self) -> list[Tool]:
return [Tool(self.search_web, name="web_search", strict=False)]
def search_web(self, query: str, count: int = 10, country: str = "US", search_lang: str = "en") -> str:
"""Search the web and return up to 20 title, URL, and description results."""
query = query.strip()
if not query:
raise ValueError("query must not be empty")
if len(query) > 600 or len(query.split()) > 75:
raise ValueError("query exceeds Brave Search limits")
if not 1 <= count <= 20:
raise ValueError("count must be between 1 and 20")
url = self._ENDPOINT + "?" + urlencode(
{"q": query, "count": count, "country": country.upper(), "search_lang": search_lang}
)
payload = self._request(url)
return json.dumps({"query": query, "results": self._results(payload)}, ensure_ascii=False)
def _request(self, url: str) -> Mapping[str, object]:
request = Request(
url,
headers={"Accept": "application/json", "X-Subscription-Token": self._api_key},
)
try:
with urlopen(request, timeout=self._timeout_seconds) as response:
payload = json.load(response)
except HTTPError as error:
raise RuntimeError(f"Brave Search returned HTTP {error.code}") from error
except URLError as error:
raise RuntimeError(f"Brave Search request failed: {error.reason}") from error
except (OSError, TimeoutError, json.JSONDecodeError) as error:
raise RuntimeError("Brave Search returned an invalid response") from error
if not isinstance(payload, Mapping):
raise RuntimeError("Brave Search returned an invalid response")
return payload
@staticmethod
def _results(payload: Mapping[str, object]) -> list[dict[str, str]]:
web = payload.get("web")
if not isinstance(web, Mapping):
return []
raw_results = web.get("results")
if not isinstance(raw_results, list):
return []
results: list[dict[str, str]] = []
for raw_result in raw_results:
if not isinstance(raw_result, Mapping):
continue
title = raw_result.get("title")
url = raw_result.get("url")
if not isinstance(title, str) or not isinstance(url, str):
continue
description = raw_result.get("description")
result = {"title": title, "url": url}
if isinstance(description, str) and description:
result["description"] = description
results.append(result)
return results

View File

@@ -0,0 +1 @@
"""Runnable examples for micro-agents-tools."""

View File

@@ -0,0 +1,30 @@
"""Set DEEPSEEK_API_KEY, edit PROMPT, then run this module."""
import asyncio
import os
from pathlib import Path
from micro_agents import Aggregator, OpenAIBasedLLM, ToolManager
from micro_agents_tools import FilesystemTools
PROMPT = "Create hello.txt with a short greeting."
llm = OpenAIBasedLLM(
api_url="https://api.deepseek.com",
api_key=os.environ["DEEPSEEK_API_KEY"],
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-flash"),
)
agent = Aggregator(
llm,
ToolManager(FilesystemTools().tools()),
master_prompt=lambda _: "Use tools to work only in the test directory.",
)
result = asyncio.run(agent.create_session(context={"base_dir": Path("test")}).run(PROMPT))
if result.succeeded:
final_message = result.final_message
assert final_message is not None
print(final_message.message())
else:
print(f"Agent failed: {result.error}")

View File

@@ -0,0 +1,126 @@
"""Filesystem tools confined to ``base_dir`` from the session context."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from micro_agents import Injected, Media, SessionContext, Tool, ToolOutput
def context_base_dir(context: SessionContext, key: str) -> Path:
"""Return the existing base directory selected by the embedding application."""
value = context.get(key)
if not isinstance(value, (Path, str)):
raise ValueError(f"Session context must contain a Path or str under '{key}'")
base_dir = Path(value).resolve()
if not base_dir.is_dir():
raise ValueError(f"Context directory does not exist: {base_dir}")
return base_dir
class FilesystemTools:
"""Basic filesystem tools restricted to a per-session base directory."""
def __init__(
self,
*,
base_dir_key: str = "base_dir",
max_read_chars: int = 20_000,
max_attachment_bytes: int = 50 * 1024 * 1024,
):
if max_read_chars < 1:
raise ValueError("max_read_chars must be positive")
if max_attachment_bytes < 1:
raise ValueError("max_attachment_bytes must be positive")
self._base_dir_key = base_dir_key
self._max_read_chars = max_read_chars
self._max_attachment_bytes = max_attachment_bytes
def tools(self) -> list[Tool]:
return [
Tool(self.list_files, name="list_files", strict=False),
Tool(self.read_file, name="read_file", strict=False),
Tool(self.attach_files, name="attach_files", strict=False),
Tool(self.write_file, name="write_file", strict=False),
Tool(self.make_directory, name="make_directory", strict=False),
]
def list_files(self, path: str = ".", context: Annotated[SessionContext | None, Injected] = None) -> list[str]:
"""List direct children of a relative directory."""
target = self._resolve(path, context)
if not target.is_dir():
raise ValueError(f"Not a directory: {path}")
return sorted(
item.relative_to(self._base_dir(context)).as_posix() + ("/" if item.is_dir() else "")
for item in target.iterdir()
)
def read_file(self, path: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
"""Read a UTF-8 file, truncated to the configured maximum size."""
target = self._resolve(path, context)
if not target.is_file():
raise ValueError(f"Not a file: {path}")
content = target.read_text(encoding="utf-8")
if len(content) <= self._max_read_chars:
return content
return content[:self._max_read_chars] + "\n...[truncated]"
def attach_files(
self,
paths: list[str],
context: Annotated[SessionContext | None, Injected] = None,
) -> ToolOutput:
"""Attach selected images or documents to the model context.
Params:
paths: Relative paths of the files to attach.
"""
if not paths:
raise ValueError("At least one path is required")
targets = [self._resolve(path, context) for path in paths]
for path, target in zip(paths, targets, strict=True):
if not target.is_file():
raise ValueError(f"Not a file: {path}")
total_bytes = sum(target.stat().st_size for target in targets)
if total_bytes > self._max_attachment_bytes:
raise ValueError(f"Attachments exceed the {self._max_attachment_bytes}-byte limit")
attachments = tuple(Media.from_path(target) for target in targets)
if any(attachment.kind == "audio" for attachment in attachments):
raise ValueError("Audio cannot be attached from a function tool output")
relative_paths = [target.relative_to(self._base_dir(context)).as_posix() for target in targets]
return ToolOutput(f"Attached files: {', '.join(relative_paths)}", attachments)
def write_file(
self,
path: str,
content: str,
context: Annotated[SessionContext | None, Injected] = None,
) -> str:
"""Create or replace a UTF-8 text file at a relative path."""
target = self._resolve(path, context)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Wrote {target.relative_to(self._base_dir(context)).as_posix()}"
def make_directory(self, path: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
"""Create a relative directory and any missing parents."""
target = self._resolve(path, context)
target.mkdir(parents=True, exist_ok=True)
return f"Created {target.relative_to(self._base_dir(context)).as_posix()}"
def _base_dir(self, context: SessionContext | None) -> Path:
if context is None:
raise RuntimeError("Session context was not injected")
return context_base_dir(context, self._base_dir_key)
def _resolve(self, relative_path: str, context: SessionContext | None) -> Path:
path = Path(relative_path)
if path.is_absolute():
raise ValueError("Absolute paths are not allowed")
base_dir = self._base_dir(context)
resolved = (base_dir / path).resolve()
if resolved != base_dir and base_dir not in resolved.parents:
raise ValueError("Path escapes the session base directory")
return resolved

View File

@@ -0,0 +1,63 @@
"""Opt-in Python execution tool."""
from __future__ import annotations
import json
import subprocess
import sys
from typing import Annotated
from micro_agents import Injected, SessionContext, Tool
from micro_agents_tools.filesystem import context_base_dir
class PythonTools:
"""Run Python code in the session's base directory.
This is process isolation, not a security sandbox. Only enable it for code
the embedding application trusts, or run the application in a container/VM.
"""
def __init__(self, *, base_dir_key: str = "base_dir", timeout_seconds: float = 10, max_output_chars: int = 20_000):
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if max_output_chars < 1:
raise ValueError("max_output_chars must be positive")
self._base_dir_key = base_dir_key
self._timeout_seconds = timeout_seconds
self._max_output_chars = max_output_chars
def tools(self) -> list[Tool]:
return [Tool(self.execute_python, name="execute_python", strict=False)]
def execute_python(self, code: str, context: Annotated[SessionContext | None, Injected] = None) -> str:
"""Execute Python code in the session base directory and return its output."""
if context is None:
raise RuntimeError("Session context was not injected")
try:
completed = subprocess.run(
[sys.executable, "-I", "-c", code],
cwd=context_base_dir(context, self._base_dir_key),
capture_output=True,
encoding="utf-8",
errors="replace",
text=True,
timeout=self._timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as error:
return self._result(None, self._as_text(error.stdout), self._as_text(error.stderr), timed_out=True)
return self._result(completed.returncode, completed.stdout, completed.stderr, timed_out=False)
def _result(self, exit_code: int | None, stdout: str, stderr: str, *, timed_out: bool) -> str:
return json.dumps(
{"exit_code": exit_code, "stdout": self._clip(stdout), "stderr": self._clip(stderr), "timed_out": timed_out},
ensure_ascii=False,
)
def _clip(self, text: str) -> str:
return text if len(text) <= self._max_output_chars else text[:self._max_output_chars] + "\n...[truncated]"
@staticmethod
def _as_text(value: bytes | str | None) -> str:
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or ""

View File

@@ -0,0 +1,100 @@
import json
import tempfile
import unittest
from pathlib import Path
from micro_agents import OpenAIBasedLLM, ToolCallMessage, ToolManager
from micro_agents_tools import BraveSearchTools, FilesystemTools, PythonTools
class FakeBraveSearchTools(BraveSearchTools):
def _request(self, url):
self.url = url
return {
"web": {
"results": [
{"title": "Example", "url": "https://example.com", "description": "A result"},
{"title": "Missing URL"},
]
}
}
class FilesystemToolsTests(unittest.IsolatedAsyncioTestCase):
async def test_brave_search_returns_compact_results(self):
tools = FakeBraveSearchTools(api_key="test-key")
response = await ToolManager(tools.tools()).execute(
ToolCallMessage("search", "web_search", {"query": "micro agents", "count": 3}),
{},
)
payload = json.loads(response.response.text())
self.assertIn("q=micro+agents", tools.url)
self.assertEqual(
[{"title": "Example", "url": "https://example.com", "description": "A result"}],
payload["results"],
)
async def test_filesystem_tools_use_the_context_base_directory(self):
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory) / "test"
root.mkdir()
manager = ToolManager(FilesystemTools().tools())
write = await manager.execute(
ToolCallMessage("write", "write_file", {"path": "nested/hello.txt", "content": "hello"}),
{"base_dir": root},
)
read = await manager.execute(
ToolCallMessage("read", "read_file", {"path": "nested/hello.txt"}),
{"base_dir": root},
)
self.assertEqual("Wrote nested/hello.txt", write.response.text())
self.assertEqual("hello", read.response.text())
async def test_filesystem_tools_reject_path_escapes(self):
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory) / "test"
root.mkdir()
response = await ToolManager(FilesystemTools().tools()).execute(
ToolCallMessage("write", "write_file", {"path": "../outside.txt", "content": "nope"}),
{"base_dir": root},
)
self.assertTrue(response.response.is_error())
self.assertEqual("tool_execution_failed", response.response.error_code())
async def test_python_tool_uses_the_session_base_directory(self):
with tempfile.TemporaryDirectory() as temporary_directory:
base_dir = Path(temporary_directory)
code = "from pathlib import Path; Path('created.txt').write_text('ok'); print(Path.cwd())"
response = await ToolManager(PythonTools().tools()).execute(
ToolCallMessage("call-1", "execute_python", {"code": code}),
{"base_dir": base_dir},
)
payload = json.loads(response.response.text())
self.assertEqual(0, payload["exit_code"])
self.assertFalse(payload["timed_out"])
self.assertEqual("ok", (base_dir / "created.txt").read_text())
async def test_attach_files_returns_model_attachments(self):
with tempfile.TemporaryDirectory() as temporary_directory:
base_dir = Path(temporary_directory)
(base_dir / "note.txt").write_text("hello", encoding="utf-8")
response = await ToolManager(FilesystemTools().tools()).execute(
ToolCallMessage("attach", "attach_files", {"paths": ["note.txt"]}),
{"base_dir": base_dir},
)
self.assertEqual(["note.txt"], [item.filename for item in response.response.attachments()])
serialized = OpenAIBasedLLM._serialize_tool_call_response_message(response.response)
output = serialized["output"]
assert isinstance(output, list)
self.assertEqual("input_text", output[0]["type"])
self.assertEqual("input_file", output[1]["type"])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "micro-agents" name = "micro-agents"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Small async building blocks for embedding LLM tool loops in Python applications"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Azimkin", email = "112006710+Azimkin@users.noreply.github.com" } { name = "Azimkin", email = "112006710+Azimkin@users.noreply.github.com" }
@@ -12,9 +12,24 @@ dependencies = [
"orjson>=3.12.0", "orjson>=3.12.0",
] ]
[project.urls]
Repository = "https://git.azimkin.dev/Azimkin/micro-agents"
Issues = "https://git.azimkin.dev/Azimkin/micro-agents/issues"
[project.scripts] [project.scripts]
micro-agents = "micro_agents:main" micro-agents = "micro_agents:main"
[dependency-groups]
dev = [
"micro-agents-tools",
]
[tool.uv.sources]
micro-agents-tools = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
[build-system] [build-system]
requires = ["uv_build>=0.12.2,<0.13.0"] requires = ["uv_build>=0.12.2,<0.13.0"]
build-backend = "uv_build" build-backend = "uv_build"

5
pyrightconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"include": ["src", "packages", "tests"],
"exclude": [".venv", "dist"],
"extraPaths": ["src", "packages/micro-agents-tools/src"]
}

View File

@@ -1,2 +1,53 @@
from micro_agents.aggregator import (
Aggregator,
Completed,
Context,
EventSubscriber,
Failed,
MasterPrompt,
MessageAdded,
Session,
SessionEvent,
SessionResult,
ToolCallFailed,
ToolCallFinished,
ToolCallStarted,
)
from micro_agents.context import Injected, SessionContext
from micro_agents.conversation import Conversation, LlmMessage, Media, ToolCallMessage, ToolCallResponseMessage, UserMessage
from micro_agents.llm import LLM, OpenAIBasedLLM
from micro_agents.tools import DynamicToolManager, Tool, ToolManager, ToolOutput
__all__ = [
"Aggregator",
"Completed",
"Context",
"Conversation",
"DynamicToolManager",
"EventSubscriber",
"Failed",
"Injected",
"LLM",
"LlmMessage",
"MasterPrompt",
"Media",
"MessageAdded",
"OpenAIBasedLLM",
"Session",
"SessionContext",
"SessionEvent",
"SessionResult",
"Tool",
"ToolCallFailed",
"ToolCallFinished",
"ToolCallMessage",
"ToolCallResponseMessage",
"ToolCallStarted",
"ToolManager",
"ToolOutput",
"UserMessage",
]
def main() -> None: def main() -> None:
print("Hello from micro-agents!") print("micro-agents")

View File

@@ -1,28 +1,233 @@
from abc import ABC, abstractmethod from __future__ import annotations
from typing import Iterable
from micro_agents.conversation import Message import asyncio
import inspect
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Protocol
from uuid import uuid4
from micro_agents.context import SessionContext
from micro_agents.conversation import Conversation, LlmMessage, Media, Message, ToolCallMessage, ToolCallResponseMessage, UserMessage
from micro_agents.llm import LLM from micro_agents.llm import LLM
from micro_agents.tools import ToolManager
class EventSubscriber(ABC): Context = SessionContext
@abstractmethod MasterPrompt = Callable[[SessionContext], str | Awaitable[str]]
async def on_message(self, messages: Iterable[Message]):
pass
class EventSubscriber(Protocol):
async def on_event(self, event: "SessionEvent") -> None: ...
@dataclass(frozen=True, slots=True)
class SessionEvent:
session: "Session"
@dataclass(frozen=True, slots=True)
class MessageAdded(SessionEvent):
message: Message
@dataclass(frozen=True, slots=True)
class ToolCallStarted(SessionEvent):
call: ToolCallMessage
@dataclass(frozen=True, slots=True)
class ToolCallFinished(SessionEvent):
call: ToolCallMessage
response: ToolCallResponseMessage
@dataclass(frozen=True, slots=True)
class ToolCallFailed(SessionEvent):
call: ToolCallMessage
response: ToolCallResponseMessage
@dataclass(frozen=True, slots=True)
class Completed(SessionEvent):
result: "SessionResult"
@dataclass(frozen=True, slots=True)
class Failed(SessionEvent):
error: Exception
@dataclass(frozen=True, slots=True)
class SessionResult:
final_message: LlmMessage | None
conversation: Conversation
error: Exception | None = None
@property
def succeeded(self) -> bool:
return self.error is None and self.final_message is not None
class Session: class Session:
def __init__(self, event_subscriber: EventSubscriber, session_name: str | None = None, messages: list[Message] | None = None): """State and runtime for one isolated agent conversation."""
def __init__(
self,
llm: LLM,
tool_manager: ToolManager,
*,
context: Context | None = None,
master_prompt: MasterPrompt | None = None,
event_subscriber: EventSubscriber | object | None = None,
session_name: str | None = None,
messages: Iterable[Message] | None = None,
max_tool_rounds: int = 16,
thinking: bool | None = None,
):
if max_tool_rounds < 1:
raise ValueError("max_tool_rounds must be at least 1")
self._llm = llm
self._tool_manager = tool_manager
# Retain the caller's dict intentionally: applications may refresh roles,
# quotas, or locale between turns.
self.context: Context = context if context is not None else {}
self.name = session_name
self.id = session_name or str(uuid4())
self.conversation = Conversation(list(messages or ()))
self._master_prompt = master_prompt
self._event_subscriber = event_subscriber
self._max_tool_rounds = max_tool_rounds
self._thinking = thinking
self._lock = asyncio.Lock()
async def stream(self, user_text: str, *, media: Iterable[Media] | None = None) -> AsyncIterator[SessionEvent]:
"""Run one user turn and yield progress events in transcript order."""
async with self._lock:
async for event in self._run_events(user_text, media):
await self._notify(event)
yield event
async def run(self, user_text: str, *, media: Iterable[Media] | None = None) -> SessionResult:
"""Run one user turn to completion without requiring event consumption."""
async for event in self.stream(user_text, media=media):
if isinstance(event, Completed):
return event.result
if isinstance(event, Failed):
return SessionResult(None, self.conversation, event.error)
return SessionResult(None, self.conversation, RuntimeError("Session ended without a result"))
async def _run_events(self, user_text: str, media: Iterable[Media] | None) -> AsyncIterator[SessionEvent]:
user_message = UserMessage(user_text, list(media or ()))
self.conversation.add_message(user_message)
yield MessageAdded(self, user_message)
try:
for _ in range(self._max_tool_rounds):
instructions = await self._build_master_prompt()
tools = await self._tool_manager.available_tools(self.context)
response_messages = await self._llm.ask(
self.conversation,
tools,
instructions=instructions,
thinking=self._thinking,
)
calls: list[ToolCallMessage] = []
final_message: LlmMessage | None = None
for message in response_messages:
self.conversation.add_message(message)
yield MessageAdded(self, message)
if isinstance(message, ToolCallMessage):
calls.append(message)
elif isinstance(message, LlmMessage):
final_message = message
if not calls:
if final_message is None:
raise RuntimeError("LLM response did not contain a final message or a tool call")
yield Completed(self, SessionResult(final_message, self.conversation))
return
for call in calls:
yield ToolCallStarted(self, call)
execution = await self._tool_manager.execute(call, self.context)
self.conversation.add_message(execution.response)
yield MessageAdded(self, execution.response)
if execution.response.is_error():
yield ToolCallFailed(self, call, execution.response)
else:
yield ToolCallFinished(self, call, execution.response)
except Exception as error:
yield Failed(self, error)
return
yield Failed(self, RuntimeError(f"Tool loop exceeded {self._max_tool_rounds} rounds"))
async def _build_master_prompt(self) -> str | None:
if self._master_prompt is None:
return None
prompt = self._master_prompt(self.context)
if not isinstance(prompt, str):
prompt = await prompt
if not isinstance(prompt, str):
raise TypeError("master_prompt must return str or awaitable[str]")
return prompt
async def _notify(self, event: SessionEvent) -> None:
if self._event_subscriber is None:
return
on_event = getattr(self._event_subscriber, "on_event", None)
if callable(on_event):
value = on_event(event)
if inspect.isawaitable(value):
await value
return
# Compatibility with the original EventSubscriber sketch.
if isinstance(event, MessageAdded):
on_message = getattr(self._event_subscriber, "on_message", None)
if callable(on_message):
value = on_message((event.message,))
if inspect.isawaitable(value):
await value
class Aggregator: class Aggregator:
def __init__(self, llm: LLM): """Factory for sessions that share an LLM and tool policy."""
def __init__(
self,
llm: LLM,
tool_manager: ToolManager | None = None,
*,
master_prompt: MasterPrompt | None = None,
max_tool_rounds: int = 16,
thinking: bool | None = None,
):
self._llm = llm self._llm = llm
self._tool_manager = tool_manager or ToolManager()
self._master_prompt = master_prompt
self._max_tool_rounds = max_tool_rounds
self._thinking = thinking
def create_session(self): def create_session(
... self,
*,
async def start_loop(self): context: Context | None = None,
... event_subscriber: EventSubscriber | object | None = None,
session_name: str | None = None,
messages: Iterable[Message] | None = None,
llm: LLM | None = None,
thinking: bool | None = None,
) -> Session:
return Session(
self._llm if llm is None else llm,
self._tool_manager,
context=context,
master_prompt=self._master_prompt,
event_subscriber=event_subscriber,
session_name=session_name,
messages=messages,
max_tool_rounds=self._max_tool_rounds,
thinking=self._thinking if thinking is None else thinking,
)

View File

@@ -0,0 +1,8 @@
from collections.abc import Mapping
SessionContext = Mapping[str, object]
class Injected:
"""Marker for a tool parameter supplied from the active session context."""

View File

@@ -1,7 +1,12 @@
import json from __future__ import annotations
from abc import ABC from abc import ABC
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import cast import mimetypes
from pathlib import Path
from typing import Literal, cast
class Side(Enum): class Side(Enum):
@@ -13,30 +18,115 @@ class Side(Enum):
class Message(ABC): class Message(ABC):
"""A provider-neutral item in a conversation transcript."""
def __init__(self, side: Side, content: dict[str, object]): def __init__(self, side: Side, content: dict[str, object]):
self.side = side self.side = side
self.content = content self.content = content
MediaType = Literal["image", "file", "audio"]
MediaDetail = Literal["auto", "low", "high"]
@dataclass(frozen=True, slots=True)
class Media:
"""A provider-neutral image, file, or audio attachment."""
kind: MediaType
data: bytes | None = None
url: str | None = None
file_id: str | None = None
filename: str | None = None
mime_type: str | None = None
detail: MediaDetail = "auto"
def __post_init__(self) -> None:
sources = sum(value is not None for value in (self.data, self.url, self.file_id))
if sources != 1:
raise ValueError("Media requires exactly one of data, url, or file_id")
if self.kind == "audio" and self.url is not None:
raise ValueError("Audio URLs are not supported; use Media.from_path()")
@classmethod
def from_path(
cls,
path: str | Path,
*,
media_type: MediaType | None = None,
detail: MediaDetail = "auto",
) -> "Media":
file_path = Path(path)
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
resolved_type = media_type or cls._type_from_mime(mime_type)
if resolved_type == "audio" and mime_type not in ("audio/mpeg", "audio/wav", "audio/x-wav"):
raise ValueError("Responses API audio input supports only MP3 and WAV")
return cls(
resolved_type,
data=file_path.read_bytes(),
filename=file_path.name,
mime_type=mime_type,
detail=detail,
)
@classmethod
def from_url(
cls,
url: str,
*,
media_type: Literal["image", "file"] = "image",
detail: MediaDetail = "auto",
) -> "Media":
return cls(media_type, url=url, detail=detail)
@classmethod
def from_file_id(
cls,
file_id: str,
*,
media_type: Literal["image", "file"] = "file",
detail: MediaDetail = "auto",
) -> "Media":
return cls(media_type, file_id=file_id, detail=detail)
@staticmethod
def _type_from_mime(mime_type: str) -> MediaType:
if mime_type.startswith("image/"):
return "image"
if mime_type.startswith("audio/"):
return "audio"
return "file"
class ReasoningMessage(Message): class ReasoningMessage(Message):
def __init__(self, text: str): def __init__(self, text: str, *, provider_item: Mapping[str, object] | None = None):
super().__init__(Side.REASONING, {'text': text}) content: dict[str, object] = {"text": text}
if provider_item is not None:
content["provider_item"] = dict(provider_item)
super().__init__(Side.REASONING, content)
def text(self) -> str: def text(self) -> str:
return self.content["text"].__str__() return self.content["text"].__str__()
def provider_item(self) -> dict[str, object] | None:
value = self.content.get("provider_item")
return value if isinstance(value, dict) else None
class UserMessage(Message): class UserMessage(Message):
def __init__(self, message: str): def __init__(self, message: str, media: list[Media] | None = None):
super().__init__(Side.USER, {'message': message}) super().__init__(Side.USER, {"message": message, "media": list(media or ())})
def message(self) -> str: def message(self) -> str:
return self.content["message"].__str__() return self.content["message"].__str__()
def media(self) -> list[Media]:
return cast(list[Media], self.content["media"])
class LlmMessage(Message): class LlmMessage(Message):
def __init__(self, message: str): def __init__(self, message: str):
super().__init__(Side.LLM, {'message': message}) super().__init__(Side.LLM, {"message": message})
def message(self) -> str: def message(self) -> str:
return self.content["message"].__str__() return self.content["message"].__str__()
@@ -44,7 +134,10 @@ class LlmMessage(Message):
class ToolCallMessage(Message): class ToolCallMessage(Message):
def __init__(self, unique_id: str, tool_name: str, arguments: dict[str, object]): def __init__(self, unique_id: str, tool_name: str, arguments: dict[str, object]):
super().__init__(Side.TOOL_CALL, {"unique_id": unique_id, 'tool_name': tool_name, 'arguments': arguments}) super().__init__(
Side.TOOL_CALL,
{"unique_id": unique_id, "tool_name": tool_name, "arguments": arguments},
)
def unique_id(self) -> str: def unique_id(self) -> str:
return self.content["unique_id"].__str__() return self.content["unique_id"].__str__()
@@ -52,13 +145,29 @@ class ToolCallMessage(Message):
def arguments(self) -> dict[str, object]: def arguments(self) -> dict[str, object]:
return cast(dict[str, object], self.content["arguments"]) return cast(dict[str, object], self.content["arguments"])
def tool_name(self): def tool_name(self) -> str:
return self.content["tool_call"].__str__() return self.content["tool_name"].__str__()
class ToolCallResponseMessage(Message): class ToolCallResponseMessage(Message):
def __init__(self, unique_id: str, text: str): def __init__(
super().__init__(Side.TOOL_RESPONSE, {'unique_id': unique_id, 'text': text}) self,
unique_id: str,
text: str,
*,
attachments: list[Media] | None = None,
is_error: bool = False,
error_code: str | None = None,
):
content: dict[str, object] = {
"unique_id": unique_id,
"text": text,
"attachments": list(attachments or ()),
"is_error": is_error,
}
if error_code is not None:
content["error_code"] = error_code
super().__init__(Side.TOOL_RESPONSE, content)
def text(self) -> str: def text(self) -> str:
return self.content["text"].__str__() return self.content["text"].__str__()
@@ -66,10 +175,20 @@ class ToolCallResponseMessage(Message):
def unique_id(self) -> str: def unique_id(self) -> str:
return self.content["unique_id"].__str__() return self.content["unique_id"].__str__()
def attachments(self) -> list[Media]:
return cast(list[Media], self.content["attachments"])
def is_error(self) -> bool:
return bool(self.content["is_error"])
def error_code(self) -> str | None:
value = self.content.get("error_code")
return value.__str__() if value is not None else None
class Conversation: class Conversation:
def __init__(self, messages: list[Message]): def __init__(self, messages: list[Message] | None = None):
self.messages = messages self.messages = list(messages or [])
def add_message(self, message: Message): def add_message(self, message: Message) -> None:
self.messages.append(message) self.messages.append(message)

View File

@@ -1,5 +1,7 @@
from abc import ABC from abc import ABC, abstractmethod
from typing import Iterable, Optional, Literal, cast, Any, Union, get_args, get_origin, get_type_hints, Annotated import base64
from collections.abc import Buffer
from typing import Optional, Literal, cast, Any, Union, get_args, get_origin, get_type_hints, Annotated
import dataclasses import dataclasses
import enum import enum
@@ -7,14 +9,14 @@ import inspect
import re import re
import types import types
import collections.abc import collections.abc
from uuid import uuid4
import orjson import orjson
from openai import AsyncOpenAI from openai import AsyncOpenAI
from openai.types.responses import ResponseInputParam, ToolParam, ResponseInputItemParam, EasyInputMessageParam, \ from openai.types.responses import ResponseInputParam, EasyInputMessageParam, ResponseFunctionToolCallParam, \
ResponseFunctionToolCallParam, ResponseCustomToolCallOutputParam, FunctionToolParam FunctionToolParam
from openai.types.shared_params.reasoning import Reasoning
from micro_agents.conversation import Conversation, UserMessage, LlmMessage, ToolCallMessage, ToolCallResponseMessage, \ from micro_agents.conversation import Conversation, Media, Message, UserMessage, LlmMessage, ToolCallMessage, \
ToolCallResponseMessage, \
ReasoningMessage ReasoningMessage
import micro_agents.tools as tools_package import micro_agents.tools as tools_package
@@ -22,13 +24,21 @@ import micro_agents.tools as tools_package
def _safe_issubclass(tp: Any, classes: Any) -> bool: def _safe_issubclass(tp: Any, classes: Any) -> bool:
try: try:
return isinstance(tp, type) and issubclass(tp, classes) return isinstance(tp, type) and issubclass(tp, classes)
except Exception: except TypeError:
return False return False
class LLM(ABC): class LLM(ABC):
def __init__(self): @abstractmethod
... async def ask(
self,
conversation: Conversation,
tools: list[tools_package.Tool],
*,
instructions: str | None = None,
thinking: bool | None = None,
) -> list[Message]:
"""Return the next provider-neutral messages for a conversation."""
class OpenAIBasedLLM(LLM): class OpenAIBasedLLM(LLM):
@@ -37,24 +47,51 @@ class OpenAIBasedLLM(LLM):
self._ai = AsyncOpenAI(api_key=api_key, base_url=api_url, ) self._ai = AsyncOpenAI(api_key=api_key, base_url=api_url, )
self._model = model self._model = model
async def ask(self, conversation: Conversation, tools: list[tools_package.Tool]): async def ask(
self,
conversation: Conversation,
tools: list[tools_package.Tool],
*,
instructions: str | None = None,
thinking: bool | None = None,
) -> list[Message]:
reasoning: Reasoning | None = None
if thinking is not None:
reasoning = Reasoning(effort="medium" if thinking else "none")
response = await self._ai.responses.create( response = await self._ai.responses.create(
model=self._model, model=self._model,
input=self._serialize_conversation(conversation), input=self._serialize_conversation(conversation),
tools=self._serialize_tools(tools) tools=self._serialize_tools(tools),
instructions=instructions,
reasoning=reasoning,
stream=False,
) )
out_messages = [] out_messages: list[Message] = []
output_text_added = False
for item in response.output: for item in response.output:
match item.type: match item.type:
case "reasoning": case "reasoning":
reasoning_parts = [] reasoning_parts: list[str] = []
for c in item.content: for content in (*getattr(item, "content", ()), *getattr(item, "summary", ())):
reasoning_parts.append(c.text) text = getattr(content, "text", None)
out_messages.append(ReasoningMessage(" ".join(reasoning_parts))) if text:
reasoning_parts.append(str(text))
raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else None
out_messages.append(ReasoningMessage(" ".join(reasoning_parts), provider_item=raw_item))
case 'message': case 'message':
if response.output_text and not output_text_added:
out_messages.append(LlmMessage(response.output_text)) out_messages.append(LlmMessage(response.output_text))
output_text_added = True
case "function_call": case "function_call":
out_messages.append(ToolCallMessage(item.call_id, item.name, orjson.loads(item.arguments))) call_id = getattr(item, "call_id", None)
name = getattr(item, "name", None)
arguments = getattr(item, "arguments", None)
if not isinstance(call_id, str) or not isinstance(name, str) or not isinstance(arguments, str):
raise ValueError("OpenAI returned an invalid function call")
parsed_arguments = orjson.loads(arguments)
if not isinstance(parsed_arguments, dict):
raise ValueError("OpenAI function-call arguments must be an object")
out_messages.append(ToolCallMessage(call_id, name, parsed_arguments))
return out_messages return out_messages
@@ -63,6 +100,8 @@ class OpenAIBasedLLM(LLM):
for m in conversation.messages: for m in conversation.messages:
if isinstance(m, UserMessage): if isinstance(m, UserMessage):
out.append(self._serialize_user_message(m)) out.append(self._serialize_user_message(m))
elif isinstance(m, ReasoningMessage):
out.append(self._serialize_reasoning_message(m))
elif isinstance(m, LlmMessage): elif isinstance(m, LlmMessage):
out.append(self._serialize_llm_message(m)) out.append(self._serialize_llm_message(m))
elif isinstance(m, ToolCallMessage): elif isinstance(m, ToolCallMessage):
@@ -70,24 +109,79 @@ class OpenAIBasedLLM(LLM):
elif isinstance(m, ToolCallResponseMessage): elif isinstance(m, ToolCallResponseMessage):
out.append(self._serialize_tool_call_response_message(m)) out.append(self._serialize_tool_call_response_message(m))
else: else:
raise Exception(f"Unknown type of message {type(m).__name__}") raise ValueError(f"Unknown type of message {type(m).__name__}")
return out return out
@staticmethod @staticmethod
def _serialize_user_message(message: UserMessage) -> EasyInputMessageParam: def _serialize_user_message(message: UserMessage) -> EasyInputMessageParam:
if not message.media():
return { return {
'role': 'user', 'role': 'user',
'content': message.message() 'content': message.message()
} }
content: list[dict[str, object]] = []
if message.message():
content.append({'type': 'input_text', 'text': message.message()})
content.extend(OpenAIBasedLLM._serialize_media(media) for media in message.media())
return {
'role': 'user',
'content': cast(Any, content),
}
@staticmethod
def _serialize_media(media: Media) -> dict[str, object]:
if media.kind == "image":
result: dict[str, object] = {"type": "input_image", "detail": media.detail}
if media.file_id is not None:
result["file_id"] = media.file_id
elif media.url is not None:
result["image_url"] = media.url
else:
mime_type = media.mime_type or "application/octet-stream"
encoded = OpenAIBasedLLM._encode_base64(media.data)
result["image_url"] = f"data:{mime_type};base64,{encoded}"
return result
if media.kind == "audio":
mime_type = media.mime_type or ""
audio_format = "mp3" if mime_type == "audio/mpeg" else "wav"
return {
"type": "input_audio",
"input_audio": {
"data": OpenAIBasedLLM._encode_base64(media.data),
"format": audio_format,
},
}
result = {"type": "input_file", "detail": media.detail}
if media.file_id is not None:
result["file_id"] = media.file_id
elif media.url is not None:
result["file_url"] = media.url
else:
mime_type = media.mime_type or "application/octet-stream"
encoded = OpenAIBasedLLM._encode_base64(media.data)
result["file_data"] = f"data:{mime_type};base64,{encoded}"
result["filename"] = media.filename or "file"
return result
@staticmethod
def _encode_base64(data: bytes | None) -> str:
return base64.b64encode(cast(Buffer, data or b"")).decode("ascii")
@staticmethod @staticmethod
def _serialize_llm_message(message: LlmMessage) -> EasyInputMessageParam: def _serialize_llm_message(message: LlmMessage) -> EasyInputMessageParam:
return { return {
'role': 'assistant', 'role': 'assistant',
'content': message.message(), 'content': message.message(),
'phase': cast(Optional[Literal["commentary", "final_answer"]], message.content["phase"])
} }
@staticmethod
def _serialize_reasoning_message(message: ReasoningMessage) -> dict[str, object]:
# Preserve opaque provider state (such as encrypted reasoning tokens)
# when the same adapter continues after a tool call.
if (provider_item := message.provider_item()) is not None:
return provider_item
return {'role': 'assistant', 'content': message.text()}
@staticmethod @staticmethod
def _serialize_tool_call_message(message: ToolCallMessage) -> ResponseFunctionToolCallParam: def _serialize_tool_call_message(message: ToolCallMessage) -> ResponseFunctionToolCallParam:
return { return {
@@ -98,19 +192,30 @@ class OpenAIBasedLLM(LLM):
} }
@staticmethod @staticmethod
def _serialize_tool_call_response_message(message: ToolCallResponseMessage) -> ResponseCustomToolCallOutputParam: def _serialize_tool_call_response_message(message: ToolCallResponseMessage) -> dict[str, object]:
attachments = message.attachments()
if not attachments:
output: object = message.text()
else:
if any(attachment.kind == "audio" for attachment in attachments):
raise ValueError("Responses API function outputs support image and file attachments, not audio")
content: list[dict[str, object]] = []
if message.text():
content.append({"type": "input_text", "text": message.text()})
content.extend(OpenAIBasedLLM._serialize_media(attachment) for attachment in attachments)
output = content
return { return {
'type': 'custom_tool_call_output', 'type': 'function_call_output',
'call_id': message.unique_id(), 'call_id': message.unique_id(),
'output': message.text() 'output': output,
} }
def _serialize_tools(self, tools: list[tools_package.Tool]) -> Iterable[FunctionToolParam]: def _serialize_tools(self, tools: list[tools_package.Tool]) -> list[FunctionToolParam]:
return (self._serialize_tool(tool) for tool in tools) return [self._serialize_tool(tool) for tool in tools]
def _serialize_tool(self, tool: tools_package.Tool) -> FunctionToolParam: def _serialize_tool(self, tool: tools_package.Tool) -> FunctionToolParam:
fn = tool.executor fn = tool.executor
name = self._infer_tool_name(tool, fn) name = self._infer_tool_name(tool)
description = self._infer_tool_description(tool, fn) description = self._infer_tool_description(tool, fn)
# Описания параметров: блок Params в description тула > param_descriptions > docstring функции. # Описания параметров: блок Params в description тула > param_descriptions > docstring функции.
extra: dict[str, str] = {} extra: dict[str, str] = {}
@@ -131,14 +236,8 @@ class OpenAIBasedLLM(LLM):
} }
@staticmethod @staticmethod
def _infer_tool_name(tool: tools_package.Tool, fn: Any) -> str: def _infer_tool_name(tool: tools_package.Tool) -> str:
candidate = getattr(tool, "name", None) or getattr(fn, "__name__", None) return tools_package.tool_name(tool)
if not candidate or candidate == "<lambda>":
candidate = getattr(fn, "__qualname__", None) or fn.__class__.__name__
candidate = str(candidate)
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", candidate)
sanitized = sanitized.strip("_") or "tool"
return sanitized[:64]
@staticmethod @staticmethod
def _infer_tool_description(tool: tools_package.Tool, fn: Any) -> Optional[str]: def _infer_tool_description(tool: tools_package.Tool, fn: Any) -> Optional[str]:
@@ -179,18 +278,20 @@ class OpenAIBasedLLM(LLM):
r"^[ \t]*(?::(?:param|parameter|arg|argument)\s+(\w+)\s*:|@param\s+(\w+)\s*:?)[ \t]*(.+?)[ \t]*$", r"^[ \t]*(?::(?:param|parameter|arg|argument)\s+(\w+)\s*:|@param\s+(\w+)\s*:?)[ \t]*(.+?)[ \t]*$",
text, re.MULTILINE, text, re.MULTILINE,
): ):
descriptions.setdefault(m.group(1) or m.group(2), m.group(3).strip()) parameter_name = m.group(1) or m.group(2)
parameter_description = m.group(3)
if parameter_name is not None and parameter_description is not None:
descriptions.setdefault(parameter_name, parameter_description.strip())
# 2. Google-style / свой блок: "Args:" / "Params:" / "Параметры:" ... # 2. Google-style / свой блок: "Args:" / "Params:" / "Параметры:" ...
header_pat = r"(?im)^[ \t]*(" + "|".join(OpenAIBasedLLM._PARAM_SECTION_NAMES) + r")[ \t]*:[ \t]*$" header_pat = r"(?im)^[ \t]*(" + "|".join(OpenAIBasedLLM._PARAM_SECTION_NAMES) + r")[ \t]*:[ \t]*$"
m = re.search(header_pat, text) m = re.search(header_pat, text)
head = text head = text
params_body = ""
if m: if m:
head = text[:m.start()] head = text[:m.start()]
rest = text[m.end():] rest = text[m.end():]
end = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", rest) end = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", rest)
params_body = rest[:end.start()] if end else rest google_params_body = rest[:end.start()] if end else rest
for pname, pdesc in OpenAIBasedLLM._parse_google_param_block(params_body).items(): for pname, pdesc in OpenAIBasedLLM._parse_google_param_block(google_params_body).items():
descriptions.setdefault(pname, pdesc) descriptions.setdefault(pname, pdesc)
else: else:
# 3. NumPy-style: "Parameters\n----------" # 3. NumPy-style: "Parameters\n----------"
@@ -199,11 +300,11 @@ class OpenAIBasedLLM(LLM):
head = text[:nm.start()] head = text[:nm.start()]
rest = text[nm.end():] rest = text[nm.end():]
end = re.search(r"(?m)^[ \t]*\w[\w ]*[ \t]*\n[ \t]*-{3,}[ \t]*$", rest) end = re.search(r"(?m)^[ \t]*\w[\w ]*[ \t]*\n[ \t]*-{3,}[ \t]*$", rest)
params_body = rest[:end.start()] if end else rest numpy_params_body = rest[:end.start()] if end else rest
end2 = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", params_body) end2 = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", numpy_params_body)
if end2: if end2:
params_body = params_body[:end2.start()] numpy_params_body = numpy_params_body[:end2.start()]
for pname, pdesc in OpenAIBasedLLM._parse_numpy_param_block(params_body).items(): for pname, pdesc in OpenAIBasedLLM._parse_numpy_param_block(numpy_params_body).items():
descriptions.setdefault(pname, pdesc) descriptions.setdefault(pname, pdesc)
# Чистим описание тула: отрезаем хвостовые секции и sphinx-поля. # Чистим описание тула: отрезаем хвостовые секции и sphinx-поля.
head = re.split(r"(?im)^[ \t]*(returns?|yields?|raises?|examples?|notes?)[ \t]*:[ \t]*$", head)[0] head = re.split(r"(?im)^[ \t]*(returns?|yields?|raises?|examples?|notes?)[ \t]*:[ \t]*$", head)[0]
@@ -223,7 +324,9 @@ class OpenAIBasedLLM(LLM):
m = pat.match(line) m = pat.match(line)
if m and m.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES: if m and m.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES:
current = m.group(1) current = m.group(1)
out[current] = m.group(2).strip() description = m.group(2)
if current is not None and description is not None:
out[current] = description.strip()
elif current is not None and line.strip() and line[:1] in (" ", "\t"): elif current is not None and line.strip() and line[:1] in (" ", "\t"):
out[current] += " " + line.strip() out[current] += " " + line.strip()
elif line.strip(): elif line.strip():
@@ -244,11 +347,14 @@ class OpenAIBasedLLM(LLM):
dm = dash_pat.match(line) dm = dash_pat.match(line)
if dm: if dm:
current = dm.group(1) current = dm.group(1)
out[current] = dm.group(2).strip() description = dm.group(2)
if current is not None and description is not None:
out[current] = description.strip()
elif not indented: elif not indented:
em = entry_pat.match(line) em = entry_pat.match(line)
if em and em.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES: if em and em.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES:
current = em.group(1) current = em.group(1)
if current is not None:
out.setdefault(current, "") out.setdefault(current, "")
else: else:
current = None current = None
@@ -265,7 +371,7 @@ class OpenAIBasedLLM(LLM):
return {"type": "object", "properties": {}, "required": [], "additionalProperties": False} return {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
try: try:
hints = get_type_hints(fn, include_extras=True) hints = get_type_hints(fn, include_extras=True)
except Exception: except (AttributeError, NameError, TypeError):
hints = dict(getattr(fn, "__annotations__", {}) or {}) hints = dict(getattr(fn, "__annotations__", {}) or {})
# isinstance get_type_hints may return non-dict in weird cases; guard # isinstance get_type_hints may return non-dict in weird cases; guard
if not isinstance(hints, dict): if not isinstance(hints, dict):
@@ -291,6 +397,8 @@ class OpenAIBasedLLM(LLM):
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue continue
ann = hints.get(param.name, param.annotation) ann = hints.get(param.name, param.annotation)
if tools_package.is_injected_annotation(ann):
continue
schema: dict[str, object] = dict(OpenAIBasedLLM._python_type_to_json_schema(ann)) schema: dict[str, object] = dict(OpenAIBasedLLM._python_type_to_json_schema(ann))
# description из docstring — только если её ещё нет (Annotated в приоритете). # description из docstring — только если её ещё нет (Annotated в приоритете).
if "description" not in schema and param.name in merged: if "description" not in schema and param.name in merged:
@@ -386,7 +494,8 @@ class OpenAIBasedLLM(LLM):
if len(args) == 2 and args[1] is Ellipsis: if len(args) == 2 and args[1] is Ellipsis:
return {"type": "array", "items": OpenAIBasedLLM._python_type_to_json_schema(args[0])} return {"type": "array", "items": OpenAIBasedLLM._python_type_to_json_schema(args[0])}
if args: if args:
return {"type": "array", "prefixItems": [OpenAIBasedLLM._python_type_to_json_schema(a) for a in args]} return {"type": "array",
"prefixItems": [OpenAIBasedLLM._python_type_to_json_schema(a) for a in args]}
return {"type": "array"} return {"type": "array"}
# list / Sequence / Iterable ... # list / Sequence / Iterable ...
if args: if args:
@@ -403,14 +512,13 @@ class OpenAIBasedLLM(LLM):
if len(args) == 2: if len(args) == 2:
return {"type": "object", "additionalProperties": OpenAIBasedLLM._python_type_to_json_schema(args[1])} return {"type": "object", "additionalProperties": OpenAIBasedLLM._python_type_to_json_schema(args[1])}
return {"type": "object"} return {"type": "object"}
if ann in (dict,) or (isinstance(ann, type) and _safe_issubclass(ann, dict) and not OpenAIBasedLLM._is_structured_class(ann)): if ann in (dict,) or (
isinstance(ann, type) and _safe_issubclass(ann, dict) and not OpenAIBasedLLM._is_structured_class(ann)):
return {"type": "object"} return {"type": "object"}
# Enum -> enum of values # Enum -> enum of values
if isinstance(ann, type) and _safe_issubclass(ann, enum.Enum): if isinstance(ann, type) and _safe_issubclass(ann, enum.Enum):
try: enum_type = cast(type[enum.Enum], ann)
values = [e.value for e in ann] values = [e.value for e in enum_type]
except Exception:
return {}
return {"enum": values} return {"enum": values}
# Pydantic v2 / v1 without hard dependency # Pydantic v2 / v1 without hard dependency
if hasattr(ann, "model_json_schema") and callable(getattr(ann, "model_json_schema")): if hasattr(ann, "model_json_schema") and callable(getattr(ann, "model_json_schema")):
@@ -418,29 +526,32 @@ class OpenAIBasedLLM(LLM):
schema = ann.model_json_schema() # type: ignore[union-attr] schema = ann.model_json_schema() # type: ignore[union-attr]
if isinstance(schema, dict): if isinstance(schema, dict):
return schema return schema
except Exception: except (AttributeError, TypeError, ValueError):
pass pass
if hasattr(ann, "schema") and callable(getattr(ann, "schema")): if hasattr(ann, "schema") and callable(getattr(ann, "schema")):
try: try:
schema = ann.schema() # type: ignore[union-attr] schema = ann.schema() # type: ignore[union-attr]
if isinstance(schema, dict): if isinstance(schema, dict):
return schema return schema
except Exception: except (AttributeError, TypeError, ValueError):
pass pass
# dataclass -> object with properties # dataclass -> object with properties
if dataclasses.is_dataclass(ann) and isinstance(ann, type): if dataclasses.is_dataclass(ann) and isinstance(ann, type):
try: try:
hints = get_type_hints(ann, include_extras=True) hints = get_type_hints(ann, include_extras=True)
except Exception: except (AttributeError, NameError, TypeError):
hints = dict(getattr(ann, "__annotations__", {}) or {}) hints = dict(getattr(ann, "__annotations__", {}) or {})
props = {f.name: OpenAIBasedLLM._python_type_to_json_schema(hints.get(f.name, f.type)) for f in dataclasses.fields(ann)} fields = dataclasses.fields(cast(Any, ann))
req = [f.name for f in dataclasses.fields(ann) if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING] props = {field.name: OpenAIBasedLLM._python_type_to_json_schema(hints.get(field.name, field.type)) for field
in fields}
req = [field.name for field in fields if
field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING]
return {"type": "object", "properties": props, "required": req, "additionalProperties": False} return {"type": "object", "properties": props, "required": req, "additionalProperties": False}
# TypedDict -> object with properties # TypedDict -> object with properties
if isinstance(ann, type) and _safe_issubclass(ann, dict) and hasattr(ann, "__annotations__"): if isinstance(ann, type) and _safe_issubclass(ann, dict) and hasattr(ann, "__annotations__"):
try: try:
hints = get_type_hints(ann, include_extras=True) hints = get_type_hints(ann, include_extras=True)
except Exception: except (AttributeError, NameError, TypeError):
hints = dict(getattr(ann, "__annotations__", {}) or {}) hints = dict(getattr(ann, "__annotations__", {}) or {})
if hints: if hints:
props = {k: OpenAIBasedLLM._python_type_to_json_schema(v) for k, v in hints.items()} props = {k: OpenAIBasedLLM._python_type_to_json_schema(v) for k, v in hints.items()}
@@ -450,3 +561,13 @@ class OpenAIBasedLLM(LLM):
if isinstance(ann, type): if isinstance(ann, type):
return {} return {}
return {} return {}
class DeepSeekLLM(OpenAIBasedLLM):
def __init__(self, api_key: str, model: Union[Literal["deepseek-flash"], Literal["deepseek-v4-pro"]]):
super().__init__("https://api.deepseek.com/", api_key, model)
class ZAiLLM(OpenAIBasedLLM):
def __init__(self, api_key: str, model: Union[Literal["glm-5.3-flash"], Literal["glm-5.3"]]): # остальные лень
super().__init__("https://api.z.ai/api/paas/v4", api_key, model)

View File

@@ -1,6 +1,182 @@
from collections.abc import Callable from __future__ import annotations
import inspect
import json
import re
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Annotated, get_args, get_origin, get_type_hints
from micro_agents.context import Injected, SessionContext
from micro_agents.conversation import Media, ToolCallMessage, ToolCallResponseMessage
def tool_name(tool: "Tool") -> str:
"""Return the stable, provider-safe name used for a tool call."""
candidate = tool.name or getattr(tool.executor, "__name__", None)
if not candidate or candidate == "<lambda>":
candidate = getattr(tool.executor, "__qualname__", None) or type(tool.executor).__name__
return (re.sub(r"[^a-zA-Z0-9_-]", "_", str(candidate)).strip("_") or "tool")[:64]
class Tool: class Tool:
def __init__(self, executor: Callable, strict: bool = True): def __init__(
self,
executor: Callable[..., object],
strict: bool = True,
*,
name: str | None = None,
description: str | None = None,
param_descriptions: dict[str, str] | None = None,
):
self.executor = executor self.executor = executor
self.name = name
self.description = description
self.strict = strict self.strict = strict
self.param_descriptions = param_descriptions
self._injected_parameters = _injected_parameter_names(executor)
def invoke(self, arguments: dict[str, object], context: SessionContext) -> object:
values = dict(arguments)
values.update({name: context for name in self._injected_parameters})
return self.executor(**values)
def is_injected_annotation(annotation: object) -> bool:
return get_origin(annotation) is Annotated and Injected in get_args(annotation)[1:]
def _injected_parameter_names(executor: Callable[..., object]) -> tuple[str, ...]:
try:
signature = inspect.signature(executor)
except (TypeError, ValueError):
return ()
try:
hints = get_type_hints(executor, include_extras=True)
except (AttributeError, NameError, TypeError):
hints = getattr(executor, "__annotations__", {}) or {}
names: list[str] = []
for parameter in signature.parameters.values():
annotation = hints.get(parameter.name, parameter.annotation)
if not is_injected_annotation(annotation):
continue
if parameter.kind is inspect.Parameter.POSITIONAL_ONLY:
raise TypeError(f"Injected parameter '{parameter.name}' cannot be positional-only")
names.append(parameter.name)
return tuple(names)
@dataclass(frozen=True, slots=True)
class ToolExecution:
response: ToolCallResponseMessage
@dataclass(frozen=True, slots=True)
class ToolOutput:
"""Text and optional attachments returned by a tool to the model."""
text: str
attachments: tuple[Media, ...] = ()
class ToolManager:
"""Select and execute tools for a session.
Override :meth:`is_allowed` to enforce application-specific access rules.
The predicate is evaluated both while tools are advertised and immediately
before execution, so a stale model call cannot bypass a context update.
"""
def __init__(self, tools: Iterable[Tool] = ()):
self._tools: tuple[Tool, ...] = ()
self._by_name: dict[str, Tool] = {}
self._set_tools(tuple(tools))
def _set_tools(self, tools: tuple[Tool, ...]) -> None:
names = [tool_name(tool) for tool in tools]
if len(names) != len(set(names)):
raise ValueError("Tool names must be unique")
# Replace both snapshots instead of mutating them in place. Sessions
# that are currently filtering tools can safely finish using the old tuple.
self._tools = tools
self._by_name = dict(zip(names, tools, strict=True))
def is_allowed(self, tool: Tool, context: SessionContext) -> bool | Awaitable[bool]:
return True
async def available_tools(self, context: SessionContext) -> list[Tool]:
return [tool for tool in self._tools if await self._is_allowed(tool, context)]
async def execute(self, call: ToolCallMessage, context: SessionContext) -> ToolExecution:
tool = self._by_name.get(call.tool_name())
if tool is None:
return self._error(call, "tool_not_found", f"Unknown tool: {call.tool_name()}")
if not await self._is_allowed(tool, context):
return self._error(call, "tool_not_allowed", f"Tool is not available: {call.tool_name()}")
if self._by_name.get(call.tool_name()) is not tool:
return self._error(call, "tool_not_found", f"Tool changed or was removed: {call.tool_name()}")
try:
result = tool.invoke(call.arguments(), context)
if isinstance(result, Awaitable):
result = await result
except TypeError as error:
return self._error(call, "invalid_arguments", str(error))
except Exception as error:
return self._error(call, "tool_execution_failed", str(error) or type(error).__name__)
if isinstance(result, ToolOutput):
return ToolExecution(
ToolCallResponseMessage(
call.unique_id(),
result.text,
attachments=list(result.attachments),
)
)
return ToolExecution(ToolCallResponseMessage(call.unique_id(), self._serialize_result(result)))
@staticmethod
def _serialize_result(result: object) -> str:
if isinstance(result, str):
return result
try:
return json.dumps(result, ensure_ascii=False, default=str, separators=(",", ":"))
except (TypeError, ValueError):
return result.__str__()
async def _is_allowed(self, tool: Tool, context: SessionContext) -> bool:
allowed = self.is_allowed(tool, context)
if isinstance(allowed, Awaitable):
allowed = await allowed
return bool(allowed)
@staticmethod
def _error(call: ToolCallMessage, code: str, message: str) -> ToolExecution:
payload = json.dumps({"ok": False, "error": {"code": code, "message": message}}, ensure_ascii=False)
return ToolExecution(ToolCallResponseMessage(call.unique_id(), payload, is_error=True, error_code=code))
class DynamicToolManager(ToolManager):
"""A tool manager whose registry can be changed while sessions are running."""
def add_tool(self, tool: Tool, *, replace: bool = False) -> None:
name = tool_name(tool)
existing = self._by_name.get(name)
if existing is not None and not replace:
raise ValueError(f"Tool is already registered: {name}")
tools = tuple(item for item in self._tools if tool_name(item) != name) + (tool,)
self._set_tools(tools)
def remove_tool(self, name: str) -> Tool | None:
existing = self._by_name.get(name)
if existing is None:
return None
self._set_tools(tuple(tool for tool in self._tools if tool is not existing))
return existing
def clear(self) -> None:
self._set_tools(())
def registered_tools(self) -> tuple[Tool, ...]:
return self._tools

1
test/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,26 @@
import unittest
from typing import Annotated
from micro_agents import Injected, OpenAIBasedLLM, SessionContext, Tool, ToolCallMessage, ToolManager
class InjectedContextTests(unittest.IsolatedAsyncioTestCase):
async def test_context_is_injected_but_not_exposed_to_the_llm(self):
def who_am_i(text: str, context: Annotated[SessionContext, Injected]) -> str:
return f"{context['user_id'].__str__()}: {text}"
tool = Tool(who_am_i)
schema = OpenAIBasedLLM._infer_parameters_schema(who_am_i)
response = await ToolManager([tool]).execute(
ToolCallMessage("call-1", "who_am_i", {"text": "hello"}),
{"user_id": 42},
)
properties = schema["properties"]
assert isinstance(properties, dict)
self.assertEqual({"text"}, set(properties))
self.assertEqual(["text"], schema["required"])
self.assertEqual("42: hello", response.response.text())
if __name__ == "__main__":
unittest.main()

187
tests/test_runtime.py Normal file
View File

@@ -0,0 +1,187 @@
import unittest
from micro_agents import (
Aggregator,
Completed,
DynamicToolManager,
LLM,
LlmMessage,
Media,
Tool,
ToolCallFailed,
ToolCallMessage,
ToolCallResponseMessage,
ToolManager,
UserMessage,
)
from micro_agents.conversation import ReasoningMessage
from micro_agents.llm import OpenAIBasedLLM
class ScriptedLLM(LLM):
def __init__(self, responses, after_first_call=None):
self.responses = list(responses)
self.after_first_call = after_first_call
self.instructions = []
self.tools = []
self.thinking = []
async def ask(self, conversation, tools, *, instructions=None, thinking=None):
self.instructions.append(instructions)
self.tools.append([tool.name for tool in tools])
self.thinking.append(thinking)
if len(self.instructions) == 1 and self.after_first_call is not None:
self.after_first_call()
return self.responses.pop(0)
class ContextualToolManager(ToolManager):
async def is_allowed(self, tool, context):
return bool(context.get("can_echo"))
class SessionRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_master_prompt_is_rebuilt_on_each_llm_round(self):
async def echo(text: str):
return {"echo": text}
tool = Tool(echo, name="echo")
llm = ScriptedLLM(
[
[ToolCallMessage("call-1", "echo", {"text": "hello"})],
[LlmMessage("done")],
]
)
contexts = []
def prompt(prompt_context):
contexts.append(prompt_context)
return f"user={prompt_context['user_id']}"
context = {"user_id": 42, "can_echo": True}
session = Aggregator(llm, ToolManager([tool]), master_prompt=prompt).create_session(context=context)
result = await session.run("start")
self.assertTrue(result.succeeded)
final_message = result.final_message
assert final_message is not None
self.assertEqual("done", final_message.message())
self.assertEqual(["user=42", "user=42"], llm.instructions)
self.assertEqual([context, context], contexts)
responses = [m for m in session.conversation.messages if isinstance(m, ToolCallResponseMessage)]
self.assertEqual('{"echo":"hello"}', responses[0].text())
async def test_permission_is_checked_again_before_execution(self):
def restricted():
raise AssertionError("A denied tool must not execute")
context = {"can_echo": True}
llm = ScriptedLLM(
[
[ToolCallMessage("call-1", "restricted", {})],
[LlmMessage("I cannot access that tool")],
],
after_first_call=lambda: context.update(can_echo=False),
)
session = Aggregator(llm, ContextualToolManager([Tool(restricted, name="restricted")])).create_session(
context=context
)
events = [event async for event in session.stream("do it")]
result = next(event.result for event in events if isinstance(event, Completed))
self.assertTrue(result.succeeded)
self.assertEqual([['restricted'], []], llm.tools)
response = next(m for m in session.conversation.messages if isinstance(m, ToolCallResponseMessage))
self.assertTrue(response.is_error())
self.assertEqual("tool_not_allowed", response.error_code())
self.assertTrue(any(isinstance(event, ToolCallFailed) for event in events))
async def test_unknown_tool_becomes_a_recoverable_tool_response(self):
llm = ScriptedLLM(
[
[ToolCallMessage("call-1", "missing", {})],
[LlmMessage("I do not have that capability")],
]
)
session = Aggregator(llm).create_session()
result = await session.run("use missing")
self.assertTrue(result.succeeded)
response = next(m for m in session.conversation.messages if isinstance(m, ToolCallResponseMessage))
self.assertEqual("tool_not_found", response.error_code())
async def test_dynamic_tool_manager_updates_running_sessions(self):
manager = DynamicToolManager()
session = Aggregator(
ScriptedLLM(
[
[ToolCallMessage("call-1", "echo", {"text": "hello"})],
[LlmMessage("done")],
]
),
manager,
).create_session()
manager.add_tool(Tool(lambda text: text, name="echo"))
result = await session.run("start")
self.assertTrue(result.succeeded)
manager.remove_tool("echo")
self.assertEqual((), manager.registered_tools())
def test_dynamic_tool_manager_requires_explicit_replace(self):
manager = DynamicToolManager([Tool(lambda: "first", name="status")])
with self.assertRaises(ValueError):
manager.add_tool(Tool(lambda: "second", name="status"))
replacement = Tool(lambda: "second", name="status")
manager.add_tool(replacement, replace=True)
self.assertEqual((replacement,), manager.registered_tools())
def test_openai_reasoning_state_is_retained_for_the_next_request(self):
raw_reasoning = {"type": "reasoning", "id": "rs_123", "encrypted_content": "opaque"}
message = ReasoningMessage("", provider_item=raw_reasoning)
self.assertEqual(raw_reasoning, OpenAIBasedLLM._serialize_reasoning_message(message))
async def test_session_can_override_llm_and_thinking(self):
default_llm = ScriptedLLM([[LlmMessage("default")]])
session_llm = ScriptedLLM([[LlmMessage("override")]])
session = Aggregator(default_llm, thinking=False).create_session(llm=session_llm, thinking=True)
result = await session.run("hello")
self.assertEqual("override", result.final_message.message() if result.final_message else None)
self.assertEqual([True], session_llm.thinking)
self.assertEqual([], default_llm.thinking)
async def test_session_accepts_media(self):
llm = ScriptedLLM([[LlmMessage("seen")]])
session = Aggregator(llm).create_session()
media = Media("image", data=b"image", mime_type="image/png", filename="image.png")
await session.run("describe", media=[media])
user_message = session.conversation.messages[0]
self.assertIsInstance(user_message, UserMessage)
assert isinstance(user_message, UserMessage)
self.assertEqual([media], user_message.media())
def test_openai_serializes_inline_image(self):
media = Media("image", data=b"image", mime_type="image/png", filename="image.png")
serialized = OpenAIBasedLLM._serialize_user_message(UserMessage("describe", [media]))
content = serialized["content"]
assert isinstance(content, list)
self.assertEqual("user", serialized["role"])
self.assertEqual("input_text", content[0]["type"])
self.assertEqual("data:image/png;base64,aW1hZ2U=", content[1].get("image_url"))
if __name__ == "__main__":
unittest.main()

25
uv.lock generated
View File

@@ -2,6 +2,12 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[manifest]
members = [
"micro-agents",
"micro-agents-tools",
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.8.0" version = "0.8.0"
@@ -158,12 +164,31 @@ dependencies = [
{ name = "orjson" }, { name = "orjson" },
] ]
[package.dev-dependencies]
dev = [
{ name = "micro-agents-tools" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "openai", specifier = ">=3.11.0" }, { name = "openai", specifier = ">=3.11.0" },
{ name = "orjson", specifier = ">=3.12.0" }, { name = "orjson", specifier = ">=3.12.0" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "micro-agents-tools", editable = "packages/micro-agents-tools" }]
[[package]]
name = "micro-agents-tools"
version = "0.1.0"
source = { editable = "packages/micro-agents-tools" }
dependencies = [
{ name = "micro-agents" },
]
[package.metadata]
requires-dist = [{ name = "micro-agents", editable = "." }]
[[package]] [[package]]
name = "openai" name = "openai"
version = "3.11.0" version = "3.11.0"