"""Claude agent definition, backed by the Claude Agent SDK. The system prompt is ``system_prompt`` verbatim or, per conversation kind, the granules named in ``prompts`` assembled at every session spawn (see ``core/prompt.py``). ``skill_sets`` are directories of ``/SKILL.md`` folders; each becomes a local SDK plugin, either the same tuple for every kind or a ``SkillSets`` with a tuple per kind (§4.3: the master never sees what a branch opens). Nothing from disk is loaded otherwise: the adapter runs with ``setting_sources=[]``. """ from __future__ import annotations from collections.abc import Mapping # noqa: TC003 - pydantic runtime from pathlib import Path # noqa: TC003 - pydantic runtime from pydantic import BaseModel, ConfigDict, Field, model_validator from beaver_gateway.agents.base import BaseAgent from beaver_gateway.agents.policy import PolicyRule # noqa: TC001 - pydantic runtime from beaver_gateway.agents.prompts import PromptSource # noqa: TC001 - pydantic runtime from beaver_gateway.conversations.kinds import KINDS, Kind __all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"] class ClaudeOptions(BaseModel): model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) effort: str | None = None include_partial_messages: bool = True """Token-level deltas on the wire; off means one delta per finished block.""" permission_mode: str = "bypassPermissions" tools: tuple[str, ...] | None = None """Base set of built-in tools; ``None`` keeps the CLI default set.""" disallowed_tools: tuple[str, ...] = () add_dirs: tuple[str, ...] = () env: Mapping[str, str] = Field(default_factory=dict) """Extra environment for the claude subprocess (always passed through).""" env_keep: tuple[str, ...] = () """Extra inherited variable names to let through the env whitelist.""" max_turns: int | None = None session_store_flush: str = "eager" """``eager`` mirrors every transcript frame as it lands, so a gateway killed mid-turn loses at most the frame in flight.""" class Prompts(BaseModel): """Prompt assembly per conversation kind (§3.12): the granules, in order. A kind left ``None`` is not served by the agent; ``ClaudeAgent.kinds`` follows from the kinds set here. """ model_config = ConfigDict(frozen=True) master: tuple[PromptSource, ...] | None = None branch: tuple[PromptSource, ...] | None = None deep: tuple[PromptSource, ...] | None = None job: tuple[PromptSource, ...] | None = None fork: tuple[PromptSource, ...] | None = None def for_kind(self, kind: Kind) -> tuple[PromptSource, ...] | None: return getattr(self, kind) @property def kinds(self) -> tuple[Kind, ...]: return tuple(k for k in KINDS if self.for_kind(k) is not None) class SkillSets(BaseModel): """Skill-set directories per conversation kind; ``None`` means none.""" model_config = ConfigDict(frozen=True) master: tuple[Path, ...] | None = None branch: tuple[Path, ...] | None = None deep: tuple[Path, ...] | None = None job: tuple[Path, ...] | None = None fork: tuple[Path, ...] | None = None def for_kind(self, kind: Kind) -> tuple[Path, ...]: return getattr(self, kind) or () class ClaudeAgent(BaseAgent): cwd: Path system_prompt: str = "" """Verbatim prompt for agents without ``prompts`` (tests, one-offs).""" prompts: Prompts = Field(default_factory=Prompts) kinds: tuple[Kind, ...] = () """Conversation kinds this agent serves; ``create``/``spawn`` reject the rest. Defaults to the kinds ``prompts`` covers, or ``("deep",)`` for a verbatim ``system_prompt``.""" skill_sets: tuple[Path, ...] | SkillSets = () """Skill-set directories, each a local plugin: one tuple for every kind, or ``SkillSets`` to give each kind its own (a kind left ``None`` gets no skills).""" gateway_tools: tuple[str, ...] = () """Gateway tools exposed in-process (``read_conversation``, ``spawn``, ``say``, ``schedule``, ``inject``); empty = no gateway MCP server.""" options: ClaudeOptions = Field(default_factory=ClaudeOptions) policy: tuple[PolicyRule, ...] = () """``PreToolUse`` rules (§3.7), run in order on every tool call; the first ``Deny`` is what the model reads back. See ``core/policy``.""" @model_validator(mode="after") def _kinds_follow_prompts(self) -> ClaudeAgent: covered = self.prompts.kinds if not self.kinds: object.__setattr__(self, "kinds", covered or ("deep",)) elif covered: missing = [k for k in self.kinds if k not in covered] if missing: msg = f"agent {self.name!r} serves {missing} without a prompt" raise ValueError(msg) return self def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None: return self.prompts.for_kind(kind) def skills_for(self, kind: Kind) -> tuple[Path, ...]: if isinstance(self.skill_sets, SkillSets): return self.skill_sets.for_kind(kind) return self.skill_sets def serves(self, kind: str) -> bool: return kind in self.kinds