use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::core::{Budget, Digest, Sensitivity, canon};
mod binding;
pub use binding::MemorySubject;
mod error;
pub use error::ManifestError;
pub mod registry;
pub use registry::{MemoryRegistry, Registry, RegistryError};
mod triage;
pub use triage::{Condition, Predicate, TriagePriority, TriageRule};
pub const API_VERSION: &str = "agentplane.hupe1980.github.io/v1alpha1";
pub const KIND: &str = "Agent";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: Metadata,
pub spec: Spec,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub annotations: BTreeMap<String, String>,
}
pub const RESERVED_ANNOTATION_PREFIX: &str = "agentplane.hupe1980.github.io/";
pub const MAX_ANNOTATIONS_BYTES: usize = 256 * 1024;
fn is_dns_subdomain(prefix: &str) -> bool {
prefix.len() <= 253
&& prefix.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !label.starts_with('-')
&& !label.ends_with('-')
})
}
fn is_annotation_name(name: &str) -> bool {
let alnum = |b: u8| b.is_ascii_alphanumeric();
name.len() <= 63
&& name.as_bytes().first().is_some_and(|&b| alnum(b))
&& name.as_bytes().last().is_some_and(|&b| alnum(b))
&& name
.bytes()
.all(|b| alnum(b) || b == b'-' || b == b'_' || b == b'.')
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Spec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<Identity>,
#[serde(default)]
pub security: Security,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budgets: Option<Budgets>,
#[serde(default)]
pub tools: Vec<ToolGrant>,
#[serde(default, skip_serializing_if = "ContextGrants::is_empty")]
pub context: ContextGrants,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oversight: Option<Oversight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<Execution>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology: Option<Topology>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models: Option<Models>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<Input>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<Output>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<Memory>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Memory {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recall: Option<MemoryRecall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formation: Option<MemoryFormation>,
}
impl Memory {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.recall.is_none() && self.formation.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MemoryRecall {
pub subject: MemorySubject,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
#[serde(default = "default_recall_limit")]
pub limit: usize,
#[serde(default)]
pub refresh_access: bool,
}
const fn default_recall_limit() -> usize {
5
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContextGrants {
#[serde(default)]
pub prompts: Vec<ContextPrompt>,
#[serde(default)]
pub resources: Vec<ContextResource>,
#[serde(default)]
pub task_input: Vec<ContextTaskInput>,
}
impl ContextGrants {
#[must_use]
pub fn is_empty(&self) -> bool {
self.prompts.is_empty() && self.resources.is_empty() && self.task_input.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContextPrompt {
pub server: String,
pub name: String,
#[serde(default = "public_sensitivity")]
pub max_input_sensitivity: Sensitivity,
#[serde(default = "public_sensitivity")]
pub output_sensitivity: Sensitivity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContextResource {
pub server: String,
pub uri: String,
#[serde(default = "public_sensitivity")]
pub output_sensitivity: Sensitivity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContextTaskInput {
pub server: String,
#[serde(default = "public_sensitivity")]
pub max_input_sensitivity: Sensitivity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MemoryFormation {
pub subject: MemorySubject,
pub purpose: String,
pub instruction: String,
#[serde(default = "default_formation_items")]
pub max_items: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_retention_seconds: Option<u64>,
#[serde(default = "public_sensitivity")]
pub max_sensitivity: crate::core::Sensitivity,
}
const fn default_formation_items() -> usize {
3
}
const fn public_sensitivity() -> crate::core::Sensitivity {
crate::core::Sensitivity::Public
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Identity {
pub role: String,
#[serde(default)]
pub constraints: String,
}
impl Identity {
#[must_use]
pub fn system_prompt(&self) -> String {
if self.constraints.trim().is_empty() {
self.role.trim().to_owned()
} else {
format!("{}\n\n{}", self.role.trim(), self.constraints.trim())
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Oversight {
pub approval: Approval,
#[serde(default)]
pub approvers: Vec<String>,
pub deadline: OversightDeadline,
#[serde(default)]
pub on_expiry: Expiry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub escalate_to: Vec<String>,
#[serde(default)]
pub allow_unattended: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub triage: Vec<TriageRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OversightDeadline {
pub name: String,
pub kind: String,
#[serde(default)]
pub params: serde_json::Value,
}
impl OversightDeadline {
#[must_use]
pub fn spec(&self) -> crate::core::DeadlineSpec {
crate::core::DeadlineSpec::new(
self.kind.clone(),
if self.params.is_null() {
serde_json::json!({})
} else {
self.params.clone()
},
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Approval {
Required,
ToolsOnly,
None,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum Expiry {
#[default]
Deny,
Escalate,
Proceed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Execution {
pub kind: ExecutionKind,
#[serde(default = "default_max_turns")]
pub max_turns: u32,
}
const fn default_max_turns() -> u32 {
8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ExecutionKind {
Completion,
ToolCalling,
Planned,
}
impl ExecutionKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Completion => "completion",
Self::ToolCalling => "tool-calling",
Self::Planned => "planned",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Topology {
#[serde(default)]
pub mode: TopologyMode,
#[serde(default)]
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<Justification>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum TopologyMode {
#[default]
Single,
Collaborative,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum Role {
#[default]
Specialist,
Orchestrator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Justification {
ParallelDisjoint,
DistinctAuthority,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Models {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privileged: Option<ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quarantined: Option<ModelRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ModelRef {
pub provider: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<crate::model::ReasoningEffort>,
}
fn role(r: &ModelRef) -> crate::model::ModelRole {
crate::model::ModelRole {
model: crate::model::ModelId::new(&r.provider, &r.model),
max_output_tokens: r.max_tokens,
reasoning_effort: r.reasoning_effort,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Input {
pub schema: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Output {
pub schema: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Security {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_sensitivity_egress: Option<Sensitivity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_sensitivity_journaled: Option<Sensitivity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_delegation_depth: Option<u8>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
#[serde(default)]
pub provides: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Budgets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_steps: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_effects: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_minor_units: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_replans: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wallclock_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_denials: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_egress_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel_steps: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ToolGrant {
#[serde(rename = "ref")]
pub reference: String,
#[serde(default = "yes")]
pub mutates: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_sensitivity: Option<Sensitivity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protected_fields: Vec<crate::core::ProtectedField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub requires_approval: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<serde_json::Value>,
}
const fn yes() -> bool {
true
}
fn trim_descriptions(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
if let Some(serde_json::Value::String(text)) = object.get_mut("description") {
*text = text
.split("\n\n")
.next()
.unwrap_or_default()
.replace("[`", "`")
.replace("`]", "`")
.replace('\n', " ");
}
for value in object.values_mut() {
trim_descriptions(value);
}
}
serde_json::Value::Array(items) => {
for value in items {
trim_descriptions(value);
}
}
_ => {}
}
}
fn tool_references_in(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find(crate::tools::TOOL_SCHEME) {
let candidate: String = rest[at..]
.chars()
.take_while(|c| {
c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '-' | '_' | '.' | '+')
})
.collect();
if crate::tools::ToolId::parse(&candidate).is_some() {
found.push(candidate.clone());
}
rest = &rest[at + crate::tools::TOOL_SCHEME.len()..];
}
found.sort();
found.dedup();
found
}
fn wire_names_in(text: &str) -> Vec<String> {
let mut found: Vec<String> = Vec::new();
for word in text.split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))) {
if word.matches("__").count() == 1
&& !word.starts_with("__")
&& !word.ends_with("__")
&& word.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.')
})
{
found.push(word.to_owned());
}
}
found.sort();
found.dedup();
found
}
impl Manifest {
#[must_use]
pub fn privileged_role(&self) -> Option<crate::model::ModelRole> {
self.spec.models.as_ref()?.privileged.as_ref().map(role)
}
#[must_use]
pub fn quarantined_role(&self) -> Option<crate::model::ModelRole> {
self.spec.models.as_ref()?.quarantined.as_ref().map(role)
}
#[must_use]
pub fn builder(name: impl Into<String>, version: impl Into<String>) -> ManifestBuilder {
ManifestBuilder {
metadata: Metadata {
name: name.into(),
version: version.into(),
annotations: BTreeMap::new(),
},
spec: Spec::default(),
}
}
pub fn build(mut self) -> Result<Self, ManifestError> {
self.normalize();
self.validate()?;
Ok(self)
}
pub fn parse(yaml: &str) -> Result<Self, ManifestError> {
let mut m: Self =
serde_yaml_ng::from_str(yaml).map_err(|e| ManifestError::Syntax(e.to_string()))?;
m.normalize();
m.validate()?;
Ok(m)
}
pub fn parse_all(yaml: &str) -> Result<Vec<Self>, ManifestError> {
use serde::Deserialize as _;
let mut manifests: Vec<Self> = Vec::new();
for (index, document) in serde_yaml_ng::Deserializer::from_str(yaml).enumerate() {
let ordinal = index + 1;
let value = serde_yaml_ng::Value::deserialize(document)
.map_err(|e| ManifestError::Syntax(format!("document {ordinal}: {e}")))?;
if value.is_null() {
continue;
}
let mut m: Self = serde_yaml_ng::from_value(value)
.map_err(|e| ManifestError::Syntax(format!("document {ordinal}: {e}")))?;
m.normalize();
m.validate()?;
if let Some(twin) = manifests
.iter()
.find(|prior| prior.metadata.name == m.metadata.name)
{
return Err(ManifestError::Syntax(format!(
"document {ordinal} declares agent '{}' a second time (first at \
version {}) — one file declaring the same agent twice is a merge \
conflict, not a room",
m.metadata.name, twin.metadata.version
)));
}
manifests.push(m);
}
if manifests.is_empty() {
return Err(ManifestError::Syntax(
"the file contains no manifest documents".to_owned(),
));
}
Ok(manifests)
}
pub fn parse_each<'a>(
documents: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<std::collections::BTreeMap<String, Self>, ManifestError> {
let mut out: std::collections::BTreeMap<String, Self> = std::collections::BTreeMap::new();
let mut origins: std::collections::BTreeMap<String, &str> =
std::collections::BTreeMap::new();
for (origin, yaml) in documents {
let m =
Self::parse(yaml).map_err(|e| ManifestError::Syntax(format!("{origin}: {e}")))?;
if let Some(first) = origins.get(&m.metadata.name) {
return Err(ManifestError::Syntax(format!(
"'{origin}' and '{first}' both declare agent '{}' — a name resolves to \
one declaration, so one of the two would silently not be the one that \
runs. Two agents need two names; one agent needs one file",
m.metadata.name
)));
}
origins.insert(m.metadata.name.clone(), origin);
out.insert(m.metadata.name.clone(), m);
}
if out.is_empty() {
return Err(ManifestError::Syntax(
"no manifest documents were supplied".to_owned(),
));
}
Ok(out)
}
fn normalize(&mut self) {
for grant in &mut self.spec.tools {
grant
.protected_fields
.sort_by(|left, right| left.path().cmp(right.path()));
}
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.api_version != API_VERSION || self.kind != KIND {
return Err(ManifestError::WrongDocument {
api_version: self.api_version.clone(),
kind: self.kind.clone(),
});
}
if self.metadata.name.trim().is_empty() {
return Err(ManifestError::Empty("metadata.name"));
}
if self.metadata.version.trim().is_empty() {
return Err(ManifestError::Empty("metadata.version"));
}
self.validate_annotations()?;
if let Some(identity) = &self.spec.identity {
if identity.role.trim().is_empty() {
return Err(ManifestError::Empty("spec.identity.role"));
}
}
if self.spec.execution.is_some() && self.spec.capabilities.provides.len() > 1 {
return Err(ManifestError::Unenforceable {
field: "spec.capabilities.provides",
detail: "a declarative agent provides exactly one capability — the \
behaviour cannot tell two apart, so a second name would be a \
distinction nothing executes. Split into two documents in one \
room file, each with its own digest",
});
}
self.validate_prompt_tool_references()?;
self.validate_oversight()?;
self.validate_tool_approval()?;
self.validate_tool_previews()?;
self.validate_tool_grants()?;
self.validate_mutating_grants_can_fire()?;
self.validate_agent_grants()?;
self.validate_context_grants()?;
self.validate_topology()?;
self.validate_models()?;
self.validate_input()?;
self.validate_output()?;
self.validate_declared_schemas()?;
self.validate_memory()?;
let mut tool_ids = std::collections::BTreeSet::new();
for grant in &self.spec.tools {
if grant.reference.trim().is_empty() {
return Err(ManifestError::Empty("spec.tools[].ref"));
}
let Some(id) = crate::tools::ToolId::parse(&grant.reference) else {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{}' is not an exact tool://server/name reference",
grant.reference
)));
};
if !tool_ids.insert(id.clone()) {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{}' is granted more than once — two safety declarations \
for one tool make list order decide which one governs",
grant.reference
)));
}
let mut paths = std::collections::BTreeSet::new();
for field in &grant.protected_fields {
field.validate().map_err(|detail| {
ManifestError::Syntax(format!(
"spec.tools[].protected_fields entry '{}': {detail}",
field.path()
))
})?;
if !paths.insert(field.path()) {
return Err(ManifestError::Syntax(
"spec.tools[].protected_fields must have unique paths".to_owned(),
));
}
}
}
if self.spec.budgets.is_none() {
return Err(ManifestError::Unbounded);
}
self.validate_budgets()
}
fn validate_budgets(&self) -> Result<(), ManifestError> {
let Some(field) = self.budget().bricked_ceiling() else {
return Ok(());
};
Err(ManifestError::Syntax(format!(
"spec.budgets.{field} is 0, which permits nothing at all — not merely no \
model spend. This ceiling is checked before every step and every effect, \
so at 0 it is already reached and the run is refused its first operation \
of any kind: a read-only tool call, a local lookup, an agent that declares \
no models at all. Such an agent does not run once and stop, it fails \
identically on every run it will ever make. Omit the field to mean 'no \
limit'. To stop a tenant doing work, use the operator's emergency stop \
(`QuotaStore::set_halt`), which refuses new runs with a reason attached — \
a halt says somebody is dealing with an incident, where a ceiling only \
says not right now"
)))
}
fn validate_annotations(&self) -> Result<(), ManifestError> {
let mut total = 0usize;
for (key, value) in &self.metadata.annotations {
let Some((prefix, name)) = key.split_once('/') else {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' is not namespaced — a key must be \
'prefix/name' with a dotted prefix you control (e.g. \
'example.com/business-owner'), so it cannot collide with a field \
this format may grow"
)));
};
if name.contains('/') {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' must be exactly one 'prefix/name' pair"
)));
}
if !prefix.contains('.') || !is_dns_subdomain(prefix) {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' has no valid prefix — it must be a DNS \
subdomain you control, like 'example.com/{name}': lowercase labels of \
letters, digits and '-', joined by dots, at most 253 characters"
)));
}
if !is_annotation_name(name) {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' has an invalid name — at most 63 characters, \
beginning and ending with a letter or digit, with '-', '_' and '.' between"
)));
}
if key.starts_with(RESERVED_ANNOTATION_PREFIX) {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' is under '{RESERVED_ANNOTATION_PREFIX}', which \
this format reserves so an annotation can never shadow a field it grows. \
Use a prefix you control"
)));
}
if value.trim().is_empty() {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: '{key}' has no value — a key that answers nothing \
reads to a reviewer like a question that was answered"
)));
}
total += key.len() + value.len();
}
if total > MAX_ANNOTATIONS_BYTES {
return Err(ManifestError::Syntax(format!(
"metadata.annotations: {total} bytes of keys and values, and the limit is \
{MAX_ANNOTATIONS_BYTES} — annotations are facts about the agent, not a \
place to keep its documents; store the document and annotate its address"
)));
}
Ok(())
}
fn validate_context_grants(&self) -> Result<(), ManifestError> {
let mut prompts = std::collections::BTreeSet::new();
for grant in &self.spec.context.prompts {
if grant.server.trim().is_empty() || grant.name.trim().is_empty() {
return Err(ManifestError::Empty("spec.context.prompts[].server/name"));
}
if !prompts.insert((&grant.server, &grant.name)) {
return Err(ManifestError::Syntax(format!(
"spec.context.prompts contains duplicate '{}/{}'",
grant.server, grant.name
)));
}
}
let mut resources = std::collections::BTreeSet::new();
for grant in &self.spec.context.resources {
if grant.server.trim().is_empty() || grant.uri.trim().is_empty() {
return Err(ManifestError::Empty("spec.context.resources[].server/uri"));
}
if !resources.insert((&grant.server, &grant.uri)) {
return Err(ManifestError::Syntax(format!(
"spec.context.resources contains duplicate '{}/{}'",
grant.server, grant.uri
)));
}
}
Ok(())
}
fn validate_tool_grants(&self) -> Result<(), ManifestError> {
let Some(execution) = &self.spec.execution else {
return Ok(());
};
if !matches!(
execution.kind,
ExecutionKind::ToolCalling | ExecutionKind::Planned
) {
return Ok(());
}
for grant in &self.spec.tools {
if grant
.description
.as_ref()
.is_none_or(|d| d.trim().is_empty())
{
return Err(ManifestError::Syntax(format!(
"spec.tools: '{}' has no description, and a `tool-calling` or `planned` \
agent offers its tools to a model by name and description. Without one \
the model guesses, and a guessed call is refused at the field check \
after it has been paid for",
grant.reference
)));
}
}
Ok(())
}
fn validate_mutating_grants_can_fire(&self) -> Result<(), ManifestError> {
let Some(execution) = &self.spec.execution else {
return Ok(());
};
if execution.kind != ExecutionKind::ToolCalling {
return Ok(());
}
for grant in &self.spec.tools {
if grant.reference.starts_with("tool://agent/") {
continue;
}
if grant.mutates && grant.protected_fields.is_empty() {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{}' declares `mutates: true` with no `protected_fields`, \
and this agent is `execution.kind: tool-calling`. A tool \
loop's arguments come from a model completion, which is \
always untrusted, so a mutating call with no field rules is \
refused by the taint gate on every run — the grant reads as \
a capability and is decoration. Three honest fixes: declare \
the authority-bearing arguments in `protected_fields` \
(ordinary untrusted content may sit beside them, which is \
what the feature is for); or use `execution.kind: planned`, \
whose step arguments are resolved by the runtime and keep \
the input's labels; or set `mutates: false` if the call \
really does not change anything",
grant.reference
)));
}
if grant.mutates
&& !grant.protected_fields.is_empty()
&& grant.protected_fields.iter().all(|f| {
!f.requires_trusted()
&& f.allowed_sources().is_empty()
&& f.allowed_values().is_empty()
})
{
return Err(ManifestError::Syntax(format!(
"spec.tools: '{}' declares `mutates: true` and every \
`protected_fields` entry carries only a sensitivity \
ceiling. Declaring protected fields is what lifts the \
whole-object taint gate on a mutating sink, and a ceiling \
bounds how secret an argument may be — not who authored \
it — so the model's own untrusted completion would fill \
every authority-bearing field unconstrained. At least one \
protected field must carry a trust, source, or value \
rule: `require_trusted: true`, `allowed_sources` naming \
where the value must come from, or `one_of` enumerating \
what may stand in it (a ceiling may sit beside any)",
grant.reference
)));
}
}
Ok(())
}
fn validate_agent_grants(&self) -> Result<(), ManifestError> {
for grant in &self.spec.tools {
let Some(rest) = grant.reference.strip_prefix("tool://agent/") else {
continue;
};
let reference = &grant.reference;
if rest.is_empty() {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{reference}' names no capability"
)));
}
if self.spec.capabilities.provides.iter().any(|c| c == rest) {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].ref",
detail: "this grant names a capability the agent itself provides, so it \
is a grant to call itself. The recursion terminates only if a \
model decides it should, which is not something the declaration \
can bound — grant the capability of a *different* agent, or \
drop the grant",
});
}
if !grant.mutates {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].mutates",
detail: "an agent consulted as a tool runs under its own declaration, \
and what it does to the world is its manifest's statement to \
make — `mutates: false` here is a claim this document cannot \
back",
});
}
if !grant.protected_fields.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].protected_fields",
detail: "an agent consultation dispatches through `commission`, not \
through the sink-binding gate — a protected-field rule here \
would be reviewed and never checked",
});
}
if grant.max_sensitivity.is_some() {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].max_sensitivity",
detail: "an agent consultation dispatches through `commission`, not \
through the sink gate that enforces a sensitivity ceiling — \
declare ceilings on the consulted agent instead",
});
}
if self
.spec
.topology
.as_ref()
.is_none_or(|t| t.role != Role::Orchestrator)
{
return Err(ManifestError::Syntax(format!(
"spec.tools: '{reference}' consults another agent, which is delegation \
— declare `topology.role: orchestrator`, because a specialist may not \
delegate and the default topology is a lone specialist"
)));
}
}
Ok(())
}
fn validate_prompt_tool_references(&self) -> Result<(), ManifestError> {
let Some(identity) = &self.spec.identity else {
return Ok(());
};
let granted: std::collections::BTreeSet<&str> = self
.spec
.tools
.iter()
.map(|g| g.reference.as_str())
.collect();
let mut prose = vec![
("spec.identity.role", identity.role.as_str()),
("spec.identity.constraints", identity.constraints.as_str()),
];
if let Some(formation) = self.spec.memory.as_ref().and_then(|m| m.formation.as_ref()) {
prose.push((
"spec.memory.formation.instruction",
formation.instruction.as_str(),
));
}
let offered: std::collections::BTreeSet<String> = self
.spec
.tools
.iter()
.filter_map(|g| crate::tools::ToolId::parse(&g.reference))
.map(|id| id.wire_name())
.collect();
for (field, text) in prose {
for reference in tool_references_in(text) {
if !granted.contains(reference.as_str()) {
return Err(ManifestError::Syntax(format!(
"{field} instructs the agent to use '{reference}', which \
`spec.tools` does not grant. An ungranted name is reported to the \
model as a failed call, so the model improvises and the \
instruction silently does not happen — grant the tool, or stop \
naming it"
)));
}
}
for wire in wire_names_in(text) {
if !offered.contains(&wire) {
return Err(ManifestError::Syntax(format!(
"{field} instructs the agent to call '{wire}', which is the \
spelling a model is offered and `spec.tools` grants no tool \
under. The model is told of no such tool, so it improvises and \
the instruction silently does not happen — grant the tool, or \
stop naming it"
)));
}
}
}
Ok(())
}
fn validate_oversight(&self) -> Result<(), ManifestError> {
let Some(o) = &self.spec.oversight else {
return Ok(());
};
if self.spec.execution.is_none() {
return Err(ManifestError::Unenforceable {
field: "spec.oversight",
detail: "oversight is applied by a declarative agent; a hand-written skill \
chooses its own moment to ask, so declaring it here would name a \
control nothing performs",
});
}
if o.deadline.name.trim().is_empty() {
return Err(ManifestError::Empty("spec.oversight.deadline.name"));
}
if o.deadline.kind.trim().is_empty() {
return Err(ManifestError::Empty("spec.oversight.deadline.kind"));
}
let gates_a_call = self.spec.tools.iter().any(|grant| grant.requires_approval);
if o.approval == Approval::ToolsOnly && !gates_a_call {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.approval",
detail: "'tools-only' with no tool grant requesting approval gates nothing — \
set `requires_approval: true` on the calls a person must see, or \
use 'required' to gate the answer",
});
}
if o.approval == Approval::ToolsOnly
&& let Some(silent) = self
.spec
.tools
.iter()
.find(|grant| grant.mutates && !grant.requires_approval)
{
return Err(ManifestError::Syntax(format!(
"spec.oversight.approval: 'tools-only' names per-call approval as this \
agent's human control, and '{}' is a mutating grant with no \
`requires_approval` — a mode that gates tool calls while a call that \
changes the world needs nobody is a declared control nothing enforces. \
Set `requires_approval: true` on every mutating grant, or use \
'required' to gate the answer instead",
silent.reference
)));
}
if o.approval == Approval::None && !gates_a_call && o.triage.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.approval",
detail: "'none' with no `triage` rule and no grant requesting approval is an \
oversight block that performs nothing — declare the rules that open \
a task beside the answer, set `requires_approval: true` on the calls \
a person must see, or remove `spec.oversight` entirely",
});
}
self.validate_triage(&o.triage)?;
if o.on_expiry == Expiry::Proceed && !o.allow_unattended {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.on_expiry",
detail: "'proceed' needs `allow_unattended: true` — acting with no human when \
the window closes must be a decision somebody wrote down, not a \
value picked off a list",
});
}
if o.on_expiry == Expiry::Escalate {
if o.escalate_to.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.on_expiry",
detail: "'escalate' promises a wider audience and `escalate_to` names \
nobody — declare the roles the task widens to, or use 'deny'",
});
}
if (o.approval != Approval::None || gates_a_call) && o.approvers.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.approvers",
detail: "'escalate' needs a bounded audience to widen, and an empty \
`approvers` already means anyone — name the initial reviewers, \
or use 'deny'",
});
}
if let Some(rule) = o.triage.iter().find(|r| r.audience.is_empty()) {
return Err(ManifestError::Syntax(format!(
"spec.oversight.triage: 'escalate' needs a bounded audience to widen, \
and triage rule '{}' declares none — name its audience, or use 'deny'",
rule.name
)));
}
} else if !o.escalate_to.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.escalate_to",
detail: "`escalate_to` names an escalation audience, but `on_expiry` never \
escalates — set `on_expiry: escalate` or drop the list, so the \
declaration and the policy say the same thing",
});
}
Ok(())
}
fn validate_tool_approval(&self) -> Result<(), ManifestError> {
if !self.spec.tools.iter().any(|grant| grant.requires_approval) {
return Ok(());
}
if self.spec.oversight.is_none() {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].requires_approval",
detail: "a grant asks for approval but `spec.oversight` is absent, so there \
is nobody to ask, no window to wait in and no rule for what happens \
when it closes — a grant claiming a human is in the loop when none is",
});
}
if !matches!(
self.spec.execution.as_ref().map(|e| e.kind),
Some(ExecutionKind::ToolCalling | ExecutionKind::Planned)
) {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].requires_approval",
detail: "per-call approval is applied by the `tool-calling` loop and the \
`planned` executor; a hand-written skill chooses its own moment \
to ask, and a `completion` agent calls no tools at all",
});
}
Ok(())
}
fn validate_tool_previews(&self) -> Result<(), ManifestError> {
for grant in &self.spec.tools {
let Some(preview) = grant.preview.as_deref() else {
continue;
};
let named = grant.reference.as_str();
if !grant.requires_approval {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].preview",
detail: "a preview is computed to show a reviewer what a call will do, \
and nothing would ever call it without `requires_approval: \
true` — a field in the reviewed file that the runtime never \
reaches",
});
}
if preview == named {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{named}' names itself as its own preview, so the dry run \
would be the mutation"
)));
}
let Some(target) = self.spec.tools.iter().find(|g| g.reference == preview) else {
return Err(ManifestError::Syntax(format!(
"spec.tools: '{named}' names the preview '{preview}', which this \
manifest does not grant — a call with no declared safety, no \
sensitivity ceiling and no protected fields"
)));
};
if target.mutates {
return Err(ManifestError::Unenforceable {
field: "spec.tools[].preview",
detail: "a preview that this manifest declares `mutates: true` is a dry \
run that changes the world, which is the opposite of what the \
field claims. Declare the preview grant `mutates: false`, or \
name a different tool",
});
}
}
Ok(())
}
fn validate_topology(&self) -> Result<(), ManifestError> {
let Some(t) = &self.spec.topology else {
return Ok(());
};
if t.role == Role::Specialist
&& self
.spec
.security
.max_delegation_depth
.is_some_and(|d| d > 0)
{
return Err(ManifestError::IncoherentTopology {
detail: "role 'specialist' with security.max_delegation_depth above zero \
— a specialist that may hand off is an orchestrator that was \
never reviewed as one",
});
}
if t.mode == TopologyMode::Single && t.role != Role::Specialist {
return Err(ManifestError::IncoherentTopology {
detail: "mode 'single' with a role other than 'specialist' — there is \
nobody to orchestrate or route to",
});
}
match (t.mode, t.reason) {
(TopologyMode::Collaborative, None) => {
return Err(ManifestError::IncoherentTopology {
detail: "mode 'collaborative' with no reason — collaboration opens a \
failure surface the other modes structurally do not have, so \
why it is warranted has to be in the file",
});
}
(mode, Some(_)) if mode != TopologyMode::Collaborative => {
return Err(ManifestError::IncoherentTopology {
detail: "a collaboration reason on a mode that is not collaborative \
— a justification for something this agent does not do reads \
in review as one that was required",
});
}
_ => {}
}
Ok(())
}
fn validate_models(&self) -> Result<(), ManifestError> {
if self.spec.execution.is_some() && self.spec.capabilities.provides.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.capabilities.provides",
detail: "this agent's behaviour is declared but it advertises no capability, \
and a declarative agent's driver is registered once per capability \
it provides — so nothing would be registered and no run could ever \
reach the model, tools and prompt named here. Name what this agent \
answers",
});
}
if self.spec.execution.is_some()
&& self
.spec
.models
.as_ref()
.and_then(|m| m.privileged.as_ref())
.is_none()
{
return Err(ManifestError::Unenforceable {
field: "spec.execution",
detail: "this agent's behaviour is declared, so the runtime drives it by \
calling a model — and no `spec.models.privileged` names one. The \
model is named rather than defaulted, because falling back to \
another registered driver would run the agent on a model its own \
declaration does not name, so there is nothing to call and the \
plane will refuse to assemble. Name a privileged model, or drop \
`spec.execution` and attach a coded skill instead",
});
}
let Some(models) = &self.spec.models else {
return Ok(());
};
for (field, m) in [
("spec.models.privileged", &models.privileged),
("spec.models.quarantined", &models.quarantined),
] {
if let Some(m) = m
&& (m.provider.trim().is_empty() || m.model.trim().is_empty())
{
return Err(ManifestError::Empty(field));
}
}
if let (Some(privileged), Some(quarantined)) = (&models.privileged, &models.quarantined)
&& privileged.provider == quarantined.provider
&& privileged.model == quarantined.model
{
return Err(ManifestError::Unenforceable {
field: "spec.models.quarantined",
detail: "the quarantined role names the same provider and model as the \
privileged role, so the declared dual-model isolation has only one \
model behind both sides",
});
}
if models.quarantined.is_some() {
let kind = self.spec.execution.as_ref().map(|e| e.kind);
let selectable = matches!(kind, Some(ExecutionKind::Planned))
|| self
.spec
.memory
.as_ref()
.is_some_and(|m| m.formation.is_some())
|| kind.is_none();
if !selectable {
return Err(ManifestError::Unenforceable {
field: "spec.models.quarantined",
detail: "nothing in this declaration would ever select it: `parse` steps \
(execution.kind: planned) and memory formation are the only two \
places the declarative tier points a model at untrusted-derived \
content, and this agent has neither — so every call would go to \
the privileged model while the file reads as dual-model \
isolation. Use `execution.kind: planned`, declare \
`memory.formation`, or drop the role",
});
}
}
Ok(())
}
fn validate_triage(&self, rules: &[TriageRule]) -> Result<(), ManifestError> {
if rules.is_empty() {
return Ok(());
}
let Some(schema) = self.output_schema() else {
return Err(ManifestError::Unenforceable {
field: "spec.oversight.triage",
detail: "a triage rule is a predicate over the agent's answer, and this \
agent declares no `spec.output.schema` — so there is no shape a \
reviewer could check the rule's pointers against. Declare the \
answer's schema, or drop the rules",
});
};
let mut names = std::collections::BTreeSet::new();
for rule in rules {
if rule.name.trim().is_empty() {
return Err(ManifestError::Empty("spec.oversight.triage[].name"));
}
if rule.summary.trim().is_empty() {
return Err(ManifestError::Empty("spec.oversight.triage[].summary"));
}
if rule.deadline.name.trim().is_empty() {
return Err(ManifestError::Empty(
"spec.oversight.triage[].deadline.name",
));
}
if rule.deadline.kind.trim().is_empty() {
return Err(ManifestError::Empty(
"spec.oversight.triage[].deadline.kind",
));
}
if !names.insert(rule.name.as_str()) {
return Err(ManifestError::Syntax(format!(
"spec.oversight.triage: two rules are both named '{}' — a worklist \
filtered on the kind could not tell them apart",
rule.name
)));
}
if rule.when.is_empty() {
return Err(ManifestError::Syntax(format!(
"spec.oversight.triage: rule '{}' has no conditions, so it matches \
every answer — that is a task on every run, and writing it as a rule \
hides the decision. State the condition, or use \
`approval: required` if a person really must see every answer",
rule.name
)));
}
for condition in &rule.when {
condition.validate().map_err(|detail| {
ManifestError::Syntax(format!(
"spec.oversight.triage: rule '{}': {detail}",
rule.name
))
})?;
condition.check_against(schema).map_err(|detail| {
ManifestError::Syntax(format!(
"spec.oversight.triage: rule '{}': {detail}",
rule.name
))
})?;
}
}
Ok(())
}
fn validate_memory(&self) -> Result<(), ManifestError> {
let Some(memory) = &self.spec.memory else {
return Ok(());
};
if memory.is_empty() {
return Err(ManifestError::Unenforceable {
field: "spec.memory",
detail: "this block declares neither `recall` nor `formation`, so it \
reads in review as a memory declaration and performs nothing. \
State one, or drop the block",
});
}
if self.spec.execution.is_none() {
return Err(ManifestError::Unenforceable {
field: "spec.memory",
detail: "reading and writing durable memory are behaviours the \
declarative tier supplies; a coded skill calls \
`StepCtx::recall` and `StepCtx::form_memories` at the moments \
it chooses, and this block would govern nothing",
});
}
if let Some(recall) = &memory.recall {
self.validate_memory_recall(recall)?;
}
if let Some(formation) = &memory.formation {
self.validate_memory_formation(formation)?;
}
Ok(())
}
fn validate_memory_recall(&self, recall: &MemoryRecall) -> Result<(), ManifestError> {
if self.spec.execution.as_ref().map(|e| e.kind) == Some(ExecutionKind::Planned) {
return Err(ManifestError::Unenforceable {
field: "spec.memory.recall",
detail: "a `planned` agent compiles its plan from what the planner reads \
and refuses untrusted input for that reason — and a recalled \
memory is untrusted whenever whatever wrote it was. Use \
`execution.kind: tool-calling`, or recall inside a `parse` \
step's own agent",
});
}
if let MemorySubject::Literal(literal) = &recall.subject
&& literal.trim().is_empty()
{
return Err(ManifestError::Empty("spec.memory.recall.subject"));
}
if recall.purpose.as_ref().is_some_and(|p| p.trim().is_empty()) {
return Err(ManifestError::Empty("spec.memory.recall.purpose"));
}
if !(1..=50).contains(&recall.limit) {
return Err(ManifestError::Syntax(
"spec.memory.recall.limit must be between 1 and 50 — 0 is a recall that \
reads nothing while reading as a ceiling, and a prompt is not the place \
for an unbounded corpus"
.to_owned(),
));
}
Ok(())
}
fn validate_memory_formation(&self, formation: &MemoryFormation) -> Result<(), ManifestError> {
if self
.spec
.models
.as_ref()
.and_then(|models| models.privileged.as_ref())
.is_none()
{
return Err(ManifestError::Unenforceable {
field: "spec.memory.formation",
detail: "formation extracts facts with a model, and this agent declares \
no privileged model to extract them with",
});
}
for (field, value) in [
("spec.memory.formation.purpose", formation.purpose.as_str()),
(
"spec.memory.formation.instruction",
formation.instruction.as_str(),
),
] {
if value.trim().is_empty() {
return Err(ManifestError::Empty(field));
}
}
match &formation.subject {
MemorySubject::Literal(literal) if literal.trim().is_empty() => {
return Err(ManifestError::Empty("spec.memory.formation.subject"));
}
_ => {}
}
if !(1..=10).contains(&formation.max_items) {
return Err(ManifestError::Syntax(
"spec.memory.formation.max_items must be between 1 and 10".to_owned(),
));
}
if formation.retention_seconds == Some(0) || formation.access_retention_seconds == Some(0) {
return Err(ManifestError::Syntax(
"memory formation retention windows must be greater than zero".to_owned(),
));
}
for (field, seconds) in [
("retention_seconds", formation.retention_seconds),
(
"access_retention_seconds",
formation.access_retention_seconds,
),
] {
if seconds.is_some_and(|s| s > crate::core::MAX_WINDOW_SECONDS) {
return Err(ManifestError::Syntax(format!(
"spec.memory.formation.{field} is longer than the {} seconds \
between the first and last instant this runtime can name",
crate::core::MAX_WINDOW_SECONDS
)));
}
}
Ok(())
}
fn refuse_open_objects(schema: &serde_json::Value, at: &str) -> Result<(), ManifestError> {
let is_object = match schema.get("type") {
Some(serde_json::Value::String(name)) => name == "object",
Some(serde_json::Value::Array(names)) => names.iter().any(|n| n == "object"),
_ => false,
};
if is_object && schema.get("additionalProperties") != Some(&serde_json::Value::Bool(false))
{
return Err(ManifestError::Syntax(format!(
"{at} declares an object without `additionalProperties: false`, so the \
model may answer with fields nobody declared — and constrained decoding \
cannot bind a schema that permits them, which leaves the declaration \
advisory at exactly the moment it is supposed to hold. Close it"
)));
}
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
for (name, nested) in properties {
Self::refuse_open_objects(nested, &format!("{at}.{name}"))?;
}
}
if let Some(items) = schema.get("items") {
Self::refuse_open_objects(items, &format!("{at}[]"))?;
}
Ok(())
}
fn validate_declared_schemas(&self) -> Result<(), ManifestError> {
if self.spec.execution.is_none() {
return Ok(());
}
if let Some(output) = &self.spec.output {
Self::refuse_open_objects(&output.schema, "spec.output.schema")?;
}
for grant in &self.spec.tools {
if let Some(arguments) = &grant.arguments {
Self::refuse_open_objects(
arguments,
&format!("spec.tools['{}'].arguments", grant.reference),
)?;
}
}
Ok(())
}
fn validate_input(&self) -> Result<(), ManifestError> {
let Some(input) = &self.spec.input else {
return Ok(());
};
Self::schema_is_a_constraint(&input.schema, "spec.input.schema")
}
fn schema_is_a_constraint(
schema: &serde_json::Value,
field: &'static str,
) -> Result<(), ManifestError> {
match schema {
serde_json::Value::Object(m) if !m.is_empty() => Ok(()),
serde_json::Value::Object(_) => Err(ManifestError::Empty(field)),
other => Err(ManifestError::NotASchema {
found: crate::core::canon::json_kind(other),
}),
}
}
fn validate_output(&self) -> Result<(), ManifestError> {
let Some(output) = &self.spec.output else {
return Ok(());
};
Self::schema_is_a_constraint(&output.schema, "spec.output.schema")
}
#[must_use]
pub fn json_schema() -> serde_json::Value {
const SCHEMA_ID: &str = "https://hupe1980.github.io/agentplane/agent.schema.json";
let generator = schemars::generate::SchemaSettings::draft07().into_generator();
let mut value = serde_json::to_value(generator.into_root_schema_for::<Self>())
.expect("a schemars-generated schema must serialize to JSON");
trim_descriptions(&mut value);
let root = value.as_object_mut().expect("a root schema is an object");
root.insert(
"$id".to_owned(),
serde_json::Value::String(SCHEMA_ID.to_owned()),
);
root.insert(
"title".to_owned(),
serde_json::Value::String("agentplane Agent manifest".to_owned()),
);
root.insert(
"description".to_owned(),
serde_json::Value::String(
"The shape of an agentplane Agent manifest. The crate's parser stays \
authoritative: this schema refuses unknown fields, missing fields, and \
wrong types exactly as the parser does, but the parser's semantic \
refusals (an unstated budget, a declared control nothing performs) run \
only there — `agentplane validate` is the full check."
.to_owned(),
),
);
value
}
pub fn digest(&self) -> Result<Digest, ManifestError> {
let mut normalized = self.clone();
normalized.normalize();
let value =
serde_json::to_value(normalized).map_err(|e| ManifestError::Syntax(e.to_string()))?;
Ok(Digest::of(&canon::value_bytes(&value)))
}
#[must_use]
pub fn permits_model(&self, provider: &str, model: &str) -> bool {
let Some(models) = &self.spec.models else {
return true;
};
let declared = [models.privileged.as_ref(), models.quarantined.as_ref()];
declared
.into_iter()
.flatten()
.any(|m| m.provider == provider && m.model == model)
}
#[must_use]
pub fn tool_grant(&self, reference: &str) -> Option<&ToolGrant> {
self.spec.tools.iter().find(|g| g.reference == reference)
}
#[must_use]
pub fn prompt_grant(&self, server: &str, name: &str) -> Option<&ContextPrompt> {
self.spec
.context
.prompts
.iter()
.find(|grant| grant.server == server && grant.name == name)
}
#[must_use]
pub fn resource_grant(&self, server: &str, uri: &str) -> Option<&ContextResource> {
self.spec
.context
.resources
.iter()
.find(|grant| grant.server == server && grant.uri == uri)
}
#[must_use]
pub fn task_input_grant(&self, server: &str) -> Option<&ContextTaskInput> {
self.spec
.context
.task_input
.iter()
.find(|grant| grant.server == server)
}
#[must_use]
pub fn output_schema(&self) -> Option<&serde_json::Value> {
self.spec.output.as_ref().map(|o| &o.schema)
}
#[must_use]
pub fn input_schema(&self) -> Option<&serde_json::Value> {
self.spec.input.as_ref().map(|i| &i.schema)
}
#[must_use]
pub fn budget(&self) -> Budget {
let b = self.spec.budgets.clone().unwrap_or_default();
Budget {
max_steps: b.max_steps,
max_effects: b.max_effects,
max_tokens: b.max_tokens,
max_minor_units: b.max_minor_units,
max_replans: b.max_replans,
max_wallclock_secs: b.max_wallclock_secs,
max_denials: b.max_denials,
max_parallel_steps: b.max_parallel_steps,
max_egress_bytes: b.max_egress_bytes,
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestBuilder {
metadata: Metadata,
spec: Spec,
}
impl ManifestBuilder {
#[must_use]
pub fn annotate(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.annotations.insert(key.into(), value.into());
self
}
#[must_use]
pub fn spec(mut self, spec: Spec) -> Self {
self.spec = spec;
self
}
#[must_use]
pub fn configure(mut self, configure: impl FnOnce(&mut Spec)) -> Self {
configure(&mut self.spec);
self
}
pub fn build(self) -> Result<Manifest, ManifestError> {
Manifest {
api_version: API_VERSION.to_owned(),
kind: KIND.to_owned(),
metadata: self.metadata,
spec: self.spec,
}
.build()
}
}