Build session-based agent tool loop
Some checks failed
Test and publish / publish (push) Failing after 2s
Some checks failed
Test and publish / publish (push) Failing after 2s
This commit is contained in:
38
.gitea/workflows/publish.yml
Normal file
38
.gitea/workflows/publish.yml
Normal 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
4
.gitignore
vendored
@@ -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
11
.idea/micro-agents-tools.iml
generated
Normal 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>
|
||||||
2
.idea/micro-agents.iml
generated
2
.idea/micro-agents.iml
generated
@@ -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
1
.idea/modules.xml
generated
@@ -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
118
README.md
@@ -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.
|
||||||
|
|||||||
9
packages/micro-agents-tools/README.md
Normal file
9
packages/micro-agents-tools/README.md
Normal 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.
|
||||||
20
packages/micro-agents-tools/pyproject.toml
Normal file
20
packages/micro-agents-tools/pyproject.toml
Normal 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"
|
||||||
@@ -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"]
|
||||||
92
packages/micro-agents-tools/src/micro_agents_tools/brave.py
Normal file
92
packages/micro-agents-tools/src/micro_agents_tools/brave.py
Normal 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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Runnable examples for micro-agents-tools."""
|
||||||
@@ -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}")
|
||||||
126
packages/micro-agents-tools/src/micro_agents_tools/filesystem.py
Normal file
126
packages/micro-agents-tools/src/micro_agents_tools/filesystem.py
Normal 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
|
||||||
63
packages/micro-agents-tools/src/micro_agents_tools/python.py
Normal file
63
packages/micro-agents-tools/src/micro_agents_tools/python.py
Normal 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 ""
|
||||||
100
packages/micro-agents-tools/tests/test_tools.py
Normal file
100
packages/micro-agents-tools/tests/test_tools.py
Normal 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()
|
||||||
@@ -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
5
pyrightconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"include": ["src", "packages", "tests"],
|
||||||
|
"exclude": [".venv", "dist"],
|
||||||
|
"extraPaths": ["src", "packages/micro-agents-tools/src"]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
8
src/micro_agents/context.py
Normal file
8
src/micro_agents/context.py
Normal 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."""
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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':
|
||||||
out_messages.append(LlmMessage(response.output_text))
|
if response.output_text and not output_text_added:
|
||||||
|
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 {
|
||||||
|
'role': 'user',
|
||||||
|
'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 {
|
return {
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'content': message.message()
|
'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]:
|
||||||
@@ -176,21 +275,23 @@ class OpenAIBasedLLM(LLM):
|
|||||||
text = doc.strip()
|
text = doc.strip()
|
||||||
# 1. Sphinx-style: :param name: desc / @param name: desc
|
# 1. Sphinx-style: :param name: desc / @param name: desc
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
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,12 +347,15 @@ 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)
|
||||||
out.setdefault(current, "")
|
if current is not None:
|
||||||
|
out.setdefault(current, "")
|
||||||
else:
|
else:
|
||||||
current = None
|
current = None
|
||||||
elif current is not None:
|
elif current is not 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:
|
||||||
@@ -314,9 +422,9 @@ class OpenAIBasedLLM(LLM):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_structured_class(ann: Any) -> bool:
|
def _is_structured_class(ann: Any) -> bool:
|
||||||
return (
|
return (
|
||||||
(dataclasses.is_dataclass(ann) and isinstance(ann, type))
|
(dataclasses.is_dataclass(ann) and isinstance(ann, type))
|
||||||
or (isinstance(ann, type) and _safe_issubclass(ann, enum.Enum))
|
or (isinstance(ann, type) and _safe_issubclass(ann, enum.Enum))
|
||||||
or (hasattr(ann, "model_json_schema") or hasattr(ann, "__required_keys__"))
|
or (hasattr(ann, "model_json_schema") or hasattr(ann, "__required_keys__"))
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -372,8 +480,8 @@ class OpenAIBasedLLM(LLM):
|
|||||||
if ann in (list,):
|
if ann in (list,):
|
||||||
return {"type": "array"}
|
return {"type": "array"}
|
||||||
if origin is not None and (
|
if origin is not None and (
|
||||||
origin in (list, set, frozenset, tuple, collections.abc.Sequence, collections.abc.MutableSequence)
|
origin in (list, set, frozenset, tuple, collections.abc.Sequence, collections.abc.MutableSequence)
|
||||||
or _safe_issubclass(origin, (list, set, frozenset, tuple))
|
or _safe_issubclass(origin, (list, set, frozenset, tuple))
|
||||||
):
|
):
|
||||||
args = get_args(ann)
|
args = get_args(ann)
|
||||||
if origin in (set, frozenset) or (isinstance(ann, type) and _safe_issubclass(ann, (set, frozenset))):
|
if origin in (set, frozenset) or (isinstance(ann, type) and _safe_issubclass(ann, (set, frozenset))):
|
||||||
@@ -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:
|
||||||
@@ -396,21 +505,20 @@ class OpenAIBasedLLM(LLM):
|
|||||||
return {"type": "array"}
|
return {"type": "array"}
|
||||||
# dict / mapping
|
# dict / mapping
|
||||||
if origin is not None and (
|
if origin is not None and (
|
||||||
origin in (dict, collections.abc.Mapping, collections.abc.MutableMapping)
|
origin in (dict, collections.abc.Mapping, collections.abc.MutableMapping)
|
||||||
or _safe_issubclass(origin, dict)
|
or _safe_issubclass(origin, dict)
|
||||||
):
|
):
|
||||||
args = get_args(ann)
|
args = get_args(ann)
|
||||||
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)
|
||||||
|
|||||||
@@ -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
1
test/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
26
tests/test_injected_context.py
Normal file
26
tests/test_injected_context.py
Normal 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
187
tests/test_runtime.py
Normal 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
25
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user