Skip to content

Commit c791fcd

Browse files
committed
feat(subagents): show the agent a persona roster so delegation is informed
Selection stays user-led — omitting `persona` runs the subagent as the caller itself (the chat's bound persona). For the specialist case the agent no longer guesses a name blindly: - _personae_roster_block injects a compact `name — role` roster of available personae into the main turn preamble, gated to when spawn_subagent is actually in scope (subagents enabled + the persona's tool allowlist permits it). Off for subagent sub-loops (offer_personae defaults False), so it never leaks where it can't be used. - The "Persona not found" error now lists the valid names, so a wrong guess self-corrects. Tests cover the name/role rendering (first role line only), the current- persona "(you)" tag, the disabled / out-of-scope gates, main-turn-only injection, and the name-listing error.
1 parent 9c26cbc commit c791fcd

3 files changed

Lines changed: 123 additions & 1 deletion

File tree

core/agent.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,7 @@ async def process(
635635
scope=_persona_scope(persona),
636636
persona=persona,
637637
session_key=session_key,
638+
offer_personae=True,
638639
)
639640
# Append the status of still-running background subagents from this chat,
640641
# so the agent always knows what is pending (their results are folded into
@@ -760,6 +761,7 @@ async def _turn_preamble(
760761
scope: str = "",
761762
persona: Persona | None = None,
762763
session_key: tuple[str, str, str] | None = None,
764+
offer_personae: bool = False,
763765
) -> str:
764766
"""Build the per-turn preamble prepended to the current user message.
765767
@@ -821,6 +823,14 @@ async def _turn_preamble(
821823
except Exception:
822824
log.exception("Failed to load memories for turn preamble")
823825

826+
# Roster of personae the agent can delegate to via spawn_subagent, so its
827+
# choice is informed rather than guessed (#15). Only on the main turn —
828+
# selection stays user-led (omit persona = run as yourself / the bound one).
829+
if offer_personae:
830+
roster = await self._personae_roster_block(persona)
831+
if roster:
832+
preamble += f"\n\n{roster}"
833+
824834
if self.config.task_reflection.enabled:
825835
try:
826836
reflections = await self.reflections.format_for_prompt()
@@ -840,6 +850,41 @@ async def _turn_preamble(
840850
)
841851
return preamble
842852

853+
async def _personae_roster_block(self, persona: Persona | None) -> str:
854+
"""Compact `name — role` roster of personae the agent can delegate to (#15).
855+
856+
Makes specialist delegation an informed choice instead of a guess, while
857+
leaving selection user-led: omitting ``persona`` runs the subagent as the
858+
caller itself. Returns "" (nothing injected) when subagents are disabled,
859+
the active persona can't spawn, or there is no one to delegate to.
860+
"""
861+
if not self.config.subagents.enabled:
862+
return ""
863+
if persona is not None and not persona.allows_tool("spawn_subagent"):
864+
return ""
865+
try:
866+
personae = await self.personae.list_personae()
867+
except Exception:
868+
log.exception("Failed to list personae for the subagent roster")
869+
return ""
870+
current = persona.name if persona else ""
871+
lines = []
872+
for p in personae:
873+
role = p.role.strip().splitlines()[0].strip() if (p.role or "").strip() else ""
874+
tag = " (you)" if p.name == current else ""
875+
lines.append(f"- {p.name}{tag}" + (f" — {role}" if role else ""))
876+
if not lines:
877+
return ""
878+
body = "\n".join(lines)
879+
return (
880+
"<personae>\n"
881+
"Personae you may run a subagent as via spawn_subagent's 'persona'. "
882+
"Omit 'persona' to run as yourself (the default) — name one only when "
883+
"the subtask clearly fits that specialist.\n"
884+
f"{body}\n"
885+
"</personae>"
886+
)
887+
843888
async def _skills_block_in_history(self, session_key: tuple[str, str, str], block: str) -> bool:
844889
"""True if the exact ``<available_skills>`` block is already present in the
845890
replayed session history — so the model still sees it and we needn't
@@ -1938,7 +1983,17 @@ async def run_subagent(
19381983
if persona_name:
19391984
requested = await self._load_persona(persona_name)
19401985
if requested is None:
1941-
return {"error": f"Persona not found: {persona_name}"}
1986+
try:
1987+
names = [p.name for p in await self.personae.list_personae()]
1988+
except Exception:
1989+
names = []
1990+
hint = f" Available: {', '.join(names)}." if names else ""
1991+
return {
1992+
"error": (
1993+
f"Persona not found: {persona_name}.{hint} "
1994+
"Omit 'persona' to run as yourself."
1995+
)
1996+
}
19421997
else:
19431998
requested = parent_state.get("persona_obj")
19441999
child_persona = self._narrow_persona(requested, parent_state) if requested else None

docs/content/docs/subagents.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ and a scoped caller delegating to an open persona keeps its own limits.
9393
[Permission rules](/docs/permissions) (`NEVER` in particular) still apply inside
9494
the subagent, so a blocked action stays blocked at any depth.
9595

96+
## Choosing a persona
97+
98+
Selection stays **user-led**: omitting `persona` runs the subagent as the caller
99+
itself (the chat's bound persona), so a subtask stays in the identity the user
100+
already chose. To make the *specialist* case an informed choice rather than a
101+
guess, each turn the agent sees a compact roster of available personae
102+
(`name — role`) and only names one when the subtask clearly fits it. A wrong
103+
name is rejected with the list of valid names, so the agent can self-correct.
104+
96105
## Guardrails
97106

98107
Sensible defaults keep delegation safe and bounded. Edit them in the admin

tests/test_subagents.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,61 @@ def spy(provider, level=""):
506506
assert result["ok"] is True
507507
assert captured["level"] == "high"
508508
assert agent.subagents.get(result["run_id"]).effort == "high"
509+
510+
511+
# ---------------------------------------------------------------------------
512+
# Persona roster — let the agent pick a specialist, selection stays user-led
513+
# ---------------------------------------------------------------------------
514+
515+
516+
@pytest.mark.asyncio
517+
async def test_personae_roster_lists_name_and_role(agent, monkeypatch) -> None:
518+
personae = [
519+
Persona(name="coding-helper", role="Writes and reviews code"),
520+
Persona(name="writing-editor", role="Edits prose\nsecond line ignored"),
521+
]
522+
monkeypatch.setattr(agent.personae, "list_personae", AsyncMock(return_value=personae))
523+
block = await agent._personae_roster_block(None)
524+
assert "<personae>" in block
525+
assert "- coding-helper — Writes and reviews code" in block
526+
assert "- writing-editor — Edits prose" in block # only the first role line
527+
assert "second line ignored" not in block
528+
529+
530+
@pytest.mark.asyncio
531+
async def test_personae_roster_marks_current_and_gates(agent, monkeypatch) -> None:
532+
personae = [Persona(name="me", role="r1"), Persona(name="other", role="r2")]
533+
monkeypatch.setattr(agent.personae, "list_personae", AsyncMock(return_value=personae))
534+
block = await agent._personae_roster_block(Persona(name="me", role="r1"))
535+
assert "- me (you) — r1" in block
536+
# a persona whose tool scope excludes spawn_subagent gets no roster
537+
scoped = Persona(name="me", tools=["web_search"])
538+
assert await agent._personae_roster_block(scoped) == ""
539+
# nor when subagents are disabled
540+
agent.config.subagents.enabled = False
541+
assert await agent._personae_roster_block(None) == ""
542+
543+
544+
@pytest.mark.asyncio
545+
async def test_personae_roster_only_offered_on_main_turn(agent, monkeypatch) -> None:
546+
monkeypatch.setattr(
547+
agent.personae,
548+
"list_personae",
549+
AsyncMock(return_value=[Persona(name="coding-helper", role="code")]),
550+
)
551+
# subagent preamble (offer_personae defaults False) → no roster leaks in
552+
assert "<personae>" not in await agent._turn_preamble(None, query="x")
553+
# main turn opts in
554+
assert "<personae>" in await agent._turn_preamble(None, query="x", offer_personae=True)
555+
556+
557+
@pytest.mark.asyncio
558+
async def test_run_subagent_unknown_persona_lists_available(agent, monkeypatch) -> None:
559+
monkeypatch.setattr(
560+
agent.personae,
561+
"list_personae",
562+
AsyncMock(return_value=[Persona(name="coding-helper"), Persona(name="analyst")]),
563+
)
564+
result = await agent.run_subagent(task="x", persona_name="nope")
565+
assert "not found" in result["error"].lower()
566+
assert "coding-helper" in result["error"] and "analyst" in result["error"]

0 commit comments

Comments
 (0)