Azimkin ver

This commit is contained in:
Azimkin
2026-09-11 14:22:30 +02:00
commit 4ae48659f3
18 changed files with 1024 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
def main() -> None:
print("Hello from micro-agents!")

View File

@@ -0,0 +1,28 @@
from abc import ABC, abstractmethod
from typing import Iterable
from micro_agents.conversation import Message
from micro_agents.llm import LLM
class EventSubscriber(ABC):
@abstractmethod
async def on_message(self, messages: Iterable[Message]):
pass
class Session:
def __init__(self, event_subscriber: EventSubscriber, session_name: str | None = None, messages: list[Message] | None = None):
class Aggregator:
def __init__(self, llm: LLM):
self._llm = llm
def create_session(self):
...
async def start_loop(self):
...

View File

@@ -0,0 +1,75 @@
import json
from abc import ABC
from enum import Enum
from typing import cast
class Side(Enum):
REASONING = "reasoning"
USER = "user"
LLM = "llm"
TOOL_CALL = "tool_call"
TOOL_RESPONSE = "tool_response"
class Message(ABC):
def __init__(self, side: Side, content: dict[str, object]):
self.side = side
self.content = content
class ReasoningMessage(Message):
def __init__(self, text: str):
super().__init__(Side.REASONING, {'text': text})
def text(self) -> str:
return self.content["text"].__str__()
class UserMessage(Message):
def __init__(self, message: str):
super().__init__(Side.USER, {'message': message})
def message(self) -> str:
return self.content["message"].__str__()
class LlmMessage(Message):
def __init__(self, message: str):
super().__init__(Side.LLM, {'message': message})
def message(self) -> str:
return self.content["message"].__str__()
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})
def unique_id(self) -> str:
return self.content["unique_id"].__str__()
def arguments(self) -> dict[str, object]:
return cast(dict[str, object], self.content["arguments"])
def tool_name(self):
return self.content["tool_call"].__str__()
class ToolCallResponseMessage(Message):
def __init__(self, unique_id: str, text: str):
super().__init__(Side.TOOL_RESPONSE, {'unique_id': unique_id, 'text': text})
def text(self) -> str:
return self.content["text"].__str__()
def unique_id(self) -> str:
return self.content["unique_id"].__str__()
class Conversation:
def __init__(self, messages: list[Message]):
self.messages = messages
def add_message(self, message: Message):
self.messages.append(message)

452
src/micro_agents/llm.py Normal file
View File

@@ -0,0 +1,452 @@
from abc import ABC
from typing import Iterable, Optional, Literal, cast, Any, Union, get_args, get_origin, get_type_hints, Annotated
import dataclasses
import enum
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 micro_agents.conversation import Conversation, UserMessage, LlmMessage, ToolCallMessage, ToolCallResponseMessage, \
ReasoningMessage
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:
return False
class LLM(ABC):
def __init__(self):
...
class OpenAIBasedLLM(LLM):
def __init__(self, api_url: str, api_key: str, model: str):
super().__init__()
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]):
response = await self._ai.responses.create(
model=self._model,
input=self._serialize_conversation(conversation),
tools=self._serialize_tools(tools)
)
out_messages = []
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)))
case 'message':
out_messages.append(LlmMessage(response.output_text))
case "function_call":
out_messages.append(ToolCallMessage(item.call_id, item.name, orjson.loads(item.arguments)))
return out_messages
def _serialize_conversation(self, conversation: Conversation) -> ResponseInputParam:
out = []
for m in conversation.messages:
if isinstance(m, UserMessage):
out.append(self._serialize_user_message(m))
elif isinstance(m, LlmMessage):
out.append(self._serialize_llm_message(m))
elif isinstance(m, ToolCallMessage):
out.append(self._serialize_tool_call_message(m))
elif isinstance(m, ToolCallResponseMessage):
out.append(self._serialize_tool_call_response_message(m))
else:
raise Exception(f"Unknown type of message {type(m).__name__}")
return out
@staticmethod
def _serialize_user_message(message: UserMessage) -> EasyInputMessageParam:
return {
'role': 'user',
'content': message.message()
}
@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_tool_call_message(message: ToolCallMessage) -> ResponseFunctionToolCallParam:
return {
'type': 'function_call',
'call_id': message.unique_id(),
'name': message.tool_name(),
'arguments': orjson.dumps(message.arguments()).decode('utf-8'),
}
@staticmethod
def _serialize_tool_call_response_message(message: ToolCallResponseMessage) -> ResponseCustomToolCallOutputParam:
return {
'type': 'custom_tool_call_output',
'call_id': message.unique_id(),
'output': message.text()
}
def _serialize_tools(self, tools: list[tools_package.Tool]) -> Iterable[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)
description = self._infer_tool_description(tool, fn)
# Описания параметров: блок Params в description тула > param_descriptions > docstring функции.
extra: dict[str, str] = {}
tool_desc_raw = getattr(tool, "description", None)
if isinstance(tool_desc_raw, str) and tool_desc_raw.strip():
extra.update(self._parse_docstring(tool_desc_raw)[1])
override = getattr(tool, "param_descriptions", None)
if isinstance(override, dict):
extra.update({str(k): str(v) for k, v in override.items() if isinstance(v, str) and v.strip()})
parameters: dict[str, object] = self._infer_parameters_schema(fn, extra)
strict = tool.strict
return {
"type": "function",
"name": name,
"description": description,
"parameters": parameters,
"strict": bool(strict),
}
@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]
@staticmethod
def _infer_tool_description(tool: tools_package.Tool, fn: Any) -> Optional[str]:
desc = getattr(tool, "description", None)
if isinstance(desc, str) and desc.strip():
# Чистим от блока с описаниями параметров, если он там есть.
return OpenAIBasedLLM._parse_docstring(desc)[0] or desc.strip()
doc = inspect.getdoc(fn)
if doc:
return OpenAIBasedLLM._parse_docstring(doc)[0]
return None
# Заголовки блока с описаниями параметров (включая русские).
_PARAM_SECTION_NAMES = ("args", "arguments", "params", "parameters", "параметры", "аргументы")
_END_SECTION_NAMES = ("returns", "return", "yields", "yield", "raises", "raise",
"examples", "example", "notes", "note", "see also")
@staticmethod
def _parse_docstring(doc: str) -> tuple[Optional[str], dict[str, str]]:
"""Разобрать docstring: вернуть (чистое описание, {параметр: описание}).
Понимает блоки вида::
'''Краткое описание.
Params:
city: город, например "Berlin".
units: единицы измерения.
'''
а также стили Google (``Args:``), Sphinx (``:param name:``)
и NumPy (``Parameters\\n----------``).
"""
descriptions: dict[str, str] = {}
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,
):
descriptions.setdefault(m.group(1) or m.group(2), m.group(3).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():
descriptions.setdefault(pname, pdesc)
else:
# 3. NumPy-style: "Parameters\n----------"
nm = re.search(r"(?m)^[ \t]*(Parameters|Параметры)[ \t]*\n[ \t]*-{3,}[ \t]*$", text)
if nm:
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)
if end2:
params_body = params_body[:end2.start()]
for pname, pdesc in OpenAIBasedLLM._parse_numpy_param_block(params_body).items():
descriptions.setdefault(pname, pdesc)
# Чистим описание тула: отрезаем хвостовые секции и sphinx-поля.
head = re.split(r"(?im)^[ \t]*(returns?|yields?|raises?|examples?|notes?)[ \t]*:[ \t]*$", head)[0]
head = re.sub(
r"(?m)^[ \t]*(@param\b.*|:(?:param|parameter|arg|argument|type|return|returns|rtype|raises?)\b.*)$",
"", head,
)
return head.strip() or None, descriptions
@staticmethod
def _parse_google_param_block(body: str) -> dict[str, str]:
"""Строки ``name: desc`` / ``name (type): desc`` / ``name - desc`` + продолжения."""
out: dict[str, str] = {}
current: Optional[str] = None
pat = re.compile(r"^[ \t]*\*{0,2}(\w+)[ \t]*(?:\([^)]*\))?[ \t]*[:\-–—][ \t]*(.+?)[ \t]*$")
for line in body.splitlines():
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()
elif current is not None and line.strip() and line[:1] in (" ", "\t"):
out[current] += " " + line.strip()
elif line.strip():
current = None
return out
@staticmethod
def _parse_numpy_param_block(body: str) -> dict[str, str]:
"""Строки ``name : type`` + описание с отступом; ``name - desc`` в одну строку."""
out: dict[str, str] = {}
current: Optional[str] = None
dash_pat = re.compile(r"^[ \t]*\*{0,2}(\w+)[ \t]*[–—-][ \t]*(.+?)[ \t]*$")
entry_pat = re.compile(r"^[ \t]*\*{0,2}(\w+)[ \t]*(?::[^\n]*)?$")
for line in body.splitlines():
if not line.strip():
continue
indented = line[:1] in (" ", "\t")
dm = dash_pat.match(line)
if dm:
current = dm.group(1)
out[current] = dm.group(2).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, "")
else:
current = None
elif current is not None:
piece = line.strip()
out[current] = (out[current] + " " + piece).strip() if out[current] else piece
return {k: v for k, v in out.items() if v}
@staticmethod
def _infer_parameters_schema(fn: Any, param_descriptions: Optional[dict[str, str]] = None) -> dict[str, object]:
try:
sig = inspect.signature(fn)
except (ValueError, TypeError):
return {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
try:
hints = get_type_hints(fn, include_extras=True)
except Exception:
hints = dict(getattr(fn, "__annotations__", {}) or {})
# isinstance get_type_hints may return non-dict in weird cases; guard
if not isinstance(hints, dict):
hints = {}
# Описания параметров из блока в docstring функции (Annotated в приоритете — см. ниже).
doc = inspect.getdoc(fn) or ""
merged: dict[str, str] = OpenAIBasedLLM._parse_docstring(doc)[1] if doc else {}
if param_descriptions:
merged.update(param_descriptions)
params = list(sig.parameters.values())
skip_first_self = False
if params and params[0].name in ("self", "cls"):
qualname = getattr(fn, "__qualname__", "")
if "." in qualname or inspect.ismethod(fn) or inspect.isclass(getattr(fn, "__self__", None)):
skip_first_self = True
properties: dict[str, object] = {}
required: list[str] = []
for i, param in enumerate(params):
if i == 0 and skip_first_self:
continue
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
ann = hints.get(param.name, param.annotation)
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:
schema["description"] = merged[param.name]
has_default = param.default is not inspect.Parameter.empty
if has_default:
default = param.default
if default is None or isinstance(default, (str, int, float, bool)):
schema["default"] = default
else:
if not OpenAIBasedLLM._is_optional_annotation(ann):
required.append(param.name)
properties[param.name] = schema
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
@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__"))
)
@staticmethod
def _is_optional_annotation(ann: Any) -> bool:
if ann is None or ann is type(None):
return True
if ann is inspect.Parameter.empty:
return False
origin = get_origin(ann)
if origin in (Union, types.UnionType):
return any(a in (None, type(None)) for a in get_args(ann))
return False
@staticmethod
def _python_type_to_json_schema(ann: Any) -> dict[str, object]:
from typing import Literal as TypingLiteral
if ann is None or ann is type(None):
return {"type": "null"}
if ann is inspect.Parameter.empty or ann is Any or ann is object:
return {}
# Annotated[X, ...] -> unwrap, first str metadata becomes description
if get_origin(ann) is Annotated:
args = get_args(ann)
inner = OpenAIBasedLLM._python_type_to_json_schema(args[0]) if args else {}
for meta in args[1:]:
if isinstance(meta, str) and "description" not in inner:
inner = {**inner, "description": meta}
return inner
# Literal[...] -> enum
if get_origin(ann) in (TypingLiteral, Literal):
return {"enum": list(get_args(ann))}
origin = get_origin(ann)
# Union / X | Y
if origin is Union or origin is types.UnionType:
args = get_args(ann)
non_none = [a for a in args if a not in (None, type(None))]
if len(non_none) == 1 and len(non_none) != len(args):
# Optional[X] -> unwrap to inner (omittable via `required`)
return OpenAIBasedLLM._python_type_to_json_schema(non_none[0])
return {"anyOf": [OpenAIBasedLLM._python_type_to_json_schema(a) for a in args]}
# primitives
if ann is str:
return {"type": "string"}
if ann is int:
return {"type": "integer"}
if ann is float:
return {"type": "number"}
if ann is bool:
return {"type": "boolean"}
if ann is bytes:
return {"type": "string", "format": "byte"}
# list / sequence
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))
):
args = get_args(ann)
if origin in (set, frozenset) or (isinstance(ann, type) and _safe_issubclass(ann, (set, frozenset))):
items = OpenAIBasedLLM._python_type_to_json_schema(args[0]) if args else {}
out: dict[str, object] = {"type": "array", "uniqueItems": True}
if items:
out["items"] = items
return out
if origin is tuple or ann is tuple:
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"}
# list / Sequence / Iterable ...
if args:
return {"type": "array", "items": OpenAIBasedLLM._python_type_to_json_schema(args[0])}
return {"type": "array"}
if isinstance(ann, type) and _safe_issubclass(ann, (list, set, frozenset, tuple)):
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)
):
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)):
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 {}
return {"enum": values}
# Pydantic v2 / v1 without hard dependency
if hasattr(ann, "model_json_schema") and callable(getattr(ann, "model_json_schema")):
try:
schema = ann.model_json_schema() # type: ignore[union-attr]
if isinstance(schema, dict):
return schema
except Exception:
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:
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:
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]
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:
hints = dict(getattr(ann, "__annotations__", {}) or {})
if hints:
props = {k: OpenAIBasedLLM._python_type_to_json_schema(v) for k, v in hints.items()}
req = list(getattr(ann, "__required_keys__", list(hints.keys())))
return {"type": "object", "properties": props, "required": list(req), "additionalProperties": False}
# unknown class -> generic object / any
if isinstance(ann, type):
return {}
return {}

View File

@@ -0,0 +1,6 @@
from collections.abc import Callable
class Tool:
def __init__(self, executor: Callable, strict: bool = True):
self.executor = executor
self.strict = strict