polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
//! The `Agent` custom resource (`polychrome.dev/v1alpha1`).
//!
//! An `Agent` is a **reusable agent definition**: a model backend, system
//! instructions, a scoped tool surface (connectors *and* built-ins), the agents
//! it may hand off to, its sandbox mode, and its approval policy — declared once
//! as data and referenced by many conversations. It is the keystone of the
//! declarative catalog (see `docs/reference/declarative-catalog.md`): today an
//! agent's identity is split across `ConversationSpec.model`,
//! `ConversationSpec.tools_enabled`, `ConversationSpec.agent_id`, per-tool
//! `ToolService` flags, and process config. This collapses that into one object.
//!
//! A [`Conversation`](crate::Conversation) runs *as* an `Agent` via its
//! `agent_id` (the `instanceOf` relation). The control plane resolves the agent
//! at turn dispatch and applies it — system prompt, connector scope, built-in
//! scope, model — instead of reading inline conversation fields. See
//! `docs/reference/agent-tool-surfaces.md` for the orchestrator + specialists
//! topology this enables.
//!
//! This module defines the schema only; resolution at turn dispatch happens
//! in `polychrome-control-plane` (`KubeHarnessProvider::resolve_agent`), and
//! `canHandoffTo` is enforced there too, at handoff emission
//! (`crate::handoff::handoff_permitted` in that crate).

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Desired state of a reusable agent definition.
///
/// Every field is optional or defaulted so a minimal `Agent` (just a name) is
/// valid and behaves like today's unconfigured generalist; an operator scopes
/// it down field by field.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
    group = "polychrome.dev",
    version = "v1alpha1",
    kind = "Agent",
    namespaced,
    status = "AgentStatus",
    shortname = "ag",
    category = "polychrome",
    derive = "PartialEq",
    printcolumn = r#"{"name":"Model","type":"string","jsonPath":".spec.model"}"#,
    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct AgentSpec {
    /// When to route to this agent — the capability summary an orchestrator
    /// reads to pick a handoff target (and an operator reads to understand the
    /// agent). Display/selection only; it does not gate anything.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// System instructions prepended (as a System message) on every turn this
    /// agent runs. Alongside, not replacing, the caller-persona context block.
    /// `None` → no agent-specific instructions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instructions: Option<String>,

    /// Backend selection as `provider/model` (e.g. `"vertex/<model-id>"`).
    /// `None` → inherit the deployment's live active-model selection, so an
    /// agent need only pin a model when it deliberately differs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,

    /// Connector tools this agent may use — handles resolved against the
    /// `ToolService` registry, exactly like
    /// [`ConversationSpec.tools_enabled`](crate::ConversationSpec). The
    /// effective set is the intersection with the conversation's own
    /// `tools_enabled`. Empty → default-enabled connectors only.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools_enabled: Vec<String>,

    /// The always-on tool core — the tools this agent leads with on **every**
    /// turn, advertised first and never displaced by any per-turn filtering.
    /// Names use the same grammar the model sees: a **bare** name is a built-in
    /// (e.g. `"grep"`, `"file_read"`); a **namespaced** `<connector>__<tool>`
    /// name is a connector tool (e.g. `"vcs__repo_list"`).
    ///
    /// The core pins advertisement, it does not widen a grant. A core built-in
    /// scoped out by [`Self::builtin_tools`] is still advertised — the core wins
    /// over built-in scoping, which is a display concern, not a security one. A
    /// core connector tool whose connector is outside this agent's
    /// [`Self::tools_enabled`] ceiling is **not** advertised — the grant is a
    /// security boundary the core never crosses. A core tool nothing currently
    /// owns (its connector is down or ungranted) is skipped: the core guarantees
    /// advertisement of what exists, it cannot conjure a tool. Empty → the agent
    /// declares no core. Maps to the harness `TurnInput.core_tools` wire field.
    // Design lineage (kept out of the CRD description): #637, pinning
    // invariant 3 of the tool-retrieval design (#582) ahead of ranking.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub core_tools: Vec<String>,

    /// Built-in (local) tools this agent may use, by name (e.g. `"grep"`,
    /// `"shell_exec"`). `Some(list)` scopes the built-in surface to exactly
    /// those — an empty list means **none**, so an orchestrator advertises zero
    /// built-ins and a visible tool can't shadow a connector tool. `None` → the
    /// full built-in set (the unconfigured default). Maps to the harness
    /// `TurnInput.scope_builtin_tools` / `builtin_tools` wire fields.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub builtin_tools: Option<Vec<String>>,

    /// Agents this one may hand off to in the `canHandoffTo` graph.
    /// The control plane enforces this list when `__handoff_to` emits a request.
    /// A denied target produces a signed `HandoffDenied` event. It does not
    /// produce an accepted request. An empty list denies every target. The gate
    /// does not affect a conversation with no bound agent.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub can_handoff_to: Vec<String>,

    /// Agents this one may delegate a single task to, in-process, within the
    /// SAME turn (`__delegate_to`, #870) — the tracer-bullet sibling of
    /// [`Self::can_handoff_to`]. Unlike a handoff (which suspends this
    /// conversation and hands off entirely to a child `Conversation`), a
    /// delegation runs a nested, context-isolated turn with no parent
    /// history and returns the worker's final text as the calling tool's
    /// result — the turn never suspends. Enforced at `__delegate_to`
    /// resolution with the same fail-closed semantics as `can_handoff_to`:
    /// empty on a BOUND agent denies every delegation; an unbound
    /// conversation is unaffected by this gate.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub can_delegate_to: Vec<String>,

    /// Step budget applied to a nested turn run when THIS agent is delegated
    /// to as a worker (#870) — consulted when resolving a *delegator's*
    /// `canDelegateTo` entry that names this agent, never at this agent's own
    /// turns. `None` → a small default (at or below the crate's own
    /// `DEFAULT_MAX_STEPS` of 8): a scoped worker task needs less headroom
    /// than a full conversational turn.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delegate_step_budget: Option<u32>,

    /// Ceiling on the delegator's workspace files a nested turn run as THIS
    /// agent may be seeded with (`#2295`) — like
    /// [`Self::delegate_step_budget`], a property of the agent AS A WORKER,
    /// consulted when resolving a *delegator's* `canDelegateTo` entry that
    /// names this agent.
    ///
    /// Each worker runs in its own workspace subtree and cannot see the
    /// delegator's files (`#2286`). This declares the most a worker of this
    /// agent may ever be handed back; the delegating model then names
    /// specific paths inside it on each `__delegate_to` call. `None` → no
    /// sharing at all, so a worker keeps the fully isolated workspace it gets
    /// today.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delegate_share_in: Option<DelegateShareIn>,

    /// Fan-out width cap (`#874`): the maximum number of `__delegate_to`
    /// calls THIS agent's own model may emit in a single step/batch, when
    /// running as the *orchestrator* dispatching those calls — unlike
    /// [`Self::delegate_step_budget`] (a property of the agent AS A
    /// WORKER, consulted when it's the delegation TARGET), this is a
    /// property of the agent as the caller, capping how many workers it may
    /// fan out to in one model turn. `None` → a safe default (4). Resolved
    /// and clamped at turn dispatch (`polyc_control_plane::delegate`); a
    /// configured value above the hard ceiling (16) is clamped down to it,
    /// never rejected — an operator typo should degrade to a safe bound, not
    /// fail the whole `Agent` resource.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delegate_max_fanout: Option<u32>,

    /// Turn-scoped total delegate-call budget (`#874`): the maximum number
    /// of `__delegate_to` calls THIS agent's model may dispatch ACROSS ALL
    /// its batches/steps in one turn — not just one batch, unlike
    /// [`Self::delegate_max_fanout`]. Bounds a pathological
    /// re-decompose-every-step loop from spawning unbounded workers over a
    /// long-running turn. Also a property of the agent as the ORCHESTRATOR,
    /// resolved the same way as `delegateMaxFanout`. `None` → a safe
    /// default (12). Resolved and clamped at turn dispatch
    /// (`polyc_control_plane::delegate`); a configured value above the hard
    /// ceiling (32) is clamped down to it, never rejected.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delegate_turn_budget: Option<u32>,

    /// Sandbox mode the agent's harness runs in: `"read-only"`,
    /// `"workspace-write"`, or `"danger-full-access"`. `None` → the deployment
    /// default. A `coding` agent sets `workspace-write`; routing/connector
    /// agents stay `read-only`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox_mode: Option<String>,

    /// Declared per-agent approval overrides: tool names (built-in or
    /// connector) intended to always require signed human approval for this
    /// agent. **Not yet consumed anywhere** — dispatch today resolves
    /// approval through the separate, already-shipped `polyc-persona`
    /// `ApprovalPolicy` axis (`Always`/`Default`/`Never`, resolved per
    /// persona in the control plane's `grpc/permissions.rs`), not this
    /// field. Declaration only until this is either wired in or superseded
    /// outright.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_policy: Option<ApprovalPolicy>,
}

/// Ceiling on the delegator workspace files a worker may be seeded with
/// (`#2295`), declared on the agent that runs AS the worker.
///
/// Every field must admit something for any sharing to happen: an empty
/// [`Self::allow`], a zero [`Self::max_files`], or a zero [`Self::max_bytes`]
/// each independently close the ceiling. A partially filled block therefore
/// shares nothing rather than falling back to an unbounded default.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DelegateShareIn {
    /// Glob patterns, matched against delegator-workspace-relative file paths
    /// (`*`, `?`, and `**` mean what they mean to the `glob` tool). A file can
    /// be shared only if it matches at least one pattern. Empty → nothing is
    /// shareable.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allow: Vec<String>,

    /// Maximum files one delegate call may seed. `None` → a conservative
    /// default (32), so an operator who declares `allow` alone still gets a
    /// bounded ceiling rather than an unbounded one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_files: Option<u32>,

    /// Maximum total bytes one delegate call may seed, bounding what a fan-out
    /// of workers can copy into the workspace volume. `None` → a conservative
    /// default (8 MiB).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_bytes: Option<u64>,
}

/// Declared per-agent approval overrides (see
/// [`AgentSpec::approval_policy`] for current wiring status).
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ApprovalPolicy {
    /// Tool names intended to always route through the HITL approval gate
    /// for this agent. Not yet enforced — see
    /// [`AgentSpec::approval_policy`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub needs_approval: Vec<String>,
}

/// Observed state, written back to the `status` subresource by the controller.
///
/// Reflects whether the agent's references resolve (model present, named
/// connectors and handoff targets exist). The validating reconciler that writes
/// this lands in a later slice; until then the field is informational and the
/// control plane reads the spec directly.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentStatus {
    /// `true` when every reference in the spec resolves.
    #[serde(default)]
    pub ready: bool,
    /// Human-readable detail (e.g. an unresolved connector or handoff target).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;
    use kube::CustomResourceExt;
    use serde_json::{Value, json};

    #[test]
    fn crd_identity_is_polychrome_agent() {
        let crd = Agent::crd();
        assert_eq!(crd.spec.group, "polychrome.dev");
        assert_eq!(crd.spec.names.kind, "Agent");
        assert_eq!(crd.spec.names.plural, "agents");
    }

    #[test]
    fn spec_round_trips_camel_case() {
        // The wire is camelCase; a typo'd rename would silently drop a field on
        // the way to/from etcd, so pin the exact JSON shape.
        let spec = AgentSpec {
            description: Some("route service ops".to_owned()),
            instructions: Some("You are the ops agent.".to_owned()),
            model: Some("vertex/some-model".to_owned()),
            tools_enabled: vec!["toolservice:polychrome/scaffold".to_owned()],
            core_tools: vec!["grep".to_owned(), "scaffold__plan".to_owned()],
            builtin_tools: Some(vec![]),
            can_handoff_to: vec!["agent:default/coding".to_owned()],
            can_delegate_to: vec!["agent:default/researcher".to_owned()],
            delegate_step_budget: Some(4),
            delegate_share_in: Some(DelegateShareIn {
                allow: vec!["src/**".to_owned()],
                max_files: Some(8),
                max_bytes: Some(4096),
            }),
            delegate_max_fanout: Some(6),
            delegate_turn_budget: Some(10),
            sandbox_mode: Some("read-only".to_owned()),
            approval_policy: Some(ApprovalPolicy {
                needs_approval: vec!["service_delete".to_owned()],
            }),
        };
        let v = serde_json::to_value(&spec).unwrap();
        assert!(
            v.get("builtinTools").is_some(),
            "builtin_tools → builtinTools"
        );
        assert!(
            v.get("canHandoffTo").is_some(),
            "can_handoff_to → canHandoffTo"
        );
        assert!(
            v.get("canDelegateTo").is_some(),
            "can_delegate_to → canDelegateTo"
        );
        assert!(
            v.get("delegateStepBudget").is_some(),
            "delegate_step_budget → delegateStepBudget"
        );
        assert!(
            v.get("delegateMaxFanout").is_some(),
            "delegate_max_fanout → delegateMaxFanout"
        );
        assert!(
            v.get("delegateTurnBudget").is_some(),
            "delegate_turn_budget → delegateTurnBudget"
        );
        assert!(v.get("sandboxMode").is_some(), "sandbox_mode → sandboxMode");
        assert_eq!(
            v["delegateShareIn"]["allow"][0], "src/**",
            "delegate_share_in → delegateShareIn"
        );
        assert_eq!(v["delegateShareIn"]["maxFiles"], 8, "max_files → maxFiles");
        assert_eq!(
            v["delegateShareIn"]["maxBytes"], 4096,
            "max_bytes → maxBytes"
        );
        assert_eq!(v["coreTools"][0], "grep", "core_tools → coreTools");
        assert_eq!(v["approvalPolicy"]["needsApproval"][0], "service_delete");
        assert_eq!(serde_json::from_value::<AgentSpec>(v).unwrap(), spec);
    }

    #[test]
    fn core_tools_defaults_empty_and_round_trips() {
        // Absent `coreTools` → the agent declares no core (empty), and the
        // field is omitted from a minimal Agent's serialized form. A present
        // list round-trips its declared order (the advertised-prefix order).
        let absent: AgentSpec = serde_json::from_value(json!({})).unwrap();
        assert!(absent.core_tools.is_empty());
        assert!(
            serde_json::to_value(&absent)
                .unwrap()
                .get("coreTools")
                .is_none(),
            "empty core omitted from the wire"
        );
        let present: AgentSpec = serde_json::from_value(json!({
            "coreTools": ["grep", "vcs__repo_list"]
        }))
        .unwrap();
        assert_eq!(present.core_tools, vec!["grep", "vcs__repo_list"]);
        let round_tripped: AgentSpec =
            serde_json::from_value(serde_json::to_value(&present).unwrap()).unwrap();
        assert_eq!(round_tripped, present);
    }

    #[test]
    fn builtin_tools_distinguishes_unset_from_empty() {
        // The keystone distinction: absent → full built-in set; present-empty →
        // no built-ins. These must NOT collapse to the same wire shape.
        let unset: AgentSpec = serde_json::from_value(json!({})).unwrap();
        assert_eq!(unset.builtin_tools, None);
        let empty: AgentSpec = serde_json::from_value(json!({ "builtinTools": [] })).unwrap();
        assert_eq!(empty.builtin_tools, Some(vec![]));
        // `None` is omitted from the serialized form (so a minimal Agent is tiny).
        let out = serde_json::to_value(&unset).unwrap();
        assert!(out.get("builtinTools").is_none());
    }

    #[test]
    fn minimal_agent_spec_is_valid() {
        // A name-only Agent (empty spec) deserializes — the unconfigured
        // generalist default, scoped down field by field.
        let spec: AgentSpec = serde_json::from_value(json!({})).unwrap();
        assert!(
            spec.model.is_none() && spec.tools_enabled.is_empty() && spec.can_handoff_to.is_empty()
        );
        let _: Value = serde_json::to_value(spec).unwrap();
    }
}