Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8392,6 +8392,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:
tasks=_strip_model_hidden_task_fields(function_args.get("tasks")),
max_iterations=function_args.get("max_iterations"),
role=function_args.get("role"),
persona=function_args.get("persona"),
background=(not _is_subagent),
action=function_args.get("action"),
subagent_id=function_args.get("subagent_id"),
Expand Down
202 changes: 202 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,127 @@ def test_goal_only(self):
self.assertIn("YOUR TASK", prompt)
self.assertNotIn("CONTEXT", prompt)


class TestChildSystemPromptPersona(unittest.TestCase):
"""Persona kwarg on _build_child_system_prompt (PR #50040).

persona is a generic authoritative role for a subagent (code-reviewer,
skeptic, domain-expert), not tied to any one caller.
"""

def test_empty_persona_is_backcompat_noop(self):
"""persona='' (and omitted) must leave the prompt byte-identical."""
baseline = _build_child_system_prompt("Fix the tests", "some context")
empty = _build_child_system_prompt(
"Fix the tests", "some context", persona=""
)
whitespace = _build_child_system_prompt(
"Fix the tests", "some context", persona=" \n "
)
self.assertEqual(baseline, empty)
self.assertEqual(baseline, whitespace)
self.assertNotIn("AUTHORITATIVE PERSONA", baseline)

def test_persona_prepended_with_delimiter_ahead_of_goal(self):
"""A non-empty persona rides on the system prompt with the
AUTHORITATIVE PERSONA delimiter, and its body appears BEFORE the
delegated goal (not stuffed into context)."""
persona_body = "You are a code-reviewer. Flag risks tersely."
prompt = _build_child_system_prompt(
"Fix the tests",
"some context",
persona=persona_body,
)
# Delimiter present.
self.assertIn("=== AUTHORITATIVE PERSONA ===", prompt)
self.assertIn("=== END PERSONA ===", prompt)
# The actual persona text is in the prompt (anti-tautology: not just
# that the kwarg was accepted, but that the body is materially there).
self.assertIn(persona_body, prompt)
# Persona body precedes the delegated goal.
self.assertLess(
prompt.index(persona_body),
prompt.index("Fix the tests"),
"persona body must appear ahead of the goal",
)
# Delimiter opens the whole prompt, before the default subagent line.
self.assertLess(
prompt.index("=== AUTHORITATIVE PERSONA ==="),
prompt.index("You are a focused subagent"),
)
# Goal + default guidance still present (persona augments, not
# replaces).
self.assertIn("YOUR TASK", prompt)
self.assertIn("some context", prompt)

def test_persona_flows_through_build_child_agent_to_prompt_builder(self):
"""_build_child_agent forwards its persona kwarg into
_build_child_system_prompt (the wiring seam), so the persona lands
on the child's system prompt rather than being silently dropped."""
persona_body = "PERSONA-SENTINEL-XYZZY be brief."
captured = {}

real_builder = _build_child_system_prompt

class _StopBuild(Exception):
pass

def _spy_builder(*args, **kwargs):
captured["persona"] = kwargs.get("persona")
# Delegate to the real builder so the returned prompt is genuine.
prompt = real_builder(*args, **kwargs)
captured["prompt"] = prompt
# Abort the rest of _build_child_agent deterministically: we only
# need to prove the forward, not construct a whole AIAgent.
raise _StopBuild()

parent = MagicMock()
parent.session_id = "parent-sess"
parent.model = "test-model"
parent.api_key = "k"
# Depth must be a real int: _build_child_agent computes
# child_depth = parent._delegate_depth + 1 and compares to max_spawn.
parent._delegate_depth = 0

with patch(
"tools.delegate_tool._build_child_system_prompt",
side_effect=_spy_builder,
):
with self.assertRaises(_StopBuild):
_build_child_agent(
task_index=0,
goal="Do the thing",
context=None,
toolsets=None,
model="test-model",
max_iterations=3,
task_count=1,
parent_agent=parent,
persona=persona_body,
)

# The persona kwarg was forwarded verbatim...
self.assertEqual(captured.get("persona"), persona_body)
# ...and the real builder wove it into the prompt ahead of the goal.
prompt = captured.get("prompt")
self.assertIsNotNone(prompt)
self.assertIn("=== AUTHORITATIVE PERSONA ===", prompt)
self.assertIn(persona_body, prompt)
self.assertLess(
prompt.index(persona_body), prompt.index("Do the thing")
)

def test_schema_exposes_persona_top_level_and_per_task(self):
"""The model-facing schema advertises persona at both levels so the
LLM can actually pass it."""
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
self.assertIn("persona", props)
self.assertEqual(props["persona"]["type"], "string")
task_props = props["tasks"]["items"]["properties"]
self.assertIn("persona", task_props)
self.assertEqual(task_props["persona"]["type"], "string")


class TestStripBlockedTools(unittest.TestCase):
def test_removes_blocked_toolsets(self):
result = _strip_blocked_tools(["terminal", "file", "delegation", "clarify", "memory", "code_execution"])
Expand Down Expand Up @@ -263,6 +384,68 @@ def test_depth_limit(self):
self.assertIn("error", result)
self.assertIn("depth limit", result["error"].lower())

def test_single_persona_reaches_child_system_prompt(self):
parent = _make_mock_parent(depth=0)
persona = "Review security boundaries and return only findings."

with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
mock_child.run_conversation.return_value = {
"final_response": "ok",
"completed": True,
"api_calls": 1,
}
MockAgent.return_value = mock_child

delegate_task(
goal="Review the authentication changes",
persona=persona,
parent_agent=parent,
)

prompt = MockAgent.call_args.kwargs["ephemeral_system_prompt"]
self.assertIn(persona, prompt)
self.assertLess(
prompt.index(persona),
prompt.index("Review the authentication changes"),
)

def test_batch_persona_default_and_per_task_override_reach_children(self):
parent = _make_mock_parent(depth=0)
default_persona = "Act as a careful implementation reviewer."
override_persona = "Act as a skeptical test reviewer."

children = []
for _ in range(2):
child = MagicMock()
child.run_conversation.return_value = {
"final_response": "ok",
"completed": True,
"api_calls": 1,
}
children.append(child)

with patch("run_agent.AIAgent", side_effect=children) as MockAgent:
delegate_task(
tasks=[
{"goal": "Review the implementation for correctness"},
{
"goal": "Review the test coverage for missing cases",
"persona": override_persona,
},
],
persona=default_persona,
parent_agent=parent,
)

prompts = [
call.kwargs["ephemeral_system_prompt"]
for call in MockAgent.call_args_list
]
self.assertIn(default_persona, prompts[0])
self.assertNotIn(override_persona, prompts[0])
self.assertIn(override_persona, prompts[1])
self.assertNotIn(default_persona, prompts[1])

def test_child_inherits_runtime_credentials(self):
parent = _make_mock_parent(depth=0)
Expand Down Expand Up @@ -1384,6 +1567,25 @@ def fake_delegate_task(**kwargs):
self.assertNotIn("acp_command", captured["tasks"][0])
self.assertNotIn("acp_args", captured["tasks"][0])

def test_persona_is_forwarded_from_agent_dispatch(self):
import run_agent

captured = {}

def fake_delegate_task(**kwargs):
captured.update(kwargs)
return "{}"

parent = _make_mock_parent(depth=0)
with patch("tools.delegate_tool.delegate_task", fake_delegate_task):
run_agent.AIAgent._dispatch_delegate_task(
parent,
{"goal": "review this", "persona": "Act as a skeptic."},
)

self.assertEqual(captured["persona"], "Act as a skeptic.")


class TestDelegateEventEnum(unittest.TestCase):
"""Tests for DelegateEvent enum and back-compat aliases."""

Expand Down
85 changes: 80 additions & 5 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,7 @@ def _build_child_system_prompt(
*,
workspace_path: Optional[str] = None,
role: str = "leaf",
persona: str = "",
max_spawn_depth: int = 2,
child_depth: int = 1,
) -> str:
Expand All @@ -1186,12 +1187,38 @@ def _build_child_system_prompt(
inspiration/openclaw/src/agents/subagent-system-prompt.ts:63-95).
The depth note is literal truth (grounded in the passed config) so
the LLM doesn't confabulate nesting capabilities that don't exist.

When ``persona`` is a non-empty string, its body is prepended to the
default prompt with an explicit "AUTHORITATIVE PERSONA, follow these
rules over any default subagent guidance" delimiter. persona is any
authoritative role for a subagent (code-reviewer, skeptic,
domain-expert, ...), not tied to any one caller; it is the cleaner
replacement for stuffing a role prompt into ``context``, so the
persona text rides on the child's system prompt where it belongs,
ahead of the delegated goal.
"""
parts = [
"You are a focused subagent working on a specific delegated task.",
"",
f"YOUR TASK:\n{goal}",
]
parts: List[str] = []
if persona and persona.strip():
parts.extend(
[
"=== AUTHORITATIVE PERSONA ===",
"Follow the persona rules below over any default subagent",
"guidance that appears later in this prompt. The persona's",
"output format, refusal rules, and tool-use constraints are",
"non-negotiable for this task.",
"",
persona.strip(),
"=== END PERSONA ===",
"",
]
)
parts.extend(
[
"You are a focused subagent working on a specific delegated task.",
"",
f"YOUR TASK:\n{goal}",
]
)
if context and context.strip():
parts.append(f"\nCONTEXT:\n{context}")
if workspace_path and str(workspace_path).strip():
Expand Down Expand Up @@ -1626,6 +1653,12 @@ def _build_child_agent(
# 'leaf' (default) cannot; 'orchestrator' retains the delegation
# toolset subject to depth/kill-switch bounds applied below.
role: str = "leaf",
# Persona system-prompt prepend. persona is any authoritative role for
# a subagent (code-reviewer, skeptic, domain-expert, ...). When set, the
# body is hoisted to the top of the child's system prompt with an
# explicit AUTHORITATIVE PERSONA delimiter. Default empty preserves
# today's behavior exactly.
persona: str = "",
):
"""
Build a child AIAgent on the main thread (thread-safe construction).
Expand Down Expand Up @@ -1733,6 +1766,7 @@ def _build_child_agent(
context,
workspace_path=workspace_hint,
role=effective_role,
persona=persona,
max_spawn_depth=max_spawn,
child_depth=child_depth,
)
Expand Down Expand Up @@ -3628,6 +3662,7 @@ def delegate_task(
tasks: Optional[List[Dict[str, Any]]] = None,
max_iterations: Optional[int] = None,
role: Optional[str] = None,
persona: Optional[str] = None,
background: Optional[bool] = None,
output_schema: Optional[Dict[str, Any]] = None,
action: Optional[str] = None,
Expand Down Expand Up @@ -3655,6 +3690,14 @@ def delegate_task(
toolset and can spawn its own workers, bounded by
delegation.max_spawn_depth. Per-task role beats the top-level one.

The 'persona' parameter, when set, is prepended to the child's system
prompt with an authoritative delimiter (see
``_build_child_system_prompt``). persona is any authoritative role for
a subagent (code-reviewer, skeptic, domain-expert, ...), not tied to
any one caller. Per-task ``persona`` inside ``tasks=[...]`` beats the
top-level one; empty/omitted preserves the default subagent prompt
exactly.

Returns JSON with results array, one entry per task.
"""
if parent_agent is None:
Expand Down Expand Up @@ -3768,6 +3811,8 @@ def delegate_task(
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role}
if persona:
single_task["persona"] = persona
if output_schema is not None:
single_task["output_schema"] = output_schema
task_list = [single_task]
Expand Down Expand Up @@ -3895,6 +3940,11 @@ def delegate_task(
override_acp_command=creds.get("command"),
override_acp_args=creds.get("args"),
role=effective_role,
persona=(
t.get("persona")
if t.get("persona") is not None
else (persona or "")
),
)
except ValueError as exc:
# Explicit-pin preflight failures (e.g. pinned delegation.command
Expand Down Expand Up @@ -4813,6 +4863,15 @@ def _build_dynamic_schema_overrides() -> dict:
"enum": ["leaf", "orchestrator"],
"description": "Per-task role override. See top-level 'role' for semantics.",
},
"persona": {
"type": "string",
"description": (
"Per-task persona override. See top-level "
"'persona' for semantics; prepended to this "
"task's subagent system prompt with an "
"AUTHORITATIVE PERSONA delimiter."
),
},
"output_schema": {
"type": "object",
"description": (
Expand All @@ -4839,6 +4898,21 @@ def _build_dynamic_schema_overrides() -> dict:
"enum": ["leaf", "orchestrator"],
"description": "(rebuilt at get_definitions() time)",
},
"persona": {
"type": "string",
"description": (
"Optional persona for the subagent, any authoritative "
"role such as code-reviewer, skeptic, or domain-expert "
"(not tied to any one caller). When set, its body is "
"prepended to the child's system prompt with an "
"AUTHORITATIVE PERSONA delimiter, ahead of the goal, so "
"the persona's output format, refusal rules, and "
"tool-use constraints take precedence over the default "
"subagent guidance. Cleaner than stuffing persona text "
"into 'context'. Ignored when empty. In batch mode a "
"per-task 'persona' overrides this top-level value."
),
},
"output_schema": {
"type": "object",
"description": (
Expand Down Expand Up @@ -4950,6 +5024,7 @@ def _strip_model_hidden_task_fields(tasks: Any) -> Any:
tasks=_strip_model_hidden_task_fields(args.get("tasks")),
max_iterations=args.get("max_iterations"),
role=args.get("role"),
persona=args.get("persona"),
background=_model_background_value(args, kw.get("parent_agent")),
output_schema=args.get("output_schema"),
action=args.get("action"),
Expand Down
Loading