1use std::collections::{HashMap, HashSet};
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use smooth_operator_core::tool::{ToolCall, ToolHook};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ConversationWorkflowStep {
38 pub id: String,
41 pub intent: String,
43 pub criteria: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub next: Option<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConversationWorkflow {
56 pub goal: String,
58 pub steps: Vec<ConversationWorkflowStep>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct EnabledTool {
67 pub tool_id: String,
69 pub enabled: bool,
71 pub auth_level: String,
73 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
75 pub config: serde_json::Value,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum AuthLevel {
83 #[default]
85 None,
86 EndUser,
88 Admin,
90}
91
92impl AuthLevel {
93 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum Visibility {
110 #[default]
112 Public,
113 Internal,
115}
116
117impl Visibility {
118 #[must_use]
120 pub fn parse(s: &str) -> Self {
121 match s {
122 "internal" => Self::Internal,
123 _ => Self::Public,
124 }
125 }
126}
127
128#[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; }
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#[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct AgentBehaviorConfig {
240 #[serde(default)]
242 pub visibility: Visibility,
243 pub instructions: Option<String>,
246 pub persona: Option<String>,
249 pub greeting: Option<String>,
252 pub conversation_workflow: Option<ConversationWorkflow>,
255 #[serde(default)]
259 pub enabled_tools: Vec<EnabledTool>,
260}
261
262impl AgentBehaviorConfig {
263 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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
419fn 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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520pub enum WorkflowJudgeVerdict {
521 Yes,
523 No,
525 Maybe,
527 Skipped,
529}
530
531impl WorkflowJudgeVerdict {
532 #[must_use]
537 pub fn parse(reply: &str) -> Self {
538 let lower = reply.trim().to_lowercase();
539 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
554pub 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#[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#[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#[async_trait]
608pub trait AgentConfigResolver: Send + Sync {
609 async fn resolve(&self, agent_id: &str) -> Option<AgentBehaviorConfig>;
612}
613
614#[derive(Debug, Default)]
618pub struct StaticAgentConfigResolver {
619 rows: std::collections::HashMap<String, AgentBehaviorConfig>,
620}
621
622impl StaticAgentConfigResolver {
623 #[must_use]
625 pub fn new(rows: std::collections::HashMap<String, AgentBehaviorConfig>) -> Self {
626 Self { rows }
627 }
628
629 #[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 let greet = &w.steps[0];
703 assert_eq!(next_step(&w, greet).unwrap().id, "collect");
704 let collect = &w.steps[1];
706 assert_eq!(next_step(&w, collect).unwrap().id, "summary");
707 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()), },
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 assert_eq!(
802 WorkflowJudgeVerdict::parse("maybe not"),
803 WorkflowJudgeVerdict::Maybe
804 );
805 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 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 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 assert!(!p.contains("Welcome!"));
874 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 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 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 assert!(cfg.enabled_tool_ids().is_none());
942 }
943
944 #[test]
945 fn from_row_values_tolerates_malformed_jsonb() {
946 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 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 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 assert!(tool_auth_refusal("t", AuthLevel::None, Visibility::Public, false).is_none());
985 let admin =
987 tool_auth_refusal("admin_tool", AuthLevel::Admin, Visibility::Public, false).unwrap();
988 assert!(admin.contains("requires admin authentication"));
989 let eu = tool_auth_refusal("pay", AuthLevel::EndUser, Visibility::Public, false).unwrap();
991 assert!(eu.contains("verify your identity"));
992 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 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 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 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}