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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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