use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 2;
pub const SCHEMA_PATH: &str = "instruction-package.schema.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SectionId {
Identity,
Core,
PmAllowlist,
DelegationMechanics,
AgentRouting,
SubagentReEngagement,
Phases,
QaGate,
GitFileTracking,
TicketsPrsReleases,
MessagesReportsSessions,
AutonomousExecution,
Memory,
Search,
Workflow,
AgentDelegation,
Enforcement,
NonOverridableRules,
FrameworkGuaranteedConventions,
}
impl SectionId {
pub const CANONICAL: [SectionId; 19] = [
SectionId::Identity,
SectionId::Core,
SectionId::PmAllowlist,
SectionId::DelegationMechanics,
SectionId::AgentRouting,
SectionId::SubagentReEngagement,
SectionId::Phases,
SectionId::QaGate,
SectionId::GitFileTracking,
SectionId::TicketsPrsReleases,
SectionId::MessagesReportsSessions,
SectionId::AutonomousExecution,
SectionId::Memory,
SectionId::Search,
SectionId::Workflow,
SectionId::AgentDelegation,
SectionId::Enforcement,
SectionId::NonOverridableRules,
SectionId::FrameworkGuaranteedConventions,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomizationTier {
Fixed,
Project,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverrideTier {
Project,
User,
}
impl CustomizationTier {
pub const fn permits(self, from: OverrideTier) -> bool {
match (self, from) {
(CustomizationTier::Fixed, _) => false,
(CustomizationTier::Project, OverrideTier::Project) => true,
(CustomizationTier::Project, OverrideTier::User) => false,
(CustomizationTier::User, _) => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Generator {
AgentRoster,
StackProfile,
ProjectAddendum,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Join {
#[default]
Rule,
Blank,
None,
Literal(String),
}
impl Join {
pub fn as_str(&self) -> &str {
match self {
Join::Rule => "\n\n---\n\n",
Join::Blank => "\n\n",
Join::None => "",
Join::Literal(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum BlockBody {
Text {
text: String,
},
File {
path: String,
},
Generated {
generator: Generator,
},
}
impl BlockBody {
pub fn authored<'a>(
&'a self,
sources: &'a BTreeMap<String, String>,
) -> Option<Result<&'a str, &'a str>> {
match self {
BlockBody::Text { text } => Some(Ok(text.as_str())),
BlockBody::File { path } => {
Some(sources.get(path).map(String::as_str).ok_or(path.as_str()))
}
BlockBody::Generated { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionBlock {
pub section: SectionId,
pub body: BlockBody,
#[serde(default, skip_serializing_if = "is_default_join")]
pub join_before: Join,
#[serde(default, skip_serializing_if = "is_false")]
pub optional: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub pinned: bool,
}
fn is_default_join(join: &Join) -> bool {
*join == Join::Rule
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionSection {
pub id: SectionId,
pub title: String,
pub customization_tier: CustomizationTier,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionPackage {
pub schema_version: u32,
pub package_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub trailing_newline: bool,
pub sections: Vec<InstructionSection>,
pub blocks: Vec<InstructionBlock>,
#[serde(skip)]
pub sources: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
#[error("unsupported schema_version {found}; this build implements {SCHEMA_VERSION}")]
UnsupportedSchemaVersion {
found: u32,
},
#[error("package_id must not be empty")]
EmptyPackageId,
#[error(
"sections must list every canonical section exactly once, in canonical order; \
got {found:?}"
)]
SectionsNotCanonical {
found: Vec<SectionId>,
},
#[error(
"section {section:?} declares tier {tier:?}; `core` is the only `fixed` \
section and every other section must be `project`"
)]
TierNotCoreOnly {
section: SectionId,
tier: CustomizationTier,
},
#[error("blocks must not be empty")]
NoBlocks,
#[error("block {index} ({section:?}) has an empty authored body; remove it instead")]
EmptyAuthoredBody {
index: usize,
section: SectionId,
},
#[error("block {index} ({section:?}) is an authored block and may not be `optional`")]
OptionalAuthoredBlock {
index: usize,
section: SectionId,
},
#[error(
"block {index} ({section:?}) references section source `{path}`, which this build \
does not bundle"
)]
UnknownFileSource {
index: usize,
section: SectionId,
path: String,
},
#[error(
"no block consumes the agent roster; the computed roster must reach the \
composed prompt (issue #4196)"
)]
RosterNotConsumed,
#[error("block {index} consumes the agent roster and may not be `optional` (issue #4196)")]
OptionalRoster {
index: usize,
},
#[error("section {section:?} is declared but contributes no block")]
SectionWithoutBlocks {
section: SectionId,
},
#[error(
"section {section:?} contributes only `optional` blocks, so it can emit nothing \
while still validating; give it at least one non-optional block"
)]
SectionOnlyOptionalBlocks {
section: SectionId,
},
#[error("block {index} ({section:?}) is a generated block and may not be `pinned`")]
PinnedGeneratedBlock {
index: usize,
section: SectionId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CompositionError {
#[error("invalid instruction package: {0}")]
Invalid(#[from] ValidationError),
#[error(
"block {index} requires generator {generator:?} but it supplied no content; \
mark the block `optional` if that is intended"
)]
MissingGeneratedInput {
index: usize,
generator: Generator,
},
}
#[derive(Debug, Clone, Default)]
pub struct CompositionInputs {
pub agent_roster: String,
pub stack_profile: Option<String>,
pub project_addendum: Option<String>,
}
impl InstructionPackage {
pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
#[derive(Deserialize)]
struct VersionProbe {
schema_version: u32,
}
let probe: VersionProbe = serde_json::from_str(raw)?;
if probe.schema_version != SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"unsupported schema_version {}; this build implements {SCHEMA_VERSION}",
probe.schema_version
)));
}
serde_json::from_str(raw)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn section(&self, id: SectionId) -> Option<&InstructionSection> {
self.sections.iter().find(|s| s.id == id)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::UnsupportedSchemaVersion {
found: self.schema_version,
});
}
if self.package_id.trim().is_empty() {
return Err(ValidationError::EmptyPackageId);
}
let declared: Vec<SectionId> = self.sections.iter().map(|s| s.id).collect();
if declared != SectionId::CANONICAL {
return Err(ValidationError::SectionsNotCanonical { found: declared });
}
for section in &self.sections {
let should_be_fixed =
crate::core::instruction_safety_core::is_fixed_core_section(section.id);
let is_fixed = section.customization_tier == CustomizationTier::Fixed;
if should_be_fixed != is_fixed {
return Err(ValidationError::TierNotCoreOnly {
section: section.id,
tier: section.customization_tier,
});
}
}
if self.blocks.is_empty() {
return Err(ValidationError::NoBlocks);
}
for (index, block) in self.blocks.iter().enumerate() {
let Some(authored) = block.body.authored(&self.sources) else {
if block.pinned {
return Err(ValidationError::PinnedGeneratedBlock {
index,
section: block.section,
});
}
continue;
};
let body = match authored {
Ok(body) => body,
Err(path) => {
return Err(ValidationError::UnknownFileSource {
index,
section: block.section,
path: path.to_string(),
});
}
};
if body.trim().is_empty() {
return Err(ValidationError::EmptyAuthoredBody {
index,
section: block.section,
});
}
if block.optional {
return Err(ValidationError::OptionalAuthoredBlock {
index,
section: block.section,
});
}
}
self.validate_roster()?;
let covered: BTreeSet<SectionId> = self.blocks.iter().map(|b| b.section).collect();
let guaranteed: BTreeSet<SectionId> = self
.blocks
.iter()
.filter(|b| !b.optional)
.map(|b| b.section)
.collect();
for id in SectionId::CANONICAL {
if !covered.contains(&id) {
return Err(ValidationError::SectionWithoutBlocks { section: id });
}
if !guaranteed.contains(&id) {
return Err(ValidationError::SectionOnlyOptionalBlocks { section: id });
}
}
Ok(())
}
fn validate_roster(&self) -> Result<(), ValidationError> {
let mut seen = false;
for (index, block) in self.blocks.iter().enumerate() {
if matches!(
block.body,
BlockBody::Generated {
generator: Generator::AgentRoster
}
) {
if block.optional {
return Err(ValidationError::OptionalRoster { index });
}
seen = true;
}
}
if seen {
Ok(())
} else {
Err(ValidationError::RosterNotConsumed)
}
}
pub fn compose(&self, inputs: &CompositionInputs) -> Result<String, CompositionError> {
self.validate()?;
let mut out = String::new();
let mut emitted = false;
for (index, block) in self.blocks.iter().enumerate() {
let resolved: Option<&str> = match &block.body {
BlockBody::Text { .. } | BlockBody::File { .. } => {
match block.body.authored(&self.sources) {
Some(Ok(body)) => Some(body),
_ => unreachable!("validate rejects unknown file sources"),
}
}
BlockBody::Generated { generator } => match generator {
Generator::AgentRoster => Some(inputs.agent_roster.as_str()),
Generator::StackProfile => inputs.stack_profile.as_deref(),
Generator::ProjectAddendum => inputs.project_addendum.as_deref(),
},
};
let body = resolved.map(str::trim).unwrap_or("");
if body.is_empty() {
if block.optional {
continue;
}
let BlockBody::Generated { generator } = &block.body else {
unreachable!("validate rejects blank authored blocks");
};
return Err(CompositionError::MissingGeneratedInput {
index,
generator: *generator,
});
}
let folded = crate::core::instruction_fold::fold_block(body);
if folded.is_empty() {
continue;
}
if emitted {
out.push_str(block.join_before.as_str());
}
out.push_str(&folded);
emitted = true;
}
if self.trailing_newline && !out.is_empty() {
out.push('\n');
}
Ok(out)
}
pub fn authored_run(&self, sections: &[SectionId]) -> String {
let mut out = String::new();
let mut emitted = false;
for block in &self.blocks {
if !sections.contains(&block.section) {
continue;
}
let Some(Ok(body)) = block.body.authored(&self.sources) else {
continue;
};
let body = body.trim();
if body.is_empty() {
continue;
}
if emitted {
out.push_str(block.join_before.as_str());
}
out.push_str(body);
emitted = true;
}
out
}
}
#[cfg(test)]
#[path = "instruction_package_tests.rs"]
mod tests;