use serde::{Deserialize, Serialize};
use crate::core::{Budget, Digest, Sensitivity, canon};
mod error;
pub use error::ManifestError;
mod registry;
pub use registry::{MemoryRegistry, Registry, RegistryError};
pub const API_VERSION: &str = "agentplane.hupe1980.github.io/v1alpha1";
pub const KIND: &str = "Agent";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[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)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[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 output: Option<Output>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_formation: Option<MemoryFormation>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextGrants {
#[serde(default)]
pub prompts: Vec<ContextPrompt>,
#[serde(default)]
pub resources: Vec<ContextResource>,
}
impl ContextGrants {
#[must_use]
pub fn is_empty(&self) -> bool {
self.prompts.is_empty() && self.resources.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[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)]
#[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)]
#[serde(deny_unknown_fields)]
pub struct MemoryFormation {
pub subject: String,
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)]
#[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, Eq, Serialize, Deserialize)]
#[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)]
pub allow_unattended: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[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)]
#[serde(rename_all = "kebab-case")]
pub enum Approval {
Required,
ToolsOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Expiry {
#[default]
Deny,
Escalate,
Proceed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[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)]
#[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)]
#[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)]
#[serde(rename_all = "kebab-case")]
pub enum TopologyMode {
#[default]
Single,
Collaborative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Role {
#[default]
Specialist,
Orchestrator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Justification {
ParallelDisjoint,
DistinctAuthority,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[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)]
#[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>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Output {
pub schema: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[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)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
#[serde(default)]
pub provides: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[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>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[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 arguments: Option<serde_json::Value>,
}
const fn yes() -> bool {
true
}
impl Manifest {
#[must_use]
pub fn builder(name: impl Into<String>, version: impl Into<String>) -> ManifestBuilder {
ManifestBuilder {
metadata: Metadata {
name: name.into(),
version: version.into(),
},
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)
}
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"));
}
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_oversight()?;
self.validate_tool_approval()?;
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_output()?;
self.validate_memory_formation()?;
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);
}
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
)));
}
}
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 !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_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"));
}
if o.approval == Approval::ToolsOnly
&& !self.spec.tools.iter().any(|grant| grant.requires_approval)
{
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.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",
});
}
Ok(())
}
fn validate_tool_approval(&self) -> Result<(), ManifestError> {
let asking: Vec<&str> = self
.spec
.tools
.iter()
.filter(|grant| grant.requires_approval)
.map(|grant| grant.reference.as_str())
.collect();
if asking.is_empty() {
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_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> {
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_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_memory_formation(&self) -> Result<(), ManifestError> {
let Some(formation) = &self.spec.memory_formation else {
return Ok(());
};
if self.spec.execution.is_none() {
return Err(ManifestError::Unenforceable {
field: "spec.memory_formation",
detail: "automatic formation is implemented by declarative execution; a coded skill must call StepCtx::form_memories explicitly",
});
}
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 needs a declared privileged model",
});
}
for (field, value) in [
("spec.memory_formation.subject", formation.subject.as_str()),
("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));
}
}
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(),
));
}
Ok(())
}
fn validate_output(&self) -> Result<(), ManifestError> {
let Some(output) = &self.spec.output else {
return Ok(());
};
match &output.schema {
serde_json::Value::Object(m) if !m.is_empty() => {}
serde_json::Value::Object(_) => {
return Err(ManifestError::Empty("spec.output.schema"));
}
other => {
return Err(ManifestError::NotASchema {
found: match other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => unreachable!(),
},
});
}
}
Ok(())
}
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 output_schema(&self) -> Option<&serde_json::Value> {
self.spec.output.as_ref().map(|o| &o.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,
..Budget::default()
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestBuilder {
metadata: Metadata,
spec: Spec,
}
impl ManifestBuilder {
#[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()
}
}