Aari 1aa813ddb3
feat: add managed subagents and delegation scopes (#4887)
* feat: manage and scope subagents

* fix: address subagent review feedback

* fix: address managed subagent review feedback

* fix: harden subagent settings semantics

* fix: harden managed subagent cache invalidation

* fix: reuse assembled lead agent inputs

* fix: migrate managed subagent definitions

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-24 11:04:23 +08:00

117 lines
4.1 KiB
Python

"""Storage contract for deployment-level managed subagents."""
from __future__ import annotations
import abc
import re
from collections.abc import Hashable
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
REQUIRED_DISALLOWED_TOOLS = frozenset({"task", "ask_clarification", "present_files"})
MANAGED_SUBAGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
def normalize_managed_subagent_name(value: str) -> str:
"""Validate and normalize a managed subagent natural key."""
if not isinstance(value, str) or not MANAGED_SUBAGENT_NAME_PATTERN.fullmatch(value):
raise ValueError(f"Invalid managed subagent name {value!r}. Must match {MANAGED_SUBAGENT_NAME_PATTERN.pattern}")
return value.lower()
class ManagedSubagentDefinition(BaseModel):
"""Administrator-managed worker definition stored outside config.yaml."""
model_config = ConfigDict(extra="forbid")
name: str
display_name: str | None = None
description: str = Field(min_length=1)
system_prompt: str = Field(min_length=1)
tools: list[str] | None = None
disallowed_tools: list[str] = Field(default_factory=lambda: sorted(REQUIRED_DISALLOWED_TOOLS))
skills: list[str] | None = None
model: str = "inherit"
max_turns: int = Field(default=50, ge=1)
timeout_seconds: int = Field(default=900, ge=1)
enabled: bool = True
@field_validator("name")
@classmethod
def _normalize_name(cls, value: str) -> str:
return normalize_managed_subagent_name(value)
@field_validator("display_name")
@classmethod
def _normalize_display_name(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
@field_validator("description", "system_prompt")
@classmethod
def _strip_required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("must not be blank")
return stripped
@field_validator("tools", "skills")
@classmethod
def _validate_name_lists(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized: list[str] = []
for item in value:
stripped = item.strip()
if not stripped:
raise ValueError("entries must not be blank")
if stripped not in normalized:
normalized.append(stripped)
return normalized
@model_validator(mode="after")
def _enforce_worker_tool_boundary(self) -> ManagedSubagentDefinition:
denied = list(dict.fromkeys([*self.disallowed_tools, *sorted(REQUIRED_DISALLOWED_TOOLS)]))
self.disallowed_tools = denied
return self
class ManagedSubagentExistsError(Exception):
"""Raised when a managed definition already owns a name."""
class ManagedSubagentStore(abc.ABC):
def cache_identity(self) -> Hashable:
"""Return the process-local identity of the backing catalog.
Stateless store instances that point at the same backing data should
override this so registry snapshots can be reused across instances.
"""
return id(self)
@abc.abstractmethod
def get(self, name: str) -> ManagedSubagentDefinition:
"""Return one definition or raise ``FileNotFoundError``."""
@abc.abstractmethod
def list(self) -> list[ManagedSubagentDefinition]:
"""Return every managed definition, including disabled ones."""
@abc.abstractmethod
def create(self, definition: ManagedSubagentDefinition) -> None:
"""Create one definition or raise ``ManagedSubagentExistsError``."""
@abc.abstractmethod
def update(self, definition: ManagedSubagentDefinition) -> None:
"""Replace one existing definition or raise ``FileNotFoundError``."""
@abc.abstractmethod
def delete(self, name: str) -> bool:
"""Delete one definition and return whether it existed."""
@abc.abstractmethod
def signature(self) -> Hashable:
"""Return an opaque token suitable for cache invalidation."""