Skip to main content

smooth_operator_adapter_postgres/
agent_config.rs

1//! Postgres-backed [`AgentConfigResolver`] over the monorepo `agents` table.
2//!
3//! The reference server points its Postgres storage backend at the same database
4//! the SmooAI monorepo owns (the schema in [`crate::schema`] mirrors that shape),
5//! so the `agents` row for a connection's `agent_id` is reachable on the adapter's
6//! existing pool — no second connection, no HTTP hop. This provider reads the
7//! per-agent behavior knobs (`instructions`, `personality`, `greeting`,
8//! `conversation_workflow`, `tool_config`) so the runner can honor them.
9//!
10//! **Failure-tolerant by construction**: a non-UUID `agent_id`, an absent row, a
11//! missing `agents` table (a standalone deploy whose DB has only the operator's
12//! own tables), or a malformed jsonb value all resolve to `None` / an empty
13//! config — the turn falls back to the org-default persona rather than failing.
14
15use async_trait::async_trait;
16use deadpool_postgres::Pool;
17use tracing::debug;
18
19use smooth_operator::agent_config::{AgentBehaviorConfig, AgentConfigResolver};
20
21/// Postgres-backed [`AgentConfigResolver`] over the `agents` table.
22#[derive(Clone)]
23pub struct PgAgentConfigResolver {
24    pool: Pool,
25}
26
27impl PgAgentConfigResolver {
28    /// Build over the adapter's async pool.
29    #[must_use]
30    pub fn new(pool: Pool) -> Self {
31        Self { pool }
32    }
33
34    /// Query the `agents` row, mapping any failure to `None` (see module docs).
35    async fn fetch(&self, agent_id: &str) -> Option<AgentBehaviorConfig> {
36        // `agents.id` is a uuid; a widget/session `agentId` that isn't a valid
37        // uuid can't match a row (and would make Postgres error on the cast), so
38        // short-circuit to None.
39        let id = match uuid::Uuid::parse_str(agent_id) {
40            Ok(id) => id,
41            Err(_) => {
42                debug!(agent_id, "agent_id is not a uuid; no per-agent config");
43                return None;
44            }
45        };
46
47        let client = match self.pool.get().await {
48            Ok(c) => c,
49            Err(e) => {
50                debug!(error = %e, "agent config: pool.get failed; falling back to org default");
51                return None;
52            }
53        };
54
55        let row = match client
56            .query_opt(
57                "SELECT instructions, personality, greeting, conversation_workflow, tool_config, visibility \
58                 FROM agents WHERE id = $1",
59                &[&id],
60            )
61            .await
62        {
63            Ok(row) => row?,
64            Err(e) => {
65                // Missing table (standalone deploy) or any query error: degrade.
66                debug!(error = %e, agent_id, "agent config query failed; falling back to org default");
67                return None;
68            }
69        };
70
71        // Column reads are `Option` so a NULL / unexpected type never panics.
72        let instructions: Option<serde_json::Value> = row.try_get("instructions").ok().flatten();
73        let personality: Option<serde_json::Value> = row.try_get("personality").ok().flatten();
74        let greeting: Option<String> = row.try_get("greeting").ok().flatten();
75        let workflow: Option<serde_json::Value> =
76            row.try_get("conversation_workflow").ok().flatten();
77        let tool_config: Option<serde_json::Value> = row.try_get("tool_config").ok().flatten();
78        let visibility: Option<String> = row.try_get("visibility").ok().flatten();
79
80        let config = AgentBehaviorConfig::from_row_values(
81            instructions,
82            personality,
83            greeting,
84            workflow,
85            tool_config,
86            visibility,
87        );
88        if config.is_empty() {
89            None
90        } else {
91            Some(config)
92        }
93    }
94}
95
96#[async_trait]
97impl AgentConfigResolver for PgAgentConfigResolver {
98    async fn resolve(&self, agent_id: &str) -> Option<AgentBehaviorConfig> {
99        self.fetch(agent_id).await
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    // Behavior against a live Postgres is covered by the parity/integration
108    // suites; here we assert the credential-free invariants that must hold with
109    // no database reachable.
110
111    #[tokio::test]
112    async fn non_uuid_agent_id_is_none_without_touching_db() {
113        // A pool pointed at an unreachable host proves the uuid guard returns
114        // BEFORE any `pool.get()` — the bogus host is never dialed.
115        let mut cfg = deadpool_postgres::Config::new();
116        cfg.host = Some("127.0.0.1".to_string());
117        cfg.port = Some(1); // nothing listens here
118        cfg.dbname = Some("nope".to_string());
119        cfg.user = Some("nobody".to_string());
120        let pool = cfg
121            .create_pool(
122                Some(deadpool_postgres::Runtime::Tokio1),
123                tokio_postgres::NoTls,
124            )
125            .expect("build pool");
126        let provider = PgAgentConfigResolver::new(pool);
127        assert!(provider.resolve("not-a-uuid").await.is_none());
128    }
129}