use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use perspt_sdk::{EffectKind, FootprintSpec, ProposalBinding, RiskClass};
const MASKED_API_KEY: &str = "***";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub adapter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
impl ProviderEntry {
pub fn adapter_or<'a>(&'a self, key: &'a str) -> &'a str {
self.adapter.as_deref().unwrap_or(key)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub architect: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actuator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speculator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub adjudicator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExternalToolMode {
Agent,
Chat,
}
fn default_external_modes() -> Vec<ExternalToolMode> {
vec![ExternalToolMode::Agent]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExternalToolTransport {
Stdio,
StreamableHttp,
}
fn default_external_timeout_ms() -> u64 {
30_000
}
fn default_external_result_bytes() -> usize {
1_048_576
}
fn default_external_stderr_bytes() -> usize {
65_536
}
fn default_subscriptions() -> bool {
true
}
fn default_tasks() -> bool {
true
}
fn default_task_wait_ms() -> u64 {
300_000
}
fn default_sampling_tokens() -> u32 {
4096
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct McpRootConfig {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ExternalToolPolicy {
pub effect: Option<EffectKind>,
pub risk: Option<RiskClass>,
pub footprint: Option<FootprintSpec>,
pub proposal_bindings: Vec<ProposalBinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ExternalToolConfig {
pub id: String,
pub transport: ExternalToolTransport,
pub command: Vec<String>,
pub url: Option<String>,
pub env_from_env: BTreeMap<String, String>,
pub headers_from_env: BTreeMap<String, String>,
#[serde(default = "default_external_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_external_result_bytes")]
pub max_result_bytes: usize,
#[serde(default = "default_external_stderr_bytes")]
pub max_stderr_bytes: usize,
#[serde(default = "default_external_modes")]
pub modes: Vec<ExternalToolMode>,
pub roots: Vec<McpRootConfig>,
pub sampling: bool,
pub elicitation: bool,
#[serde(default = "default_subscriptions")]
pub subscriptions: bool,
pub resource_subscriptions: Vec<String>,
#[serde(default = "default_tasks")]
pub tasks: bool,
#[serde(default = "default_task_wait_ms")]
pub max_task_wait_ms: u64,
#[serde(default = "default_sampling_tokens")]
pub max_sampling_tokens: u32,
pub tools: BTreeMap<String, ExternalToolPolicy>,
}
impl Default for ExternalToolConfig {
fn default() -> Self {
Self {
id: String::new(),
transport: ExternalToolTransport::Stdio,
command: Vec::new(),
url: None,
env_from_env: BTreeMap::new(),
headers_from_env: BTreeMap::new(),
timeout_ms: default_external_timeout_ms(),
max_result_bytes: default_external_result_bytes(),
max_stderr_bytes: default_external_stderr_bytes(),
modes: default_external_modes(),
roots: Vec::new(),
sampling: false,
elicitation: false,
subscriptions: default_subscriptions(),
resource_subscriptions: Vec::new(),
tasks: default_tasks(),
max_task_wait_ms: default_task_wait_ms(),
max_sampling_tokens: default_sampling_tokens(),
tools: BTreeMap::new(),
}
}
}
impl ExternalToolConfig {
pub fn supports(&self, mode: ExternalToolMode) -> bool {
self.modes.contains(&mode)
}
fn validate(&self) -> Result<()> {
anyhow::ensure!(
!self.id.is_empty()
&& self.id.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
}),
"external tool server id {:?} must contain only ASCII letters, digits, '-' or '_'",
self.id
);
anyhow::ensure!(
self.timeout_ms > 0,
"external tool {:?}: timeout_ms must be positive",
self.id
);
anyhow::ensure!(
self.max_result_bytes > 0 && self.max_stderr_bytes > 0,
"external tool {:?}: result and stderr caps must be positive",
self.id
);
self.validate_mcp()?;
anyhow::ensure!(
!self.modes.is_empty(),
"external tool {:?}: modes cannot be empty",
self.id
);
let unique_modes: std::collections::BTreeSet<_> =
self.modes.iter().map(|mode| format!("{mode:?}")).collect();
anyhow::ensure!(
unique_modes.len() == self.modes.len(),
"external tool {:?}: duplicate mode",
self.id
);
match self.transport {
ExternalToolTransport::Stdio => anyhow::ensure!(
!self.command.is_empty() && self.url.is_none(),
"external tool {:?}: stdio requires command argv and forbids url",
self.id
),
ExternalToolTransport::StreamableHttp => anyhow::ensure!(
self.command.is_empty() && self.url.is_some(),
"external tool {:?}: Streamable HTTP requires url and forbids command",
self.id
),
}
Ok(())
}
fn validate_mcp(&self) -> Result<()> {
anyhow::ensure!(
self.max_sampling_tokens > 0,
"external tool {:?}: max_sampling_tokens must be positive",
self.id
);
anyhow::ensure!(
self.max_task_wait_ms > 0,
"external tool {:?}: max_task_wait_ms must be positive",
self.id
);
for root in &self.roots {
let uri = url::Url::parse(&root.uri)
.with_context(|| format!("external tool {:?}: invalid MCP root URI", self.id))?;
anyhow::ensure!(
uri.scheme() == "file" && uri.path().starts_with('/'),
"external tool {:?}: MCP roots must be absolute file:// URIs",
self.id
);
}
for uri in &self.resource_subscriptions {
anyhow::ensure!(
url::Url::parse(uri).is_ok(),
"external tool {:?}: invalid resource subscription URI {uri:?}",
self.id
);
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(
alias = "provider_type",
alias = "default_provider",
skip_serializing_if = "Option::is_none"
)]
pub provider: Option<String>,
#[serde(alias = "default_model", skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vertex_project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vertex_location: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
alias = "python_package_manager"
)]
pub package_manager: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub architect_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actuator_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verifier_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speculator_model: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub providers: BTreeMap<String, ProviderEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<ModelsConfig>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub external_tools: Vec<ExternalToolConfig>,
#[serde(default, skip_serializing)]
pub ensemble: Option<toml::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option<VerificationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exploration: Option<ExplorationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompts: Option<PromptsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ContextConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ContextConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub working_set_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub synopsis_frame_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_reserve_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guard_reserve_tokens: Option<u64>,
}
impl ContextConfig {
pub fn validate(&self) -> Result<()> {
for (name, value) in [
("working_set_turns", self.working_set_turns.map(u64::from)),
("synopsis_frame_tokens", self.synopsis_frame_tokens),
("output_reserve_tokens", self.output_reserve_tokens),
("guard_reserve_tokens", self.guard_reserve_tokens),
] {
if value == Some(0) {
anyhow::bail!("[context] {name} must be positive");
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct VerificationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_unisolated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_policy: Option<TestPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_oracle: Option<ExternalOracleConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require_format: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stage_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub syntax_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lint_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format_timeout_secs: Option<u64>,
}
impl VerificationConfig {
pub fn validate(&self) -> Result<()> {
for (name, value) in [
("stage_timeout_secs", self.stage_timeout_secs),
("syntax_timeout_secs", self.syntax_timeout_secs),
("build_timeout_secs", self.build_timeout_secs),
("test_timeout_secs", self.test_timeout_secs),
("lint_timeout_secs", self.lint_timeout_secs),
("format_timeout_secs", self.format_timeout_secs),
] {
if value == Some(0) {
anyhow::bail!("[verification] {name} must be positive");
}
}
match (self.test_policy.unwrap_or_default(), &self.external_oracle) {
(TestPolicy::ExternalOracle, Some(oracle)) => oracle.validate()?,
(TestPolicy::ExternalOracle, None) => anyhow::bail!(
"[verification] test_policy = \"external-oracle\" requires \
[verification.external_oracle]"
),
(_, Some(_)) => anyhow::bail!(
"[verification.external_oracle] requires \
test_policy = \"external-oracle\""
),
(_, None) => {}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TestPolicy {
#[default]
Evolving,
BackwardCompatible,
ExternalOracle,
}
impl TestPolicy {
pub const fn as_str(self) -> &'static str {
match self {
Self::Evolving => "evolving",
Self::BackwardCompatible => "backward-compatible",
Self::ExternalOracle => "external-oracle",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalOracleConfig {
pub path: PathBuf,
pub command: String,
}
impl ExternalOracleConfig {
fn validate(&self) -> Result<()> {
anyhow::ensure!(
!self.path.as_os_str().is_empty(),
"[verification.external_oracle] path must not be empty"
);
anyhow::ensure!(
!self.command.trim().is_empty(),
"[verification.external_oracle] command must not be empty"
);
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ExplorationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_branches: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_branches: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub distinct_family: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_workspace_files: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_workspace_bytes: Option<u64>,
}
impl ExplorationConfig {
pub fn validate(&self) -> Result<()> {
let initial = self.initial_branches.unwrap_or(1);
let max = self.max_branches.unwrap_or(3);
anyhow::ensure!(
(1..=3).contains(&max),
"[exploration] max_branches must be between 1 and 3 (got {max})"
);
anyhow::ensure!(
initial >= 1 && initial <= max,
"[exploration] initial_branches must be between 1 and max_branches"
);
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PromptsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bundles: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_min_tasks: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub noninferiority_margin: Option<f64>,
}
impl PromptsConfig {
pub fn activation_bounds(&self) -> Result<perspt_sdk::prompt::ActivationBounds> {
let bounds = perspt_sdk::prompt::ActivationBounds {
min_tasks: self.activation_min_tasks.unwrap_or(30),
noninferiority_margin: self.noninferiority_margin.unwrap_or(0.05),
};
bounds
.validate()
.map_err(|e| anyhow::anyhow!("[prompts]: {e}"))?;
Ok(bounds)
}
}
impl Config {
pub fn from_toml_str(content: &str) -> Result<Self> {
let config: Self = toml::from_str(content).context("Failed to parse TOML configuration")?;
config.validate()?;
Ok(config)
}
pub fn load_from_path(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
Self::from_toml_str(&content)
}
pub fn to_toml_string(&self) -> Result<String> {
toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")
}
pub fn masked(&self) -> Self {
let mut clone = self.clone();
if clone.api_key.is_some() {
clone.api_key = Some(MASKED_API_KEY.to_string());
}
for entry in clone.providers.values_mut() {
if entry.api_key.is_some() {
entry.api_key = Some(MASKED_API_KEY.to_string());
}
}
clone
}
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(
self.ensemble.is_none(),
"the [ensemble] section was removed by PSP-10: the ensemble is replaced \
by the bounded search forest. Configure [exploration] instead \
(initial_branches, max_branches, distinct_family) and delete [ensemble]."
);
if let Some(exploration) = &self.exploration {
exploration.validate()?;
}
if let Some(prompts) = &self.prompts {
prompts.activation_bounds()?;
}
if self.models.as_ref().and_then(|m| m.turn_timeout_secs) == Some(0) {
anyhow::bail!("[models] turn_timeout_secs must be positive");
}
if let Some(context) = &self.context {
context.validate()?;
}
if let Some(verification) = &self.verification {
verification.validate()?;
}
let mut server_ids = std::collections::BTreeSet::new();
for server in &self.external_tools {
server.validate()?;
anyhow::ensure!(
server_ids.insert(server.id.as_str()),
"duplicate external tool server id {:?}",
server.id
);
}
Ok(())
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
let value = value.to_string();
match key {
"provider" | "provider_type" | "default_provider" => self.provider = Some(value),
"model" | "default_model" => self.model = Some(value),
"api_key" => self.api_key = Some(value),
"base_url" => self.base_url = Some(value),
"vertex_project_id" => self.vertex_project_id = Some(value),
"vertex_location" => self.vertex_location = Some(value),
"architect_model" => self.architect_model = Some(value),
"actuator_model" => self.actuator_model = Some(value),
"verifier_model" => self.verifier_model = Some(value),
"speculator_model" => self.speculator_model = Some(value),
"package_manager" | "python_package_manager" => self.package_manager = Some(value),
other => anyhow::bail!(
"Unknown configuration key: {other}. Valid keys: provider, model, api_key, \
base_url, vertex_project_id, vertex_location, architect_model, actuator_model, \
verifier_model, speculator_model, package_manager"
),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_parses_to_defaults() {
let cfg = Config::from_toml_str("").unwrap();
assert!(cfg.provider.is_none());
assert!(cfg.model.is_none());
assert!(cfg.api_key.is_none());
}
#[test]
fn test_evidence_policies_parse_and_evolving_is_the_default() {
assert_eq!(TestPolicy::default(), TestPolicy::Evolving);
let cfg = Config::from_toml_str(
r#"
[verification]
test_policy = "external-oracle"
[verification.external_oracle]
path = "/trusted/acceptance"
command = "cargo test --test acceptance"
"#,
)
.unwrap();
let verification = cfg.verification.unwrap();
assert_eq!(verification.test_policy, Some(TestPolicy::ExternalOracle));
assert_eq!(
verification.external_oracle.unwrap().command,
"cargo test --test acceptance"
);
}
#[test]
fn external_oracle_configuration_is_never_silently_ignored() {
let missing = Config::from_toml_str(
r#"
[verification]
test_policy = "external-oracle"
"#,
)
.unwrap_err();
assert!(missing.to_string().contains("external_oracle"));
let inactive = Config::from_toml_str(
r#"
[verification]
test_policy = "evolving"
[verification.external_oracle]
path = "/trusted/acceptance"
command = "cargo test"
"#,
)
.unwrap_err();
assert!(inactive.to_string().contains("external-oracle"));
}
#[test]
fn package_manager_set_value_and_alias() {
let mut cfg = Config::default();
cfg.set_value("package_manager", "poetry").unwrap();
assert_eq!(cfg.package_manager.as_deref(), Some("poetry"));
let mut cfg2 = Config::default();
cfg2.set_value("python_package_manager", "pdm").unwrap();
assert_eq!(cfg2.package_manager.as_deref(), Some("pdm"));
}
#[test]
fn aliases_are_accepted() {
let cfg = Config::from_toml_str(
r#"
provider_type = "openai"
default_model = "phi-4-npu-ov"
"#,
)
.unwrap();
assert_eq!(cfg.provider.as_deref(), Some("openai"));
assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
}
#[test]
fn missing_file_returns_default() {
let path = Path::new("/nonexistent/perspt/config.toml");
let cfg = Config::load_from_path(path).unwrap();
assert!(cfg.provider.is_none());
}
#[test]
fn masked_hides_api_key() {
let cfg = Config {
api_key: Some("super-secret".to_string()),
..Default::default()
};
assert_eq!(cfg.masked().api_key.as_deref(), Some("***"));
}
#[test]
fn masked_leaves_absent_key_absent() {
let cfg = Config::default();
assert!(cfg.masked().api_key.is_none());
}
#[test]
fn set_value_updates_known_keys() {
let mut cfg = Config::default();
cfg.set_value("default_model", "phi-4-npu-ov").unwrap();
assert_eq!(cfg.model.as_deref(), Some("phi-4-npu-ov"));
cfg.set_value("provider", "openai").unwrap();
assert_eq!(cfg.provider.as_deref(), Some("openai"));
cfg.set_value("vertex_project_id", "test-project").unwrap();
cfg.set_value("vertex_location", "test-location").unwrap();
assert_eq!(cfg.vertex_project_id.as_deref(), Some("test-project"));
assert_eq!(cfg.vertex_location.as_deref(), Some("test-location"));
}
#[test]
fn providers_table_parses_with_defaulted_adapter() {
let cfg = Config::from_toml_str(
r#"
[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"
[providers.local]
adapter = "ollama"
base_url = "http://localhost:11434"
[models]
architect = "anthropic::claude-opus-5"
speculator = "local::qwen2.5-coder"
"#,
)
.unwrap();
assert_eq!(cfg.providers.len(), 2);
let anthropic = &cfg.providers["anthropic"];
assert_eq!(anthropic.adapter_or("anthropic"), "anthropic");
assert_eq!(anthropic.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
let local = &cfg.providers["local"];
assert_eq!(local.adapter_or("local"), "ollama");
let models = cfg.models.unwrap();
assert_eq!(
models.architect.as_deref(),
Some("anthropic::claude-opus-5")
);
assert_eq!(models.adjudicator, None);
}
#[test]
fn masked_hides_provider_table_keys_too() {
let cfg = Config::from_toml_str(
r#"
[providers.openai]
api_key = "sk-super-secret"
"#,
)
.unwrap();
assert_eq!(
cfg.masked().providers["openai"].api_key.as_deref(),
Some("***")
);
}
#[test]
fn flat_config_still_parses_without_tables() {
let cfg = Config::from_toml_str("provider = \"openai\"\n").unwrap();
assert!(cfg.providers.is_empty());
assert!(cfg.models.is_none());
}
#[test]
fn set_value_rejects_unknown_key() {
let mut cfg = Config::default();
assert!(cfg.set_value("nope", "x").is_err());
}
#[test]
fn round_trip_set_does_not_duplicate() {
let mut cfg = Config::default();
cfg.set_value("default_model", "a").unwrap();
cfg.set_value("default_model", "b").unwrap();
let serialized = cfg.to_toml_string().unwrap();
assert_eq!(serialized.matches("model").count(), 1);
let reparsed = Config::from_toml_str(&serialized).unwrap();
assert_eq!(reparsed.model.as_deref(), Some("b"));
}
#[test]
fn external_tools_default_to_agent_only() {
let config = Config::from_toml_str(
r#"
[[external_tools]]
id = "local-search"
transport = "stdio"
command = ["search-server", "--stdio"]
"#,
)
.unwrap();
let server = &config.external_tools[0];
assert!(server.supports(ExternalToolMode::Agent));
assert!(!server.supports(ExternalToolMode::Chat));
}
#[test]
fn external_tool_modes_and_local_policy_parse() {
let config = Config::from_toml_str(
r#"
[[external_tools]]
id = "records"
transport = "streamable_http"
url = "https://tools.example.test/mcp"
modes = ["agent", "chat"]
headers_from_env = { Authorization = "RECORDS_AUTH" }
[external_tools.tools.lookup]
effect = "data_read"
risk = "low"
"#,
)
.unwrap();
let server = &config.external_tools[0];
assert!(server.supports(ExternalToolMode::Agent));
assert!(server.supports(ExternalToolMode::Chat));
assert_eq!(server.tools["lookup"].effect, Some(EffectKind::DataRead));
}
#[test]
fn mcp_options_validate() {
let config = Config::from_toml_str(
r#"
[[external_tools]]
id = "complete"
transport = "stdio"
command = ["server"]
sampling = true
elicitation = true
subscriptions = true
resource_subscriptions = ["file:///workspace/guide.md"]
tasks = true
max_sampling_tokens = 2048
max_task_wait_ms = 45000
roots = [{ uri = "file:///workspace", name = "Project" }]
"#,
)
.unwrap();
let server = &config.external_tools[0];
assert!(server.sampling && server.elicitation && server.subscriptions && server.tasks);
assert_eq!(server.roots[0].name.as_deref(), Some("Project"));
assert_eq!(server.max_task_wait_ms, 45_000);
}
#[test]
fn invalid_mcp_config_is_rejected() {
assert!(Config::from_toml_str(
r#"
[[external_tools]]
id = "legacy"
transport = "http"
url = "https://example.test/mcp"
"#,
)
.is_err());
assert!(Config::from_toml_str(
r#"
[[external_tools]]
id = "bad-root"
transport = "stdio"
command = ["server"]
roots = [{ uri = "https://example.test/not-a-root" }]
"#,
)
.is_err());
assert!(Config::from_toml_str(
r#"
[[external_tools]]
id = "bad-task-bound"
transport = "stdio"
command = ["server"]
max_task_wait_ms = 0
"#,
)
.is_err());
}
#[test]
fn duplicate_external_server_ids_are_rejected() {
let invalid = r#"
[[external_tools]]
id = "same"
transport = "stdio"
command = ["one"]
[[external_tools]]
id = "same"
transport = "stdio"
command = ["two"]
"#;
assert!(Config::from_toml_str(invalid).is_err());
}
}
#[cfg(test)]
mod psp10_config_tests {
use super::*;
#[test]
fn a_present_ensemble_block_fails_with_a_pointed_migration_error() {
let error = Config::from_toml_str("[ensemble]\nwidth = 2\n").unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("[exploration]"), "{message}");
assert!(message.contains("PSP-10"), "{message}");
}
#[test]
fn exploration_bounds_are_enforced_at_startup() {
assert!(Config::from_toml_str("[exploration]\nmax_branches = 4\n").is_err());
assert!(Config::from_toml_str("[exploration]\ninitial_branches = 0\n").is_err());
let config =
Config::from_toml_str("[exploration]\ninitial_branches = 1\nmax_branches = 3\n")
.unwrap();
config.exploration.unwrap().validate().unwrap();
}
#[test]
fn prompt_activation_bounds_are_floor_and_range_checked() {
assert!(Config::from_toml_str("[prompts]\nactivation_min_tasks = 29\n").is_err());
assert!(Config::from_toml_str("[prompts]\nnoninferiority_margin = 0.06\n").is_err());
let config = Config::from_toml_str(
"[prompts]\nactivation_min_tasks = 40\nnoninferiority_margin = 0.01\n",
)
.unwrap();
let bounds = config.prompts.unwrap().activation_bounds().unwrap();
assert_eq!(bounds.min_tasks, 40);
}
}