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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user