frontier-agent-demo / plugins /tools /create_subagent.py
christlurker's picture
FrontierAgent react demo
9d0fd45
Raw
History Blame Contribute Delete
25.3 kB
"""Tool: create_subagent — register persistent sub-agent sessions."""
from __future__ import annotations
import importlib
import inspect
import logging
import os
from typing import Any
from frontier_agent.components.agent_bus import AgentBus
from frontier_agent.core.execution_context import get_current_execution_scope
from frontier_agent.core.runtime.loop.message_trimmer import TaskBoundaryTrimmer
from frontier_agent.core.runtime.registries import services as registry
from frontier_agent.core.tool import tool
from plugins.tools._bus_scope import resolve_bus_task_id, resolve_root_task_id
from plugins.tools._coerce import coerce_json_list
logger = logging.getLogger(__name__)
# create_subagent only registers sessions; actual concurrent execution is
# bounded by SpawnGuard(max_parallel). Raise via FRONTIER_AGENT_MAX_SUBAGENTS_PER_DISPATCH.
MAX_SUBAGENTS_PER_DISPATCH: int = int(
os.environ.get("FRONTIER_AGENT_MAX_SUBAGENTS_PER_DISPATCH", "20")
)
SUB_ROLE_ID = "swarm_sub"
SUB_MAX_TURNS_DEFAULT = 100
def _resolve_sub_role_id(runtime: Any | None) -> str:
"""Sub-agent role id, workflow-overridable.
Defaults to the shared ``swarm_sub`` registration (the name is
historical — see ``workflows/agent_team/README.md``); a workflow can set
``sub_role_id`` on its runtime (agent_team → ``agent_team_sub``) so its
sub-agents resolve their own fail-closed tool pool instead of sharing it.
"""
return getattr(runtime, "sub_role_id", None) or SUB_ROLE_ID
# Sentinel: caller did not specify task_types → ask the workflow for its tuple
# (the behaviour assign_task / scope-rewrite callers rely on). Distinct from an
# explicit ``None`` (lenient role-label path) that create_subagent passes for
# agent_team.
_TASK_TYPES_UNSET: Any = object()
def _normalize_agent_name(
name: str, task_types: tuple[str, ...] | None = _TASK_TYPES_UNSET,
) -> str:
"""Auto-fix underscored topic to dashed topic (strict-naming convention).
The convention is ``{topic}_{task_type}[_{N}]`` where the topic segment
uses dashes for multi-word descriptors. LLMs often emit
``gpu_market_research_1`` instead of canonical ``gpu-market_research_1``;
rather than reject, we rewrite the topic span when the suffix
unambiguously identifies a ``task_type``. Names whose last segment isn't a
known task_type are returned unchanged so :func:`_validate_agent_name`
surfaces the real error.
``task_types is None`` (workflows like agent_team that use free-form
role-label names) → return ``name`` unchanged: there is no
``{topic}_{task_type}`` structure to normalize.
"""
if task_types is _TASK_TYPES_UNSET:
task_types = _resolve_task_types(None) # caller did not say → ask the workflow
if not task_types:
return name
parts = name.split("_")
if len(parts) < 2:
return name
last = parts[-1]
if last.isdigit():
if len(parts) < 3:
return name
task_type = parts[-2]
topic_parts = parts[:-2]
suffix = f"_{task_type}_{last}"
else:
task_type = last
topic_parts = parts[:-1]
suffix = f"_{task_type}"
if task_type not in task_types:
return name
if len(topic_parts) <= 1:
return name
return "-".join(topic_parts) + suffix
def _validate_agent_name(
name: str, task_types: tuple[str, ...] | None = _TASK_TYPES_UNSET,
) -> str | None:
"""Return an error message if ``name`` violates the naming convention.
With ``task_types`` (strict mode): valid format is ``{topic}_{task_type}`` or
``{topic}_{task_type}_{N}`` where the topic uses dashes and ``task_type``
is one of ``task_types``.
With ``task_types is None`` (agent_team and any workflow using free-form
ROLE-label sub-agent names, resolved by substring in its prompts): use a
LENIENT check — accept any safe identifier. The previous behaviour
hard-coded a fixed task-type tuple here, so it rejected EVERY agent_team role
name (``final_verifier`` / ``lit_search`` / ``match_researcher`` / …),
created zero sub-agents, and deadlocked the planning-mode finalize gate.
Returns ``None`` when valid. Callers should run :func:`_normalize_agent_name`
first (a no-op in the lenient case).
"""
if task_types is _TASK_TYPES_UNSET:
task_types = _resolve_task_types(None) # caller did not say → ask the workflow
if not task_types:
# Lenient role-label path: a safe identifier is all that's required;
# the workflow's get_subagent_system_prompt resolves the specialist by
# substring, so any reasonable label is valid. Reject only empty names
# or shell-metacharacter/space injection in the session name.
if (
not name
or not name[0].isalnum()
or not all(c.isalnum() or c in "_-" for c in name)
):
return (
f"Agent name {name!r} must be a non-empty identifier "
f"(letters/digits/_/-, starting alphanumeric; no spaces or "
f"shell metacharacters)."
)
return None
SUBAGENT_TASK_TYPES = task_types
parts = name.split("_")
# Need at least topic + task_type → 2 segments minimum.
if len(parts) < 2:
return (
f"Agent name {name!r} must follow {{topic}}_{{task_type}}[_{{N}}]. "
f"Valid task_types: {', '.join(SUBAGENT_TASK_TYPES)}. "
f"Use dashes inside the topic: e.g. 'gpu-market_research_1'."
)
last = parts[-1]
if last.isdigit():
# Format: topic_tasktype_N — need at least 3 segments.
if len(parts) < 3:
return (
f"Agent name {name!r}: numeric suffix requires a task_type before it. "
f"Format: {{topic}}_{{task_type}}_{{N}}. "
f"Valid task_types: {', '.join(SUBAGENT_TASK_TYPES)}."
)
task_type = parts[-2]
topic_parts = parts[:-2]
else:
task_type = last
topic_parts = parts[:-1]
if task_type not in SUBAGENT_TASK_TYPES:
return (
f"Agent name {name!r} has invalid task_type {task_type!r}. "
f"Must be one of: {', '.join(SUBAGENT_TASK_TYPES)}. "
f"Format: {{topic}}_{{task_type}}[_{{N}}] — use dashes inside the topic. "
f"Examples: 'gpu-market_research_1', 'draft-answer_verify', "
f"'conflicting-claims_lverify'."
)
# After _normalize_agent_name(), a well-typed topic is a single
# dash-joined token. If we still see extra ``_`` segments here it
# means normalization bailed (unknown task_type path) — surface a
# clear error instead of silently mangling the name.
if len(topic_parts) != 1:
bad_topic = "_".join(topic_parts)
good_topic = "-".join(topic_parts)
return (
f"Agent name {name!r}: topic {bad_topic!r} contains underscores. "
f"Use dashes instead: '{good_topic}_{task_type}'. "
f"Format: {{topic}}_{{task_type}}[_{{N}}] — topic uses dashes only."
)
return None
def _resolve_runtime(scope_metadata: dict[str, Any]) -> Any | None:
"""Pull the sub-agent runtime config from the active ExecutionScope.
Returns ``None`` outside a multi-agent run so the tool can no-op
cleanly (still registers the session, just without the workflow's
observers). The scope key is shared verbatim by every workflow that
spawns sub-agents, so this resolves whichever one stashed its runtime.
"""
from plugins.tools._bus_scope import SWARM_SCOPE_KEY
return scope_metadata.get(SWARM_SCOPE_KEY)
def _runtime_workflow_pkg(runtime: Any | None) -> str:
"""Resolve the workflow package that owns ``runtime``.
``create_subagent`` is shared across coordinator-style workflows that
each ship their own ``subagent_runtime`` / ``prompts`` /
``stream_repetition`` modules. The runtime dataclass lives in its
workflow's ``subagent_runtime`` module, so its ``__module__`` (e.g.
``workflows.agent_team.subagent_runtime``) names the package to
dispatch to. ``None`` / anything not under a ``workflows.<pkg>``
namespace falls back to ``workflows.agent_team``.
"""
mod = type(runtime).__module__ if runtime is not None else ""
if mod.startswith("workflows.") and mod.count(".") >= 2:
return mod.rsplit(".", 1)[0]
return "workflows.agent_team"
def _resolve_task_types(runtime: Any | None) -> tuple[str, ...] | None:
"""Return the active workflow's ``SUBAGENT_TASK_TYPES``, or ``None`` when the
workflow does not use the strict ``{topic}_{task_type}`` naming convention.
No workflow in this repository declares it, so the strict path is an
extension seam rather than a live branch here.
A workflow opts into strict ``{topic}_{task_type}[_N]`` names by
defining ``SUBAGENT_TASK_TYPES`` in its prompts module. ``agent_team``
does not: it uses free-form ROLE-label names (``final_verifier`` /
``lit_search`` / ``match_researcher`` / …) that its
``get_subagent_system_prompt`` resolves by substring. Returning
``None`` switches name handling to the lenient path below, instead of
rejecting every role-label name — which created ZERO sub-agents and
deadlocked the planning-mode finalize gate.
"""
pkg = _runtime_workflow_pkg(runtime)
try:
mod = importlib.import_module(f"{pkg}.prompts")
tt = getattr(mod, "SUBAGENT_TASK_TYPES", None)
return tuple(tt) if tt else None
except Exception:
return None
def _build_runtime_spec(
runtime: Any,
*,
session_name: str,
task_id: str,
task_id_for_sse: str | None = None,
run_id: str = "",
run_type: str = "",
) -> Any:
mod = importlib.import_module(f"{_runtime_workflow_pkg(runtime)}.subagent_runtime")
return mod.build_swarm_session_runtime_spec(
runtime,
session_name=session_name,
task_id=task_id,
task_id_for_sse=task_id_for_sse,
run_id=run_id,
run_type=run_type,
)
def _resolve_specialist_prompt(
name: str,
role_hint: str,
*,
fs_mode: bool,
enhancements: bool = False,
mcp_tool_names: list[str] | None = None,
mcp_tool_specs: list[dict[str, Any]] | None = None,
runtime: Any | None = None,
) -> str:
"""Route a sub-agent to its workflow's specialist system prompt by name."""
mod = importlib.import_module(f"{_runtime_workflow_pkg(runtime)}.prompts")
fn = mod.get_subagent_system_prompt
kwargs: dict[str, Any] = dict(
name=name,
role=role_hint,
include_domain_guide=fs_mode,
mcp_tool_names=mcp_tool_names or (),
mcp_tool_specs=mcp_tool_specs or (),
)
params = inspect.signature(fn).parameters
# Optional prompt-builder knobs are capability-detected so the shared tool
# remains compatible with workflows that do not expose them.
if "enhancements" in params:
kwargs["enhancements"] = enhancements
# Two blocks of runtime facts the static templates cannot know. Both are
# task-level constants — identical for every sub-agent of one task — so
# both belong in the shared KV-cache prefix, ahead of the per-agent role.
#
# ``sub_prompt_suffix``: for agent_team this is the filesystem-convention
# note
# (scratch → /workspace, final deliverable → /outputs) when a sandbox is
# active — the main agent gets it inline, and sub-agents share the same
# mounts, so they need the same convention or a sub could drop a final into
# its /workspace cwd and it would never be collected. In strict mode it is the
# task's /inputs listing, since read_file cannot list a directory.
#
# ``notice``: the research/verifier templates hard-code web_search /
# web_fetch and a web-centric methodology. If the active tool policy
# disabled those tools, an explicit override keeps the model from trying
# tools that will not be in its list.
runtime_block = (
str(getattr(runtime, "sub_prompt_suffix", "") or "")
+ _disabled_web_tools_notice(runtime)
)
accepts_suffix = "runtime_suffix" in params
if accepts_suffix:
kwargs["runtime_suffix"] = runtime_block
prompt = fn(**kwargs)
# Capable builders place the block before the role; legacy builders can
# only have it appended, so adding a new workflow cannot crash. Same block,
# same internal order either way.
return prompt if accepts_suffix else prompt + runtime_block
def _runtime_tool_names(runtime: Any | None) -> set[str] | None:
names = getattr(runtime, "sub_agent_tool_names", None)
if names is None:
return None
return {str(name) for name in names}
def _runtime_tools_override(runtime: Any | None) -> list[Any] | None:
tools = getattr(runtime, "sub_agent_tools", None)
if tools is not None:
return list(tools)
names = getattr(runtime, "sub_agent_tool_names", None)
if names is None:
return None
from frontier_agent.core.runtime.resources.manager import ResourceManager
rm = registry.get_optional(ResourceManager)
if rm is None:
return []
all_tools = rm.all_tools
return [all_tools[name] for name in names if name in all_tools]
def _disabled_web_tools_notice(runtime: Any | None = None) -> str:
"""Override block emitted when the tool policy disabled web access for
sub-agents — keeps the web-centric research prompt coherent without it.
``check_permission`` runs through the ResourceManager's effective context
(which layers the global allow/deny policy), so this returns non-empty
exactly when ``web_search`` / ``web_fetch`` were switched off for this run.
The wording is position-independent because this task-level constant sits
in the shared prompt prefix, before the per-agent role.
"""
from frontier_agent.core.runtime.resources.manager import ResourceManager
rm = registry.get_optional(ResourceManager)
runtime_names = _runtime_tool_names(runtime)
if rm is None and runtime_names is None:
return ""
if runtime_names is not None:
# A lambda rather than ``.__contains__``: the bound method accepts
# object, which does not match the (name: str) -> bool signature the
# else-branch defines.
def has_tool(name: str) -> bool:
return name in runtime_names
else:
assert rm is not None
def has_tool(name: str) -> bool:
return rm.check_permission(_resolve_sub_role_id(runtime), name)
disabled = [
name for name in ("web_search", "web_fetch")
if not has_tool(name)
]
if not disabled:
return ""
if has_tool("bash"):
fallback = " Use `bash` for local computation or data work."
elif has_tool("run_python_code"):
fallback = " Use `run_python_code` for local computation or data work."
else:
fallback = ""
tools_str = " and ".join(f"`{n}`" for n in disabled)
verb = "are" if len(disabled) > 1 else "is"
return (
"\n\n# Tool Availability Override (READ FIRST)\n"
f"{tools_str} {verb} DISABLED for this run and will NOT appear in your "
"tool list. Ignore every other instruction in this prompt that tells "
"you to search the web or fetch pages, wherever it appears — before or "
"after this section, your role included. Do not attempt them, and do not "
"use code to issue web requests as a workaround." + fallback +
" Answer from your own knowledge and reasoning; when a fact cannot be "
"verified without the disabled tools, state it as unverified rather "
"than fabricating a source."
)
def _bind_sub_agent_llm(runtime: Any | None) -> Any | None:
"""Pick the sub-agent LLM and bind ``max_tokens`` for full reports."""
if runtime is not None and runtime.sub_agent_llm is not None:
llm = runtime.sub_agent_llm
else:
from frontier_agent.core.runtime.resources.manager import ResourceManager
resource_mgr = registry.get_optional(ResourceManager)
if resource_mgr is None:
return None
try:
llm = resource_mgr.get_llm(_resolve_sub_role_id(runtime))
except Exception:
return None
# Native clients carry no langchain ``Runnable.bind``; bind the
# per-call ``max_tokens`` knob via the kernel loop's ``_BoundLLM``
# shim (mirrors ``llm_client._bind_reduced_max_tokens``) so the
# sub-agent loop threads a big output budget into every request for
# full-length reports.
#
# BUT never request MORE output than the model actually accepts: the
# profile's declared ``llm.max_tokens`` (threaded as
# ``runtime.llm_max_tokens``) is the model's real
# ``max_completion_tokens`` ceiling. Over-requesting (e.g. the legacy
# hardcoded 65536 against a 32768-cap model) makes the server 400 the
# sub-agent on turn 1 (``stopped_by=llm_error``, empty report). Cap the
# desired budget by the profile ceiling when known; fall back to the
# legacy desired value when unknown (no profile).
desired_max_tokens = 65536
profile_cap = getattr(runtime, "llm_max_tokens", None)
if isinstance(profile_cap, int) and profile_cap > 0:
eff_max_tokens = min(desired_max_tokens, profile_cap)
if eff_max_tokens < desired_max_tokens:
logger.info(
"sub-agent LLM: capping max_tokens %d→%d per profile "
"llm.max_tokens (model output ceiling)",
desired_max_tokens, eff_max_tokens,
)
else:
# No profile ceiling known: fall back to the legacy desired value.
# This is the path that historically 400'd sub-agents when the model's
# real output cap was below 65536 — warn so it's greppable in logs.
eff_max_tokens = desired_max_tokens
logger.warning(
"sub-agent LLM: no profile llm.max_tokens known; requesting "
"max_tokens=%d unbounded — if the model rejects it the sub-agent "
"dies on turn 1 with stopped_by=llm_error and an empty report",
eff_max_tokens,
)
try:
from dataclasses import replace
from frontier_agent.core.runtime.loop.llm_client import _ensure_bound
bound = replace(_ensure_bound(llm), max_tokens=eff_max_tokens)
except Exception:
bound = llm
stream_cfg = getattr(runtime, "stream_repetition_config", None)
if stream_cfg is None:
return bound
_sr = importlib.import_module(
f"{_runtime_workflow_pkg(runtime)}.stream_repetition"
)
wrap_llm_for_stream_repetition = _sr.wrap_llm_for_stream_repetition
wrapped, _observer = wrap_llm_for_stream_repetition(
bound,
config=stream_cfg,
role_id=SUB_ROLE_ID,
label="subagent",
)
return wrapped
@tool
async def create_subagent(agents: list[Any] | str = "") -> str:
"""Create one or more persistent sub-agents.
Each sub-agent is a long-lived session that can accept multiple
``assign_task`` calls, accumulating history across tasks. The
session's history is trimmed between tasks (system + each completed
task's prompt + final report) so reused agents stay context-efficient.
Args:
agents: list of dicts with:
- ``name`` (required): Unique sub-agent name following
``{topic}_{task_type}[_{N}]``, e.g. ``lit-review_research``.
- ``system_prompt`` (required): Custom system prompt for this
instance — describes its specialty/focus.
Returns:
Confirmation text listing the created sub-agents.
"""
# ``agents`` defaults to ``""`` so models emitting empty ``{}`` args
# land on the actionable error path instead of crashing pydantic
# with ``Field required``. JSON-string serialisation is handled by
# ``coerce_json_list``.
agents = coerce_json_list(agents) or []
if not agents:
return "Error: create_subagent requires at least one agent spec."
if len(agents) > MAX_SUBAGENTS_PER_DISPATCH:
return (
f"Error: create_subagent supports at most "
f"{MAX_SUBAGENTS_PER_DISPATCH} agents per call; you passed "
f"{len(agents)}. Split the list across multiple calls so no "
f"agent is silently dropped."
)
scope = get_current_execution_scope()
if scope is None:
return (
"Error: create_subagent can only be called inside an "
"active ReAct execution."
)
runtime = _resolve_runtime(scope.metadata)
sub_role_id = _resolve_sub_role_id(runtime)
fs_mode = bool(getattr(runtime, "fs_mode", False))
# Online prompt enhancements gate — same ``sdk_protocol_emitter`` signal
# the main agent reads (set by serve.py / run.py, absent on benchmark
# eval). On → online-tuned sub-agent templates; off → ``*_lean`` baseline.
online_prompt = bool((scope.metadata or {}).get("sdk_protocol_emitter"))
bus = registry.get(AgentBus)
bus_task_id = resolve_bus_task_id(scope)
sse_task_id = resolve_root_task_id(scope)
# Heavy-mode tags carried in scope_metadata by main_agent_node
# (S0 plumbing). Empty in a normal run — passed through to
# SSEObserver so per-run sub-agent events are tagged with the
# owning run_id.
run_id = str((scope.metadata or {}).get("run_id") or "")
run_type = str((scope.metadata or {}).get("run_type") or "")
bound_llm = _bind_sub_agent_llm(runtime)
created: list[str] = []
errors: list[str] = []
renamed: list[tuple[str, str]] = []
# Workflow-aware name validation: strict mode enforces {topic}_{task_type};
# agent_team (no SUBAGENT_TASK_TYPES) uses lenient role-label names.
task_types = _resolve_task_types(runtime)
for spec in agents:
if not isinstance(spec, dict):
errors.append(f"Skipping non-dict agent spec: {spec!r}")
continue
raw_name = str(spec.get("name", "")).strip()
prompt = str(spec.get("system_prompt", "")).strip()
if not raw_name:
errors.append("Skipping agent with no name")
continue
name = _normalize_agent_name(raw_name, task_types)
if name != raw_name:
renamed.append((raw_name, name))
name_error = _validate_agent_name(name, task_types)
if name_error:
errors.append(name_error)
continue
if not prompt:
errors.append(f"Skipping agent {name!r} with no system_prompt")
continue
try:
effective_prompt = _resolve_specialist_prompt(
name,
prompt,
fs_mode=fs_mode,
enhancements=online_prompt,
mcp_tool_names=getattr(runtime, "mcp_tool_names", None),
mcp_tool_specs=getattr(runtime, "mcp_tool_specs", None),
runtime=runtime,
)
runtime_spec = (
_build_runtime_spec(
runtime,
session_name=name,
task_id=bus_task_id,
task_id_for_sse=sse_task_id,
run_id=run_id,
run_type=run_type,
)
if runtime is not None
else None
)
await bus.create_session(
task_id=bus_task_id,
name=name,
role_id=sub_role_id,
system_prompt=effective_prompt,
tools_override=_runtime_tools_override(runtime),
trimmer=TaskBoundaryTrimmer(),
max_turns=(
int(runtime.sub_agent_max_turns)
if runtime is not None
and getattr(runtime, "sub_agent_max_turns", None)
else SUB_MAX_TURNS_DEFAULT
),
llm_override=bound_llm,
tool_result_max_chars=getattr(runtime, "tool_result_max_chars", 15_000),
runtime_spec=runtime_spec,
)
created.append(name)
except Exception as exc:
logger.warning(
"create_subagent: failed to create %s: %s", name, exc,
)
errors.append(f"Failed to create {name!r}: {exc}")
if not created and errors:
return "Error: " + "; ".join(errors)
lines = [f"Created {len(created)} sub-agent(s):"]
lines.extend(f" - {n}" for n in created)
if renamed:
lines.append("")
lines.append(
"Note: normalized underscores in topic → dashes "
"(canonical form is {topic}_{task_type}[_{N}] with dash-only topic):"
)
lines.extend(f" - {raw}{fixed}" for raw, fixed in renamed)
if errors:
lines.append("")
lines.append("Warnings:")
lines.extend(f" - {e}" for e in errors)
lines.append("")
lines.append(
"Call assign_task(tasks=[{agent:NAME, prompt:...}]) to give them work."
)
return "\n".join(lines)
__all__ = ["create_subagent"]