af-agent 0.4.0

Stable Agent model, tool, inbox, and trusted-plugin contracts.
Documentation
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Who owns a prompt section; higher authority may not be shadowed by lower.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptAuthority {
    /// Factory runtime.
    Platform,
    /// The product.
    Product,
    /// The pinned Profile revision.
    Profile,
    /// An official Skill.
    TrustedCapability,
    /// A trusted plugin.
    Plugin,
}

/// One uniquely owned, ordered piece of the system prompt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptSection {
    /// Stable identifier of this record.
    pub id: String,
    /// Render position; lower renders first.
    pub order: i32,
    /// Owner authority.
    pub authority: PromptAuthority,
    /// Where this record originated.
    pub source: String,
    /// Semantic version string.
    pub version: String,
    /// Content blocks carried by this record.
    pub content: String,
}

/// Deterministic set of prompt sections keyed by id.
#[derive(Debug, Clone, Default)]
pub struct PromptRegistry {
    sections: BTreeMap<(i32, String), PromptSection>,
}

impl PromptRegistry {
    /// Insert a section; a duplicate id is an error.
    pub fn insert(&mut self, section: PromptSection) -> Result<(), String> {
        if section.id.trim().is_empty() || section.content.trim().is_empty() {
            return Err("prompt section id and content are required".into());
        }
        if self
            .sections
            .values()
            .any(|current| current.id == section.id)
        {
            return Err(format!("duplicate prompt section '{}'", section.id));
        }
        self.sections
            .insert((section.order, section.id.clone()), section);
        Ok(())
    }

    /// Render sections in `order` as one system prompt.
    pub fn render(&self) -> String {
        self.sections
            .values()
            .map(|section| section.content.trim())
            .collect::<Vec<_>>()
            .join("\n\n")
    }

    /// Sections in render order.
    pub fn sections(&self) -> Vec<PromptSection> {
        self.sections.values().cloned().collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_stably_and_rejects_duplicate_ids() {
        let mut prompts = PromptRegistry::default();
        prompts
            .insert(PromptSection {
                id: "profile".into(),
                order: 20,
                authority: PromptAuthority::Profile,
                source: "profile".into(),
                version: "r1".into(),
                content: "profile".into(),
            })
            .unwrap();
        prompts
            .insert(PromptSection {
                id: "base".into(),
                order: 10,
                authority: PromptAuthority::Product,
                source: "product".into(),
                version: "1".into(),
                content: "base".into(),
            })
            .unwrap();
        assert_eq!(prompts.render(), "base\n\nprofile");
        assert!(prompts
            .insert(PromptSection {
                id: "base".into(),
                order: 30,
                authority: PromptAuthority::Plugin,
                source: "plugin".into(),
                version: "1".into(),
                content: "duplicate".into(),
            })
            .is_err());
    }
}