Skip to content

Commit 1fd3d4c

Browse files
whatevertogoclaudeSoulter
authored
fix: subagent lookup failure when using default persona (#5672)
* 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> * refactor: simplify get_default_persona_v3 using get_persona_v3_by_id Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
1 parent 26d69c9 commit 1fd3d4c

File tree

7 files changed

+259
-31
lines changed

7 files changed

+259
-31
lines changed

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: 17 additions & 6 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,
@@ -54,12 +70,7 @@ async def get_default_persona_v3(
5470
"default_personality",
5571
"default",
5672
)
57-
if not default_persona_id or default_persona_id == "default":
58-
return DEFAULT_PERSONALITY
59-
try:
60-
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
61-
except Exception:
62-
return DEFAULT_PERSONALITY
73+
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
6374

6475
async def resolve_selected_persona(
6576
self,

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import copy
23
import io
34
import os
45
import sys
@@ -107,6 +108,53 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
107108

108109

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

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
@@ -538,6 +539,63 @@ async def test_ensure_tools_from_persona(self, mock_event, mock_context):
538539

539540
assert req.func_tool is not None
540541

542+
@pytest.mark.asyncio
543+
async def test_subagent_dedupe_uses_default_persona_tools(
544+
self, mock_event, mock_context
545+
):
546+
"""Test dedupe uses resolved default persona tools in subagent mode."""
547+
module = ama
548+
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
549+
return_value=(None, None, None, False)
550+
)
551+
mock_context.persona_manager.get_persona_v3_by_id = MagicMock(
552+
return_value={"name": "default", "tools": ["tool_a"]}
553+
)
554+
555+
tool_a = FunctionTool(
556+
name="tool_a",
557+
parameters={"type": "object", "properties": {}},
558+
description="tool a",
559+
)
560+
tool_b = FunctionTool(
561+
name="tool_b",
562+
parameters={"type": "object", "properties": {}},
563+
description="tool b",
564+
)
565+
tmgr = mock_context.get_llm_tool_manager.return_value
566+
tmgr.func_list = [tool_a, tool_b]
567+
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
568+
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
569+
name
570+
)
571+
572+
handoff = MagicMock()
573+
handoff.name = "transfer_to_planner"
574+
mock_context.subagent_orchestrator = MagicMock(handoffs=[handoff])
575+
mock_context.get_config.return_value = {
576+
"subagent_orchestrator": {
577+
"main_enable": True,
578+
"remove_main_duplicate_tools": True,
579+
"agents": [
580+
{
581+
"name": "planner",
582+
"enabled": True,
583+
"persona_id": "default",
584+
}
585+
],
586+
}
587+
}
588+
589+
req = ProviderRequest()
590+
req.conversation = MagicMock(persona_id=None)
591+
592+
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
593+
594+
assert req.func_tool is not None
595+
assert "transfer_to_planner" in req.func_tool.names()
596+
assert "tool_a" not in req.func_tool.names()
597+
assert "tool_b" in req.func_tool.names()
598+
541599

542600
class TestDecorateLlmRequest:
543601
"""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)