smooth_operator/
settings.rs1use std::collections::HashMap;
20use std::sync::RwLock;
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24
25pub const DEFAULT_MODEL: &str = "gpt-4o-mini";
27
28pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AgentSettings {
37 pub org_id: String,
39 pub model: String,
41 pub system_prompt: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub persona: Option<String>,
54 pub default_tools: Vec<String>,
56 pub updated_at: DateTime<Utc>,
58}
59
60impl AgentSettings {
61 #[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
75pub trait SettingsStore: Send + Sync {
78 fn get(&self, org_id: &str) -> AgentSettings;
80
81 fn put(&self, settings: AgentSettings);
83}
84
85#[derive(Default)]
87pub struct InMemorySettingsStore {
88 rows: RwLock<HashMap<String, AgentSettings>>,
89}
90
91impl InMemorySettingsStore {
92 #[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 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 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}