Skip to content

Commit 9de57e2

Browse files
whatevertogoclaude
andcommitted
fix: resolve subagent persona lookup for 'default' and unify resolution logic
- Add PersonaManager.get_persona_v3_by_id() to centralize v3 persona resolution - Handle 'default' persona_id mapping to DEFAULT_PERSONALITY in subagent orchestrator - Fix HandoffTool.default_description using agent_name parameter correctly - Add tests for default persona in subagent config and tool deduplication Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0dbe32e commit 9de57e2

7 files changed

Lines changed: 260 additions & 25 deletions

File tree

astrbot/core/agent/handoff.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ def default_parameters(self) -> dict:
6262

6363
def default_description(self, agent_name: str | None) -> str:
6464
agent_name = agent_name or "another"
65-
return f"Delegate tasks to {self.name} agent to handle the request."
65+
return f"Delegate tasks to {agent_name} agent to handle the request."

astrbot/core/astr_main_agent.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -390,14 +390,9 @@ async def _ensure_persona_and_skills(
390390
persona_tools = None
391391
pid = a.get("persona_id")
392392
if pid:
393-
persona_tools = next(
394-
(
395-
p.get("tools")
396-
for p in plugin_context.persona_manager.personas_v3
397-
if p["name"] == pid
398-
),
399-
None,
400-
)
393+
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
394+
if persona is not None:
395+
persona_tools = persona.get("tools")
401396
tools = a.get("tools", [])
402397
if persona_tools is not None:
403398
tools = persona_tools

astrbot/core/persona_mgr.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ async def get_persona(self, persona_id: str):
4444
raise ValueError(f"Persona with ID {persona_id} does not exist.")
4545
return persona
4646

47+
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
48+
"""Resolve a v3 persona object by id.
49+
50+
- None/empty id returns None.
51+
- "default" maps to in-memory DEFAULT_PERSONALITY.
52+
- Otherwise search in personas_v3 by persona name.
53+
"""
54+
if not persona_id:
55+
return None
56+
if persona_id == "default":
57+
return DEFAULT_PERSONALITY
58+
return next(
59+
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
60+
None,
61+
)
62+
4763
async def get_default_persona_v3(
4864
self,
4965
umo: str | MessageSession | None = None,

astrbot/core/subagent_orchestrator.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
import copy
4+
from typing import TYPE_CHECKING, Any
45

56
from astrbot import logger
67
from astrbot.core.agent.agent import Agent
78
from astrbot.core.agent.handoff import HandoffTool
8-
from astrbot.core.persona_mgr import PersonaManager
99
from astrbot.core.provider.func_tool_manager import FunctionToolManager
1010

11+
if TYPE_CHECKING:
12+
from astrbot.core.persona_mgr import PersonaManager
13+
1114

1215
class SubAgentOrchestrator:
1316
"""Loads subagent definitions from config and registers handoff tools.
@@ -43,15 +46,14 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
4346
continue
4447

4548
persona_id = item.get("persona_id")
46-
persona_data = None
47-
if persona_id:
48-
try:
49-
persona_data = await self._persona_mgr.get_persona(persona_id)
50-
except StopIteration:
51-
logger.warning(
52-
"SubAgent persona %s not found, fallback to inline prompt.",
53-
persona_id,
54-
)
49+
if persona_id is not None:
50+
persona_id = str(persona_id).strip() or None
51+
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
52+
if persona_id and persona_data is None:
53+
logger.warning(
54+
"SubAgent persona %s not found, fallback to inline prompt.",
55+
persona_id,
56+
)
5557

5658
instructions = str(item.get("system_prompt", "")).strip()
5759
public_description = str(item.get("public_description", "")).strip()
@@ -62,11 +64,15 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
6264
begin_dialogs = None
6365

6466
if persona_data:
65-
instructions = persona_data.system_prompt or instructions
66-
begin_dialogs = persona_data.begin_dialogs
67-
tools = persona_data.tools
68-
if public_description == "" and persona_data.system_prompt:
69-
public_description = persona_data.system_prompt[:120]
67+
prompt = str(persona_data.get("prompt", "")).strip()
68+
if prompt:
69+
instructions = prompt
70+
begin_dialogs = copy.deepcopy(
71+
persona_data.get("_begin_dialogs_processed")
72+
)
73+
tools = persona_data.get("tools")
74+
if public_description == "" and prompt:
75+
public_description = prompt[:120]
7076
if tools is None:
7177
tools = None
7278
elif not isinstance(tools, list):

tests/test_dashboard.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import copy
23
import os
34
import sys
45
from types import SimpleNamespace
@@ -101,6 +102,55 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
101102
assert data["status"] == "ok" and "platform" in data["data"]
102103

103104

105+
@pytest.mark.asyncio
106+
async def test_subagent_config_accepts_default_persona(
107+
app: Quart,
108+
authenticated_header: dict,
109+
core_lifecycle_td: AstrBotCoreLifecycle,
110+
):
111+
test_client = app.test_client()
112+
old_cfg = copy.deepcopy(
113+
core_lifecycle_td.astrbot_config.get("subagent_orchestrator", {})
114+
)
115+
payload = {
116+
"main_enable": True,
117+
"remove_main_duplicate_tools": True,
118+
"agents": [
119+
{
120+
"name": "planner",
121+
"persona_id": "default",
122+
"public_description": "planner",
123+
"system_prompt": "",
124+
"enabled": True,
125+
}
126+
],
127+
}
128+
129+
try:
130+
response = await test_client.post(
131+
"/api/subagent/config",
132+
json=payload,
133+
headers=authenticated_header,
134+
)
135+
assert response.status_code == 200
136+
data = await response.get_json()
137+
assert data["status"] == "ok"
138+
139+
get_response = await test_client.get(
140+
"/api/subagent/config", headers=authenticated_header
141+
)
142+
assert get_response.status_code == 200
143+
get_data = await get_response.get_json()
144+
assert get_data["status"] == "ok"
145+
assert get_data["data"]["agents"][0]["persona_id"] == "default"
146+
finally:
147+
await test_client.post(
148+
"/api/subagent/config",
149+
json=old_cfg,
150+
headers=authenticated_header,
151+
)
152+
153+
104154
@pytest.mark.asyncio
105155
async def test_plugins(
106156
app: Quart,

tests/unit/test_astr_main_agent.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def mock_context():
3939
ctx.persona_manager.resolve_selected_persona = AsyncMock(
4040
return_value=(None, None, None, False)
4141
)
42+
ctx.persona_manager.get_persona_v3_by_id = MagicMock(return_value=None)
4243
ctx.get_llm_tool_manager.return_value = MagicMock()
4344
ctx.subagent_orchestrator = None
4445
return ctx
@@ -562,6 +563,63 @@ async def test_ensure_tools_from_persona(self, mock_event, mock_context):
562563

563564
assert req.func_tool is not None
564565

566+
@pytest.mark.asyncio
567+
async def test_subagent_dedupe_uses_default_persona_tools(
568+
self, mock_event, mock_context
569+
):
570+
"""Test dedupe uses resolved default persona tools in subagent mode."""
571+
module = ama
572+
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
573+
return_value=(None, None, None, False)
574+
)
575+
mock_context.persona_manager.get_persona_v3_by_id = MagicMock(
576+
return_value={"name": "default", "tools": ["tool_a"]}
577+
)
578+
579+
tool_a = FunctionTool(
580+
name="tool_a",
581+
parameters={"type": "object", "properties": {}},
582+
description="tool a",
583+
)
584+
tool_b = FunctionTool(
585+
name="tool_b",
586+
parameters={"type": "object", "properties": {}},
587+
description="tool b",
588+
)
589+
tmgr = mock_context.get_llm_tool_manager.return_value
590+
tmgr.func_list = [tool_a, tool_b]
591+
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
592+
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
593+
name
594+
)
595+
596+
handoff = MagicMock()
597+
handoff.name = "transfer_to_planner"
598+
mock_context.subagent_orchestrator = MagicMock(handoffs=[handoff])
599+
mock_context.get_config.return_value = {
600+
"subagent_orchestrator": {
601+
"main_enable": True,
602+
"remove_main_duplicate_tools": True,
603+
"agents": [
604+
{
605+
"name": "planner",
606+
"enabled": True,
607+
"persona_id": "default",
608+
}
609+
],
610+
}
611+
}
612+
613+
req = ProviderRequest()
614+
req.conversation = MagicMock(persona_id=None)
615+
616+
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
617+
618+
assert req.func_tool is not None
619+
assert "transfer_to_planner" in req.func_tool.names()
620+
assert "tool_a" not in req.func_tool.names()
621+
assert "tool_b" in req.func_tool.names()
622+
565623

566624
class TestDecorateLlmRequest:
567625
"""Tests for _decorate_llm_request function."""
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from copy import deepcopy
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
7+
8+
9+
def _build_cfg(agent_overrides: dict) -> dict:
10+
agent = {
11+
"name": "planner",
12+
"enabled": True,
13+
"persona_id": None,
14+
"system_prompt": "inline prompt",
15+
"public_description": "",
16+
"tools": ["tool_a", " ", "tool_b"],
17+
}
18+
agent.update(agent_overrides)
19+
return {"agents": [agent]}
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_reload_from_config_default_persona_is_resolved():
24+
tool_mgr = MagicMock()
25+
persona_mgr = MagicMock()
26+
default_persona = {
27+
"name": "default",
28+
"prompt": "You are a helpful and friendly assistant.",
29+
"tools": None,
30+
"_begin_dialogs_processed": [],
31+
}
32+
persona_mgr.get_persona_v3_by_id.return_value = deepcopy(default_persona)
33+
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
34+
35+
await orchestrator.reload_from_config(_build_cfg({"persona_id": "default"}))
36+
37+
assert len(orchestrator.handoffs) == 1
38+
handoff = orchestrator.handoffs[0]
39+
assert handoff.agent.instructions == default_persona["prompt"]
40+
assert handoff.agent.tools is None
41+
assert handoff.agent.begin_dialogs == default_persona["_begin_dialogs_processed"]
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_reload_from_config_missing_persona_falls_back_to_inline_and_warns():
46+
tool_mgr = MagicMock()
47+
persona_mgr = MagicMock()
48+
persona_mgr.get_persona_v3_by_id.return_value = None
49+
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
50+
51+
with patch("astrbot.core.subagent_orchestrator.logger") as mock_logger:
52+
await orchestrator.reload_from_config(_build_cfg({"persona_id": "not_exists"}))
53+
54+
assert len(orchestrator.handoffs) == 1
55+
handoff = orchestrator.handoffs[0]
56+
assert handoff.agent.instructions == "inline prompt"
57+
assert handoff.agent.tools == ["tool_a", "tool_b"]
58+
assert handoff.agent.begin_dialogs is None
59+
mock_logger.warning.assert_called_once_with(
60+
"SubAgent persona %s not found, fallback to inline prompt.",
61+
"not_exists",
62+
)
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
67+
tool_mgr = MagicMock()
68+
persona_mgr = MagicMock()
69+
processed_dialogs = [{"role": "user", "content": "hello", "_no_save": True}]
70+
persona_mgr.get_persona_v3_by_id.return_value = {
71+
"name": "custom",
72+
"prompt": "persona prompt",
73+
"tools": ["tool_from_persona"],
74+
"_begin_dialogs_processed": processed_dialogs,
75+
}
76+
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
77+
78+
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
79+
processed_dialogs[0]["content"] = "mutated"
80+
81+
handoff = orchestrator.handoffs[0]
82+
assert handoff.agent.instructions == "persona prompt"
83+
assert handoff.agent.tools == ["tool_from_persona"]
84+
assert handoff.agent.begin_dialogs[0]["content"] == "hello"
85+
86+
87+
@pytest.mark.asyncio
88+
@pytest.mark.parametrize(
89+
("raw_tools", "expected_tools"),
90+
[
91+
(None, None),
92+
([], []),
93+
("not-a-list", []),
94+
],
95+
)
96+
async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):
97+
tool_mgr = MagicMock()
98+
persona_mgr = MagicMock()
99+
persona_mgr.get_persona_v3_by_id.return_value = {
100+
"name": "custom",
101+
"prompt": "persona prompt",
102+
"tools": raw_tools,
103+
"_begin_dialogs_processed": [],
104+
}
105+
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
106+
107+
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
108+
109+
handoff = orchestrator.handoffs[0]
110+
assert handoff.agent.tools == expected_tools

0 commit comments

Comments
 (0)