Skip to main content

smooth_operator/
settings.rs

1//! Per-org agent settings storage (Phase 12, increment 3).
2//!
3//! The management console reads + writes an org's **agent settings** — the model,
4//! the system prompt, and the default tool set — through the admin write API. A
5//! [`SettingsStore`] persists one [`AgentSettings`] per org; an unset org reads
6//! back [`AgentSettings::defaults`] rather than `None`, so the console always has
7//! a populated form to edit.
8//!
9//! These are *configuration*, not secrets — no `auth_ref` model applies here.
10//!
11//! ## Persistence
12//!
13//! Ships with an [`InMemorySettingsStore`]. The persistent follow-up is a
14//! Postgres/DynamoDB `agent_settings` table keyed on `org_id` —
15//! [`put`](SettingsStore::put) is an upsert, [`get`](SettingsStore::get) a single
16//! `SELECT` falling back to defaults. Only the trait + in-memory impl are built
17//! here.
18
19use std::collections::HashMap;
20use std::sync::RwLock;
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24
25/// The default model an org uses until settings are saved.
26pub const DEFAULT_MODEL: &str = "gpt-4o-mini";
27
28/// The default system prompt for a fresh org.
29pub const DEFAULT_SYSTEM_PROMPT: &str =
30    "You are a helpful support assistant. Answer using the provided knowledge; \
31     cite sources and say you don't know when the knowledge doesn't cover the question.";
32
33/// Per-org agent configuration: model, system prompt, and default tools.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AgentSettings {
37    /// The owning organization.
38    pub org_id: String,
39    /// The LLM model id the agent runs on.
40    pub model: String,
41    /// The agent's system prompt.
42    pub system_prompt: String,
43    /// Optional per-org **agent persona** — when set, the runner uses it as the
44    /// turn's system prompt INSTEAD of its built-in default, letting a host give
45    /// each org its own agent voice without forking the runner. `None` (the
46    /// default) leaves the runner on its built-in const prompt, so default
47    /// behavior is byte-for-byte unchanged.
48    ///
49    /// Distinct from [`system_prompt`](Self::system_prompt) (which always has a
50    /// value for the management console to edit): `persona` is the *override
51    /// signal* the runner keys off — absent ⇒ fall back to the const.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub persona: Option<String>,
54    /// Tool names enabled by default for this org's agent.
55    pub default_tools: Vec<String>,
56    /// When the settings were last written.
57    pub updated_at: DateTime<Utc>,
58}
59
60impl AgentSettings {
61    /// The defaults an org starts from before any settings are saved.
62    #[must_use]
63    pub fn defaults(org_id: impl Into<String>) -> Self {
64        Self {
65            org_id: org_id.into(),
66            model: DEFAULT_MODEL.to_string(),
67            system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
68            persona: None,
69            default_tools: Vec::new(),
70            updated_at: Utc::now(),
71        }
72    }
73}
74
75/// Storage seam for per-org [`AgentSettings`]. Org-scoped: a caller only ever
76/// reads / writes its own org's settings.
77pub trait SettingsStore: Send + Sync {
78    /// The org's settings, or [`AgentSettings::defaults`] when unset.
79    fn get(&self, org_id: &str) -> AgentSettings;
80
81    /// Insert or replace the org's settings.
82    fn put(&self, settings: AgentSettings);
83}
84
85/// In-memory [`SettingsStore`] keyed on `org_id`.
86#[derive(Default)]
87pub struct InMemorySettingsStore {
88    rows: RwLock<HashMap<String, AgentSettings>>,
89}
90
91impl InMemorySettingsStore {
92    /// A fresh, empty store.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97}
98
99impl SettingsStore for InMemorySettingsStore {
100    fn get(&self, org_id: &str) -> AgentSettings {
101        self.rows
102            .read()
103            .ok()
104            .and_then(|rows| rows.get(org_id).cloned())
105            .unwrap_or_else(|| AgentSettings::defaults(org_id))
106    }
107
108    fn put(&self, settings: AgentSettings) {
109        if let Ok(mut rows) = self.rows.write() {
110            rows.insert(settings.org_id.clone(), settings);
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn get_unset_returns_defaults_scoped_to_org() {
121        let store = InMemorySettingsStore::new();
122        let s = store.get("org-x");
123        assert_eq!(s.org_id, "org-x");
124        assert_eq!(s.model, DEFAULT_MODEL);
125        assert!(!s.system_prompt.is_empty());
126        // No per-org persona override by default — the runner stays on its const.
127        assert!(s.persona.is_none());
128        assert!(s.default_tools.is_empty());
129    }
130
131    #[test]
132    fn put_then_get_reflects_change_and_is_org_scoped() {
133        let store = InMemorySettingsStore::new();
134        store.put(AgentSettings {
135            org_id: "org-a".into(),
136            model: "claude-x".into(),
137            system_prompt: "be terse".into(),
138            persona: Some("You are Org A's snarky concierge.".into()),
139            default_tools: vec!["knowledge_search".into(), "fetch_url".into()],
140            updated_at: Utc::now(),
141        });
142
143        let a = store.get("org-a");
144        assert_eq!(a.model, "claude-x");
145        assert_eq!(a.system_prompt, "be terse");
146        assert_eq!(
147            a.persona.as_deref(),
148            Some("You are Org A's snarky concierge.")
149        );
150        assert_eq!(a.default_tools, vec!["knowledge_search", "fetch_url"]);
151
152        // A different org still sees defaults.
153        assert_eq!(store.get("org-b").model, DEFAULT_MODEL);
154    }
155
156    #[test]
157    fn put_replaces_existing() {
158        let store = InMemorySettingsStore::new();
159        store.put(AgentSettings {
160            org_id: "o".into(),
161            model: "m1".into(),
162            system_prompt: "p".into(),
163            persona: None,
164            default_tools: vec![],
165            updated_at: Utc::now(),
166        });
167        store.put(AgentSettings {
168            org_id: "o".into(),
169            model: "m2".into(),
170            system_prompt: "p".into(),
171            persona: None,
172            default_tools: vec![],
173            updated_at: Utc::now(),
174        });
175        assert_eq!(store.get("o").model, "m2");
176    }
177}