1#![allow(
8 deprecated,
9 reason = "deprecated AgentRegistry shims retained for one-release migration; internal use until loader cutover"
10)]
11
12use crate::AgentConfig;
13use crate::{Agent, AgentResponse, ExecutionMetadata};
14use ares_llm::coordinator::ConversationMessage;
15use ares_llm::observability::{LlmCallRecord, ObservabilitySink, ToolCallRecord};
16#[cfg(feature = "postgres")]
17use ares_llm::compact::{CompactConfig, CompactionState, TurnEntry};
18use ares_llm::compact::Compactor;
19use ares_llm::{LLMClient, LLMResponse};
20use ares_tools::Tools;
21use ares_types::types::{AgentContext, AgentType, AppError, ContentPart, Result, ToolDefinition};
22use async_trait::async_trait;
23use cordis::{Context, CordisError, EventsService};
24use std::future::Future;
25use std::sync::Arc;
26
27use cordis::Service;
33
34pub struct PostgresService;
40impl Service for PostgresService {
41 fn check(&self) -> bool {
42 cfg!(feature = "postgres")
43 }
44}
45
46struct ProviderLlm {
47 provider_name: String,
48 llm: Box<dyn LLMClient>,
49}
50
51struct LlmAttemptResponse {
52 response: LLMResponse,
53 provider_name: String,
54 model_name: String,
55}
56
57const SESSION_COMPACTOR_CAP: usize = 256;
64
65#[derive(Default)]
73pub struct SessionCompactors {
74 inner: parking_lot::Mutex<std::collections::HashMap<(String, String), Arc<Compactor>>>,
75}
76
77fn global_session_compactors() -> &'static SessionCompactors {
84 static COMPACTORS: std::sync::LazyLock<SessionCompactors> =
85 std::sync::LazyLock::new(SessionCompactors::new);
86 &COMPACTORS
87}
88
89fn log_compact_event(stage: &'static str, event: &ares_llm::compact::CompactEvent) {
91 use ares_llm::compact::CompactEvent;
92 match event {
93 CompactEvent::Scored { seq, score } => {
94 tracing::debug!(stage, seq, score, "compaction scored turn");
95 }
96 CompactEvent::Audited {
97 critical_kept,
98 memory_chars,
99 dropped_seqs,
100 } => {
101 tracing::debug!(
102 stage,
103 critical_kept,
104 memory_chars,
105 dropped = dropped_seqs.len(),
106 "compaction audit finished"
107 );
108 }
109 CompactEvent::Skipped { reason } => {
110 tracing::debug!(stage, reason, "compaction step skipped");
111 }
112 }
113}
114
115impl SessionCompactors {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 #[cfg(feature = "postgres")]
125 async fn get_or_create(
126 &self,
127 tenant_id: &str,
128 session_id: &str,
129 llm: &ares_llm::Llm,
130 ctx: &Arc<Context>,
131 ) -> Option<Arc<Compactor>> {
132 if let Some(existing) = self.lock().get(&(tenant_id.to_string(), session_id.to_string())) {
133 return Some(Arc::clone(existing));
134 }
135
136 let client = llm.get_client(ctx, ares_llm::CapabilityRequirements::default()).await.ok()?;
137 let mut state = CompactionState::default();
138 if let Some(db) = ctx.get::<ares_store::TenantDb>() {
139 let store = ares_store::postgres::PostgresClient {
140 pool: db.pool().clone(),
141 };
142 match store.get_conversation_snapshot(session_id).await {
143 Ok(Some(row)) => {
144 let entries: Vec<TurnEntry> =
145 serde_json::from_value(row.entries).unwrap_or_default();
146 let critical: Vec<String> =
147 serde_json::from_value(row.critical).unwrap_or_default();
148 state = CompactionState::from_parts(
149 entries,
150 critical,
151 row.memory,
152 row.last_audit_seq.max(0) as u64,
153 );
154 }
155 Ok(None) => {}
156 Err(e) => {
157 tracing::debug!(session_id, error = %e, "snapshot hydrate failed; starting cold");
158 }
159 }
160 }
161
162 let compactor = Arc::new(Compactor::hydrate(
163 CompactConfig::default(),
164 client,
165 state,
166 ));
167 let mut map = self.lock();
168 if map.len() >= SESSION_COMPACTOR_CAP {
169 map.clear();
171 }
172 map.insert(
173 (tenant_id.to_string(), session_id.to_string()),
174 Arc::clone(&compactor),
175 );
176 Some(compactor)
177 }
178
179 #[cfg(not(feature = "postgres"))]
181 async fn get_or_create(
182 &self,
183 tenant_id: &str,
184 session_id: &str,
185 llm: &ares_llm::Llm,
186 ctx: &Arc<Context>,
187 ) -> Option<Arc<Compactor>> {
188 if let Some(existing) = self.lock().get(&(tenant_id.to_string(), session_id.to_string())) {
189 return Some(Arc::clone(existing));
190 }
191 let client = llm.get_client(ctx, ares_llm::CapabilityRequirements::default()).await.ok()?;
192 let compactor = Arc::new(Compactor::with_client(client));
193 let mut map = self.lock();
194 if map.len() >= SESSION_COMPACTOR_CAP {
195 map.clear();
196 }
197 map.insert(
198 (tenant_id.to_string(), session_id.to_string()),
199 Arc::clone(&compactor),
200 );
201 Some(compactor)
202 }
203
204 fn lock(
205 &self,
206 ) -> parking_lot::MutexGuard<
207 '_,
208 std::collections::HashMap<(String, String), Arc<Compactor>>,
209 > {
210 self.inner.lock()
211 }
212}
213
214fn tenant_key_for_compaction(ctx: &Context, fallback_user: &str) -> String {
220 for tid in [
221 std::any::TypeId::of::<crate::Execute>(),
222 std::any::TypeId::of::<ares_tools::Tools>(),
223 ] {
224 if let Some(label) = ctx.isolate_label(tid) {
225 let trimmed = label
226 .strip_prefix("tenant:")
227 .or_else(|| label.strip_prefix("user:"))
228 .unwrap_or(&label);
229 if !trimmed.is_empty() {
230 return trimmed.to_string();
231 }
232 }
233 }
234 if let Some(tc) = ctx.get::<ares_types::models::TenantContext>() {
235 if !tc.tenant_id.is_empty() {
236 return tc.tenant_id.clone();
237 }
238 }
239 fallback_user.to_string()
240}
241
242#[cfg(feature = "postgres")]
247async fn persist_session_snapshot(compactor: &Compactor, pool: sqlx::PgPool, session_key: &str) {
248 let state = compactor.export();
249 let entries = match serde_json::to_value(state.entries()) {
250 Ok(v) => v,
251 Err(e) => {
252 tracing::debug!(session_key, error = %e, "snapshot entries serialize failed");
253 return;
254 }
255 };
256 let critical = match serde_json::to_value(state.critical()) {
257 Ok(v) => v,
258 Err(e) => {
259 tracing::debug!(session_key, error = %e, "snapshot critical serialize failed");
260 return;
261 }
262 };
263 let store = ares_store::postgres::PostgresClient { pool };
264 if let Err(e) = store
265 .upsert_conversation_snapshot(
266 session_key,
267 entries,
268 critical,
269 state.memory(),
270 state.last_audit_seq() as i64,
271 )
272 .await
273 {
274 tracing::debug!(session_key, error = %e, "conversation snapshot persist failed");
275 }
276}
277
278pub struct ConfigurableAgent {
280 name: String,
282 agent_type: AgentType,
284 llm: Box<dyn LLMClient>,
286 provider_name: String,
288 system_prompt: String,
290 tools: Option<Arc<Tools>>,
292 cordis_ctx: Option<Arc<Context>>,
294 allowed_tools: Option<Vec<String>>,
297 max_tool_iterations: usize,
299 parallel_tools: bool,
301 observability: Option<Arc<dyn ObservabilitySink>>,
303 fallback_llms: Vec<ProviderLlm>,
305 run_id: Option<String>,
307 compaction: bool,
309 user_parts: Vec<ContentPart>,
311 previous_response_id: Option<String>,
313 web_search: bool,
315}
316
317fn is_prebuilt_connector_tool(name: &str) -> bool {
318 matches!(
319 name,
320 "google_calendar_list_events"
321 | "google_calendar_create_event"
322 | "google_calendar_delete_event"
323 | "google_calendar_get_free_busy"
324 | "gmail_send_email"
325 | "gmail_list_messages"
326 | "gmail_get_message"
327 | "hubspot_get_contact"
328 | "hubspot_create_contact"
329 | "hubspot_list_deals"
330 | "hubspot_create_deal"
331 | "linkedin_create_share"
332 | "linkedin_get_company_updates"
333 | "salesforce_soql_query"
334 | "salesforce_get_record"
335 | "salesforce_create_record"
336 | "slack_send_message"
337 | "slack_list_channels"
338 | "slack_upload_file"
339 )
340}
341
342fn history_messages_from_payload(payload: &serde_json::Value) -> Vec<(String, String)> {
343 payload
344 .get("messages")
345 .and_then(|v| v.as_array())
346 .map(|arr| {
347 arr.iter()
348 .filter_map(|m| {
349 Some((
350 m.get("role")?.as_str()?.to_string(),
351 m.get("content")?.as_str()?.to_string(),
352 ))
353 })
354 .collect()
355 })
356 .unwrap_or_default()
357}
358
359fn conversation_messages_from_payload(payload: &serde_json::Value) -> Vec<ConversationMessage> {
360 payload
361 .get("messages")
362 .cloned()
363 .and_then(|v| serde_json::from_value(v).ok())
364 .unwrap_or_default()
365}
366
367fn tools_from_payload(payload: &serde_json::Value) -> Vec<ToolDefinition> {
368 let Some(v) = payload.get("tools") else {
369 return Vec::new();
370 };
371 if let Ok(defs) = serde_json::from_value::<Vec<ToolDefinition>>(v.clone()) {
372 return defs;
373 }
374 v.as_array()
375 .map(|arr| {
376 arr.iter()
377 .filter_map(|t| {
378 let name = t.as_str().or_else(|| t.get("name")?.as_str())?;
379 Some(ToolDefinition {
380 name: name.to_string(),
381 description: String::new(),
382 parameters: serde_json::json!({}),
383 })
384 })
385 .collect()
386 })
387 .unwrap_or_default()
388}
389
390fn attempt_to_generate_json(attempt: &LlmAttemptResponse) -> serde_json::Value {
391 serde_json::json!({
392 "content": attempt.response.content,
393 "usage": attempt.response.usage,
394 "model_name": attempt.model_name,
395 "provider_name": attempt.provider_name,
396 "tool_calls": attempt.response.tool_calls,
397 "finish_reason": attempt.response.finish_reason,
398 })
399}
400
401fn generate_denied(out: &serde_json::Value) -> bool {
402 match out.get("deny") {
403 Some(serde_json::Value::Bool(true)) => true,
404 Some(serde_json::Value::String(s)) if !s.is_empty() => true,
405 _ => false,
406 }
407}
408
409async fn run_events_waterfall<F, Fut>(
413 events: &EventsService,
414 event: &str,
415 payload: serde_json::Value,
416 core: F,
417) -> std::result::Result<serde_json::Value, CordisError>
418where
419 F: FnOnce(serde_json::Value) -> Fut,
420 Fut: Future<Output = std::result::Result<serde_json::Value, CordisError>>,
421{
422 let (req_tx, req_rx) = tokio::sync::oneshot::channel();
423 let (res_tx, res_rx) = tokio::sync::oneshot::channel();
424 let wf = events.waterfall_around(event.to_string(), payload, move |p| async move {
425 let _ = req_tx.send(p);
426 match res_rx.await {
427 Ok(r) => r,
428 Err(_) => Err(CordisError::Fiber("llm generate core dropped".into())),
429 }
430 });
431 tokio::pin!(wf);
432 tokio::select! {
433 wf_res = &mut wf => wf_res,
434 req = req_rx => {
435 match req {
436 Ok(p) => {
437 let out = core(p).await;
438 let _ = res_tx.send(out);
439 wf.await
440 }
441 Err(_) => wf.await,
442 }
443 }
444 }
445}
446
447impl ConfigurableAgent {
448 #[deprecated(note = "use new_with_tool_service with ctx.get::<dyn ToolService>()")]
454 pub fn new(
455 name: &str,
456 config: &AgentConfig,
457 llm: Box<dyn LLMClient>,
458 tools: Option<Arc<Tools>>,
459 ) -> Self {
460 Self::new_with_provider(name, config, llm, tools, config.model.clone())
461 }
462
463 fn resolve_common_fields(
468 name: &str,
469 config: &AgentConfig,
470 ) -> (AgentType, String, Option<Vec<String>>) {
471 let agent_type = Self::name_to_type(name);
472 let system_prompt = config
473 .system_prompt
474 .clone()
475 .unwrap_or_else(|| Self::default_system_prompt(name));
476 let allowed_tools = config.allowed_tools.clone().or_else(|| {
478 if config.tools.is_empty() {
479 None
480 } else {
481 Some(config.tools.clone())
482 }
483 });
484 (agent_type, system_prompt, allowed_tools)
485 }
486
487 #[deprecated(note = "use new_with_provider_and_tool_service with ctx.get::<dyn ToolService>()")]
491 pub fn new_with_provider(
492 name: &str,
493 config: &AgentConfig,
494 llm: Box<dyn LLMClient>,
495 tools: Option<Arc<Tools>>,
496 provider_name: String,
497 ) -> Self {
498 let (agent_type, system_prompt, allowed_tools) = Self::resolve_common_fields(name, config);
499
500 Self {
501 name: name.to_string(),
502 agent_type,
503 llm,
504 provider_name,
505 system_prompt,
506 tools,
507 cordis_ctx: None,
508 allowed_tools,
509 max_tool_iterations: config.max_tool_iterations,
510 parallel_tools: config.parallel_tools,
511 observability: None,
512 fallback_llms: Vec::new(),
513 run_id: None,
514 compaction: config.compaction_enabled.unwrap_or(false),
515 user_parts: Vec::new(),
516 previous_response_id: None,
517 web_search: false,
518 }
519 }
520
521 #[deprecated(note = "use with_tool_service_params with ctx.get::<dyn ToolService>()")]
525 #[allow(clippy::too_many_arguments)]
526 pub fn with_params(
527 name: &str,
528 agent_type: AgentType,
529 llm: Box<dyn LLMClient>,
530 system_prompt: String,
531 tools: Option<Arc<Tools>>,
532 allowed_tools: Option<Vec<String>>,
533 max_tool_iterations: usize,
534 parallel_tools: bool,
535 ) -> Self {
536 Self {
537 name: name.to_string(),
538 agent_type,
539 llm,
540 provider_name: "unknown".to_string(),
541 system_prompt,
542 tools,
543 cordis_ctx: None,
544 allowed_tools,
545 max_tool_iterations,
546 parallel_tools,
547 observability: None,
548 fallback_llms: Vec::new(),
549 run_id: None,
550 compaction: false,
551 user_parts: Vec::new(),
552 previous_response_id: None,
553 web_search: false,
554 }
555 }
556
557 pub fn new_with_tool_service(
565 name: &str,
566 config: &AgentConfig,
567 llm: Box<dyn LLMClient>,
568 tool_service: Option<Arc<Tools>>,
569 ) -> Self {
570 Self::new_with_provider_and_tool_service(
571 name,
572 config,
573 llm,
574 tool_service,
575 config.model.clone(),
576 )
577 }
578
579 pub fn new_with_provider_and_tool_service(
581 name: &str,
582 config: &AgentConfig,
583 llm: Box<dyn LLMClient>,
584 tool_service: Option<Arc<Tools>>,
585 provider_name: String,
586 ) -> Self {
587 let (agent_type, system_prompt, allowed_tools) = Self::resolve_common_fields(name, config);
588 Self {
589 name: name.to_string(),
590 agent_type,
591 llm,
592 provider_name,
593 system_prompt,
594 tools: tool_service,
595 cordis_ctx: None,
596 allowed_tools,
597 max_tool_iterations: config.max_tool_iterations,
598 parallel_tools: config.parallel_tools,
599 observability: None,
600 fallback_llms: Vec::new(),
601 run_id: None,
602 compaction: config.compaction_enabled.unwrap_or(false),
603 user_parts: Vec::new(),
604 previous_response_id: None,
605 web_search: false,
606 }
607 }
608
609 pub fn set_compaction(&mut self, enabled: bool) {
611 self.compaction = enabled;
612 }
613
614 #[allow(clippy::too_many_arguments)]
616 pub fn with_tool_service_params(
617 name: &str,
618 agent_type: AgentType,
619 llm: Box<dyn LLMClient>,
620 system_prompt: String,
621 tool_service: Option<Arc<Tools>>,
622 allowed_tools: Option<Vec<String>>,
623 max_tool_iterations: usize,
624 parallel_tools: bool,
625 ) -> Self {
626 Self {
627 name: name.to_string(),
628 agent_type,
629 llm,
630 provider_name: "unknown".to_string(),
631 system_prompt,
632 tools: tool_service,
633 cordis_ctx: None,
634 allowed_tools,
635 max_tool_iterations,
636 parallel_tools,
637 observability: None,
638 fallback_llms: Vec::new(),
639 run_id: None,
640 compaction: false,
641 user_parts: Vec::new(),
642 previous_response_id: None,
643 web_search: false,
644 }
645 }
646
647 pub fn new_from_context(
651 ctx: &Arc<Context>,
652 name: &str,
653 config: &AgentConfig,
654 llm: Box<dyn LLMClient>,
655 ) -> Self {
656 let mut agent = Self::new_with_tool_service(name, config, llm, None);
657 if let Some(tools) = ctx.get::<Tools>() {
658 agent.set_tools(tools);
659 }
660 agent.bind_request_ctx(ctx.clone());
661 agent
662 }
663
664 pub fn new_from_context_with_provider(
666 ctx: &Arc<Context>,
667 name: &str,
668 config: &AgentConfig,
669 llm: Box<dyn LLMClient>,
670 provider_name: String,
671 ) -> Self {
672 let mut agent =
673 Self::new_with_provider_and_tool_service(name, config, llm, None, provider_name);
674 if let Some(tools) = ctx.get::<Tools>() {
675 agent.set_tools(tools);
676 }
677 agent.bind_request_ctx(ctx.clone());
678 agent
679 }
680
681 fn name_to_type(name: &str) -> AgentType {
683 AgentType::from_string(name)
684 }
685
686 fn default_system_prompt(name: &str) -> String {
688 match name.to_lowercase().as_str() {
689 "router" => r#"You are a routing agent that classifies user queries.
690Available agents: product, invoice, sales, finance, hr, orchestrator.
691Respond with ONLY the agent name (one word, lowercase)."#
692 .to_string(),
693
694 "orchestrator" => r#"You are an orchestrator agent for complex queries.
695Break down requests, delegate to specialists, and synthesize results."#
696 .to_string(),
697
698 "product" => r#"You are a Product Agent for product-related queries.
699Handle catalog, specifications, inventory, and pricing questions."#
700 .to_string(),
701
702 "invoice" => r#"You are an Invoice Agent for billing queries.
703Handle invoices, payments, and billing history."#
704 .to_string(),
705
706 "sales" => r#"You are a Sales Agent for sales analytics.
707Handle performance metrics, revenue, and customer data."#
708 .to_string(),
709
710 "finance" => r#"You are a Finance Agent for financial analysis.
711Handle statements, budgets, and expense management."#
712 .to_string(),
713
714 "hr" => r#"You are an HR Agent for human resources.
715Handle employee info, policies, and benefits."#
716 .to_string(),
717
718 _ => format!("You are a {} agent.", name),
719 }
720 }
721
722 pub fn name(&self) -> &str {
724 &self.name
725 }
726
727 pub fn max_tool_iterations(&self) -> usize {
729 self.max_tool_iterations
730 }
731
732 pub fn parallel_tools(&self) -> bool {
734 self.parallel_tools
735 }
736
737 pub fn has_tools(&self) -> bool {
739 self.tools.is_some()
740 }
741
742 pub fn tools(&self) -> Option<&Arc<Tools>> {
744 self.tools.as_ref()
745 }
746
747 pub fn set_tools(&mut self, tools: Arc<Tools>) {
749 self.tools = Some(tools);
750 }
751
752 pub fn bind_request_ctx(&mut self, ctx: Arc<Context>) {
755 if let Some(tools) = ctx.get::<Tools>() {
756 self.tools = Some(tools);
757 }
758 self.cordis_ctx = Some(ctx);
759 }
760
761 pub fn allowed_tools(&self) -> Option<&[String]> {
764 self.allowed_tools.as_deref()
765 }
766
767 pub fn set_allowed_tools(&mut self, allowed_tools: Option<Vec<String>>) {
770 self.allowed_tools = allowed_tools;
771 }
772
773 pub fn set_observability(&mut self, obs: Arc<dyn ObservabilitySink>) {
775 self.observability = Some(obs);
776 }
777
778 pub fn set_fallback_llms(&mut self, fallbacks: Vec<Box<dyn LLMClient>>) {
780 self.fallback_llms = fallbacks
781 .into_iter()
782 .map(|llm| ProviderLlm {
783 provider_name: "unknown".to_string(),
784 llm,
785 })
786 .collect();
787 }
788
789 pub fn set_fallback_llms_with_providers(
791 &mut self,
792 fallbacks: Vec<(String, Box<dyn LLMClient>)>,
793 ) {
794 self.fallback_llms = fallbacks
795 .into_iter()
796 .map(|(provider_name, llm)| ProviderLlm { provider_name, llm })
797 .collect();
798 }
799
800 pub fn set_run_id(&mut self, run_id: String) {
802 self.run_id = Some(run_id);
803 }
804
805 pub fn set_user_turn(
807 &mut self,
808 parts: Vec<ContentPart>,
809 previous_response_id: Option<String>,
810 web_search: bool,
811 ) {
812 self.user_parts = parts;
813 self.previous_response_id = previous_response_id;
814 self.web_search = web_search;
815 }
816
817 fn apply_turn_hints(&self) {
818 crate::execution::apply_generation_hints(
819 self.llm.as_ref(),
820 self.web_search,
821 self.previous_response_id.clone(),
822 );
823 for fb in &self.fallback_llms {
824 crate::execution::apply_generation_hints(
825 fb.llm.as_ref(),
826 self.web_search,
827 self.previous_response_id.clone(),
828 );
829 }
830 }
831
832 async fn session_compactor(&self, context: &AgentContext) -> Option<Arc<Compactor>> {
837 let ctx = self.cordis_ctx.as_ref()?;
838 let llm = ctx.get::<ares_llm::Llm>()?;
839 let registry = global_session_compactors();
840 let tenant = tenant_key_for_compaction(ctx.as_ref(), &context.user_id);
841 registry
842 .get_or_create(&tenant, &context.session_id, llm.as_ref(), ctx)
843 .await
844 }
845
846 async fn preflight_budget_check(&self, tenant_id: &str) -> Result<()> {
847 let Some(ctx) = self.cordis_ctx.as_ref() else {
848 return Ok(());
849 };
850 #[cfg(feature = "postgres")]
851 if let Some(db) = ctx.get::<ares_store::TenantDb>() {
852 let store = ares_store::token_budgets::TokenBudgetStore::new(db.pool());
853 let status = store.check_budget(tenant_id).await?;
854 if status.would_exceed {
855 return Err(AppError::RateLimited(format!(
856 "Tenant {} token budget exceeded ({} / {})",
857 tenant_id, status.tokens_used, status.token_limit
858 )));
859 }
860 }
861 Ok(())
862 }
863
864 async fn record_and_check_budget(
865 &self,
866 tenant_id: &str,
867 prompt_tokens: i64,
868 completion_tokens: i64,
869 ) -> Result<()> {
870 let Some(ctx) = self.cordis_ctx.as_ref() else {
871 return Ok(());
872 };
873 #[cfg(feature = "postgres")]
874 if let Some(db) = ctx.get::<ares_store::TenantDb>() {
875 let store = ares_store::token_budgets::TokenBudgetStore::new(db.pool());
876 store
877 .record_usage(
878 tenant_id,
879 self.run_id.as_deref(),
880 &self.name,
881 self.llm.model_name(),
882 prompt_tokens,
883 completion_tokens,
884 )
885 .await?;
886 let status = store.check_budget(tenant_id).await?;
887 if status.percentage >= status.alert_threshold {
888 tracing::warn!(
889 tenant_id,
890 usage_pct = status.percentage,
891 threshold = status.alert_threshold,
892 "Token budget alert threshold crossed"
893 );
894 }
895 if status.would_exceed {
896 tracing::warn!(
897 tenant_id,
898 remaining = status.remaining,
899 "Tenant token budget would be exceeded"
900 );
901 }
902 }
903 Ok(())
904 }
905
906 async fn try_generate_with_history(
908 &self,
909 messages: &[(String, String)],
910 ) -> Result<LlmAttemptResponse> {
911 let ctx = self.cordis_ctx.clone().unwrap_or_else(Context::new_root);
912 let Some(events) = ctx.get::<EventsService>() else {
913 return self.generate_with_history_direct(messages).await;
914 };
915 let orig: Vec<(String, String)> = messages.to_vec();
916 let payload = serde_json::to_value(cordis::LlmGeneratePayload {
917 messages: orig
918 .iter()
919 .map(|(role, content)| cordis::LlmMessage {
920 role: role.clone(),
921 content: serde_json::Value::String(content.clone()),
922 ..Default::default()
923 })
924 .collect(),
925 })
926 .unwrap_or(serde_json::Value::Null);
927 let out = run_events_waterfall(
928 &events,
929 cordis::events_catalog::ev::LLM_GENERATE,
930 payload,
931 |payload| async move {
932 let parsed = history_messages_from_payload(&payload);
933 let msgs = if parsed.is_empty() { orig } else { parsed };
934 match self.generate_with_history_direct(&msgs).await {
935 Ok(attempt) => Ok(attempt_to_generate_json(&attempt)),
936 Err(e) => Err(CordisError::Fiber(e.to_string())),
937 }
938 },
939 )
940 .await
941 .map_err(|e| AppError::Internal(e.to_string()))?;
942 self.generate_attempt_from_payload(out, cordis::events_catalog::ev::LLM_GENERATE)
943 }
944
945 async fn generate_with_history_direct(
946 &self,
947 messages: &[(String, String)],
948 ) -> Result<LlmAttemptResponse> {
949 match self.llm.generate_with_history(messages).await {
950 Ok(response) => Ok(LlmAttemptResponse {
951 response,
952 provider_name: self.provider_name.clone(),
953 model_name: self.llm.model_name().to_string(),
954 }),
955 Err(e) => {
956 let primary_error = e.to_string();
957 let mut fallback_errors = Vec::new();
958 for (i, fallback) in self.fallback_llms.iter().enumerate() {
959 tracing::warn!(
960 agent = %self.name,
961 fallback_idx = %i,
962 "Primary LLM failed, trying fallback"
963 );
964 match fallback.llm.generate_with_history(messages).await {
965 Ok(response) => {
966 tracing::info!(
967 agent = %self.name,
968 fallback_idx = %i,
969 provider = %fallback.provider_name,
970 "Fallback LLM succeeded"
971 );
972 return Ok(LlmAttemptResponse {
973 response,
974 provider_name: fallback.provider_name.clone(),
975 model_name: fallback.llm.model_name().to_string(),
976 });
977 }
978 Err(fallback_error) => {
979 fallback_errors.push(format!("fallback[{i}]: {fallback_error}"));
980 }
981 }
982 }
983 if fallback_errors.is_empty() {
984 Err(e)
985 } else {
986 Err(AppError::LLM(format!(
987 "All LLM providers failed for agent '{}'; primary: {}; {}",
988 self.name,
989 primary_error,
990 fallback_errors.join("; ")
991 )))
992 }
993 }
994 }
995 }
996
997 async fn try_generate_with_tools_and_history(
999 &self,
1000 messages: &[ConversationMessage],
1001 tools: &[ToolDefinition],
1002 ) -> Result<LlmAttemptResponse> {
1003 let ctx = self.cordis_ctx.clone().unwrap_or_else(Context::new_root);
1004 let Some(events) = ctx.get::<EventsService>() else {
1005 return self
1006 .generate_with_tools_and_history_direct(messages, tools)
1007 .await;
1008 };
1009 let orig_messages = messages.to_vec();
1010 let orig_tools = tools.to_vec();
1011 let payload = serde_json::to_value(cordis::LlmGenerateToolsPayload {
1012 messages: orig_messages
1013 .iter()
1014 .map(|m| serde_json::to_value(m).unwrap_or(serde_json::Value::Null))
1015 .collect(),
1016 tools: orig_tools
1017 .iter()
1018 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null))
1019 .collect(),
1020 })
1021 .unwrap_or(serde_json::Value::Null);
1022 let out = run_events_waterfall(
1023 &events,
1024 cordis::events_catalog::ev::LLM_GENERATE_TOOLS,
1025 payload,
1026 |payload| async move {
1027 let parsed_msgs = conversation_messages_from_payload(&payload);
1028 let msgs = if parsed_msgs.is_empty() {
1029 orig_messages
1030 } else {
1031 parsed_msgs
1032 };
1033 let parsed_tools = tools_from_payload(&payload);
1034 let tool_defs = if parsed_tools.is_empty() && payload.get("tools").is_none() {
1035 orig_tools
1036 } else {
1037 parsed_tools
1038 };
1039 match self
1040 .generate_with_tools_and_history_direct(&msgs, &tool_defs)
1041 .await
1042 {
1043 Ok(attempt) => Ok(attempt_to_generate_json(&attempt)),
1044 Err(e) => Err(CordisError::Fiber(e.to_string())),
1045 }
1046 },
1047 )
1048 .await
1049 .map_err(|e| AppError::Internal(e.to_string()))?;
1050 self.generate_attempt_from_payload(out, cordis::events_catalog::ev::LLM_GENERATE_TOOLS)
1051 }
1052
1053 async fn generate_with_tools_and_history_direct(
1054 &self,
1055 messages: &[ConversationMessage],
1056 tools: &[ToolDefinition],
1057 ) -> Result<LlmAttemptResponse> {
1058 match self
1059 .llm
1060 .generate_with_tools_and_history(messages, tools)
1061 .await
1062 {
1063 Ok(response) => Ok(LlmAttemptResponse {
1064 response,
1065 provider_name: self.provider_name.clone(),
1066 model_name: self.llm.model_name().to_string(),
1067 }),
1068 Err(e) => {
1069 let primary_error = e.to_string();
1070 let mut fallback_errors = Vec::new();
1071 for (i, fallback) in self.fallback_llms.iter().enumerate() {
1072 tracing::warn!(
1073 agent = %self.name,
1074 fallback_idx = %i,
1075 "Primary LLM (tools) failed, trying fallback"
1076 );
1077 match fallback
1078 .llm
1079 .generate_with_tools_and_history(messages, tools)
1080 .await
1081 {
1082 Ok(response) => {
1083 tracing::info!(
1084 agent = %self.name,
1085 fallback_idx = %i,
1086 provider = %fallback.provider_name,
1087 "Fallback LLM (tools) succeeded"
1088 );
1089 return Ok(LlmAttemptResponse {
1090 response,
1091 provider_name: fallback.provider_name.clone(),
1092 model_name: fallback.llm.model_name().to_string(),
1093 });
1094 }
1095 Err(fallback_error) => {
1096 fallback_errors.push(format!("fallback[{i}]: {fallback_error}"));
1097 }
1098 }
1099 }
1100 if fallback_errors.is_empty() {
1101 Err(e)
1102 } else {
1103 Err(AppError::LLM(format!(
1104 "All LLM providers failed for agent '{}'; primary: {}; {}",
1105 self.name,
1106 primary_error,
1107 fallback_errors.join("; ")
1108 )))
1109 }
1110 }
1111 }
1112 }
1113
1114 fn generate_attempt_from_payload(
1115 &self,
1116 out: serde_json::Value,
1117 event: &str,
1118 ) -> Result<LlmAttemptResponse> {
1119 if generate_denied(&out) {
1120 let reason = out
1121 .get("reason")
1122 .and_then(|v| v.as_str())
1123 .unwrap_or("denied");
1124 return Err(AppError::InvalidInput(format!("{event} {reason}")));
1125 }
1126 let content = out
1127 .get("content")
1128 .and_then(|v| v.as_str())
1129 .unwrap_or("")
1130 .to_string();
1131 let usage = out
1132 .get("usage")
1133 .cloned()
1134 .and_then(|v| serde_json::from_value(v).ok());
1135 let model_name = out
1136 .get("model_name")
1137 .and_then(|v| v.as_str())
1138 .map(str::to_string)
1139 .unwrap_or_else(|| self.llm.model_name().to_string());
1140 let provider_name = out
1141 .get("provider_name")
1142 .and_then(|v| v.as_str())
1143 .map(str::to_string)
1144 .unwrap_or_else(|| self.provider_name.clone());
1145 let tool_calls = out
1146 .get("tool_calls")
1147 .cloned()
1148 .and_then(|v| serde_json::from_value(v).ok())
1149 .unwrap_or_default();
1150 let finish_reason = out
1151 .get("finish_reason")
1152 .and_then(|v| v.as_str())
1153 .unwrap_or("stop")
1154 .to_string();
1155 Ok(LlmAttemptResponse {
1156 response: LLMResponse {
1157 content,
1158 tool_calls,
1159 finish_reason,
1160 usage,
1161 reasoning_content: None,
1162 response_id: None,
1163 },
1164 provider_name,
1165 model_name,
1166 })
1167 }
1168
1169 pub fn get_filtered_tool_definitions(&self) -> Vec<ToolDefinition> {
1174 let (Some(tools), Some(allowed)) = (&self.tools, &self.allowed_tools) else {
1175 return Vec::new();
1176 };
1177 if allowed.is_empty() {
1178 return Vec::new();
1179 }
1180 let ctx = self.cordis_ctx.clone().unwrap_or_else(Context::new_root);
1181 tools
1182 .list(&ctx)
1183 .into_iter()
1184 .filter(|def| allowed.iter().any(|a| a == &def.name))
1185 .collect()
1186 }
1187
1188 pub fn can_use_tool(&self, tool_name: &str) -> bool {
1191 let whitelisted = match &self.allowed_tools {
1192 Some(allowed) => allowed.iter().any(|allowed| allowed == tool_name),
1193 None => false,
1194 };
1195 if !whitelisted {
1196 return false;
1197 }
1198 self.tools.is_some()
1200 }
1201
1202 fn tenant_scoped_builtin_args(
1203 &self,
1204 name: &str,
1205 mut args: serde_json::Value,
1206 ) -> Result<serde_json::Value> {
1207 if !is_prebuilt_connector_tool(name) {
1208 return Ok(args);
1209 }
1210 let Some(tenant_id) = self.connector_tenant_id() else {
1211 return Err(AppError::InvalidInput(
1212 "tenant_id is required for connector tool execution".to_string(),
1213 ));
1214 };
1215 let serde_json::Value::Object(map) = &mut args else {
1216 return Err(AppError::InvalidInput(
1217 "connector tool arguments must be an object".to_string(),
1218 ));
1219 };
1220 if let Some(provided) = map.get("tenant_id").and_then(|value| value.as_str()) {
1221 if provided != tenant_id {
1222 return Err(AppError::Auth(
1223 "connector tenant_id does not match executing tenant".to_string(),
1224 ));
1225 }
1226 }
1227 map.insert(
1228 "tenant_id".to_string(),
1229 serde_json::Value::String(tenant_id),
1230 );
1231 Ok(args)
1232 }
1233
1234 fn connector_tenant_id(&self) -> Option<String> {
1235 let ctx = self.cordis_ctx.as_ref()?;
1236 let id = crate::user_id_from_ctx(ctx, "");
1237 if id.is_empty() {
1238 None
1239 } else {
1240 Some(id)
1241 }
1242 }
1243
1244 async fn dispatch_tool(
1246 &self,
1247 name: &str,
1248 args: serde_json::Value,
1249 ) -> Result<serde_json::Value> {
1250 let Some(tools) = &self.tools else {
1251 return Err(AppError::NotFound(format!("Tool not found: {name}")));
1252 };
1253 let ctx = self.cordis_ctx.clone().unwrap_or_else(Context::new_root);
1254 let args = self.tenant_scoped_builtin_args(name, args)?;
1255 tools.execute(&ctx, name, args).await
1256 }
1257
1258 fn observed_tool_type(&self, name: &str, is_builtin: bool) -> String {
1259 if is_builtin {
1260 return "builtin".to_string();
1261 }
1262 let _ = name;
1263 "runtime".to_string()
1264 }
1265
1266 fn effective_system_prompt(&self) -> String {
1267 if let Some(ctx) = &self.cordis_ctx {
1268 if let Some(ext) = ctx.get::<crate::external_context::ExternalContext>() {
1269 if !ext.0.is_empty() {
1270 tracing::debug!(
1271 agent = %self.name,
1272 ctx_len = ext.0.len(),
1273 "External context injected into system prompt"
1274 );
1275 return format!(
1276 "{}
1277
1278{}
1279
1280When referencing facts above, cite [E1], [E2] etc.",
1281 ext.0, self.system_prompt
1282 );
1283 }
1284 }
1285 }
1286 self.system_prompt.clone()
1287 }
1288
1289 async fn execute_with_tools(
1291 &self,
1292 input: &str,
1293 context: &AgentContext,
1294 ) -> Result<AgentResponse> {
1295 use ares_llm::client::TokenUsage;
1296
1297 let tools = self.get_filtered_tool_definitions();
1298 tracing::debug!(
1299 agent = %self.name,
1300 allowed_tools = ?self.allowed_tools,
1301 tool_count = tools.len(),
1302 "execute_with_tools: tool definitions loaded"
1303 );
1304
1305 let mut messages: Vec<ConversationMessage> = Vec::new();
1306
1307 let effective_prompt = self.effective_system_prompt();
1310 messages.push(ConversationMessage::system(&effective_prompt));
1311
1312 let session_compactor = if self.compaction {
1316 self.session_compactor(context).await
1317 } else {
1318 None
1319 };
1320 #[cfg(feature = "postgres")]
1322 let snapshot_pool: Option<sqlx::PgPool> = if session_compactor.is_some() {
1323 self.cordis_ctx
1324 .as_ref()
1325 .and_then(|ctx| ctx.get::<ares_store::TenantDb>())
1326 .map(|db| db.pool().clone())
1327 } else {
1328 None
1329 };
1330 #[cfg(feature = "postgres")]
1331 let session_key = context.session_id.clone();
1332 #[cfg(feature = "postgres")]
1333 let tenant_key: Option<String> = self.cordis_ctx.as_ref().and_then(|ctx| {
1334 let key = tenant_key_for_compaction(ctx, &context.user_id);
1335 if key.is_empty() { None } else { Some(key) }
1336 });
1337 #[cfg(not(feature = "postgres"))]
1338 let _ = &context;
1339 if let Some(compactor) = &session_compactor {
1340 for (role, content) in compactor.build_context("", 5).into_iter().skip(1) {
1341 if role == "assistant" {
1342 messages.push(ConversationMessage::assistant(&content, vec![]));
1343 } else if role == "user" {
1344 messages.push(ConversationMessage::user(&content));
1345 } else {
1346 messages.push(ConversationMessage::system(&content));
1347 }
1348 }
1349 } else {
1350 for msg in context.conversation_history.iter().rev().take(5).rev() {
1352 let cm = match msg.role {
1353 ares_types::types::MessageRole::User => ConversationMessage::user(&msg.content),
1354 ares_types::types::MessageRole::Assistant => {
1355 ConversationMessage::assistant(&msg.content, vec![])
1356 }
1357 _ => ConversationMessage::system(&msg.content),
1358 };
1359 messages.push(cm);
1360 }
1361 }
1362
1363 messages.push(crate::execution::user_message_with_parts(
1364 input,
1365 self.user_parts.clone(),
1366 self.previous_response_id.clone(),
1367 ));
1368
1369 let mut total_usage = TokenUsage::default();
1370 let mut last_provider_name = self.provider_name.clone();
1371 let mut last_model_name = self.llm.model_name().to_string();
1372
1373 for iteration in 0..self.max_tool_iterations {
1374 self.preflight_budget_check(&context.user_id).await?;
1375
1376 let llm_start = std::time::Instant::now();
1377 let attempt = self
1378 .try_generate_with_tools_and_history(&messages, &tools)
1379 .await?;
1380 let llm_latency = llm_start.elapsed().as_millis() as i64;
1381 last_provider_name = attempt.provider_name;
1382 last_model_name = attempt.model_name;
1383 let response = attempt.response;
1384
1385 {
1386 let prompt_tok = response
1387 .usage
1388 .as_ref()
1389 .map(|u| u.prompt_tokens as i64)
1390 .unwrap_or(0);
1391 let completion_tok = response
1392 .usage
1393 .as_ref()
1394 .map(|u| u.completion_tokens as i64)
1395 .unwrap_or(0);
1396 self.record_and_check_budget(&context.user_id, prompt_tok, completion_tok)
1397 .await?;
1398 }
1399
1400 if let Some(obs) = &self.observability {
1402 let prompt_tok = response
1403 .usage
1404 .as_ref()
1405 .map(|u| u.prompt_tokens as i64)
1406 .unwrap_or(0);
1407 let completion_tok = response
1408 .usage
1409 .as_ref()
1410 .map(|u| u.completion_tokens as i64)
1411 .unwrap_or(0);
1412 let record = LlmCallRecord {
1413 step_index: iteration as i32,
1414 provider: last_provider_name.clone(),
1415 model: last_model_name.clone(),
1416 prompt_tokens: prompt_tok,
1417 completion_tokens: completion_tok,
1418 latency_ms: llm_latency,
1419 status: "success".to_string(),
1420 cached_tokens: response.usage.as_ref().and_then(|u| u.cached_tokens),
1421 total_time_ms: Some(llm_latency),
1422 };
1423 let _ = obs.log_llm_call(record).await;
1424 }
1425
1426 if let Some(usage) = &response.usage {
1427 total_usage = TokenUsage::new(
1428 total_usage.prompt_tokens + usage.prompt_tokens,
1429 total_usage.completion_tokens + usage.completion_tokens,
1430 );
1431 }
1432
1433 if response.tool_calls.is_empty() {
1434 if let Some(compactor) = &session_compactor {
1437 let compactor = Arc::clone(compactor);
1438 let input = input.to_string();
1439 let content = response.content.clone();
1440 tokio::spawn(async move {
1441 let event = compactor.record_turn(input, content).await;
1442 log_compact_event("record_turn", &event);
1443 for event in compactor.audit_if_due().await {
1444 log_compact_event("audit", &event);
1445 }
1446 #[cfg(feature = "postgres")]
1447 if let Some(pool) = snapshot_pool.clone() {
1448 persist_session_snapshot(&compactor, pool, &session_key).await;
1449 }
1450 });
1451 }
1452 return Ok(AgentResponse {
1453 content: response.content,
1454 usage: Some(total_usage),
1455 metadata: Some(ExecutionMetadata {
1456 model_name: last_model_name,
1457 provider_name: last_provider_name,
1458 }),
1459 });
1460 }
1461
1462 messages.push(ConversationMessage::assistant(
1464 &response.content,
1465 response.tool_calls.clone(),
1466 ));
1467
1468 for tc in &response.tool_calls {
1470 if !self.can_use_tool(&tc.name) {
1472 tracing::warn!(
1473 agent = %self.name,
1474 tool = %tc.name,
1475 allowed_tools = ?self.allowed_tools,
1476 "Tool not in allowed_tools list — denying execution"
1477 );
1478 return Err(AppError::Auth(format!(
1479 "Tool '{}' is not allowed for this agent",
1480 tc.name
1481 )));
1482 }
1483
1484 let tool_start = std::time::Instant::now();
1485 let is_builtin = {
1486 let ctx = self.cordis_ctx.clone().unwrap_or_else(Context::new_root);
1487 self.tools
1488 .as_ref()
1489 .and_then(|t| t.resolve(&ctx, &tc.name))
1490 .is_some()
1491 };
1492 let tool_type = self.observed_tool_type(&tc.name, is_builtin);
1493 let result = self.dispatch_tool(&tc.name, tc.arguments.clone()).await;
1494 let tool_latency = tool_start.elapsed().as_millis() as i64;
1495 let result_value = match result {
1496 Ok(v) => v,
1497 Err(e) => serde_json::json!({"error": e.to_string()}),
1498 };
1499
1500 if let Some(obs) = &self.observability {
1502 let status = if result_value.get("error").is_some() {
1503 "error".to_string()
1504 } else {
1505 "success".to_string()
1506 };
1507 let tool_record = ToolCallRecord {
1508 step_index: iteration as i32,
1509 tool_name: tc.name.clone(),
1510 tool_type,
1511 arguments: tc.arguments.clone(),
1512 result: Some(result_value.clone()),
1513 latency_ms: tool_latency,
1514 status,
1515 };
1516 let _ = obs.log_tool_call(tool_record).await;
1517 }
1518
1519 messages.push(ConversationMessage::tool_result(&tc.id, &result_value));
1520 }
1521 }
1522
1523 tracing::warn!(
1527 agent = %self.name,
1528 "Max tool iterations ({}) reached — making final synthesis call",
1529 self.max_tool_iterations
1530 );
1531 self.preflight_budget_check(&context.user_id).await?;
1532
1533 let synth_start = std::time::Instant::now();
1534 let final_response = self
1535 .try_generate_with_tools_and_history(&messages, &[])
1536 .await;
1537 let synth_latency = synth_start.elapsed().as_millis() as i64;
1538 if let Ok(attempt) = &final_response {
1539 last_provider_name = attempt.provider_name.clone();
1540 last_model_name = attempt.model_name.clone();
1541 }
1542
1543 if let Ok(attempt) = &final_response {
1544 let prompt_tok = attempt
1545 .response
1546 .usage
1547 .as_ref()
1548 .map(|u| u.prompt_tokens as i64)
1549 .unwrap_or(0);
1550 let completion_tok = attempt
1551 .response
1552 .usage
1553 .as_ref()
1554 .map(|u| u.completion_tokens as i64)
1555 .unwrap_or(0);
1556 let _ = self
1557 .record_and_check_budget(&context.user_id, prompt_tok, completion_tok)
1558 .await;
1559 }
1560
1561 if let Some(obs) = &self.observability {
1563 let (prompt_tok, completion_tok, status) = match &final_response {
1564 Ok(attempt) => (
1565 attempt
1566 .response
1567 .usage
1568 .as_ref()
1569 .map(|u| u.prompt_tokens as i64)
1570 .unwrap_or(0),
1571 attempt
1572 .response
1573 .usage
1574 .as_ref()
1575 .map(|u| u.completion_tokens as i64)
1576 .unwrap_or(0),
1577 "success".to_string(),
1578 ),
1579 Err(_) => (0, 0, "error".to_string()),
1580 };
1581 let record = LlmCallRecord {
1582 step_index: self.max_tool_iterations as i32,
1583 provider: last_provider_name.clone(),
1584 model: last_model_name.clone(),
1585 prompt_tokens: prompt_tok,
1586 completion_tokens: completion_tok,
1587 latency_ms: synth_latency,
1588 cached_tokens: final_response
1589 .as_ref()
1590 .ok()
1591 .and_then(|attempt| attempt.response.usage.as_ref())
1592 .and_then(|u| u.cached_tokens),
1593 total_time_ms: Some(synth_latency),
1594 status,
1595 };
1596 let _ = obs.log_llm_call(record).await;
1597 }
1598
1599 if let (Ok(attempt), Some(compactor)) = (&final_response, &session_compactor) {
1602 if !attempt.response.content.is_empty() {
1603 let compactor = Arc::clone(compactor);
1604 let input = input.to_string();
1605 let content = attempt.response.content.clone();
1606 tokio::spawn(async move {
1607 let event = compactor.record_turn(input, content).await;
1608 log_compact_event("record_turn", &event);
1609 for event in compactor.audit_if_due().await {
1610 log_compact_event("audit", &event);
1611 }
1612 #[cfg(feature = "postgres")]
1613 if let Some(pool) = snapshot_pool.clone() {
1614 persist_session_snapshot(&compactor, pool, &session_key).await;
1615 }
1616 });
1617 }
1618 }
1619
1620 let content = match final_response {
1621 Ok(attempt) if !attempt.response.content.is_empty() => attempt.response.content,
1622 Ok(_) => {
1623 messages
1625 .iter()
1626 .rev()
1627 .find(|m| {
1628 m.role == ares_llm::coordinator::MessageRole::Assistant
1629 && !m.content.is_empty()
1630 })
1631 .map(|m| m.content.clone())
1632 .unwrap_or_else(|| {
1633 "Agent completed tool calls but could not generate a final response."
1634 .to_string()
1635 })
1636 }
1637 Err(e) => {
1638 tracing::error!(error = %e, "Final synthesis call failed");
1639 messages
1641 .iter()
1642 .rev()
1643 .find(|m| {
1644 m.role == ares_llm::coordinator::MessageRole::Assistant
1645 && !m.content.is_empty()
1646 })
1647 .map(|m| m.content.clone())
1648 .unwrap_or_else(|| format!("Agent completed but synthesis failed: {}", e))
1649 }
1650 };
1651
1652 Ok(AgentResponse {
1653 content,
1654 usage: Some(total_usage),
1655 metadata: Some(ExecutionMetadata {
1656 model_name: last_model_name,
1657 provider_name: last_provider_name,
1658 }),
1659 })
1660 }
1661}
1662
1663#[async_trait]
1664impl Agent for ConfigurableAgent {
1665 async fn execute(&self, input: &str, context: &AgentContext) -> Result<AgentResponse> {
1666 self.apply_turn_hints();
1667 if self.has_tools()
1668 || !self.user_parts.is_empty()
1669 || self.previous_response_id.is_some()
1670 || self.web_search
1671 {
1672 tracing::debug!(agent = %self.name, "execute: using tool-calling path");
1673 return self.execute_with_tools(input, context).await;
1674 }
1675 tracing::debug!(agent = %self.name, "execute: no tools, using simple path");
1676
1677 let effective_prompt = self.effective_system_prompt();
1680 let mut messages = vec![("system".to_string(), effective_prompt)];
1681
1682 if let Some(memory) = &context.user_memory {
1684 let memory_context = format!(
1685 "User preferences: {}",
1686 memory
1687 .preferences
1688 .iter()
1689 .map(|p| format!("{}: {}", p.key, p.value))
1690 .collect::<Vec<_>>()
1691 .join(", ")
1692 );
1693 messages.push(("system".to_string(), memory_context));
1694 }
1695
1696 let session_compactor = if self.compaction {
1700 self.session_compactor(context).await
1701 } else {
1702 None
1703 };
1704 #[cfg(feature = "postgres")]
1706 let snapshot_pool: Option<sqlx::PgPool> = if session_compactor.is_some() {
1707 self.cordis_ctx
1708 .as_ref()
1709 .and_then(|ctx| ctx.get::<ares_store::TenantDb>())
1710 .map(|db| db.pool().clone())
1711 } else {
1712 None
1713 };
1714 #[cfg(feature = "postgres")]
1715 let session_key = context.session_id.clone();
1716 #[cfg(feature = "postgres")]
1717 let tenant_key: Option<String> = self.cordis_ctx.as_ref().and_then(|ctx| {
1718 let key = tenant_key_for_compaction(ctx, &context.user_id);
1719 if key.is_empty() { None } else { Some(key) }
1720 });
1721 #[cfg(not(feature = "postgres"))]
1722 let _ = &context;
1723 if let Some(compactor) = &session_compactor {
1724 for (role, content) in compactor.build_context("", 5).into_iter().skip(1) {
1725 messages.push((role, content));
1726 }
1727 } else {
1728 for msg in context.conversation_history.iter().rev().take(5).rev() {
1730 let role = match msg.role {
1731 ares_types::types::MessageRole::User => "user",
1732 ares_types::types::MessageRole::Assistant => "assistant",
1733 _ => "system",
1734 };
1735 messages.push((role.to_string(), msg.content.clone()));
1736 }
1737 }
1738
1739 messages.push(("user".to_string(), input.to_string()));
1740
1741 self.preflight_budget_check(&context.user_id).await?;
1742
1743 let llm_start = std::time::Instant::now();
1744 let attempt = self.try_generate_with_history(&messages).await?;
1745 let llm_latency = llm_start.elapsed().as_millis() as i64;
1746 let provider_name = attempt.provider_name;
1747 let model_name = attempt.model_name;
1748 let llm_response = attempt.response;
1749
1750 {
1751 let prompt_tok = llm_response
1752 .usage
1753 .as_ref()
1754 .map(|u| u.prompt_tokens as i64)
1755 .unwrap_or(0);
1756 let completion_tok = llm_response
1757 .usage
1758 .as_ref()
1759 .map(|u| u.completion_tokens as i64)
1760 .unwrap_or(0);
1761 self.record_and_check_budget(&context.user_id, prompt_tok, completion_tok)
1762 .await?;
1763 }
1764
1765 if let Some(obs) = &self.observability {
1767 let prompt_tok = llm_response
1768 .usage
1769 .as_ref()
1770 .map(|u| u.prompt_tokens as i64)
1771 .unwrap_or(0);
1772 let completion_tok = llm_response
1773 .usage
1774 .as_ref()
1775 .map(|u| u.completion_tokens as i64)
1776 .unwrap_or(0);
1777 let record = LlmCallRecord {
1778 step_index: 0,
1779 provider: provider_name.clone(),
1780 model: model_name.clone(),
1781 prompt_tokens: prompt_tok,
1782 completion_tokens: completion_tok,
1783 latency_ms: llm_latency,
1784 status: "success".to_string(),
1785 cached_tokens: llm_response.usage.as_ref().and_then(|u| u.cached_tokens),
1786 total_time_ms: Some(llm_latency),
1787 };
1788 let _ = obs.log_llm_call(record).await;
1789 }
1790
1791 if let Some(compactor) = &session_compactor {
1794 let compactor = Arc::clone(compactor);
1795 let input = input.to_string();
1796 let content = llm_response.content.clone();
1797 tokio::spawn(async move {
1798 let event = compactor.record_turn(input, content).await;
1799 log_compact_event("record_turn", &event);
1800 for event in compactor.audit_if_due().await {
1801 log_compact_event("audit", &event);
1802 }
1803 #[cfg(feature = "postgres")]
1804 if let Some(pool) = snapshot_pool.clone() {
1805 persist_session_snapshot(&compactor, pool, &session_key).await;
1806 }
1807 });
1808 }
1809
1810 Ok(AgentResponse {
1811 content: llm_response.content,
1812 usage: llm_response.usage,
1813 metadata: Some(ExecutionMetadata {
1814 model_name,
1815 provider_name,
1816 }),
1817 })
1818 }
1819
1820 fn system_prompt(&self) -> String {
1821 self.system_prompt.clone()
1822 }
1823
1824 fn agent_type(&self) -> AgentType {
1825 self.agent_type.clone()
1826 }
1827}
1828
1829#[cfg(feature = "postgres")]
1833pub fn agent_config_from_user_agent(user_agent: &ares_store::postgres::UserAgent) -> AgentConfig {
1834 AgentConfig {
1835 model: user_agent.model.clone(),
1836 system_prompt: user_agent.system_prompt.clone(),
1837 tools: user_agent.tools_vec(),
1838 max_tool_iterations: user_agent.max_tool_iterations as usize,
1839 parallel_tools: user_agent.parallel_tools,
1840 compaction_enabled: None,
1841 allowed_tools: None,
1842 extra: std::collections::HashMap::new(),
1843 }
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848 use super::*;
1849 use crate::AgentConfig;
1850 use ares_llm::client::TokenUsage;
1851 use ares_llm::LLMResponse;
1852 use ares_tools::Tool;
1853 use ares_types::types::{Message, MessageRole, Preference, ToolCall, UserMemory};
1854 use chrono::Utc;
1855 use std::collections::{HashMap, VecDeque};
1856 use std::sync::atomic::{AtomicBool, Ordering};
1857 use std::sync::{Arc, Mutex};
1858
1859 struct MockLLM {
1867 content: String,
1868 tool_responses: Arc<Mutex<VecDeque<LLMResponse>>>,
1869 generated: Arc<AtomicBool>,
1870 echo_last: bool,
1871 }
1872
1873 impl MockLLM {
1874 fn new() -> Self {
1875 Self::with_content("mock")
1876 }
1877
1878 fn with_content(content: &str) -> Self {
1879 Self {
1880 content: content.to_string(),
1881 tool_responses: Arc::new(Mutex::new(VecDeque::new())),
1882 generated: Arc::new(AtomicBool::new(false)),
1883 echo_last: false,
1884 }
1885 }
1886
1887 fn with_tool_responses(responses: Vec<LLMResponse>) -> Self {
1890 Self {
1891 content: "mock".to_string(),
1892 tool_responses: Arc::new(Mutex::new(responses.into())),
1893 generated: Arc::new(AtomicBool::new(false)),
1894 echo_last: false,
1895 }
1896 }
1897
1898 fn echo_last() -> Self {
1899 let mut llm = Self::new();
1900 llm.echo_last = true;
1901 llm
1902 }
1903
1904 fn with_generated_flag() -> (Self, Arc<AtomicBool>) {
1905 let generated = Arc::new(AtomicBool::new(false));
1906 (
1907 Self {
1908 content: "should-not-appear".to_string(),
1909 tool_responses: Arc::new(Mutex::new(VecDeque::new())),
1910 generated: Arc::clone(&generated),
1911 echo_last: false,
1912 },
1913 generated,
1914 )
1915 }
1916 }
1917
1918 #[async_trait]
1919 impl LLMClient for MockLLM {
1920 async fn generate(&self, _: &str) -> Result<String> {
1921 Ok(self.content.clone())
1922 }
1923 async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
1924 Ok(self.content.clone())
1925 }
1926 async fn generate_with_history(
1927 &self,
1928 messages: &[(String, String)],
1929 ) -> Result<LLMResponse> {
1930 self.generated.store(true, Ordering::SeqCst);
1931 let content = if self.echo_last {
1932 messages
1933 .last()
1934 .map(|(_, c)| c.clone())
1935 .unwrap_or_else(|| self.content.clone())
1936 } else {
1937 self.content.clone()
1938 };
1939 Ok(LLMResponse {
1940 content,
1941 tool_calls: vec![],
1942 finish_reason: "stop".to_string(),
1943 usage: None,
1944 reasoning_content: None,
1945 response_id: None,
1946 })
1947 }
1948 async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
1949 Ok(LLMResponse {
1950 content: self.content.clone(),
1951 tool_calls: vec![],
1952 finish_reason: "stop".to_string(),
1953 usage: None,
1954 reasoning_content: None,
1955 response_id: None,
1956 })
1957 }
1958 async fn generate_with_tools_and_history(
1959 &self,
1960 _: &[ares_llm::coordinator::ConversationMessage],
1961 _: &[ToolDefinition],
1962 ) -> Result<LLMResponse> {
1963 let mut q = self.tool_responses.lock().unwrap();
1964 Ok(q.pop_front().unwrap_or(LLMResponse {
1965 content: self.content.clone(),
1966 tool_calls: vec![],
1967 finish_reason: "stop".to_string(),
1968 usage: None,
1969 reasoning_content: None,
1970 response_id: None,
1971 }))
1972 }
1973 async fn stream(
1974 &self,
1975 _: &str,
1976 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1977 Ok(Box::new(futures::stream::empty()))
1978 }
1979 async fn stream_with_system(
1980 &self,
1981 _: &str,
1982 _: &str,
1983 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1984 Ok(Box::new(futures::stream::empty()))
1985 }
1986 async fn stream_with_history(
1987 &self,
1988 _: &[(String, String)],
1989 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1990 Ok(Box::new(futures::stream::empty()))
1991 }
1992 fn model_name(&self) -> &str {
1993 "mock"
1994 }
1995 }
1996
1997 struct MockTool {
2000 name: String,
2001 description: String,
2002 }
2003
2004 struct EchoArgsTool {
2005 name: String,
2006 }
2007
2008 impl MockTool {
2009 fn new(name: &str) -> Self {
2010 Self {
2011 name: name.to_string(),
2012 description: format!("Mock tool: {}", name),
2013 }
2014 }
2015 }
2016
2017 #[async_trait]
2018 impl ares_tools::Tool for EchoArgsTool {
2019 fn name(&self) -> &str {
2020 &self.name
2021 }
2022 fn description(&self) -> &str {
2023 "Echoes arguments"
2024 }
2025 fn parameters_schema(&self) -> serde_json::Value {
2026 serde_json::json!({"type":"object"})
2027 }
2028 async fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value> {
2029 Ok(args)
2030 }
2031 }
2032
2033 #[async_trait]
2034 impl ares_tools::Tool for MockTool {
2035 fn name(&self) -> &str {
2036 &self.name
2037 }
2038 fn description(&self) -> &str {
2039 &self.description
2040 }
2041 fn parameters_schema(&self) -> serde_json::Value {
2042 serde_json::json!({})
2043 }
2044 async fn execute(&self, _args: serde_json::Value) -> Result<serde_json::Value> {
2045 Ok(serde_json::json!({"result": "ok"}))
2046 }
2047 }
2048
2049 fn make_config(tools: Vec<&str>, system_prompt: Option<&str>) -> AgentConfig {
2052 AgentConfig {
2053 model: "default".to_string(),
2054 system_prompt: system_prompt.map(String::from),
2055 tools: tools.into_iter().map(String::from).collect(),
2056 allowed_tools: None,
2057 max_tool_iterations: 5,
2058 parallel_tools: false,
2059 compaction_enabled: None,
2060 extra: HashMap::new(),
2061 }
2062 }
2063
2064 fn make_context() -> AgentContext {
2065 AgentContext {
2066 user_id: "test-user".to_string(),
2067 session_id: "test-session".to_string(),
2068 conversation_history: vec![],
2069 user_memory: None,
2070 }
2071 }
2072
2073 fn make_context_with_history(history: Vec<(MessageRole, &str)>) -> AgentContext {
2074 AgentContext {
2075 user_id: "test-user".to_string(),
2076 session_id: "test-session".to_string(),
2077 conversation_history: history
2078 .into_iter()
2079 .map(|(role, content)| Message {
2080 role,
2081 content: content.to_string(),
2082 timestamp: Utc::now(),
2083 })
2084 .collect(),
2085 user_memory: None,
2086 }
2087 }
2088
2089 fn make_tool_response(content: &str, calls: Vec<ToolCall>) -> LLMResponse {
2090 let finish_reason = if calls.is_empty() {
2091 "stop"
2092 } else {
2093 "tool_calls"
2094 };
2095 LLMResponse {
2096 content: content.to_string(),
2097 tool_calls: calls,
2098 finish_reason: finish_reason.to_string(),
2099 usage: Some(TokenUsage::new(10, 5)),
2100 reasoning_content: None,
2101 response_id: None,
2102 }
2103 }
2104
2105 fn make_tools_with_tool(name: &str) -> Arc<Tools> {
2106 Arc::new(Tools::from_static([
2107 Arc::new(MockTool::new(name)) as Arc<dyn Tool>
2108 ]))
2109 }
2110
2111 fn make_tools_with_echo_tool(name: &str) -> Arc<Tools> {
2112 Arc::new(Tools::from_static([Arc::new(EchoArgsTool {
2113 name: name.to_string(),
2114 }) as Arc<dyn Tool>]))
2115 }
2116
2117 #[test]
2122 fn test_default_system_prompt_router() {
2123 let p = ConfigurableAgent::default_system_prompt("router");
2124 assert!(
2125 p.contains("routing"),
2126 "router prompt should mention 'routing'"
2127 );
2128 }
2129
2130 #[test]
2131 fn test_default_system_prompt_orchestrator() {
2132 let p = ConfigurableAgent::default_system_prompt("orchestrator");
2133 assert!(p.contains("orchestrator"));
2134 }
2135
2136 #[test]
2137 fn test_default_system_prompt_product() {
2138 let p = ConfigurableAgent::default_system_prompt("product");
2139 assert!(p.contains("Product"));
2140 }
2141
2142 #[test]
2143 fn test_default_system_prompt_invoice() {
2144 let p = ConfigurableAgent::default_system_prompt("invoice");
2145 assert!(p.contains("Invoice"));
2146 }
2147
2148 #[test]
2149 fn test_default_system_prompt_sales() {
2150 let p = ConfigurableAgent::default_system_prompt("sales");
2151 assert!(p.contains("Sales"));
2152 }
2153
2154 #[test]
2155 fn test_default_system_prompt_finance() {
2156 let p = ConfigurableAgent::default_system_prompt("finance");
2157 assert!(p.contains("Finance"));
2158 }
2159
2160 #[test]
2161 fn test_default_system_prompt_hr() {
2162 let p = ConfigurableAgent::default_system_prompt("hr");
2163 assert!(p.contains("HR"));
2164 }
2165
2166 #[test]
2167 fn test_default_system_prompt_unknown_name() {
2168 let p = ConfigurableAgent::default_system_prompt("unknown_name");
2169 assert_eq!(p, "You are a unknown_name agent.");
2170 }
2171
2172 #[test]
2173 fn test_default_system_prompt_case_insensitive() {
2174 let p = ConfigurableAgent::default_system_prompt("ROUTER");
2175 assert!(p.contains("routing"), "ROUTER should match router branch");
2176 }
2177
2178 #[test]
2183 fn test_name_to_type_router() {
2184 assert!(matches!(
2185 ConfigurableAgent::name_to_type("router"),
2186 AgentType::Router
2187 ));
2188 }
2189
2190 #[test]
2191 fn test_name_to_type_invoice() {
2192 assert!(matches!(
2193 ConfigurableAgent::name_to_type("invoice"),
2194 AgentType::Invoice
2195 ));
2196 }
2197
2198 #[test]
2199 fn test_name_to_type_sales() {
2200 assert!(matches!(
2201 ConfigurableAgent::name_to_type("sales"),
2202 AgentType::Sales
2203 ));
2204 }
2205
2206 #[test]
2207 fn test_name_to_type_finance() {
2208 assert!(matches!(
2209 ConfigurableAgent::name_to_type("finance"),
2210 AgentType::Finance
2211 ));
2212 }
2213
2214 #[test]
2215 fn test_name_to_type_hr() {
2216 assert!(matches!(
2217 ConfigurableAgent::name_to_type("hr"),
2218 AgentType::HR
2219 ));
2220 }
2221
2222 #[test]
2223 fn test_name_to_type_orchestrator() {
2224 assert!(matches!(
2225 ConfigurableAgent::name_to_type("orchestrator"),
2226 AgentType::Orchestrator
2227 ));
2228 }
2229
2230 #[test]
2231 fn test_name_to_type_product_upper() {
2232 assert!(matches!(
2233 ConfigurableAgent::name_to_type("PRODUCT"),
2234 AgentType::Product
2235 ));
2236 }
2237
2238 #[test]
2239 fn test_name_to_type_unknown() {
2240 assert!(matches!(
2241 ConfigurableAgent::name_to_type("unknown"),
2242 AgentType::Custom(_)
2243 ));
2244 }
2245
2246 #[test]
2247 fn test_name_to_type_custom_preserves_name() {
2248 if let AgentType::Custom(name) = ConfigurableAgent::name_to_type("my-custom-agent") {
2249 assert_eq!(name, "my-custom-agent");
2250 } else {
2251 panic!("Expected Custom variant");
2252 }
2253 }
2254
2255 #[test]
2256 fn test_name_to_type_empty_string() {
2257 assert!(matches!(
2258 ConfigurableAgent::name_to_type(""),
2259 AgentType::Custom(ref s) if s.is_empty()
2260 ));
2261 }
2262
2263 #[test]
2268 fn test_name_accessor() {
2269 let config = make_config(vec![], None);
2270 let agent = ConfigurableAgent::new("product", &config, Box::new(MockLLM::new()), None);
2271 assert_eq!(agent.name(), "product");
2272 }
2273
2274 #[test]
2275 fn test_max_tool_iterations_accessor() {
2276 let mut config = make_config(vec![], None);
2277 config.max_tool_iterations = 42;
2278 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2279 assert_eq!(agent.max_tool_iterations(), 42);
2280 }
2281
2282 #[test]
2283 fn test_parallel_tools_accessor() {
2284 let mut config = make_config(vec![], None);
2285 config.parallel_tools = true;
2286 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2287 assert!(agent.parallel_tools());
2288 }
2289
2290 #[test]
2291 fn test_tools_returns_some_when_provided() {
2292 let config = make_config(vec![], None);
2293 let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
2294 let agent = ConfigurableAgent::new(
2295 "router",
2296 &config,
2297 Box::new(MockLLM::new()),
2298 Some(tools.clone()),
2299 );
2300 assert!(agent.tools().is_some());
2301 assert!(Arc::ptr_eq(agent.tools().unwrap(), &tools));
2302 }
2303
2304 #[test]
2305 fn test_tools_returns_none_when_absent() {
2306 let config = make_config(vec![], None);
2307 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2308 assert!(agent.tools().is_none());
2309 }
2310
2311 #[test]
2312 fn test_allowed_tools_from_config() {
2313 let config = make_config(vec!["calculator", "web_search"], None);
2314 let agent = ConfigurableAgent::new("orchestrator", &config, Box::new(MockLLM::new()), None);
2315 let allowed = agent.allowed_tools().expect("should have allowed tools");
2316 assert_eq!(allowed.len(), 2);
2317 assert!(allowed.contains(&"calculator".to_string()));
2318 assert!(allowed.contains(&"web_search".to_string()));
2319 }
2320
2321 #[test]
2326 fn test_can_use_tool_in_allowed_but_no_registry() {
2327 let config = make_config(vec!["calculator"], None);
2328 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2329 assert!(!agent.can_use_tool("calculator"), "no registry → false");
2330 }
2331
2332 #[test]
2333 fn test_can_use_tool_not_in_allowed_list() {
2334 let config = make_config(vec!["calculator"], None);
2335 let reg = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
2336 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), Some(reg));
2337 assert!(!agent.can_use_tool("web_search"), "not in allowed → false");
2338 }
2339
2340 #[test]
2341 fn test_can_use_tool_both_empty() {
2342 let config = make_config(vec![], None);
2343 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2344 assert!(!agent.can_use_tool("anything"));
2345 }
2346
2347 #[test]
2352 fn test_get_filtered_tool_definitions_no_registry() {
2353 let config = make_config(vec!["calculator"], None);
2354 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2355 assert!(agent.get_filtered_tool_definitions().is_empty());
2356 }
2357
2358 #[test]
2359 fn test_get_filtered_tool_definitions_with_registry() {
2360 let reg = make_tools_with_tool("calculator");
2361 let config = make_config(vec!["calculator"], None);
2362 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), Some(reg));
2363 let defs = agent.get_filtered_tool_definitions();
2364 assert_eq!(defs.len(), 1);
2365 assert_eq!(defs[0].name, "calculator");
2366 }
2367
2368 #[test]
2373 fn test_with_params_sets_all_fields() {
2374 let agent = ConfigurableAgent::with_params(
2375 "my-agent",
2376 AgentType::Finance,
2377 Box::new(MockLLM::new()),
2378 "Custom prompt".to_string(),
2379 Some(Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()))),
2380 Some(vec!["tool_a".to_string()]),
2381 10,
2382 true,
2383 );
2384 assert_eq!(agent.name(), "my-agent");
2385 assert!(matches!(agent.agent_type(), AgentType::Finance));
2386 assert_eq!(agent.system_prompt(), "Custom prompt");
2387 assert!(agent.tools().is_some());
2388 let allowed = agent.allowed_tools().expect("should have allowed tools");
2389 assert_eq!(allowed.len(), 1);
2390 assert_eq!(agent.max_tool_iterations(), 10);
2391 assert!(agent.parallel_tools());
2392 }
2393
2394 #[test]
2395 fn test_with_params_provider_name_is_unknown() {
2396 let agent = ConfigurableAgent::with_params(
2397 "x",
2398 AgentType::Router,
2399 Box::new(MockLLM::new()),
2400 "p".to_string(),
2401 None,
2402 None,
2403 1,
2404 false,
2405 );
2406 assert_eq!(agent.provider_name, "unknown");
2407 }
2408
2409 #[test]
2414 fn test_new_with_provider_uses_explicit_name() {
2415 let config = make_config(vec![], None);
2416 let agent = ConfigurableAgent::new_with_provider(
2417 "sales",
2418 &config,
2419 Box::new(MockLLM::new()),
2420 None,
2421 "my-provider".to_string(),
2422 );
2423 assert_eq!(agent.name(), "sales");
2424 assert!(matches!(agent.agent_type(), AgentType::Sales));
2425 assert_eq!(agent.provider_name, "my-provider");
2426 }
2427
2428 #[test]
2429 fn test_new_with_provider_falls_back_to_default_prompt() {
2430 let config = make_config(vec![], None); let agent = ConfigurableAgent::new_with_provider(
2432 "invoice",
2433 &config,
2434 Box::new(MockLLM::new()),
2435 None,
2436 "p".to_string(),
2437 );
2438 assert!(
2439 agent.system_prompt().contains("Invoice"),
2440 "should use default_system_prompt for 'invoice'"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_new_with_provider_uses_config_prompt_when_some() {
2446 let config = make_config(vec![], Some("Custom system prompt"));
2447 let agent = ConfigurableAgent::new_with_provider(
2448 "invoice",
2449 &config,
2450 Box::new(MockLLM::new()),
2451 None,
2452 "p".to_string(),
2453 );
2454 assert_eq!(agent.system_prompt(), "Custom system prompt");
2455 }
2456
2457 #[test]
2462 fn test_agent_trait_system_prompt() {
2463 let config = make_config(vec![], Some("Hello from config"));
2464 let agent = ConfigurableAgent::new("router", &config, Box::new(MockLLM::new()), None);
2465 assert_eq!(Agent::system_prompt(&agent), "Hello from config");
2466 }
2467
2468 #[test]
2469 fn test_agent_trait_agent_type() {
2470 let config = make_config(vec![], None);
2471 let agent = ConfigurableAgent::new("finance", &config, Box::new(MockLLM::new()), None);
2472 assert!(matches!(Agent::agent_type(&agent), AgentType::Finance));
2473 }
2474
2475 #[tokio::test]
2480 async fn test_execute_simple_calls_generate_with_history() {
2481 let config = make_config(vec![], Some("You are helpful"));
2482 let agent = ConfigurableAgent::new(
2483 "router",
2484 &config,
2485 Box::new(MockLLM::with_content("hello world")),
2486 None,
2487 );
2488 let ctx = make_context();
2489 let resp = Agent::execute(&agent, "hi", &ctx).await.unwrap();
2490 assert_eq!(resp.content, "hello world");
2491 }
2492
2493 #[tokio::test]
2494 async fn test_execute_simple_returns_metadata() {
2495 let config = make_config(vec![], None);
2496 let agent = ConfigurableAgent::new(
2497 "router",
2498 &config,
2499 Box::new(MockLLM::with_content("ok")),
2500 None,
2501 );
2502 let ctx = make_context();
2503 let resp = Agent::execute(&agent, "test", &ctx).await.unwrap();
2504 let meta = resp.metadata.unwrap();
2505 assert_eq!(meta.model_name, "mock");
2506 assert_eq!(meta.provider_name, "default"); }
2508
2509 #[tokio::test]
2510 async fn test_execute_simple_empty_conversation_history() {
2511 let config = make_config(vec![], Some("system"));
2512 let agent = ConfigurableAgent::new(
2513 "router",
2514 &config,
2515 Box::new(MockLLM::with_content("reply")),
2516 None,
2517 );
2518 let ctx = AgentContext {
2519 user_id: "u".to_string(),
2520 session_id: "s".to_string(),
2521 conversation_history: vec![],
2522 user_memory: None,
2523 };
2524 let resp = Agent::execute(&agent, "q", &ctx).await.unwrap();
2525 assert_eq!(resp.content, "reply");
2526 }
2527
2528 #[tokio::test]
2529 async fn test_execute_simple_with_conversation_history() {
2530 let config = make_config(vec![], Some("system"));
2531 let agent = ConfigurableAgent::new(
2532 "router",
2533 &config,
2534 Box::new(MockLLM::with_content("contextual reply")),
2535 None,
2536 );
2537 let ctx = make_context_with_history(vec![
2538 (MessageRole::User, "first question"),
2539 (MessageRole::Assistant, "first answer"),
2540 (MessageRole::User, "follow up"),
2541 ]);
2542 let resp = Agent::execute(&agent, "final", &ctx).await.unwrap();
2543 assert_eq!(resp.content, "contextual reply");
2544 }
2545
2546 #[tokio::test]
2547 async fn test_execute_simple_with_user_memory() {
2548 let config = make_config(vec![], Some("system"));
2549 let agent = ConfigurableAgent::new(
2550 "router",
2551 &config,
2552 Box::new(MockLLM::with_content("memory reply")),
2553 None,
2554 );
2555 let ctx = AgentContext {
2556 user_id: "u".to_string(),
2557 session_id: "s".to_string(),
2558 conversation_history: vec![],
2559 user_memory: Some(UserMemory {
2560 user_id: "u".to_string(),
2561 preferences: vec![Preference {
2562 category: "communication".to_string(),
2563 key: "style".to_string(),
2564 value: "concise".to_string(),
2565 confidence: 0.9,
2566 }],
2567 facts: vec![],
2568 }),
2569 };
2570 let resp = Agent::execute(&agent, "q", &ctx).await.unwrap();
2571 assert_eq!(resp.content, "memory reply");
2572 }
2573
2574 #[tokio::test]
2579 async fn test_execute_tool_path_no_tool_calls_returns_final() {
2580 let reg = make_tools_with_tool("calculator");
2581 let mut config = make_config(vec!["calculator"], Some("system"));
2582 config.max_tool_iterations = 3;
2583 let agent = ConfigurableAgent::new(
2585 "orchestrator",
2586 &config,
2587 Box::new(MockLLM::with_content("final answer")),
2588 Some(reg),
2589 );
2590 let ctx = make_context();
2591 let resp = Agent::execute(&agent, "compute 2+2", &ctx).await.unwrap();
2592 assert_eq!(resp.content, "final answer");
2593 let meta = resp.metadata.unwrap();
2594 assert_eq!(meta.model_name, "mock");
2595 }
2596
2597 #[tokio::test]
2598 async fn test_execute_tool_path_tool_calls_then_final() {
2599 let reg = make_tools_with_tool("calculator");
2600 let mut config = make_config(vec!["calculator"], Some("system"));
2601 config.max_tool_iterations = 3;
2602
2603 let tool_call = ToolCall {
2605 id: "tc_1".to_string(),
2606 name: "calculator".to_string(),
2607 arguments: serde_json::json!({"expression": "2+2"}),
2608 };
2609 let responses = vec![
2610 make_tool_response("", vec![tool_call]),
2611 make_tool_response("The answer is 4", vec![]),
2612 ];
2613
2614 let agent = ConfigurableAgent::new(
2615 "orchestrator",
2616 &config,
2617 Box::new(MockLLM::with_tool_responses(responses)),
2618 Some(reg),
2619 );
2620 let ctx = make_context();
2621 let resp = Agent::execute(&agent, "2+2?", &ctx).await.unwrap();
2622 assert_eq!(resp.content, "The answer is 4");
2623 }
2624
2625 #[tokio::test]
2626 async fn test_execute_tool_path_max_iterations_reaches_synthesis() {
2627 let reg = make_tools_with_tool("calculator");
2628 let mut config = make_config(vec!["calculator"], Some("system"));
2629 config.max_tool_iterations = 2; let tc = ToolCall {
2632 id: "tc_1".to_string(),
2633 name: "calculator".to_string(),
2634 arguments: serde_json::json!({}),
2635 };
2636 let responses = vec![
2638 make_tool_response("", vec![tc.clone()]),
2639 make_tool_response("", vec![tc]),
2640 ];
2641
2642 let agent = ConfigurableAgent::new(
2643 "orchestrator",
2644 &config,
2645 Box::new(MockLLM::with_tool_responses(responses)),
2646 Some(reg),
2647 );
2648 let ctx = make_context();
2649 let resp = Agent::execute(&agent, "compute", &ctx).await.unwrap();
2650 assert!(!resp.content.is_empty());
2653 }
2654
2655 #[tokio::test]
2656 async fn test_execute_tool_path_tool_execution_error() {
2657 struct FailingTool;
2659 #[async_trait]
2660 impl ares_tools::Tool for FailingTool {
2661 fn name(&self) -> &str {
2662 "fail_tool"
2663 }
2664 fn description(&self) -> &str {
2665 "always fails"
2666 }
2667 fn parameters_schema(&self) -> serde_json::Value {
2668 serde_json::json!({})
2669 }
2670 async fn execute(&self, _args: serde_json::Value) -> Result<serde_json::Value> {
2671 Err(ares_types::AppError::Internal("tool crashed".to_string()))
2672 }
2673 }
2674
2675 let reg = Arc::new(Tools::from_static([Arc::new(FailingTool) as Arc<dyn Tool>]));
2676
2677 let mut config = make_config(vec!["fail_tool"], Some("system"));
2678 config.max_tool_iterations = 3;
2679
2680 let tc = ToolCall {
2681 id: "tc_err".to_string(),
2682 name: "fail_tool".to_string(),
2683 arguments: serde_json::json!({}),
2684 };
2685 let responses = vec![
2686 make_tool_response("", vec![tc]),
2687 make_tool_response("Error handled", vec![]),
2688 ];
2689
2690 let agent = ConfigurableAgent::new(
2691 "orchestrator",
2692 &config,
2693 Box::new(MockLLM::with_tool_responses(responses)),
2694 Some(reg),
2695 );
2696 let ctx = make_context();
2697 let resp = Agent::execute(&agent, "do it", &ctx).await.unwrap();
2698 assert_eq!(resp.content, "Error handled");
2701 }
2702
2703 #[test]
2708 fn test_can_use_tool_empty_list_denies_all() {
2709 let reg = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
2710 let agent = ConfigurableAgent::with_params(
2711 "router",
2712 AgentType::Router,
2713 Box::new(MockLLM::new()),
2714 "system".to_string(),
2715 Some(reg),
2716 Some(vec![]),
2717 1,
2718 false,
2719 );
2720 assert!(
2721 !agent.can_use_tool("anything"),
2722 "empty allowed_tools → deny all"
2723 );
2724 }
2725
2726 #[test]
2727 fn prebuilt_connector_tool_detection_covers_registered_connectors() {
2728 assert!(is_prebuilt_connector_tool("slack_send_message"));
2729 assert!(is_prebuilt_connector_tool("google_calendar_list_events"));
2730 assert!(is_prebuilt_connector_tool("salesforce_create_record"));
2731 assert!(!is_prebuilt_connector_tool("calculator"));
2732 }
2733
2734 #[tokio::test]
2735 async fn dispatch_prebuilt_connector_injects_runtime_tenant() {
2736 let reg = make_tools_with_echo_tool("slack_send_message");
2737 let mut agent = ConfigurableAgent::with_params(
2738 "orchestrator",
2739 AgentType::Orchestrator,
2740 Box::new(MockLLM::new()),
2741 "system".to_string(),
2742 Some(reg),
2743 Some(vec!["slack_send_message".to_string()]),
2744 3,
2745 false,
2746 );
2747 let ctx = crate::tenant_scope(&cordis::Context::new_root(), "tenant-a");
2748 agent.bind_request_ctx(ctx);
2749
2750 let result = agent
2751 .dispatch_tool("slack_send_message", serde_json::json!({"channel":"ops"}))
2752 .await
2753 .expect("connector dispatch");
2754
2755 assert_eq!(result["tenant_id"], "tenant-a");
2756 assert_eq!(result["channel"], "ops");
2757 }
2758
2759 #[tokio::test]
2760 async fn dispatch_prebuilt_connector_rejects_cross_tenant_arg() {
2761 let reg = make_tools_with_echo_tool("slack_send_message");
2762 let mut agent = ConfigurableAgent::with_params(
2763 "orchestrator",
2764 AgentType::Orchestrator,
2765 Box::new(MockLLM::new()),
2766 "system".to_string(),
2767 Some(reg),
2768 Some(vec!["slack_send_message".to_string()]),
2769 3,
2770 false,
2771 );
2772 let ctx = crate::tenant_scope(&cordis::Context::new_root(), "tenant-a");
2773 agent.bind_request_ctx(ctx);
2774
2775 let err = agent
2776 .dispatch_tool(
2777 "slack_send_message",
2778 serde_json::json!({"tenant_id":"tenant-b","channel":"ops"}),
2779 )
2780 .await
2781 .unwrap_err();
2782
2783 assert!(err.to_string().contains("does not match executing tenant"));
2784 }
2785
2786 #[tokio::test]
2787 async fn test_execute_tool_allowed_tool_succeeds() {
2788 let reg = make_tools_with_tool("http");
2789 let mut config = make_config(vec!["http"], Some("system"));
2790 config.max_tool_iterations = 3;
2791
2792 let tc = ToolCall {
2793 id: "tc_1".to_string(),
2794 name: "http".to_string(),
2795 arguments: serde_json::json!({}),
2796 };
2797 let responses = vec![
2798 make_tool_response("", vec![tc]),
2799 make_tool_response("HTTP result", vec![]),
2800 ];
2801
2802 let agent = ConfigurableAgent::new(
2803 "orchestrator",
2804 &config,
2805 Box::new(MockLLM::with_tool_responses(responses)),
2806 Some(reg),
2807 );
2808 let ctx = make_context();
2809 let resp = Agent::execute(&agent, "fetch", &ctx).await.unwrap();
2810 assert_eq!(resp.content, "HTTP result");
2811 }
2812
2813 #[tokio::test]
2814 async fn test_execute_tool_disallowed_tool_returns_error() {
2815 let reg = Arc::new(Tools::from_static([
2816 Arc::new(MockTool::new("http")) as Arc<dyn Tool>,
2817 Arc::new(MockTool::new("sql")) as Arc<dyn Tool>,
2818 ]));
2819
2820 let agent = ConfigurableAgent::with_params(
2821 "orchestrator",
2822 AgentType::Orchestrator,
2823 Box::new(MockLLM::with_tool_responses(vec![make_tool_response(
2824 "",
2825 vec![ToolCall {
2826 id: "tc_1".to_string(),
2827 name: "sql".to_string(),
2828 arguments: serde_json::json!({}),
2829 }],
2830 )])),
2831 "system".to_string(),
2832 Some(reg),
2833 Some(vec!["http".to_string()]),
2834 3,
2835 false,
2836 );
2837
2838 let ctx = make_context();
2839 let result = Agent::execute(&agent, "query", &ctx).await;
2840 assert!(result.is_err(), "disallowed tool should return error");
2841 let err = result.err().unwrap().to_string();
2842 assert!(
2843 err.contains("sql"),
2844 "error should mention denied tool: {}",
2845 err
2846 );
2847 assert!(
2848 err.contains("not allowed"),
2849 "error should say tool is not allowed: {}",
2850 err
2851 );
2852 }
2853
2854 #[tokio::test]
2855 async fn test_execute_tool_empty_allowed_tools_denies_all() {
2856 let reg = make_tools_with_tool("http");
2857 let agent = ConfigurableAgent::with_params(
2858 "orchestrator",
2859 AgentType::Orchestrator,
2860 Box::new(MockLLM::with_tool_responses(vec![make_tool_response(
2861 "",
2862 vec![ToolCall {
2863 id: "tc_1".to_string(),
2864 name: "http".to_string(),
2865 arguments: serde_json::json!({}),
2866 }],
2867 )])),
2868 "system".to_string(),
2869 Some(reg),
2870 Some(vec![]),
2871 3,
2872 false,
2873 );
2874
2875 let ctx = make_context();
2876 let result = Agent::execute(&agent, "fetch", &ctx).await;
2877 assert!(result.is_err(), "empty allowed_tools should deny all");
2878 let err = result.err().unwrap().to_string();
2879 assert!(
2880 err.contains("http"),
2881 "error should mention denied tool: {}",
2882 err
2883 );
2884 }
2885
2886 #[tokio::test]
2887 async fn test_execute_tool_none_allowed_tools_denies_all() {
2888 let reg = Arc::new(Tools::from_static([
2889 Arc::new(MockTool::new("http")) as Arc<dyn Tool>,
2890 Arc::new(MockTool::new("sql")) as Arc<dyn Tool>,
2891 ]));
2892
2893 let agent = ConfigurableAgent::with_params(
2894 "orchestrator",
2895 AgentType::Orchestrator,
2896 Box::new(MockLLM::with_tool_responses(vec![
2897 make_tool_response(
2898 "",
2899 vec![ToolCall {
2900 id: "tc_1".to_string(),
2901 name: "sql".to_string(),
2902 arguments: serde_json::json!({}),
2903 }],
2904 ),
2905 make_tool_response("SQL result", vec![]),
2906 ])),
2907 "system".to_string(),
2908 Some(reg),
2909 None,
2910 3,
2911 false,
2912 );
2913
2914 let ctx = make_context();
2915 let result = Agent::execute(&agent, "query", &ctx).await;
2916 assert!(result.is_err(), "missing allowed_tools should deny all");
2917 let err = result.err().unwrap().to_string();
2918 assert!(
2919 err.contains("sql"),
2920 "error should mention denied tool: {err}"
2921 );
2922 }
2923
2924 #[test]
2925 fn test_observed_tool_type_marks_builtins() {
2926 let agent = ConfigurableAgent::with_params(
2927 "router",
2928 AgentType::Router,
2929 Box::new(MockLLM::new()),
2930 "system".to_string(),
2931 None,
2932 Some(vec!["http".to_string()]),
2933 1,
2934 false,
2935 );
2936
2937 assert_eq!(agent.observed_tool_type("http", true), "builtin");
2938 }
2939
2940 #[test]
2941 fn test_observed_tool_type_falls_back_for_runtime_tools() {
2942 let agent = ConfigurableAgent::with_params(
2943 "router",
2944 AgentType::Router,
2945 Box::new(MockLLM::new()),
2946 "system".to_string(),
2947 None,
2948 Some(vec!["tenant_http".to_string()]),
2949 1,
2950 false,
2951 );
2952
2953 assert_eq!(agent.observed_tool_type("tenant_http", false), "runtime");
2954 }
2955
2956 #[test]
2957 fn test_set_allowed_tools_intersection() {
2958 let reg = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
2959 let mut agent = ConfigurableAgent::with_params(
2960 "router",
2961 AgentType::Router,
2962 Box::new(MockLLM::new()),
2963 "system".to_string(),
2964 Some(reg),
2965 Some(vec!["http".to_string(), "sql".to_string()]),
2966 1,
2967 false,
2968 );
2969 assert!(agent.can_use_tool("http"));
2970 assert!(agent.can_use_tool("sql"));
2971 agent.set_allowed_tools(Some(vec!["http".to_string()]));
2972 assert!(agent.can_use_tool("http"));
2973 assert!(!agent.can_use_tool("sql"));
2974 agent.set_allowed_tools(Some(vec![]));
2975 assert!(!agent.can_use_tool("http"));
2976 agent.set_allowed_tools(None);
2977 assert!(!agent.can_use_tool("sql"));
2978 }
2979
2980 struct FailingMockLLM {
2983 error_msg: String,
2984 }
2985
2986 #[async_trait]
2987 impl LLMClient for FailingMockLLM {
2988 async fn generate(&self, _: &str) -> Result<String> {
2989 Err(ares_types::types::AppError::LLM(self.error_msg.clone()))
2990 }
2991 async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
2992 Err(ares_types::types::AppError::LLM(self.error_msg.clone()))
2993 }
2994 async fn generate_with_history(&self, _: &[(String, String)]) -> Result<LLMResponse> {
2995 Err(ares_types::types::AppError::LLM(self.error_msg.clone()))
2996 }
2997 async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
2998 Err(ares_types::types::AppError::LLM(self.error_msg.clone()))
2999 }
3000 async fn generate_with_tools_and_history(
3001 &self,
3002 _: &[ares_llm::coordinator::ConversationMessage],
3003 _: &[ToolDefinition],
3004 ) -> Result<LLMResponse> {
3005 Err(ares_types::types::AppError::LLM(self.error_msg.clone()))
3006 }
3007 async fn stream(
3008 &self,
3009 _: &str,
3010 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
3011 Ok(Box::new(futures::stream::empty()))
3012 }
3013 async fn stream_with_system(
3014 &self,
3015 _: &str,
3016 _: &str,
3017 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
3018 Ok(Box::new(futures::stream::empty()))
3019 }
3020 async fn stream_with_history(
3021 &self,
3022 _: &[(String, String)],
3023 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
3024 Ok(Box::new(futures::stream::empty()))
3025 }
3026 fn model_name(&self) -> &str {
3027 "failing-mock"
3028 }
3029 }
3030
3031 #[tokio::test]
3032 async fn test_fallback_used_when_primary_fails() {
3033 let mut agent = ConfigurableAgent::with_params(
3034 "test",
3035 AgentType::Product,
3036 Box::new(FailingMockLLM {
3037 error_msg: "primary failed".to_string(),
3038 }),
3039 "system".to_string(),
3040 None,
3041 None,
3042 1,
3043 false,
3044 );
3045
3046 let fallback = MockLLM::with_content("fallback-success");
3047 agent.set_fallback_llms_with_providers(vec![(
3048 "fallback-provider".to_string(),
3049 Box::new(fallback),
3050 )]);
3051
3052 let ctx = make_context();
3053 let resp = Agent::execute(&agent, "hello", &ctx).await.unwrap();
3054 assert_eq!(resp.content, "fallback-success");
3055 let metadata = resp.metadata.expect("metadata");
3056 assert_eq!(metadata.provider_name, "fallback-provider");
3057 assert_eq!(metadata.model_name, "mock");
3058 }
3059
3060 #[tokio::test]
3061 async fn test_primary_succeeds_without_fallback() {
3062 let mut agent = ConfigurableAgent::with_params(
3063 "test",
3064 AgentType::Product,
3065 Box::new(MockLLM::with_content("primary-success")),
3066 "system".to_string(),
3067 None,
3068 None,
3069 1,
3070 false,
3071 );
3072
3073 let fallback = FailingMockLLM {
3074 error_msg: "fallback should not run".to_string(),
3075 };
3076 agent.set_fallback_llms(vec![Box::new(fallback)]);
3077
3078 let ctx = make_context();
3079 let resp = Agent::execute(&agent, "hello", &ctx).await.unwrap();
3080 assert_eq!(resp.content, "primary-success");
3081 }
3082
3083 #[tokio::test]
3084 async fn test_all_fallbacks_fail_reports_every_error() {
3085 let mut agent = ConfigurableAgent::with_params(
3086 "test",
3087 AgentType::Product,
3088 Box::new(FailingMockLLM {
3089 error_msg: "primary failed".to_string(),
3090 }),
3091 "system".to_string(),
3092 None,
3093 None,
3094 1,
3095 false,
3096 );
3097
3098 agent.set_fallback_llms(vec![
3099 Box::new(FailingMockLLM {
3100 error_msg: "fallback-0 failed".to_string(),
3101 }),
3102 Box::new(FailingMockLLM {
3103 error_msg: "fallback-1 failed".to_string(),
3104 }),
3105 ]);
3106
3107 let ctx = make_context();
3108 let err = match Agent::execute(&agent, "hello", &ctx).await {
3109 Ok(_) => panic!("expected all LLMs to fail"),
3110 Err(err) => err.to_string(),
3111 };
3112 assert!(err.contains("primary failed"), "got: {err}");
3113 assert!(err.contains("fallback[0]"), "got: {err}");
3114 assert!(err.contains("fallback-0 failed"), "got: {err}");
3115 assert!(err.contains("fallback[1]"), "got: {err}");
3116 assert!(err.contains("fallback-1 failed"), "got: {err}");
3117 }
3118
3119 #[test]
3120 fn user_id_from_ctx_reads_intercept() {
3121 use ares_types::models::{TenantContext, TenantTier};
3122
3123 let root: Arc<cordis::Context> = cordis::Context::new_root();
3124 assert_eq!(crate::user_id_from_ctx(&root, ""), "");
3125
3126 let ctx = root.with_intercept(TenantContext::new("acme".into(), TenantTier::Pro));
3127 assert_eq!(crate::user_id_from_ctx(&ctx, "anon"), "acme");
3128 }
3129
3130 #[test]
3131 fn user_id_from_ctx_isolate_wins_over_intercept() {
3132 use ares_types::models::{TenantContext, TenantTier};
3133
3134 let root: Arc<cordis::Context> = cordis::Context::new_root();
3135 let intercepted =
3136 root.with_intercept(TenantContext::new("from-intercept".into(), TenantTier::Pro));
3137 let isolated = crate::tenant_scope(&intercepted, "from-isolate");
3138 assert_eq!(crate::user_id_from_ctx(&isolated, "anon"), "from-isolate");
3139 }
3140
3141 #[tokio::test]
3142 async fn configurable_generate_waterfall_rewrites_last_message() {
3143 let ctx = Context::new_root();
3144 let events = ctx.provide(EventsService::new());
3145 events.on_waterfall(
3146 cordis::events_catalog::ev::LLM_GENERATE.to_string(),
3147 |mut payload, next| async move {
3148 if let Some(arr) = payload.get_mut("messages").and_then(|v| v.as_array_mut()) {
3149 if let Some(last) = arr.last_mut() {
3150 last["content"] = serde_json::json!("rewritten-hello");
3151 }
3152 }
3153 next(payload).await
3154 },
3155 );
3156
3157 let mut agent = ConfigurableAgent::new(
3158 "router",
3159 &make_config(vec![], Some("system")),
3160 Box::new(MockLLM::echo_last()),
3161 None,
3162 );
3163 agent.bind_request_ctx(ctx);
3164
3165 let resp = Agent::execute(&agent, "original", &make_context())
3166 .await
3167 .expect("execute");
3168 assert_eq!(resp.content, "rewritten-hello");
3169 }
3170
3171 #[tokio::test]
3172 async fn configurable_generate_short_circuit_skips_llm() {
3173 let ctx = Context::new_root();
3174 let events = ctx.provide(EventsService::new());
3175 events.on_waterfall(
3176 cordis::events_catalog::ev::LLM_GENERATE.to_string(),
3177 |_payload, _next| async move { Ok(serde_json::json!({ "content": "cached" })) },
3178 );
3179
3180 let (llm, generated) = MockLLM::with_generated_flag();
3181 let mut agent = ConfigurableAgent::new(
3182 "router",
3183 &make_config(vec![], Some("system")),
3184 Box::new(llm),
3185 None,
3186 );
3187 agent.bind_request_ctx(ctx);
3188
3189 let resp = Agent::execute(&agent, "would-call-llm", &make_context())
3190 .await
3191 .expect("execute");
3192 assert_eq!(resp.content, "cached");
3193 assert!(
3194 !generated.load(Ordering::SeqCst),
3195 "dummy generate must stay false when handler skips next"
3196 );
3197 }
3198
3199 #[tokio::test]
3200 async fn configurable_generate_tools_short_circuit_skips_llm() {
3201 let ctx = Context::new_root();
3204 let events = ctx.provide(EventsService::new());
3205 events.on_waterfall(
3206 cordis::events_catalog::ev::LLM_GENERATE_TOOLS.to_string(),
3207 |_payload, _next| async move {
3208 Ok(serde_json::json!({
3209 "content": "tools-cached",
3210 "provider": "cache",
3211 "model": "cached-model",
3212 "tool_calls": [],
3213 }))
3214 },
3215 );
3216
3217 let (llm, generated) = MockLLM::with_generated_flag();
3218 ctx.provide(ares_tools::Tools::from_static(Vec::<
3221 Arc<dyn ares_tools::Tool>,
3222 >::new()));
3223 let mut agent = ConfigurableAgent::new(
3224 "router",
3225 &make_config(vec![], Some("system")),
3226 Box::new(llm),
3227 None,
3228 );
3229 agent.bind_request_ctx(ctx);
3230 let resp = Agent::execute(&agent, "would-call-llm", &make_context())
3231 .await
3232 .expect("execute");
3233 assert_eq!(resp.content, "tools-cached");
3234 assert!(
3235 !generated.load(Ordering::SeqCst),
3236 "mock client must never be called when generate_tools handler skips next"
3237 );
3238 }
3239}