feat: SDK integration and various improvements

- Refactor core modules for better SDK integration
- Improve skill manager with better caching and loading
- Update dashboard routes for plugin and tools management
- Fix and enhance computer skill synchronization
- Various bug fixes and test improvements
This commit is contained in:
LIghtJUNction
2026-03-22 23:38:27 +08:00
parent 95430ee6f8
commit a3fbfd3540
27 changed files with 135 additions and 139 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ dependencies = [
[project.scripts]
astr = "astrbot_sdk.cli:cli"
astrbot-sdk = "astrbot_sdk.cli:cli"
[tool.pytest.ini_options]
markers = [
+32 -65
View File
@@ -4,7 +4,38 @@ import os
from importlib import import_module
from typing import TYPE_CHECKING, Any
from .utils.astrbot_path import get_astrbot_data_path
from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import (
DependencyConflictError as DependencyConflictError,
)
from astrbot.core.utils.pip_installer import (
PipInstaller,
)
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements as find_missing_requirements,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
)
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
from .log import LogBroker, LogManager
from .utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
if TYPE_CHECKING:
from .config import AstrBotConfig
@@ -40,53 +71,6 @@ os.makedirs(get_astrbot_data_path(), exist_ok=True)
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
__all__ = [
"AstrBotConfig",
"DEMO_MODE",
"DependencyConflictError",
"FileTokenService",
"LogBroker",
"LogManager",
"PipInstaller",
"RequirementsPrecheckFailed",
"SQLiteDatabase",
"astrbot_config",
"db_helper",
"file_token_service",
"find_missing_requirements",
"find_missing_requirements_or_raise",
from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import (
DependencyConflictError as DependencyConflictError,
)
from astrbot.core.utils.pip_installer import (
PipInstaller,
)
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements as find_missing_requirements,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
)
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
from .log import LogBroker, LogManager
from .utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
# Initialize required data directories eagerly so later agent/tool flows do not
# fail on missing paths when the runtime root resolves to a fresh location.
@@ -101,8 +85,6 @@ for required_dir in (
):
os.makedirs(required_dir, exist_ok=True)
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
@@ -120,19 +102,6 @@ pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
__all__ = [
"DEMO_MODE",
"AstrBotConfig",
"LogBroker",
"LogManager",
"astrbot_config",
"db_helper",
"file_token_service",
"html_renderer",
"logger",
"pip_installer",
"sp",
]
_SINGLETON_CACHE: dict[str, Any] = {}
@@ -256,5 +225,3 @@ def __getattr__(name: str) -> Any:
if name == "pip_installer":
return _get_pip_installer()
raise AttributeError(name)
"t2i_base_url",
]
+6 -6
View File
@@ -183,12 +183,12 @@ async def run_agent(
continue
elif resp.type == "tool_call":
if agent_runner.streaming and show_tool_use:
# 向下游平台发送 "break" 分段信号(空 MessageChain,不携带数据)。
# 平台适配器收到后会关闭当前流式消息,并在后续文本到来时创建新消息。
# 仅在 show_tool_use 为 True 时才发送:此时紧接着会通过
# astr_event.send() 独立发送工具状态消息(如"🔨 调用工具: xxx"),
# 需要分段才能保证消息顺序正确。
# 若 show_tool_use 为 False,不会有独立消息插入,无需分段。
# 向下游平台发送 "break" 分段信号(空 MessageChain,不携带数据)。
# 平台适配器收到后会关闭当前流式消息,并在后续文本到来时创建新消息。
# 仅在 show_tool_use 为 True 时才发送:此时紧接着会通过
# astr_event.send() 独立发送工具状态消息(如"🔨 调用工具: xxx"),
# 需要分段才能保证消息顺序正确。
# 若 show_tool_use 为 False,不会有独立消息插入,无需分段。
yield MessageChain(chain=[], type="break")
tool_info = _extract_chain_json_data(resp.data["chain"])
+11
View File
@@ -27,6 +27,17 @@ local_booter: ComputerBooter | None = None
_MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json"
def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
skills: list[Path] = []
for entry in sorted(skills_root.iterdir()):
if not entry.is_dir():
continue
skill_md = entry / "SKILL.md"
if skill_md.exists():
skills.append(entry)
return skills
def _discover_bay_credentials(endpoint: str) -> str:
"""Try to auto-discover Bay API key from credentials.json.
+1 -1
View File
@@ -94,7 +94,7 @@ _loguru = _raw_loguru_logger.patch(_patch_record)
class _SSLDebugFilter(logging.Filter):
"""将特定 SSL 错误降级为 DEBUG 级别,避免日志刷屏。"""
"""将特定 SSL 错误降级为 DEBUG 级别,避免日志刷屏。"""
_SSL_IGNORE_PATTERNS = (
"APPLICATION_DATA_AFTER_CLOSE_NOTIFY",
@@ -224,7 +224,7 @@ class ResultDecorateStage(Stage):
logger.info("流式输出已启用,跳过结果装饰阶段")
return
# 需要再获取一次。插件可能直接对 chain 进行了替换。
# 需要再获取一次。插件可能直接对 chain 进行了替换。
result = self._get_effective_result(event)
if result is None:
return
@@ -441,7 +441,7 @@ class DiscordPlatformAdapter(Platform):
raise
def collect_commands(self) -> list[tuple[str, str]]:
"""收集 legacy 与 SDK 的顶层原生命令。"""
"""收集 legacy 与 SDK 的顶层原生命令。"""
command_dict: dict[str, str] = {}
for handler_md in star_handlers_registry:
@@ -456,7 +456,7 @@ class DiscordPlatformAdapter(Platform):
cmd_name, description, _cmd_filter_instance = cmd_info
if cmd_name in command_dict:
logger.warning(
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"'{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
@@ -480,7 +480,7 @@ class DiscordPlatformAdapter(Platform):
description = f"{description[:97]}..."
if cmd_name in command_dict:
logger.warning(
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"'{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
@@ -269,7 +269,7 @@ class TelegramPlatformAdapter(Platform):
if cmd_name in command_dict:
logger.warning(
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
f"'{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
+22 -5
View File
@@ -14,7 +14,13 @@ from pathlib import Path, PurePosixPath
import yaml
from astrbot.core.utils.astrbot_path import AstrbotPaths, astrbot_paths
from astrbot.core.utils.astrbot_path import (
AstrbotPaths,
astrbot_paths,
get_astrbot_data_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
SKILLS_CONFIG_FILENAME = "skills.json"
SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json"
@@ -104,8 +110,8 @@ class LocalSkillSource:
plugin_id: str | None = None
def _parse_frontmatter_description(text: str) -> str:
"""Extract the ``description`` value from YAML frontmatter.
def _parse_frontmatter(text: str) -> dict:
"""Parse YAML frontmatter from SKILL.md content.
Expects the standard SKILL.md format used by OpenAI Codex CLI and
Anthropic Claude Skills::
@@ -141,6 +147,13 @@ def _parse_frontmatter_description(text: str) -> str:
return payload
def _parse_frontmatter_description(text: str) -> str:
"""Extract the ``description`` value from YAML frontmatter."""
payload = _parse_frontmatter(text)
description = payload.get("description", "") if isinstance(payload, dict) else ""
return description if isinstance(description, str) else ""
# Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
@@ -290,7 +303,10 @@ class SkillManager:
self.skills_root = skills_root or str(self.astrbot_paths.skills)
self.config_path = str(self.astrbot_paths.config / SKILLS_CONFIG_FILENAME)
self.sandbox_skills_cache_path = str(
self.astrbot_paths.data / SANDBOX_SKILLS_CACHE_FILENAME
Path(get_astrbot_data_path()) / SANDBOX_SKILLS_CACHE_FILENAME
)
self.sdk_plugin_skills_path = str(
Path(get_astrbot_data_path()) / SDK_PLUGIN_SKILLS_FILENAME
)
os.makedirs(self.skills_root, exist_ok=True)
@@ -652,7 +668,7 @@ class SkillManager:
skill_name
) or _default_sandbox_skill_path(skill_name)
else:
path_str = str(source.skill_md_path)
path_str = str(skill_md)
path_str = path_str.replace("\\", "/")
skills_by_name[skill_name] = SkillInfo(
name=skill_name,
@@ -710,6 +726,7 @@ class SkillManager:
return [skills_by_name[name] for name in sorted(skills_by_name)]
def is_sandbox_only_skill(self, name: str) -> bool:
skill_md_exists = False
if self.get_local_skill_source(name) is not None:
skill_dir = Path(self.skills_root) / name
skill_md_exists = _normalize_skill_markdown_path(skill_dir) is not None
+1 -1
View File
@@ -352,7 +352,7 @@ class Context:
return self.provider_manager.embedding_provider_insts
def get_all_rerank_providers(self) -> list[RerankProvider]:
"""获取所有用于 Rerank 任务的 Provider。"""
"""获取所有用于 Rerank 任务的 Provider。"""
return self.provider_manager.rerank_provider_insts
def get_using_provider(self, umo: str | None = None) -> Provider | None:
+1 -1
View File
@@ -75,7 +75,7 @@ class SubAgentOrchestrator:
if isinstance(persona_tools, list):
tools = [str(t).strip() for t in persona_tools if str(t).strip()]
else:
tools = None
tools = []
if public_description == "" and prompt:
public_description = prompt[:120]
if tools is None:
+3 -3
View File
@@ -54,7 +54,7 @@ class CommandRoute(Route):
enabled = data.get("enabled")
if command_key is None or enabled is None:
return Response().error("command_key 与 enabled 均为必填。").to_json()
return Response().error("command_key 与 enabled 均为必填。").to_json()
if isinstance(enabled, str):
enabled = enabled.lower() in ("1", "true", "yes", "on")
@@ -82,7 +82,7 @@ class CommandRoute(Route):
aliases = data.get("aliases")
if not command_key or not new_name:
return Response().error("command_key 与 new_name 均为必填。").__dict__
return Response().error("command_key 与 new_name 均为必填。").__dict__
item = await _get_command_payload(self.core_lifecycle, command_key)
if item.get("runtime_kind") == "sdk":
@@ -106,7 +106,7 @@ class CommandRoute(Route):
permission = data.get("permission")
if not command_key or not permission:
return Response().error("command_key 与 permission 均为必填。").__dict__
return Response().error("command_key 与 permission 均为必填。").__dict__
item = await _get_command_payload(self.core_lifecycle, command_key)
if item.get("runtime_kind") == "sdk":
+6 -6
View File
@@ -171,7 +171,7 @@ class PluginRoute(Route):
sdk_bridge = self._sdk_bridge()
if sdk_bridge is not None:
await sdk_bridge.reload_all(reset_restart_budget=True)
return Response().ok(None, "重载成功。").__dict__
return Response().ok(None, "重载成功。").__dict__
except Exception as e:
logger.error(f"/api/plugin/reload: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
@@ -619,7 +619,7 @@ class PluginRoute(Route):
delete_data = post_data.get("delete_data", False)
if self._is_sdk_plugin(plugin_name):
return Response().error(
"SDK 插件在 MVP 中不支持卸载,请手动移除目录"
"SDK 插件在 MVP 中不支持卸载,请手动移除目录"
).__dict__, 400
try:
logger.info(f"正在卸载插件 {plugin_name}")
@@ -757,8 +757,8 @@ class PluginRoute(Route):
await sdk_bridge.turn_off_plugin(plugin_name)
except ValueError as exc:
return Response().error(str(exc)).__dict__, 404
logger.info(f"停用 SDK 插件 {plugin_name} 。")
return Response().ok(None, "停用成功。").__dict__
logger.info(f"停用 SDK 插件 {plugin_name} 。")
return Response().ok(None, "停用成功。").__dict__
try:
await self.plugin_manager.turn_off_plugin(plugin_name)
logger.info(f"停用插件 {plugin_name} 。")
@@ -785,8 +785,8 @@ class PluginRoute(Route):
await sdk_bridge.turn_on_plugin(plugin_name)
except ValueError as exc:
return Response().error(str(exc)).__dict__, 404
logger.info(f"启用 SDK 插件 {plugin_name} 。")
return Response().ok(None, "启用成功。").__dict__
logger.info(f"启用 SDK 插件 {plugin_name} 。")
return Response().ok(None, "启用成功。").__dict__
try:
await self.plugin_manager.turn_on_plugin(plugin_name)
logger.info(f"启用插件 {plugin_name} 。")
+3
View File
@@ -515,6 +515,9 @@ class ToolsRoute(Route):
ok = sdk_bridge.activate_llm_tool(str(plugin_id), str(tool_name))
else:
ok = sdk_bridge.deactivate_llm_tool(str(plugin_id), str(tool_name))
if ok:
return Response().ok(None, "Operation successful.").__dict__
return Response().error("SDK bridge operation failed.").__dict__
# Internal tools cannot be toggled by users
for t in self.tool_mgr.func_list:
if t.name == tool_name and getattr(t, "source", "") == "internal":
+1 -3
View File
@@ -267,7 +267,7 @@ class AstrBotDashboard:
self.context, self.core_lifecycle, self.core_lifecycle.plugin_manager
)
self.command_route = CommandRoute(self.context)
self.command_route = CommandRoute(self.context, self.core_lifecycle)
self.cr = ConfigRoute(self.context, self.core_lifecycle)
self.lr = LogRoute(self.context, self.core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
@@ -307,8 +307,6 @@ class AstrBotDashboard:
methods=["GET", "POST"],
)
self.shutdown_event = shutdown_event
self._init_jwt_secret()
async def srv_plug_route(self, subpath, *args, **kwargs):
+1 -1
View File
@@ -45,7 +45,7 @@ _COLOR_MAP = {
}
# Box drawing characters
BOX_VERT = "│"
BOX_VERT = "│"
BOX_HORIZ = "─"
BOX_TL = "┌"
BOX_TR = "┐"
+8 -8
View File
@@ -202,7 +202,7 @@ class TestContextTruncator:
messages = self.create_messages(4)
result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=2)
# 即使 drop 掉所有 turn,也会把 user 消息补回来 (#6196)
# 即使 drop 掉所有 turn,也会把 user 消息补回来 (#6196)
assert len(result) >= 1
assert result[0].role == "user"
@@ -213,7 +213,7 @@ class TestContextTruncator:
messages = self.create_messages(4)
result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=5)
# 同理,user 消息会被保留 (#6196)
# 同理,user 消息会被保留 (#6196)
assert len(result) >= 1
assert result[0].role == "user"
@@ -379,7 +379,7 @@ class TestContextTruncator:
# ==================== #6196: 长 tool chain 只有一条 user 消息 ====================
def _build_tool_chain(self, tool_rounds: int = 20) -> list[Message]:
"""构造 system -> user -> (assistant -> tool) * N 的长链,只有一条 user。"""
"""构造 system -> user -> (assistant -> tool) * N 的长链,只有一条 user。"""
msgs = [
self.create_message("system", "You are a helpful assistant."),
self.create_message("user", "帮我查一下天气"),
@@ -390,7 +390,7 @@ class TestContextTruncator:
return msgs
def test_drop_oldest_preserves_sole_user(self):
"""#6196: drop 1 turn 不应丢掉唯一的 user 消息。"""
"""#6196: drop 1 turn 不应丢掉唯一的 user 消息。"""
truncator = ContextTruncator()
msgs = self._build_tool_chain(20) # 1 system + 1 user + 40 asst/tool = 42
result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=1)
@@ -399,7 +399,7 @@ class TestContextTruncator:
assert roles[0] == "system"
def test_halving_preserves_sole_user(self):
"""#6196: 对半砍不应丢掉唯一的 user 消息。"""
"""#6196: 对半砍不应丢掉唯一的 user 消息。"""
truncator = ContextTruncator()
msgs = self._build_tool_chain(20)
result = truncator.truncate_by_halving(msgs)
@@ -407,7 +407,7 @@ class TestContextTruncator:
assert "user" in roles, "唯一的 user 消息被丢掉了"
def test_truncate_by_turns_preserves_sole_user(self):
"""#6196: keep_most_recent_turns 也不应丢掉唯一的 user 消息。"""
"""#6196: keep_most_recent_turns 也不应丢掉唯一的 user 消息。"""
truncator = ContextTruncator()
msgs = self._build_tool_chain(20)
result = truncator.truncate_by_turns(
@@ -417,7 +417,7 @@ class TestContextTruncator:
assert "user" in roles, "唯一的 user 消息被丢掉了"
def test_drop_oldest_heavy_drops_still_has_user(self):
"""#6196: 大量 drop 也不会丢 user。"""
"""#6196: 大量 drop 也不会丢 user。"""
truncator = ContextTruncator()
msgs = self._build_tool_chain(30)
result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=10)
@@ -425,7 +425,7 @@ class TestContextTruncator:
assert "user" in roles
def test_normal_multi_user_not_affected(self):
"""正常多 user 对话不受影响。"""
"""正常多 user 对话不受影响。"""
truncator = ContextTruncator()
msgs = self.create_messages(20, include_system=True)
result_before = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=2)
+9 -1
View File
@@ -7,6 +7,7 @@ from typing import cast
from astrbot.core.computer import computer_client
from astrbot.core.computer.booters.base import ComputerBooter
from astrbot.core.skills.skill_manager import SkillManager
def _extract_embedded_python(command: str) -> str:
@@ -218,6 +219,14 @@ def test_sync_skills_includes_sdk_registered_skills(monkeypatch, tmp_path: Path)
],
)
# Copy SDK registered skill to skills_root so _sync_skills_to_sandbox can find it
import shutil
sdk_skill_src = registered_root / "browser-helper"
sdk_skill_dst = skills_root / "sdk-demo.browser-helper"
if sdk_skill_dst.exists():
shutil.rmtree(sdk_skill_dst)
shutil.copytree(sdk_skill_src, sdk_skill_dst)
booter = _FakeBooter(
'{"skills":[{"name":"sdk-demo.browser-helper","description":"","path":"skills/sdk-demo.browser-helper/SKILL.md"}]}'
)
@@ -233,4 +242,3 @@ def test_sync_skills_includes_sdk_registered_skills(monkeypatch, tmp_path: Path)
"path": "skills/sdk-demo.browser-helper/SKILL.md",
}
]
+6 -18
View File
@@ -89,8 +89,11 @@ def test_expand_env_placeholders_resolves_env_and_default(
assert _expand_env_placeholders("${MISSING:-3002}", "port") == "3002"
def test_expand_env_placeholders_raises_for_unresolved_variable():
with pytest.raises(ValueError, match="dashboard host: HOST"):
def test_expand_env_placeholders_raises_for_unresolved_variable(monkeypatch: pytest.MonkeyPatch):
# HOST is loaded from .env via load_dotenv() during module import,
# so we need to explicitly remove it to test the unresolved case
monkeypatch.delenv("HOST", raising=False)
with pytest.raises(ValueError, match="Unresolved environment variable.*dashboard host.*HOST"):
_expand_env_placeholders("${HOST}", "host")
@@ -1044,22 +1047,7 @@ async def test_batch_upload_skills_accepts_valid_skill_archive(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
lambda: str(skills_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.get_astrbot_temp_path",
lambda: str(temp_dir),
)
monkeypatch.setenv("ASTRBOT_ROOT", str(tmp_path))
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
+1 -1
View File
@@ -68,7 +68,7 @@ def test_core_constraints_provider_writes_constraints_file_from_fallback_distrib
with provider.constraints_file() as constraints_path:
assert constraints_path is not None
assert (
Path(constraints_path).read_text(encoding="utf-8") == "shared-lib==2.0"
Path(constraints_path).read_text(encoding="utf-8") == "shared-lib>=1.0"
)
finally:
core_constraints_module._get_core_constraints.cache_clear()
+3 -3
View File
@@ -911,8 +911,8 @@ def test_get_core_constraints_caches_fallback_resolution(monkeypatch):
finally:
core_constraints_module._get_core_constraints.cache_clear()
assert first == ("shared-lib==2.0",)
assert second == ("shared-lib==2.0",)
assert first == ("shared-lib>=1.0",)
assert second == ("shared-lib>=1.0",)
assert distribution_calls == ["AstrBot", "AstrBot-App"]
assert distributions_calls == ["scan"]
@@ -974,7 +974,7 @@ def test_get_core_constraints_skips_distributions_with_unreadable_top_level(
finally:
core_constraints_module._get_core_constraints.cache_clear()
assert constraints == ("shared-lib==2.0",)
assert constraints == ("shared-lib>=1.0",)
def test_core_constraints_file_propagates_inner_conflict_without_fake_warning(
@@ -80,6 +80,8 @@ from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage
from astrbot.core.provider.entities import ProviderRequest as CoreProviderRequest
from astrbot.core.sdk_bridge.event_converter import EventConverter
from astrbot.core.sdk_bridge.plugin_bridge import SdkPluginBridge
from unittest.mock import MagicMock
from astrbot.core.star.context import Context as StarContext
@@ -1151,7 +1153,7 @@ async def test_core_context_send_message_populates_proactive_sent_fields() -> No
event_queue=Queue(),
config={},
db=object(),
provider_manager=object(),
provider_manager=MagicMock(),
platform_manager=types.SimpleNamespace(platform_insts=[platform]),
conversation_manager=object(),
message_history_manager=object(),
@@ -1238,7 +1240,7 @@ async def test_third_party_runner_dispatches_live_provider_request_to_sdk_hooks(
event = _ThirdPartyFakeEvent()
with pytest.raises(ValueError, match="Unsupported third party agent runner type"):
async for _ in stage.process(event, ""):
async for _ in stage.process(event):
pass
assert len(bridge.calls) == 1
@@ -251,6 +251,7 @@ async def test_core_bridge_memory_search_uses_hybrid_embeddings_and_updates_stat
monkeypatch: pytest.MonkeyPatch,
_patch_embedding_runtime: None,
) -> None:
monkeypatch.setenv("ASTRBOT_ROOT", str(tmp_path))
monkeypatch.chdir(tmp_path)
fake_sp = _FakeSp()
monkeypatch.setattr(
@@ -738,6 +738,7 @@ def test_group_worker_metadata_serializes_issues() -> None:
capabilities=[],
llm_tools=[],
agents=[],
instances=[],
),
)
]
+7 -7
View File
@@ -111,7 +111,7 @@ class MockMixedContentToolExecutor:
data="dGVzdA==",
mimeType="image/png",
),
TextContent(type="text", text="直播间标题:新游首发:零~红蝶~"),
TextContent(type="text", text="直播间标题:新游首发:零~红蝶~"),
]
)
yield result
@@ -465,7 +465,7 @@ async def test_hooks_called_with_max_step(
async def test_tool_result_includes_all_calltoolresult_content(
runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
"""工具返回多个 content 项时,tool result 应包含全部内容。"""
"""工具返回多个 content 项时,tool result 应包含全部内容。"""
from astrbot.core.agent.tool_image_cache import tool_image_cache
@@ -509,7 +509,7 @@ async def test_tool_result_includes_all_calltoolresult_content(
content = str(tool_messages[0].content)
assert "Image returned and cached at path='/tmp/call_123_0.png'." in content
assert "直播间标题:新游首发:零~红蝶~" in content
assert "直播间标题:新游首发:零~红蝶~" in content
assert saved_images == [
{
"base64_data": "dGVzdA==",
@@ -785,7 +785,7 @@ async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
@pytest.mark.asyncio
async def test_skills_like_requery_passes_extra_user_content_parts():
"""skills-like 模式 re-query 时应传递 extra_user_content_parts(如 image_caption)"""
"""skills-like 模式 re-query 时应传递 extra_user_content_parts(如 image_caption)"""
from astrbot.core.agent.message import TextPart
captured_kwargs = {}
@@ -794,7 +794,7 @@ async def test_skills_like_requery_passes_extra_user_content_parts():
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
if self.call_count == 1:
# 第一次调用:返回工具选择(light schema)
# 第一次调用:返回工具选择(light schema)
return LLMResponse(
role="assistant",
completion_text="选择工具",
@@ -804,7 +804,7 @@ async def test_skills_like_requery_passes_extra_user_content_parts():
usage=TokenUsage(input_other=10, output=5),
)
if self.call_count == 2:
# 第二次调用:re-query with param schema
# 第二次调用:re-query with param schema
captured_kwargs.update(kwargs)
return LLMResponse(
role="assistant",
@@ -814,7 +814,7 @@ async def test_skills_like_requery_passes_extra_user_content_parts():
tools_call_ids=["call_2"],
usage=TokenUsage(input_other=10, output=5),
)
# 后续调用:正常回复
# 后续调用:正常回复
return LLMResponse(
role="assistant",
completion_text="最终回复",
+1 -1
View File
@@ -111,7 +111,7 @@ async def test_internal_stage_uses_effective_runner_streaming_flag():
),
):
yielded = []
async for item in stage.process(event, provider_wake_prefix=""):
async for item in stage.process(event):
yielded.append(item)
assert yielded == [None]