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