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

@@ -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:
print("Hello from micro-agents!")
print("micro-agents")

View File

@@ -1,28 +1,233 @@
from abc import ABC, abstractmethod
from typing import Iterable
from __future__ import annotations
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.tools import ToolManager
class EventSubscriber(ABC):
@abstractmethod
async def on_message(self, messages: Iterable[Message]):
pass
Context = SessionContext
MasterPrompt = Callable[[SessionContext], str | Awaitable[str]]
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:
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:
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._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):
...
async def start_loop(self):
...
def create_session(
self,
*,
context: Context | None = None,
event_subscriber: EventSubscriber | object | None = None,
session_name: str | None = None,
messages: Iterable[Message] | None = None,
llm: LLM | None = None,
thinking: bool | None = None,
) -> Session:
return Session(
self._llm if llm is None else llm,
self._tool_manager,
context=context,
master_prompt=self._master_prompt,
event_subscriber=event_subscriber,
session_name=session_name,
messages=messages,
max_tool_rounds=self._max_tool_rounds,
thinking=self._thinking if thinking is None else thinking,
)

View File

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

View File

@@ -1,7 +1,12 @@
import json
from __future__ import annotations
from abc import ABC
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from typing import cast
import mimetypes
from pathlib import Path
from typing import Literal, cast
class Side(Enum):
@@ -13,30 +18,115 @@ class Side(Enum):
class Message(ABC):
"""A provider-neutral item in a conversation transcript."""
def __init__(self, side: Side, content: dict[str, object]):
self.side = side
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):
def __init__(self, text: str):
super().__init__(Side.REASONING, {'text': text})
def __init__(self, text: str, *, provider_item: Mapping[str, object] | None = None):
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:
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):
def __init__(self, message: str):
super().__init__(Side.USER, {'message': message})
def __init__(self, message: str, media: list[Media] | None = None):
super().__init__(Side.USER, {"message": message, "media": list(media or ())})
def message(self) -> str:
return self.content["message"].__str__()
def media(self) -> list[Media]:
return cast(list[Media], self.content["media"])
class LlmMessage(Message):
def __init__(self, message: str):
super().__init__(Side.LLM, {'message': message})
super().__init__(Side.LLM, {"message": message})
def message(self) -> str:
return self.content["message"].__str__()
@@ -44,7 +134,10 @@ class LlmMessage(Message):
class ToolCallMessage(Message):
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:
return self.content["unique_id"].__str__()
@@ -52,13 +145,29 @@ class ToolCallMessage(Message):
def arguments(self) -> dict[str, object]:
return cast(dict[str, object], self.content["arguments"])
def tool_name(self):
return self.content["tool_call"].__str__()
def tool_name(self) -> str:
return self.content["tool_name"].__str__()
class ToolCallResponseMessage(Message):
def __init__(self, unique_id: str, text: str):
super().__init__(Side.TOOL_RESPONSE, {'unique_id': unique_id, 'text': text})
def __init__(
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:
return self.content["text"].__str__()
@@ -66,10 +175,20 @@ class ToolCallResponseMessage(Message):
def unique_id(self) -> 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:
def __init__(self, messages: list[Message]):
self.messages = messages
def __init__(self, messages: list[Message] | None = None):
self.messages = list(messages or [])
def add_message(self, message: Message):
def add_message(self, message: Message) -> None:
self.messages.append(message)

View File

@@ -1,5 +1,7 @@
from abc import ABC
from typing import Iterable, Optional, Literal, cast, Any, Union, get_args, get_origin, get_type_hints, Annotated
from abc import ABC, abstractmethod
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 enum
@@ -7,14 +9,14 @@ import inspect
import re
import types
import collections.abc
from uuid import uuid4
import orjson
from openai import AsyncOpenAI
from openai.types.responses import ResponseInputParam, ToolParam, ResponseInputItemParam, EasyInputMessageParam, \
ResponseFunctionToolCallParam, ResponseCustomToolCallOutputParam, FunctionToolParam
from openai.types.responses import ResponseInputParam, EasyInputMessageParam, ResponseFunctionToolCallParam, \
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
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:
try:
return isinstance(tp, type) and issubclass(tp, classes)
except Exception:
except TypeError:
return False
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):
@@ -37,24 +47,51 @@ class OpenAIBasedLLM(LLM):
self._ai = AsyncOpenAI(api_key=api_key, base_url=api_url, )
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(
model=self._model,
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:
match item.type:
case "reasoning":
reasoning_parts = []
for c in item.content:
reasoning_parts.append(c.text)
out_messages.append(ReasoningMessage(" ".join(reasoning_parts)))
reasoning_parts: list[str] = []
for content in (*getattr(item, "content", ()), *getattr(item, "summary", ())):
text = getattr(content, "text", None)
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':
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":
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
@@ -63,6 +100,8 @@ class OpenAIBasedLLM(LLM):
for m in conversation.messages:
if isinstance(m, UserMessage):
out.append(self._serialize_user_message(m))
elif isinstance(m, ReasoningMessage):
out.append(self._serialize_reasoning_message(m))
elif isinstance(m, LlmMessage):
out.append(self._serialize_llm_message(m))
elif isinstance(m, ToolCallMessage):
@@ -70,24 +109,79 @@ class OpenAIBasedLLM(LLM):
elif isinstance(m, ToolCallResponseMessage):
out.append(self._serialize_tool_call_response_message(m))
else:
raise Exception(f"Unknown type of message {type(m).__name__}")
raise ValueError(f"Unknown type of message {type(m).__name__}")
return out
@staticmethod
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 {
'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
def _serialize_llm_message(message: LlmMessage) -> EasyInputMessageParam:
return {
'role': 'assistant',
'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
def _serialize_tool_call_message(message: ToolCallMessage) -> ResponseFunctionToolCallParam:
return {
@@ -98,19 +192,30 @@ class OpenAIBasedLLM(LLM):
}
@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 {
'type': 'custom_tool_call_output',
'type': 'function_call_output',
'call_id': message.unique_id(),
'output': message.text()
'output': output,
}
def _serialize_tools(self, tools: list[tools_package.Tool]) -> Iterable[FunctionToolParam]:
return (self._serialize_tool(tool) for tool in tools)
def _serialize_tools(self, tools: list[tools_package.Tool]) -> list[FunctionToolParam]:
return [self._serialize_tool(tool) for tool in tools]
def _serialize_tool(self, tool: tools_package.Tool) -> FunctionToolParam:
fn = tool.executor
name = self._infer_tool_name(tool, fn)
name = self._infer_tool_name(tool)
description = self._infer_tool_description(tool, fn)
# Описания параметров: блок Params в description тула > param_descriptions > docstring функции.
extra: dict[str, str] = {}
@@ -131,14 +236,8 @@ class OpenAIBasedLLM(LLM):
}
@staticmethod
def _infer_tool_name(tool: tools_package.Tool, fn: Any) -> str:
candidate = getattr(tool, "name", None) or getattr(fn, "__name__", None)
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]
def _infer_tool_name(tool: tools_package.Tool) -> str:
return tools_package.tool_name(tool)
@staticmethod
def _infer_tool_description(tool: tools_package.Tool, fn: Any) -> Optional[str]:
@@ -176,21 +275,23 @@ class OpenAIBasedLLM(LLM):
text = doc.strip()
# 1. Sphinx-style: :param name: desc / @param name: desc
for m in re.finditer(
r"^[ \t]*(?::(?:param|parameter|arg|argument)\s+(\w+)\s*:|@param\s+(\w+)\s*:?)[ \t]*(.+?)[ \t]*$",
text, re.MULTILINE,
r"^[ \t]*(?::(?:param|parameter|arg|argument)\s+(\w+)\s*:|@param\s+(\w+)\s*:?)[ \t]*(.+?)[ \t]*$",
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:" / "Параметры:" ...
header_pat = r"(?im)^[ \t]*(" + "|".join(OpenAIBasedLLM._PARAM_SECTION_NAMES) + r")[ \t]*:[ \t]*$"
m = re.search(header_pat, text)
head = text
params_body = ""
if m:
head = text[:m.start()]
rest = text[m.end():]
end = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", rest)
params_body = rest[:end.start()] if end else rest
for pname, pdesc in OpenAIBasedLLM._parse_google_param_block(params_body).items():
google_params_body = rest[:end.start()] if end else rest
for pname, pdesc in OpenAIBasedLLM._parse_google_param_block(google_params_body).items():
descriptions.setdefault(pname, pdesc)
else:
# 3. NumPy-style: "Parameters\n----------"
@@ -199,11 +300,11 @@ class OpenAIBasedLLM(LLM):
head = text[:nm.start()]
rest = text[nm.end():]
end = re.search(r"(?m)^[ \t]*\w[\w ]*[ \t]*\n[ \t]*-{3,}[ \t]*$", rest)
params_body = rest[:end.start()] if end else rest
end2 = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", params_body)
numpy_params_body = rest[:end.start()] if end else rest
end2 = re.search(r"(?im)^[ \t]*\w[\w ]*[ \t]*:[ \t]*$", numpy_params_body)
if end2:
params_body = params_body[:end2.start()]
for pname, pdesc in OpenAIBasedLLM._parse_numpy_param_block(params_body).items():
numpy_params_body = numpy_params_body[:end2.start()]
for pname, pdesc in OpenAIBasedLLM._parse_numpy_param_block(numpy_params_body).items():
descriptions.setdefault(pname, pdesc)
# Чистим описание тула: отрезаем хвостовые секции и sphinx-поля.
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)
if m and m.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES:
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"):
out[current] += " " + line.strip()
elif line.strip():
@@ -244,12 +347,15 @@ class OpenAIBasedLLM(LLM):
dm = dash_pat.match(line)
if dm:
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:
em = entry_pat.match(line)
if em and em.group(1).lower() not in OpenAIBasedLLM._END_SECTION_NAMES:
current = em.group(1)
out.setdefault(current, "")
if current is not None:
out.setdefault(current, "")
else:
current = None
elif current is not None:
@@ -265,7 +371,7 @@ class OpenAIBasedLLM(LLM):
return {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
try:
hints = get_type_hints(fn, include_extras=True)
except Exception:
except (AttributeError, NameError, TypeError):
hints = dict(getattr(fn, "__annotations__", {}) or {})
# isinstance get_type_hints may return non-dict in weird cases; guard
if not isinstance(hints, dict):
@@ -291,6 +397,8 @@ class OpenAIBasedLLM(LLM):
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
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))
# description из docstring — только если её ещё нет (Annotated в приоритете).
if "description" not in schema and param.name in merged:
@@ -314,9 +422,9 @@ class OpenAIBasedLLM(LLM):
@staticmethod
def _is_structured_class(ann: Any) -> bool:
return (
(dataclasses.is_dataclass(ann) and isinstance(ann, type))
or (isinstance(ann, type) and _safe_issubclass(ann, enum.Enum))
or (hasattr(ann, "model_json_schema") or hasattr(ann, "__required_keys__"))
(dataclasses.is_dataclass(ann) and isinstance(ann, type))
or (isinstance(ann, type) and _safe_issubclass(ann, enum.Enum))
or (hasattr(ann, "model_json_schema") or hasattr(ann, "__required_keys__"))
)
@staticmethod
@@ -372,8 +480,8 @@ class OpenAIBasedLLM(LLM):
if ann in (list,):
return {"type": "array"}
if origin is not None and (
origin in (list, set, frozenset, tuple, collections.abc.Sequence, collections.abc.MutableSequence)
or _safe_issubclass(origin, (list, set, frozenset, tuple))
origin in (list, set, frozenset, tuple, collections.abc.Sequence, collections.abc.MutableSequence)
or _safe_issubclass(origin, (list, set, frozenset, tuple))
):
args = get_args(ann)
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:
return {"type": "array", "items": OpenAIBasedLLM._python_type_to_json_schema(args[0])}
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"}
# list / Sequence / Iterable ...
if args:
@@ -396,21 +505,20 @@ class OpenAIBasedLLM(LLM):
return {"type": "array"}
# dict / mapping
if origin is not None and (
origin in (dict, collections.abc.Mapping, collections.abc.MutableMapping)
or _safe_issubclass(origin, dict)
origin in (dict, collections.abc.Mapping, collections.abc.MutableMapping)
or _safe_issubclass(origin, dict)
):
args = get_args(ann)
if len(args) == 2:
return {"type": "object", "additionalProperties": OpenAIBasedLLM._python_type_to_json_schema(args[1])}
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"}
# Enum -> enum of values
if isinstance(ann, type) and _safe_issubclass(ann, enum.Enum):
try:
values = [e.value for e in ann]
except Exception:
return {}
enum_type = cast(type[enum.Enum], ann)
values = [e.value for e in enum_type]
return {"enum": values}
# Pydantic v2 / v1 without hard dependency
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]
if isinstance(schema, dict):
return schema
except Exception:
except (AttributeError, TypeError, ValueError):
pass
if hasattr(ann, "schema") and callable(getattr(ann, "schema")):
try:
schema = ann.schema() # type: ignore[union-attr]
if isinstance(schema, dict):
return schema
except Exception:
except (AttributeError, TypeError, ValueError):
pass
# dataclass -> object with properties
if dataclasses.is_dataclass(ann) and isinstance(ann, type):
try:
hints = get_type_hints(ann, include_extras=True)
except Exception:
except (AttributeError, NameError, TypeError):
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)}
req = [f.name for f in dataclasses.fields(ann) if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING]
fields = dataclasses.fields(cast(Any, ann))
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}
# TypedDict -> object with properties
if isinstance(ann, type) and _safe_issubclass(ann, dict) and hasattr(ann, "__annotations__"):
try:
hints = get_type_hints(ann, include_extras=True)
except Exception:
except (AttributeError, NameError, TypeError):
hints = dict(getattr(ann, "__annotations__", {}) or {})
if hints:
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):
return {}
return {}
class DeepSeekLLM(OpenAIBasedLLM):
def __init__(self, api_key: str, model: Union[Literal["deepseek-flash"], Literal["deepseek-v4-pro"]]):
super().__init__("https://api.deepseek.com/", api_key, model)
class ZAiLLM(OpenAIBasedLLM):
def __init__(self, api_key: str, model: Union[Literal["glm-5.3-flash"], Literal["glm-5.3"]]): # остальные лень
super().__init__("https://api.z.ai/api/paas/v4", api_key, model)

View File

@@ -1,6 +1,182 @@
from collections.abc import Callable
from __future__ import annotations
import inspect
import json
import re
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Annotated, get_args, get_origin, get_type_hints
from micro_agents.context import Injected, SessionContext
from micro_agents.conversation import Media, ToolCallMessage, ToolCallResponseMessage
def tool_name(tool: "Tool") -> str:
"""Return the stable, provider-safe name used for a tool call."""
candidate = tool.name or getattr(tool.executor, "__name__", None)
if not candidate or candidate == "<lambda>":
candidate = getattr(tool.executor, "__qualname__", None) or type(tool.executor).__name__
return (re.sub(r"[^a-zA-Z0-9_-]", "_", str(candidate)).strip("_") or "tool")[:64]
class Tool:
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.name = name
self.description = description
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