use std::collections::BTreeMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use claudius::chat::ChatConfig;
use claudius::{Model, ThinkingConfig};
use handled::SError;
use rc_conf::{RcConf, SwitchPosition, var_name_from_service, var_prefix_from_service};
use serde::Deserialize;
use shvar::VariableProvider;
use utf8path::Path;
pub const DEFAULT_THINKING_BUDGET: u32 = 1024;
pub const AGENTS_CONF_FILE: &str = "agents.conf";
pub const MODELS_CONF_FILE: &str = "models.conf";
pub const TOOLS_CONF_FILE: &str = "tools.conf";
pub const AGENTS_DIR: &str = "agents";
pub const TOOLS_DIR: &str = "tools";
pub const SKILLS_DIR: &str = "skills";
pub const SKILL_FILE: &str = "SKILL.md";
pub const AGENTS_MD_FILE: &str = "AGENTS.md";
pub const AGENTS_MD_PATH_ENV: &str = "AGENTS_MD_PATH";
pub const TOOL_PROTOCOL_VERSION: u32 = 1;
pub const SYSTEM_PROMPT_ID: &str = "SYSTEM";
pub const COMPACTION_PROMPT_ID: &str = "COMPACTION";
pub const MEMORY_EXPERT_PROMPT_ID: &str = "MEMORY_EXPERT";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct KnownPromptField {
id: &'static str,
field: &'static str,
}
const KNOWN_AGENT_PROMPTS: &[KnownPromptField] = &[
KnownPromptField {
id: SYSTEM_PROMPT_ID,
field: "PROMPT",
},
KnownPromptField {
id: COMPACTION_PROMPT_ID,
field: "PROMPT_COMPACTION",
},
KnownPromptField {
id: MEMORY_EXPERT_PROMPT_ID,
field: "PROMPT_MEMORY_EXPERT",
},
];
const KNOWN_TOOL_PROMPTS: &[KnownPromptField] = &[KnownPromptField {
id: SYSTEM_PROMPT_ID,
field: "PROMPT",
}];
#[derive(Debug)]
pub struct Config {
pub root: Path<'static>,
pub default_agent: Option<String>,
pub agents: BTreeMap<String, AgentConfig>,
pub tools: BTreeMap<String, ToolConfig>,
pub skills: BTreeMap<String, SkillConfig>,
pub(crate) agents_rc_conf: RcConf,
pub(crate) tools_rc_conf: RcConf,
}
impl Config {
pub fn load(root: &Path) -> Result<Self, SError> {
let root = root.clone().into_owned();
let agents_conf_path = root.join(AGENTS_CONF_FILE);
let models_conf_path = root.join(MODELS_CONF_FILE);
let tools_conf_path = root.join(TOOLS_CONF_FILE);
require_file(&agents_conf_path, AGENTS_CONF_FILE)?;
require_file(&tools_conf_path, TOOLS_CONF_FILE)?;
let agents_rc_conf = parse_rc_conf(&agents_conf_path)?;
let models_rc_conf = if path_exists(&models_conf_path) {
Some(parse_rc_conf(&models_conf_path)?)
} else {
None
};
let tools_rc_conf = parse_rc_conf(&tools_conf_path)?;
let agent_names = collect_names_from_rc_conf(&agents_rc_conf)?;
let tool_names = collect_names_from_rc_conf(&tools_rc_conf)?;
let skills_dirs = resolve_skills_dirs(&root);
let skills = load_skills(&skills_dirs)?;
Self::from_parts(
root,
agents_rc_conf,
&agent_names,
models_rc_conf,
tools_rc_conf,
&tool_names,
skills,
)
}
fn from_parts(
root: Path<'static>,
agents_rc_conf: RcConf,
agent_names: &[String],
models_rc_conf: Option<RcConf>,
tools_rc_conf: RcConf,
tool_names: &[String],
skills: BTreeMap<String, SkillConfig>,
) -> Result<Self, SError> {
let agents_dir = root.join(AGENTS_DIR).into_owned();
let tools_dir = root.join(TOOLS_DIR).into_owned();
let default_agent = resolve_default_agent(&agents_rc_conf, agent_names)?;
let mut agents = BTreeMap::new();
for agent_name in agent_names {
let agent = AgentConfig::from_rc_conf(
&root,
&agents_dir,
&agents_rc_conf,
models_rc_conf.as_ref(),
agent_name,
)?;
agents.insert(agent_name.clone(), agent);
}
let tools = resolve_tool_configs(&root, &tools_dir, &tools_rc_conf, tool_names)?;
Ok(Self {
root,
default_agent,
agents,
tools,
skills,
agents_rc_conf,
tools_rc_conf,
})
}
}
#[derive(Clone, Default, Eq, PartialEq)]
pub struct AnthropicConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for AnthropicConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnthropicConfig")
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
impl AnthropicConfig {
fn from_providers(
config_root: &Path,
models_rc_conf: Option<&RcConf>,
model: Option<&str>,
agent_provider: &impl VariableProvider,
agent: &str,
) -> Result<Self, SError> {
let mut api_key = None;
let mut base_url = None;
if let (Some(models_rc_conf), Some(model)) = (models_rc_conf, model) {
let model_provider = model_variable_provider(models_rc_conf, model)?;
api_key = lookup_nonempty_field(&model_provider, model, "API_KEY")?
.map(|value| rewrite_relative_file_api_key(config_root, value));
base_url = lookup_nonempty_field(&model_provider, model, "BASE_URL")?;
}
if let Some(value) = lookup_expanded(agent_provider, agent, "API_KEY")? {
api_key =
nonempty(value).map(|value| rewrite_relative_file_api_key(config_root, value));
}
if let Some(value) = lookup_expanded(agent_provider, agent, "BASE_URL")? {
base_url = nonempty(value);
}
Ok(Self { api_key, base_url })
}
}
fn rewrite_relative_file_api_key(config_root: &Path, value: String) -> String {
let Some(rest) = value.strip_prefix("file://") else {
return value;
};
let path = std::path::Path::new(rest);
if path.is_absolute() {
return value;
}
let resolved = std::path::PathBuf::from(config_root.as_str()).join(path);
format!("file://{}", resolved.display())
}
#[derive(Debug)]
pub struct AgentConfig {
pub id: String,
pub enabled: SwitchPosition,
pub display_name: Option<String>,
pub description: Option<String>,
pub ps1: Option<String>,
pub tools: Vec<String>,
pub skills: Vec<String>,
pub prompt_path: Path<'static>,
pub prompt_paths: Vec<Path<'static>>,
pub prompt_markdown: Option<String>,
pub prompts: BTreeMap<String, PromptConfig>,
pub anthropic: AnthropicConfig,
pub chat_config: ChatConfig,
pub user_instructions_enabled: bool,
pub agents_md_enabled: bool,
pub agents_md_path: Option<String>,
pub user_instructions_hook: Option<String>,
pub auto_compact_tokens: Option<u64>,
}
impl AgentConfig {
fn from_rc_conf(
config_root: &Path,
agents_dir: &Path,
rc_conf: &RcConf,
models_rc_conf: Option<&RcConf>,
agent: &str,
) -> Result<Self, SError> {
let provider = rc_conf.variable_provider_for(agent).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to derive agent config from rc_conf")
.with_string_field("agent", agent)
.with_string_field("cause", &format!("{err:?}"))
})?;
let enabled = rc_conf.service_switch(agent);
let display_name = lookup_expanded(&provider, agent, "NAME")?;
let description = lookup_expanded(&provider, agent, "DESC")?;
let ps1 = lookup_ps1_field(&provider, agent)?;
let tools = lookup_split_field(&provider, agent, "TOOLS")?;
let skills = lookup_split_field(&provider, agent, "SKILLS")?;
let user_instructions_enabled =
lookup_bool_field(&provider, agent, "USER_INSTRUCTIONS", true)?;
let agents_md_enabled = lookup_bool_field(&provider, agent, "AGENTS_MD", true)?;
let agents_md_path = lookup_nonempty_field(&provider, agent, "AGENTS_MD_PATH")?;
let user_instructions_hook =
lookup_nonempty_field(&provider, agent, "USER_INSTRUCTIONS_HOOK")?;
let model_selection = resolve_agent_model_selection(models_rc_conf, &provider, agent)?;
let anthropic = AnthropicConfig::from_providers(
config_root,
models_rc_conf,
model_selection.as_deref(),
&provider,
agent,
)?;
let auto_compact_tokens = match lookup_expanded(&provider, agent, "AUTO_COMPACT")? {
Some(value) => Some(parse_u64_field(agent, "AUTO_COMPACT", &value)?),
None => None,
};
let prompts =
load_named_prompts(config_root, rc_conf, agent, KNOWN_AGENT_PROMPTS, "agent")?;
let default_prompt_path = resolve_agent_prompt_path(agents_dir, rc_conf, agent);
let (prompt_path, prompt_paths, prompt_markdown) =
if let Some(system_prompt) = prompts.get(SYSTEM_PROMPT_ID) {
(
system_prompt
.paths
.first()
.cloned()
.unwrap_or_else(|| default_prompt_path.clone()),
system_prompt.paths.clone(),
Some(system_prompt.markdown.clone()),
)
} else if path_exists(&default_prompt_path) {
(
default_prompt_path.clone(),
vec![default_prompt_path.clone()],
Some(read_utf8_file(&default_prompt_path, agent, "prompt")?),
)
} else {
(default_prompt_path, vec![], None)
};
let mut non_system_prompts = prompts;
non_system_prompts.remove(SYSTEM_PROMPT_ID);
let mut chat_config = ChatConfig::new();
if let Some(prompt) = prompt_markdown.as_ref() {
chat_config.set_system_prompt(Some(prompt.clone()));
}
if let (Some(models_rc_conf), Some(model)) = (models_rc_conf, model_selection.as_deref()) {
apply_model_chat_config_overrides(&mut chat_config, models_rc_conf, model)?;
}
apply_agent_chat_config_overrides(
&mut chat_config,
&provider,
agent,
model_selection.is_some(),
)?;
Ok(Self {
id: agent.to_string(),
enabled,
display_name,
description,
ps1,
tools,
skills,
prompt_path,
prompt_paths,
prompt_markdown,
prompts: non_system_prompts,
anthropic,
chat_config,
user_instructions_enabled,
agents_md_enabled,
agents_md_path,
user_instructions_hook,
auto_compact_tokens,
})
}
}
pub const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(270);
#[derive(Debug)]
pub struct ToolConfig {
pub id: String,
pub enabled: SwitchPosition,
pub confirm_preview: bool,
pub executable_path: Option<Path<'static>>,
pub manifest_path: Path<'static>,
pub manifest: Option<ToolManifest>,
pub prompts: BTreeMap<String, PromptConfig>,
pub timeout: Option<Duration>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolManifest {
pub protocol_version: u32,
pub description: String,
pub input_schema: serde_json::Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptConfig {
pub id: String,
pub paths: Vec<Path<'static>>,
pub markdown: String,
}
#[derive(Clone, Debug)]
pub struct SkillConfig {
pub id: String,
pub path: Path<'static>,
pub content: String,
}
#[derive(Debug, Deserialize)]
struct ToolManifestFile {
protocol_version: u32,
description: String,
input_schema: serde_json::Value,
}
fn resolve_tool_configs(
config_root: &Path,
tools_dir: &Path,
rc_conf: &RcConf,
tool_names: &[String],
) -> Result<BTreeMap<String, ToolConfig>, SError> {
let mut resolved_ids = BTreeMap::new();
for tool in tool_names {
let canonical_id = resolve_canonical_tool_id(rc_conf, tool)?;
validate_anthropic_tool_name(&canonical_id)?;
resolved_ids.insert(tool.clone(), canonical_id);
}
let mut canonical_metadata = BTreeMap::new();
for canonical_id in resolved_ids.values() {
if canonical_metadata.contains_key(canonical_id) {
continue;
}
let executable_path = if builtin_tool_executable_is_optional(canonical_id) {
None
} else {
let executable_path = tools_dir.join(canonical_id).into_owned();
require_tool_executable(canonical_id, &executable_path)?;
Some(executable_path)
};
let manifest_path = tools_dir.join(format!("{canonical_id}.json")).into_owned();
let manifest = if path_is_file(&manifest_path) {
Some(load_tool_manifest(canonical_id, &manifest_path)?)
} else if builtin_tool_manifest_is_optional(canonical_id) {
None
} else {
return Err(SError::new("config")
.with_code("missing_tool_manifest")
.with_message("required tool manifest does not exist")
.with_string_field("tool", canonical_id)
.with_string_field("manifest_path", manifest_path.as_str()));
};
canonical_metadata.insert(
canonical_id.clone(),
(executable_path, manifest_path, manifest),
);
}
let mut tools = BTreeMap::new();
for tool_id in tool_names {
let enabled = resolve_tool_switch(rc_conf, tool_id)?;
let confirm_preview = resolve_tool_confirm_preview(rc_conf, tool_id)?;
let timeout = resolve_tool_timeout(rc_conf, tool_id)?;
let prompts =
load_named_prompts(config_root, rc_conf, tool_id, KNOWN_TOOL_PROMPTS, "tool")?;
let canonical_id = resolved_ids
.get(tool_id)
.expect("resolved tool id should exist")
.clone();
let (executable_path, manifest_path, manifest) = canonical_metadata
.get(&canonical_id)
.expect("canonical metadata should exist");
tools.insert(
tool_id.clone(),
ToolConfig {
id: tool_id.clone(),
enabled,
confirm_preview,
executable_path: executable_path.clone(),
manifest_path: manifest_path.clone(),
manifest: manifest.clone(),
prompts,
timeout,
},
);
}
Ok(tools)
}
pub(crate) fn resolve_canonical_tool_id(rc_conf: &RcConf, tool: &str) -> Result<String, SError> {
let services = collect_names_from_rc_conf(rc_conf)?;
let canonical_id = rc_conf.resolve_alias(tool).to_string();
if services.iter().any(|service| service == &canonical_id) {
Ok(canonical_id)
} else {
Err(SError::new("config")
.with_code("unknown_tool")
.with_message("tool alias resolves to an undefined tool")
.with_string_field("tool", tool)
.with_string_field("alias_target", &canonical_id))
}
}
fn resolve_tool_switch(rc_conf: &RcConf, tool: &str) -> Result<SwitchPosition, SError> {
Ok(rc_conf.service_switch(tool))
}
fn resolve_tool_confirm_preview(rc_conf: &RcConf, tool: &str) -> Result<bool, SError> {
let provider = rc_conf.variable_provider_for(tool).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to derive tool config from rc_conf")
.with_string_field("tool", tool)
.with_string_field("cause", &format!("{err:?}"))
})?;
let Some(value) = lookup_expanded(&provider, tool, "CONFIRM")? else {
return Ok(false);
};
parse_bool_field(tool, "CONFIRM", &value)
}
fn resolve_tool_timeout(rc_conf: &RcConf, tool: &str) -> Result<Option<Duration>, SError> {
let provider = rc_conf.variable_provider_for(tool).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to derive tool config from rc_conf")
.with_string_field("tool", tool)
.with_string_field("cause", &format!("{err:?}"))
})?;
let Some(value) = lookup_expanded(&provider, tool, "TIMEOUT")? else {
return Ok(Some(DEFAULT_TOOL_TIMEOUT));
};
let seconds = parse_u64_field(tool, "TIMEOUT", &value)?;
if seconds == 0 {
Ok(None)
} else {
Ok(Some(Duration::from_secs(seconds)))
}
}
pub(crate) fn is_valid_anthropic_tool_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-')
}
fn validate_anthropic_tool_name(name: &str) -> Result<(), SError> {
if is_valid_anthropic_tool_name(name) {
Ok(())
} else {
Err(SError::new("config")
.with_code("invalid_tool_id")
.with_message("tool id is not a legal Anthropic tool name")
.with_string_field("tool", name)
.with_string_field("reason", "expected 1-64 ASCII letters, digits, '_' or '-'"))
}
}
fn require_tool_executable(tool: &str, path: &Path) -> Result<(), SError> {
require_file_with_code(
path,
"missing_tool_executable",
"required tool executable does not exist",
"tool",
tool,
"path",
)?;
#[cfg(unix)]
{
let metadata = fs::metadata(path.as_str()).map_err(|err| {
SError::new("config")
.with_code("io_error")
.with_message("failed to inspect tool executable")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str())
.with_string_field("cause", &err.to_string())
})?;
if metadata.permissions().mode() & 0o111 == 0 {
return Err(SError::new("config")
.with_code("tool_not_executable")
.with_message("tool executable is not marked executable")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str()));
}
}
Ok(())
}
fn load_tool_manifest(tool: &str, path: &Path) -> Result<ToolManifest, SError> {
let raw = read_utf8_file(path, tool, "manifest")?;
let manifest: ToolManifestFile = serde_json::from_str(&raw).map_err(|err| {
SError::new("config")
.with_code("invalid_tool_manifest_json")
.with_message("failed to parse tool manifest")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str())
.with_string_field("cause", &err.to_string())
})?;
if manifest.protocol_version != TOOL_PROTOCOL_VERSION {
return Err(SError::new("config")
.with_code("unsupported_tool_protocol_version")
.with_message("tool manifest declares an unsupported protocol version")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str())
.with_string_field("protocol_version", &manifest.protocol_version.to_string()));
}
if manifest.description.trim().is_empty() {
return Err(SError::new("config")
.with_code("invalid_tool_manifest")
.with_message("tool manifest description must not be empty")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str()));
}
if !manifest.input_schema.is_object() {
return Err(SError::new("config")
.with_code("invalid_tool_manifest")
.with_message("tool manifest input_schema must be a JSON object")
.with_string_field("tool", tool)
.with_string_field("path", path.as_str()));
}
Ok(ToolManifest {
protocol_version: manifest.protocol_version,
description: manifest.description,
input_schema: manifest.input_schema,
})
}
fn builtin_tool_manifest_is_optional(tool: &str) -> bool {
matches!(tool, "bash" | "edit")
}
fn builtin_tool_executable_is_optional(tool: &str) -> bool {
matches!(tool, "bash")
}
fn resolve_skills_dirs(root: &Path) -> Vec<Path<'static>> {
match std::env::var("SID_SKILLS_PATH") {
Ok(path) if !path.is_empty() => path
.split(':')
.filter(|component| !component.is_empty())
.map(|component| Path::new(component).into_owned())
.collect(),
_ => vec![root.join(SKILLS_DIR).into_owned()],
}
}
fn load_skills(dirs: &[Path<'static>]) -> Result<BTreeMap<String, SkillConfig>, SError> {
let mut skills = BTreeMap::new();
for dir in dirs {
if !std::path::Path::new(dir.as_str()).is_dir() {
continue;
}
let entries = fs::read_dir(dir.as_str()).map_err(|err| {
SError::new("config")
.with_code("io_error")
.with_message("failed to read skills directory")
.with_string_field("path", dir.as_str())
.with_string_field("cause", &err.to_string())
})?;
for entry in entries {
let entry = entry.map_err(|err| {
SError::new("config")
.with_code("io_error")
.with_message("failed to read skills directory entry")
.with_string_field("path", dir.as_str())
.with_string_field("cause", &err.to_string())
})?;
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let skill_file = entry_path.join(SKILL_FILE);
if !skill_file.is_file() {
continue;
}
let dir_name = entry.file_name();
let skill_name = dir_name.to_string_lossy();
if skill_name.is_empty() {
continue;
}
if skills.contains_key(skill_name.as_ref()) {
continue;
}
let path = Path::try_from(skill_file)
.map_err(|err| {
SError::new("config")
.with_code("invalid_skill_path")
.with_message("skill path is not valid UTF-8")
.with_string_field("cause", &format!("{err:?}"))
})?
.into_owned();
let content = read_utf8_file(&path, &skill_name, "skill")?;
skills.insert(
skill_name.to_string(),
SkillConfig {
id: skill_name.to_string(),
path,
content,
},
);
}
}
Ok(skills)
}
fn resolve_agent_model_selection(
models_rc_conf: Option<&RcConf>,
agent_provider: &impl VariableProvider,
agent: &str,
) -> Result<Option<String>, SError> {
let Some(models_rc_conf) = models_rc_conf else {
return Ok(None);
};
let Some(selector) = lookup_expanded(agent_provider, agent, "MODEL")? else {
return Ok(None);
};
let selector = selector.trim();
let Some(model) = resolve_existing_model_service(models_rc_conf, selector) else {
return Ok(None);
};
let enabled = models_rc_conf.service_switch(&model);
if enabled != SwitchPosition::Yes {
return Err(SError::new("config")
.with_code("disabled_model_selected")
.with_message("agent MODEL names a models.conf service that is not enabled")
.with_string_field("agent", agent)
.with_string_field("model", &model)
.with_string_field("enabled", &format!("{enabled:?}")));
}
let provider = model_variable_provider(models_rc_conf, &model)?;
let concrete = lookup_expanded(&provider, &model, "MODEL")?;
if concrete
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
{
Ok(Some(model))
} else {
Err(SError::new("config")
.with_code("abstract_model_selected")
.with_message("agent MODEL names a models.conf service without a concrete MODEL")
.with_string_field("agent", agent)
.with_string_field("model", &model))
}
}
fn resolve_existing_model_service(models_rc_conf: &RcConf, selector: &str) -> Option<String> {
if selector.is_empty() {
return None;
}
let var_name = var_name_from_service(selector);
let mut candidates = vec![selector.to_string()];
if var_name != selector {
candidates.push(var_name);
}
candidates
.iter()
.find(|candidate| model_service_has_alias(models_rc_conf, candidate))
.cloned()
.or_else(|| {
candidates
.into_iter()
.find(|candidate| model_service_has_direct_field(models_rc_conf, candidate))
})
}
fn model_service_has_alias(models_rc_conf: &RcConf, service: &str) -> bool {
models_rc_conf.alias_lookup_order(service).0.len() > 1
}
fn model_service_has_direct_field(models_rc_conf: &RcConf, service: &str) -> bool {
const MODEL_FIELDS: &[&str] = &[
"ENABLED",
"ALIASES",
"INHERIT",
"API_KEY",
"BASE_URL",
"MODEL",
"MAX_TOKENS",
"TEMPERATURE",
"TOP_P",
"TOP_K",
"STOP_SEQUENCES",
"THINKING",
"USE_COLOR",
"NO_COLOR",
"SESSION_SPEND",
"CACHING_ENABLED",
];
let prefix = var_prefix_from_service(service);
MODEL_FIELDS
.iter()
.any(|field| models_rc_conf.lookup(&format!("{prefix}{field}")).is_some())
}
fn model_variable_provider<'a>(
models_rc_conf: &'a RcConf,
model: &str,
) -> Result<impl VariableProvider + 'a, SError> {
models_rc_conf.variable_provider_for(model).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to derive model config from rc_conf")
.with_string_field("model", model)
.with_string_field("cause", &format!("{err:?}"))
})
}
#[derive(Clone, Copy)]
struct ChatConfigOverrideOptions {
model: bool,
system: bool,
}
fn apply_model_chat_config_overrides(
chat_config: &mut ChatConfig,
models_rc_conf: &RcConf,
model: &str,
) -> Result<(), SError> {
let provider = model_variable_provider(models_rc_conf, model)?;
apply_chat_config_overrides(
chat_config,
&provider,
model,
ChatConfigOverrideOptions {
model: true,
system: false,
},
)
}
fn apply_agent_chat_config_overrides(
chat_config: &mut ChatConfig,
provider: &impl VariableProvider,
agent: &str,
model_selected: bool,
) -> Result<(), SError> {
apply_chat_config_overrides(
chat_config,
provider,
agent,
ChatConfigOverrideOptions {
model: !model_selected,
system: true,
},
)
}
fn apply_chat_config_overrides(
chat_config: &mut ChatConfig,
provider: &impl VariableProvider,
scope: &str,
options: ChatConfigOverrideOptions,
) -> Result<(), SError> {
if options.model
&& let Some(model) = lookup_expanded(provider, scope, "MODEL")?
{
let model = model
.parse()
.unwrap_or_else(|_| Model::Custom(model.clone()));
chat_config.set_model(model);
}
if options.system
&& let Some(system_prompt) = lookup_expanded(provider, scope, "SYSTEM")?
{
chat_config.set_system_prompt(Some(system_prompt));
}
if let Some(max_tokens) = lookup_expanded(provider, scope, "MAX_TOKENS")? {
chat_config.set_max_tokens(parse_u32_field(scope, "MAX_TOKENS", &max_tokens)?);
}
if let Some(temperature) = lookup_expanded(provider, scope, "TEMPERATURE")? {
chat_config.set_temperature(Some(parse_unit_interval_field(
scope,
"TEMPERATURE",
&temperature,
)?));
}
if let Some(top_p) = lookup_expanded(provider, scope, "TOP_P")? {
chat_config.set_top_p(Some(parse_unit_interval_field(scope, "TOP_P", &top_p)?));
}
if let Some(top_k) = lookup_expanded(provider, scope, "TOP_K")? {
chat_config.set_top_k(Some(parse_u32_field(scope, "TOP_K", &top_k)?));
}
if let Some(stop_sequences) = lookup_expanded(provider, scope, "STOP_SEQUENCES")? {
let stop_sequences = shvar::split(&stop_sequences).map_err(|err| {
invalid_config_field(scope, "STOP_SEQUENCES", &stop_sequences, format!("{err:?}"))
})?;
if stop_sequences.is_empty() {
chat_config.template.stop_sequences = None;
} else {
chat_config.template.stop_sequences = Some(stop_sequences);
}
}
if let Some(thinking) = lookup_expanded(provider, scope, "THINKING")? {
chat_config.template.thinking = parse_thinking_budget(scope, "THINKING", &thinking)?;
}
if let Some(use_color) = lookup_expanded(provider, scope, "USE_COLOR")? {
chat_config.use_color = parse_bool_field(scope, "USE_COLOR", &use_color)?;
}
if let Some(no_color) = lookup_expanded(provider, scope, "NO_COLOR")? {
chat_config.use_color = !parse_bool_field(scope, "NO_COLOR", &no_color)?;
}
if let Some(session_spend) = lookup_expanded(provider, scope, "SESSION_SPEND")? {
chat_config.set_session_spend(Some(parse_f64_field(
scope,
"SESSION_SPEND",
&session_spend,
)?));
}
if let Some(caching_enabled) = lookup_expanded(provider, scope, "CACHING_ENABLED")? {
chat_config.caching_enabled = parse_bool_field(scope, "CACHING_ENABLED", &caching_enabled)?;
}
Ok(())
}
fn resolve_default_agent(
agents_rc_conf: &RcConf,
agent_names: &[String],
) -> Result<Option<String>, SError> {
let Some(value) = agents_rc_conf.lookup("DEFAULT_AGENT") else {
return Ok(None);
};
let value = value.trim().to_string();
if value.is_empty() {
return Ok(None);
}
if !agent_names.contains(&value) {
return Err(SError::new("config")
.with_code("invalid_default_agent")
.with_message("DEFAULT_AGENT names an undefined agent")
.with_string_field("default_agent", &value));
}
Ok(Some(value))
}
fn collect_names_from_rc_conf(rc_conf: &RcConf) -> Result<Vec<String>, SError> {
Ok(rc_conf
.list()
.map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to list configured names")
.with_string_field("cause", &format!("{err:?}"))
})?
.collect())
}
fn parse_rc_conf(path: &Path) -> Result<RcConf, SError> {
RcConf::parse(path.as_str()).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to parse rc_conf file")
.with_string_field("path", path.as_str())
.with_string_field("cause", &format!("{err:?}"))
})
}
fn require_file(path: &Path, label: &str) -> Result<(), SError> {
require_file_with_code(
path,
"missing_config_file",
"required config file does not exist",
"file",
label,
"path",
)
}
fn require_file_with_code(
path: &Path,
code: &str,
message: &str,
name_field: &str,
name: &str,
path_field: &str,
) -> Result<(), SError> {
match path.is_file() {
Ok(true) => Ok(()),
Ok(false) => Err(SError::new("config")
.with_code(code)
.with_message(message)
.with_string_field(name_field, name)
.with_string_field(path_field, path.as_str())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(SError::new("config")
.with_code(code)
.with_message(message)
.with_string_field(name_field, name)
.with_string_field(path_field, path.as_str())),
Err(err) => Err(SError::new("config")
.with_code("io_error")
.with_message("failed to inspect config file")
.with_string_field(name_field, name)
.with_string_field(path_field, path.as_str())
.with_string_field("cause", &err.to_string())),
}
}
fn read_utf8_file(path: &Path, name: &str, field: &str) -> Result<String, SError> {
fs::read_to_string(path.as_str()).map_err(|err| {
SError::new("config")
.with_code("io_error")
.with_message("failed to read config file")
.with_string_field("name", name)
.with_string_field("field", field)
.with_string_field("path", path.as_str())
.with_string_field("cause", &err.to_string())
})
}
fn load_named_prompts(
config_root: &Path,
rc_conf: &RcConf,
service: &str,
known_prompts: &[KnownPromptField],
scope_kind: &str,
) -> Result<BTreeMap<String, PromptConfig>, SError> {
let provider = rc_conf.variable_provider_for(service).map_err(|err| {
SError::new("config")
.with_code("rc_conf_error")
.with_message("failed to derive config from rc_conf")
.with_string_field("scope", service)
.with_string_field("kind", scope_kind)
.with_string_field("cause", &format!("{err:?}"))
})?;
let mut prompts = BTreeMap::new();
for (prompt, value) in collect_prompt_fields(&provider, service, known_prompts)? {
let paths = resolve_prompt_paths(config_root, service, prompt.field, &value)?;
let markdown = read_markdown_files(&paths, service, prompt.field)?;
prompts.insert(
prompt.id.to_string(),
PromptConfig {
id: prompt.id.to_string(),
paths,
markdown,
},
);
}
Ok(prompts)
}
fn resolve_agent_prompt_path(agents_dir: &Path, rc_conf: &RcConf, agent: &str) -> Path<'static> {
for candidate in rc_conf.alias_lookup_order(agent).0 {
let path = agents_dir.join(format!("{candidate}.md")).into_owned();
if path_is_file(&path) {
return path;
}
}
agents_dir.join(format!("{agent}.md")).into_owned()
}
fn collect_prompt_fields(
provider: &impl VariableProvider,
scope: &str,
known_prompts: &[KnownPromptField],
) -> Result<Vec<(KnownPromptField, String)>, SError> {
let mut prompts = Vec::new();
for prompt in known_prompts {
if let Some(value) = lookup_expanded(provider, scope, prompt.field)? {
prompts.push((*prompt, value));
}
}
Ok(prompts)
}
fn resolve_prompt_paths(
config_root: &Path,
scope: &str,
field: &str,
value: &str,
) -> Result<Vec<Path<'static>>, SError> {
let mut paths = Vec::new();
for component in value.split(':') {
let component = component.trim();
if component.is_empty() {
continue;
}
let path = resolve_prompt_path_component(config_root, component);
let path = Path::try_from(path).map_err(|err| {
invalid_config_field(
scope,
field,
value,
format!("prompt path {component:?} is not valid UTF-8: {err:?}"),
)
})?;
if !path_is_file(&path) {
return Err(SError::new("config")
.with_code("missing_prompt_file")
.with_message("configured prompt markdown file does not exist")
.with_string_field("scope", scope)
.with_string_field("field", field)
.with_string_field("path", path.as_str()));
}
paths.push(path.into_owned());
}
if paths.is_empty() {
return Err(invalid_config_field(
scope,
field,
value,
"expected one or more colon-separated markdown file paths",
));
}
Ok(paths)
}
fn path_exists(path: &Path) -> bool {
path.exists().unwrap_or(false)
}
fn path_is_file(path: &Path) -> bool {
path.is_file().unwrap_or(false)
}
fn resolve_prompt_path_component(config_root: &Path, component: &str) -> std::path::PathBuf {
if let Some(rest) = component.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return std::path::PathBuf::from(home).join(rest);
}
let path = std::path::PathBuf::from(component);
if path.is_absolute() {
path
} else {
std::path::PathBuf::from(config_root.as_str()).join(path)
}
}
fn read_markdown_files(
paths: &[Path<'static>],
scope: &str,
field: &str,
) -> Result<String, SError> {
let mut output = String::new();
for path in paths {
let content = read_utf8_file(path, scope, field)?;
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(content.trim_end());
}
if !output.is_empty() {
output.push('\n');
}
Ok(output)
}
fn lookup_expanded(
provider: &impl VariableProvider,
scope: &str,
key: &str,
) -> Result<Option<String>, SError> {
let Some(value) = provider.lookup(key) else {
return Ok(None);
};
let expanded = expand_config_value(provider, scope, key, &value)?;
Ok(Some(expanded))
}
fn lookup_split_field(
provider: &impl VariableProvider,
scope: &str,
key: &str,
) -> Result<Vec<String>, SError> {
let Some(value) = lookup_expanded(provider, scope, key)? else {
return Ok(vec![]);
};
shvar::split(&value).map_err(|err| invalid_config_field(scope, key, &value, format!("{err:?}")))
}
fn lookup_nonempty_field(
provider: &impl VariableProvider,
scope: &str,
key: &str,
) -> Result<Option<String>, SError> {
Ok(lookup_expanded(provider, scope, key)?.and_then(nonempty))
}
fn nonempty(value: String) -> Option<String> {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
fn lookup_ps1_field(
provider: &impl VariableProvider,
scope: &str,
) -> Result<Option<String>, SError> {
let Some(value) = provider.lookup("PS1") else {
return Ok(None);
};
expand_preserving_whitespace(provider, scope, "PS1", &value).map(Some)
}
fn lookup_bool_field(
provider: &impl VariableProvider,
scope: &str,
key: &str,
default: bool,
) -> Result<bool, SError> {
let Some(value) = lookup_expanded(provider, scope, key)? else {
return Ok(default);
};
parse_bool_field(scope, key, &value)
}
fn parse_u32_field(scope: &str, field: &str, value: &str) -> Result<u32, SError> {
value
.trim()
.parse::<u32>()
.map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
}
fn parse_u64_field(scope: &str, field: &str, value: &str) -> Result<u64, SError> {
value
.trim()
.parse::<u64>()
.map_err(|err| invalid_config_field(scope, field, value, err.to_string()))
}
fn parse_f64_field(scope: &str, field: &str, value: &str) -> Result<f64, SError> {
let parsed = value
.trim()
.parse::<f64>()
.map_err(|err| invalid_config_field(scope, field, value, err.to_string()))?;
if parsed.is_finite() && parsed > 0.0 {
Ok(parsed)
} else {
Err(invalid_config_field(
scope,
field,
value,
"expected a positive dollar amount",
))
}
}
fn parse_unit_interval_field(scope: &str, field: &str, value: &str) -> Result<f32, SError> {
let parsed = value
.trim()
.parse::<f32>()
.map_err(|err| invalid_config_field(scope, field, value, err.to_string()))?;
if parsed.is_finite() && (0.0..=1.0).contains(&parsed) {
Ok(parsed)
} else {
Err(invalid_config_field(
scope,
field,
value,
"expected a finite value between 0.0 and 1.0",
))
}
}
fn parse_bool_field(scope: &str, field: &str, value: &str) -> Result<bool, SError> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "on" | "true" | "yes" | "enable" | "enabled" => Ok(true),
"0" | "off" | "false" | "no" | "disable" | "disabled" => Ok(false),
_ => Err(invalid_config_field(
scope,
field,
value,
"expected one of yes/no, true/false, on/off, or 1/0",
)),
}
}
fn parse_thinking_budget(
scope: &str,
field: &str,
value: &str,
) -> Result<Option<ThinkingConfig>, SError> {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "false" | "no" | "disable" | "disabled" => Ok(None),
"on" | "true" | "yes" | "enable" | "enabled" => {
Ok(Some(ThinkingConfig::enabled(DEFAULT_THINKING_BUDGET)))
}
"adaptive" => Ok(Some(ThinkingConfig::adaptive_summarized())),
_ => parse_u32_field(scope, field, value).map(|v| Some(ThinkingConfig::enabled(v))),
}
}
fn invalid_config_field(
scope: &str,
field: &str,
value: &str,
reason: impl Into<String>,
) -> SError {
let reason = reason.into();
SError::new("config")
.with_code("invalid_config_field")
.with_message("failed to derive config from rc_conf")
.with_string_field("scope", scope)
.with_string_field("field", field)
.with_string_field("value", value)
.with_string_field("reason", &reason)
}
fn expand_preserving_whitespace(
provider: &impl VariableProvider,
scope: &str,
field: &str,
value: &str,
) -> Result<String, SError> {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('"');
for ch in value.chars() {
match ch {
'\\' | '"' | '`' => {
quoted.push('\\');
quoted.push(ch);
}
_ => quoted.push(ch),
}
}
quoted.push('"');
let expanded = shvar::expand_recursive(provider, "ed)
.map_err(|err| invalid_config_field(scope, field, value, format!("{err:?}")))?;
let split = shvar::split(&expanded)
.map_err(|err| invalid_config_field(scope, field, value, format!("{err:?}")))?;
if split.len() == 1 {
Ok(split.into_iter().next().unwrap_or_default())
} else {
Err(invalid_config_field(
scope,
field,
value,
format!(
"expected one shell word after expansion, got {}",
split.len()
),
))
}
}
fn expand_config_value(
provider: &impl VariableProvider,
scope: &str,
field: &str,
value: &str,
) -> Result<String, SError> {
let mut current = value.to_string();
for _ in 0..128 {
let next = expand_config_value_once(provider, scope, field, ¤t)?;
if next == current {
return Ok(next);
}
current = next;
}
Err(invalid_config_field(
scope,
field,
value,
"variable expansion exceeded recursion limit",
))
}
fn expand_config_value_once(
provider: &impl VariableProvider,
scope: &str,
field: &str,
value: &str,
) -> Result<String, SError> {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '$' {
output.push(ch);
continue;
}
match chars.peek().copied() {
Some('$') => {
output.push('$');
chars.next();
}
Some('{') => {
chars.next();
let ident = parse_braced_identifier(&mut chars, scope, field, value)?;
output.push_str(&provider.lookup(&ident).unwrap_or_default());
}
Some(next) if is_identifier_start(next) => {
let ident = parse_identifier(&mut chars);
output.push_str(&provider.lookup(&ident).unwrap_or_default());
}
_ => {
output.push('$');
}
}
}
Ok(output)
}
fn parse_braced_identifier(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
scope: &str,
field: &str,
value: &str,
) -> Result<String, SError> {
let Some(first) = chars.next() else {
return Err(invalid_config_field(
scope,
field,
value,
"unterminated variable expansion",
));
};
if !is_identifier_start(first) {
return Err(invalid_config_field(
scope,
field,
value,
"invalid variable name in expansion",
));
}
let mut ident = String::from(first);
loop {
match chars.next() {
Some('}') => return Ok(ident),
Some(ch) if is_identifier_continue(ch) => ident.push(ch),
Some(_) => {
return Err(invalid_config_field(
scope,
field,
value,
"invalid variable name in expansion",
));
}
None => {
return Err(invalid_config_field(
scope,
field,
value,
"unterminated variable expansion",
));
}
}
}
}
fn parse_identifier(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut ident = String::new();
while let Some(ch) = chars.peek().copied() {
if !is_identifier_continue(ch) {
break;
}
ident.push(ch);
chars.next();
}
ident
}
fn is_identifier_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_identifier_continue(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
#[cfg(test)]
mod tests {
use std::fs;
use claudius::KnownModel;
use super::*;
use crate::test_support::{
unique_temp_dir, write_default_tool_manifest, write_tool_manifest,
write_tool_manifest_with_schema, write_tool_script,
};
#[test]
fn rewrite_relative_file_api_key_rewrites_relative_paths() {
let root = Path::from("/config/dir");
assert_eq!(
rewrite_relative_file_api_key(&root, "file://secrets/key".to_string()),
"file:///config/dir/secrets/key".to_string()
);
}
#[test]
fn rewrite_relative_file_api_key_preserves_absolute_paths() {
let root = Path::from("/config/dir");
assert_eq!(
rewrite_relative_file_api_key(&root, "file:///etc/secrets/key".to_string()),
"file:///etc/secrets/key".to_string()
);
}
#[test]
fn rewrite_relative_file_api_key_ignores_non_file_keys() {
let root = Path::from("/config/dir");
assert_eq!(
rewrite_relative_file_api_key(&root, "sk-ant-literal".to_string()),
"sk-ant-literal".to_string()
);
}
#[test]
fn load_config_from_readme_style_files() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
API_KEY="file://secrets/default.key"
BASE_URL="https://default.example.test/anthropic"
ROLE='principal engineer'
build_ENABLED="YES"
plan_ENABLED="MANUAL"
evil_ENABLED="NO"
build_API_KEY="file://secrets/build.key"
build_BASE_URL="https://build.example.test/anthropic"
build_NAME="Let's go ${ROLE}"
build_DESC="buildit"
build_TOOLS='format bash'
build_SKILLS='* "rust docs"'
plan_MODEL=claude-sonnet-4-5
plan_SYSTEM="You are ${ROLE}"
plan_MAX_TOKENS=8192
plan_TEMPERATURE=0.7
plan_TOP_P=0.9
plan_TOP_K=40
plan_STOP_SEQUENCES='END "two words"'
plan_THINKING=on
plan_NO_COLOR=yes
plan_SESSION_SPEND=5.00
plan_CACHING_ENABLED=off
"#,
)
.unwrap();
fs::write(
root.join("tools.conf").as_str(),
r#"
fmt_ENABLED="YES"
bash_ENABLED="YES"
format_INHERIT="YES"
format_ALIASES="fmt"
"#,
)
.unwrap();
write_tool_contract(&root, "fmt", "Format files in the workspace.");
write_tool_contract(&root, "bash", "Run a shell command.");
fs::write(
root.join("agents/build.md").as_str(),
"# Build\n\nYou are an expert builder.\n",
)
.unwrap();
fs::write(
root.join("agents/plan.md").as_str(),
"# Plan\n\nYou are an expert planner.\n",
)
.unwrap();
let config = Config::load(&root).unwrap();
assert_eq!(config.agents.len(), 4);
assert_eq!(config.tools.len(), 3);
let build = config.agents.get("build").unwrap();
assert_eq!(
build.anthropic,
AnthropicConfig {
api_key: Some(format!("file://{}/secrets/build.key", root.as_str())),
base_url: Some("https://build.example.test/anthropic".to_string()),
}
);
assert_eq!(build.enabled, SwitchPosition::Yes);
assert_eq!(
build.display_name.as_deref(),
Some("Let's go principal engineer")
);
assert_eq!(build.description.as_deref(), Some("buildit"));
assert_eq!(build.ps1, None);
assert_eq!(build.tools, vec!["format".to_string(), "bash".to_string()]);
assert_eq!(build.skills, vec!["*".to_string(), "rust docs".to_string()]);
assert_eq!(
build.prompt_markdown.as_deref(),
Some("# Build\n\nYou are an expert builder.\n")
);
assert_eq!(
build.chat_config.system_prompt_text(),
Some("# Build\n\nYou are an expert builder.\n")
);
let plan = config.agents.get("plan").unwrap();
assert_eq!(
plan.anthropic,
AnthropicConfig {
api_key: Some(format!("file://{}/secrets/default.key", root.as_str())),
base_url: Some("https://default.example.test/anthropic".to_string()),
}
);
assert_eq!(plan.enabled, SwitchPosition::Manual);
assert_eq!(
plan.chat_config.model(),
Model::Known(KnownModel::ClaudeSonnet45)
);
assert_eq!(
plan.chat_config.system_prompt_text(),
Some("You are principal engineer")
);
assert_eq!(plan.chat_config.max_tokens(), 8192);
assert_eq!(plan.chat_config.template.temperature, Some(0.7));
assert_eq!(plan.chat_config.template.top_p, Some(0.9));
assert_eq!(plan.chat_config.template.top_k, Some(40));
assert_eq!(
plan.chat_config.stop_sequences(),
&["END".to_string(), "two words".to_string()]
);
assert_eq!(
plan.chat_config.thinking_budget(),
Some(DEFAULT_THINKING_BUDGET)
);
assert!(!plan.chat_config.use_color);
assert!(plan.chat_config.session_spend.is_some());
assert!(!plan.chat_config.caching_enabled);
let evil = config.agents.get("evil").unwrap();
assert_eq!(evil.enabled, SwitchPosition::No);
assert!(evil.prompt_markdown.is_none());
assert_eq!(evil.prompt_path, root.join("agents/evil.md"));
let plan_caching = config.agents.get("plan-caching").unwrap();
assert_eq!(plan_caching.enabled, SwitchPosition::No);
assert!(plan_caching.prompt_markdown.is_none());
assert_eq!(
plan_caching.prompt_path,
root.join("agents/plan-caching.md")
);
let fmt = config.tools.get("fmt").unwrap();
assert_eq!(fmt.enabled, SwitchPosition::Yes);
assert!(!fmt.confirm_preview);
assert_eq!(fmt.executable_path, Some(root.join("tools/fmt")));
assert_eq!(fmt.manifest_path, root.join("tools/fmt.json"));
let fmt_manifest = fmt.manifest.as_ref().unwrap();
assert_eq!(fmt_manifest.protocol_version, TOOL_PROTOCOL_VERSION);
assert_eq!(fmt_manifest.description, "Format files in the workspace.");
let format = config.tools.get("format").unwrap();
assert_eq!(format.enabled, SwitchPosition::Yes);
assert_eq!(format.executable_path, Some(root.join("tools/fmt")));
assert_eq!(format.manifest_path, root.join("tools/fmt.json"));
assert_eq!(
format.manifest.as_ref().unwrap().description,
"Format files in the workspace."
);
let bash = config.tools.get("bash").unwrap();
assert_eq!(bash.enabled, SwitchPosition::Yes);
assert!(!bash.confirm_preview);
assert!(bash.executable_path.is_none());
assert_eq!(bash.timeout, Some(DEFAULT_TOOL_TIMEOUT));
}
#[test]
fn selected_model_defaults_are_absorbed_before_agent_overrides() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
ROLE='principal engineer'
build_ENABLED=YES
build_MODEL='glm-5p2'
build_API_KEY='file://secrets/agent.key'
build_MAX_TOKENS=64000
build_TOP_P=0.7
build_SYSTEM='You are ${ROLE}'
literal_ENABLED=YES
literal_MODEL='glm-unknown'
"#,
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
r#"
fireworks_ENABLED=NO
fireworks_API_KEY='file://secrets/fireworks.key'
fireworks_BASE_URL='https://api.fireworks.ai/inference'
fireworks_TEMPERATURE=0.2
fireworks_SYSTEM='model system should not apply'
glm_5p2_ENABLED=YES
glm_5p2_INHERIT=YES
glm_5p2_ALIASES=fireworks
glm_5p2_MODEL='accounts/fireworks/models/glm-5p2'
glm_5p2_MAX_TOKENS=128000
glm_5p2_TOP_P=0.8
glm_5p2_STOP_SEQUENCES='DONE "two words"'
glm_5p2_THINKING=on
glm_5p2_CACHING_ENABLED=off
"#,
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let config = Config::load(&root).unwrap();
let build = config.agents.get("build").unwrap();
assert_eq!(
build.anthropic,
AnthropicConfig {
api_key: Some(format!("file://{}/secrets/agent.key", root.as_str())),
base_url: Some("https://api.fireworks.ai/inference".to_string()),
}
);
assert_eq!(
build.chat_config.model(),
Model::Custom("accounts/fireworks/models/glm-5p2".to_string())
);
assert_eq!(
build.chat_config.system_prompt_text(),
Some("You are principal engineer")
);
assert_eq!(build.chat_config.max_tokens(), 64000);
assert_eq!(build.chat_config.template.temperature, Some(0.2));
assert_eq!(build.chat_config.template.top_p, Some(0.7));
assert_eq!(
build.chat_config.stop_sequences(),
&["DONE".to_string(), "two words".to_string()]
);
assert_eq!(
build.chat_config.thinking_budget(),
Some(DEFAULT_THINKING_BUDGET)
);
assert!(!build.chat_config.caching_enabled);
let literal = config.agents.get("literal").unwrap();
assert_eq!(
literal.chat_config.model(),
Model::Custom("glm-unknown".to_string())
);
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn empty_agent_client_fields_clear_selected_model_defaults() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
build_ENABLED=YES
build_MODEL='glm-5p2'
build_API_KEY=''
build_BASE_URL=''
"#,
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
r#"
glm_5p2_ENABLED=YES
glm_5p2_MODEL='accounts/fireworks/models/glm-5p2'
glm_5p2_API_KEY='file://secrets/fireworks.key'
glm_5p2_BASE_URL='https://api.fireworks.ai/inference'
"#,
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let config = Config::load(&root).unwrap();
let build = config.agents.get("build").unwrap();
assert_eq!(build.anthropic, AnthropicConfig::default());
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn disabled_model_selection_is_rejected_without_literal_fallback() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"judge_ENABLED=YES\njudge_MODEL='claude-opus-4-8'\n",
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
"claude_opus_4_8_ENABLED=NO\nclaude_opus_4_8_MODEL='claude-opus-4-8'\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("disabled_model_selected"));
assert!(err.contains("claude-opus-4-8"));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn manual_model_selection_is_rejected() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"judge_ENABLED=YES\njudge_MODEL='manual-model'\n",
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
"manual_model_ENABLED=MANUAL\nmanual_model_MODEL='provider/manual-model'\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("disabled_model_selected"));
assert!(err.contains("Manual"));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn abstract_model_selection_is_rejected() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_MODEL=fireworks\n",
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
"fireworks_ENABLED=YES\nfireworks_BASE_URL='https://api.fireworks.ai/inference'\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("abstract_model_selected"));
assert!(err.contains("fireworks"));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn unused_invalid_model_fields_are_not_validated() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_MODEL='literal-model'\n",
)
.unwrap();
fs::write(
root.join("models.conf").as_str(),
"bad_ENABLED=YES\nbad_MODEL='provider/bad'\nbad_TEMPERATURE=wat\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let config = Config::load(&root).unwrap();
let build = config.agents.get("build").unwrap();
assert_eq!(
build.chat_config.model(),
Model::Custom("literal-model".to_string())
);
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn named_prompt_sets_load_from_markdown_files() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::create_dir_all(root.join("prompts").as_str()).unwrap();
fs::create_dir_all(root.join("tool-prompts").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
build_ENABLED="YES"
build_PROMPT='agents/base.md:agents/build.md'
build_PROMPT_COMPACTION='prompts/common.md:prompts/compact.md'
"#,
)
.unwrap();
fs::write(
root.join("tools.conf").as_str(),
r#"
fmt_ENABLED="YES"
fmt_PROMPT='tool-prompts/base.md:tool-prompts/review.md'
"#,
)
.unwrap();
fs::write(root.join("agents/base.md").as_str(), "# Base\n").unwrap();
fs::write(
root.join("agents/build.md").as_str(),
"Use the build plan.\n",
)
.unwrap();
fs::write(
root.join("prompts/common.md").as_str(),
"Summarize the session.\n",
)
.unwrap();
fs::write(
root.join("prompts/compact.md").as_str(),
"Write only the handoff.\n",
)
.unwrap();
fs::write(
root.join("tool-prompts/base.md").as_str(),
"Review output.\n",
)
.unwrap();
fs::write(
root.join("tool-prompts/review.md").as_str(),
"Focus on bugs.\n",
)
.unwrap();
write_tool_contract(&root, "fmt", "Format files.");
let config = Config::load(&root).unwrap();
let build = config.agents.get("build").unwrap();
assert_eq!(
build.prompt_paths,
vec![root.join("agents/base.md"), root.join("agents/build.md")]
);
assert_eq!(
build.prompt_markdown.as_deref(),
Some("# Base\n\nUse the build plan.\n")
);
let compaction = build.prompts.get("COMPACTION").unwrap();
assert_eq!(
compaction.paths,
vec![
root.join("prompts/common.md"),
root.join("prompts/compact.md"),
]
);
assert_eq!(
compaction.markdown,
"Summarize the session.\n\nWrite only the handoff.\n"
);
let fmt = config.tools.get("fmt").unwrap();
let tool_prompt = fmt.prompts.get(SYSTEM_PROMPT_ID).unwrap();
assert_eq!(
tool_prompt.paths,
vec![
root.join("tool-prompts/base.md"),
root.join("tool-prompts/review.md"),
]
);
assert_eq!(tool_prompt.markdown, "Review output.\n\nFocus on bugs.\n");
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn unknown_prompt_keys_are_ignored() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
build_ENABLED="YES"
build_PROMPT_REVIEW='agents/missing.md'
"#,
)
.unwrap();
fs::write(
root.join("tools.conf").as_str(),
r#"
fmt_ENABLED="YES"
fmt_PROMPT_REVIEW='tool-prompts/missing.md'
"#,
)
.unwrap();
write_tool_contract(&root, "fmt", "Format files.");
let config = Config::load(&root).unwrap();
assert!(config.agents.get("build").unwrap().prompts.is_empty());
assert!(config.tools.get("fmt").unwrap().prompts.is_empty());
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn missing_named_prompt_file_is_reported() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_PROMPT='agents/missing.md'\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("missing_prompt_file"));
assert!(err.contains("agents/missing.md"));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn tool_confirm_preview_defaults_and_parses_bool() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_TOOLS='fmt plain'\n",
)
.unwrap();
fs::write(
root.join("tools.conf").as_str(),
"fmt_ENABLED=YES\nfmt_CONFIRM=YES\nplain_ENABLED=YES\n",
)
.unwrap();
write_tool_contract(&root, "fmt", "Format files.");
write_tool_contract(&root, "plain", "Plain tool.");
let config = Config::load(&root).unwrap();
assert!(config.tools.get("fmt").unwrap().confirm_preview);
assert!(!config.tools.get("plain").unwrap().confirm_preview);
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn invalid_tool_confirm_preview_bool_is_an_error() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_TOOLS='fmt'\n",
)
.unwrap();
fs::write(
root.join("tools.conf").as_str(),
"fmt_ENABLED=YES\nfmt_CONFIRM=maybe\n",
)
.unwrap();
write_tool_contract(&root, "fmt", "Format files.");
let err = Config::load(&root)
.expect_err("invalid CONFIRM value should fail")
.to_string();
assert!(err.contains("CONFIRM"));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn missing_top_level_config_file_is_an_error() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "build_ENABLED=YES\n").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("missing_config_file"));
assert!(err.contains("tools.conf"));
}
#[test]
fn invalid_agent_field_is_reported() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
"build_ENABLED=YES\nbuild_TOP_P=wat\n",
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("invalid_config_field"));
assert!(err.contains("TOP_P"));
assert!(err.contains("wat"));
}
#[test]
fn ps1_expands_with_shell_semantics_and_preserves_trailing_space() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::write(
root.join("agents.conf").as_str(),
r#"
ROLE='principal engineer'
build_ENABLED=YES
build_PS1='${NAME:-${ROLE}}> '
"#,
)
.unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let config = Config::load(&root).unwrap();
let build = config.agents.get("build").unwrap();
assert_eq!(build.ps1.as_deref(), Some("principal engineer> "));
fs::remove_dir_all(root.as_str()).unwrap();
}
#[test]
fn missing_tool_executable_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("missing_tool_executable"));
assert!(err.contains("fmt"));
}
#[test]
fn missing_tool_manifest_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("missing_tool_manifest"));
assert!(err.contains("fmt"));
}
#[test]
fn missing_builtin_tool_manifest_is_allowed_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(
root.join("tools.conf").as_str(),
"bash_ENABLED=YES\nedit_ENABLED=YES\n",
)
.unwrap();
write_tool_script(&root, "edit", "#!/bin/sh\nexit 0\n");
let config = Config::load(&root).unwrap();
let bash = config.tools.get("bash").unwrap();
assert!(bash.executable_path.is_none());
assert_eq!(bash.manifest_path, root.join("tools/bash.json"));
assert!(bash.manifest.is_none());
let edit = config.tools.get("edit").unwrap();
assert_eq!(edit.executable_path, Some(root.join("tools/edit")));
assert_eq!(edit.manifest_path, root.join("tools/edit.json"));
assert!(edit.manifest.is_none());
}
#[test]
fn missing_builtin_edit_executable_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(
root.join("tools.conf").as_str(),
"bash_ENABLED=YES\nedit_ENABLED=YES\n",
)
.unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("missing_tool_executable"));
assert!(err.contains("edit"));
}
#[test]
fn invalid_tool_manifest_json_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
fs::write(root.join("tools/fmt.json").as_str(), "{ not valid json").unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("invalid_tool_manifest_json"));
}
#[test]
fn unsupported_tool_protocol_version_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
write_tool_manifest(&root, "fmt", 2, "Format files.");
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("unsupported_tool_protocol_version"));
}
#[test]
fn invalid_tool_id_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
let long_name = "a".repeat(65);
fs::write(
root.join("tools.conf").as_str(),
format!("{long_name}_ENABLED=YES\n"),
)
.unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("invalid_tool_id"));
assert!(err.contains(&long_name));
}
#[cfg(unix)]
#[test]
fn tool_must_be_executable_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
fs::create_dir_all(root.join("tools").as_str()).unwrap();
let executable = root.join("tools/fmt").into_owned();
fs::write(executable.as_str(), "#!/bin/sh\nexit 0\n").unwrap();
write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, "Format files.");
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("tool_not_executable"));
assert!(err.contains("fmt"));
}
#[test]
fn empty_tool_description_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
write_tool_manifest(&root, "fmt", TOOL_PROTOCOL_VERSION, " ");
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("invalid_tool_manifest"));
assert!(err.contains("description"));
}
#[test]
fn non_object_input_schema_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
write_tool_script(&root, "fmt", "#!/bin/sh\nexit 0\n");
write_tool_manifest_with_schema(
&root,
"fmt",
TOOL_PROTOCOL_VERSION,
"Format files.",
serde_json::json!("not an object"),
);
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("invalid_tool_manifest"));
assert!(err.contains("input_schema"));
}
#[test]
fn undefined_tool_alias_target_is_reported_during_config_load() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(
root.join("tools.conf").as_str(),
"format_ALIASES=fmt\nformat_INHERIT=YES\n",
)
.unwrap();
let err = Config::load(&root).unwrap_err().to_string();
assert!(err.contains("unknown_tool"));
assert!(err.contains("fmt"));
}
#[test]
fn load_skills_from_skills_directory() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.join("agents").as_str()).unwrap();
fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
fs::create_dir_all(root.join("skills/empty-dir").as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
fs::write(
root.join("skills/rust/SKILL.md").as_str(),
"# Rust\n\nYou are a Rust expert.\n",
)
.unwrap();
fs::write(
root.join("skills/python/SKILL.md").as_str(),
"# Python\n\nYou know Python.\n",
)
.unwrap();
fs::write(root.join("skills/notes.txt").as_str(), "not a skill").unwrap();
let config = Config::load(&root).unwrap();
assert_eq!(config.skills.len(), 2);
let rust_skill = config.skills.get("rust").unwrap();
assert_eq!(rust_skill.id, "rust");
assert_eq!(rust_skill.content, "# Rust\n\nYou are a Rust expert.\n");
assert_eq!(rust_skill.path, root.join("skills/rust/SKILL.md"));
let python_skill = config.skills.get("python").unwrap();
assert_eq!(python_skill.id, "python");
assert_eq!(python_skill.content, "# Python\n\nYou know Python.\n");
}
#[test]
fn missing_skills_directory_produces_empty_skills() {
let root = unique_temp_dir("config");
fs::create_dir_all(root.as_str()).unwrap();
fs::write(root.join("agents.conf").as_str(), "").unwrap();
fs::write(root.join("tools.conf").as_str(), "").unwrap();
let config = Config::load(&root).unwrap();
assert!(config.skills.is_empty());
}
#[test]
fn load_skills_earlier_directory_wins() {
let dir_a = unique_temp_dir("skills-a");
let dir_b = unique_temp_dir("skills-b");
fs::create_dir_all(dir_a.join("rust").as_str()).unwrap();
fs::create_dir_all(dir_a.join("go").as_str()).unwrap();
fs::create_dir_all(dir_b.join("rust").as_str()).unwrap();
fs::create_dir_all(dir_b.join("python").as_str()).unwrap();
fs::write(dir_a.join("rust/SKILL.md").as_str(), "# Rust from A\n").unwrap();
fs::write(dir_a.join("go/SKILL.md").as_str(), "# Go from A\n").unwrap();
fs::write(
dir_b.join("rust/SKILL.md").as_str(),
"# Rust from B (should be shadowed)\n",
)
.unwrap();
fs::write(dir_b.join("python/SKILL.md").as_str(), "# Python from B\n").unwrap();
let dirs = vec![dir_a.clone(), dir_b.clone()];
let skills = load_skills(&dirs).unwrap();
assert_eq!(skills.len(), 3);
let rust_skill = skills.get("rust").unwrap();
assert_eq!(rust_skill.content, "# Rust from A\n");
assert_eq!(rust_skill.path, dir_a.join("rust/SKILL.md"));
assert_eq!(skills.get("go").unwrap().content, "# Go from A\n");
let python_skill = skills.get("python").unwrap();
assert_eq!(python_skill.content, "# Python from B\n");
assert_eq!(python_skill.path, dir_b.join("python/SKILL.md"));
}
#[test]
fn load_skills_skips_nonexistent_directories() {
let dir_exists = unique_temp_dir("skills-exists");
let dir_missing = unique_temp_dir("skills-missing");
fs::create_dir_all(dir_exists.join("rust").as_str()).unwrap();
fs::write(dir_exists.join("rust/SKILL.md").as_str(), "# Rust\n").unwrap();
let dirs = vec![dir_missing, dir_exists];
let skills = load_skills(&dirs).unwrap();
assert_eq!(skills.len(), 1);
assert!(skills.contains_key("rust"));
}
fn write_tool_contract(root: &Path, tool: &str, description: &str) {
write_tool_script(root, tool, "#!/bin/sh\nexit 0\n");
write_default_tool_manifest(root, tool, description);
}
}