ruff check --fix --select ALL

This commit is contained in:
LIghtJUNction
2026-04-10 19:57:02 +08:00
parent 33f54aaec6
commit ec1af1ac0f
281 changed files with 3308 additions and 3164 deletions
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+192
View File
@@ -0,0 +1,192 @@
{
"main": {
"id": "f98f11a1ba9b1b69",
"type": "split",
"children": [
{
"id": "4347c65e925426f6",
"type": "tabs",
"children": [
{
"id": "7ddf9ff7a14ac645",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "README_zh.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "README_zh"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "3873c1543132d010",
"type": "split",
"children": [
{
"id": "98d0304b3093fe36",
"type": "tabs",
"children": [
{
"id": "0765d293c9935b2a",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "文件列表"
}
},
{
"id": "1dbfe7984144b1ea",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "搜索"
}
},
{
"id": "66c197bef01cb226",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "书签"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "bef5d27b73e38648",
"type": "split",
"children": [
{
"id": "076ab49585b131f0",
"type": "tabs",
"children": [
{
"id": "9d5e8d1459abee95",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "README_zh.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "README_zh 的反向链接列表"
}
},
{
"id": "9a91f0eb932cd56e",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "README_zh.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "README_zh 的出链列表"
}
},
{
"id": "5c9c48f356751800",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "标签"
}
},
{
"id": "5b41ca46073ca7d6",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "添加笔记属性"
}
},
{
"id": "65fe5368baf4273d",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "README_zh.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "README_zh 的大纲"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"left-ribbon": {
"hiddenItems": {
"switcher:打开快速切换": false,
"graph:查看关系图谱": false,
"canvas:新建白板": false,
"daily-notes:打开/创建今天的日记": false,
"templates:插入模板": false,
"command-palette:打开命令面板": false,
"bases:新建数据库": false
}
},
"active": "9d5e8d1459abee95",
"lastOpenFiles": [
"README_fr.md",
"README.md",
"AGENTS.md",
"docs/skills/astrbot-dev-guide.md"
]
}
+2 -2
View File
@@ -99,7 +99,7 @@ async def check_dashboard_files(webui_dir: str | None = None):
await download_dashboard(version=f"v{VERSION}", latest=False)
except Exception as e:
logger.warning(
f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。"
f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。",
)
try:
await download_dashboard(latest=True)
@@ -118,7 +118,7 @@ async def main_async(webui_dir_arg: str | None, log_broker: LogBroker) -> None:
if webui_dir is None:
logger.warning(
"管理面板文件检查失败,WebUI 功能将不可用。"
"请检查网络连接或手动指定 --webui-dir 参数。"
"请检查网络连接或手动指定 --webui-dir 参数。",
)
db = db_helper
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Astbot内部实现
"""Astbot内部实现
外部模块请勿导入
"""
@@ -1,5 +1,4 @@
"""
ABP (AstrBot Protocol) client - in-process star communication.
"""ABP (AstrBot Protocol) client - in-process star communication.
"""
from __future__ import annotations
@@ -9,8 +8,7 @@ from typing import Any
class BaseAstrbotAbpClient(ABC):
"""
ABP client: in-process star (plugin) communication.
"""ABP client: in-process star (plugin) communication.
Stars register themselves; client delegates calls to registered instances.
@@ -1,5 +1,4 @@
"""
ACP (AstrBot Communication Protocol) client.
"""ACP (AstrBot Communication Protocol) client.
Transport: TCP | Unix Socket
Messages: JSON with Content-Length header
@@ -12,8 +11,7 @@ from typing import Any
class BaseAstrbotAcpClient(ABC):
"""
ACP client: connects to ACP servers via TCP or Unix socket.
"""ACP client: connects to ACP servers via TCP or Unix socket.
Subclass must implement:
- connect() -> None
@@ -1,5 +1,4 @@
"""
ACP (AstrBot Communication Protocol) server.
"""ACP (AstrBot Communication Protocol) server.
Transport: TCP listening socket
Messages: JSON with Content-Length header
@@ -13,8 +12,7 @@ from typing import Any
class BaseAstrbotAcpServer(ABC):
"""
ACP server: listens for client connections, exposes tools.
"""ACP server: listens for client connections, exposes tools.
Subclass must implement:
- start(host, port) -> None
@@ -1,5 +1,4 @@
"""
LSP (Language Server Protocol) client.
"""LSP (Language Server Protocol) client.
Transport: stdio subprocess
Messages: JSON-RPC 2.0 with Content-Length header
@@ -8,10 +7,7 @@ Messages: JSON-RPC 2.0 with Content-Length header
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
pass
from typing import Any
class LspMessage:
@@ -43,8 +39,7 @@ class LspNotification(LspMessage):
class BaseAstrbotLspClient(ABC):
"""
LSP client: connects to LSP servers via stdio subprocess.
"""LSP client: connects to LSP servers via stdio subprocess.
Subclass must implement:
- connect() -> None
@@ -71,8 +66,7 @@ class BaseAstrbotLspClient(ABC):
command: list[str],
workspace_uri: str,
) -> None:
"""
Start LSP server subprocess and complete handshake.
"""Start LSP server subprocess and complete handshake.
Steps:
1. Spawn subprocess with stdin/stdout pipes
@@ -88,12 +82,12 @@ class BaseAstrbotLspClient(ABC):
method: str,
params: dict[str, Any] | None = None,
) -> Any:
"""
Send JSON-RPC request and return result.
"""Send JSON-RPC request and return result.
Raises:
RuntimeError: not connected
Exception: server returned error
"""
...
@@ -103,8 +97,7 @@ class BaseAstrbotLspClient(ABC):
method: str,
params: dict[str, Any] | None = None,
) -> None:
"""
Send JSON-RPC notification (no response expected).
"""Send JSON-RPC notification (no response expected).
"""
...
@@ -1,5 +1,4 @@
"""
MCP (Model Context Protocol) client.
"""MCP (Model Context Protocol) client.
Transport: stdio | SSE | streamable_http
Messages: JSON-RPC 2.0
@@ -8,10 +7,7 @@ Messages: JSON-RPC 2.0
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Literal, TypedDict
if TYPE_CHECKING:
pass
from typing import Any, Literal, TypedDict
class McpServerConfig(TypedDict, total=False):
@@ -38,8 +34,7 @@ class McpToolInfo(TypedDict):
class BaseAstrbotMcpClient(ABC):
"""
MCP client: connects to MCP servers for external tools.
"""MCP client: connects to MCP servers for external tools.
Subclass must implement:
- connect() -> None
@@ -66,8 +61,7 @@ class BaseAstrbotMcpClient(ABC):
config: McpServerConfig,
name: str,
) -> None:
"""
Connect to MCP server.
"""Connect to MCP server.
Stdio: {"command": "python", "args": ["server.py"], "env": {...}}
HTTP: {"url": "https://...", "transport": "sse"}
@@ -1,5 +1,4 @@
"""
AstrBot Gateway - HTTP/WebSocket API server.
"""AstrBot Gateway - HTTP/WebSocket API server.
Built on FastAPI, provides:
- HTTP REST API (stats, inspector, config)
@@ -14,8 +13,7 @@ from abc import ABC, abstractmethod
class BaseAstrbotGateway(ABC):
"""
Gateway: HTTP/WebSocket server built on FastAPI.
"""Gateway: HTTP/WebSocket server built on FastAPI.
┌─────────────────────────────────────────────────────────┐
│ FastAPI App │
@@ -56,8 +54,7 @@ class BaseAstrbotGateway(ABC):
@abstractmethod
async def serve(self) -> None:
"""
Start gateway server - blocks until shutdown.
"""Start gateway server - blocks until shutdown.
Should:
1. Create FastAPI app with routes
@@ -69,5 +66,6 @@ class BaseAstrbotGateway(ABC):
Raises:
OSError: address already in use
"""
...
@@ -1,5 +1,4 @@
"""
AstrBot Orchestrator - core runtime lifecycle manager.
"""AstrBot Orchestrator - core runtime lifecycle manager.
Architecture
============
@@ -165,8 +164,7 @@ DEFAULT_SLEEP_INTERVAL: float = 5.0
class BaseAstrbotOrchestrator(ABC):
"""
Core runtime: owns lifecycle of all protocol clients and stars.
"""Core runtime: owns lifecycle of all protocol clients and stars.
┌────────────────────────────────────────────────────────────┐
│ Protocol Clients (always present, never None after init) │
@@ -217,8 +215,7 @@ class BaseAstrbotOrchestrator(ABC):
abp: AstrbotAbpClient
def __init__(self) -> None:
"""
Initialize orchestrator and all protocol clients.
"""Initialize orchestrator and all protocol clients.
After __init__, all clients exist but are not connected.
Call start() or run_loop() to begin operation.
@@ -232,6 +229,7 @@ class BaseAstrbotOrchestrator(ABC):
self.abp = AstrbotAbpClient()
self._stars: dict[str, Any] = {}
self._running = False
"""
self._stars: dict[str, Any] = {}
self._running: bool = False
@@ -243,8 +241,7 @@ class BaseAstrbotOrchestrator(ABC):
@abstractmethod
async def start(self) -> None:
"""
Initialize all protocol clients.
"""Initialize all protocol clients.
Called once before run_loop(). Should:
1. Call lsp.connect()
@@ -255,13 +252,13 @@ class BaseAstrbotOrchestrator(ABC):
Raises:
Exception: if any client fails to initialize
"""
...
@abstractmethod
async def run_loop(self) -> None:
"""
Main event loop - blocks until shutdown.
"""Main event loop - blocks until shutdown.
Execution:
self._running = True
@@ -286,13 +283,13 @@ class BaseAstrbotOrchestrator(ABC):
Note:
Subclass defines _heartbeat() for periodic tasks.
This method only handles the loop control.
"""
...
@abstractmethod
async def register_star(self, name: str, star_instance: Any) -> None:
"""
Register a star (plugin) with the orchestrator.
"""Register a star (plugin) with the orchestrator.
Args:
name: Unique identifier for the star
@@ -304,13 +301,13 @@ class BaseAstrbotOrchestrator(ABC):
Raises:
ValueError: if name already registered
"""
...
@abstractmethod
async def unregister_star(self, name: str) -> None:
"""
Unregister a star (plugin) from the orchestrator.
"""Unregister a star (plugin) from the orchestrator.
Args:
name: Identifier of star to remove
@@ -321,6 +318,7 @@ class BaseAstrbotOrchestrator(ABC):
Note:
Idempotent - does nothing if name not found.
"""
...
@@ -336,8 +334,7 @@ class BaseAstrbotOrchestrator(ABC):
@abstractmethod
async def shutdown(self) -> None:
"""
Graceful shutdown of orchestrator and all clients.
"""Graceful shutdown of orchestrator and all clients.
Execution order:
1. self._running = False (stop run_loop)
+1 -2
View File
@@ -1,4 +1,3 @@
"""
依赖注入
"""依赖注入
"""
+8 -11
View File
@@ -1,5 +1,4 @@
"""
AstrBot Gateway - FastAPI server for the dashboard backend.
"""AstrBot Gateway - FastAPI server for the dashboard backend.
Provides REST API endpoints and WebSocket connections for the frontend dashboard.
The gateway acts as the communication bridge between the dashboard and the orchestrator.
@@ -32,8 +31,7 @@ log = logger
class AstrbotGateway(BaseAstrbotGateway):
"""
FastAPI-based gateway server for AstrBot.
"""FastAPI-based gateway server for AstrBot.
Handles:
- REST API endpoints for configuration and stats
@@ -49,8 +47,7 @@ class AstrbotGateway(BaseAstrbotGateway):
self._port = 8765
async def serve(self) -> None:
"""
Start the gateway server.
"""Start the gateway server.
Creates and runs a FastAPI application with WebSocket support.
"""
@@ -82,7 +79,7 @@ class AstrbotGateway(BaseAstrbotGateway):
import uvicorn
config = uvicorn.Config(
self._app, host=self._host, port=self._port, log_level="info"
self._app, host=self._host, port=self._port, log_level="info",
)
server = uvicorn.Server(config)
await server.serve()
@@ -140,16 +137,16 @@ class AstrbotGateway(BaseAstrbotGateway):
self._app.include_router(memory_router)
async def _handle_ws_message(
self, message: dict[str, Any]
self, message: dict[str, Any],
) -> dict[str, Any] | None:
"""
Handle an incoming WebSocket message.
"""Handle an incoming WebSocket message.
Args:
message: Parsed JSON message from the client
Returns:
Response message to send back, or None for no response
"""
msg_type = message.get("type")
data = message.get("data", {})
@@ -176,7 +173,7 @@ class AstrbotGateway(BaseAstrbotGateway):
}
try:
result = await self.orchestrator.abp.call_star_tool(
star_name, tool_name, arguments
star_name, tool_name, arguments,
)
return {"type": "tool_result", "data": {"result": result}}
except Exception as e:
+9 -11
View File
@@ -1,5 +1,4 @@
"""
WebSocket connection manager for the AstrBot gateway.
"""WebSocket connection manager for the AstrBot gateway.
"""
from __future__ import annotations
@@ -22,8 +21,7 @@ log = logger
class WebSocketManager:
"""
Manages all active WebSocket connections.
"""Manages all active WebSocket connections.
Provides connection/disconnection handling and broadcast capabilities.
"""
@@ -46,12 +44,12 @@ class WebSocketManager:
log.debug(f"WebSocket disconnected. Total: {len(self._connections)}")
async def send_json(self, websocket: WebSocket, data: dict[str, Any]) -> None:
"""
Send JSON data to a specific WebSocket.
"""Send JSON data to a specific WebSocket.
Args:
websocket: Target WebSocket connection
data: Data to send (must be JSON-serializable)
"""
try:
await websocket.send_json(data)
@@ -60,11 +58,11 @@ class WebSocketManager:
await self.disconnect(websocket)
async def broadcast(self, data: dict[str, Any]) -> None:
"""
Broadcast JSON data to all connected WebSockets.
"""Broadcast JSON data to all connected WebSockets.
Args:
data: Data to broadcast (must be JSON-serializable)
"""
async with self._lock:
connections = list(self._connections)
@@ -77,14 +75,14 @@ class WebSocketManager:
self._connections.discard(conn)
async def send_to(
self, websocket: WebSocket, message: str | dict[str, Any]
self, websocket: WebSocket, message: str | dict[str, Any],
) -> None:
"""
Send a message to a specific WebSocket.
"""Send a message to a specific WebSocket.
Args:
websocket: Target WebSocket connection
message: Message to send (string or dict)
"""
try:
if isinstance(message, str):
+5 -7
View File
@@ -1,5 +1,4 @@
"""
ABP (AstrBot Protocol) client implementation.
"""ABP (AstrBot Protocol) client implementation.
ABP is the built-in plugin protocol where the orchestrator acts as client
connecting to internal stars (plugins) embedded in the runtime.
@@ -16,8 +15,7 @@ log = logger
class AstrbotAbpClient(BaseAstrbotAbpClient):
"""
ABP client for communicating with internal stars (built-in plugins).
"""ABP client for communicating with internal stars (built-in plugins).
The orchestrator acts as the client, sending requests to and receiving
notifications from stars running within the same process.
@@ -42,10 +40,9 @@ class AstrbotAbpClient(BaseAstrbotAbpClient):
log.info("ABP client connected to internal stars registry.")
async def call_star_tool(
self, star_name: str, tool_name: str, arguments: dict[str, Any]
self, star_name: str, tool_name: str, arguments: dict[str, Any],
) -> Any:
"""
Call a tool on a registered star.
"""Call a tool on a registered star.
Args:
star_name: Name of the star (plugin)
@@ -54,6 +51,7 @@ class AstrbotAbpClient(BaseAstrbotAbpClient):
Returns:
Tool call result
"""
if not self._connected:
raise RuntimeError("ABP client is not connected")
+11 -14
View File
@@ -1,5 +1,4 @@
"""
ACP (AstrBot Communication Protocol) client implementation.
"""ACP (AstrBot Communication Protocol) client implementation.
ACP is a client-server protocol for inter-service communication,
similar to MCP but designed specifically for AstrBot's architecture.
@@ -18,8 +17,7 @@ log = logger
class AstrbotAcpClient(BaseAstrbotAcpClient):
"""
ACP client for communicating with ACP servers.
"""ACP client for communicating with ACP servers.
The orchestrator acts as an ACP client, connecting to external
ACP-compatible services.
@@ -40,8 +38,7 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
return self._connected
async def connect(self) -> None:
"""
Connect to configured ACP servers.
"""Connect to configured ACP servers.
ACP servers can be accessed via TCP (host:port) or Unix socket.
"""
@@ -51,12 +48,12 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
log.info("ACP client initialized.")
async def connect_to_server(self, host: str, port: int) -> None:
"""
Connect to an ACP server via TCP.
"""Connect to an ACP server via TCP.
Args:
host: Server hostname or IP
port: Server port
"""
self._server_url = f"{host}:{port}"
self._reader, self._writer = await asyncio.open_connection(host, port)
@@ -68,11 +65,11 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
log.info(f"ACP client connected to {self._server_url}")
async def connect_to_unix_socket(self, socket_path: str) -> None:
"""
Connect to an ACP server via Unix socket.
"""Connect to an ACP server via Unix socket.
Args:
socket_path: Path to the Unix socket
"""
self._server_url = f"unix://{socket_path}"
self._reader, self._writer = await asyncio.open_unix_connection(socket_path)
@@ -140,10 +137,9 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
log.debug(f"ACP notification: {method}")
async def call_tool(
self, server_name: str, tool_name: str, arguments: dict[str, Any]
self, server_name: str, tool_name: str, arguments: dict[str, Any],
) -> Any:
"""
Call a tool on an ACP server.
"""Call a tool on an ACP server.
Args:
server_name: Name of the ACP server
@@ -152,6 +148,7 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
Returns:
Tool call result
"""
if not self._connected:
raise RuntimeError("ACP client is not connected")
@@ -184,7 +181,7 @@ class AstrbotAcpClient(BaseAstrbotAcpClient):
await self._writer.drain()
async def send_notification(
self, method: str, params: dict[str, Any] | None = None
self, method: str, params: dict[str, Any] | None = None,
) -> None:
"""Send a one-way notification to the server."""
message = {
+12 -14
View File
@@ -1,5 +1,4 @@
"""
ACP (AstrBot Communication Protocol) server implementation.
"""ACP (AstrBot Communication Protocol) server implementation.
ACP servers listen for connections from ACP clients and provide
services/tools to the orchestrator.
@@ -19,8 +18,7 @@ log = logger
class AstrbotAcpServer(BaseAstrbotAcpServer):
"""
ACP server for accepting connections from ACP clients.
"""ACP server for accepting connections from ACP clients.
ACP servers expose tools/notifications that can be called by clients.
"""
@@ -35,36 +33,36 @@ class AstrbotAcpServer(BaseAstrbotAcpServer):
self._notification_handlers: dict[str, Callable[..., Any]] = {}
def register_tool(self, name: str, handler: Callable[..., Any]) -> None:
"""
Register a tool handler.
"""Register a tool handler.
Args:
name: Tool name
handler: Async callable that handles tool calls
"""
self._tool_handlers[name] = handler
log.debug(f"ACP server registered tool: {name}")
def register_notification_handler(
self, name: str, handler: Callable[..., Any]
self, name: str, handler: Callable[..., Any],
) -> None:
"""
Register a notification handler.
"""Register a notification handler.
Args:
name: Notification method name
handler: Async callable that handles notifications
"""
self._notification_handlers[name] = handler
log.debug(f"ACP server registered notification handler: {name}")
async def start(self, host: str = "127.0.0.1", port: int = 8765) -> None:
"""
Start the ACP server.
"""Start the ACP server.
Args:
host: Host to bind to
port: Port to listen on
"""
self._host = host
self._port = port
@@ -77,7 +75,7 @@ class AstrbotAcpServer(BaseAstrbotAcpServer):
log.info(f"ACP server listening on {host}:{port}")
async def _handle_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
) -> None:
"""Handle an incoming ACP client connection."""
addr = writer.get_extra_info("peername")
@@ -180,12 +178,12 @@ class AstrbotAcpServer(BaseAstrbotAcpServer):
return response
async def broadcast_notification(self, method: str, params: dict[str, Any]) -> None:
"""
Broadcast a notification to all connected clients.
"""Broadcast a notification to all connected clients.
Args:
method: Notification method name
params: Notification parameters
"""
message = {
"jsonrpc": "2.0",
+7 -10
View File
@@ -1,5 +1,4 @@
"""
LSP (Language Server Protocol) client implementation.
"""LSP (Language Server Protocol) client implementation.
The orchestrator acts as an LSP client, connecting to LSP servers
that provide language intelligence features (completions, diagnostics, etc.).
@@ -20,8 +19,7 @@ log = logger
class AstrbotLspClient(BaseAstrbotLspClient):
"""
LSP client for communicating with LSP servers.
"""LSP client for communicating with LSP servers.
Implements the Microsoft Language Server Protocol for connecting to
external language intelligence services.
@@ -44,8 +42,7 @@ class AstrbotLspClient(BaseAstrbotLspClient):
return self._connected
async def connect(self) -> None:
"""
Connect to configured LSP servers.
"""Connect to configured LSP servers.
LSP servers are typically stdio-based subprocesses. This method
establishes the communication channel.
@@ -57,12 +54,12 @@ class AstrbotLspClient(BaseAstrbotLspClient):
log.info("LSP client initialized.")
async def connect_to_server(self, command: list[str], workspace_uri: str) -> None:
"""
Connect to an LSP server subprocess.
"""Connect to an LSP server subprocess.
Args:
command: Command line to start the LSP server (e.g., ["python", "lsp_server.py"])
workspace_uri: Root URI of the workspace to serve
"""
log.debug(f"Starting LSP server: {' '.join(command)}")
@@ -99,7 +96,7 @@ class AstrbotLspClient(BaseAstrbotLspClient):
log.info(f"LSP client connected to server: {command[0]}")
async def send_request(
self, method: str, params: dict[str, Any] | None = None
self, method: str, params: dict[str, Any] | None = None,
) -> Any:
"""Send an LSP request and wait for response."""
if not self._writer:
@@ -138,7 +135,7 @@ class AstrbotLspClient(BaseAstrbotLspClient):
raise TimeoutError(f"LSP request {method} timed out")
async def send_notification(
self, method: str, params: dict[str, Any] | None = None
self, method: str, params: dict[str, Any] | None = None,
) -> None:
"""Send an LSP notification (no response expected)."""
if not self._writer:
+18 -16
View File
@@ -30,13 +30,13 @@ try:
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
)
try:
from mcp.client.streamable_http import streamablehttp_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable."
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
)
@@ -166,7 +166,7 @@ class McpClient(BaseAstrbotMcpClient):
return tools
async def call_tool(
self, name: str, arguments: dict[str, Any], read_timeout_seconds: int = 60
self, name: str, arguments: dict[str, Any], read_timeout_seconds: int = 60,
) -> Any:
"""Call a tool on the MCP server with reconnection support."""
return await self.call_tool_with_reconnect(
@@ -236,7 +236,7 @@ class McpClient(BaseAstrbotMcpClient):
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
)
streams = await self.exit_stack.enter_async_context(
self._streams_context
self._streams_context,
)
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
self.session = await self.exit_stack.enter_async_context(
@@ -244,12 +244,12 @@ class McpClient(BaseAstrbotMcpClient):
*streams,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
)
),
)
else:
timeout = timedelta(seconds=cfg.get("timeout", 30))
sse_read_timeout = timedelta(
seconds=cfg.get("sse_read_timeout", 60 * 5)
seconds=cfg.get("sse_read_timeout", 60 * 5),
)
self._streams_context = streamablehttp_client(
url=cfg["url"],
@@ -259,7 +259,7 @@ class McpClient(BaseAstrbotMcpClient):
terminate_on_close=cfg.get("terminate_on_close", True),
)
read_s, write_s, _ = await self.exit_stack.enter_async_context(
self._streams_context
self._streams_context,
)
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
self.session = await self.exit_stack.enter_async_context(
@@ -268,7 +268,7 @@ class McpClient(BaseAstrbotMcpClient):
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
)
),
)
else:
cfg = _prepare_stdio_env(cfg)
@@ -295,11 +295,11 @@ class McpClient(BaseAstrbotMcpClient):
identifier=f"MCPServer-{name}",
callback=callback,
),
)
),
)
self.process_pid = self._extract_stdio_process_pid(stdio_transport)
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport)
mcp.ClientSession(*stdio_transport),
)
await self.session.initialize()
@@ -318,11 +318,12 @@ class McpClient(BaseAstrbotMcpClient):
Raises:
Exception: raised when reconnection fails
"""
async with self._reconnect_lock:
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping"
f"MCP Client {self._server_name} is already reconnecting, skipping",
)
return
if not self._mcp_server_config or not self._server_name:
@@ -330,7 +331,7 @@ class McpClient(BaseAstrbotMcpClient):
self._reconnecting = True
try:
logger.info(
f"Attempting to reconnect to MCP server {self._server_name}..."
f"Attempting to reconnect to MCP server {self._server_name}...",
)
if self.exit_stack:
self._old_exit_stacks.append(self.exit_stack)
@@ -339,18 +340,18 @@ class McpClient(BaseAstrbotMcpClient):
await self.connect_to_server(self._mcp_server_config, self._server_name)
await self.list_tools_and_save()
logger.info(
f"Successfully reconnected to MCP server {self._server_name}"
f"Successfully reconnected to MCP server {self._server_name}",
)
except Exception as e:
logger.error(
f"Failed to reconnect to MCP server {self._server_name}: {e}"
f"Failed to reconnect to MCP server {self._server_name}: {e}",
)
raise
finally:
self._reconnecting = False
async def call_tool_with_reconnect(
self, tool_name: str, arguments: dict, read_timeout_seconds: timedelta
self, tool_name: str, arguments: dict, read_timeout_seconds: timedelta,
) -> mcp.types.CallToolResult:
"""Call MCP tool with automatic reconnection on failure, max 2 retries.
@@ -365,6 +366,7 @@ class McpClient(BaseAstrbotMcpClient):
Raises:
ValueError: MCP session is not available
anyio.ClosedResourceError: raised after reconnection failure
"""
@retry(
@@ -385,7 +387,7 @@ class McpClient(BaseAstrbotMcpClient):
)
except anyio.ClosedResourceError:
logger.warning(
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect...",
)
await self._reconnect()
raise
+9 -14
View File
@@ -1,5 +1,4 @@
"""
AstrBot Orchestrator - core runtime that coordinates all protocol clients.
"""AstrBot Orchestrator - core runtime that coordinates all protocol clients.
The orchestrator manages the lifecycle of LSP, MCP, ACP, and ABP clients,
and runs the main event loop that dispatches messages between components.
@@ -23,8 +22,7 @@ log = logger
class AstrbotOrchestrator(BaseAstrbotOrchestrator):
"""
Core runtime orchestrator for AstrBot.
"""Core runtime orchestrator for AstrBot.
Manages:
- LSP client: Language Server Protocol for editor integrations
@@ -54,8 +52,7 @@ class AstrbotOrchestrator(BaseAstrbotOrchestrator):
log.debug("AstrbotOrchestrator initialized.")
async def start(self) -> None:
"""
Initialize all protocol clients.
"""Initialize all protocol clients.
Calls connect() on all protocol clients to prepare them for use.
"""
@@ -70,8 +67,7 @@ class AstrbotOrchestrator(BaseAstrbotOrchestrator):
log.info("AstrbotOrchestrator started.")
async def run_loop(self) -> None:
"""
Main orchestrator event loop.
"""Main orchestrator event loop.
This loop runs continuously, handling:
- Periodic health checks of protocol clients
@@ -109,23 +105,23 @@ class AstrbotOrchestrator(BaseAstrbotOrchestrator):
log.info("AstrbotOrchestrator run loop stopped.")
async def register_star(self, name: str, star_instance: Any) -> None:
"""
Register a star (plugin) with the orchestrator.
"""Register a star (plugin) with the orchestrator.
Args:
name: Unique name for the star
star_instance: Star plugin instance
"""
self._stars[name] = star_instance
self.abp.register_star(name, star_instance)
log.info(f"Star '{name}' registered.")
async def unregister_star(self, name: str) -> None:
"""
Unregister a star (plugin) from the orchestrator.
"""Unregister a star (plugin) from the orchestrator.
Args:
name: Name of the star to unregister
"""
self._stars.pop(name, None)
self.abp.unregister_star(name)
@@ -147,8 +143,7 @@ class AstrbotOrchestrator(BaseAstrbotOrchestrator):
self._last_activity_timestamp = time.time()
async def shutdown(self) -> None:
"""
Shutdown the orchestrator and all protocol clients.
"""Shutdown the orchestrator and all protocol clients.
"""
log.info("Shutting down AstrbotOrchestrator...")
self._running = False
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Stars (built-in plugins) for AstrBot runtime.
"""Stars (built-in plugins) for AstrBot runtime.
"""
from astrbot._internal.stars.runtime_status_star import RuntimeStatusStar
+9 -12
View File
@@ -1,5 +1,4 @@
"""
RuntimeStatusStar - ABP plugin that exposes core runtime internal state.
"""RuntimeStatusStar - ABP plugin that exposes core runtime internal state.
This star provides tools for querying:
- Runtime status (running state, uptime)
@@ -18,8 +17,7 @@ from typing import Any
@dataclass
class RuntimeStatusStar:
"""
ABP star that exposes core runtime internal state as callable tools.
"""ABP star that exposes core runtime internal state as callable tools.
Tools provided:
- get_runtime_status: Returns running state and uptime
@@ -39,8 +37,7 @@ class RuntimeStatusStar:
self._orchestrator = orchestrator
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""
Handle tool calls from ABP client.
"""Handle tool calls from ABP client.
Args:
tool_name: Name of the tool to call
@@ -48,17 +45,17 @@ class RuntimeStatusStar:
Returns:
Tool result
"""
if tool_name == "get_runtime_status":
return self._get_runtime_status()
elif tool_name == "get_protocol_status":
if tool_name == "get_protocol_status":
return await self._get_protocol_status()
elif tool_name == "get_star_registry":
if tool_name == "get_star_registry":
return await self._get_star_registry()
elif tool_name == "get_stats":
if tool_name == "get_stats":
return self._get_stats()
else:
raise ValueError(f"Unknown tool: {tool_name}")
raise ValueError(f"Unknown tool: {tool_name}")
def _get_runtime_status(self) -> dict[str, Any]:
"""Get overall runtime state."""
@@ -120,7 +117,7 @@ class RuntimeStatusStar:
last_ts = getattr(self._orchestrator, "_last_activity_timestamp", None)
if last_ts is not None:
result["last_activity"] = datetime.fromtimestamp(
last_ts, tz=timezone.utc
last_ts, tz=timezone.utc,
).isoformat()
else:
result["last_activity"] = None
+7 -8
View File
@@ -36,7 +36,7 @@ class ToolSchema:
import jsonschema
jsonschema.validate(
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA,
)
return self
@@ -75,13 +75,12 @@ class FunctionTool(ToolSchema):
async def call(self, **kwargs: Any) -> Any:
"""Run the tool with the given arguments. The handler field has priority."""
raise NotImplementedError(
"FunctionTool.call() must be implemented by subclasses or set a handler."
"FunctionTool.call() must be implemented by subclasses or set a handler.",
)
class ToolSet:
"""
A collection of FunctionTools grouped under a namespace.
"""A collection of FunctionTools grouped under a namespace.
ToolSets allow organizing related tools together. The LLM sees tools
as "namespace/tool_name" when calling.
@@ -166,7 +165,7 @@ class ToolSet:
description=tool.description,
parameters={"type": "object", "properties": {}},
handler=None,
)
),
)
return ToolSet("default", light_tools)
@@ -187,7 +186,7 @@ class ToolSet:
description="",
parameters=params,
handler=None,
)
),
)
return ToolSet("default", param_tools)
@@ -197,7 +196,7 @@ class ToolSet:
return list(self._tools.values())
def openai_schema(
self, omit_empty_parameter_field: bool = False
self, omit_empty_parameter_field: bool = False,
) -> list[dict[str, Any]]:
"""Convert tools to OpenAI API function calling schema format."""
result: list[dict[str, Any]] = []
@@ -266,7 +265,7 @@ class ToolSet:
if target_type in supported_types:
result["type"] = target_type
if "format" in schema and schema["format"] in supported_formats.get(
result["type"], set()
result["type"], set(),
):
result["format"] = schema["format"]
else:
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Builtin tools for AstrBot - re-exports from core.tools for backward compatibility.
"""Builtin tools for AstrBot - re-exports from core.tools for backward compatibility.
This module re-exports the builtin tools (cron, send_message, kb_query) from
the deprecated core.tools module for backward compatibility.
+7 -15
View File
@@ -121,30 +121,26 @@ class FunctionToolManager:
# MCP-related stub methods for base class compatibility
async def enable_mcp_server(
self, name: str, config: dict[str, Any], init_timeout: int = 30
self, name: str, config: dict[str, Any], init_timeout: int = 30,
) -> None:
"""Enable an MCP server (stub)."""
pass
async def disable_mcp_server(
self, name: str = "", timeout: int = 10, shutdown_timeout: int = 10
self, name: str = "", timeout: int = 10, shutdown_timeout: int = 10,
) -> None:
"""Disable an MCP server (stub)."""
pass
async def init_mcp_clients(self) -> None:
"""Initialize MCP clients (stub)."""
pass
async def test_mcp_server_connection(
self, config: dict[str, Any]
self, config: dict[str, Any],
) -> tuple[bool, str]:
"""Test MCP server connection (stub)."""
return False, "Not implemented"
async def sync_modelscope_mcp_servers(self, access_token: str = "") -> None:
"""Sync ModelScope MCP servers (stub)."""
pass
def load_mcp_config(self) -> dict[str, Any]:
"""Load MCP configuration (stub)."""
@@ -201,7 +197,6 @@ class FuncCall(FunctionToolManager):
async def init_mcp_clients(self) -> None:
"""Initialize MCP clients (stub implementation)."""
pass
def add_func(
self,
@@ -271,23 +266,21 @@ class FuncCall(FunctionToolManager):
self.remove(name)
def get_func_desc_openai_style(
self, omit_empty_parameter_field: bool = False
self, omit_empty_parameter_field: bool = False,
) -> list[dict[str, Any]]:
"""Get tools in OpenAI style (deprecated, use get_full_tool_set().openai_schema())."""
tool_set = self.get_full_tool_set()
return tool_set.openai_schema(omit_empty_parameter_field)
async def enable_mcp_server(
self, name: str, config: dict[str, Any], init_timeout: int = 30
self, name: str, config: dict[str, Any], init_timeout: int = 30,
) -> None:
"""Enable an MCP server (stub implementation)."""
pass
async def disable_mcp_server(
self, name: str = "", timeout: int = 10, shutdown_timeout: int = 10
self, name: str = "", timeout: int = 10, shutdown_timeout: int = 10,
) -> None:
"""Disable an MCP server (stub implementation)."""
pass
def load_mcp_config(self) -> dict[str, Any]:
"""Load MCP configuration (stub implementation)."""
@@ -298,7 +291,7 @@ class FuncCall(FunctionToolManager):
return True
async def test_mcp_server_connection(
self, config: dict[str, Any]
self, config: dict[str, Any],
) -> tuple[bool, str]:
"""Test MCP server connection (stub implementation)."""
# Import the actual test function if available
@@ -316,7 +309,6 @@ class FuncCall(FunctionToolManager):
async def sync_modelscope_mcp_servers(self, access_token: str = "") -> None:
"""Sync ModelScope MCP servers (stub implementation)."""
pass
def get_full_tool_set(self) -> ToolSet:
"""Return a ToolSet with all active tools."""
+1 -2
View File
@@ -1,5 +1,4 @@
"""
AstrBot Public API.
"""AstrBot Public API.
This package exposes the public interface for extending and integrating with
AstrBot. All exports from this module are guaranteed to be stable across
+3 -2
View File
@@ -1,5 +1,4 @@
"""
MCP (Model Context Protocol) Public API for AstrBot.
"""MCP (Model Context Protocol) Public API for AstrBot.
This module provides a simple, stable interface for MCP server management,
delegating to the _internal package.
@@ -23,6 +22,7 @@ Example:
url="http://localhost:8080/sse",
transport="sse",
)
"""
from __future__ import annotations
@@ -71,6 +71,7 @@ async def register_mcp_server(
Example - Stdio:
await register_mcp_server(name="weather", command="uv",
args=["tool", "run", "weather-mcp"])
"""
from astrbot.core.provider.register import llm_tools as func_tool_manager
+3 -2
View File
@@ -1,5 +1,4 @@
"""
Skills Public API for AstrBot.
"""Skills Public API for AstrBot.
This module provides a simple, stable interface for skill management,
delegating to the _internal package.
@@ -19,6 +18,7 @@ Example:
tool_skills = [s for s in skills if s.input_schema]
if tool_skills:
func_tool = skill_to_tool(tool_skills[0])
"""
from __future__ import annotations
@@ -45,6 +45,7 @@ def skill_to_tool(skill: SkillInfo) -> FunctionTool | None:
Returns:
A FunctionTool, or None if the skill has no input_schema
"""
if not skill.input_schema:
return None
+5 -4
View File
@@ -1,5 +1,4 @@
"""
Tools Public API for AstrBot.
"""Tools Public API for AstrBot.
This module provides a simple, stable interface for tool registration
and management. All implementations are delegated to the _internal package.
@@ -13,6 +12,7 @@ Example:
registry = get_registry()
tools = registry.list_tools()
"""
from __future__ import annotations
@@ -28,10 +28,10 @@ from astrbot._internal.tools.registry import FunctionToolManager
__all__ = [
"FunctionTool",
"ToolRegistry",
"ToolSchema",
"ToolSet",
"get_registry",
"tool",
"ToolSchema",
]
@@ -88,7 +88,7 @@ def tool(
description: str,
parameters: dict[str, Any] | None = None,
) -> Callable[
[Callable[..., Awaitable[str | None]]], Callable[..., Awaitable[str | None]]
[Callable[..., Awaitable[str | None]]], Callable[..., Awaitable[str | None]],
]:
"""Decorator to register an async function as a tool.
@@ -101,6 +101,7 @@ def tool(
@tool(name="weather", description="Get weather for a city", parameters={...})
async def get_weather(city: str) -> str:
return f"The weather in {city} is sunny"
"""
if parameters is None:
parameters = {"type": "object", "properties": {}}
@@ -32,10 +32,10 @@ class LongTermMemory:
max_cnt = 300
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_ltm_settings"].get(
"image_caption_provider_id"
"image_caption_provider_id",
)
image_caption = cfg["provider_ltm_settings"]["image_caption"] and bool(
image_caption_provider_id
image_caption_provider_id,
)
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False)
@@ -172,7 +172,7 @@ class LongTermMemory:
req.system_prompt += chats_str
async def after_req_llm(
self, event: AstrMessageEvent, llm_resp: LLMResponse
self, event: AstrMessageEvent, llm_resp: LLMResponse,
) -> None:
if event.unified_msg_origin not in self.session_chats:
return
@@ -180,7 +180,7 @@ class LongTermMemory:
if llm_resp.completion_text:
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {llm_resp.completion_text}"
logger.debug(
f"Recorded AI response: {event.unified_msg_origin} | {final_message}"
f"Recorded AI response: {event.unified_msg_origin} | {final_message}",
)
self.session_chats[event.unified_msg_origin].append(final_message)
cfg = self.cfg(event)
+2 -2
View File
@@ -86,7 +86,7 @@ class Main(star.Star):
@filter.on_llm_request()
async def decorate_llm_req(
self, event: AstrMessageEvent, req: ProviderRequest
self, event: AstrMessageEvent, req: ProviderRequest,
) -> None:
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
if self.ltm and self.ltm_enabled(event):
@@ -97,7 +97,7 @@ class Main(star.Star):
@filter.on_llm_response()
async def record_llm_resp_to_ltm(
self, event: AstrMessageEvent, resp: LLMResponse
self, event: AstrMessageEvent, resp: LLMResponse,
) -> None:
"""在 LLM 响应后记录对话"""
if self.ltm and self.ltm_enabled(event):
@@ -59,12 +59,12 @@ class ConversationCommands:
async def _get_current_persona_id(self, session_id):
curr = await self.context.conversation_manager.get_curr_conversation_id(
session_id
session_id,
)
if not curr:
return None
conv = await self.context.conversation_manager.get_conversation(
session_id, curr
session_id, curr,
)
if not conv:
return None
@@ -78,18 +78,18 @@ class ConversationCommands:
is_group = bool(message.get_group_id())
scene = RstScene.get_scene(is_group, is_unique_session)
alter_cmd_cfg = _normalize_alter_cmd_config(
await sp.get_async("global", "global", "alter_cmd", {})
await sp.get_async("global", "global", "alter_cmd", {}),
)
plugin_config = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_config.get("reset", {})
required_perm = reset_cfg.get(
scene.key, "admin" if is_group and (not is_unique_session) else "member"
scene.key, "admin" if is_group and (not is_unique_session) else "member",
)
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"{scene.name}场景下,reset命令需要管理员权限,您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。"
)
f"{scene.name}场景下,reset命令需要管理员权限,您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
),
)
return
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
@@ -104,15 +104,15 @@ class ConversationCommands:
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。")
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
if not cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 切换或者 /new 创建。"
)
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
),
)
return
active_event_registry.stop_all(umo, exclude=message)
@@ -130,13 +130,13 @@ class ConversationCommands:
stopped_count = active_event_registry.stop_all(umo, exclude=message)
else:
stopped_count = active_event_registry.request_agent_stop_all(
umo, exclude=message
umo, exclude=message,
)
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"已请求停止 {stopped_count} 个运行中的任务。"
)
f"已请求停止 {stopped_count} 个运行中的任务。",
),
)
return
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
@@ -145,7 +145,7 @@ class ConversationCommands:
"""查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。")
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
size_per_page = 6
@@ -154,10 +154,10 @@ class ConversationCommands:
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
if not session_curr_cid:
session_curr_cid = await conv_mgr.new_conversation(
umo, message.get_platform_id()
umo, message.get_platform_id(),
)
contexts, total_pages = await conv_mgr.get_human_readable_context(
umo, session_curr_cid, page, size_per_page
umo, session_curr_cid, page, size_per_page,
)
parts = []
for context in contexts:
@@ -175,14 +175,14 @@ class ConversationCommands:
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。"
)
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
),
)
return
size_per_page = 6
"获取所有对话列表"
conversations_all = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin
message.unified_msg_origin,
)
"计算总页数"
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
@@ -225,13 +225,13 @@ class ConversationCommands:
persona_name = f"{persona_name} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n",
)
global_index += 1
parts.append("---\n")
ret = "".join(parts)
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin
message.unified_msg_origin,
)
if curr_cid:
"从所有对话的标题字典中获取标题"
@@ -266,11 +266,11 @@ class ConversationCommands:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona
message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona,
)
message.set_extra("_clean_ltm_session", True)
message.set_result(
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。")
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
)
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
@@ -281,55 +281,55 @@ class ConversationCommands:
platform_name=message.platform_meta.id,
message_type=MessageType("GroupMessage"),
session_id=sid,
)
),
)
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation(
session, message.get_platform_id(), persona_id=cpersona
session, message.get_platform_id(), persona_id=cpersona,
)
message.set_result(
MessageEventResult().message(
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。"
)
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
),
)
else:
message.set_result(
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。")
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
)
async def switch_conv(
self, message: AstrMessageEvent, index: int | None = None
self, message: AstrMessageEvent, index: int | None = None,
) -> None:
"""通过 /ls 前面的序号切换对话"""
if not isinstance(index, int):
message.set_result(
MessageEventResult().message("类型错误,请输入数字对话序号。")
MessageEventResult().message("类型错误,请输入数字对话序号。"),
)
return
if index is None:
message.set_result(
MessageEventResult().message(
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话"
)
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
),
)
return
conversations = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin
message.unified_msg_origin,
)
if index > len(conversations) or index < 1:
message.set_result(
MessageEventResult().message("对话序号错误,请使用 /ls 查看")
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
)
else:
conversation = conversations[index - 1]
title = conversation.title if conversation.title else "新对话"
await self.context.conversation_manager.switch_conversation(
message.unified_msg_origin, conversation.cid
message.unified_msg_origin, conversation.cid,
)
message.set_result(
MessageEventResult().message(
f"切换到对话: {title}({conversation.cid[:4]})。"
)
f"切换到对话: {title}({conversation.cid[:4]})。",
),
)
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
@@ -338,7 +338,7 @@ class ConversationCommands:
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
return
await self.context.conversation_manager.update_conversation_title(
message.unified_msg_origin, new_name
message.unified_msg_origin, new_name,
)
message.set_result(MessageEventResult().message("重命名对话成功。"))
@@ -354,8 +354,8 @@ class ConversationCommands:
):
message.set_result(
MessageEventResult().message(
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。"
)
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
),
)
return
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
@@ -374,13 +374,13 @@ class ConversationCommands:
if not session_curr_cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。"
)
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
),
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
umo, session_curr_cid
umo, session_curr_cid,
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
message.set_extra("_clean_ltm_session", True)
@@ -23,8 +23,7 @@ class HelpCommand:
return ""
async def _build_reserved_command_lines(self) -> list[str]:
"""
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
"""使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
"""
try:
commands = await command_management.list_commands()
@@ -45,7 +45,7 @@ class PersonaCommands:
children,
all_personas,
depth + 1,
)
),
)
return lines
@@ -64,7 +64,7 @@ class _ModelCache:
return models
def set(
self, provider_id: str, umo: str | None, models: list[str], ttl: float
self, provider_id: str, umo: str | None, models: list[str], ttl: float,
) -> None:
if ttl <= 0:
return
@@ -83,7 +83,7 @@ class _ModelCache:
self._store.pop(key, None)
def invalidate(
self, provider_id: str | None = None, *, umo: str | None = None
self, provider_id: str | None = None, *, umo: str | None = None,
) -> None:
if provider_id is None:
self._store.clear()
@@ -122,7 +122,7 @@ class ProviderCommands:
register_change_hook(self._on_provider_manager_changed)
def invalidate_provider_models_cache(
self, provider_id: str | None = None, *, umo: str | None = None
self, provider_id: str | None = None, *, umo: str | None = None,
) -> None:
"""Public hook for cache invalidation on external provider config changes."""
self._model_cache.invalidate(provider_id, umo=umo)
@@ -214,14 +214,14 @@ class ProviderCommands:
for candidate in models:
cand_norm = candidate.casefold()
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
f":{requested_norm}"
f":{requested_norm}",
):
return candidate
return None
def _apply_model(
self, prov: Provider, model_name: str, *, umo: str | None = None
self, prov: Provider, model_name: str, *, umo: str | None = None,
) -> str:
prov.set_model(model_name)
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
@@ -292,7 +292,7 @@ class ProviderCommands:
)
async def _test_provider_capability(
self, provider: ListedProvider
self, provider: ListedProvider,
) -> tuple[bool, str | None, str | None]:
"""测试单个 provider 的可用性"""
meta = provider.meta()
@@ -305,7 +305,7 @@ class ProviderCommands:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
provider, provider_capability_type, err_code, err_reason,
)
return False, err_code, err_reason
@@ -357,7 +357,7 @@ class ProviderCommands:
return provider, None, err
results = await asyncio.gather(
*(fetch_models(provider) for provider in all_providers)
*(fetch_models(provider) for provider in all_providers),
)
failed_provider_errors: list[tuple[str, str]] = []
for provider, models, err in results:
@@ -425,8 +425,8 @@ class ProviderCommands:
if all_providers:
await event.send(
MessageEventResult().message(
"正在进行提供商可达性测试,请稍候..."
)
"正在进行提供商可达性测试,请稍候...",
),
)
check_results: list[ReachabilityCheckResult] = await asyncio.gather(
*[self._test_provider_capability(p) for p, _ in all_providers],
@@ -484,7 +484,7 @@ class ProviderCommands:
"info": info,
"mark": mark,
"provider": p,
}
},
)
# 分组输出
@@ -579,7 +579,7 @@ class ProviderCommands:
event.set_result(MessageEventResult().message("无效的参数。"))
async def _switch_model_by_name(
self, message: AstrMessageEvent, model_name: str, prov: Provider
self, message: AstrMessageEvent, model_name: str, prov: Provider,
) -> None:
model_name = model_name.strip()
if not model_name:
@@ -604,7 +604,7 @@ class ProviderCommands:
if matched_model_name is not None:
message.set_result(
MessageEventResult().message(
self._apply_model(prov, matched_model_name, umo=umo)
self._apply_model(prov, matched_model_name, umo=umo),
),
)
return
@@ -641,7 +641,7 @@ class ProviderCommands:
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("跨提供商切换并设置模型失败: ", e)
safe_error("跨提供商切换并设置模型失败: ", e),
),
)
@@ -676,7 +676,7 @@ class ProviderCommands:
curr_model = prov.get_model() or ""
parts.append(f"\n当前模型: [{curr_model}]")
parts.append(
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。",
)
ret = "".join(parts)
@@ -701,13 +701,13 @@ class ProviderCommands:
prov,
new_model,
umo=message.unified_msg_origin,
)
),
),
)
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换模型未知错误: ", e)
safe_error("切换模型未知错误: ", e),
),
)
return
@@ -751,7 +751,7 @@ class ProviderCommands:
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换 Key 未知错误: ", e)
safe_error("切换 Key 未知错误: ", e),
),
)
return
@@ -20,7 +20,7 @@ class SetUnsetCommands:
"""设置会话变量"""
uid = event.unified_msg_origin
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {})
await sp.session_get(uid, "session_variables", {}),
)
session_var[key] = value
await sp.session_put(uid, "session_variables", session_var)
@@ -35,7 +35,7 @@ class SetUnsetCommands:
"""移除会话变量"""
uid = event.unified_msg_origin
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {})
await sp.session_get(uid, "session_variables", {}),
)
if key not in session_var:
@@ -170,7 +170,7 @@ class Main(star.Star):
@filter.command("switch")
async def switch_conv(
self, message: AstrMessageEvent, index: int | None = None
self, message: AstrMessageEvent, index: int | None = None,
) -> None:
"""通过 /ls 前面的序号切换对话"""
await self.conversation_c.switch_conv(message, index)
@@ -11,6 +11,7 @@ class Comet(SearchEngine):
Note:
- This endpoint is often protected by anti-bot challenges.
- We intentionally treat failures as non-fatal and rely on fallback engines.
"""
NAME = "comet"
+9 -7
View File
@@ -379,7 +379,7 @@ class Main(star.Star):
"snippet": f"{result.snippet}",
# TODO: do not need ref for non-webchat platform adapter
"index": index,
}
},
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
@@ -481,8 +481,7 @@ class Main(star.Star):
exclude: str = "",
count: int = 10,
) -> str:
"""
A web search tool based on Bocha Search API, used to retrieve web pages
"""A web search tool based on Bocha Search API, used to retrieve web pages
related to the user's query.
Args:
@@ -511,14 +510,16 @@ class Main(star.Star):
include (string): Optional. Specifies the domains to include in
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
Examples:
- "qq.com"
- "qq.com|m.163.com"
exclude (string): Optional. Specifies the domains to exclude from
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
Examples:
- "qq.com"
- "qq.com|m.163.com"
@@ -527,6 +528,7 @@ class Main(star.Star):
- Default: 10
The actual number of returned results may be less than the
specified count.
"""
logger.info(f"web_searcher - search_from_bocha: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
@@ -569,7 +571,7 @@ class Main(star.Star):
"url": f"{result.url}",
"snippet": f"{result.snippet}",
"index": index,
}
},
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
@@ -592,7 +594,7 @@ class Main(star.Star):
DEFAULT_WEB_SEARCH_PROVIDER,
)
branch_provider, is_known_provider = normalize_websearch_provider_for_tools(
raw_provider
raw_provider,
)
tool_set = req.func_tool
@@ -54,7 +54,7 @@ _WEB_SEARCH_PROVIDER_ALIASES.update(
"bochaai": "bocha",
# ZeroClaw compatibility: AstrBot has no Brave provider yet, so downgrade to default.
"brave": DEFAULT_WEB_SEARCH_PROVIDER,
}
},
)
+2 -2
View File
@@ -34,7 +34,7 @@ def cli() -> None:
@click.command()
@click.argument("command_name", required=False, type=str)
@click.option(
"--all", "-a", is_flag=True, help="Show help for all commands recursively."
"--all", "-a", is_flag=True, help="Show help for all commands recursively.",
)
def help(command_name: str | None, all: bool) -> None:
"""Display help information for commands
@@ -111,7 +111,7 @@ def completion(shell: str | None) -> None:
click.echo(f"No completion support for shell: {shell}", err=True)
sys.exit(1)
comp = comp_cls(
cli, ctx_args={}, prog_name="astrbot", complete_var="_ASTRBOT_COMPLETE"
cli, ctx_args={}, prog_name="astrbot", complete_var="_ASTRBOT_COMPLETE",
)
click.echo(comp.source())
+17 -19
View File
@@ -47,13 +47,12 @@ async def _get_kb_manager():
@click.group(name="bk")
def bk():
"""Backup management (Export/Import)"""
pass
@bk.command(name="export")
@click.option("--output", "-o", help="Output directory", default=None)
@click.option(
"--gpg-sign", "-S", is_flag=True, help="Sign backup with GPG default private key"
"--gpg-sign", "-S", is_flag=True, help="Sign backup with GPG default private key",
)
@click.option(
"--gpg-encrypt",
@@ -62,7 +61,7 @@ def bk():
metavar="RECIPIENT",
)
@click.option(
"--gpg-symmetric", "-C", is_flag=True, help="Encrypt with symmetric cipher (GPG)"
"--gpg-symmetric", "-C", is_flag=True, help="Encrypt with symmetric cipher (GPG)",
)
@click.option(
"--digest",
@@ -83,7 +82,6 @@ def export_data(
and saved with a .gpg extension.
Examples:
\b
1. Standard Export:
astrbot bk export
@@ -113,8 +111,8 @@ def export_data(
5. Signed and Encrypted with Digest:
astrbot bk export -S -E "bob@example.com" -d sha256
-> Signs, encrypts for Bob, and generates a SHA256 checksum file.
"""
"""
# Handle case where -E consumes the next flag (e.g. -E -S)
if gpg_encrypt and gpg_encrypt.startswith("-"):
consumed_flag = gpg_encrypt
@@ -122,7 +120,7 @@ def export_data(
click.style(
f"Warning: Flag '{consumed_flag}' was interpreted as the recipient for -E.",
fg="yellow",
)
),
)
# Recover flags
@@ -140,7 +138,7 @@ def export_data(
if gpg_sign or gpg_encrypt or gpg_symmetric:
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Please install GnuPG to use encryption/signing features."
"GPG tool not found. Please install GnuPG to use encryption/signing features.",
)
exporter = AstrBotExporter(db_helper)
@@ -152,7 +150,7 @@ def export_data(
path_str = await exporter.export_all(output, progress_callback=on_progress)
final_path = Path(path_str)
click.echo(
click.style(f"\nRaw backup exported to: {final_path}", fg="green")
click.style(f"\nRaw backup exported to: {final_path}", fg="green"),
)
# GPG Operations
@@ -168,7 +166,7 @@ def export_data(
click.style(
"Warning: Symmetric encryption selected, ignoring asymmetric recipient.",
fg="yellow",
)
),
)
cmd.append("--symmetric")
# No --batch to allow interactive passphrase entry on TTY
@@ -196,7 +194,7 @@ def export_data(
await anyio.Path(final_path).unlink()
final_path = gpg_output
click.echo(
click.style(f"Processed backup created: {final_path}", fg="green")
click.style(f"Processed backup created: {final_path}", fg="green"),
)
# Digest Generation
@@ -211,7 +209,7 @@ def export_data(
digest_val = hash_func.hexdigest()
digest_file = final_path.with_name(final_path.name + f".{digest}")
await anyio.Path(digest_file).write_text(
f"{digest_val} *{final_path.name}\n", encoding="utf-8"
f"{digest_val} *{final_path.name}\n", encoding="utf-8",
)
click.echo(click.style(f"Digest generated: {digest_file}", fg="green"))
@@ -266,13 +264,13 @@ def import_data_command(backup_file: str, yes: bool):
if calculated_digest == expected_digest:
click.echo(
click.style("Digest verification PASSED.", fg="green")
click.style("Digest verification PASSED.", fg="green"),
)
else:
click.echo(
click.style(
"Digest verification FAILED!", fg="red", bold=True
)
"Digest verification FAILED!", fg="red", bold=True,
),
)
click.echo(f" Expected: {expected_digest}")
click.echo(f" Actual: {calculated_digest}")
@@ -286,7 +284,7 @@ def import_data_command(backup_file: str, yes: bool):
if not _verify_digest(backup_path):
if not yes:
if not click.confirm(
"Digest verification failed. Abort import?", default=True, abort=True
"Digest verification failed. Abort import?", default=True, abort=True,
):
pass
else:
@@ -294,7 +292,7 @@ def import_data_command(backup_file: str, yes: bool):
click.style(
"Warning: Digest verification failed. Continuing due to --yes.",
fg="yellow",
)
),
)
if not yes:
@@ -312,7 +310,7 @@ def import_data_command(backup_file: str, yes: bool):
if backup_path.suffix == ".gpg":
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Cannot decrypt .gpg file."
"GPG tool not found. Cannot decrypt .gpg file.",
)
# Remove .gpg extension for output
@@ -356,12 +354,12 @@ def import_data_command(backup_file: str, yes: bool):
try:
result = await importer.import_all(
str(zip_path), progress_callback=on_progress
str(zip_path), progress_callback=on_progress,
)
if result.errors:
click.echo(
click.style("\nImport failed with errors:", fg="red"), err=True
click.style("\nImport failed with errors:", fg="red"), err=True,
)
for err in result.errors:
click.echo(f" - {err}", err=True)
+12 -18
View File
@@ -1,5 +1,4 @@
"""
Configuration CLI for AstrBot.
"""Configuration CLI for AstrBot.
This module provides:
- secure hashing utilities for the dashboard password (argon2)
@@ -32,8 +31,7 @@ from astrbot.core.utils.auth_password import (
def is_dashboard_password_hash(value: str) -> bool:
"""
Heuristic: return True if `value` looks like a supported dashboard password hash.
"""Heuristic: return True if `value` looks like a supported dashboard password hash.
"""
if not isinstance(value, str) or not value:
return False
@@ -106,14 +104,13 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
def _load_config() -> dict[str, Any]:
"""
Load or initialize the CLI config file (data/cmd_config.json).
"""Load or initialize the CLI config file (data/cmd_config.json).
Ensures the astrbot root is valid before proceeding.
"""
root = astrbot_paths.root
if not astrbot_paths.is_root:
raise click.ClickException(
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize"
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
config_path = astrbot_paths.data / "cmd_config.json"
@@ -133,7 +130,7 @@ def _load_config() -> dict[str, Any]:
def _save_config(config: dict[str, Any]) -> None:
config_path = astrbot_paths.data / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig"
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig",
)
@@ -149,7 +146,7 @@ def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
cur[part] = {}
elif not isinstance(cur[part], dict):
raise click.ClickException(
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict"
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
)
cur = cur[part]
cur[parts[-1]] = value
@@ -179,7 +176,7 @@ def set_dashboard_credentials(
) -> None:
if username is not None:
_set_nested_item(
config, "dashboard.username", _validate_dashboard_username(username)
config, "dashboard.username", _validate_dashboard_username(username),
)
if password_hash is not None:
if isinstance(password_hash, str) and is_dashboard_password_hash(password_hash):
@@ -189,7 +186,7 @@ def set_dashboard_credentials(
raise click.ClickException(
"Storing legacy dashboard password hashes is no longer supported. "
"Please provide the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string."
"or provide an Argon2-encoded hash string.",
)
_set_nested_item(
config,
@@ -200,8 +197,7 @@ def set_dashboard_credentials(
@click.group(name="conf")
def conf() -> None:
"""
Configuration management commands.
"""Configuration management commands.
Supported config keys:
- timezone
@@ -211,7 +207,6 @@ def conf() -> None:
- dashboard.password
- callback_api_base
"""
pass
@conf.command(name="set")
@@ -286,7 +281,7 @@ def _check_astrbot_not_running() -> None:
except Timeout:
raise click.ClickException(
"AstrBot is currently running. "
"Please stop it first before changing the password via CLI."
"Please stop it first before changing the password via CLI.",
) from None
else:
lock.release()
@@ -301,8 +296,7 @@ def _check_astrbot_not_running() -> None:
help="Set admain password directly without interactive prompt",
)
def set_dashboard_password(username: str | None, password: str | None) -> None:
"""
Interactively set dashboard password (with confirmation) or set directly with -p.
"""Interactively set dashboard password (with confirmation) or set directly with -p.
Acceptable inputs:
- Plaintext password (recommended): it will be hashed securely before storage.
@@ -319,7 +313,7 @@ def set_dashboard_password(username: str | None, password: str | None) -> None:
raise click.ClickException(
"Providing legacy dashboard password hashes is no longer supported. "
"Please supply the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string."
"or provide an Argon2-encoded hash string.",
)
password_hash = _validate_dashboard_password(password)
else:
+9 -9
View File
@@ -42,7 +42,7 @@ async def initialize_astrbot(
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
click.echo(
f"{('Created' if not path.exists() else f'{name} Directory exists')}: {path}"
f"{('Created' if not path.exists() else f'{name} Directory exists')}: {path}",
)
config_path = astrbot_root / "data" / "cmd_config.json"
if not config_path.exists():
@@ -87,7 +87,7 @@ async def initialize_astrbot(
click.echo("No config.template found; skipping .env generation")
if admin_password is not None:
raise click.ClickException(
"--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization."
"--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization.",
)
effective_admin_username = (
admin_username.strip()
@@ -97,19 +97,19 @@ async def initialize_astrbot(
if admin_username:
config = ensure_config_file()
set_dashboard_credentials(
config, username=effective_admin_username, password_hash=None
config, username=effective_admin_username, password_hash=None,
)
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig"
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig",
)
click.echo(f"Configured dashboard admin username: {effective_admin_username}")
click.echo(
"Dashboard password is not initialized for interactive use. Run 'astrbot conf admin' before the first login."
"Dashboard password is not initialized for interactive use. Run 'astrbot conf admin' before the first login.",
)
if not backend_only and (
yes
or click.confirm(
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", default=True
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", default=True,
)
):
await DashboardManager().ensure_installed(astrbot_root)
@@ -164,19 +164,19 @@ def init(
backend_only=backend_only,
admin_username=admin_username,
admin_password=admin_password,
)
),
)
if backup:
from .cmd_bk import import_data_command
click.echo(f"Restoring from backup: {backup}")
click.get_current_context().invoke(
import_data_command, backup_file=backup, yes=True
import_data_command, backup_file=backup, yes=True,
)
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
"Cannot acquire lock file. Please check if another instance is running",
)
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}")
+4 -4
View File
@@ -22,7 +22,7 @@ def display_plugins(plugins, title=None, color=None) -> None:
click.echo(click.style(title, fg=color, bold=True))
click.echo(
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}",
)
click.echo("-" * 85)
@@ -75,7 +75,7 @@ def new(name: str) -> None:
# Rewrite README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n",
)
# Rewrite main.py
@@ -183,7 +183,7 @@ def remove(name: str) -> None:
click.echo(t("plugin_uninstall_success", name=name))
except Exception as e:
raise click.ClickException(
t("plugin_uninstall_failed_ex", name=name, error=str(e))
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
)
@@ -210,7 +210,7 @@ def update(name: str, proxy: str | None) -> None:
if not plugin:
raise click.ClickException(
f"Plugin {name} does not need updating or cannot be updated"
f"Plugin {name} does not need updating or cannot be updated",
)
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
+5 -5
View File
@@ -66,7 +66,7 @@ _PARAM_EXPAND_RE = re.compile(r"\$\{([^}:]+?)(:-([^}]*))?\}")
def _expand_parameter(
match: re.Match, env: dict[str, str], local: dict[str, str]
match: re.Match, env: dict[str, str], local: dict[str, str],
) -> str:
"""Helper to expand a single ${VAR:-default} or ${VAR} occurrence.
@@ -302,7 +302,7 @@ def run(
click.echo(f" {click.style(key, fg='cyan')}: {val}")
if svc_path:
click.echo(
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}"
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}",
)
click.echo("")
@@ -338,7 +338,7 @@ def run(
while True:
try:
log_entry = await asyncio.wait_for(
log_queue.get(), timeout=0.5
log_queue.get(), timeout=0.5,
)
# Format: [LEVEL] message
level = log_entry.get("level_name", "INFO")
@@ -385,10 +385,10 @@ def run(
click.echo("AstrBot has been shut down.")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
"Cannot acquire lock file. Please check if another instance is running",
) from None
except Exception as e:
# Keep original traceback visible for diagnostics
raise click.ClickException(
f"Runtime error: {e}\n{traceback.format_exc()}"
f"Runtime error: {e}\n{traceback.format_exc()}",
) from e
+1 -2
View File
@@ -10,11 +10,10 @@ from astrbot.core.utils.astrbot_path import astrbot_paths
@click.command()
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
@click.option(
"--keep-data", is_flag=True, help="Keep data directory (config, plugins, etc.)"
"--keep-data", is_flag=True, help="Keep data directory (config, plugins, etc.)",
)
def uninstall(yes: bool, keep_data: bool) -> None:
"""Remove AstrBot files from the current root directory."""
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
yes = True
+1
View File
@@ -245,6 +245,7 @@ def t(translation_key: str, **kwargs: str) -> str:
Returns:
Translated string, or the key itself if not found
"""
result = _t_cached(translation_key, get_current_language())
if kwargs:
+2 -2
View File
@@ -226,7 +226,7 @@ def manage_plugin(
# Check if plugin exists
if is_update and not target_path.exists():
raise click.ClickException(
f"Plugin {plugin_name} is not installed and cannot be updated"
f"Plugin {plugin_name} is not installed and cannot be updated",
)
# Backup existing plugin
@@ -245,7 +245,7 @@ def manage_plugin(
if is_update and backup_path is not None and backup_path.exists():
shutil.rmtree(backup_path)
click.echo(
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully",
)
except Exception as e:
if target_path.exists():
+1 -6
View File
@@ -62,12 +62,7 @@ class VersionComparator:
return -1
if isinstance(p1, str) and isinstance(p2, int):
return 1
if isinstance(p1, int) and isinstance(p2, int):
if p1 > p2:
return 1
if p1 < p2:
return -1
elif isinstance(p1, str) and isinstance(p2, str):
if (isinstance(p1, int) and isinstance(p2, int)) or (isinstance(p1, str) and isinstance(p2, str)):
if p1 > p2:
return 1
if p1 < p2:
+1 -1
View File
@@ -53,7 +53,7 @@ t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(
logger, astrbot_config, override_level=os.getenv("ASTRBOT_LOG_LEVEL")
logger, astrbot_config, override_level=os.getenv("ASTRBOT_LOG_LEVEL"),
)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH)
+16 -10
View File
@@ -20,13 +20,12 @@ from astrbot.core.agent.context.truncator import ContextTruncator
@runtime_checkable
class ContextCompressor(Protocol):
"""
Protocol for context compressors.
"""Protocol for context compressors.
Provides an interface for compressing message lists.
"""
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
self, messages: list[Message], current_tokens: int, max_tokens: int,
) -> bool:
"""Check if compression is needed.
@@ -37,6 +36,7 @@ class ContextCompressor(Protocol):
Returns:
True if compression is needed, False otherwise.
"""
...
@@ -48,6 +48,7 @@ class ContextCompressor(Protocol):
Returns:
The compressed message list.
"""
...
@@ -58,19 +59,20 @@ class TruncateByTurnsCompressor:
"""
def __init__(
self, truncate_turns: int = 1, compression_threshold: float = 0.82
self, truncate_turns: int = 1, compression_threshold: float = 0.82,
) -> None:
"""Initialize the truncate by turns compressor.
Args:
truncate_turns: The number of turns to remove when truncating (default: 1).
compression_threshold: The compression trigger threshold (default: 0.82).
"""
self.truncate_turns = truncate_turns
self.compression_threshold = compression_threshold
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
self, messages: list[Message], current_tokens: int, max_tokens: int,
) -> bool:
"""Check if compression is needed.
@@ -81,6 +83,7 @@ class TruncateByTurnsCompressor:
Returns:
True if compression is needed, False otherwise.
"""
if max_tokens <= 0 or current_tokens <= 0:
return False
@@ -97,7 +100,7 @@ class TruncateByTurnsCompressor:
def split_history(
messages: list[Message], keep_recent: int
messages: list[Message], keep_recent: int,
) -> tuple[list[Message], list[Message], list[Message]]:
"""Split the message list into system messages, messages to summarize, and recent messages.
@@ -109,6 +112,7 @@ def split_history(
Returns:
tuple: (system_messages, messages_to_summarize, recent_messages)
"""
# keep the system messages
first_non_system = 0
@@ -161,6 +165,7 @@ class LLMSummaryCompressor:
keep_recent: The number of latest messages to keep (default: 4).
instruction_text: Custom instruction for summary generation.
compression_threshold: The compression trigger threshold (default: 0.82).
"""
self.provider = provider
self.keep_recent = keep_recent
@@ -175,7 +180,7 @@ class LLMSummaryCompressor:
)
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
self, messages: list[Message], current_tokens: int, max_tokens: int,
) -> bool:
"""Check if compression is needed.
@@ -186,6 +191,7 @@ class LLMSummaryCompressor:
Returns:
True if compression is needed, False otherwise.
"""
if max_tokens <= 0 or current_tokens <= 0:
return False
@@ -204,7 +210,7 @@ class LLMSummaryCompressor:
return messages
system_messages, messages_to_summarize, recent_messages = split_history(
messages, self.keep_recent
messages, self.keep_recent,
)
if not messages_to_summarize:
@@ -230,13 +236,13 @@ class LLMSummaryCompressor:
Message(
role="user",
content=f"Our previous history conversation summary: {summary_content}",
)
),
)
result.append(
Message(
role="assistant",
content="Acknowledged the summary of our previous conversation history.",
)
),
)
result.extend(recent_messages)
+11 -9
View File
@@ -22,6 +22,7 @@ class ContextManager:
Args:
config: The context configuration.
"""
self.config = config
@@ -38,11 +39,11 @@ class ContextManager:
)
else:
self.compressor = TruncateByTurnsCompressor(
truncate_turns=config.truncate_turns
truncate_turns=config.truncate_turns,
)
async def process(
self, messages: list[Message], trusted_token_usage: int = 0
self, messages: list[Message], trusted_token_usage: int = 0,
) -> list[Message]:
"""Process the messages.
@@ -51,6 +52,7 @@ class ContextManager:
Returns:
The processed message list.
"""
try:
result = messages
@@ -66,11 +68,11 @@ class ContextManager:
# 2. 基于 token 的压缩
if self.config.max_context_tokens > 0:
total_tokens = self.token_counter.count_tokens(
result, trusted_token_usage
result, trusted_token_usage,
)
if self.compressor.should_compress(
result, total_tokens, self.config.max_context_tokens
result, total_tokens, self.config.max_context_tokens,
):
result = await self._run_compression(result, total_tokens)
@@ -80,10 +82,9 @@ class ContextManager:
return messages
async def _run_compression(
self, messages: list[Message], prev_tokens: int
self, messages: list[Message], prev_tokens: int,
) -> list[Message]:
"""
Compress/truncate the messages.
"""Compress/truncate the messages.
Args:
messages: The original message list.
@@ -91,6 +92,7 @@ class ContextManager:
Returns:
The compressed/truncated message list.
"""
logger.debug("Compress triggered, starting compression...")
@@ -109,10 +111,10 @@ class ContextManager:
# last check
if self.compressor.should_compress(
messages, tokens_after_summary, self.config.max_context_tokens
messages, tokens_after_summary, self.config.max_context_tokens,
):
logger.info(
"Context still exceeds max tokens after compression, applying halving truncation..."
"Context still exceeds max tokens after compression, applying halving truncation...",
)
# still need compress, truncate by half
messages = self.truncator.truncate_by_halving(messages)
+4 -4
View File
@@ -12,13 +12,12 @@ from astrbot.core.agent.message import (
@runtime_checkable
class TokenCounter(Protocol):
"""
Protocol for token counters.
"""Protocol for token counters.
Provides an interface for counting tokens in message lists.
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
self, messages: list[Message], trusted_token_usage: int = 0,
) -> int:
"""Count the total tokens in the message list.
@@ -30,6 +29,7 @@ class TokenCounter(Protocol):
Returns:
The total token count.
"""
...
@@ -50,7 +50,7 @@ class EstimateTokenCounter:
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
self, messages: list[Message], trusted_token_usage: int = 0,
) -> int:
if trusted_token_usage > 0:
return trusted_token_usage
+7 -6
View File
@@ -20,6 +20,7 @@ class ContextTruncator:
Returns:
tuple: (system_messages, non_system_messages)
"""
first_non_system = 0
for i, msg in enumerate(messages):
@@ -51,7 +52,7 @@ class ContextTruncator:
# If a user message exists inside the truncated list, promote it to the front.
index_in_truncated = next(
(i for i, m in enumerate(truncated) if m.role == "user"), None
(i for i, m in enumerate(truncated) if m.role == "user"), None,
)
if index_in_truncated is not None:
# Build a new truncated list that places the found user message first,
@@ -127,8 +128,7 @@ class ContextTruncator:
keep_most_recent_turns: int,
drop_turns: int = 1,
) -> list[Message]:
"""
Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
"""Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
A turn consists of a user message and an assistant message.
This method ensures that the truncated context list conforms to OpenAI's context format.
@@ -139,6 +139,7 @@ class ContextTruncator:
Returns:
The truncated list of messages.
"""
if keep_most_recent_turns == -1:
return messages
@@ -163,7 +164,7 @@ class ContextTruncator:
truncated_contexts = truncated_contexts[index:]
result = self._ensure_user_message(
system_messages, truncated_contexts, messages
system_messages, truncated_contexts, messages,
)
return self.fix_messages(result)
@@ -192,7 +193,7 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages, truncated_non_system, messages
system_messages, truncated_non_system, messages,
)
return self.fix_messages(result)
@@ -221,6 +222,6 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages, truncated_non_system, messages
system_messages, truncated_non_system, messages,
)
return self.fix_messages(result)
+15 -14
View File
@@ -1,5 +1,4 @@
"""
MCP client - DEPRECATED
"""MCP client - DEPRECATED
.. deprecated::
This module has been moved to :mod:`astrbot._internal.mcp`.
@@ -51,7 +50,7 @@ try:
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
)
try:
@@ -277,7 +276,7 @@ class MCPClient:
write_stream=write_stream,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
)
),
)
self.session = session
else:
@@ -304,7 +303,7 @@ class MCPClient:
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
)
),
)
self.session = session
@@ -333,10 +332,10 @@ class MCPClient:
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
)
),
)
errlog_stream: TextIO = self.exit_stack.enter_context(
os.fdopen(os.dup(log_pipe.fileno()), "w")
os.fdopen(os.dup(log_pipe.fileno()), "w"),
)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
@@ -370,12 +369,13 @@ class MCPClient:
Raises:
Exception: raised when reconnection fails
"""
async with self._reconnect_lock:
# Check if already reconnecting (useful for logging)
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping"
f"MCP Client {self._server_name} is already reconnecting, skipping",
)
return
@@ -385,7 +385,7 @@ class MCPClient:
self._reconnecting = True
try:
logger.info(
f"Attempting to reconnect to MCP server {self._server_name}..."
f"Attempting to reconnect to MCP server {self._server_name}...",
)
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
@@ -403,11 +403,11 @@ class MCPClient:
await self.list_tools_and_save()
logger.info(
f"Successfully reconnected to MCP server {self._server_name}"
f"Successfully reconnected to MCP server {self._server_name}",
)
except Exception as e:
logger.error(
f"Failed to reconnect to MCP server {self._server_name}: {e}"
f"Failed to reconnect to MCP server {self._server_name}: {e}",
)
raise
finally:
@@ -432,6 +432,7 @@ class MCPClient:
Raises:
ValueError: MCP session is not available
anyio.ClosedResourceError: raised after reconnection failure
"""
@retry(
@@ -453,7 +454,7 @@ class MCPClient:
)
except anyio.ClosedResourceError:
logger.warning(
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect...",
)
# Attempt to reconnect
await self._reconnect()
@@ -484,7 +485,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""
def __init__(
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs,
) -> None:
super().__init__(
name=mcp_tool.name,
@@ -497,7 +498,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
self.source = "mcp"
async def call(
self, context: ContextWrapper[TContext], **kwargs
self, context: ContextWrapper[TContext], **kwargs,
) -> mcp.types.CallToolResult:
return await self.mcp_client.call_tool_with_reconnect(
tool_name=self.mcp_tool.name,
+7 -12
View File
@@ -38,7 +38,7 @@ class ContentPart(BaseModel):
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: object, handler: GetCoreSchemaHandler
cls, source_type: object, handler: GetCoreSchemaHandler,
) -> core_schema.CoreSchema:
# If we're dealing with the base ContentPart class, use custom validation
if cls.__name__ == "ContentPart":
@@ -65,8 +65,7 @@ class ContentPart(BaseModel):
class TextPart(ContentPart):
"""
>>> TextPart(text="Hello, world!").model_dump()
""">>> TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
@@ -75,8 +74,7 @@ class TextPart(ContentPart):
class ThinkPart(ContentPart):
"""
>>> ThinkPart(think="I think I need to think about this.").model_dump()
""">>> ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
@@ -97,8 +95,7 @@ class ThinkPart(ContentPart):
class ImageURLPart(ContentPart):
"""
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
""">>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
@@ -113,8 +110,7 @@ class ImageURLPart(ContentPart):
class AudioURLPart(ContentPart):
"""
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
""">>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
@@ -129,8 +125,7 @@ class AudioURLPart(ContentPart):
class ToolCall(BaseModel):
"""
A tool call requested by the assistant.
"""A tool call requested by the assistant.
>>> ToolCall(
... id="123",
@@ -200,7 +195,7 @@ class Message(BaseModel):
# other all cases: content is required
if self.content is None:
raise ValueError(
"content is required unless role='assistant' and tool_calls is not None"
"content is required unless role='assistant' and tool_calls is not None",
)
return self
@@ -86,8 +86,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Coze Agent 的一个步骤
"""执行 Coze Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -109,12 +108,12 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"Coze 请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Coze 请求失败:{e!s}"
role="err", completion_text=f"Coze 请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Coze 请求失败:{e!s}")
chain=MessageChain().message(f"Coze 请求失败:{e!s}"),
),
)
finally:
@@ -179,7 +178,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
if url:
file_id = (
await self._download_and_upload_image(
url, session_id
url, session_id,
)
)
processed_content.append(
@@ -187,7 +186,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"type": "file",
"file_id": file_id,
"file_url": url,
}
},
)
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
@@ -199,7 +198,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
}
},
)
else:
# 纯文本内容
@@ -208,7 +207,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": content,
"content_type": "text",
}
},
)
# 构建当前消息
@@ -228,7 +227,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
{
"type": "image",
"file_id": file_id,
}
},
)
except Exception as e:
logger.warning(f"处理图片失败 {url}: {e}")
@@ -241,7 +240,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": "user",
"content": content,
"content_type": "object_string",
}
},
)
elif prompt:
# 纯文本
@@ -295,7 +294,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(content)
chain=MessageChain().message(content),
),
)
@@ -98,8 +98,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Dashscope Agent 的一个步骤
"""执行 Dashscope Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -121,12 +120,12 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"阿里云百炼请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"阿里云百炼请求失败:{e!s}"
role="err", completion_text=f"阿里云百炼请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"阿里云百炼请求失败:{e!s}")
chain=MessageChain().message(f"阿里云百炼请求失败:{e!s}"),
),
)
@@ -137,7 +136,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
yield resp
def _consume_sync_generator(
self, response: Any, response_queue: queue.Queue
self, response: Any, response_queue: queue.Queue,
) -> None:
"""在线程中消费同步generator,将结果放入队列
@@ -158,7 +157,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
response_queue.put(("done", None))
async def _process_stream_chunk(
self, chunk: ApplicationResponse, output_text: str
self, chunk: ApplicationResponse, output_text: str,
) -> tuple[str, list | None, AgentResponse | None]:
"""处理流式响应的单个chunk
@@ -234,7 +233,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
return f"\n\n回答来源:\n{ref_str}"
async def _build_request_payload(
self, prompt: str, session_id: str, contexts: list, system_prompt: str
self, prompt: str, session_id: str, contexts: list, system_prompt: str,
) -> dict:
"""构建请求payload
@@ -282,22 +281,21 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
if conversation_id:
p["session_id"] = conversation_id
return p
else:
# 不支持多轮对话的
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
return payload
# 不支持多轮对话的
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
return payload
async def _handle_streaming_response(
self, response: Any, session_id: str
self, response: Any, session_id: str,
) -> AsyncGenerator[AgentResponse, None]:
"""处理流式响应
@@ -322,7 +320,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
while True:
try:
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
None, response_queue.get, True, 1
None, response_queue.get, True, 1,
)
except queue.Empty:
continue
@@ -332,7 +330,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
elif item_type == "error":
if not isinstance(item_data, BaseException):
raise RuntimeError(
f"Unexpected Dashscope error payload: {item_data!r}"
f"Unexpected Dashscope error payload: {item_data!r}",
)
raise item_data
elif item_type == "data":
@@ -404,7 +402,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
# 构建请求payload
payload = await self._build_request_payload(
prompt, session_id, contexts, system_prompt
prompt, session_id, contexts, system_prompt,
)
if not self.streaming:
@@ -134,7 +134,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
async def _finish_with_result(
self, chain: MessageChain, role: str
self, chain: MessageChain, role: str,
) -> AgentResponse:
self.final_llm_resp = LLMResponse(
role=role,
@@ -251,7 +251,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
await old_client.close()
except Exception as e:
logger.warning(
f"Failed to close previous DeerFlow API client cleanly: {e}"
f"Failed to close previous DeerFlow API client cleanly: {e}",
)
self.api_client = DeerFlowAPIClient(
@@ -331,7 +331,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
if not self.done():
raise RuntimeError(
f"DeerFlow agent reached max_step ({max_step}) without completion."
f"DeerFlow agent reached max_step ({max_step}) without completion.",
)
def _extract_new_messages_from_values(
@@ -396,7 +396,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
thread_id = thread.get("thread_id", "")
if not thread_id:
raise Exception(
f"DeerFlow create thread returned invalid payload: {thread}"
f"DeerFlow create thread returned invalid payload: {thread}",
)
await sp.put_async(
@@ -482,7 +482,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(delta)),
)
),
]
if delta_text:
@@ -492,9 +492,9 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(delta_text)
chain=MessageChain().message(delta_text),
),
)
),
]
return []
@@ -546,7 +546,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
self._update_text_and_maybe_stream(
state=state,
new_full_text=latest_text or None,
)
),
)
return responses
@@ -563,7 +563,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
self._update_text_and_maybe_stream(
state=state,
delta_text=delta,
)
),
)
maybe_clarification = extract_clarification_from_event_data(data)
@@ -170,7 +170,7 @@ class DeerFlowAPIClient:
input_payload = payload.get("input")
message_count = 0
if isinstance(input_payload, dict) and isinstance(
input_payload.get("messages"), list
input_payload.get("messages"), list,
):
message_count = len(input_payload["messages"])
# Log only a minimal summary to avoid exposing sensitive user content.
@@ -239,7 +239,7 @@ class DeerFlowAPIClient:
return
logger.warning(
"DeerFlowAPIClient garbage collected with unclosed session; "
"explicit close() should be called by runner lifecycle (or `async with`)."
"explicit close() should be called by runner lifecycle (or `async with`).",
)
@property
@@ -58,7 +58,7 @@ def build_user_content(prompt: str, image_urls: list[str]) -> Any:
if not is_likely_base64_image(url):
skipped_invalid_images += 1
logger.debug(
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64."
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64.",
)
continue
compact_base64 = url.replace("\n", "").replace("\r", "")
@@ -164,14 +164,14 @@ def append_components_from_content(
if "content" in content:
append_components_from_content(
content.get("content"), components, image_resolver
content.get("content"), components, image_resolver,
)
return
kwargs = content.get("kwargs")
if isinstance(kwargs, dict) and "content" in kwargs:
append_components_from_content(
kwargs.get("content"), components, image_resolver
kwargs.get("content"), components, image_resolver,
)
@@ -78,8 +78,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Dify Agent 的一个步骤
"""执行 Dify Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -101,12 +100,12 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"Dify 请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Dify 请求失败:{e!s}"
role="err", completion_text=f"Dify 请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Dify 请求失败:{e!s}")
chain=MessageChain().message(f"Dify 请求失败:{e!s}"),
),
)
finally:
@@ -148,7 +147,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。",
)
continue
files_payload.append(
@@ -156,7 +155,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
},
)
except Exception as e:
logger.warning(f"上传图片失败:{e}")
@@ -210,7 +209,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(chunk["answer"])
chain=MessageChain().message(chunk["answer"]),
),
)
elif chunk["event"] == "message_end":
@@ -219,7 +218,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
elif chunk["event"] == "error":
logger.error(f"Dify 出现错误:{chunk}")
raise Exception(
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}",
)
case "workflow":
@@ -237,11 +236,11 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
match chunk["event"]:
case "workflow_started":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。"
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。",
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。"
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。",
)
case "text_chunk":
if self.streaming and chunk["data"]["text"]:
@@ -249,25 +248,25 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(
chunk["data"]["text"]
)
chunk["data"]["text"],
),
),
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束",
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}"
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
raise Exception(
f"Dify 工作流出现错误:{chunk['data']['error']}"
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
if self.workflow_output_key not in chunk["data"]["outputs"]:
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}",
)
result = chunk
case _:
@@ -123,8 +123,10 @@ class DifyAPIClient:
file_path: The path to the file to upload.
file_data: The file data in bytes.
file_name: Optional file name when using file_data.
Returns:
A dictionary containing the uploaded file information.
"""
url = f"{self.api_base}/files/upload"
@@ -72,7 +72,7 @@ class _HandleFunctionToolsResult:
@classmethod
def from_tool_call_result_blocks(
cls, blocks: list[ToolCallMessageSegment]
cls, blocks: list[ToolCallMessageSegment],
) -> "_HandleFunctionToolsResult":
return cls(kind="tool_call_result_blocks", tool_call_result_blocks=blocks)
@@ -276,7 +276,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.stats.start_time = time.time()
async def _iter_llm_responses(
self, *, include_model: bool = True
self, *, include_model: bool = True,
) -> AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload: dict[str, Any] = {
@@ -374,7 +374,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
await asyncio.sleep(wait_time)
try:
async for resp in self._iter_llm_responses(
include_model=idx == 0
include_model=idx == 0,
):
if resp.is_chunk:
has_stream_output = True
@@ -527,7 +527,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
self._simple_print_message_role("[BefCompact]")
self.run_context.messages = await self.context_manager.process(
self.run_context.messages, trusted_token_usage=token_usage
self.run_context.messages, trusted_token_usage=token_usage,
)
self._simple_print_message_role("[AftCompact]")
@@ -620,13 +620,13 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning(
"LLM returned empty assistant message with no tool calls."
"LLM returned empty assistant message with no tool calls.",
)
self.run_context.messages.append(Message(role="assistant", content=parts))
@@ -691,7 +691,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
@@ -706,7 +706,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
# record the assistant message with tool calls
self.run_context.messages.extend(
tool_calls_result.to_openai_messages_model()
tool_calls_result.to_openai_messages_model(),
)
# If there are cached images and the model supports image input,
@@ -719,29 +719,29 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
image_parts = []
for cached_img in cached_images:
img_data = tool_image_cache.get_image_base64_by_path(
cached_img.file_path, cached_img.mime_type
cached_img.file_path, cached_img.mime_type,
)
if img_data:
base64_data, mime_type = img_data
image_parts.append(
TextPart(
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
)
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']",
),
)
image_parts.append(
ImageURLPart(
image_url=ImageURLPart.ImageURL(
url=f"data:{mime_type};base64,{base64_data}",
id=cached_img.file_path,
)
)
),
),
)
if image_parts:
self.run_context.messages.append(
Message(role="user", content=image_parts)
Message(role="user", content=image_parts),
)
logger.debug(
f"Appended {len(cached_images)} cached image(s) to context for LLM review"
f"Appended {len(cached_images)} cached image(s) to context for LLM review",
)
self.req.append_tool_calls_result(tool_calls_result)
@@ -758,7 +758,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
f"Agent reached max steps ({max_step}), forcing a final response.",
)
# 拔掉所有工具
if self.req:
@@ -768,7 +768,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
Message(
role="user",
content=self.MAX_STEPS_REACHED_PROMPT,
)
),
)
# 再执行最后一步
async for resp in self.step():
@@ -835,10 +835,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
},
),
],
)
),
)
try:
if not req.func_tool:
@@ -936,11 +936,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
f"with type='image' and path='{cached_img.file_path}'.",
)
# Yield image info for LLM visibility (will be handled in step())
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
cached_img,
)
elif isinstance(content_item, EmbeddedResource):
resource = content_item.resource
@@ -962,22 +962,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
f"with type='image' and path='{cached_img.file_path}'.",
)
# Yield image info for LLM visibility
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
cached_img,
)
else:
result_parts.append(
"The tool has returned a data type that is not supported."
"The tool has returned a data type that is not supported.",
)
if result_parts:
_append_tool_call_result(
func_tool_id,
"\n\n".join(result_parts)
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
func_tool_name, tool_call_streak,
),
)
@@ -986,7 +986,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 这里我们将直接结束 Agent Loop
# 发送消息逻辑在 ToolExecutor 中处理了
logger.warning(
f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。"
f"{func_tool_name} 没有返回值,或者已将结果直接发送给用户。",
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
@@ -994,7 +994,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
"The tool has no return value, or has sent the result directly to the user."
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
func_tool_name, tool_call_streak,
),
)
else:
@@ -1006,7 +1006,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
func_tool_name, tool_call_streak,
),
)
@@ -1027,7 +1027,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
func_tool_id,
f"error: {e!s}"
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
func_tool_name, tool_call_streak,
),
)
@@ -1043,21 +1043,21 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
},
),
],
)
),
)
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:
yield _HandleFunctionToolsResult.from_tool_call_result_blocks(
tool_call_result_blocks
tool_call_result_blocks,
)
def _build_tool_requery_context(
self, tool_names: list[str], extra_instruction: str | None = None
self, tool_names: list[str], extra_instruction: str | None = None,
) -> list[dict[str, Any]]:
"""Build contexts for re-querying LLM with param-only tool schemas."""
contexts: list[dict[str, Any]] = []
@@ -1067,7 +1067,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
elif isinstance(msg, dict):
contexts.append(copy.deepcopy(msg))
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
tool_names=", ".join(tool_names)
tool_names=", ".join(tool_names),
)
if extra_instruction:
instruction = f"{instruction}\n{extra_instruction}"
@@ -1105,7 +1105,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if isinstance(self._tool_schema_param_set, ToolSet):
param_subset = self._build_tool_subset(
self._tool_schema_param_set, tool_names
self._tool_schema_param_set, tool_names,
)
if param_subset.tools and tool_names:
contexts = self._build_tool_requery_context(tool_names)
@@ -1128,7 +1128,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
and not self._has_meaningful_assistant_reply(llm_resp)
):
logger.warning(
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction.",
)
repair_contexts = self._build_tool_requery_context(
tool_names,
@@ -1187,7 +1187,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
),
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
@@ -1220,7 +1220,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if self._is_stop_requested():
await self._close_executor(executor)
raise _ToolExecutionInterrupted(
"Tool execution interrupted before reading the next tool result."
"Tool execution interrupted before reading the next tool result.",
)
async def _get_next():
@@ -1247,7 +1247,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
await self._close_executor(executor)
raise _ToolExecutionInterrupted(
"Tool execution interrupted by a stop request."
"Tool execution interrupted by a stop request.",
)
try:
+5 -5
View File
@@ -44,7 +44,7 @@ class ToolSchema:
def validate_parameters(self) -> "ToolSchema":
if self.parameters is not None:
jsonschema.validate(
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA,
)
return self
@@ -106,11 +106,11 @@ class FunctionTool(ToolSchema, Generic[TContext]):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
async def call(
self, context: ContextWrapper[TContext], **kwargs: Any
self, context: ContextWrapper[TContext], **kwargs: Any,
) -> ToolExecResult:
"""Run the tool with the given arguments. The handler field has priority."""
raise NotImplementedError(
"FunctionTool.call() must be implemented by subclasses or set a handler."
"FunctionTool.call() must be implemented by subclasses or set a handler.",
)
@@ -184,7 +184,7 @@ class ToolSet:
description=tool.description,
parameters=light_params,
handler=None,
)
),
)
return ToolSet(light_tools)
@@ -205,7 +205,7 @@ class ToolSet:
description="",
parameters=params,
handler=None,
)
),
)
return ToolSet(param_tools)
+4 -1
View File
@@ -90,6 +90,7 @@ class ToolImageCache:
Returns:
CachedImage object with file path.
"""
ext = self._get_file_extension(mime_type)
file_name = f"{tool_call_id}_{index}{ext}"
@@ -113,7 +114,7 @@ class ToolImageCache:
)
def get_image_base64_by_path(
self, file_path: str, mime_type: str = "image/png"
self, file_path: str, mime_type: str = "image/png",
) -> tuple[str, str] | None:
"""Read an image file and return its base64 encoded data.
@@ -123,6 +124,7 @@ class ToolImageCache:
Returns:
Tuple of (base64_data, mime_type) if found, None otherwise.
"""
if not os.path.exists(file_path):
return None
@@ -141,6 +143,7 @@ class ToolImageCache:
Returns:
Number of images cleaned up.
"""
now = time.time()
cleaned = 0
+5 -8
View File
@@ -1,5 +1,4 @@
"""
ToolSessionManager - Session-level state management for stateful tools.
"""ToolSessionManager - Session-level state management for stateful tools.
Provides per-(UMO, tool_name) session state that persists across conversation
turns within the same session, with optional persistence via SharedPreferences.
@@ -14,8 +13,7 @@ from astrbot.core.utils.shared_preferences import SharedPreferences
@dataclass
class ToolSessionState(MutableMapping[str, Any]):
"""
Represents the session state for a single tool within a session.
"""Represents the session state for a single tool within a session.
Acts like a dict but supports persistence markers.
Use `set_persistent(key)` to mark keys that survive session clear.
@@ -51,8 +49,7 @@ class ToolSessionState(MutableMapping[str, Any]):
class ToolSessionManager:
"""
Central manager for all tool session states.
"""Central manager for all tool session states.
Maintains in-memory state per (umo, tool_name) combination.
Optional SharedPreferences integration for persistence across sessions.
@@ -62,6 +59,7 @@ class ToolSessionManager:
state = mgr.get_state(umo, "shell")
state["cwd"] = "/tmp"
state.set_persistent("env") # env survives session clear
"""
def __init__(self, sp: SharedPreferences | None = None) -> None:
@@ -104,8 +102,7 @@ class ToolSessionManager:
state.set_persistent(actual_key)
def clear_session(self, umo: str) -> None:
"""
Clear non-persistent state for all tools in a session.
"""Clear non-persistent state for all tools in a session.
Persistent keys (marked via `set_persistent`) are preserved.
"""
+4 -4
View File
@@ -34,7 +34,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
if llm_response and llm_response.reasoning_content:
# we will use this in result_decorate stage to inject reasoning content to chain
run_context.context.event.set_extra(
"_llm_reasoning_content", llm_response.reasoning_content
"_llm_reasoning_content", llm_response.reasoning_content,
)
await call_event_hook(
@@ -43,7 +43,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
llm_response,
)
sdk_plugin_bridge = getattr(
run_context.context.context, "sdk_plugin_bridge", None
run_context.context.context, "sdk_plugin_bridge", None,
)
if sdk_plugin_bridge is not None:
try:
@@ -80,7 +80,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_args,
)
sdk_plugin_bridge = getattr(
run_context.context.context, "sdk_plugin_bridge", None
run_context.context.context, "sdk_plugin_bridge", None,
)
if sdk_plugin_bridge is not None:
try:
@@ -113,7 +113,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_result,
)
sdk_plugin_bridge = getattr(
run_context.context.context, "sdk_plugin_bridge", None
run_context.context.context, "sdk_plugin_bridge", None,
)
if sdk_plugin_bridge is not None:
try:
+21 -21
View File
@@ -49,7 +49,7 @@ def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
def _record_tool_call_name(
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
tool_info: dict | None, tool_name_by_call_id: dict[str, str],
) -> None:
if not isinstance(tool_info, dict):
return
@@ -67,7 +67,7 @@ def _build_tool_call_status_message(tool_info: dict | None) -> str:
def _build_tool_result_status_message(
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str],
) -> str:
tool_name = "unknown"
tool_result = ""
@@ -120,7 +120,7 @@ async def run_agent(
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
f"Agent reached max steps ({max_step}), forcing a final response.",
)
if not agent_runner.done():
# 拔掉所有工具
@@ -131,7 +131,7 @@ async def run_agent(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
),
)
stop_watcher = asyncio.create_task(
@@ -162,7 +162,7 @@ async def run_agent(
astr_event.trace.record(
"agent_tool_result",
tool_result=msg_chain.get_plain_text(
with_other_comps_mark=True
with_other_comps_mark=True,
),
)
@@ -174,10 +174,10 @@ async def run_agent(
await astr_event.send(msg_chain)
elif show_tool_use and show_tool_call_result:
status_msg = _build_tool_result_status_message(
msg_chain, tool_name_by_call_id
msg_chain, tool_name_by_call_id,
)
await astr_event.send(
MessageChain(type="tool_call").message(status_msg)
MessageChain(type="tool_call").message(status_msg),
)
# 对于其他情况,暂时先不处理
continue
@@ -205,7 +205,7 @@ async def run_agent(
# Delay tool status notification until tool_call_result.
continue
chain = MessageChain(type="tool_call").message(
_build_tool_call_status_message(tool_info)
_build_tool_call_status_message(tool_info),
)
await astr_event.send(chain)
continue
@@ -235,7 +235,7 @@ async def run_agent(
yield resp.data["chain"] # MessageChain
elif resp.type == "llm_result":
if final_chain := _extract_final_streaming_chain(
resp.data["chain"]
resp.data["chain"],
):
yield final_chain
if not stop_watcher.done():
@@ -251,7 +251,7 @@ async def run_agent(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
),
)
break
@@ -266,7 +266,7 @@ async def run_agent(
logger.error(traceback.format_exc())
custom_error_message = extract_persona_custom_error_message_from_event(
astr_event
astr_event,
)
if custom_error_message:
err_msg = custom_error_message
@@ -283,7 +283,7 @@ async def run_agent(
)
try:
await agent_runner.agent_hooks.on_agent_done(
agent_runner.run_context, error_llm_response
agent_runner.run_context, error_llm_response,
)
except Exception:
logger.exception("Error in on_agent_done hook")
@@ -323,6 +323,7 @@ async def run_live_agent(
Yields:
MessageChain: 包含文本或音频数据的消息链
"""
# 如果没有 TTS Provider,直接发送文本
if not tts_provider:
@@ -343,7 +344,7 @@ async def run_live_agent(
else:
logger.info(
f"[Live Agent] 使用 TTS({tts_provider.meta().type} "
"使用 get_audio,将按句子分块生成音频)"
"使用 get_audio,将按句子分块生成音频)",
)
# 统计数据初始化
@@ -365,17 +366,17 @@ async def run_live_agent(
show_tool_use,
show_tool_call_result,
show_reasoning,
)
),
)
# 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
if support_stream:
tts_task = asyncio.create_task(
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue),
)
else:
tts_task = asyncio.create_task(
_simulated_stream_tts(tts_provider, text_queue, audio_queue)
_simulated_stream_tts(tts_provider, text_queue, audio_queue),
)
# 3. 主循环:从 audio_queue 读取音频并 yield
@@ -417,7 +418,6 @@ async def run_live_agent(
tts_task.cancel()
# 确保队列被消费
pass
tts_end_time = time.time()
@@ -436,10 +436,10 @@ async def run_live_agent(
"tts_first_frame_time": tts_first_frame_time,
"tts": tts_provider.meta().type,
"chat_model": agent_runner.provider.get_model(),
}
)
},
),
],
)
),
)
except Exception as e:
logger.error(f"发送 TTS 统计信息失败: {e}")
@@ -541,7 +541,7 @@ async def _simulated_stream_tts(
await audio_queue.put((text, audio_data))
except Exception as e:
logger.error(
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}",
)
# 继续处理下一句
+34 -33
View File
@@ -58,7 +58,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def _collect_image_urls_from_message(
cls, run_context: ContextWrapper[AstrAgentContext]
cls, run_context: ContextWrapper[AstrAgentContext],
) -> list[str]:
urls: list[str] = []
event = getattr(run_context.context, "event", None)
@@ -83,7 +83,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def _collect_handoff_image_urls(
cls, run_context: ContextWrapper[AstrAgentContext], image_urls_raw: Any
cls, run_context: ContextWrapper[AstrAgentContext], image_urls_raw: Any,
) -> list[str]:
candidates: list[str] = []
candidates.extend(cls._collect_image_urls_from_args(image_urls_raw))
@@ -126,7 +126,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
is_bg = tool_args.pop("background_task", False)
if is_bg:
async for r in cls._execute_handoff_background(
tool, run_context, **tool_args
tool, run_context, **tool_args,
):
yield r
return
@@ -143,16 +143,16 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def _run_in_background() -> None:
try:
await cls._execute_background(
tool=tool, run_context=run_context, task_id=task_id, **tool_args
tool=tool, run_context=run_context, task_id=task_id, **tool_args,
)
except Exception as e:
logger.error(
f"Background task {task_id} failed: {e!s}", exc_info=True
f"Background task {task_id} failed: {e!s}", exc_info=True,
)
asyncio.create_task(_run_in_background())
text_content = mcp.types.TextContent(
type="text", text=f"Background task submitted. task_id={task_id}"
type="text", text=f"Background task submitted. task_id={task_id}",
)
yield mcp.types.CallToolResult(content=[text_content])
return
@@ -170,15 +170,16 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
"astrbot_execute_browser",
"astrbot_execute_browser_batch",
"astrbot_run_browser_skill",
}
},
)
@classmethod
def _check_sandbox_capability(
cls, tool: FunctionTool, run_context: ContextWrapper[AstrAgentContext]
cls, tool: FunctionTool, run_context: ContextWrapper[AstrAgentContext],
) -> mcp.types.CallToolResult | None:
"""Return a rejection result if the tool requires a sandbox capability
that is not available, or None if the tool may proceed."""
that is not available, or None if the tool may proceed.
"""
if tool.name not in cls._BROWSER_TOOL_NAMES:
return None
from astrbot.core.computer.computer_client import get_sandbox_capabilities
@@ -190,23 +191,23 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
if "browser" not in caps:
msg = f"Tool '{tool.name}' requires browser capability, but the current sandbox profile does not include it (capabilities: {list(caps)}). Please ask the administrator to switch to a sandbox profile with browser support, or use shell/python tools instead."
logger.warning(
"[ToolExec] capability_rejected tool=%s caps=%s", tool.name, list(caps)
"[ToolExec] capability_rejected tool=%s caps=%s", tool.name, list(caps),
)
return mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=msg)], isError=True
content=[mcp.types.TextContent(type="text", text=msg)], isError=True,
)
return None
@classmethod
def _get_runtime_computer_tools(
cls, runtime: str, sandbox_cfg: dict | None = None, session_id: str = ""
cls, runtime: str, sandbox_cfg: dict | None = None, session_id: str = "",
) -> dict[str, ToolSchema]:
from astrbot.core.computer.computer_tool_provider import ComputerToolProvider
from astrbot.core.tool_provider import ToolProviderContext
provider = ComputerToolProvider()
ctx = ToolProviderContext(
computer_use_runtime=runtime, sandbox_cfg=sandbox_cfg, session_id=session_id
computer_use_runtime=runtime, sandbox_cfg=sandbox_cfg, session_id=session_id,
)
tools = provider.get_tools(ctx)
result = {tool.name: tool for tool in tools}
@@ -231,7 +232,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
runtime = str(provider_settings.get("computer_use_runtime", "local"))
sandbox_cfg = provider_settings.get("sandbox", {})
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime, sandbox_cfg=sandbox_cfg, session_id=event.unified_msg_origin
runtime, sandbox_cfg=sandbox_cfg, session_id=event.unified_msg_origin,
)
if tools is None:
toolset = ToolSet()
@@ -282,7 +283,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
image_urls = []
else:
image_urls = await cls._collect_handoff_image_urls(
run_context, tool_args.get("image_urls")
run_context, tool_args.get("image_urls"),
)
tool_args["image_urls"] = image_urls
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
@@ -290,7 +291,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event = run_context.context.event
umo = event.unified_msg_origin
prov_id = getattr(
tool, "provider_id", None
tool, "provider_id", None,
) or await ctx.get_current_chat_provider_id(umo)
contexts = None
dialogs = tool.agent.begin_dialogs
@@ -301,7 +302,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
contexts.append(
dialog
if isinstance(dialog, Message)
else Message.model_validate(dialog)
else Message.model_validate(dialog),
)
except Exception:
continue
@@ -321,7 +322,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
stream=stream,
)
yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)],
)
@classmethod
@@ -344,7 +345,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def _run_handoff_in_background() -> None:
try:
await cls._do_handoff_background(
tool=tool, run_context=run_context, task_id=task_id, **tool_args
tool=tool, run_context=run_context, task_id=task_id, **tool_args,
)
except Exception as e:
logger.error(
@@ -371,11 +372,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
result_text = ""
tool_args = dict(tool_args)
tool_args["image_urls"] = await cls._collect_handoff_image_urls(
run_context, tool_args.get("image_urls")
run_context, tool_args.get("image_urls"),
)
try:
async for r in cls._execute_handoff(
tool, run_context, image_urls_prepared=True, **tool_args
tool, run_context, image_urls_prepared=True, **tool_args,
):
if isinstance(r, mcp.types.CallToolResult):
for content in r.content:
@@ -409,7 +410,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
result_text = ""
try:
async for r in cls._execute_local(
tool, run_context, tool_call_timeout=3600, **tool_args
tool, run_context, tool_call_timeout=3600, **tool_args,
):
if isinstance(r, mcp.types.CallToolResult):
result_text = ""
@@ -491,14 +492,14 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
req.system_prompt += CONVERSATION_HISTORY_INJECT_PREFIX + context_dump
bg = json.dumps(extras["background_task_result"], ensure_ascii=False)
req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format(
background_task_result=bg
background_task_result=bg,
)
req.prompt = BACKGROUND_TASK_WOKE_USER_PROMPT
if not req.func_tool:
req.func_tool = ToolSet()
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
result = await build_main_agent(
event=cron_event, plugin_context=ctx, config=config, req=req
event=cron_event, plugin_context=ctx, config=config, req=req,
)
if not result:
logger.error(f"Failed to build main agent for background task {tool_name}.")
@@ -556,7 +557,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
if awaitable is None:
raise ValueError("Tool must have a valid handler or override 'run' method.")
sdk_plugin_bridge = getattr(
run_context.context.context, "sdk_plugin_bridge", None
run_context.context.context, "sdk_plugin_bridge", None,
)
if sdk_plugin_bridge is not None:
try:
@@ -566,7 +567,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
{
"tool_name": tool.name,
"tool_args": json.loads(
json.dumps(tool_args, ensure_ascii=False, default=str)
json.dumps(tool_args, ensure_ascii=False, default=str),
),
},
)
@@ -578,7 +579,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
| AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
]
wrapper = call_local_llm_tool(
context=run_context, handler=awaitable, method_name=method_name, **tool_args
context=run_context, handler=awaitable, method_name=method_name, **tool_args,
)
while True:
try:
@@ -591,7 +592,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
yield resp
else:
text_content = mcp.types.TextContent(
type="text", text=str(resp)
type="text", text=str(resp),
)
yield mcp.types.CallToolResult(content=[text_content])
else:
@@ -599,7 +600,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
if res and res.chain:
try:
await event.send(
MessageChain(chain=res.chain, type="tool_direct_result")
MessageChain(chain=res.chain, type="tool_direct_result"),
)
except Exception as e:
logger.error(f"Tool 直接发送消息失败: {e}", exc_info=True)
@@ -610,12 +611,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
mcp.types.TextContent(
type="text",
text="Tool executed successfully with no output.",
)
]
),
],
)
except asyncio.TimeoutError:
raise Exception(
f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds."
f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds.",
) from None
except StopAsyncIteration:
break
@@ -681,7 +682,7 @@ async def call_local_llm_tool(
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}",
) from e
except Exception as e:
trace_ = traceback.format_exc()
+60 -59
View File
@@ -71,7 +71,8 @@ from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
@dataclass(slots=True)
class MainAgentBuildConfig:
"""The main agent build configuration.
Most of the configs can be found in the cmd_config.json"""
Most of the configs can be found in the cmd_config.json
"""
tool_call_timeout: int
"The timeout (in seconds) for a tool call.\n When the tool call exceeds this time,\n a timeout error as a tool result will be returned.\n "
@@ -129,7 +130,7 @@ class MainAgentBuildResult:
def _select_provider(
event: AstrMessageEvent, plugin_context: Context
event: AstrMessageEvent, plugin_context: Context,
) -> Provider | None:
"""Select chat provider for the event."""
sel_provider = event.get_extra("selected_provider")
@@ -149,7 +150,7 @@ def _select_provider(
async def _get_session_conv(
event: AstrMessageEvent, plugin_context: Context
event: AstrMessageEvent, plugin_context: Context,
) -> Conversation:
conv_mgr = plugin_context.conversation_manager
umo = event.unified_msg_origin
@@ -176,7 +177,7 @@ async def _apply_kb(
return
try:
kb_result = await retrieve_knowledge_base(
query=req.prompt, umo=event.unified_msg_origin, context=plugin_context
query=req.prompt, umo=event.unified_msg_origin, context=plugin_context,
)
if not kb_result:
return
@@ -193,7 +194,7 @@ async def _apply_kb(
async def _apply_file_extract(
event: AstrMessageEvent, req: ProviderRequest, config: MainAgentBuildConfig
event: AstrMessageEvent, req: ProviderRequest, config: MainAgentBuildConfig,
) -> None:
file_paths = []
file_names = []
@@ -218,7 +219,7 @@ async def _apply_file_extract(
*[
extract_file_moonshotai(file_path, config.file_extract_msh_api_key)
for file_path in file_paths
]
],
)
else:
logger.error("Unsupported file extract provider: %s", config.file_extract_prov)
@@ -228,9 +229,9 @@ async def _apply_file_extract(
{
"role": "system",
"content": FILE_EXTRACT_CONTEXT_TEMPLATE.format(
file_content=file_content, file_name=file_name or "Unknown"
file_content=file_content, file_name=file_name or "Unknown",
),
}
},
)
@@ -245,7 +246,7 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
async def _ensure_persona_and_skills(
req: ProviderRequest, cfg: dict, plugin_context: Context, event: AstrMessageEvent
req: ProviderRequest, cfg: dict, plugin_context: Context, event: AstrMessageEvent,
) -> None:
"""Ensure persona and skills are applied to the request's system prompt or user prompt."""
if not req.conversation:
@@ -262,7 +263,7 @@ async def _ensure_persona_and_skills(
provider_settings=cfg,
)
set_persona_custom_error_message_on_event(
event, extract_persona_custom_error_message_from_persona(persona)
event, extract_persona_custom_error_message_from_persona(persona),
)
if persona:
if prompt := persona["prompt"]:
@@ -286,7 +287,7 @@ async def _ensure_persona_and_skills(
if runtime == "none":
req.system_prompt += COMPUTER_USE_DISABLED_PROMPT
tmgr = plugin_context.get_llm_tool_manager()
if persona and persona.get("tools") is None or not persona:
if (persona and persona.get("tools") is None) or not persona:
persona_toolset = tmgr.get_full_tool_set()
for tool in list(persona_toolset):
if not tool.active:
@@ -329,7 +330,7 @@ async def _ensure_persona_and_skills(
tool.name
for tool in tmgr.func_list
if not isinstance(tool, HandoffTool)
]
],
)
continue
if not isinstance(tools, list):
@@ -367,16 +368,16 @@ async def _ensure_persona_and_skills(
async def _request_img_caption(
provider_id: str, cfg: dict, image_urls: list[str], plugin_context: Context
provider_id: str, cfg: dict, image_urls: list[str], plugin_context: Context,
) -> str:
prov = plugin_context.get_provider_by_id(provider_id)
if prov is None:
raise ValueError(
f"Cannot get image caption because provider `{provider_id}` is not exist."
f"Cannot get image caption because provider `{provider_id}` is not exist.",
)
if not isinstance(prov, Provider):
raise ValueError(
f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}."
f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.",
)
img_cap_prompt = cfg.get("image_caption_prompt", IMAGE_CAPTION_DEFAULT_PROMPT)
logger.debug("Processing image caption with provider: %s", provider_id)
@@ -399,11 +400,11 @@ async def _ensure_img_caption(
if _is_generated_compressed_image_path(url, compressed_url):
event.track_temporary_local_file(compressed_url)
caption = await _request_img_caption(
image_caption_provider, cfg, compressed_urls, plugin_context
image_caption_provider, cfg, compressed_urls, plugin_context,
)
if caption:
req.extra_user_content_parts.append(
TextPart(text=f"<image_caption>{caption}</image_caption>")
TextPart(text=f"<image_caption>{caption}</image_caption>"),
)
req.image_urls = []
except Exception as exc:
@@ -415,19 +416,19 @@ async def _ensure_img_caption(
def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None:
req.extra_user_content_parts.append(
TextPart(text=f"[Image Attachment in quoted message: path {image_path}]")
TextPart(text=f"[Image Attachment in quoted message: path {image_path}]"),
)
def _append_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
TextPart(text=f"[Audio Attachment: path {audio_path}]")
TextPart(text=f"[Audio Attachment: path {audio_path}]"),
)
def _append_quoted_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]")
TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]"),
)
@@ -467,7 +468,7 @@ def _get_image_compress_args(
async def _compress_image_for_provider(
url_or_path: str, provider_settings: dict[str, object] | None
url_or_path: str, provider_settings: dict[str, object] | None,
) -> str:
try:
enabled, max_size, quality = _get_image_compress_args(provider_settings)
@@ -504,7 +505,7 @@ def _get_image_compress_args(
async def _compress_image_for_provider(
url_or_path: str, provider_settings: dict[str, object] | None
url_or_path: str, provider_settings: dict[str, object] | None,
) -> str:
try:
enabled, max_size, quality = _get_image_compress_args(provider_settings)
@@ -517,7 +518,7 @@ async def _compress_image_for_provider(
def _is_generated_compressed_image_path(
original_path: str, compressed_path: str | None
original_path: str, compressed_path: str | None,
) -> bool:
if not compressed_path or compressed_path == original_path:
return False
@@ -551,7 +552,7 @@ def _get_image_compress_args(
async def _compress_image_for_provider(
url_or_path: str, provider_settings: dict[str, object] | None
url_or_path: str, provider_settings: dict[str, object] | None,
) -> str:
try:
enabled, max_size, quality = _get_image_compress_args(provider_settings)
@@ -564,7 +565,7 @@ async def _compress_image_for_provider(
def _is_generated_compressed_image_path(
original_path: str, compressed_path: str | None
original_path: str, compressed_path: str | None,
) -> bool:
if not compressed_path or compressed_path == original_path:
return False
@@ -592,7 +593,7 @@ async def _process_quote_message(
sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else ""
message_str = (
await extract_quoted_message_text(
event, quote, settings=quoted_message_settings
event, quote, settings=quoted_message_settings,
)
or quote.message_str
or "[Empty Text]"
@@ -616,7 +617,7 @@ async def _process_quote_message(
if prov and isinstance(prov, Provider):
path = await image_seg.convert_to_file_path()
compress_path = await _compress_image_for_provider(
path, config.provider_settings if config else None
path, config.provider_settings if config else None,
)
if path and _is_generated_compressed_image_path(path, compress_path):
event.track_temporary_local_file(compress_path)
@@ -626,7 +627,7 @@ async def _process_quote_message(
)
if llm_resp.completion_text:
content_parts.append(
f"[Image Caption in quoted message]: {llm_resp.completion_text}"
f"[Image Caption in quoted message]: {llm_resp.completion_text}",
)
else:
logger.warning("No provider found for image captioning in quote.")
@@ -645,7 +646,7 @@ async def _process_quote_message(
def _append_system_reminders(
event: AstrMessageEvent, req: ProviderRequest, cfg: dict, timezone: str | None
event: AstrMessageEvent, req: ProviderRequest, cfg: dict, timezone: str | None,
) -> None:
system_parts: list[str] = []
if cfg.get("identifier"):
@@ -689,7 +690,7 @@ async def _decorate_llm_request(
config: MainAgentBuildConfig,
) -> None:
cfg = config.provider_settings or plugin_context.get_config(
umo=event.unified_msg_origin
umo=event.unified_msg_origin,
).get("provider_settings", {})
_apply_prompt_prefix(req, cfg)
if req.conversation:
@@ -700,7 +701,7 @@ async def _decorate_llm_request(
img_cap_prov_id = cfg.get("default_image_caption_provider_id") or ""
quoted_message_settings = _get_quoted_message_parser_settings(cfg)
await _process_quote_message(
event, req, img_cap_prov_id, plugin_context, quoted_message_settings, config
event, req, img_cap_prov_id, plugin_context, quoted_message_settings, config,
)
tz = config.timezone
if tz is None:
@@ -713,7 +714,7 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
provider_cfg = provider.provider_config.get("modalities", ["image"])
if "image" not in provider_cfg:
logger.debug(
"Provider %s does not support image, using placeholder.", provider
"Provider %s does not support image, using placeholder.", provider,
)
image_count = len(req.image_urls)
placeholder = " ".join(["[Image]"] * image_count)
@@ -726,7 +727,7 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
provider_cfg = provider.provider_config.get("modalities", ["audio"])
if "audio" not in provider_cfg:
logger.debug(
"Provider %s does not support audio, using placeholder.", provider
"Provider %s does not support audio, using placeholder.", provider,
)
audio_count = len(req.audio_urls)
placeholder = " ".join(["[Audio]"] * audio_count)
@@ -739,13 +740,13 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
if "tool_use" not in provider_cfg:
logger.debug(
"Provider %s does not support tool_use, clearing tools.", provider
"Provider %s does not support tool_use, clearing tools.", provider,
)
req.func_tool = None
def _sanitize_context_by_modalities(
config: MainAgentBuildConfig, provider: Provider, req: ProviderRequest
config: MainAgentBuildConfig, provider: Provider, req: ProviderRequest,
) -> None:
if not config.sanitize_context_by_modalities:
return
@@ -843,14 +844,14 @@ def _model_outputs_image(provider: Provider, req: ProviderRequest) -> bool:
def _should_disable_streaming_for_webchat_output(
event: AstrMessageEvent, provider: Provider, req: ProviderRequest
event: AstrMessageEvent, provider: Provider, req: ProviderRequest,
) -> bool:
if event.get_platform_name() != "webchat":
return False
provider_cfg = provider.provider_config
provider_type = provider_cfg.get("type", "")
if provider_type == "googlegenai_chat_completion" and provider_cfg.get(
"gm_resp_image_modal", False
"gm_resp_image_modal", False,
):
return True
if _model_outputs_image(provider, req):
@@ -884,7 +885,7 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
async def _handle_webchat(
event: AstrMessageEvent, req: ProviderRequest, prov: Provider
event: AstrMessageEvent, req: ProviderRequest, prov: Provider,
) -> None:
from astrbot.core import db_helper
@@ -905,7 +906,7 @@ async def _handle_webchat(
)
except Exception as e:
logger.exception(
"Failed to generate webchat title for session %s: %s", chatui_session_id, e
"Failed to generate webchat title for session %s: %s", chatui_session_id, e,
)
return
if llm_resp and llm_resp.completion_text:
@@ -913,10 +914,10 @@ async def _handle_webchat(
if not title or "<None>" in title:
return
logger.info(
"Generated chatui title for session %s: %s", chatui_session_id, title
"Generated chatui title for session %s: %s", chatui_session_id, title,
)
await db_helper.update_platform_session(
session_id=chatui_session_id, display_name=title
session_id=chatui_session_id, display_name=title,
)
@@ -925,12 +926,12 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -
req.system_prompt = f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt}"
else:
logger.warning(
"Unsupported llm_safety_mode strategy: %s.", config.safety_mode_strategy
"Unsupported llm_safety_mode strategy: %s.", config.safety_mode_strategy,
)
def _get_compress_provider(
config: MainAgentBuildConfig, plugin_context: Context
config: MainAgentBuildConfig, plugin_context: Context,
) -> Provider | None:
if not config.llm_compress_provider_id:
return None
@@ -939,7 +940,7 @@ def _get_compress_provider(
provider = plugin_context.get_provider_by_id(config.llm_compress_provider_id)
if provider is None:
logger.warning(
"未找到指定的上下文压缩模型 %s,将跳过压缩。", config.llm_compress_provider_id
"未找到指定的上下文压缩模型 %s,将跳过压缩。", config.llm_compress_provider_id,
)
return None
if not isinstance(provider, Provider):
@@ -952,12 +953,12 @@ def _get_compress_provider(
def _get_fallback_chat_providers(
provider: Provider, plugin_context: Context, provider_settings: dict
provider: Provider, plugin_context: Context, provider_settings: dict,
) -> list[Provider]:
fallback_ids = provider_settings.get("fallback_chat_models", [])
if not isinstance(fallback_ids, list):
logger.warning(
"fallback_chat_models setting is not a list, skip fallback providers."
"fallback_chat_models setting is not a list, skip fallback providers.",
)
return []
provider_id = str(provider.provider_config.get("id", ""))
@@ -1025,13 +1026,13 @@ async def build_main_agent(
if isinstance(comp, Image):
path = await comp.convert_to_file_path()
image_path = await _compress_image_for_provider(
path, config.provider_settings
path, config.provider_settings,
)
if _is_generated_compressed_image_path(path, image_path):
event.track_temporary_local_file(image_path)
req.image_urls.append(image_path)
req.extra_user_content_parts.append(
TextPart(text=f"[Image Attachment: path {image_path}]")
TextPart(text=f"[Image Attachment: path {image_path}]"),
)
elif isinstance(comp, Record):
audio_path = await comp.convert_to_file_path()
@@ -1042,14 +1043,14 @@ async def build_main_agent(
file_name = comp.name or os.path.basename(file_path)
req.extra_user_content_parts.append(
TextPart(
text=f"[File Attachment: name {file_name}, path {file_path}]"
)
text=f"[File Attachment: name {file_name}, path {file_path}]",
),
)
reply_comps = [
comp for comp in event.message_obj.message if isinstance(comp, Reply)
]
quoted_message_settings = _get_quoted_message_parser_settings(
config.provider_settings
config.provider_settings,
)
fallback_quoted_image_count = 0
for comp in reply_comps:
@@ -1060,7 +1061,7 @@ async def build_main_agent(
has_embedded_image = True
path = await reply_comp.convert_to_file_path()
image_path = await _compress_image_for_provider(
path, config.provider_settings
path, config.provider_settings,
)
if _is_generated_compressed_image_path(path, image_path):
event.track_temporary_local_file(image_path)
@@ -1075,15 +1076,15 @@ async def build_main_agent(
file_name = reply_comp.name or os.path.basename(file_path)
req.extra_user_content_parts.append(
TextPart(
text=f"[File Attachment in quoted message: name {file_name}, path {file_path}]"
)
text=f"[File Attachment in quoted message: name {file_name}, path {file_path}]",
),
)
if not has_embedded_image:
try:
fallback_images = normalize_and_dedupe_strings(
await extract_quoted_message_images(
event, comp, settings=quoted_message_settings
)
event, comp, settings=quoted_message_settings,
),
)
remaining_limit = max(
config.max_quoted_fallback_images
@@ -1156,7 +1157,7 @@ async def build_main_agent(
session_id=req.session_id or "",
)
_inactivated: set[str] = set(
str(sp.get("inactivated_llm_tools", [], scope="global", scope_id="global"))
str(sp.get("inactivated_llm_tools", [], scope="global", scope_id="global")),
)
for _tp in config.tool_providers:
_tp_tools = _tp.get_tools(_provider_ctx)
@@ -1197,7 +1198,7 @@ async def build_main_agent(
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
streaming_response = config.streaming_response
if streaming_response and _should_disable_streaming_for_webchat_output(
event, provider, req
event, provider, req,
):
logger.info(
"Disable streaming for webchat direct media output. provider=%s model=%s",
@@ -1223,7 +1224,7 @@ async def build_main_agent(
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
fallback_providers=_get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
provider, plugin_context, config.provider_settings,
),
)
if apply_reset:
+8 -8
View File
@@ -61,11 +61,11 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
},
},
"required": ["query"],
}
},
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
self, context: ContextWrapper[AstrAgentContext], **kwargs,
) -> ToolExecResult:
query = kwargs.get("query", "")
if not query:
@@ -130,14 +130,13 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
},
},
"required": ["messages"],
}
},
)
async def _resolve_path_from_sandbox(
self, context: ContextWrapper[AstrAgentContext], path: str
self, context: ContextWrapper[AstrAgentContext], path: str,
) -> tuple[str, bool]:
"""
If the path exists locally, return it directly.
"""If the path exists locally, return it directly.
Otherwise, check if it exists in the sandbox and download it.
bool: indicates whether the file was downloaded from sandbox.
@@ -157,7 +156,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
# Download the file from sandbox
name = os.path.basename(path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}",
)
await sb.download_file(path, local_path)
logger.info(f"Downloaded file from sandbox: {path} -> {local_path}")
@@ -169,7 +168,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
return path, False
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any,
) -> ToolExecResult:
session = kwargs.get("session") or context.context.event.unified_msg_origin
messages_raw: list[dict[str, Any]] | None = kwargs.get("messages")
@@ -315,6 +314,7 @@ async def retrieve_knowledge_base(
Args:
umo: Unique message object (session ID)
p_ctx: Pipeline context
"""
kb_mgr = context.kb_manager
config = context.get_config(umo=umo)
+1
View File
@@ -68,6 +68,7 @@ def get_backup_directories() -> dict[str, str]:
Returns:
dict: 键为备份文件中的目录名称,值为目录的绝对路径
"""
return {
"plugins": get_astrbot_plugin_path(), # 插件本体
+16 -14
View File
@@ -79,6 +79,7 @@ class AstrBotExporter:
Returns:
str: 生成的 ZIP 文件路径
"""
if output_dir is None:
output_dir = get_astrbot_backups_path()
@@ -99,7 +100,7 @@ class AstrBotExporter:
await progress_callback("main_db", 0, 100, "正在导出主数据库...")
main_data = await self._export_main_database()
main_db_json = json.dumps(
main_data, ensure_ascii=False, indent=2, default=str
main_data, ensure_ascii=False, indent=2, default=str,
)
zf.writestr("databases/main_db.json", main_db_json)
self._add_checksum("databases/main_db.json", main_db_json)
@@ -115,17 +116,17 @@ class AstrBotExporter:
if self.kb_manager:
if progress_callback:
await progress_callback(
"kb_metadata", 0, 100, "正在导出知识库元数据..."
"kb_metadata", 0, 100, "正在导出知识库元数据...",
)
kb_meta_data = await self._export_kb_metadata()
kb_meta_json = json.dumps(
kb_meta_data, ensure_ascii=False, indent=2, default=str
kb_meta_data, ensure_ascii=False, indent=2, default=str,
)
zf.writestr("databases/kb_metadata.json", kb_meta_json)
self._add_checksum("databases/kb_metadata.json", kb_meta_json)
if progress_callback:
await progress_callback(
"kb_metadata", 100, 100, "知识库元数据导出完成"
"kb_metadata", 100, 100, "知识库元数据导出完成",
)
# 导出每个知识库的文档数据
@@ -141,7 +142,7 @@ class AstrBotExporter:
)
doc_data = await self._export_kb_documents(kb_helper)
doc_json = json.dumps(
doc_data, ensure_ascii=False, indent=2, default=str
doc_data, ensure_ascii=False, indent=2, default=str,
)
doc_path = f"databases/kb_{kb_id}/documents.json"
zf.writestr(doc_path, doc_json)
@@ -155,7 +156,7 @@ class AstrBotExporter:
if progress_callback:
await progress_callback(
"kb_documents", total_kbs, total_kbs, "知识库文档导出完成"
"kb_documents", total_kbs, total_kbs, "知识库文档导出完成",
)
# 3. 导出配置文件
@@ -163,7 +164,7 @@ class AstrBotExporter:
await progress_callback("config", 0, 100, "正在导出配置文件...")
if await anyio.Path(self.config_path).exists():
async with await anyio.open_file(
self.config_path, encoding="utf-8"
self.config_path, encoding="utf-8",
) as f:
config_content = await f.read()
zf.writestr("config/cmd_config.json", config_content)
@@ -181,7 +182,7 @@ class AstrBotExporter:
# 5. 导出插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导出插件和数据目录..."
"directories", 0, 100, "正在导出插件和数据目录...",
)
dir_stats = await self._export_directories(zf)
if progress_callback:
@@ -219,7 +220,7 @@ class AstrBotExporter:
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出表 {table_name}: {len(export_data[table_name])} 条记录"
f"导出表 {table_name}: {len(export_data[table_name])} 条记录",
)
except Exception as e:
logger.warning(f"导出表 {table_name} 失败: {e}")
@@ -243,7 +244,7 @@ class AstrBotExporter:
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录"
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录",
)
except Exception as e:
logger.warning(f"导出知识库表 {table_name} 失败: {e}")
@@ -289,7 +290,7 @@ class AstrBotExporter:
logger.warning(f"导出 FAISS 索引失败: {e}")
async def _export_kb_media_files(
self, zf: zipfile.ZipFile, kb_helper: Any, kb_id: str
self, zf: zipfile.ZipFile, kb_helper: Any, kb_id: str,
) -> None:
"""导出知识库的多媒体文件"""
try:
@@ -308,12 +309,13 @@ class AstrBotExporter:
logger.warning(f"导出知识库媒体文件失败: {e}")
async def _export_directories(
self, zf: zipfile.ZipFile
self, zf: zipfile.ZipFile,
) -> dict[str, dict[str, int]]:
"""导出插件和其他数据目录
Returns:
dict: 每个目录的统计信息 {dir_name: {"files": count, "size": bytes}}
"""
stats: dict[str, dict[str, int]] = {}
backup_directories = get_backup_directories()
@@ -350,7 +352,7 @@ class AstrBotExporter:
stats[dir_name] = {"files": file_count, "size": total_size}
logger.debug(
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节"
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节",
)
except Exception as e:
logger.warning(f"导出目录 {dir_path} 失败: {e}")
@@ -359,7 +361,7 @@ class AstrBotExporter:
return stats
async def _export_attachments(
self, zf: zipfile.ZipFile, attachments: list[dict]
self, zf: zipfile.ZipFile, attachments: list[dict],
) -> None:
"""导出附件文件"""
for attachment in attachments:
+65 -63
View File
@@ -47,6 +47,7 @@ def _get_major_version(version_str: str) -> str:
Returns:
主版本字符串, "4.9", "4.10"
"""
if not version_str:
return "0.0"
@@ -55,7 +56,7 @@ def _get_major_version(version_str: str) -> str:
parts = [p for p in version.split(".") if p] # 过滤空字符串
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
elif len(parts) == 1 and parts[0]:
if len(parts) == 1 and parts[0]:
return f"{parts[0]}.0"
return "0.0"
@@ -246,6 +247,7 @@ class AstrBotImporter:
Returns:
ImportPreCheckResult: 预检查结果
"""
result = ImportPreCheckResult()
result.current_version = VERSION
@@ -307,6 +309,7 @@ class AstrBotImporter:
Returns:
dict: {status, can_import, message}
"""
if not backup_version:
return {
@@ -362,6 +365,7 @@ class AstrBotImporter:
Returns:
ImportResult: 导入结果
"""
result = ImportResult()
@@ -465,7 +469,7 @@ class AstrBotImporter:
await progress_callback("attachments", 0, 100, "正在导入附件...")
attachment_count = await self._import_attachments(
zf, main_data.get("attachments", [])
zf, main_data.get("attachments", []),
)
result.imported_files["attachments"] = attachment_count
@@ -475,7 +479,7 @@ class AstrBotImporter:
# 6. 导入插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导入插件和数据目录..."
"directories", 0, 100, "正在导入插件和数据目录...",
)
dir_stats = await self._import_directories(zf, manifest, result)
@@ -516,16 +520,15 @@ class AstrBotImporter:
async def _clear_main_db(self) -> None:
"""清空主数据库所有表"""
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
raise DatabaseClearError(
f"清空表 {table_name} 失败: {e}"
) from e
async with self.main_db.get_db() as session, session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
raise DatabaseClearError(
f"清空表 {table_name} 失败: {e}",
) from e
async def _clear_kb_data(self) -> None:
"""清空知识库数据"""
@@ -533,14 +536,13 @@ class AstrBotImporter:
return
# 清空知识库元数据表
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
async with self.kb_manager.kb_db.get_db() as session, session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
# 删除知识库文件目录
for kb_id in list(self.kb_manager.kb_insts.keys()):
@@ -555,38 +557,37 @@ class AstrBotImporter:
self.kb_manager.kb_insts.clear()
async def _import_main_database(
self, data: dict[str, list[dict]]
self, data: dict[str, list[dict]],
) -> dict[str, int]:
"""导入主数据库数据"""
imported: dict[str, int] = {}
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
async with self.main_db.get_db() as session, session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
count = 0
for row in normalized_rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
count = 0
for row in normalized_rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
return imported
def _preprocess_main_table_rows(
self, table_name: str, rows: list[dict[str, Any]]
self, table_name: str, rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if table_name == "platform_stats":
normalized_rows = self._merge_platform_stats_rows(rows)
@@ -601,7 +602,7 @@ class AstrBotImporter:
return rows
def _merge_platform_stats_rows(
self, rows: list[dict[str, Any]]
self, rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Merge duplicate platform_stats rows by normalized timestamp/platform key.
@@ -609,6 +610,7 @@ class AstrBotImporter:
- Invalid/empty timestamps are kept as distinct rows to avoid accidental merging.
- Non-string platform_id/platform_type are kept as distinct rows.
- Invalid count warnings are rate-limited per function invocation.
"""
merged: dict[tuple[str, str, str], dict[str, Any]] = {}
result: list[dict[str, Any]] = []
@@ -708,24 +710,23 @@ class AstrBotImporter:
return
# 1. 导入知识库元数据
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
async with self.kb_manager.kb_db.get_db() as session, session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
result.imported_tables[f"kb_{table_name}"] = count
result.imported_tables[f"kb_{table_name}"] = count
# 2. 导入每个知识库的文档和文件
for kb_data in kb_meta_data.get("knowledge_bases", []):
@@ -769,7 +770,7 @@ class AstrBotImporter:
rel_path = name[len(media_prefix) :]
target_path = kb_dir / rel_path
await anyio.Path(target_path.parent).mkdir(
parents=True, exist_ok=True
parents=True, exist_ok=True,
)
with zf.open(name) as src:
content = src.read()
@@ -835,7 +836,7 @@ class AstrBotImporter:
target_path = attachments_dir / os.path.basename(name)
await anyio.Path(target_path.parent).mkdir(
parents=True, exist_ok=True
parents=True, exist_ok=True,
)
with zf.open(name) as src:
content = src.read()
@@ -862,6 +863,7 @@ class AstrBotImporter:
Returns:
dict: 每个目录导入的文件数量
"""
dir_stats: dict[str, int] = {}
@@ -916,7 +918,7 @@ class AstrBotImporter:
target_path = target_dir / rel_path
await anyio.Path(target_path.parent).mkdir(
parents=True, exist_ok=True
parents=True, exist_ok=True,
)
with zf.open(name) as src:
+2 -1
View File
@@ -74,7 +74,8 @@ class ComputerBooter(abc.ABC):
def get_tools(self) -> list[ToolSchema]:
"""Capability-filtered tool list (post-boot).
Defaults to get_default_tools()."""
Defaults to get_default_tools().
"""
return self.__class__.get_default_tools()
@classmethod
+11 -12
View File
@@ -60,7 +60,7 @@ class BayContainerManager:
raise RuntimeError(
"Failed to connect to Docker daemon. "
"Ensure Docker is installed and running, or configure "
"an explicit Bay endpoint instead of auto-start mode."
"an explicit Bay endpoint instead of auto-start mode.",
) from exc
# 1. Look for an existing managed container
@@ -72,13 +72,12 @@ class BayContainerManager:
logger.info("[BayManager] Reusing existing Bay container: %s", cid)
self._container = await self._docker.containers.get(existing["Id"])
return f"http://127.0.0.1:{self._host_port}"
else:
# Container exists but stopped — restart it
logger.info("[BayManager] Restarting stopped Bay container")
container = await self._docker.containers.get(existing["Id"])
await container.start()
self._container = container
return f"http://127.0.0.1:{self._host_port}"
# Container exists but stopped — restart it
logger.info("[BayManager] Restarting stopped Bay container")
container = await self._docker.containers.get(existing["Id"])
await container.start()
self._container = container
return f"http://127.0.0.1:{self._host_port}"
# 2. Pull image if needed
await self._pull_image_if_needed()
@@ -111,7 +110,7 @@ class BayContainerManager:
},
}
self._container = await self._docker.containers.create_or_replace(
BAY_CONTAINER_NAME, config
BAY_CONTAINER_NAME, config,
)
await self._container.start()
logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME)
@@ -129,7 +128,7 @@ class BayContainerManager:
while loop.time() < deadline:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=3)
url, timeout=aiohttp.ClientTimeout(total=3),
) as resp:
if resp.status == 200:
logger.info("[BayManager] Bay is healthy")
@@ -141,7 +140,7 @@ class BayContainerManager:
await asyncio.sleep(HEALTH_POLL_INTERVAL_S)
raise TimeoutError(
f"Bay did not become healthy within {timeout}s (last error: {last_error})"
f"Bay did not become healthy within {timeout}s (last error: {last_error})",
)
async def read_credentials(self) -> str:
@@ -202,7 +201,7 @@ class BayContainerManager:
return api_key
except Exception as exc:
logger.debug(
"[BayManager] Failed to read credentials from container: %s", exc
"[BayManager] Failed to read credentials from container: %s", exc,
)
return ""
+18 -20
View File
@@ -46,11 +46,10 @@ class MockShipyardSandboxClient:
) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}"
)
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}",
)
async def upload_file(self, path: str, remote_path: str) -> dict:
"""Upload a file to the sandbox"""
@@ -85,18 +84,17 @@ class MockShipyardSandboxClient:
"message": "File uploaded successfully",
"file_path": remote_path,
}
else:
error_text = await response.text()
logger.warning(
"[Computer] file_upload_failed booter=boxlite error=http_status status=%s remote_path=%s",
response.status,
remote_path,
)
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
error_text = await response.text()
logger.warning(
"[Computer] file_upload_failed booter=boxlite error=http_status status=%s remote_path=%s",
response.status,
remote_path,
)
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
except aiohttp.ClientError as e:
logger.error("[Computer] file_upload_failed booter=boxlite error=%s", e)
@@ -127,7 +125,7 @@ class MockShipyardSandboxClient:
}
except Exception as exc:
logger.exception(
"[Computer] file_upload_failed booter=boxlite error=unexpected"
"[Computer] file_upload_failed booter=boxlite error=unexpected",
)
return {
"success": False,
@@ -186,7 +184,7 @@ class BoxliteBooter(ComputerBooter):
{
"host_port": random_port,
"guest_port": 8123,
}
},
],
)
await self.box.start()
@@ -196,7 +194,7 @@ class BoxliteBooter(ComputerBooter):
self.box.id,
)
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
sb_url=f"http://127.0.0.1:{random_port}",
)
self._fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore[arg-type]
+13 -13
View File
@@ -102,7 +102,7 @@ def build_bwrap_cmd(config: BwrapConfig, script_cmd: list[str]) -> list[str]:
"--bind",
config.workspace_dir,
config.workspace_dir,
]
],
)
cmd.extend(["--"])
@@ -147,7 +147,7 @@ class BwrapShellComponent(ShellComponent):
result = subprocess.run(
bwrap_cmd,
cwd=working_dir,
check=False, cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
@@ -174,12 +174,12 @@ class BwrapPythonComponent(PythonComponent):
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
bwrap_cmd = build_bwrap_cmd(
self.config, [os.environ.get("PYTHON", "python3"), "-c", code]
self.config, [os.environ.get("PYTHON", "python3"), "-c", code],
)
try:
result = subprocess.run(
bwrap_cmd,
timeout=timeout,
check=False, timeout=timeout,
capture_output=True,
text=True,
)
@@ -221,7 +221,7 @@ class HostBackedFileSystemComponent(FileSystemComponent):
return path
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
self, path: str, content: str = "", mode: int = 0o644,
) -> dict[str, Any]:
p = self._safe_path(path)
await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True)
@@ -238,7 +238,7 @@ class HostBackedFileSystemComponent(FileSystemComponent):
return {"success": False, "error": str(e)}
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8",
) -> dict[str, Any]:
p = self._safe_path(path)
await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True)
@@ -260,7 +260,7 @@ class HostBackedFileSystemComponent(FileSystemComponent):
return {"success": False, "error": str(e)}
async def list_dir(
self, path: str = ".", show_hidden: bool = False
self, path: str = ".", show_hidden: bool = False,
) -> dict[str, Any]:
p = self._safe_path(path)
try:
@@ -274,7 +274,7 @@ class HostBackedFileSystemComponent(FileSystemComponent):
class BwrapBooter(ComputerBooter):
def __init__(
self, rw_binds: list[str] | None = None, ro_binds: list[str] | None = None
self, rw_binds: list[str] | None = None, ro_binds: list[str] | None = None,
):
self._rw_binds = rw_binds or []
self._ro_binds = ro_binds or []
@@ -307,7 +307,7 @@ class BwrapBooter(ComputerBooter):
async def boot(self, session_id: str) -> None:
workspace_dir = os.path.join(
get_astrbot_temp_path(), f"sandbox_workspace_{session_id}"
get_astrbot_temp_path(), f"sandbox_workspace_{session_id}",
)
await asyncio.to_thread(os.makedirs, workspace_dir, exist_ok=True)
@@ -321,19 +321,19 @@ class BwrapBooter(ComputerBooter):
self._shell = BwrapShellComponent(self.config)
if not await self.available():
raise RuntimeError(
"BubbleWrap sandbox unavailable on current machine for no bwrap executable."
"BubbleWrap sandbox unavailable on current machine for no bwrap executable.",
)
test_shl = await self._shell.exec(command="ls > /dev/null")
if test_shl["exit_code"] != 0:
raise RuntimeError(
"""BubbleWrap sandbox fails to exec test shell command "ls > /dev/null" with stderr:
{}""".format(test_shl["stderr"])
{}""".format(test_shl["stderr"]),
)
test_py = await self._python.exec(code="print('Yes')")
if test_py["exit_code"] != 0:
raise RuntimeError(
"""BubbleWrap sandbox fails to exec test python code "print('Yes')" with stderr:
{}""".format(test_py["stderr"])
{}""".format(test_py["stderr"]),
)
async def shutdown(self) -> None:
@@ -342,7 +342,7 @@ class BwrapBooter(ComputerBooter):
return
if await asyncio.to_thread(os.path.exists, config.workspace_dir):
await asyncio.to_thread(
shutil.rmtree, config.workspace_dir, ignore_errors=True
shutil.rmtree, config.workspace_dir, ignore_errors=True,
)
async def upload_file(self, path: str, file_name: str) -> dict:
+9 -9
View File
@@ -133,7 +133,7 @@ class LocalShellComponent(ShellComponent):
# Safety relies on `_is_safe_command()` and the allowed-root checks.
result = subprocess.run( # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
command,
shell=shell,
check=False, shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
@@ -161,7 +161,7 @@ class LocalPythonComponent(PythonComponent):
try:
result = subprocess.run(
[os.environ.get("PYTHON", sys.executable), "-c", code],
timeout=timeout,
check=False, timeout=timeout,
capture_output=True,
text=True,
)
@@ -171,14 +171,14 @@ class LocalPythonComponent(PythonComponent):
"data": {
"output": {"text": stdout, "images": []},
"error": stderr,
}
},
}
except subprocess.TimeoutExpired:
return {
"data": {
"output": {"text": "", "images": []},
"error": "Execution timed out.",
}
},
}
return await asyncio.to_thread(_run)
@@ -187,7 +187,7 @@ class LocalPythonComponent(PythonComponent):
@dataclass
class LocalFileSystemComponent(FileSystemComponent):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
self, path: str, content: str = "", mode: int = 0o644,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
@@ -213,7 +213,7 @@ class LocalFileSystemComponent(FileSystemComponent):
return await asyncio.to_thread(_run)
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8",
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
@@ -236,7 +236,7 @@ class LocalFileSystemComponent(FileSystemComponent):
return await asyncio.to_thread(_run)
async def list_dir(
self, path: str = ".", show_hidden: bool = False
self, path: str = ".", show_hidden: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
@@ -274,12 +274,12 @@ class LocalBooter(ComputerBooter):
async def upload_file(self, path: str, file_name: str) -> dict:
raise NotImplementedError(
"LocalBooter does not support upload_file operation. Use shell instead."
"LocalBooter does not support upload_file operation. Use shell instead.",
)
async def download_file(self, remote_path: str, local_path: str) -> None:
raise NotImplementedError(
"LocalBooter does not support download_file operation. Use shell instead."
"LocalBooter does not support download_file operation. Use shell instead.",
)
async def available(self) -> bool:
+1 -1
View File
@@ -49,7 +49,7 @@ class ShipyardBooter(ComputerBooter):
session_num: int = 10,
) -> None:
self._sandbox_client = ShipyardClient(
endpoint_url=endpoint_url, access_token=access_token
endpoint_url=endpoint_url, access_token=access_token,
)
self._ttl = ttl
self._session_num = session_num
+11 -11
View File
@@ -89,7 +89,7 @@ class NeoShellComponent(ShellComponent):
run_command = command
if env:
env_prefix = " ".join(
(f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items()))
(f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())),
)
run_command = f"{env_prefix} {run_command}"
if background:
@@ -132,7 +132,7 @@ class NeoFileSystemComponent(FileSystemComponent):
self._sandbox = sandbox
async def create_file(
self, path: str, content: str = "", mode: int = 420
self, path: str, content: str = "", mode: int = 420,
) -> dict[str, Any]:
_ = mode
await self._sandbox.filesystem.write_file(path, content)
@@ -144,7 +144,7 @@ class NeoFileSystemComponent(FileSystemComponent):
return {"success": True, "path": path, "content": content}
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8",
) -> dict[str, Any]:
_ = mode
_ = encoding
@@ -156,7 +156,7 @@ class NeoFileSystemComponent(FileSystemComponent):
return {"success": True, "path": path}
async def list_dir(
self, path: str = ".", show_hidden: bool = False
self, path: str = ".", show_hidden: bool = False,
) -> dict[str, Any]:
entries = await self._sandbox.filesystem.list_dir(path)
data = []
@@ -301,25 +301,25 @@ class ShipyardNeoBooter(ComputerBooter):
if not self._access_token:
self._access_token = await self._bay_manager.read_credentials()
logger.info(
"[Computer] bay_autostart status=ready endpoint=%s", self._endpoint_url
"[Computer] bay_autostart status=ready endpoint=%s", self._endpoint_url,
)
if not self._endpoint_url or not self._access_token:
if self._bay_manager is not None:
raise ValueError(
"Bay container started but credentials could not be read. Ensure Bay generated credentials.json, or set access_token manually."
"Bay container started but credentials could not be read. Ensure Bay generated credentials.json, or set access_token manually.",
)
raise ValueError(
"Shipyard Neo sandbox configuration is incomplete. Set endpoint (default http://127.0.0.1:8114) and access token, or ensure Bay's credentials.json is accessible for auto-discovery."
"Shipyard Neo sandbox configuration is incomplete. Set endpoint (default http://127.0.0.1:8114) and access token, or ensure Bay's credentials.json is accessible for auto-discovery.",
)
from shipyard_neo import BayClient
self._client = BayClient(
endpoint_url=self._endpoint_url, access_token=self._access_token
endpoint_url=self._endpoint_url, access_token=self._access_token,
)
await self._client.__aenter__()
resolved_profile = await self._resolve_profile(self._client)
self._sandbox = await self._client.create_sandbox(
profile=resolved_profile, ttl=self._ttl
profile=resolved_profile, ttl=self._ttl,
)
self._fs = NeoFileSystemComponent(self._sandbox)
self._python = NeoPythonComponent(self._sandbox)
@@ -351,7 +351,7 @@ class ShipyardNeoBooter(ComputerBooter):
"""
if self._profile and self._profile != self.DEFAULT_PROFILE:
logger.info(
"[Computer] profile_selected mode=user profile=%s", self._profile
"[Computer] profile_selected mode=user profile=%s", self._profile,
)
return self._profile
from shipyard_neo.errors import ForbiddenError, UnauthorizedError
@@ -434,7 +434,7 @@ class ShipyardNeoBooter(ComputerBooter):
remote_path = file_name.lstrip("/")
await self._sandbox.filesystem.upload(remote_path, content)
logger.info(
"[Computer] file_upload booter=shipyard_neo remote_path=%s", remote_path
"[Computer] file_upload booter=shipyard_neo remote_path=%s", remote_path,
)
return {
"success": True,
+9 -8
View File
@@ -48,6 +48,7 @@ def _discover_bay_credentials(endpoint: str) -> str:
Returns:
API key string, or empty string if not found.
"""
candidates: list[Path] = []
@@ -357,7 +358,7 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
if not _shell_exec_succeeded(apply_result):
detail = _format_exec_error_detail(apply_result)
logger.error(
"[Computer] sandbox_sync phase=apply status=failed detail=%s", detail
"[Computer] sandbox_sync phase=apply status=failed detail=%s", detail,
)
raise RuntimeError(f"Failed to apply sandbox skill sync strategy: {detail}")
logger.info("[Computer] sandbox_sync phase=apply status=done")
@@ -370,7 +371,7 @@ async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None:
if not _shell_exec_succeeded(scan_result):
detail = _format_exec_error_detail(scan_result)
logger.error(
"[Computer] sandbox_sync phase=scan status=failed detail=%s", detail
"[Computer] sandbox_sync phase=scan status=failed detail=%s", detail,
)
raise RuntimeError(f"Failed to scan sandbox skills after sync: {detail}")
@@ -415,7 +416,7 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
logger.info("[Computer] sandbox_sync phase=upload status=done")
else:
logger.info(
"[Computer] sandbox_sync phase=upload status=skipped reason=no_local_skills"
"[Computer] sandbox_sync phase=upload status=skipped reason=no_local_skills",
)
await booter.shell.exec(f"rm -f {SANDBOX_SKILLS_ROOT}/skills.zip")
@@ -449,7 +450,7 @@ async def get_booter(
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
if runtime == "local":
return get_local_booter()
elif runtime == "none":
if runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
@@ -476,7 +477,7 @@ async def get_booter(
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions,
)
elif booter_type == "shipyard_neo":
from .booters.shipyard_neo import ShipyardNeoBooter
@@ -491,7 +492,7 @@ async def get_booter(
token = _discover_bay_credentials(ep)
logger.info(
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}"
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}",
)
client = ShipyardNeoBooter(
endpoint_url=ep,
@@ -563,11 +564,11 @@ def _get_booter_class(booter_type: str) -> type[ComputerBooter] | None:
from .booters.shipyard import ShipyardBooter
return ShipyardBooter
elif booter_type == BOOTER_SHIPYARD_NEO:
if booter_type == BOOTER_SHIPYARD_NEO:
from .booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter
elif booter_type == BOOTER_BOXLITE:
if booter_type == BOOTER_BOXLITE:
from .booters.boxlite import BoxliteBooter
return BoxliteBooter
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Browser automation component
"""Browser automation component
"""
from typing import Any, Protocol
+4 -5
View File
@@ -1,5 +1,4 @@
"""
File system component
"""File system component
"""
from typing import Any, Protocol
@@ -7,7 +6,7 @@ from typing import Any, Protocol
class FileSystemComponent(Protocol):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
self, path: str, content: str = "", mode: int = 0o644,
) -> dict[str, Any]:
"""Create a file with the specified content"""
...
@@ -17,7 +16,7 @@ class FileSystemComponent(Protocol):
...
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8",
) -> dict[str, Any]:
"""Write content to file"""
...
@@ -27,7 +26,7 @@ class FileSystemComponent(Protocol):
...
async def list_dir(
self, path: str = ".", show_hidden: bool = False
self, path: str = ".", show_hidden: bool = False,
) -> dict[str, Any]:
"""List directory contents"""
...
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Python/IPython component
"""Python/IPython component
"""
from typing import Any, Protocol
+1 -2
View File
@@ -1,5 +1,4 @@
"""
Shell component
"""Shell component
"""
from typing import Any, Protocol
+4 -4
View File
@@ -24,7 +24,7 @@ async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> A
if browser is None:
raise RuntimeError(
"Current sandbox booter does not support browser capability. "
"Please switch to shipyard_neo."
"Please switch to shipyard_neo.",
)
return browser
@@ -56,7 +56,7 @@ class BrowserExecTool(FunctionTool):
},
},
"required": ["cmd"],
}
},
)
async def call( # type: ignore[override]
@@ -119,7 +119,7 @@ class BrowserBatchExecTool(FunctionTool):
},
},
"required": ["commands"],
}
},
)
async def call( # type: ignore[override]
@@ -168,7 +168,7 @@ class RunBrowserSkillTool(FunctionTool):
"tags": {"type": "string"},
},
"required": ["skill_key"],
}
},
)
async def call( # type: ignore[override]
+4 -4
View File
@@ -102,7 +102,7 @@ class FileUploadTool(FunctionTool):
# },
},
"required": ["local_path"],
}
},
)
async def call( # type: ignore[override]
@@ -167,7 +167,7 @@ class FileDownloadTool(FunctionTool):
},
},
"required": ["remote_path"],
}
},
)
async def call( # type: ignore[override]
@@ -186,7 +186,7 @@ class FileDownloadTool(FunctionTool):
name = os.path.basename(remote_path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}",
)
# Download file from sandbox
@@ -197,7 +197,7 @@ class FileDownloadTool(FunctionTool):
try:
name = os.path.basename(local_path)
await context.context.event.send(
MessageChain(chain=[File(name=name, file=local_path)])
MessageChain(chain=[File(name=name, file=local_path)]),
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
+13 -13
View File
@@ -39,7 +39,7 @@ async def _get_neo_context(
if client is None or sandbox is None:
raise RuntimeError(
"Current sandbox booter does not support Neo skill lifecycle APIs. "
"Please switch to shipyard_neo."
"Please switch to shipyard_neo.",
)
return client, sandbox
@@ -81,7 +81,7 @@ class GetExecutionHistoryTool(NeoSkillToolBase):
"has_description": {"type": "boolean", "default": False},
},
"required": [],
}
},
)
async def call( # type: ignore[override]
@@ -124,7 +124,7 @@ class AnnotateExecutionTool(NeoSkillToolBase):
"notes": {"type": "string"},
},
"required": ["execution_id"],
}
},
)
async def call( # type: ignore[override]
@@ -175,7 +175,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
},
},
"required": ["payload"],
}
},
)
async def call( # type: ignore[override]
@@ -205,7 +205,7 @@ class GetSkillPayloadTool(NeoSkillToolBase):
"payload_ref": {"type": "string"},
},
"required": ["payload_ref"],
}
},
)
async def call( # type: ignore[override]
@@ -250,7 +250,7 @@ class CreateSkillCandidateTool(NeoSkillToolBase):
},
},
"required": ["skill_key", "source_execution_ids"],
}
},
)
async def call( # type: ignore[override]
@@ -287,7 +287,7 @@ class ListSkillCandidatesTool(NeoSkillToolBase):
"offset": {"type": "integer", "default": 0},
},
"required": [],
}
},
)
async def call( # type: ignore[override]
@@ -325,7 +325,7 @@ class EvaluateSkillCandidateTool(NeoSkillToolBase):
"report": {"type": "string"},
},
"required": ["candidate_id", "passed"],
}
},
)
async def call( # type: ignore[override]
@@ -377,7 +377,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
},
},
"required": ["candidate_id"],
}
},
)
async def call( # type: ignore[override]
@@ -414,7 +414,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
"release": result.get("release"),
"sync": result.get("sync"),
"rollback": result.get("rollback"),
}
},
)
except Exception as e:
return f"Error promoting skill candidate: {e!s}"
@@ -435,7 +435,7 @@ class ListSkillReleasesTool(NeoSkillToolBase):
"offset": {"type": "integer", "default": 0},
},
"required": [],
}
},
)
async def call( # type: ignore[override]
@@ -471,7 +471,7 @@ class RollbackSkillReleaseTool(NeoSkillToolBase):
"release_id": {"type": "string"},
},
"required": ["release_id"],
}
},
)
async def call( # type: ignore[override]
@@ -501,7 +501,7 @@ class SyncSkillReleaseTool(NeoSkillToolBase):
"require_stable": {"type": "boolean", "default": True},
},
"required": [],
}
},
)
async def call( # type: ignore[override]
+2 -2
View File
@@ -3,10 +3,10 @@ from astrbot.core.astr_agent_context import AstrAgentContext
def check_admin_permission(
context: ContextWrapper[AstrAgentContext], operation_name: str
context: ContextWrapper[AstrAgentContext], operation_name: str,
) -> str | None:
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
umo=context.context.event.unified_msg_origin,
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
+4 -4
View File
@@ -46,8 +46,8 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
type="image", data=img["image/png"], mimeType="image/png",
),
)
if event.get_platform_name() == "webchat":
@@ -68,7 +68,7 @@ class PythonTool(FunctionTool):
parameters: dict = field(default_factory=lambda: param_schema)
async def call( # type: ignore[override]
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Python execution"):
return permission_error
@@ -94,7 +94,7 @@ class LocalPythonTool(FunctionTool):
parameters: dict = field(default_factory=lambda: param_schema)
async def call( # type: ignore[override]
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Python execution"):
return permission_error
+2 -2
View File
@@ -35,7 +35,7 @@ class ExecuteShellTool(FunctionTool):
},
},
"required": ["command"],
}
},
)
is_local: bool = False
@@ -61,4 +61,4 @@ class ExecuteShellTool(FunctionTool):
result = await sb.shell.exec(command, background=background, env=env)
return json.dumps(result)
except Exception as e:
return f"Error executing command: {str(e)}"
return f"Error executing command: {e!s}"
+1 -2
View File
@@ -57,8 +57,7 @@ class AstrBotConfig(dict):
with open(config_path, encoding="utf-8-sig") as f:
conf_str = f.read()
# Handle UTF-8 BOM if present
if conf_str.startswith("\ufeff"):
conf_str = conf_str[1:]
conf_str = conf_str.removeprefix("\ufeff")
conf = json.loads(conf_str)
# 检查配置完整性,并插入
+9 -11
View File
@@ -1,5 +1,4 @@
"""
配置元数据国际化工具
"""配置元数据国际化工具
提供配置元数据的国际化键转换功能
"""
@@ -21,8 +20,7 @@ class ConfigMetadataI18n:
@staticmethod
def _get_i18n_key(group: str, section: str, field: str, attr: str) -> str:
"""
生成国际化键
"""生成国际化键
Args:
group: 配置组, 'ai_group', 'platform_group'
@@ -32,27 +30,27 @@ class ConfigMetadataI18n:
Returns:
国际化键,格式如: 'ai_group.agent_runner.enable.description'
"""
if field:
return f"{group}.{section}.{field}.{attr}"
else:
return f"{group}.{section}.{attr}"
return f"{group}.{section}.{attr}"
@staticmethod
def convert_to_i18n_keys(metadata: dict[str, Any]) -> dict[str, I18nGroup]:
"""
将配置元数据转换为使用国际化键
"""将配置元数据转换为使用国际化键
Args:
metadata: 原始配置元数据字典
Returns:
使用国际化键的配置元数据字典
"""
result: dict[str, I18nGroup] = {}
def convert_items(
group: str, section: str, items: dict[str, object], prefix: str = ""
group: str, section: str, items: dict[str, object], prefix: str = "",
) -> dict[str, object]:
items_result: dict[str, object] = {}
@@ -84,7 +82,7 @@ class ConfigMetadataI18n:
field_items = field_data.get("items")
if _is_str_keyed_dict(field_items):
field_result["items"] = convert_items(
group, section, field_items, field_path
group, section, field_items, field_path,
)
template_schema = field_data.get("template_schema")
@@ -132,7 +130,7 @@ class ConfigMetadataI18n:
section_items = section_data.get("items")
if _is_str_keyed_dict(section_items):
section_result["items"] = convert_items(
group_key, section_key, section_items
group_key, section_key, section_items,
)
group_metadata[section_key] = section_result
+2 -2
View File
@@ -110,7 +110,7 @@ class ConversationManager:
return conv.conversation_id
async def switch_conversation(
self, unified_msg_origin: str, conversation_id: str
self, unified_msg_origin: str, conversation_id: str,
) -> None:
"""切换会话的对话
@@ -337,7 +337,6 @@ class ConversationManager:
conversation_id: str | None = None,
) -> None:
"""Clear the conversation-specific persona override and fall back to default."""
await self.update_conversation(
unified_msg_origin=unified_msg_origin,
conversation_id=conversation_id,
@@ -359,6 +358,7 @@ class ConversationManager:
Raises:
Exception: If the conversation with the given ID is not found
"""
conv = await self.db.get_conversation_by_id(cid=cid)
if not conv:
+1 -1
View File
@@ -346,7 +346,7 @@ class AstrBotCoreLifecycle:
logger.info("AstrBot v" + VERSION)
if os.environ.get("TESTING", ""):
LogManager.configure_logger(
logger, self.astrbot_config, override_level="DEBUG"
logger, self.astrbot_config, override_level="DEBUG",
)
LogManager.configure_trace_logger(self.astrbot_config)
else:

Some files were not shown because too many files have changed in this diff Show More