mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
* feat: supports plugin to add skills * fix tests * fix: fs tools * Add tests for plugin skills handling and improve skill management - Implement test for restricted local member reading plugin skill inventory even if the plugin is inactive. - Ensure that the skill synchronization process retains built-in skills when local skills are empty, including proper handling of plugin paths. - Update dashboard tests to verify that plugin details include components when requested. - Enhance skill metadata enrichment tests to include inactive plugin-provided skills for inventory. - Add filtering tests for plugin skills based on current configuration, ensuring only allowed plugins are considered and inactive plugins are skipped. Co-authored-by: Copilot <copilot@github.com> * fix: handle PPIO platform context-length error messages (#7888) * fix: 压缩算法删除 user 消息 Bug 修复 * perf: improve truncate algo * fix: improve context length error detection for PPIO platform compatibility - Extend error detection to handle PPIO's error message format: 'The input is longer than the model's context length' - Add case-insensitive matching using .lower() for robustness - Maintain backward compatibility with existing 'maximum context length' check This fixes the issue where PPIO platform models (e.g., ppio/zai-org/glm-5-turbo) would fail with AgentState.ERROR due to unrecognized context length errors. --------- Co-authored-by: Soulter <905617992@qq.com> * fix: 支持微信客服文件消息 (#7923) * fix: 支持微信客服文件消息 * fix: remove WeCom file message placeholder * fix(provider): fix Anthropic custom headers and system prompt compatibility (#7587) * fix(provider): fix Anthropic custom headers and system prompt compatibility - Pass custom_headers via AsyncAnthropic's `default_headers` parameter instead of creating a separate httpx.AsyncClient. This avoids `isinstance` check failures when multiple httpx installations exist on sys.path (e.g. bundled Python + system Python). - Use list format for the `system` parameter (`[{"type": "text", ...}]`) instead of a plain string. The list format is supported by the official Anthropic API and is also compatible with third-party API proxies that reject the string format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(provider): fix Anthropic custom headers and system prompt compatibility - Pass custom_headers via AsyncAnthropic's `default_headers` parameter instead of creating a separate httpx.AsyncClient. This avoids `isinstance` check failures when multiple httpx installations exist on sys.path (e.g. bundled Python + system Python). - Use list format for the `system` parameter (`[{"type": "text", ...}]`) instead of a plain string. The list format is supported by the official Anthropic API and is also compatible with third-party API proxies that reject the string format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add test unit --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * perf: improve logic of adding models Co-authored-by: piexian <piexian@users.noreply.github.com> * chore: remove redundant logger messages and improve log clarity Co-authored-by: Copilot <copilot@github.com> * chore: ruff format * docs: update knowledge base docs closes: #7962 * fix(#7907): send_message_to_user cron 场景下 session 容错 (#7911) * fix: send_message_to_user cron 场景下 session 容错 (#7907) - LLM 在主动场景可能只传 session_id 而非完整三段式, from_str 失败时用 current_session 补全前两段。 Co-authored-by: Copilot <copilot@github.com> * fix: 限制 session 补全仅对裸 session_id 生效,避免误修带冒号的错误输入 (#7907) * feat: add session information to cron job payload Co-authored-by: Copilot <copilot@github.com> * fix: improve clarity and consistency of safety mode prompts Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> * perf: tool rendering in conversation page (#7937) * fix(dashboard): route conversation history tool messages through ToolCallCard When viewing conversation history, large tool outputs (e.g. a single git log --stat producing tens of KB) caused the browser renderer to freeze. Root cause: formattedMessages mapped every role (including tool / system / _checkpoint) into user/bot bubbles, and bot plain strings went through markstream-vue's MarkdownRender. Single 88KB tool messages plus 88-of-them adding up to ~349KB of synchronous markdown parsing was enough to block the main thread for 5+ seconds. This patch: - Indexes tool-role messages by tool_call_id - Filters formattedMessages to user/assistant only — tool, system and _checkpoint roles no longer render as standalone bubbles - Converts assistant.tool_calls (OpenAI shape, with tc.name/tc.arguments fallbacks) into the existing tool_call MessagePart, attaching the paired result so MessageList's ToolCallCard renders it (default collapsed, no longer feeds large strings into the markdown renderer) - Drops empty placeholder plain parts when an assistant message only carries tool_calls - Sets ts/finished_ts to 0 as a sentinel: ToolCallCard.toolCallDuration returns "" when startTime <= 0, suppressing a misleading "0ms" duration that would otherwise appear because conversation history has no real timing data Behavior change: tool results are now embedded in their assistant's ToolCallCard.result instead of appearing as separate bot bubbles. This matches the main chat UI's behavior. Fixes #7929 Refs #7372 #7456 * style(dashboard): use single scrollbar in conversation history preview ToolCallCard's result/args panes have their own max-height + overflow, which produced a nested scrollbar when nested inside the history preview's already-scrollable .conversation-messages-container. Override those constraints inside the preview only — the outer 500px-bounded container already provides scroll bounds, so a single scrollbar feels cleaner. The main chat UI is unaffected. --------- Co-authored-by: wanger <wanger@example.com> * fix: ruff format * feat: add python tool timeout param (#7953) * feat: add python tool timeout param * Update python.py --------- Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com> * fix: 钉钉连接超时后自动重连失败 (#7924) * fix: improve DingTalk adapter error handling in run() method * fix: add retry logic for DingTalk SDK task unexpected exit * fix: use task.add_done_callback to wake thread on task completion, handle UnboundLocalError * refactor: extract retry logic into handle_retry helper function --------- Co-authored-by: Blueteemo <Blueteemo@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: leonforcode <leonbeyourside01@gmail.com> Co-authored-by: AstralSolipsism <134063164+AstralSolipsism@users.noreply.github.com> Co-authored-by: Pink YuDeer <wer00001@outlook.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: piexian <piexian@users.noreply.github.com> Co-authored-by: NayukiMeko <ChibaNayuki@163.com> Co-authored-by: wanger <122891289+10knamesmore@users.noreply.github.com> Co-authored-by: wanger <wanger@example.com> Co-authored-by: Haoran Xu <3230105281@zju.edu.cn> Co-authored-by: 千岚之夏 <108566281+Blueteemo@users.noreply.github.com> Co-authored-by: Blueteemo <Blueteemo@users.noreply.github.com>
209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from astrbot.core.computer import computer_client
|
|
from astrbot.core.computer.booters.base import ComputerBooter
|
|
|
|
|
|
def _extract_embedded_python(command: str) -> str:
|
|
start_marker = "$PYBIN - <<'PY'\n"
|
|
end_marker = "\nPY"
|
|
start = command.find(start_marker)
|
|
assert start != -1
|
|
start += len(start_marker)
|
|
end = command.rfind(end_marker)
|
|
assert end != -1
|
|
return command[start:end]
|
|
|
|
|
|
class _FakeShell:
|
|
def __init__(self, sync_payload_json: str):
|
|
self.sync_payload_json = sync_payload_json
|
|
self.commands: list[str] = []
|
|
|
|
async def exec(self, command: str, **kwargs):
|
|
_ = kwargs
|
|
self.commands.append(command)
|
|
if "PYBIN" in command and "managed_skills" in command:
|
|
return {
|
|
"success": True,
|
|
"stdout": self.sync_payload_json,
|
|
"stderr": "",
|
|
"exit_code": 0,
|
|
}
|
|
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
|
|
|
|
|
|
class _FakeBooter:
|
|
def __init__(self, sync_payload_json: str):
|
|
self.shell = _FakeShell(sync_payload_json)
|
|
self.uploads: list[tuple[str, str]] = []
|
|
|
|
async def upload_file(self, path: str, file_name: str) -> dict:
|
|
self.uploads.append((path, file_name))
|
|
return {"success": True}
|
|
|
|
|
|
def test_sync_skills_keeps_builtin_skills_when_local_is_empty(
|
|
monkeypatch, tmp_path: Path
|
|
):
|
|
skills_root = tmp_path / "skills"
|
|
plugins_root = tmp_path / "plugins"
|
|
temp_root = tmp_path / "temp"
|
|
skills_root.mkdir(parents=True, exist_ok=True)
|
|
plugins_root.mkdir(parents=True, exist_ok=True)
|
|
temp_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
captured = {"skills": None}
|
|
|
|
def _fake_set_cache(self, skills):
|
|
captured["skills"] = skills
|
|
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
|
|
lambda: str(skills_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.skills.skill_manager.get_astrbot_plugin_path",
|
|
lambda: str(plugins_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
|
|
lambda: str(temp_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
|
|
_fake_set_cache,
|
|
)
|
|
|
|
booter = _FakeBooter(
|
|
'{"skills":[{"name":"python-sandbox","description":"ship","path":"skills/python-sandbox/SKILL.md"}]}'
|
|
)
|
|
asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter)))
|
|
|
|
assert booter.uploads == []
|
|
assert any(cmd == "rm -f skills/skills.zip" for cmd in booter.shell.commands)
|
|
assert captured["skills"] == [
|
|
{
|
|
"name": "python-sandbox",
|
|
"description": "ship",
|
|
"path": "skills/python-sandbox/SKILL.md",
|
|
}
|
|
]
|
|
|
|
|
|
def test_sync_skills_uses_managed_strategy_instead_of_wiping_all(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
):
|
|
skills_root = tmp_path / "skills"
|
|
temp_root = tmp_path / "temp"
|
|
skill_dir = skills_root / "custom-agent-skill"
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
|
|
temp_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
captured = {"skills": None}
|
|
|
|
def _fake_set_cache(self, skills):
|
|
captured["skills"] = skills
|
|
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
|
|
lambda: str(skills_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
|
|
lambda: str(temp_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
|
|
_fake_set_cache,
|
|
)
|
|
|
|
booter = _FakeBooter(
|
|
'{"skills":[{"name":"custom-agent-skill","description":"","path":"skills/custom-agent-skill/SKILL.md"}]}'
|
|
)
|
|
asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter)))
|
|
|
|
assert len(booter.uploads) == 1
|
|
assert booter.uploads[0][1] == "skills/skills.zip"
|
|
assert not any(
|
|
"find skills -mindepth 1 -delete" in cmd for cmd in booter.shell.commands
|
|
)
|
|
assert captured["skills"] == [
|
|
{
|
|
"name": "custom-agent-skill",
|
|
"description": "",
|
|
"path": "skills/custom-agent-skill/SKILL.md",
|
|
}
|
|
]
|
|
|
|
|
|
def test_sync_skills_includes_plugin_provided_skills(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
):
|
|
skills_root = tmp_path / "skills"
|
|
plugins_root = tmp_path / "plugins"
|
|
temp_root = tmp_path / "temp"
|
|
skills_root.mkdir(parents=True, exist_ok=True)
|
|
temp_root.mkdir(parents=True, exist_ok=True)
|
|
plugin_skill_dir = plugins_root / "astrbot_plugin_demo" / "skills" / "demo-skill"
|
|
plugin_skill_dir.mkdir(parents=True)
|
|
plugin_skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
|
|
|
|
captured = {"skills": None}
|
|
|
|
def _fake_set_cache(self, skills):
|
|
captured["skills"] = skills
|
|
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
|
|
lambda: str(skills_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.skills.skill_manager.get_astrbot_plugin_path",
|
|
lambda: str(plugins_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
|
|
lambda: str(temp_root),
|
|
)
|
|
monkeypatch.setattr(
|
|
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
|
|
_fake_set_cache,
|
|
)
|
|
|
|
booter = _FakeBooter(
|
|
'{"skills":[{"name":"demo-skill","description":"","path":"skills/demo-skill/SKILL.md"}]}'
|
|
)
|
|
asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter)))
|
|
|
|
assert len(booter.uploads) == 1
|
|
assert booter.uploads[0][1] == "skills/skills.zip"
|
|
assert captured["skills"] == [
|
|
{
|
|
"name": "demo-skill",
|
|
"description": "",
|
|
"path": "skills/demo-skill/SKILL.md",
|
|
}
|
|
]
|
|
|
|
|
|
def test_build_scan_command_frontmatter_newline_is_escaped_literal():
|
|
command = computer_client._build_scan_command()
|
|
script = _extract_embedded_python(command)
|
|
|
|
assert 'frontmatter = "\\n".join(lines[1:end_idx])' in script
|
|
|
|
|
|
def test_build_scan_command_embedded_python_is_syntax_valid():
|
|
command = computer_client._build_scan_command()
|
|
script = _extract_embedded_python(command)
|
|
|
|
compile(script, "<scan_script>", "exec")
|