diff --git a/crates/buzz-persona/src/lib.rs b/crates/buzz-persona/src/lib.rs index f344242cac..56ede85c37 100644 --- a/crates/buzz-persona/src/lib.rs +++ b/crates/buzz-persona/src/lib.rs @@ -1,6 +1,28 @@ +//! Parser and loader for Buzz persona pack files (`.persona.md`). +//! +//! A persona pack bundles one or more agent personas — each a YAML frontmatter +//! header plus a markdown system prompt — together with a `.plugin/plugin.json` +//! manifest, optional shared MCP config, and pack-level behavioral defaults. +//! +//! This crate provides parsing, loading, merge/precedence resolution, ACP-ready +//! projection, and validation of persona packs. + +#![warn(missing_docs)] + +/// Pack manifest types and `.plugin/plugin.json` parser. pub mod manifest; + +/// Precedence resolution for persona behavioral config. pub mod merge; + +/// Pack directory loader. pub mod pack; + +/// Core persona types and `.persona.md` parser. pub mod persona; + +/// Pack resolution producing fully resolved, ACP-ready output. pub mod resolve; + +/// Pack validation (`buzz pack validate`). pub mod validate; diff --git a/crates/buzz-persona/src/manifest.rs b/crates/buzz-persona/src/manifest.rs index c2de15e6eb..3f1528fa66 100644 --- a/crates/buzz-persona/src/manifest.rs +++ b/crates/buzz-persona/src/manifest.rs @@ -19,14 +19,18 @@ use serde::{Deserialize, Serialize}; use crate::persona::RespondTo; +/// Errors returned while reading or parsing a pack manifest. #[derive(Debug, thiserror::Error)] pub enum ManifestError { + /// Failed to read the manifest file from disk. #[error("failed to read file: {0}")] Io(#[from] std::io::Error), + /// Failed to parse the manifest JSON. #[error("failed to parse JSON: {0}")] Json(#[from] serde_json::Error), + /// A required manifest field was missing or empty. #[error("missing required field: {0}")] MissingField(String), } @@ -47,24 +51,31 @@ pub struct Engines { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct BehavioralDefaults { + /// Default model string in `"provider:model-id"` format. #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Default sampling temperature. #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + /// Default maximum context window in tokens. #[serde(skip_serializing_if = "Option::is_none")] pub max_context_tokens: Option, + /// Default channels to monitor. #[serde(skip_serializing_if = "Option::is_none")] pub subscribe: Option>, + /// Default message matching triggers (legacy alias: `respond_to`). #[serde(skip_serializing_if = "Option::is_none", alias = "respond_to")] pub triggers: Option, + /// Default reply-in-thread behavior. #[serde(skip_serializing_if = "Option::is_none")] pub thread_replies: Option, + /// Default broadcast-replies behavior. #[serde(skip_serializing_if = "Option::is_none")] pub broadcast_replies: Option, } @@ -77,25 +88,34 @@ pub struct BehavioralDefaults { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct PackManifest { + /// Unique pack identifier (slug). pub id: String, + /// Human-readable pack name. pub name: String, + /// Semver version string. pub version: String, + /// Optional short description of the pack. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Optional pack author. #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, + /// Optional SPDX license identifier. #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, + /// Optional homepage URL. #[serde(skip_serializing_if = "Option::is_none")] pub homepage: Option, + /// Discovery keywords. #[serde(default)] pub keywords: Vec, + /// Runtime engine constraints. #[serde(skip_serializing_if = "Option::is_none")] pub engines: Option, diff --git a/crates/buzz-persona/src/merge.rs b/crates/buzz-persona/src/merge.rs index 380005ad4b..caf9cc7b4a 100644 --- a/crates/buzz-persona/src/merge.rs +++ b/crates/buzz-persona/src/merge.rs @@ -1,37 +1,52 @@ -/// Precedence resolution for persona behavioral config. -/// -/// Handles levels 3–5 of the 5-level precedence model: -/// 3. Per-persona frontmatter (wins) -/// 4. Pack-level defaults (from plugin.json `defaults`) -/// 5. Built-in defaults (hardcoded fallbacks) -/// -/// Levels 1–2 (operator env vars, desktop UI) are resolved at runtime. - +//! Precedence resolution for persona behavioral config. +//! +//! Handles levels 3–5 of the 5-level precedence model: +//! 3. Per-persona frontmatter (wins) +//! 4. Pack-level defaults (from plugin.json `defaults`) +//! 5. Built-in defaults (hardcoded fallbacks) +//! +//! Levels 1–2 (operator env vars, desktop UI) are resolved at runtime. + +/// Resolved message-matching triggers. #[derive(Debug, Clone, PartialEq)] pub struct TriggersData { + /// Whether the persona responds when mentioned. pub mentions: bool, + /// Keywords that trigger a response. pub keywords: Vec, + /// Whether the persona responds to every message in subscribed channels. pub all_messages: bool, } +/// Resolved lifecycle hook paths (pack-relative). #[derive(Debug, Clone, PartialEq)] pub struct HooksData { + /// Hook invoked when the persona starts. pub on_start: Option, + /// Hook invoked when the persona stops. pub on_stop: Option, + /// Hook invoked on each incoming message. pub on_message: Option, } +/// The fully resolved behavioral config for a single persona. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedConfig { + /// Model string in `"provider:model-id"` format. pub model: Option, + /// Sampling temperature. pub temperature: Option, + /// Maximum context window in tokens. pub max_context_tokens: Option, /// `None` = absent (no persona or pack value) → caller uses its own default /// `Some([])` = intentional "subscribe to nothing" /// `Some([..])` = explicit channel list pub subscribe: Option>, + /// Message-matching triggers, if any. pub triggers: Option, + /// Whether replies go in-thread. pub thread_replies: bool, + /// Whether replies are broadcast to the channel. pub broadcast_replies: bool, } diff --git a/crates/buzz-persona/src/pack.rs b/crates/buzz-persona/src/pack.rs index 4d5be7227b..237a95fb8b 100644 --- a/crates/buzz-persona/src/pack.rs +++ b/crates/buzz-persona/src/pack.rs @@ -22,35 +22,56 @@ use crate::manifest::{self, ManifestError}; use crate::merge::{resolve_persona_config, HooksData, TriggersData}; use crate::persona::{self, PersonaConfig}; +/// Errors returned while loading a persona pack. #[derive(Debug, thiserror::Error)] pub enum PackError { + /// The `.plugin/plugin.json` manifest was not found. #[error("manifest not found at {0}")] ManifestNotFound(PathBuf), + /// Failed to read a file from disk. #[error("failed to read {path}: {source}")] Io { + /// Path of the file that could not be read. path: PathBuf, + /// The underlying I/O error. #[source] source: std::io::Error, }, + /// Failed to parse the pack manifest. #[error("failed to parse manifest: {0}")] ManifestParse(String), + /// A referenced persona file was not found. #[error("persona file not found: {0}")] PersonaNotFound(PathBuf), + /// A file failed to parse. #[error("invalid file {path}: {reason}")] - FileParse { path: PathBuf, reason: String }, + FileParse { + /// Path of the file that failed to parse. + path: PathBuf, + /// Human-readable parse failure reason. + reason: String, + }, + /// A path contained a `..` traversal component or absolute prefix. #[error("path traversal rejected: {0}")] PathTraversal(String), + /// A canonicalized path escaped the pack root. #[error("path escapes pack root: {0}")] PathEscape(PathBuf), + /// Failed to parse the shared `.mcp.json` config. #[error("failed to parse .mcp.json at {path}: {reason}")] - McpConfigParse { path: PathBuf, reason: String }, + McpConfigParse { + /// Path to the `.mcp.json` file. + path: PathBuf, + /// Human-readable parse failure reason. + reason: String, + }, } impl From for PackError { @@ -62,7 +83,9 @@ impl From for PackError { /// A fully loaded persona pack. #[derive(Debug)] pub struct LoadedPack { + /// The parsed pack manifest data. pub manifest: PackManifestData, + /// Fully loaded personas with resolved config. pub personas: Vec, /// Content of instructions.md, if present. pub pack_instructions: Option, @@ -75,23 +98,37 @@ pub struct LoadedPack { /// A persona with its resolved effective config. #[derive(Debug)] pub struct LoadedPersona { + /// Path to the source `.persona.md` file. pub source_path: PathBuf, + /// Machine name (slug). pub name: String, + /// Human-readable display name. pub display_name: String, + /// One-line description. pub description: String, + /// Pack-relative path to an avatar image, if any. pub avatar: Option, + /// Model string in `"provider:model-id"` format, if set. pub model: Option, /// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude'). pub runtime: Option, + /// Sampling temperature, if set. pub temperature: Option, + /// Maximum context window in tokens, if set. pub max_context_tokens: Option, + /// Channels to monitor (empty if none). pub subscribe: Vec, + /// Message-matching triggers, if any. pub triggers: Option, + /// Whether replies go in-thread. pub thread_replies: bool, + /// Whether replies are broadcast to the channel. pub broadcast_replies: bool, + /// Pack-relative skill directories claimed by this persona. pub skills: Vec, /// Raw MCP server configs. pub mcp_servers: Vec, + /// Lifecycle hooks, if any. pub hooks: Option, /// The markdown body (system prompt). pub prompt: String, @@ -100,13 +137,19 @@ pub struct LoadedPersona { /// Minimal manifest data needed by the pack loader. #[derive(Debug)] pub struct PackManifestData { + /// Unique pack identifier (slug). pub id: String, + /// Human-readable pack name. pub name: String, + /// Semver version string. pub version: String, + /// Optional short description of the pack. pub description: Option, /// Relative paths to .persona.md files. pub personas: Vec, + /// Path to the pack-level instructions file, if declared. pub pack_instructions: Option, + /// Path to the shared `.mcp.json`, if declared. pub mcp_config: Option, // hooks_config is intentionally omitted: hooks are a runtime concern loaded // separately by buzz-acp, not a pack-parsing concern. diff --git a/crates/buzz-persona/src/persona.rs b/crates/buzz-persona/src/persona.rs index b7a49d46b4..040e537c4c 100644 --- a/crates/buzz-persona/src/persona.rs +++ b/crates/buzz-persona/src/persona.rs @@ -23,26 +23,34 @@ pub const MAX_FRONTMATTER_BYTES: usize = 1_048_576; /// Maximum persona prompt (markdown body) size in bytes (256 KiB). pub const MAX_BODY_BYTES: usize = 262_144; +/// Errors returned while parsing a `.persona.md` file. #[derive(Debug, thiserror::Error)] pub enum PersonaError { + /// Failed to read the persona file from disk. #[error("failed to read file: {0}")] Io(#[from] std::io::Error), + /// The file is missing the `---` frontmatter delimiters. #[error("missing `---` frontmatter delimiters")] NoFrontmatter, + /// The frontmatter exceeds the maximum allowed size. #[error("frontmatter exceeds {MAX_FRONTMATTER_BYTES} bytes")] FrontmatterTooLarge, + /// The prompt body exceeds the maximum allowed size. #[error("body exceeds {MAX_BODY_BYTES} bytes")] BodyTooLarge, + /// The file as a whole exceeds the maximum allowed size. #[error("file too large: {0}")] TooLarge(String), + /// Failed to parse the YAML frontmatter. #[error("failed to parse YAML frontmatter: {0}")] Yaml(#[from] serde_yaml::Error), + /// A required frontmatter field was missing or empty. #[error("missing required field: {0}")] MissingField(String), } @@ -68,12 +76,16 @@ pub struct RespondTo { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct McpServerConfig { + /// Server identifier. pub name: String, + /// Executable command to launch the server. pub command: String, + /// Command-line arguments. #[serde(default)] pub args: Vec, + /// Environment variables for the subprocess. #[serde(default)] pub env: HashMap, } @@ -82,12 +94,15 @@ pub struct McpServerConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub struct Hooks { + /// Hook invoked when the persona starts. #[serde(skip_serializing_if = "Option::is_none")] pub on_start: Option, + /// Hook invoked when the persona stops. #[serde(skip_serializing_if = "Option::is_none")] pub on_stop: Option, + /// Hook invoked on each incoming message. #[serde(skip_serializing_if = "Option::is_none")] pub on_message: Option, } @@ -112,9 +127,11 @@ pub struct PersonaConfig { /// One-line description. Required. pub description: String, + /// Optional persona version string. #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, + /// Optional persona author. #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, @@ -147,9 +164,11 @@ pub struct PersonaConfig { #[serde(skip_serializing_if = "Option::is_none")] pub runtime: Option, + /// Sampling temperature. #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + /// Maximum context window in tokens. #[serde(skip_serializing_if = "Option::is_none")] pub max_context_tokens: Option, @@ -161,6 +180,7 @@ pub struct PersonaConfig { #[serde(skip_serializing_if = "Option::is_none")] pub broadcast_replies: Option, + /// Lifecycle hooks (pack-relative paths). #[serde(skip_serializing_if = "Option::is_none")] pub hooks: Option, diff --git a/crates/buzz-persona/src/resolve.rs b/crates/buzz-persona/src/resolve.rs index 614486661a..528dba8637 100644 --- a/crates/buzz-persona/src/resolve.rs +++ b/crates/buzz-persona/src/resolve.rs @@ -22,18 +22,25 @@ use crate::persona::split_model; #[derive(Debug, Clone)] pub struct ResolvedPersona { // Identity + /// Machine name (slug). pub name: String, + /// Human-readable display name. pub display_name: String, + /// One-line description. pub description: String, + /// Pack-relative path to an avatar image, if any. pub avatar: Option, + /// Persona version (currently mirrors the pack version). pub version: String, // → Config.system_prompt (persona body only) + /// The persona prompt body (becomes `Config.system_prompt`). pub system_prompt: String, /// Pack-owned instructions kept separate for the team model migration. pub pack_instructions: Option, // → Config.model (plain model ID, post-split) + /// Plain model ID after splitting off the provider prefix. pub model: Option, /// LLM inference provider extracted from the model string colon prefix (e.g., 'databricks' /// from 'databricks:model-id'). Flows into harness-specific env vars (GOOSE_PROVIDER) only. @@ -41,61 +48,86 @@ pub struct ResolvedPersona { /// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude'). Maps to /// AgentDefinition.runtime during pack import. pub runtime: Option, + /// Sampling temperature, if set. pub temperature: Option, + /// Maximum context window in tokens, if set. pub max_context_tokens: Option, // → Config.subscribe_mode + channels_override + /// Channels to monitor. pub subscribe: Vec, // → mapped to ACP filter rules at startup + /// Message-matching triggers. pub triggers: ResolvedTriggers, + /// Whether replies go in-thread. pub thread_replies: bool, + /// Whether replies are broadcast to the channel. pub broadcast_replies: bool, // Effective MCP (pack shared + persona merged, literals preserved) + /// Effective MCP servers (pack shared plus per-persona, merged). pub mcp_servers: Vec, // Hooks (parsed, not executed — reserved for future use, not yet wired) + /// Lifecycle hooks (parsed, not executed — reserved for future use). pub hooks: Option, // Skills (bare names — reserved for future use, not yet wired) + /// Skill directory names (reserved for future use). pub skills: Vec, // Env var projection for agent subprocess + /// Env vars projected from model/temperature/context config for the agent subprocess. pub runtime_env_vars: Vec<(String, String)>, } /// An MCP server with env values as literals (no interpolation in this PR). #[derive(Debug, Clone, PartialEq)] pub struct ResolvedMcpServer { + /// Server identifier. pub name: String, + /// Executable command to launch the server. pub command: String, + /// Command-line arguments. pub args: Vec, + /// Environment variables as literal key/value pairs. pub env: Vec<(String, String)>, } /// Lifecycle hooks (pack-relative paths). #[derive(Debug, Clone, PartialEq)] pub struct ResolvedHooks { + /// Hook invoked when the persona starts. pub on_start: Option, + /// Hook invoked when the persona stops. pub on_stop: Option, + /// Hook invoked on each incoming message. pub on_message: Option, } /// What triggers a response (renamed from respond_to per spec discussion). #[derive(Debug, Clone, PartialEq)] pub struct ResolvedTriggers { + /// Whether the persona responds when mentioned. pub mentions: bool, + /// Keywords that trigger a response. pub keywords: Vec, + /// Whether the persona responds to every message in subscribed channels. pub all_messages: bool, } /// A fully resolved pack. #[derive(Debug)] pub struct ResolvedPack { + /// Unique pack identifier (slug). pub id: String, + /// Human-readable pack name. pub name: String, + /// Semver version string. pub version: String, + /// Pack description (falls back to empty string if absent). pub description: String, + /// Fully resolved personas. pub personas: Vec, } diff --git a/crates/buzz-persona/src/validate.rs b/crates/buzz-persona/src/validate.rs index 12e5887662..09ee8c4d8f 100644 --- a/crates/buzz-persona/src/validate.rs +++ b/crates/buzz-persona/src/validate.rs @@ -17,7 +17,9 @@ use crate::pack; /// A single validation finding. #[derive(Debug, Clone)] pub enum ValidationDiagnostic { + /// A hard failure that makes the pack invalid. Error(String), + /// An advisory issue that does not block use of the pack. Warning(String), } @@ -33,26 +35,31 @@ impl std::fmt::Display for ValidationDiagnostic { /// Result of validating a pack. #[derive(Debug, Default)] pub struct ValidationReport { + /// The list of diagnostics collected during validation. pub diagnostics: Vec, } impl ValidationReport { + /// Record a hard error. pub fn error(&mut self, msg: impl Into) { self.diagnostics .push(ValidationDiagnostic::Error(msg.into())); } + /// Record an advisory warning. pub fn warn(&mut self, msg: impl Into) { self.diagnostics .push(ValidationDiagnostic::Warning(msg.into())); } + /// Returns `true` if the report contains any errors. pub fn has_errors(&self) -> bool { self.diagnostics .iter() .any(|d| matches!(d, ValidationDiagnostic::Error(_))) } + /// Returns `true` if the report contains any warnings. pub fn has_warnings(&self) -> bool { self.diagnostics .iter()