use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::Mutex;
use theway_contract::subagent_settings::{
SubagentRunSettings, SubagentSettingsFile, subagent_settings_path_for_project,
};
use theway_core::multiagent::graph::types::DagNodeDef;
#[derive(Clone)]
pub struct SubagentSettingsStore {
path: PathBuf,
lock: Arc<tokio::sync::Mutex<()>>,
}
impl SubagentSettingsStore {
pub fn new(project_dir: &Path) -> Self {
let canonical =
std::fs::canonicalize(project_dir).unwrap_or_else(|_| project_dir.to_path_buf());
Self {
path: subagent_settings_path_for_project(&canonical.join(".pi")),
lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
async fn load_unlocked(&self) -> SubagentSettingsFile {
match tokio::fs::read_to_string(&self.path).await {
Ok(text) => match serde_json::from_str(&text) {
Ok(file) => file,
Err(e) => {
tracing::warn!(path = %self.path.display(), "subagent settings parse: {e}");
SubagentSettingsFile::default()
}
},
Err(_) => SubagentSettingsFile::default(),
}
}
async fn save_unlocked(&self, file: &SubagentSettingsFile) -> Result<(), String> {
let parent = self
.path
.parent()
.ok_or_else(|| format!("settings path has no parent: {}", self.path.display()))?;
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("create {}: {e}", parent.display()))?;
let tmp = self.path.with_extension("json.tmp");
let data = serde_json::to_vec_pretty(file).map_err(|e| e.to_string())?;
tokio::fs::write(&tmp, &data)
.await
.map_err(|e| format!("write {}: {e}", tmp.display()))?;
tokio::fs::rename(&tmp, &self.path)
.await
.map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), self.path.display()))?;
Ok(())
}
pub async fn merge_nodes(
&self,
nodes: &mut [DagNodeDef],
) -> Vec<(String, SubagentRunSettings)> {
let _guard = self.lock.lock().await;
let file = self.load_unlocked().await;
merge_nodes_settings(nodes, &file.nodes)
}
pub async fn remember_nodes(&self, updates: Vec<(String, SubagentRunSettings)>) {
if updates.is_empty() {
return;
}
let _guard = self.lock.lock().await;
let mut file = self.load_unlocked().await;
let mut changed = false;
for (id, entry) in updates {
if file.nodes.get(&id) == Some(&entry) {
continue;
}
if entry.is_empty() {
file.nodes.remove(&id);
} else {
file.nodes.insert(id, entry);
}
changed = true;
}
if changed {
self.persist(&file).await;
}
}
pub async fn merge_agent(
&self,
name: &str,
provider: Option<&str>,
model: Option<&str>,
thinking: Option<&str>,
) -> SubagentRunSettings {
let _guard = self.lock.lock().await;
let file = self.load_unlocked().await;
let remembered = file.agents.get(name).cloned().unwrap_or_default();
merge_run_settings(provider, model, thinking, &remembered)
}
pub async fn remember_agent(&self, name: &str, entry: SubagentRunSettings) {
let _guard = self.lock.lock().await;
let mut file = self.load_unlocked().await;
if file.agents.get(name) == Some(&entry) {
return;
}
if entry.is_empty() {
file.agents.remove(name);
} else {
file.agents.insert(name.to_string(), entry);
}
self.persist(&file).await;
}
async fn persist(&self, file: &SubagentSettingsFile) {
if let Err(e) = self.save_unlocked(file).await {
tracing::warn!(path = %self.path.display(), "subagent settings save: {e}");
}
}
}
#[derive(Clone, Default)]
pub struct SubagentSettingsRegistry {
stores: Arc<Mutex<HashMap<PathBuf, Arc<SubagentSettingsStore>>>>,
}
impl SubagentSettingsRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn store_for(&self, project_dir: &Path) -> Arc<SubagentSettingsStore> {
let canonical =
std::fs::canonicalize(project_dir).unwrap_or_else(|_| project_dir.to_path_buf());
self.stores
.lock()
.entry(canonical)
.or_insert_with(|| Arc::new(SubagentSettingsStore::new(project_dir)))
.clone()
}
}
fn opt_or_none(value: Option<&str>) -> Option<String> {
value.filter(|s| !s.is_empty()).map(str::to_string)
}
pub fn merge_run_settings(
explicit_provider: Option<&str>,
explicit_model: Option<&str>,
explicit_thinking: Option<&str>,
remembered: &SubagentRunSettings,
) -> SubagentRunSettings {
let pair_explicit = explicit_provider.is_some() || explicit_model.is_some();
let (provider, model) = if pair_explicit {
(opt_or_none(explicit_provider), opt_or_none(explicit_model))
} else {
(remembered.provider.clone(), remembered.model.clone())
};
let thinking = match explicit_thinking {
Some(raw) => opt_or_none(Some(raw)),
None => remembered.thinking.clone(),
};
SubagentRunSettings {
provider,
model,
thinking,
}
}
pub fn merge_nodes_settings(
nodes: &mut [DagNodeDef],
remembered: &BTreeMap<String, SubagentRunSettings>,
) -> Vec<(String, SubagentRunSettings)> {
let mut updates = Vec::new();
for node in nodes {
let prior = remembered.get(&node.id).cloned().unwrap_or_default();
let effective = merge_run_settings(
node.provider.as_deref(),
node.model.as_deref(),
node.thinking.as_deref(),
&prior,
);
let valid_pair = !(effective.provider.is_some() && effective.model.is_none());
if valid_pair && effective != prior {
updates.push((node.id.clone(), effective.clone()));
}
node.provider = effective.provider.clone();
node.model = effective.model.clone();
node.thinking = effective.thinking.clone();
}
updates
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("subagent_settings");