Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion astrbot/core/agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Generic

from .hooks import BaseAgentRunHooks
Expand All @@ -11,5 +11,6 @@ class Agent(Generic[TContext]):
name: str
instructions: str | None = None
tools: list[str | FunctionTool] | None = None
skills: list[str] | None = field(default_factory=list)
run_hooks: BaseAgentRunHooks[TContext] | None = None
begin_dialogs: list[Any] | None = None
43 changes: 41 additions & 2 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.tools.computer_tools import (
CuaKeyboardTypeTool,
CuaMouseClickTool,
Expand Down Expand Up @@ -292,6 +293,37 @@ def _build_handoff_toolset(
toolset.add_tool(tool_name_or_obj)
return None if toolset.empty() else toolset

@classmethod
def _build_handoff_system_prompt(
cls,
instructions: str | None,
skill_names: list[str] | None,
runtime: str,
) -> str:
system_prompt = instructions or ""
skills_prompt = cls._build_handoff_skills_prompt(skill_names, runtime)
if skills_prompt:
system_prompt += f"\n{skills_prompt}\n"
return system_prompt
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The construction of the system prompt can lead to leading or trailing newlines if instructions is empty or skills_prompt is present. It's generally cleaner to join the prompt parts with double newlines to clearly separate sections for the LLM.

        prompt_parts = []
        if instructions:
            prompt_parts.append(instructions.strip())

        skills_prompt = cls._build_handoff_skills_prompt(skill_names, runtime)
        if skills_prompt:
            prompt_parts.append(skills_prompt.strip())

        return "\n\n".join(prompt_parts)


@classmethod
def _build_handoff_skills_prompt(
cls,
skill_names: list[str] | None,
runtime: str,
) -> str:
if skill_names == []:
return ""

skills = SkillManager().list_skills(active_only=True, runtime=runtime)
if skill_names is not None:
allowed = set(skill_names)
skills = [skill for skill in skills if skill.name in allowed]

if not skills:
return ""
return build_skills_prompt(skills)

@classmethod
async def _execute_handoff(
cls,
Expand Down Expand Up @@ -348,15 +380,22 @@ async def _execute_handoff(
except Exception:
continue

prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
cfg = ctx.get_config(umo=umo)
prov_settings: dict = cfg.get("provider_settings", {})
runtime = str(prov_settings.get("computer_use_runtime", "local"))
system_prompt = cls._build_handoff_system_prompt(
tool.agent.instructions,
getattr(tool.agent, "skills", []),
runtime,
)
agent_max_step = int(prov_settings.get("max_agent_step", 30))
stream = prov_settings.get("streaming_response", False)
llm_resp = await ctx.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
prompt=input_,
image_urls=image_urls,
system_prompt=tool.agent.instructions,
system_prompt=system_prompt,
tools=toolset,
contexts=contexts,
max_steps=agent_max_step,
Expand Down
9 changes: 9 additions & 0 deletions astrbot/core/subagent_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
if provider_id is not None:
provider_id = str(provider_id).strip() or None
tools = item.get("tools", [])
skills = item.get("skills", [])
begin_dialogs = None

if persona_data:
Expand All @@ -71,6 +72,7 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
persona_data.get("_begin_dialogs_processed")
)
tools = persona_data.get("tools")
skills = persona_data.get("skills", [])
if public_description == "" and prompt:
public_description = prompt[:120]
if tools is None:
Expand All @@ -79,11 +81,18 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
tools = []
else:
tools = [str(t).strip() for t in tools if str(t).strip()]
if skills is None:
skills = None
elif not isinstance(skills, list):
skills = []
else:
skills = [str(s).strip() for s in skills if str(s).strip()]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The list comprehension str(s).strip() will convert None values in the skills list to the string "None". It's safer to filter out non-truthy values before converting to string.

Suggested change
skills = [str(s).strip() for s in skills if str(s).strip()]
skills = [str(s).strip() for s in skills if s and str(s).strip()]


agent = Agent[AstrAgentContext](
name=name,
instructions=instructions,
tools=tools, # type: ignore
skills=skills,
)
agent.begin_dialogs = begin_dialogs
# The tool description should be a short description for the main LLM,
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/test_astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.message.components import Image
from astrbot.core.skills.skill_manager import SkillInfo


class _DummyEvent:
Expand Down Expand Up @@ -321,6 +322,92 @@ async def _fake_tool_loop_agent(**kwargs):
assert captured["tool_call_timeout"] == 120


def test_build_handoff_skills_prompt_filters_selected_skills(
monkeypatch: pytest.MonkeyPatch,
):
manager = SimpleNamespace(
list_skills=lambda **_kwargs: [
SkillInfo(
name="web-search-skill",
description="Search the web",
path="/skills/web-search-skill/SKILL.md",
active=True,
),
SkillInfo(
name="other-skill",
description="Other work",
path="/skills/other-skill/SKILL.md",
active=True,
),
],
)
monkeypatch.setattr(
"astrbot.core.astr_agent_tool_exec.SkillManager",
lambda: manager,
)

prompt = FunctionToolExecutor._build_handoff_skills_prompt(
["web-search-skill"],
"local",
)

assert "web-search-skill" in prompt
assert "Search the web" in prompt
assert "other-skill" not in prompt


@pytest.mark.asyncio
async def test_execute_handoff_appends_agent_skills_prompt(
monkeypatch: pytest.MonkeyPatch,
):
captured: dict = {}

async def _fake_get_current_chat_provider_id(_umo):
return "provider-id"

async def _fake_tool_loop_agent(**kwargs):
captured.update(kwargs)
return SimpleNamespace(completion_text="ok")

context = SimpleNamespace(
get_current_chat_provider_id=_fake_get_current_chat_provider_id,
tool_loop_agent=_fake_tool_loop_agent,
get_config=lambda **_kwargs: {"provider_settings": {}},
)
event = _DummyEvent([])
run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context))
tool = SimpleNamespace(
name="transfer_to_subagent",
provider_id=None,
agent=SimpleNamespace(
name="subagent",
tools=[],
skills=["web-search-skill"],
instructions="subagent-instructions",
begin_dialogs=[],
run_hooks=None,
),
)
monkeypatch.setattr(
FunctionToolExecutor,
"_build_handoff_skills_prompt",
classmethod(lambda cls, skill_names, runtime: "SKILL PROMPT"),
)

results = []
async for result in FunctionToolExecutor._execute_handoff(
tool,
run_context,
image_urls_prepared=True,
input="hello",
image_urls=[],
):
results.append(result)

assert len(results) == 1
assert captured["system_prompt"] == "subagent-instructions\nSKILL PROMPT\n"


@pytest.mark.asyncio
async def test_collect_handoff_image_urls_filters_extensionless_file_outside_temp_root(
monkeypatch: pytest.MonkeyPatch,
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_subagent_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async def test_reload_from_config_default_persona_is_resolved():
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == default_persona["prompt"]
assert handoff.agent.tools is None
assert handoff.agent.skills == []
assert handoff.agent.begin_dialogs == default_persona["_begin_dialogs_processed"]


Expand All @@ -55,6 +56,7 @@ async def test_reload_from_config_missing_persona_falls_back_to_inline_and_warns
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "inline prompt"
assert handoff.agent.tools == ["tool_a", "tool_b"]
assert handoff.agent.skills == []
assert handoff.agent.begin_dialogs is None
mock_logger.warning.assert_called_once_with(
"SubAgent persona %s not found, fallback to inline prompt.",
Expand All @@ -71,6 +73,7 @@ async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
"name": "custom",
"prompt": "persona prompt",
"tools": ["tool_from_persona"],
"skills": ["web-search-skill"],
"_begin_dialogs_processed": processed_dialogs,
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
Expand All @@ -81,6 +84,7 @@ async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "persona prompt"
assert handoff.agent.tools == ["tool_from_persona"]
assert handoff.agent.skills == ["web-search-skill"]
assert handoff.agent.begin_dialogs[0]["content"] == "hello"


Expand Down Expand Up @@ -108,3 +112,30 @@ async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):

handoff = orchestrator.handoffs[0]
assert handoff.agent.tools == expected_tools


@pytest.mark.asyncio
@pytest.mark.parametrize(
("raw_skills", "expected_skills"),
[
(None, None),
([" web-search-skill ", "", "weather"], ["web-search-skill", "weather"]),
("not-a-list", []),
],
)
async def test_reload_from_config_skill_normalization(raw_skills, expected_skills):
tool_mgr = MagicMock()
persona_mgr = MagicMock()
persona_mgr.get_persona_v3_by_id.return_value = {
"name": "custom",
"prompt": "persona prompt",
"tools": [],
"skills": raw_skills,
"_begin_dialogs_processed": [],
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)

await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))

handoff = orchestrator.handoffs[0]
assert handoff.agent.skills == expected_skills
Loading