mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-01 15:32:49 +08:00
ruff check --fix --select ALL
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
Vendored
+33
@@ -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
|
||||
}
|
||||
Vendored
+192
@@ -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
@@ -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,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,4 +1,3 @@
|
||||
"""
|
||||
依赖注入
|
||||
"""依赖注入
|
||||
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
# 继续处理下一句
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,6 +68,7 @@ def get_backup_directories() -> dict[str, str]:
|
||||
|
||||
Returns:
|
||||
dict: 键为备份文件中的目录名称,值为目录的绝对路径
|
||||
|
||||
"""
|
||||
return {
|
||||
"plugins": get_astrbot_plugin_path(), # 插件本体
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
"""
|
||||
Browser automation component
|
||||
"""Browser automation component
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
"""
|
||||
Python/IPython component
|
||||
"""Python/IPython component
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Shell component
|
||||
"""Shell component
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 检查配置完整性,并插入
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user