use crate::provider_credential::domain::{ProviderPromptContext, ProviderSlug};
use iron_providers::Provider;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub const PROFILE_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AgentProfileId(
pub String,
);
impl AgentProfileId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for AgentProfileId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AgentProfileId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgentProfileEntry {
pub id: AgentProfileId,
pub profile: AgentProfile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentProfileProvider {
RuntimeDefault,
Managed {
provider_slug: ProviderSlug,
model: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ToolFilter {
#[default]
Inherit,
Allow(Vec<String>),
Deny(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SkillFilter {
None,
Allow(Vec<String>),
#[default]
Inherit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(try_from = "AgentApprovalRaw")]
pub enum AgentApproval {
#[default]
PerTool,
AutoApprove,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum AgentApprovalRaw {
PerTool,
AutoApprove,
ReadOnly,
RequireApproval,
}
impl TryFrom<AgentApprovalRaw> for AgentApproval {
type Error = String;
fn try_from(value: AgentApprovalRaw) -> Result<Self, Self::Error> {
match value {
AgentApprovalRaw::PerTool => Ok(AgentApproval::PerTool),
AgentApprovalRaw::AutoApprove => Ok(AgentApproval::AutoApprove),
AgentApprovalRaw::ReadOnly => {
Err("ReadOnly is not a valid user-facing profile approval value".to_string())
}
AgentApprovalRaw::RequireApproval => Err(
"RequireApproval is not a valid user-facing profile approval value; use PerTool"
.to_string(),
),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentProfile {
pub name: String,
#[serde(flatten)]
pub provider: AgentProfileProvider,
#[serde(default)]
pub tools: ToolFilter,
#[serde(default)]
pub skills: SkillFilter,
#[serde(default)]
pub approval: AgentApproval,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_prompt: Option<String>,
}
impl AgentProfile {
pub fn with_name<S: Into<String>>(name: S) -> Self {
Self {
name: name.into(),
provider: AgentProfileProvider::RuntimeDefault,
tools: ToolFilter::default(),
skills: SkillFilter::default(),
approval: AgentApproval::default(),
identity_prompt: None,
}
}
pub fn effective_identity_prompt(&self) -> &str {
match self.identity_prompt {
Some(ref prompt) if !prompt.trim().is_empty() => prompt,
_ => default_identity_prompt(),
}
}
}
pub const SHIPPED_PROFILE_IDS: &[&str] = &["explore", "plan", "apply"];
pub fn shipped_default_profiles() -> Vec<(AgentProfileId, AgentProfile)> {
vec![
(
AgentProfileId::from("explore"),
AgentProfile {
name: "Explore".to_string(),
provider: AgentProfileProvider::RuntimeDefault,
tools: ToolFilter::Inherit,
skills: SkillFilter::Inherit,
approval: AgentApproval::PerTool,
identity_prompt: Some(
"You are an exploratory research agent. Your goal is to broadly investigate \
topics, gather information, and surface options without committing to a specific \
implementation. You should ask clarifying questions, consider alternatives, and \
summarize findings."
.to_string(),
),
},
),
(
AgentProfileId::from("plan"),
AgentProfile {
name: "Plan".to_string(),
provider: AgentProfileProvider::RuntimeDefault,
tools: ToolFilter::Inherit,
skills: SkillFilter::Inherit,
approval: AgentApproval::PerTool,
identity_prompt: Some(
"You are a planning agent. Your goal is to analyze requirements, break work \
into actionable steps, and produce structured plans. You should identify \
dependencies, estimate effort, and propose milestones before any implementation \
begins."
.to_string(),
),
},
),
(
AgentProfileId::from("apply"),
AgentProfile {
name: "Apply".to_string(),
provider: AgentProfileProvider::RuntimeDefault,
tools: ToolFilter::Inherit,
skills: SkillFilter::Inherit,
approval: AgentApproval::PerTool,
identity_prompt: Some(
"You are an implementation agent. Your goal is to execute plans, write code, \
run tests, and deliver working solutions. You should focus on correctness, \
test coverage, and incremental progress toward the stated goal."
.to_string(),
),
},
),
]
}
pub fn default_identity_prompt() -> &'static str {
"You are a helpful software engineering agent."
}
pub enum ResolvedProfileProvider {
RuntimeDefault(Arc<dyn Provider>),
Managed(Box<dyn Provider>),
Fallback {
provider: Arc<dyn Provider>,
diagnostic: String,
},
}
impl std::fmt::Debug for ResolvedProfileProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResolvedProfileProvider::RuntimeDefault(_) => f.debug_tuple("RuntimeDefault").finish(),
ResolvedProfileProvider::Managed(_) => f.debug_tuple("Managed").finish(),
ResolvedProfileProvider::Fallback { diagnostic, .. } => f
.debug_struct("Fallback")
.field("diagnostic", diagnostic)
.finish(),
}
}
}
impl ResolvedProfileProvider {
pub fn as_provider(&self) -> &dyn Provider {
match self {
ResolvedProfileProvider::RuntimeDefault(arc) => arc.as_ref(),
ResolvedProfileProvider::Managed(boxed) => boxed.as_ref(),
ResolvedProfileProvider::Fallback { provider, .. } => provider.as_ref(),
}
}
pub fn fallback_diagnostic(&self) -> Option<&str> {
match self {
ResolvedProfileProvider::Fallback { diagnostic, .. } => Some(diagnostic.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileLoadIssue {
UnsupportedSchemaVersion {
version: i64,
},
InvalidPayload,
InvalidProfileId,
InvalidName,
ReservedDefault,
DuplicateName,
MissingRecord,
ReadOnlyRejected,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileLoadDiagnostic {
pub profile_id: AgentProfileId,
pub name: Option<String>,
pub issue: ProfileLoadIssue,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileLoadReport {
pub loaded: Vec<AgentProfileEntry>,
pub diagnostics: Vec<ProfileLoadDiagnostic>,
}
impl ProfileLoadReport {
pub fn empty() -> Self {
Self {
loaded: Vec::new(),
diagnostics: Vec::new(),
}
}
}
pub fn is_valid_profile_id(id: &str) -> bool {
let trimmed = id.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return false;
}
!trimmed.eq_ignore_ascii_case("default")
}
pub fn normalize_profile_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return None;
}
if trimmed.eq_ignore_ascii_case("default") {
return None;
}
Some(trimmed.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileDeletePolicy {
AllowZero,
RequireMinimumValid(usize),
}
impl ProfileDeletePolicy {
pub fn minimum(self) -> usize {
match self {
ProfileDeletePolicy::AllowZero => 0,
ProfileDeletePolicy::RequireMinimumValid(minimum) => minimum,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProfileRecordError {
UnsupportedSchemaVersion {
actual: i64,
},
ReadOnlyRejected {
approval: String,
},
DecodeFailure {
error: String,
},
InvalidFields {
messages: Vec<String>,
},
}
pub fn classify_profile_record(
id: &str,
schema_version: i64,
payload: &serde_json::Value,
) -> Result<AgentProfile, ProfileRecordError> {
if schema_version != PROFILE_SCHEMA_VERSION {
return Err(ProfileRecordError::UnsupportedSchemaVersion {
actual: schema_version,
});
}
match serde_json::from_value::<AgentProfile>(payload.clone()) {
Ok(profile) => {
let messages = validate_profile_fields(id, &profile);
if messages.is_empty() {
Ok(profile)
} else {
Err(ProfileRecordError::InvalidFields { messages })
}
}
Err(error) => {
let rejected = payload
.get("approval")
.and_then(|value| value.as_str())
.filter(|approval| matches!(*approval, "ReadOnly" | "RequireApproval"));
if let Some(approval) = rejected {
Err(ProfileRecordError::ReadOnlyRejected {
approval: approval.to_string(),
})
} else {
Err(ProfileRecordError::DecodeFailure {
error: error.to_string(),
})
}
}
}
}
pub(crate) fn validate_profile_fields(id: &str, profile: &AgentProfile) -> Vec<String> {
let mut messages = Vec::new();
if !is_valid_profile_id(id) {
messages.push(format!("Profile ID '{}' is invalid or reserved", id));
}
if normalize_profile_name(&profile.name).is_none() {
messages.push(format!(
"Profile name '{}' is invalid or reserved",
profile.name
));
}
if let AgentProfileProvider::Managed {
provider_slug,
model,
} = &profile.provider
{
if provider_slug.as_str().trim().is_empty() || model.trim().is_empty() {
messages.push("Managed profile has empty provider_slug or model".to_string());
}
}
messages
}
pub fn managed_profile_prompt_context(
provider: &AgentProfileProvider,
) -> Option<ProviderPromptContext> {
match provider {
AgentProfileProvider::RuntimeDefault => None,
AgentProfileProvider::Managed {
provider_slug,
model,
} => Some(ProviderPromptContext {
provider_slug: provider_slug.clone(),
model: model.clone(),
api_key: None,
}),
}
}
pub const DEFAULT_PROFILE_SEED_DOMAIN: &str = "agent_profiles";
pub const DEFAULT_PROFILE_SEED_KEY: &str = "default_seed";
pub const DEFAULT_PROFILE_SEED_VERSION: &str = "1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultProfileSeedPolicy {
FirstRunOnly,
RestoreMissing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DefaultProfileSeedDiagnostic {
Created(AgentProfileId),
SkippedExisting(AgentProfileId),
SkippedFirstRunDone(AgentProfileId),
StorageFailure {
profile_id: AgentProfileId,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct DefaultProfileSeedReport {
pub policy: DefaultProfileSeedPolicy,
pub marker_was_present: bool,
pub marker_written: bool,
pub created: Vec<AgentProfileId>,
pub skipped_existing: Vec<AgentProfileId>,
pub diagnostics: Vec<DefaultProfileSeedDiagnostic>,
}
impl DefaultProfileSeedReport {
pub fn no_op(policy: DefaultProfileSeedPolicy, marker_was_present: bool) -> Self {
Self {
policy,
marker_was_present,
marker_written: false,
created: Vec::new(),
skipped_existing: Vec::new(),
diagnostics: Vec::new(),
}
}
}
pub async fn seed_default_profiles(
store: &crate::config::ConfigStore,
policy: DefaultProfileSeedPolicy,
) -> Result<DefaultProfileSeedReport, crate::config::ConfigError> {
use crate::config::records::{BootstrapMetadataInput, ProfileInput};
let marker = store
.get_bootstrap_metadata(DEFAULT_PROFILE_SEED_DOMAIN, DEFAULT_PROFILE_SEED_KEY)
.await?;
let marker_was_present = marker.is_some();
if matches!(policy, DefaultProfileSeedPolicy::FirstRunOnly) && marker_was_present {
let mut report = DefaultProfileSeedReport::no_op(policy, true);
for (id, _profile) in shipped_default_profiles() {
report
.diagnostics
.push(DefaultProfileSeedDiagnostic::SkippedFirstRunDone(id));
}
return Ok(report);
}
let mut report = DefaultProfileSeedReport {
policy,
marker_was_present,
marker_written: false,
created: Vec::new(),
skipped_existing: Vec::new(),
diagnostics: Vec::new(),
};
for (id, profile) in shipped_default_profiles() {
let payload = serde_json::to_value(&profile).map_err(|e| {
crate::config::ConfigError::Serialization(format!(
"Failed to serialize shipped default profile {}: {}",
id.as_str(),
e
))
})?;
let input = ProfileInput {
id: id.as_str().to_string(),
schema_version: PROFILE_SCHEMA_VERSION,
payload,
};
let inserted = store.insert_profile_if_missing(&input).await?;
if !inserted {
report.skipped_existing.push(id.clone());
report
.diagnostics
.push(DefaultProfileSeedDiagnostic::SkippedExisting(id));
continue;
}
report.created.push(id.clone());
report
.diagnostics
.push(DefaultProfileSeedDiagnostic::Created(id));
}
if matches!(policy, DefaultProfileSeedPolicy::FirstRunOnly) || !report.created.is_empty() {
let marker_input = BootstrapMetadataInput {
domain: DEFAULT_PROFILE_SEED_DOMAIN.to_string(),
key: DEFAULT_PROFILE_SEED_KEY.to_string(),
value: DEFAULT_PROFILE_SEED_VERSION.to_string(),
};
match store.set_bootstrap_metadata(&marker_input).await {
Ok(()) => report.marker_written = true,
Err(e) => {
return Err(crate::config::ConfigError::Migration(format!(
"Failed to write default-profile seed marker: {}",
e
)));
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_profile_id_accepts_non_reserved() {
assert!(is_valid_profile_id("my-profile"));
assert!(is_valid_profile_id(" spaced-id "));
}
#[test]
fn valid_profile_id_rejects_default_variants() {
assert!(!is_valid_profile_id("default"));
assert!(!is_valid_profile_id("Default"));
assert!(!is_valid_profile_id("DEFAULT"));
}
#[test]
fn valid_profile_id_rejects_empty_and_control() {
assert!(!is_valid_profile_id(""));
assert!(!is_valid_profile_id(" "));
assert!(!is_valid_profile_id("id\0"));
}
#[test]
fn normalize_profile_name_trims_and_rejects_reserved() {
assert_eq!(normalize_profile_name(" Foo "), Some("Foo".to_string()));
assert_eq!(normalize_profile_name("default"), None);
assert_eq!(normalize_profile_name(""), None);
assert_eq!(normalize_profile_name("\t"), None);
assert_eq!(normalize_profile_name("a\nb"), None);
}
#[test]
fn default_profile_identity_prompt() {
assert_eq!(
default_identity_prompt(),
"You are a helpful software engineering agent."
);
}
#[test]
fn profile_effective_identity_prompt() {
let mut profile = AgentProfile::with_name("test");
assert_eq!(
profile.effective_identity_prompt(),
default_identity_prompt()
);
profile.identity_prompt = Some("Custom.".to_string());
assert_eq!(profile.effective_identity_prompt(), "Custom.");
profile.identity_prompt = Some(" ".to_string());
assert_eq!(
profile.effective_identity_prompt(),
default_identity_prompt()
);
}
#[test]
fn classify_valid_profile_record() {
let payload = serde_json::to_value(AgentProfile::with_name("Valid")).unwrap();
let result = classify_profile_record("prof", PROFILE_SCHEMA_VERSION, &payload);
assert!(result.is_ok());
assert_eq!(result.unwrap().name, "Valid");
}
#[test]
fn classify_unsupported_schema_version() {
let payload = serde_json::to_value(AgentProfile::with_name("Valid")).unwrap();
let result = classify_profile_record("prof", 999, &payload);
assert_eq!(
result,
Err(ProfileRecordError::UnsupportedSchemaVersion { actual: 999 })
);
}
#[test]
fn classify_malformed_json_payload() {
let payload = serde_json::json!({"unexpected": true});
let result = classify_profile_record("prof", PROFILE_SCHEMA_VERSION, &payload);
assert!(matches!(
result,
Err(ProfileRecordError::DecodeFailure { .. })
));
}
#[test]
fn classify_read_only_approval_rejected() {
let mut payload = serde_json::to_value(AgentProfile::with_name("Valid")).unwrap();
payload
.as_object_mut()
.unwrap()
.insert("approval".to_string(), serde_json::json!("ReadOnly"));
let result = classify_profile_record("prof", PROFILE_SCHEMA_VERSION, &payload);
assert_eq!(
result,
Err(ProfileRecordError::ReadOnlyRejected {
approval: "ReadOnly".to_string()
})
);
}
#[test]
fn classify_reserved_default_id_invalid() {
let payload = serde_json::to_value(AgentProfile::with_name("Valid")).unwrap();
let result = classify_profile_record("default", PROFILE_SCHEMA_VERSION, &payload);
assert!(matches!(
result,
Err(ProfileRecordError::InvalidFields { .. })
));
}
#[test]
fn classify_structurally_invalid_fields() {
let profile = AgentProfile {
name: String::new(),
..AgentProfile::with_name("placeholder")
};
let mut payload = serde_json::to_value(profile).unwrap();
payload
.as_object_mut()
.unwrap()
.insert("name".to_string(), serde_json::json!(""));
let result = classify_profile_record("prof", PROFILE_SCHEMA_VERSION, &payload);
match result {
Err(ProfileRecordError::InvalidFields { messages }) => {
assert!(messages.iter().any(|m| m.contains("Profile name")));
}
other => panic!("expected InvalidFields, got {:?}", other),
}
}
#[test]
fn delete_policy_minimum_resolves() {
assert_eq!(ProfileDeletePolicy::AllowZero.minimum(), 0);
assert_eq!(ProfileDeletePolicy::RequireMinimumValid(3).minimum(), 3);
}
}