1use std::any;
29use std::sync::Arc;
30
31use schemars::{JsonSchema, schema_for};
32use serde::de::DeserializeOwned;
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, from_value, to_string};
35use tokio::time;
36use tracing::{info, warn};
37
38use crate::error::OperationError;
39#[cfg(feature = "prometheus")]
40use crate::metric_names;
41use crate::provider::{AgentConfig, AgentOutput, AgentProvider, DebugMessage, LogSink};
42use crate::retry::RetryPolicy;
43use crate::trace_context::WorkflowTraceContext;
44
45pub struct Model;
76
77impl Model {
78 pub const SONNET: &str = "sonnet";
82 pub const OPUS: &str = "opus";
84 pub const HAIKU: &str = "haiku";
86
87 pub const HAIKU_45: &str = "claude-haiku-4-5-20251001";
91
92 pub const SONNET_46: &str = "claude-sonnet-4-6";
96 pub const OPUS_46: &str = "claude-opus-4-6";
98
99 pub const SONNET_46_1M: &str = "claude-sonnet-4-6[1m]";
103 pub const OPUS_46_1M: &str = "claude-opus-4-6[1m]";
105
106 pub const OPUS_47: &str = "claude-opus-4-7";
110 pub const OPUS_47_1M: &str = "claude-opus-4-7[1m]";
112
113 pub const OPUS_48: &str = "claude-opus-4-8";
117 pub const OPUS_48_1M: &str = "claude-opus-4-8[1m]";
119
120 pub const FABLE_5: &str = "claude-fable-5";
124 pub const FABLE_51: &str = "claude-fable-5-1";
126 pub const MYTHOS_5: &str = "claude-mythos-5";
128 pub const OPUS_5: &str = "claude-opus-5";
130 pub const OPUS_5_1M: &str = "claude-opus-5[1m]";
132 pub const SONNET_5: &str = "claude-sonnet-5";
134 pub const SONNET_5_1M: &str = "claude-sonnet-5[1m]";
136}
137
138#[derive(Debug, Default, Clone, Copy, Serialize)]
143pub enum PermissionMode {
144 #[default]
146 Default,
147 Auto,
149 DontAsk,
151 BypassPermissions,
155}
156
157impl<'de> Deserialize<'de> for PermissionMode {
158 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
159 where
160 D: serde::Deserializer<'de>,
161 {
162 let s = String::deserialize(deserializer)?;
163 Ok(match s.to_lowercase().replace('_', "").as_str() {
164 "auto" => Self::Auto,
165 "dontask" => Self::DontAsk,
166 "bypass" | "bypasspermissions" => Self::BypassPermissions,
167 _ => Self::Default,
168 })
169 }
170}
171
172#[must_use = "an Agent does nothing until .run() is awaited"]
202pub struct Agent {
203 config: AgentConfig,
204 dry_run: Option<bool>,
205 retry_policy: Option<RetryPolicy>,
206 log_sink: Option<Arc<dyn LogSink>>,
207}
208
209impl Agent {
210 pub fn new() -> Self {
215 Self {
216 config: AgentConfig::new(""),
217 dry_run: None,
218 retry_policy: None,
219 log_sink: None,
220 }
221 }
222
223 pub fn from_config(config: impl Into<AgentConfig>) -> Self {
242 Self {
243 config: config.into(),
244 dry_run: None,
245 retry_policy: None,
246 log_sink: None,
247 }
248 }
249
250 pub fn system_prompt(mut self, prompt: &str) -> Self {
252 self.config.system_prompt = Some(prompt.to_string());
253 self
254 }
255
256 pub fn prompt(mut self, prompt: &str) -> Self {
258 self.config.prompt = prompt.to_string();
259 self
260 }
261
262 pub fn model(mut self, model: impl Into<String>) -> Self {
269 self.config.model = model.into();
270 self
271 }
272
273 pub fn allowed_tools(mut self, tools: &[&str]) -> Self {
278 self.config.allowed_tools = tools.iter().map(|s| s.to_string()).collect();
279 self
280 }
281
282 pub fn max_turns(mut self, turns: u32) -> Self {
288 assert!(turns > 0, "max_turns must be greater than 0");
289 self.config.max_turns = Some(turns);
290 self
291 }
292
293 pub fn max_budget_usd(mut self, budget: f64) -> Self {
299 assert!(
300 budget.is_finite() && budget > 0.0,
301 "budget must be a positive finite number, got {budget}"
302 );
303 self.config.max_budget_usd = Some(budget);
304 self
305 }
306
307 pub fn working_dir(mut self, dir: &str) -> Self {
309 self.config.working_dir = Some(dir.to_string());
310 self
311 }
312
313 pub fn mcp_config(mut self, config: &str) -> Self {
315 self.config.mcp_config = Some(config.to_string());
316 self
317 }
318
319 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
323 self.config.permission_mode = mode;
324 self
325 }
326
327 pub fn output<T: JsonSchema>(mut self) -> Self {
358 let schema = schema_for!(T);
359 self.config.json_schema = match to_string(&schema) {
360 Ok(s) => Some(s),
361 Err(e) => {
362 warn!(error = %e, type_name = any::type_name::<T>(), "failed to serialize JSON schema, structured output disabled");
363 None
364 }
365 };
366 self
367 }
368
369 pub fn output_schema_raw(mut self, schema: &str) -> Self {
393 self.config.json_schema = Some(schema.to_string());
394 self
395 }
396
397 pub fn retry(mut self, max_retries: u32) -> Self {
425 self.retry_policy = Some(RetryPolicy::new(max_retries));
426 self
427 }
428
429 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
456 self.retry_policy = Some(policy);
457 self
458 }
459
460 pub fn dry_run(mut self, enabled: bool) -> Self {
469 self.dry_run = Some(enabled);
470 self
471 }
472
473 pub fn log_sink(mut self, sink: Arc<dyn LogSink>) -> Self {
500 self.log_sink = Some(sink);
501 self
502 }
503
504 pub fn trace_context(mut self, ctx: WorkflowTraceContext) -> Self {
529 self.config.trace_context = Some(ctx);
530 self
531 }
532
533 pub fn verbose(mut self) -> Self {
563 self.config.verbose = true;
564 self
565 }
566
567 pub fn resume(mut self, session_id: &str) -> Self {
603 assert!(!session_id.is_empty(), "session_id must not be empty");
604 assert!(
605 session_id
606 .chars()
607 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
608 "session_id must only contain alphanumeric characters, hyphens, or underscores, got: {session_id}"
609 );
610 self.config.resume_session_id = Some(session_id.to_string());
611 self
612 }
613
614 #[tracing::instrument(name = "agent", skip_all, fields(model = %self.config.model, prompt_len = self.config.prompt.len()))]
635 pub async fn run(self, provider: &dyn AgentProvider) -> Result<AgentResult, OperationError> {
636 assert!(
637 !self.config.prompt.trim().is_empty(),
638 "prompt must not be empty - call .prompt(\"...\") before .run()"
639 );
640
641 if crate::dry_run::effective_dry_run(self.dry_run) {
642 info!(
643 prompt_len = self.config.prompt.len(),
644 "[dry-run] agent call skipped"
645 );
646 let mut output =
647 AgentOutput::new(Value::String("[dry-run] agent call skipped".to_string()));
648 output.cost_usd = Some(0.0);
649 output.input_tokens = Some(0);
650 output.output_tokens = Some(0);
651 return Ok(AgentResult { output });
652 }
653
654 let result = self.invoke_once(provider).await;
655
656 let default_schema_retry = RetryPolicy::new(2);
657 let policy = match &self.retry_policy {
658 Some(p) => p,
659 None if self.config.json_schema.is_some() => &default_schema_retry,
660 None => return result,
661 };
662
663 if let Err(ref err) = result {
665 if !crate::retry::is_retryable(err) {
666 return result;
667 }
668 } else {
669 return result;
670 }
671
672 let mut last_result = result;
673
674 for attempt in 0..policy.max_retries {
675 let delay = policy.delay_for_attempt(attempt);
676 let retry_reason = if matches!(
677 &last_result,
678 Err(OperationError::Agent(
679 crate::error::AgentError::SchemaValidation { .. }
680 ))
681 ) {
682 "structured_output was null (CLI non-determinism)"
683 } else {
684 "transient failure"
685 };
686 warn!(
687 attempt = attempt + 1,
688 max_retries = policy.max_retries,
689 delay_ms = delay.as_millis() as u64,
690 reason = retry_reason,
691 "retrying agent invocation"
692 );
693 time::sleep(delay).await;
694
695 last_result = self.invoke_once(provider).await;
696
697 match &last_result {
698 Ok(_) => return last_result,
699 Err(err) if !crate::retry::is_retryable(err) => return last_result,
700 _ => {}
701 }
702 }
703
704 last_result
705 }
706
707 async fn invoke_once(
709 &self,
710 provider: &dyn AgentProvider,
711 ) -> Result<AgentResult, OperationError> {
712 #[cfg(feature = "prometheus")]
713 let model_label = self.config.model.to_string();
714
715 let invoke_result = match self.log_sink {
716 Some(ref sink) => provider.invoke_with_logs(&self.config, sink.clone()).await,
717 None => provider.invoke(&self.config).await,
718 };
719 let output = match invoke_result {
720 Ok(output) => output,
721 Err(e) => {
722 #[cfg(feature = "prometheus")]
723 {
724 metrics::counter!(metric_names::AGENT_TOTAL, "model" => model_label.clone(), "status" => metric_names::STATUS_ERROR).increment(1);
725 }
726 return Err(OperationError::Agent(e));
727 }
728 };
729
730 info!(
731 duration_ms = output.duration_ms,
732 cost_usd = output.cost_usd,
733 input_tokens = output.input_tokens,
734 output_tokens = output.output_tokens,
735 model = output.model,
736 "agent completed"
737 );
738
739 #[cfg(feature = "prometheus")]
740 {
741 metrics::counter!(metric_names::AGENT_TOTAL, "model" => model_label.clone(), "status" => metric_names::STATUS_SUCCESS).increment(1);
742 metrics::histogram!(metric_names::AGENT_DURATION_SECONDS, "model" => model_label.clone())
743 .record(output.duration_ms as f64 / 1000.0);
744 if let Some(cost) = output.cost_usd {
745 metrics::gauge!(metric_names::AGENT_COST_USD_TOTAL, "model" => model_label.clone())
746 .increment(cost);
747 }
748 if let Some(tokens) = output.input_tokens {
749 metrics::counter!(metric_names::AGENT_TOKENS_INPUT_TOTAL, "model" => model_label.clone()).increment(tokens);
750 }
751 if let Some(tokens) = output.output_tokens {
752 metrics::counter!(metric_names::AGENT_TOKENS_OUTPUT_TOTAL, "model" => model_label)
753 .increment(tokens);
754 }
755 }
756
757 Ok(AgentResult { output })
758 }
759}
760
761impl Default for Agent {
762 fn default() -> Self {
763 Self::new()
764 }
765}
766
767#[derive(Debug)]
772pub struct AgentResult {
773 output: AgentOutput,
774}
775
776impl AgentResult {
777 pub fn text(&self) -> &str {
782 match self.output.value.as_str() {
783 Some(s) => s,
784 None => {
785 warn!(
786 value_type = self.output.value.to_string(),
787 "agent output is not a string, returning empty"
788 );
789 ""
790 }
791 }
792 }
793
794 pub fn value(&self) -> &Value {
796 &self.output.value
797 }
798
799 pub fn json<T: DeserializeOwned>(&self) -> Result<T, OperationError> {
809 from_value(self.output.value.clone()).map_err(OperationError::deserialize::<T>)
810 }
811
812 pub fn into_json<T: DeserializeOwned>(self) -> Result<T, OperationError> {
818 from_value(self.output.value).map_err(OperationError::deserialize::<T>)
819 }
820
821 #[cfg(test)]
826 pub(crate) fn from_output(output: AgentOutput) -> Self {
827 Self { output }
828 }
829
830 pub fn session_id(&self) -> Option<&str> {
832 self.output.session_id.as_deref()
833 }
834
835 pub fn cost_usd(&self) -> Option<f64> {
837 self.output.cost_usd
838 }
839
840 pub fn input_tokens(&self) -> Option<u64> {
842 self.output.input_tokens
843 }
844
845 pub fn output_tokens(&self) -> Option<u64> {
847 self.output.output_tokens
848 }
849
850 pub fn duration_ms(&self) -> u64 {
852 self.output.duration_ms
853 }
854
855 pub fn model(&self) -> Option<&str> {
857 self.output.model.as_deref()
858 }
859
860 pub fn debug_messages(&self) -> Option<&[DebugMessage]> {
866 self.output.debug_messages.as_deref()
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::error::AgentError;
874 use crate::provider::InvokeFuture;
875 use serde_json::json;
876
877 struct TestProvider {
878 output: AgentOutput,
879 }
880
881 impl AgentProvider for TestProvider {
882 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
883 Box::pin(async move {
884 Ok(AgentOutput {
885 value: self.output.value.clone(),
886 session_id: self.output.session_id.clone(),
887 cost_usd: self.output.cost_usd,
888 input_tokens: self.output.input_tokens,
889 output_tokens: self.output.output_tokens,
890 model: self.output.model.clone(),
891 duration_ms: self.output.duration_ms,
892 debug_messages: None,
893 })
894 })
895 }
896 }
897
898 struct ConfigCapture {
899 output: AgentOutput,
900 }
901
902 impl AgentProvider for ConfigCapture {
903 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
904 let config_json = serde_json::to_value(config).unwrap();
905 Box::pin(async move {
906 Ok(AgentOutput {
907 value: config_json,
908 session_id: self.output.session_id.clone(),
909 cost_usd: self.output.cost_usd,
910 input_tokens: self.output.input_tokens,
911 output_tokens: self.output.output_tokens,
912 model: self.output.model.clone(),
913 duration_ms: self.output.duration_ms,
914 debug_messages: None,
915 })
916 })
917 }
918 }
919
920 fn default_output() -> AgentOutput {
921 AgentOutput {
922 value: json!("test output"),
923 session_id: Some("sess-123".to_string()),
924 cost_usd: Some(0.05),
925 input_tokens: Some(100),
926 output_tokens: Some(50),
927 model: Some("sonnet".to_string()),
928 duration_ms: 1500,
929 debug_messages: None,
930 }
931 }
932
933 #[test]
936 fn model_constants_have_expected_values() {
937 assert_eq!(Model::SONNET, "sonnet");
938 assert_eq!(Model::OPUS, "opus");
939 assert_eq!(Model::HAIKU, "haiku");
940 assert_eq!(Model::HAIKU_45, "claude-haiku-4-5-20251001");
941 assert_eq!(Model::SONNET_46, "claude-sonnet-4-6");
942 assert_eq!(Model::OPUS_46, "claude-opus-4-6");
943 assert_eq!(Model::SONNET_46_1M, "claude-sonnet-4-6[1m]");
944 assert_eq!(Model::OPUS_46_1M, "claude-opus-4-6[1m]");
945 assert_eq!(Model::OPUS_47, "claude-opus-4-7");
946 assert_eq!(Model::OPUS_47_1M, "claude-opus-4-7[1m]");
947 assert_eq!(Model::OPUS_48, "claude-opus-4-8");
948 assert_eq!(Model::OPUS_48_1M, "claude-opus-4-8[1m]");
949 assert_eq!(Model::FABLE_5, "claude-fable-5");
950 assert_eq!(Model::FABLE_51, "claude-fable-5-1");
951 assert_eq!(Model::MYTHOS_5, "claude-mythos-5");
952 assert_eq!(Model::OPUS_5, "claude-opus-5");
953 assert_eq!(Model::OPUS_5_1M, "claude-opus-5[1m]");
954 assert_eq!(Model::SONNET_5, "claude-sonnet-5");
955 assert_eq!(Model::SONNET_5_1M, "claude-sonnet-5[1m]");
956 }
957
958 #[tokio::test]
961 async fn agent_new_default_values() {
962 let provider = ConfigCapture {
963 output: default_output(),
964 };
965 let result = Agent::new().prompt("hi").run(&provider).await.unwrap();
966
967 let config = result.value();
968 assert_eq!(config["system_prompt"], json!(null));
969 assert_eq!(config["prompt"], json!("hi"));
970 assert_eq!(config["model"], json!("sonnet"));
971 assert_eq!(config["allowed_tools"], json!([]));
972 assert_eq!(config["max_turns"], json!(null));
973 assert_eq!(config["max_budget_usd"], json!(null));
974 assert_eq!(config["working_dir"], json!(null));
975 assert_eq!(config["mcp_config"], json!(null));
976 assert_eq!(config["permission_mode"], json!("Default"));
977 assert_eq!(config["json_schema"], json!(null));
978 }
979
980 #[tokio::test]
981 async fn agent_default_matches_new() {
982 let provider = ConfigCapture {
983 output: default_output(),
984 };
985 let result_new = Agent::new().prompt("x").run(&provider).await.unwrap();
986 let result_default = Agent::default().prompt("x").run(&provider).await.unwrap();
987
988 assert_eq!(result_new.value(), result_default.value());
989 }
990
991 #[tokio::test]
994 async fn builder_methods_store_values_correctly() {
995 let provider = ConfigCapture {
996 output: default_output(),
997 };
998 let result = Agent::new()
999 .system_prompt("you are a bot")
1000 .prompt("do something")
1001 .model(Model::OPUS)
1002 .allowed_tools(&["Read", "Write"])
1003 .max_turns(5)
1004 .max_budget_usd(1.5)
1005 .working_dir("/tmp")
1006 .mcp_config("{}")
1007 .permission_mode(PermissionMode::Auto)
1008 .run(&provider)
1009 .await
1010 .unwrap();
1011
1012 let config = result.value();
1013 assert_eq!(config["system_prompt"], json!("you are a bot"));
1014 assert_eq!(config["prompt"], json!("do something"));
1015 assert_eq!(config["model"], json!("opus"));
1016 assert_eq!(config["allowed_tools"], json!(["Read", "Write"]));
1017 assert_eq!(config["max_turns"], json!(5));
1018 assert_eq!(config["max_budget_usd"], json!(1.5));
1019 assert_eq!(config["working_dir"], json!("/tmp"));
1020 assert_eq!(config["mcp_config"], json!("{}"));
1021 assert_eq!(config["permission_mode"], json!("Auto"));
1022 }
1023
1024 #[test]
1027 #[should_panic(expected = "max_turns must be greater than 0")]
1028 fn max_turns_zero_panics() {
1029 let _ = Agent::new().max_turns(0);
1030 }
1031
1032 #[test]
1033 #[should_panic(expected = "budget must be a positive finite number")]
1034 fn max_budget_negative_panics() {
1035 let _ = Agent::new().max_budget_usd(-1.0);
1036 }
1037
1038 #[test]
1039 #[should_panic(expected = "budget must be a positive finite number")]
1040 fn max_budget_nan_panics() {
1041 let _ = Agent::new().max_budget_usd(f64::NAN);
1042 }
1043
1044 #[test]
1045 #[should_panic(expected = "budget must be a positive finite number")]
1046 fn max_budget_infinity_panics() {
1047 let _ = Agent::new().max_budget_usd(f64::INFINITY);
1048 }
1049
1050 #[tokio::test]
1053 async fn agent_result_text_with_string_value() {
1054 let provider = TestProvider {
1055 output: AgentOutput {
1056 value: json!("hello world"),
1057 ..default_output()
1058 },
1059 };
1060 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1061 assert_eq!(result.text(), "hello world");
1062 }
1063
1064 #[tokio::test]
1065 async fn agent_result_text_with_non_string_value() {
1066 let provider = TestProvider {
1067 output: AgentOutput {
1068 value: json!(42),
1069 ..default_output()
1070 },
1071 };
1072 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1073 assert_eq!(result.text(), "");
1074 }
1075
1076 #[tokio::test]
1077 async fn agent_result_text_with_null_value() {
1078 let provider = TestProvider {
1079 output: AgentOutput {
1080 value: json!(null),
1081 ..default_output()
1082 },
1083 };
1084 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1085 assert_eq!(result.text(), "");
1086 }
1087
1088 #[tokio::test]
1089 async fn agent_result_json_successful_deserialize() {
1090 #[derive(Deserialize, PartialEq, Debug)]
1091 struct MyOutput {
1092 name: String,
1093 count: u32,
1094 }
1095 let provider = TestProvider {
1096 output: AgentOutput {
1097 value: json!({"name": "test", "count": 7}),
1098 ..default_output()
1099 },
1100 };
1101 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1102 let parsed: MyOutput = result.json().unwrap();
1103 assert_eq!(parsed.name, "test");
1104 assert_eq!(parsed.count, 7);
1105 }
1106
1107 #[tokio::test]
1108 async fn agent_result_json_failed_deserialize() {
1109 #[derive(Debug, Deserialize)]
1110 #[allow(dead_code)]
1111 struct MyOutput {
1112 name: String,
1113 }
1114 let provider = TestProvider {
1115 output: AgentOutput {
1116 value: json!(42),
1117 ..default_output()
1118 },
1119 };
1120 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1121 let err = result.json::<MyOutput>().unwrap_err();
1122 assert!(matches!(err, OperationError::Deserialize { .. }));
1123 }
1124
1125 #[tokio::test]
1126 async fn agent_result_accessors() {
1127 let provider = TestProvider {
1128 output: AgentOutput {
1129 value: json!("v"),
1130 session_id: Some("s-1".to_string()),
1131 cost_usd: Some(0.123),
1132 input_tokens: Some(999),
1133 output_tokens: Some(456),
1134 model: Some("opus".to_string()),
1135 duration_ms: 2000,
1136 debug_messages: None,
1137 },
1138 };
1139 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1140 assert_eq!(result.session_id(), Some("s-1"));
1141 assert_eq!(result.cost_usd(), Some(0.123));
1142 assert_eq!(result.input_tokens(), Some(999));
1143 assert_eq!(result.output_tokens(), Some(456));
1144 assert_eq!(result.duration_ms(), 2000);
1145 assert_eq!(result.model(), Some("opus"));
1146 }
1147
1148 #[tokio::test]
1151 async fn resume_passes_session_id_in_config() {
1152 let provider = ConfigCapture {
1153 output: default_output(),
1154 };
1155 let result = Agent::new()
1156 .prompt("followup")
1157 .resume("sess-abc")
1158 .run(&provider)
1159 .await
1160 .unwrap();
1161
1162 let config = result.value();
1163 assert_eq!(config["resume_session_id"], json!("sess-abc"));
1164 }
1165
1166 #[tokio::test]
1167 async fn no_resume_has_null_session_id() {
1168 let provider = ConfigCapture {
1169 output: default_output(),
1170 };
1171 let result = Agent::new()
1172 .prompt("first call")
1173 .run(&provider)
1174 .await
1175 .unwrap();
1176
1177 let config = result.value();
1178 assert_eq!(config["resume_session_id"], json!(null));
1179 }
1180
1181 #[test]
1182 #[should_panic(expected = "session_id must not be empty")]
1183 fn resume_empty_session_id_panics() {
1184 let _ = Agent::new().resume("");
1185 }
1186
1187 #[test]
1188 #[should_panic(expected = "session_id must only contain")]
1189 fn resume_invalid_chars_panics() {
1190 let _ = Agent::new().resume("sess;rm -rf /");
1191 }
1192
1193 #[test]
1194 fn resume_valid_formats_accepted() {
1195 let _ = Agent::new().resume("sess-abc123");
1196 let _ = Agent::new().resume("a1b2c3d4_session");
1197 let _ = Agent::new().resume("abc-DEF-123_456");
1198 }
1199
1200 #[tokio::test]
1201 #[should_panic(expected = "prompt must not be empty")]
1202 async fn run_without_prompt_panics() {
1203 let provider = TestProvider {
1204 output: default_output(),
1205 };
1206 let _ = Agent::new().run(&provider).await;
1207 }
1208
1209 #[tokio::test]
1210 #[should_panic(expected = "prompt must not be empty")]
1211 async fn run_with_whitespace_only_prompt_panics() {
1212 let provider = TestProvider {
1213 output: default_output(),
1214 };
1215 let _ = Agent::new().prompt(" ").run(&provider).await;
1216 }
1217
1218 #[tokio::test]
1221 async fn model_accepts_custom_string() {
1222 let provider = ConfigCapture {
1223 output: default_output(),
1224 };
1225 let result = Agent::new()
1226 .prompt("hi")
1227 .model("mistral-large-latest")
1228 .run(&provider)
1229 .await
1230 .unwrap();
1231 assert_eq!(result.value()["model"], json!("mistral-large-latest"));
1232 }
1233
1234 #[tokio::test]
1235 async fn verbose_sets_config_flag() {
1236 let provider = ConfigCapture {
1237 output: default_output(),
1238 };
1239 let result = Agent::new()
1240 .prompt("hi")
1241 .verbose()
1242 .run(&provider)
1243 .await
1244 .unwrap();
1245 assert_eq!(result.value()["verbose"], json!(true));
1246 }
1247
1248 #[tokio::test]
1249 async fn verbose_not_set_by_default() {
1250 let provider = ConfigCapture {
1251 output: default_output(),
1252 };
1253 let result = Agent::new().prompt("hi").run(&provider).await.unwrap();
1254 assert_eq!(result.value()["verbose"], json!(false));
1255 }
1256
1257 #[tokio::test]
1258 async fn debug_messages_none_without_verbose() {
1259 let provider = TestProvider {
1260 output: default_output(),
1261 };
1262 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1263 assert!(result.debug_messages().is_none());
1264 }
1265
1266 #[tokio::test]
1267 async fn model_accepts_owned_string() {
1268 let provider = ConfigCapture {
1269 output: default_output(),
1270 };
1271 let model_name = String::from("gpt-4o");
1272 let result = Agent::new()
1273 .prompt("hi")
1274 .model(model_name)
1275 .run(&provider)
1276 .await
1277 .unwrap();
1278 assert_eq!(result.value()["model"], json!("gpt-4o"));
1279 }
1280
1281 #[tokio::test]
1282 async fn into_json_success() {
1283 #[derive(Deserialize, PartialEq, Debug)]
1284 struct Out {
1285 name: String,
1286 }
1287 let provider = TestProvider {
1288 output: AgentOutput {
1289 value: json!({"name": "test"}),
1290 ..default_output()
1291 },
1292 };
1293 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1294 let parsed: Out = result.into_json().unwrap();
1295 assert_eq!(parsed.name, "test");
1296 }
1297
1298 #[tokio::test]
1299 async fn into_json_failure() {
1300 #[derive(Debug, Deserialize)]
1301 #[allow(dead_code)]
1302 struct Out {
1303 name: String,
1304 }
1305 let provider = TestProvider {
1306 output: AgentOutput {
1307 value: json!(42),
1308 ..default_output()
1309 },
1310 };
1311 let result = Agent::new().prompt("test").run(&provider).await.unwrap();
1312 let err = result.into_json::<Out>().unwrap_err();
1313 assert!(matches!(err, OperationError::Deserialize { .. }));
1314 }
1315
1316 #[test]
1317 fn from_output_creates_result() {
1318 let output = AgentOutput {
1319 value: json!("hello"),
1320 ..default_output()
1321 };
1322 let result = AgentResult::from_output(output);
1323 assert_eq!(result.text(), "hello");
1324 assert_eq!(result.cost_usd(), Some(0.05));
1325 }
1326
1327 #[test]
1328 #[should_panic(expected = "budget must be a positive finite number")]
1329 fn max_budget_zero_panics() {
1330 let _ = Agent::new().max_budget_usd(0.0);
1331 }
1332
1333 #[test]
1334 fn model_constant_equality() {
1335 assert_eq!(Model::SONNET, "sonnet");
1336 assert_ne!(Model::SONNET, Model::OPUS);
1337 }
1338
1339 #[test]
1340 fn permission_mode_serialize_deserialize_roundtrip() {
1341 for mode in [
1342 PermissionMode::Default,
1343 PermissionMode::Auto,
1344 PermissionMode::DontAsk,
1345 PermissionMode::BypassPermissions,
1346 ] {
1347 let json = to_string(&mode).unwrap();
1348 let back: PermissionMode = serde_json::from_str(&json).unwrap();
1349 assert_eq!(format!("{:?}", mode), format!("{:?}", back));
1350 }
1351 }
1352
1353 #[test]
1356 fn retry_builder_stores_policy() {
1357 let agent = Agent::new().retry(3);
1358 assert!(agent.retry_policy.is_some());
1359 assert_eq!(agent.retry_policy.unwrap().max_retries(), 3);
1360 }
1361
1362 #[test]
1363 fn retry_policy_builder_stores_custom_policy() {
1364 use crate::retry::RetryPolicy;
1365 let policy = RetryPolicy::new(5).backoff(Duration::from_secs(1));
1366 let agent = Agent::new().retry_policy(policy);
1367 let p = agent.retry_policy.unwrap();
1368 assert_eq!(p.max_retries(), 5);
1369 }
1370
1371 #[test]
1372 fn no_retry_by_default() {
1373 let agent = Agent::new();
1374 assert!(agent.retry_policy.is_none());
1375 }
1376
1377 use std::sync::Arc;
1380 use std::sync::atomic::{AtomicU32, Ordering};
1381 use std::time::Duration;
1382
1383 struct FailNTimesProvider {
1384 fail_count: AtomicU32,
1385 failures_before_success: u32,
1386 output: AgentOutput,
1387 }
1388
1389 impl AgentProvider for FailNTimesProvider {
1390 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1391 Box::pin(async move {
1392 let current = self.fail_count.fetch_add(1, Ordering::SeqCst);
1393 if current < self.failures_before_success {
1394 Err(AgentError::ProcessFailed {
1395 exit_code: 1,
1396 stderr: format!("transient failure #{}", current + 1),
1397 })
1398 } else {
1399 Ok(AgentOutput {
1400 value: self.output.value.clone(),
1401 session_id: self.output.session_id.clone(),
1402 cost_usd: self.output.cost_usd,
1403 input_tokens: self.output.input_tokens,
1404 output_tokens: self.output.output_tokens,
1405 model: self.output.model.clone(),
1406 duration_ms: self.output.duration_ms,
1407 debug_messages: None,
1408 })
1409 }
1410 })
1411 }
1412 }
1413
1414 #[tokio::test]
1415 async fn retry_succeeds_after_transient_failures() {
1416 let provider = FailNTimesProvider {
1417 fail_count: AtomicU32::new(0),
1418 failures_before_success: 2,
1419 output: default_output(),
1420 };
1421 let result = Agent::new()
1422 .prompt("test")
1423 .retry_policy(crate::retry::RetryPolicy::new(3).backoff(Duration::from_millis(1)))
1424 .run(&provider)
1425 .await;
1426
1427 assert!(result.is_ok());
1428 assert_eq!(provider.fail_count.load(Ordering::SeqCst), 3); }
1430
1431 #[tokio::test]
1432 async fn retry_exhausted_returns_last_error() {
1433 let provider = FailNTimesProvider {
1434 fail_count: AtomicU32::new(0),
1435 failures_before_success: 10, output: default_output(),
1437 };
1438 let result = Agent::new()
1439 .prompt("test")
1440 .retry_policy(crate::retry::RetryPolicy::new(2).backoff(Duration::from_millis(1)))
1441 .run(&provider)
1442 .await;
1443
1444 assert!(result.is_err());
1445 assert_eq!(provider.fail_count.load(Ordering::SeqCst), 3);
1447 }
1448
1449 #[tokio::test]
1450 async fn retry_does_not_retry_prompt_too_large() {
1451 let call_count = Arc::new(AtomicU32::new(0));
1452 let count = call_count.clone();
1453
1454 struct CountingNonRetryable {
1455 count: Arc<AtomicU32>,
1456 }
1457 impl AgentProvider for CountingNonRetryable {
1458 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1459 self.count.fetch_add(1, Ordering::SeqCst);
1460 Box::pin(async move {
1461 Err(AgentError::PromptTooLarge {
1462 chars: 1_000_000,
1463 estimated_tokens: 250_000,
1464 model_limit: 200_000,
1465 })
1466 })
1467 }
1468 }
1469
1470 let provider = CountingNonRetryable { count };
1471 let result = Agent::new()
1472 .prompt("test")
1473 .retry_policy(crate::retry::RetryPolicy::new(3).backoff(Duration::from_millis(1)))
1474 .run(&provider)
1475 .await;
1476
1477 assert!(result.is_err());
1478 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1479 }
1480
1481 #[tokio::test]
1482 async fn retry_retries_schema_validation_errors() {
1483 let call_count = Arc::new(AtomicU32::new(0));
1484 let count = call_count.clone();
1485
1486 struct SchemaFailProvider {
1487 count: Arc<AtomicU32>,
1488 }
1489 impl AgentProvider for SchemaFailProvider {
1490 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1491 self.count.fetch_add(1, Ordering::SeqCst);
1492 Box::pin(async move {
1493 Err(AgentError::SchemaValidation {
1494 expected: "object".to_string(),
1495 got: "null".to_string(),
1496 debug_messages: Vec::new(),
1497 partial_usage: Box::default(),
1498 raw_response: None,
1499 })
1500 })
1501 }
1502 }
1503
1504 let provider = SchemaFailProvider { count };
1505 let result = Agent::new()
1506 .prompt("test")
1507 .retry_policy(crate::retry::RetryPolicy::new(2).backoff(Duration::from_millis(1)))
1508 .run(&provider)
1509 .await;
1510
1511 assert!(result.is_err());
1512 assert_eq!(call_count.load(Ordering::SeqCst), 3);
1514 }
1515
1516 #[tokio::test]
1517 async fn schema_validation_succeeds_on_retry() {
1518 let call_count = Arc::new(AtomicU32::new(0));
1519 let count = call_count.clone();
1520
1521 struct SchemaFailThenSucceed {
1522 count: Arc<AtomicU32>,
1523 output: AgentOutput,
1524 }
1525 impl AgentProvider for SchemaFailThenSucceed {
1526 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1527 let current = self.count.fetch_add(1, Ordering::SeqCst);
1528 let output = self.output.clone();
1529 Box::pin(async move {
1530 if current == 0 {
1531 Err(AgentError::SchemaValidation {
1532 expected: "structured_output field".to_string(),
1533 got: "null".to_string(),
1534 debug_messages: Vec::new(),
1535 partial_usage: Box::default(),
1536 raw_response: None,
1537 })
1538 } else {
1539 Ok(output)
1540 }
1541 })
1542 }
1543 }
1544
1545 let provider = SchemaFailThenSucceed {
1546 count,
1547 output: default_output(),
1548 };
1549 let result = Agent::new()
1550 .prompt("test")
1551 .retry_policy(crate::retry::RetryPolicy::new(1).backoff(Duration::from_millis(1)))
1552 .run(&provider)
1553 .await;
1554
1555 assert!(result.is_ok());
1556 assert_eq!(call_count.load(Ordering::SeqCst), 2);
1557 }
1558
1559 #[tokio::test]
1560 async fn auto_retry_applied_when_json_schema_set() {
1561 let call_count = Arc::new(AtomicU32::new(0));
1562 let count = call_count.clone();
1563
1564 struct AlwaysSchemaFail {
1565 count: Arc<AtomicU32>,
1566 }
1567 impl AgentProvider for AlwaysSchemaFail {
1568 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1569 self.count.fetch_add(1, Ordering::SeqCst);
1570 Box::pin(async move {
1571 Err(AgentError::SchemaValidation {
1572 expected: "object".to_string(),
1573 got: "null".to_string(),
1574 debug_messages: Vec::new(),
1575 partial_usage: Box::default(),
1576 raw_response: None,
1577 })
1578 })
1579 }
1580 }
1581
1582 let provider = AlwaysSchemaFail { count };
1583 let result = Agent::new()
1584 .prompt("test")
1585 .output_schema_raw(r#"{"type":"object"}"#)
1586 .run(&provider)
1587 .await;
1588
1589 assert!(result.is_err());
1590 assert_eq!(call_count.load(Ordering::SeqCst), 3);
1592 }
1593
1594 #[tokio::test]
1595 async fn no_retry_without_policy() {
1596 let provider = FailNTimesProvider {
1597 fail_count: AtomicU32::new(0),
1598 failures_before_success: 1,
1599 output: default_output(),
1600 };
1601 let result = Agent::new().prompt("test").run(&provider).await;
1602
1603 assert!(result.is_err());
1604 assert_eq!(provider.fail_count.load(Ordering::SeqCst), 1);
1605 }
1606
1607 use crate::test_support::VecSink;
1610
1611 struct SinkCapture {
1612 output: AgentOutput,
1613 saw_logs: Arc<AtomicU32>,
1614 }
1615
1616 impl AgentProvider for SinkCapture {
1617 fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
1618 Box::pin(async {
1619 Ok(AgentOutput {
1620 value: self.output.value.clone(),
1621 session_id: self.output.session_id.clone(),
1622 cost_usd: self.output.cost_usd,
1623 input_tokens: self.output.input_tokens,
1624 output_tokens: self.output.output_tokens,
1625 model: self.output.model.clone(),
1626 duration_ms: self.output.duration_ms,
1627 debug_messages: None,
1628 })
1629 })
1630 }
1631
1632 fn invoke_with_logs<'a>(
1633 &'a self,
1634 config: &'a AgentConfig,
1635 log_sink: Arc<dyn LogSink>,
1636 ) -> InvokeFuture<'a> {
1637 self.saw_logs.fetch_add(1, Ordering::SeqCst);
1638 log_sink.log("stdout", "streaming line");
1639 self.invoke(config)
1640 }
1641 }
1642
1643 #[tokio::test]
1644 async fn log_sink_routes_to_invoke_with_logs() {
1645 let saw_logs = Arc::new(AtomicU32::new(0));
1646 let provider = SinkCapture {
1647 output: default_output(),
1648 saw_logs: saw_logs.clone(),
1649 };
1650 let sink: Arc<dyn LogSink> = VecSink::new();
1651
1652 let result = Agent::new()
1653 .prompt("test")
1654 .log_sink(sink)
1655 .run(&provider)
1656 .await;
1657
1658 assert!(result.is_ok());
1659 assert_eq!(saw_logs.load(Ordering::SeqCst), 1);
1660 }
1661
1662 #[tokio::test]
1663 async fn no_log_sink_routes_to_invoke() {
1664 let saw_logs = Arc::new(AtomicU32::new(0));
1665 let provider = SinkCapture {
1666 output: default_output(),
1667 saw_logs: saw_logs.clone(),
1668 };
1669
1670 let result = Agent::new().prompt("test").run(&provider).await;
1671
1672 assert!(result.is_ok());
1673 assert_eq!(saw_logs.load(Ordering::SeqCst), 0);
1674 }
1675
1676 #[tokio::test]
1677 async fn log_sink_receives_provider_lines() {
1678 let saw_logs = Arc::new(AtomicU32::new(0));
1679 let provider = SinkCapture {
1680 output: default_output(),
1681 saw_logs: saw_logs.clone(),
1682 };
1683 let sink = VecSink::new();
1684
1685 let _ = Agent::new()
1686 .prompt("test")
1687 .log_sink(sink.clone() as Arc<dyn LogSink>)
1688 .run(&provider)
1689 .await;
1690
1691 let lines = sink.0.lock().unwrap();
1692 assert_eq!(lines.len(), 1);
1693 assert_eq!(lines[0].0, "stdout");
1694 assert_eq!(lines[0].1, "streaming line");
1695 }
1696}