1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ConversationWorkflowStep {
39 pub id: String,
42 pub intent: String,
44 pub criteria: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub next: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ConversationWorkflow {
57 pub goal: String,
59 pub steps: Vec<ConversationWorkflowStep>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct EnabledTool {
68 pub tool_id: String,
70 pub enabled: bool,
72 pub auth_level: String,
74 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
76 pub config: serde_json::Value,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum AuthLevel {
84 #[default]
86 None,
87 EndUser,
89 Admin,
91}
92
93impl AuthLevel {
94 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum Visibility {
111 #[default]
113 Public,
114 Internal,
116}
117
118impl Visibility {
119 #[must_use]
121 pub fn parse(s: &str) -> Self {
122 match s {
123 "internal" => Self::Internal,
124 _ => Self::Public,
125 }
126 }
127}
128
129#[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; }
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#[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 otp_refused_tool: Arc<Mutex<Option<String>>>,
189}
190
191impl AuthGateHook {
192 #[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 #[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 #[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
270pub struct AgentBehaviorConfig {
271 #[serde(default)]
273 pub visibility: Visibility,
274 pub instructions: Option<String>,
277 pub persona: Option<String>,
280 pub greeting: Option<String>,
283 pub conversation_workflow: Option<ConversationWorkflow>,
286 #[serde(default)]
290 pub enabled_tools: Vec<EnabledTool>,
291}
292
293impl AgentBehaviorConfig {
294 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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
450fn 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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum WorkflowJudgeVerdict {
552 Yes,
554 No,
556 Maybe,
558 Skipped,
560}
561
562impl WorkflowJudgeVerdict {
563 #[must_use]
568 pub fn parse(reply: &str) -> Self {
569 let lower = reply.trim().to_lowercase();
570 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
585pub 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#[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#[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#[async_trait]
639pub trait AgentConfigResolver: Send + Sync {
640 async fn resolve(&self, agent_id: &str) -> Option<AgentBehaviorConfig>;
643}
644
645#[derive(Debug, Default)]
649pub struct StaticAgentConfigResolver {
650 rows: std::collections::HashMap<String, AgentBehaviorConfig>,
651}
652
653impl StaticAgentConfigResolver {
654 #[must_use]
656 pub fn new(rows: std::collections::HashMap<String, AgentBehaviorConfig>) -> Self {
657 Self { rows }
658 }
659
660 #[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 let greet = &w.steps[0];
734 assert_eq!(next_step(&w, greet).unwrap().id, "collect");
735 let collect = &w.steps[1];
737 assert_eq!(next_step(&w, collect).unwrap().id, "summary");
738 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()), },
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 assert_eq!(
833 WorkflowJudgeVerdict::parse("maybe not"),
834 WorkflowJudgeVerdict::Maybe
835 );
836 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 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 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 assert!(!p.contains("Welcome!"));
905 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 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 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 assert!(cfg.enabled_tool_ids().is_none());
973 }
974
975 #[test]
976 fn from_row_values_tolerates_malformed_jsonb() {
977 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 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 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 assert!(tool_auth_refusal("t", AuthLevel::None, Visibility::Public, false).is_none());
1016 let admin =
1018 tool_auth_refusal("admin_tool", AuthLevel::Admin, Visibility::Public, false).unwrap();
1019 assert!(admin.contains("requires admin authentication"));
1020 let eu = tool_auth_refusal("pay", AuthLevel::EndUser, Visibility::Public, false).unwrap();
1022 assert!(eu.contains("verify your identity"));
1023 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 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 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 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 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 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 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}