Skip to main content

smooth_operator/
agent_config.rs

1//! Per-agent behavior config (SMOODEV-590 parity in Rust).
2//!
3//! A public chat agent served over `wss://ai.smoo.ai/ws` must behave as the
4//! agent its owner configured — not as a generic customer-support bot. The
5//! monorepo `agents` row carries the per-agent knobs:
6//!
7//! - `instructions.prompt` — the agent's persona / system prompt,
8//! - `personality.persona` — an optional custom-persona addendum,
9//! - `greeting` — an optional channel-agnostic opening line,
10//! - `conversation_workflow` — an optional stepped, judge-advanced guided flow.
11//!
12//! The reference server resolves the turn's system prompt from **per-org**
13//! settings (see [`crate::settings`]); that gives every agent in an org the same
14//! voice and never applies `conversation_workflow`. This module is the
15//! **per-agent** seam: a host installs an [`AgentConfigResolver`] (backed by the
16//! `agents` table) so the runner can key behavior off the connection's
17//! `agent_id`. Session-create carries only an agent UUID, so config is resolved
18//! server-side by id (matching the sibling lanes' `AgentConfigResolver.resolve`).
19//!
20//! Everything here is I/O-free and jsonb-tolerant on purpose: a malformed row
21//! degrades to "no per-agent config" (fall back to the org default) rather than
22//! failing the turn. The resolver trait is the only async surface.
23//!
24//! Mirrors the TS reference in
25//! `packages/backend/src/ai/graphs/general-agent/workflow.ts` +
26//! `nodes/workflow-judge.ts`.
27
28use std::collections::{HashMap, HashSet};
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use smooth_operator_core::tool::{ToolCall, ToolHook};
33
34/// One step of a structured conversation workflow. Mirrors
35/// `ConversationWorkflowStep` (`packages/schemas/src/agents/agent.ts`).
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ConversationWorkflowStep {
38    /// Stable id, referenced by [`next`](Self::next) and the conversation's
39    /// tracked pointer.
40    pub id: String,
41    /// What the agent should try to accomplish on this step.
42    pub intent: String,
43    /// Objective criteria the judge evaluates to decide whether the step was
44    /// satisfied this turn.
45    pub criteria: String,
46    /// Step id to advance to once criteria are met. Omit / empty on terminal
47    /// steps (advancement then falls through to the next array element).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub next: Option<String>,
50}
51
52/// A structured conversation workflow: a goal + ordered steps. Mirrors
53/// `ConversationWorkflow`.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConversationWorkflow {
56    /// Overall goal the agent drives toward across the conversation.
57    pub goal: String,
58    /// Ordered steps; the first is the starting point.
59    pub steps: Vec<ConversationWorkflowStep>,
60}
61
62/// One entry in `tool_config.enabledTools` (the monorepo `AgentToolConfig`
63/// shape). `auth_level` / `config` are preserved on the parsed type for
64/// downstream hosts even though the reference server doesn't act on them yet.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct EnabledTool {
67    /// The tool's snake_case id (e.g. `knowledge_search`).
68    pub tool_id: String,
69    /// Whether the tool is enabled for this agent.
70    pub enabled: bool,
71    /// Auth level the tool requires (`none` by default). Carried for hosts.
72    pub auth_level: String,
73    /// Opaque per-tool config. Carried for hosts.
74    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
75    pub config: serde_json::Value,
76}
77
78/// Auth level a tool requires (monorepo `AuthLevel`, `agent.ts`). Gating only
79/// applies when this is not [`None`](AuthLevel::None) and the tool supports auth.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum AuthLevel {
83    /// No authentication required (the default).
84    #[default]
85    None,
86    /// The end user's identity must be verified (OTP on a public agent).
87    EndUser,
88    /// Admin authentication — only satisfiable on an internal agent.
89    Admin,
90}
91
92impl AuthLevel {
93    /// Parse from the `authLevel` string, defaulting to [`None`](Self::None).
94    #[must_use]
95    pub fn parse(s: &str) -> Self {
96        match s {
97            "end_user" => Self::EndUser,
98            "admin" => Self::Admin,
99            _ => Self::None,
100        }
101    }
102}
103
104/// Where an agent is reachable (monorepo `AgentVisibility`). `internal` agents
105/// run behind an authenticated dashboard session, so their tool auth is
106/// auto-satisfied; `public` agents (the default) are widget-embeddable.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum Visibility {
110    /// Public widget-embeddable agent (the default).
111    #[default]
112    Public,
113    /// Internal dashboard-only agent (authenticated session).
114    Internal,
115}
116
117impl Visibility {
118    /// Parse from the `visibility` string, defaulting to [`Public`](Self::Public).
119    #[must_use]
120    pub fn parse(s: &str) -> Self {
121        match s {
122            "internal" => Self::Internal,
123            _ => Self::Public,
124        }
125    }
126}
127
128/// Decide whether a tool call is allowed given its required auth level, the
129/// agent's visibility, and whether the session is identity-verified. Mirrors
130/// `tool-execution.ts` (lines ~145-190). `None` (allow) or `Some(message)` (the
131/// reference refusal the model is shown). Callers gate ONLY when `level !=
132/// AuthLevel::None` AND the tool supports auth requirements.
133///
134/// - internal agent → auto-satisfied (both `end_user` and `admin`);
135/// - public + `admin` → refuse (admin tools never run on public agents);
136/// - public + `end_user` → satisfied only when the session is identity-verified,
137///   else refuse asking for verification (the OTP flow is host wiring behind
138///   this seam — here the default is fail-closed).
139#[must_use]
140pub fn tool_auth_refusal(
141    tool_name: &str,
142    level: AuthLevel,
143    visibility: Visibility,
144    session_authenticated: bool,
145) -> Option<String> {
146    if visibility == Visibility::Internal {
147        return None; // authenticated dashboard session satisfies any level
148    }
149    match level {
150        AuthLevel::None => None,
151        AuthLevel::Admin => Some(format!(
152            "Tool '{tool_name}' requires admin authentication and is not available on public-facing agents."
153        )),
154        AuthLevel::EndUser => {
155            if session_authenticated {
156                None
157            } else {
158                Some(format!(
159                    "I need to verify your identity before I can use {tool_name}. Please verify with a one-time code."
160                ))
161            }
162        }
163    }
164}
165
166/// A [`ToolHook`] that blocks a tool call whose configured [`AuthLevel`] isn't
167/// satisfied — the operator-side analog of `tool-execution.ts`'s auth gate. A
168/// blocked call surfaces the reference refusal to the model (the engine turns a
169/// `pre_call` error into the tool result), so the tool never executes.
170///
171/// Only tools in [`auth_supporting_tools`](Self::auth_supporting_tools) are gated
172/// (the `supportsAuthRequirement` flag; empty ⇒ the hook is inert — every current
173/// built-in). The identity-verified `session_authenticated` bit is the seam a
174/// host with an OTP flow flips; the reference server leaves it fail-closed
175/// (`false`).
176#[derive(Debug, Clone)]
177pub struct AuthGateHook {
178    auth_levels: HashMap<String, AuthLevel>,
179    visibility: Visibility,
180    session_authenticated: bool,
181    auth_supporting_tools: HashSet<String>,
182}
183
184impl AuthGateHook {
185    /// Build the gate from an agent's resolved auth levels + visibility. Only the
186    /// tools in `auth_supporting_tools` are ever gated.
187    #[must_use]
188    pub fn new(
189        auth_levels: HashMap<String, AuthLevel>,
190        visibility: Visibility,
191        session_authenticated: bool,
192        auth_supporting_tools: HashSet<String>,
193    ) -> Self {
194        Self {
195            auth_levels,
196            visibility,
197            session_authenticated,
198            auth_supporting_tools,
199        }
200    }
201
202    /// `true` when this hook could ever block something — i.e. some auth-supporting
203    /// tool carries a non-`None` level. Lets the caller skip installing an inert
204    /// hook (keeps the default tool path byte-for-byte unchanged).
205    #[must_use]
206    pub fn is_active(&self) -> bool {
207        self.auth_supporting_tools
208            .iter()
209            .any(|name| self.auth_levels.get(name).copied().unwrap_or_default() != AuthLevel::None)
210    }
211}
212
213#[async_trait]
214impl ToolHook for AuthGateHook {
215    async fn pre_call(&self, call: &ToolCall) -> anyhow::Result<()> {
216        if !self.auth_supporting_tools.contains(&call.name) {
217            return Ok(());
218        }
219        let level = self
220            .auth_levels
221            .get(&call.name)
222            .copied()
223            .unwrap_or_default();
224        match tool_auth_refusal(
225            &call.name,
226            level,
227            self.visibility,
228            self.session_authenticated,
229        ) {
230            Some(message) => Err(anyhow::anyhow!(message)),
231            None => Ok(()),
232        }
233    }
234}
235
236/// The resolved per-agent behavior knobs. Every field is optional so a partial
237/// or malformed `agents` row degrades cleanly to the org default.
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct AgentBehaviorConfig {
240    /// Where the agent is reachable — gates tool auth. Defaults to `Public`.
241    #[serde(default)]
242    pub visibility: Visibility,
243    /// `instructions.prompt` — the agent's system prompt / persona. When present
244    /// it overrides the org default persona for this agent's conversations.
245    pub instructions: Option<String>,
246    /// `personality.persona` — an optional custom-persona addendum appended to
247    /// the system prompt.
248    pub persona: Option<String>,
249    /// `greeting` — an optional opening line, injected into the prompt only on
250    /// the first turn of a conversation (see [`greeting_section`]).
251    pub greeting: Option<String>,
252    /// `conversation_workflow` — the optional stepped guided flow. `None` (or a
253    /// malformed / empty-steps value) means the agent runs freeform.
254    pub conversation_workflow: Option<ConversationWorkflow>,
255    /// `tool_config.enabledTools` — a tool allow-list. When non-empty, this
256    /// agent's turns are restricted to the `enabled == true` entries' `tool_id`
257    /// (empty ⇒ the full server tool set). Unknown tool ids are ignored.
258    #[serde(default)]
259    pub enabled_tools: Vec<EnabledTool>,
260}
261
262impl AgentBehaviorConfig {
263    /// `true` when the row carried nothing usable — the runner should stay on the
264    /// org default persona and take no workflow path.
265    #[must_use]
266    pub fn is_empty(&self) -> bool {
267        self.instructions.is_none()
268            && self.persona.is_none()
269            && self.greeting.is_none()
270            && self.conversation_workflow.is_none()
271            && self.enabled_tools.is_empty()
272    }
273
274    /// Build the per-agent system prompt from `instructions` (+ optional persona),
275    /// or `None` when there are no `instructions` to anchor it.
276    ///
277    /// `None` is the signal to fall back to the org default persona — a bare
278    /// persona with no instructions is not enough to define an agent. The greeting
279    /// is handled separately ([`greeting_section`](Self::greeting_section)) because
280    /// it is injected first-turn-only.
281    #[must_use]
282    pub fn system_prompt(&self) -> Option<String> {
283        let instructions = self.instructions.as_deref()?.trim();
284        if instructions.is_empty() {
285            return None;
286        }
287        let mut prompt = instructions.to_string();
288        if let Some(persona) = self
289            .persona
290            .as_deref()
291            .map(str::trim)
292            .filter(|s| !s.is_empty())
293        {
294            prompt.push_str("\n\n<Personality>\n");
295            prompt.push_str(persona);
296            prompt.push_str("\n</Personality>");
297        }
298        Some(prompt)
299    }
300
301    /// The `<GreetingAwareness>` prompt section, or `None` when no greeting is set.
302    /// The caller injects it only on the FIRST turn (empty prior history), so the
303    /// agent opens with it once. Mirrors the sibling lanes' first-turn greeting.
304    #[must_use]
305    pub fn greeting_section(&self) -> Option<String> {
306        let greeting = self
307            .greeting
308            .as_deref()
309            .map(str::trim)
310            .filter(|s| !s.is_empty())?;
311        Some(format!(
312            "<GreetingAwareness>\nThis is your first reply in this conversation. Open with a natural, brief variant of: \"{greeting}\" — then address the user's message in the same reply. Do NOT repeat the greeting verbatim, and do not reintroduce yourself later.\n</GreetingAwareness>"
313        ))
314    }
315
316    /// The enabled tool-id allow-list, or `None` when unrestricted (no
317    /// `tool_config` / empty `enabledTools` ⇒ the full server tool set).
318    /// `Some(ids)` restricts the turn to those snake_case ids (`enabled == true`
319    /// entries only); unknown ids simply match nothing.
320    #[must_use]
321    pub fn enabled_tool_ids(&self) -> Option<Vec<String>> {
322        if self.enabled_tools.is_empty() {
323            return None;
324        }
325        Some(
326            self.enabled_tools
327                .iter()
328                .filter(|t| t.enabled)
329                .map(|t| t.tool_id.clone())
330                .collect(),
331        )
332    }
333
334    /// The configured [`AuthLevel`] for a tool id (from its `enabledTools`
335    /// entry), or [`AuthLevel::None`] when unconfigured.
336    #[must_use]
337    pub fn auth_level_for(&self, tool_id: &str) -> AuthLevel {
338        self.enabled_tools
339            .iter()
340            .find(|t| t.tool_id == tool_id)
341            .map_or(AuthLevel::None, |t| AuthLevel::parse(&t.auth_level))
342    }
343
344    /// The per-tool `config` object delivered to a tool at execution (the
345    /// `enabledTools` entry's `config`), for every entry that carries one. Empty
346    /// when no tool has config. Mirrors `registry.ts`'s `toolSpecificConfig`.
347    #[must_use]
348    pub fn tool_configs(&self) -> std::collections::HashMap<String, serde_json::Value> {
349        self.enabled_tools
350            .iter()
351            .filter(|t| !t.config.is_null())
352            .map(|t| (t.tool_id.clone(), t.config.clone()))
353            .collect()
354    }
355
356    /// Parse from the raw `agents`-row jsonb / text columns, tolerating any
357    /// malformed shape (a bad value drops just that field — never an error).
358    ///
359    /// - `instructions` — jsonb `{ "prompt": string }`,
360    /// - `personality` — jsonb `{ "persona"?: string, ... }`,
361    /// - `greeting` — text,
362    /// - `conversation_workflow` — jsonb `{ goal, steps: [...] }`,
363    /// - `tool_config` — jsonb `{ enabledTools: [{ toolId, enabled, authLevel, config }] }`,
364    /// - `visibility` — text `public` | `internal` (defaults `public`).
365    #[must_use]
366    pub fn from_row_values(
367        instructions: Option<serde_json::Value>,
368        personality: Option<serde_json::Value>,
369        greeting: Option<String>,
370        conversation_workflow: Option<serde_json::Value>,
371        tool_config: Option<serde_json::Value>,
372        visibility: Option<String>,
373    ) -> Self {
374        let visibility = visibility
375            .as_deref()
376            .map_or(Visibility::Public, Visibility::parse);
377        let instructions = instructions
378            .as_ref()
379            .and_then(|v| v.get("prompt"))
380            .and_then(serde_json::Value::as_str)
381            .map(str::to_string)
382            .filter(|s| !s.trim().is_empty());
383
384        let persona = personality
385            .as_ref()
386            .and_then(|v| v.get("persona"))
387            .and_then(serde_json::Value::as_str)
388            .map(str::to_string)
389            .filter(|s| !s.trim().is_empty());
390
391        let greeting = greeting.filter(|s| !s.trim().is_empty());
392
393        // A malformed workflow (wrong shape, missing fields, empty steps) parses
394        // to None so the turn simply runs freeform — never a hard error.
395        let conversation_workflow = conversation_workflow
396            .and_then(|v| serde_json::from_value::<ConversationWorkflow>(v).ok())
397            .filter(|w| !w.steps.is_empty());
398
399        // `tool_config.enabledTools`: parse each entry tolerantly (a bad entry is
400        // dropped, not fatal). camelCase keys mirror the monorepo jsonb.
401        let enabled_tools = tool_config
402            .as_ref()
403            .and_then(|v| v.get("enabledTools"))
404            .and_then(serde_json::Value::as_array)
405            .map(|arr| arr.iter().filter_map(parse_enabled_tool).collect())
406            .unwrap_or_default();
407
408        Self {
409            visibility,
410            instructions,
411            persona,
412            greeting,
413            conversation_workflow,
414            enabled_tools,
415        }
416    }
417}
418
419/// Parse one `enabledTools` entry, tolerating missing/typed-wrong fields:
420/// `toolId` is required (else the entry is dropped); `enabled` defaults `true`,
421/// `authLevel` defaults `"none"`, `config` defaults `null`.
422fn parse_enabled_tool(v: &serde_json::Value) -> Option<EnabledTool> {
423    let tool_id = v
424        .get("toolId")
425        .and_then(serde_json::Value::as_str)
426        .map(str::to_string)
427        .filter(|s| !s.trim().is_empty())?;
428    Some(EnabledTool {
429        tool_id,
430        enabled: v
431            .get("enabled")
432            .and_then(serde_json::Value::as_bool)
433            .unwrap_or(true),
434        auth_level: v
435            .get("authLevel")
436            .and_then(serde_json::Value::as_str)
437            .unwrap_or("none")
438            .to_string(),
439        config: v.get("config").cloned().unwrap_or(serde_json::Value::Null),
440    })
441}
442
443// ---------------------------------------------------------------------------
444// Workflow step resolution + rendering (parity with workflow.ts)
445// ---------------------------------------------------------------------------
446
447/// Resolve the current step for a `(workflow, pointer)` pair.
448///
449/// - Pointer matches a step id → that step.
450/// - Pointer empty / unknown → the first step (fresh start).
451/// - Empty workflow → `None`.
452#[must_use]
453pub fn resolve_current_step<'a>(
454    workflow: &'a ConversationWorkflow,
455    current_step_id: Option<&str>,
456) -> Option<&'a ConversationWorkflowStep> {
457    if workflow.steps.is_empty() {
458        return None;
459    }
460    if let Some(id) = current_step_id {
461        if let Some(found) = workflow.steps.iter().find(|s| s.id == id) {
462            return Some(found);
463        }
464    }
465    workflow.steps.first()
466}
467
468/// The step to advance to once `current` is satisfied. Preference order:
469///   1. explicit `current.next` if it resolves to a known step id,
470///   2. the element immediately following `current`,
471///   3. `None` — workflow complete (terminal step).
472#[must_use]
473pub fn next_step<'a>(
474    workflow: &'a ConversationWorkflow,
475    current: &ConversationWorkflowStep,
476) -> Option<&'a ConversationWorkflowStep> {
477    if let Some(next_id) = current.next.as_deref().filter(|s| !s.is_empty()) {
478        if let Some(explicit) = workflow.steps.iter().find(|s| s.id == next_id) {
479            return Some(explicit);
480        }
481    }
482    let idx = workflow.steps.iter().position(|s| s.id == current.id)?;
483    workflow.steps.get(idx + 1)
484}
485
486/// Render the current step as a `<ConversationWorkflow>` block for the system
487/// prompt. Empty string when there is no resolvable step, so the caller can
488/// concatenate unconditionally. Mirrors `renderWorkflowPromptSection`.
489#[must_use]
490pub fn render_workflow_prompt_section(
491    workflow: &ConversationWorkflow,
492    current_step_id: Option<&str>,
493) -> String {
494    let Some(step) = resolve_current_step(workflow, current_step_id) else {
495        return String::new();
496    };
497    let idx = workflow
498        .steps
499        .iter()
500        .position(|s| s.id == step.id)
501        .unwrap_or(0);
502    let step_number = idx + 1;
503    let total = workflow.steps.len();
504    format!(
505        "<ConversationWorkflow>\nGOAL: {goal}\n\nCURRENT STEP ({step_number}/{total}): {id}\nINTENT: {intent}\nCRITERIA: {criteria}\n\nFocus this turn on the CURRENT STEP. Pursue the INTENT and aim to satisfy the CRITERIA. You don't have to force the step to close if the user isn't ready — stay conversational and the workflow will advance once the criteria are clearly met.\n</ConversationWorkflow>",
506        goal = workflow.goal,
507        id = step.id,
508        intent = step.intent,
509        criteria = step.criteria,
510    )
511}
512
513// ---------------------------------------------------------------------------
514// Judge (parity with workflow-judge.ts)
515// ---------------------------------------------------------------------------
516
517/// The workflow judge's verdict on whether the current step's criteria were met
518/// this turn. Mirrors `WorkflowJudgeVerdict`.
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum WorkflowJudgeVerdict {
521    /// Criteria clearly satisfied — advance.
522    Yes,
523    /// Not satisfied — stay on the current step.
524    No,
525    /// Partial / ambiguous — stay on the current step, try again next turn.
526    Maybe,
527    /// No workflow / nothing to evaluate.
528    Skipped,
529}
530
531impl WorkflowJudgeVerdict {
532    /// Parse a judge model's free-text reply into a verdict. Lenient: matches the
533    /// first of `yes` / `no` / `maybe` found (case-insensitive, word-ish), so it
534    /// survives a model that wraps the word in punctuation or a short sentence.
535    /// Anything unrecognized → [`Maybe`](Self::Maybe) (stay put, don't over-advance).
536    #[must_use]
537    pub fn parse(reply: &str) -> Self {
538        let lower = reply.trim().to_lowercase();
539        // Order matters: "maybe" contains neither "yes" nor "no", but check it
540        // first so a reply like "maybe not" resolves to Maybe, not No.
541        if lower.contains("maybe") {
542            return Self::Maybe;
543        }
544        if lower.contains("yes") {
545            return Self::Yes;
546        }
547        if lower.contains("no") {
548            return Self::No;
549        }
550        Self::Maybe
551    }
552}
553
554/// The judge's system prompt. Kept as a const so tests and the runner share the
555/// exact wording. Mirrors the TS judge's rubric.
556pub const JUDGE_SYSTEM_PROMPT: &str = "You are a conversation-workflow judge. Given the CURRENT STEP's intent + criteria and the most recent agent reply, decide whether the step was satisfied this turn.\n\nRules:\n- \"yes\" -> the criteria are clearly satisfied on the basis of this turn.\n- \"no\" -> not satisfied, or the agent moved away from the step.\n- \"maybe\" -> partial/ambiguous progress; stay on the current step and try again next turn.\n- Only answer \"yes\" when the criteria are objectively met. It is OK to stay on a step for multiple turns.\n\nReply with EXACTLY one word: yes, no, or maybe.";
557
558/// Build the judge's user prompt for one turn. Mirrors the TS human prompt.
559#[must_use]
560pub fn judge_user_prompt(
561    workflow: &ConversationWorkflow,
562    step: &ConversationWorkflowStep,
563    user_message: &str,
564    agent_reply: &str,
565) -> String {
566    format!(
567        "GOAL: {goal}\n\nCURRENT STEP ({id}):\n  intent: {intent}\n  criteria: {criteria}\n\nLAST USER MESSAGE:\n{user}\n\nAGENT REPLY:\n{reply}\n\nReturn exactly one word: yes, no, or maybe.",
568        goal = workflow.goal,
569        id = step.id,
570        intent = step.intent,
571        criteria = step.criteria,
572        user = if user_message.is_empty() { "(none)" } else { user_message },
573        reply = agent_reply,
574    )
575}
576
577/// Compute the tracked step id after a judge verdict. `Yes` advances (to
578/// [`next_step`], or stays put on a terminal step); every other verdict stays on
579/// the current step. Never freezes: an unresolvable pointer resolves to the
580/// first step. Returns `None` only for an empty workflow.
581#[must_use]
582pub fn advance_after_verdict(
583    workflow: &ConversationWorkflow,
584    current_step_id: Option<&str>,
585    verdict: WorkflowJudgeVerdict,
586) -> Option<String> {
587    let current = resolve_current_step(workflow, current_step_id)?;
588    if verdict == WorkflowJudgeVerdict::Yes {
589        if let Some(next) = next_step(workflow, current) {
590            return Some(next.id.clone());
591        }
592    }
593    Some(current.id.clone())
594}
595
596// ---------------------------------------------------------------------------
597// Provider seam
598// ---------------------------------------------------------------------------
599
600/// Seam for resolving an agent's [`AgentBehaviorConfig`] by `agent_id`.
601///
602/// The ws protocol's `create_conversation_session` carries only an agent UUID, so
603/// per-agent config is looked up **server-side by id**. Implemented by the host
604/// (backed by the monorepo `agents` table). Returning `None` means "no per-agent
605/// config" — the runner falls back to the org default persona, exactly as before
606/// this seam existed. Matches the sibling lanes' `AgentConfigResolver.resolve`.
607#[async_trait]
608pub trait AgentConfigResolver: Send + Sync {
609    /// The per-agent behavior config for `agent_id`, or `None` when the agent is
610    /// unknown / has no usable config.
611    async fn resolve(&self, agent_id: &str) -> Option<AgentBehaviorConfig>;
612}
613
614/// Static map resolver (`agentId` → config), for tests and DB-free hosts. The
615/// empty default is the server's no-op resolver (every agent → `None`), so the
616/// reference/OSS server stays on its org-default behavior.
617#[derive(Debug, Default)]
618pub struct StaticAgentConfigResolver {
619    rows: std::collections::HashMap<String, AgentBehaviorConfig>,
620}
621
622impl StaticAgentConfigResolver {
623    /// Build from an in-memory map.
624    #[must_use]
625    pub fn new(rows: std::collections::HashMap<String, AgentBehaviorConfig>) -> Self {
626        Self { rows }
627    }
628
629    /// Insert / replace one agent's config (builder style).
630    #[must_use]
631    pub fn with(mut self, agent_id: impl Into<String>, config: AgentBehaviorConfig) -> Self {
632        self.rows.insert(agent_id.into(), config);
633        self
634    }
635}
636
637#[async_trait]
638impl AgentConfigResolver for StaticAgentConfigResolver {
639    async fn resolve(&self, agent_id: &str) -> Option<AgentBehaviorConfig> {
640        self.rows.get(agent_id).cloned()
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use serde_json::json;
648
649    fn wf() -> ConversationWorkflow {
650        ConversationWorkflow {
651            goal: "Assess posture".into(),
652            steps: vec![
653                ConversationWorkflowStep {
654                    id: "greet".into(),
655                    intent: "Greet and confirm name".into(),
656                    criteria: "User's name captured".into(),
657                    next: None,
658                },
659                ConversationWorkflowStep {
660                    id: "collect".into(),
661                    intent: "Collect current tooling".into(),
662                    criteria: "At least one tool named".into(),
663                    next: Some("summary".into()),
664                },
665                ConversationWorkflowStep {
666                    id: "summary".into(),
667                    intent: "Summarize".into(),
668                    criteria: "Summary delivered".into(),
669                    next: None,
670                },
671            ],
672        }
673    }
674
675    #[test]
676    fn resolve_current_step_defaults_to_first() {
677        let w = wf();
678        assert_eq!(resolve_current_step(&w, None).unwrap().id, "greet");
679        assert_eq!(
680            resolve_current_step(&w, Some("unknown")).unwrap().id,
681            "greet"
682        );
683        assert_eq!(
684            resolve_current_step(&w, Some("collect")).unwrap().id,
685            "collect"
686        );
687    }
688
689    #[test]
690    fn resolve_current_step_empty_workflow_is_none() {
691        let empty = ConversationWorkflow {
692            goal: "g".into(),
693            steps: vec![],
694        };
695        assert!(resolve_current_step(&empty, None).is_none());
696    }
697
698    #[test]
699    fn next_step_prefers_explicit_then_sequential_then_terminal() {
700        let w = wf();
701        // greet has no `next` → sequential → collect
702        let greet = &w.steps[0];
703        assert_eq!(next_step(&w, greet).unwrap().id, "collect");
704        // collect.next = summary (explicit, also happens to be sequential here)
705        let collect = &w.steps[1];
706        assert_eq!(next_step(&w, collect).unwrap().id, "summary");
707        // summary is terminal
708        let summary = &w.steps[2];
709        assert!(next_step(&w, summary).is_none());
710    }
711
712    #[test]
713    fn next_step_explicit_jump_overrides_order() {
714        let w = ConversationWorkflow {
715            goal: "g".into(),
716            steps: vec![
717                ConversationWorkflowStep {
718                    id: "a".into(),
719                    intent: "i".into(),
720                    criteria: "c".into(),
721                    next: Some("c".into()), // skip b
722                },
723                ConversationWorkflowStep {
724                    id: "b".into(),
725                    intent: "i".into(),
726                    criteria: "c".into(),
727                    next: None,
728                },
729                ConversationWorkflowStep {
730                    id: "c".into(),
731                    intent: "i".into(),
732                    criteria: "c".into(),
733                    next: None,
734                },
735            ],
736        };
737        assert_eq!(next_step(&w, &w.steps[0]).unwrap().id, "c");
738    }
739
740    #[test]
741    fn next_step_unknown_explicit_next_falls_through_to_sequential() {
742        let w = ConversationWorkflow {
743            goal: "g".into(),
744            steps: vec![
745                ConversationWorkflowStep {
746                    id: "a".into(),
747                    intent: "i".into(),
748                    criteria: "c".into(),
749                    next: Some("nonexistent".into()),
750                },
751                ConversationWorkflowStep {
752                    id: "b".into(),
753                    intent: "i".into(),
754                    criteria: "c".into(),
755                    next: None,
756                },
757            ],
758        };
759        assert_eq!(next_step(&w, &w.steps[0]).unwrap().id, "b");
760    }
761
762    #[test]
763    fn render_section_includes_goal_intent_criteria_and_position() {
764        let w = wf();
765        let section = render_workflow_prompt_section(&w, Some("collect"));
766        assert!(section.contains("GOAL: Assess posture"));
767        assert!(section.contains("CURRENT STEP (2/3): collect"));
768        assert!(section.contains("INTENT: Collect current tooling"));
769        assert!(section.contains("CRITERIA: At least one tool named"));
770    }
771
772    #[test]
773    fn render_section_empty_workflow_is_empty_string() {
774        let empty = ConversationWorkflow {
775            goal: "g".into(),
776            steps: vec![],
777        };
778        assert_eq!(render_workflow_prompt_section(&empty, None), "");
779    }
780
781    #[test]
782    fn verdict_parse_is_lenient() {
783        assert_eq!(
784            WorkflowJudgeVerdict::parse("yes"),
785            WorkflowJudgeVerdict::Yes
786        );
787        assert_eq!(
788            WorkflowJudgeVerdict::parse("YES."),
789            WorkflowJudgeVerdict::Yes
790        );
791        assert_eq!(
792            WorkflowJudgeVerdict::parse("Yes, criteria met"),
793            WorkflowJudgeVerdict::Yes
794        );
795        assert_eq!(WorkflowJudgeVerdict::parse("no"), WorkflowJudgeVerdict::No);
796        assert_eq!(
797            WorkflowJudgeVerdict::parse("maybe"),
798            WorkflowJudgeVerdict::Maybe
799        );
800        // "maybe not" must resolve to Maybe (not No) — maybe is checked first.
801        assert_eq!(
802            WorkflowJudgeVerdict::parse("maybe not"),
803            WorkflowJudgeVerdict::Maybe
804        );
805        // Unrecognized → Maybe (conservative: don't advance).
806        assert_eq!(
807            WorkflowJudgeVerdict::parse("???"),
808            WorkflowJudgeVerdict::Maybe
809        );
810    }
811
812    #[test]
813    fn advance_only_on_yes() {
814        let w = wf();
815        assert_eq!(
816            advance_after_verdict(&w, Some("greet"), WorkflowJudgeVerdict::Yes).as_deref(),
817            Some("collect")
818        );
819        assert_eq!(
820            advance_after_verdict(&w, Some("greet"), WorkflowJudgeVerdict::No).as_deref(),
821            Some("greet")
822        );
823        assert_eq!(
824            advance_after_verdict(&w, Some("greet"), WorkflowJudgeVerdict::Maybe).as_deref(),
825            Some("greet")
826        );
827    }
828
829    #[test]
830    fn advance_on_terminal_step_stays_put() {
831        let w = wf();
832        assert_eq!(
833            advance_after_verdict(&w, Some("summary"), WorkflowJudgeVerdict::Yes).as_deref(),
834            Some("summary")
835        );
836    }
837
838    #[test]
839    fn advance_from_fresh_pointer_starts_at_first() {
840        let w = wf();
841        // None pointer resolves to first step "greet"; yes advances to "collect".
842        assert_eq!(
843            advance_after_verdict(&w, None, WorkflowJudgeVerdict::Yes).as_deref(),
844            Some("collect")
845        );
846    }
847
848    #[test]
849    fn system_prompt_requires_instructions() {
850        // Persona / greeting alone do NOT override the org default.
851        let cfg = AgentBehaviorConfig {
852            instructions: None,
853            persona: Some("snarky".into()),
854            greeting: Some("hi".into()),
855            ..Default::default()
856        };
857        assert!(cfg.system_prompt().is_none());
858    }
859
860    #[test]
861    fn system_prompt_composes_instructions_and_personality() {
862        let cfg = AgentBehaviorConfig {
863            instructions: Some("You are the Posture assistant.".into()),
864            persona: Some("Warm and direct.".into()),
865            greeting: Some("Welcome!".into()),
866            ..Default::default()
867        };
868        let p = cfg.system_prompt().unwrap();
869        assert!(p.starts_with("You are the Posture assistant."));
870        assert!(p.contains("<Personality>"));
871        assert!(p.contains("Warm and direct."));
872        // Greeting is NOT in the system prompt — it is first-turn-only.
873        assert!(!p.contains("Welcome!"));
874        // ...and is available separately for the runner to inject on turn 1.
875        assert!(cfg.greeting_section().unwrap().contains("Welcome!"));
876    }
877
878    #[test]
879    fn from_row_values_parses_well_formed_row() {
880        let cfg = AgentBehaviorConfig::from_row_values(
881            Some(
882                json!({ "prompt": "You are the Posture assistant. NOT a generic support agent." }),
883            ),
884            Some(json!({ "preset": "professional", "creativity": 0.5, "persona": "Warm." })),
885            Some("Hey there".into()),
886            Some(json!({
887                "goal": "Assess",
888                "steps": [
889                    { "id": "greet", "intent": "greet", "criteria": "name captured" }
890                ]
891            })),
892            Some(json!({
893                "enabledTools": [
894                    { "toolId": "knowledge_search", "enabled": true, "authLevel": "none" },
895                    { "toolId": "admin_tool", "enabled": true, "authLevel": "admin", "config": { "k": 1 } },
896                    { "toolId": "notify_humans", "enabled": false }
897                ]
898            })),
899            Some("internal".into()),
900        );
901        assert_eq!(
902            cfg.instructions.as_deref(),
903            Some("You are the Posture assistant. NOT a generic support agent.")
904        );
905        assert_eq!(cfg.persona.as_deref(), Some("Warm."));
906        assert_eq!(cfg.greeting.as_deref(), Some("Hey there"));
907        assert_eq!(cfg.visibility, Visibility::Internal);
908        let wf = cfg.conversation_workflow.clone().unwrap();
909        assert_eq!(wf.goal, "Assess");
910        assert_eq!(wf.steps.len(), 1);
911        assert_eq!(wf.steps[0].id, "greet");
912        // enabledTools parsed; only enabled=true entries are in the allow-list.
913        assert_eq!(cfg.enabled_tools.len(), 3);
914        assert_eq!(
915            cfg.enabled_tool_ids(),
916            Some(vec![
917                "knowledge_search".to_string(),
918                "admin_tool".to_string()
919            ])
920        );
921        // Per-tool authLevel + config are parsed.
922        assert_eq!(cfg.auth_level_for("admin_tool"), AuthLevel::Admin);
923        assert_eq!(cfg.auth_level_for("knowledge_search"), AuthLevel::None);
924        assert_eq!(
925            cfg.tool_configs().get("admin_tool"),
926            Some(&json!({ "k": 1 }))
927        );
928    }
929
930    #[test]
931    fn enabled_tool_ids_none_when_no_tool_config() {
932        let cfg = AgentBehaviorConfig::from_row_values(
933            Some(json!({ "prompt": "hi" })),
934            None,
935            None,
936            None,
937            None,
938            None,
939        );
940        // No tool_config → unrestricted (full server tool set).
941        assert!(cfg.enabled_tool_ids().is_none());
942    }
943
944    #[test]
945    fn from_row_values_tolerates_malformed_jsonb() {
946        // instructions not an object, personality a string, workflow missing
947        // `steps`, greeting blank → every field degrades to None, no panic.
948        let cfg = AgentBehaviorConfig::from_row_values(
949            Some(json!("just a string")),
950            Some(json!("not an object")),
951            Some("   ".into()),
952            Some(json!({ "goal": "no steps here" })),
953            Some(json!("tool_config not an object")),
954            Some("garbage-visibility".into()),
955        );
956        assert!(
957            cfg.is_empty(),
958            "malformed row must degrade to empty config: {cfg:?}"
959        );
960        // Unknown visibility string → default public (never an error).
961        assert_eq!(cfg.visibility, Visibility::Public);
962    }
963
964    #[test]
965    fn from_row_values_drops_empty_steps_workflow() {
966        let cfg = AgentBehaviorConfig::from_row_values(
967            Some(json!({ "prompt": "hi" })),
968            None,
969            None,
970            Some(json!({ "goal": "g", "steps": [] })),
971            None,
972            None,
973        );
974        assert!(cfg.conversation_workflow.is_none());
975        assert_eq!(cfg.instructions.as_deref(), Some("hi"));
976    }
977
978    #[test]
979    fn auth_refusal_mirrors_reference_branches() {
980        // internal agent → any level satisfied.
981        assert!(tool_auth_refusal("t", AuthLevel::Admin, Visibility::Internal, false).is_none());
982        assert!(tool_auth_refusal("t", AuthLevel::EndUser, Visibility::Internal, false).is_none());
983        // public + none → allowed.
984        assert!(tool_auth_refusal("t", AuthLevel::None, Visibility::Public, false).is_none());
985        // public + admin → refuse.
986        let admin =
987            tool_auth_refusal("admin_tool", AuthLevel::Admin, Visibility::Public, false).unwrap();
988        assert!(admin.contains("requires admin authentication"));
989        // public + end_user, unauthenticated → refuse asking for verification.
990        let eu = tool_auth_refusal("pay", AuthLevel::EndUser, Visibility::Public, false).unwrap();
991        assert!(eu.contains("verify your identity"));
992        // public + end_user, authenticated → allowed.
993        assert!(tool_auth_refusal("pay", AuthLevel::EndUser, Visibility::Public, true).is_none());
994    }
995
996    #[tokio::test]
997    async fn auth_gate_hook_only_gates_supporting_tools() {
998        let levels: HashMap<String, AuthLevel> = [("pay".to_string(), AuthLevel::Admin)]
999            .into_iter()
1000            .collect();
1001        let supporting: HashSet<String> = ["pay".to_string()].into_iter().collect();
1002        let hook = AuthGateHook::new(levels, Visibility::Public, false, supporting);
1003        assert!(hook.is_active());
1004
1005        // The gated admin tool on a public agent is blocked.
1006        let pay = ToolCall {
1007            id: "1".into(),
1008            name: "pay".into(),
1009            arguments: serde_json::json!({}),
1010        };
1011        assert!(hook.pre_call(&pay).await.is_err());
1012
1013        // A tool NOT in the supporting set is never gated, even with a level.
1014        let ks = ToolCall {
1015            id: "2".into(),
1016            name: "knowledge_search".into(),
1017            arguments: serde_json::json!({}),
1018        };
1019        assert!(hook.pre_call(&ks).await.is_ok());
1020    }
1021
1022    #[test]
1023    fn auth_gate_inactive_when_no_supporting_tool_has_a_level() {
1024        // A supporting tool with authLevel none, and a leveled tool that isn't
1025        // supporting → nothing to gate.
1026        let levels: HashMap<String, AuthLevel> = [("admin_tool".to_string(), AuthLevel::Admin)]
1027            .into_iter()
1028            .collect();
1029        let supporting: HashSet<String> = ["knowledge_search".to_string()].into_iter().collect();
1030        let hook = AuthGateHook::new(levels, Visibility::Public, false, supporting);
1031        assert!(!hook.is_active());
1032    }
1033
1034    #[tokio::test]
1035    async fn empty_resolver_returns_none() {
1036        assert!(StaticAgentConfigResolver::default()
1037            .resolve("anything")
1038            .await
1039            .is_none());
1040    }
1041
1042    #[tokio::test]
1043    async fn static_provider_is_per_agent_isolated() {
1044        let provider = StaticAgentConfigResolver::default()
1045            .with(
1046                "agent-a",
1047                AgentBehaviorConfig {
1048                    instructions: Some("A persona".into()),
1049                    ..Default::default()
1050                },
1051            )
1052            .with(
1053                "agent-b",
1054                AgentBehaviorConfig {
1055                    instructions: Some("B persona".into()),
1056                    ..Default::default()
1057                },
1058            );
1059        assert_eq!(
1060            provider
1061                .resolve("agent-a")
1062                .await
1063                .unwrap()
1064                .instructions
1065                .as_deref(),
1066            Some("A persona")
1067        );
1068        assert_eq!(
1069            provider
1070                .resolve("agent-b")
1071                .await
1072                .unwrap()
1073                .instructions
1074                .as_deref(),
1075            Some("B persona")
1076        );
1077        assert!(provider.resolve("agent-c").await.is_none());
1078    }
1079}