Skip to main content

ares_agent/
configurable.rs

1//! Configurable Agent implementation
2//!
3//! This module provides a generic agent that can be configured via TOML.
4//! It replaces the hardcoded agent implementations with a flexible,
5//! configuration-driven approach.
6
7#![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
27// cordis Phase6: runtime postgres availability via Service::check() — replaces compile-time #[cfg(feature="postgres")] branching
28// Previously: `#[cfg(feature = "postgres")] token_budget_pool: Option<PgPool>`
29// Now: always-present field guarded by `ctx.get::<PostgresService>().is_some()` / `PostgresService::check()`
30// Handler example: `if ctx.get::<PostgresService>().is_some() { /* use token_budget_pool */ } else { /* fallback */ }`
31// TODO: if ctx.get::<PostgresService>().is_some() { use db } else { fallback }
32use cordis::Service;
33
34/// Postgres availability as a Cordis Service — runtime check, not compile-time cfg.
35///
36/// `check()` returns `cfg!(feature = "postgres")` so callers can branch at runtime:
37/// `if ctx.get::<PostgresService>().is_some_and(|s| s.check()) { /* postgres path */ }`
38/// or `if ctx.get::<PostgresService>().is_some() { use db } else { fallback }`.
39pub 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
57/// Maximum tracked sessions before the whole map is cleared.
58///
59/// Eviction is deliberately CRUDE: when the 257th session arrives, every
60/// entry is dropped and compaction state rebuilds from scratch (fresh
61/// snapshot hydration on the next turn). Sessions are independent, so a
62/// bulk reset loses nothing but warm history.
63const SESSION_COMPACTOR_CAP: usize = 256;
64
65/// Process-wide per-session [`Compactor`] registry.
66///
67/// Keyed by `(tenant_id, session_id)` as available in `Agent::execute`
68/// (`context.user_id` / `context.session_id`). Agents are constructed
69/// per request, so the map must outlive any single agent instance; the
70/// `Arc` clone handed to `record_turn` tasks keeps entries alive while a
71/// spawned hook is still writing.
72#[derive(Default)]
73pub struct SessionCompactors {
74    inner: parking_lot::Mutex<std::collections::HashMap<(String, String), Arc<Compactor>>>,
75}
76
77/// Process-wide [`SessionCompactors`] singleton.
78///
79/// Agents are constructed per request, so the registry cannot live on an
80/// agent instance; a static keeps the map alive across requests without
81/// requiring boot-time provider wiring. Compaction stays OFF unless an
82/// agent's config sets `compaction_enabled`.
83fn global_session_compactors() -> &'static SessionCompactors {
84    static COMPACTORS: std::sync::LazyLock<SessionCompactors> =
85        std::sync::LazyLock::new(SessionCompactors::new);
86    &COMPACTORS
87}
88
89/// Debug-level outcome logging for fire-and-forget compaction work.
90fn 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    /// Creates an empty registry (install once via `ctx.provide`).
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Returns the compactor for `(tenant_id, session_id)`, creating one
122    /// seeded from the persisted DB snapshot when available. A missing
123    /// store or client yields no compactor — compaction silently stays off.
124    #[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            // Crude eviction: drop everything (see SESSION_COMPACTOR_CAP).
170            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    /// Non-postgres twin: never persists or hydrates.
180    #[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
214/// Tenant component of the session-compactor key.
215///
216/// Mirrors the request-path resolution used elsewhere: Execute/Tools
217/// isolate labels (stripping the legacy `tenant:`/`user:` prefixes),
218/// then the `TenantContext` intercept, then the agent-context user id.
219fn 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/// Best-effort persistence of one compactor state to its DB snapshot row.
243///
244/// Silent degradation by contract: any error is logged at debug and
245/// swallowed — a failed snapshot write must never fail a chat turn.
246#[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
278/// A configurable agent that derives its behavior from TOML configuration
279pub struct ConfigurableAgent {
280    /// The agent's name/type identifier
281    name: String,
282    /// The agent type enum value
283    agent_type: AgentType,
284    /// The LLM client to use for generation
285    llm: Box<dyn LLMClient>,
286    /// The configured provider backing the LLM client
287    provider_name: String,
288    /// The system prompt from configuration
289    system_prompt: String,
290    /// Unified tools capability. Prefer `set_tools` + request ctx.
291    tools: Option<Arc<Tools>>,
292    /// Request Cordis context bound for this execution (Tools isolate + ExternalContext).
293    cordis_ctx: Option<Arc<Context>>,
294    /// Optional whitelist of tool names this agent is allowed to use.
295    /// `None` means no tools are permitted.
296    allowed_tools: Option<Vec<String>>,
297    /// Maximum tool calling iterations
298    max_tool_iterations: usize,
299    /// Whether to execute tools in parallel
300    parallel_tools: bool,
301    /// Optional observability sink for run history logging
302    observability: Option<Arc<dyn ObservabilitySink>>,
303    /// Optional fallback LLM clients to try if primary fails
304    fallback_llms: Vec<ProviderLlm>,
305    /// Optional run id to associate with token usage records.
306    run_id: Option<String>,
307    /// Per-session history compaction (off unless `compaction_enabled`).
308    compaction: bool,
309    /// Multimodal parts for the current user turn (in-flight only).
310    user_parts: Vec<ContentPart>,
311    /// OpenAI Responses continuation id for this turn.
312    previous_response_id: Option<String>,
313    /// Enable provider built-in web search for this turn.
314    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
409/// Drive `waterfall_around` without capturing `Box<dyn LLMClient>` in the `'static`
410/// core: the terminal core only shuttles the (possibly rewritten) payload back to
411/// the caller, which runs generate on `&self.llm` and returns the JSON result.
412async 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    /// Create a new configurable agent from TOML config
449    ///
450    /// Deprecated shim kept for one commit. New code should use
451    /// `new_with_tool_service` with a service obtained via
452    /// `ctx.get::<dyn ToolService>()`.
453    #[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    /// Shared helper that resolves the common `agent_type`, `system_prompt`,
464    /// and `allowed_tools` derived from `AgentConfig`. Extracted to eliminate
465    /// the 93% near-duplicate bodies between `new_with_provider` and
466    /// `new_with_provider_and_tool_service` reported by rust-doctor.
467    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        // Use allowed_tools if present; otherwise fall back to legacy tools field.
477        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    /// Create a new configurable agent with explicit provider metadata
488    ///
489    /// Deprecated shim. Prefer `new_with_provider_and_tool_service`.
490    #[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    /// Create a new configurable agent with explicit parameters
522    ///
523    /// Deprecated shim. Prefer `with_tool_service_params`.
524    #[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    // Preferred constructors that accept unified Tools from ctx.get::<Tools>().
558
559    /// Create an agent wired to a unified ToolService.
560    ///
561    /// Obtain the service via `ctx.get::<dyn ToolService>()` and pass it here.
562    /// This is the Cordis DI path. The service provides all tools with tenant
563    /// precedence already handled.
564    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    /// Create an agent with provider metadata and a unified ToolService.
580    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    /// Enable per-session history compaction for this agent.
610    pub fn set_compaction(&mut self, enabled: bool) {
611        self.compaction = enabled;
612    }
613
614    /// Create an agent with explicit parameters and a unified ToolService.
615    #[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    /// Create an agent by resolving the ToolService from a Context.
648    ///
649    /// This is the one line handlers should use: `ConfigurableAgent::new_from_context(&ctx, name, &config, llm)`.
650    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    /// Same as `new_from_context` but with explicit provider name.
665    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    /// Convert agent name to AgentType
682    fn name_to_type(name: &str) -> AgentType {
683        AgentType::from_string(name)
684    }
685
686    /// Get default system prompt for an agent type
687    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    /// Get the agent name
723    pub fn name(&self) -> &str {
724        &self.name
725    }
726
727    /// Get the max tool iterations setting
728    pub fn max_tool_iterations(&self) -> usize {
729        self.max_tool_iterations
730    }
731
732    /// Get the parallel tools setting
733    pub fn parallel_tools(&self) -> bool {
734        self.parallel_tools
735    }
736
737    /// Check if this agent may use tools (built-in or tenant-scoped via `Tools`).
738    pub fn has_tools(&self) -> bool {
739        self.tools.is_some()
740    }
741
742    /// Get the unified tools capability (if any).
743    pub fn tools(&self) -> Option<&Arc<Tools>> {
744        self.tools.as_ref()
745    }
746
747    /// Store the unified `Tools` capability used for list/resolve/dispatch.
748    pub fn set_tools(&mut self, tools: Arc<Tools>) {
749        self.tools = Some(tools);
750    }
751
752    /// Bind the request Cordis context so tool isolate labels and
753    /// `ExternalContext` are visible during `execute`.
754    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    /// Get the list of allowed tool names for this agent.
762    /// `None` means no tools are permitted.
763    pub fn allowed_tools(&self) -> Option<&[String]> {
764        self.allowed_tools.as_deref()
765    }
766
767    /// Override the allowed tools list at runtime (e.g. after merging with
768    /// a per-tenant allowlist).
769    pub fn set_allowed_tools(&mut self, allowed_tools: Option<Vec<String>>) {
770        self.allowed_tools = allowed_tools;
771    }
772
773    /// Attach an observability sink to this agent.
774    pub fn set_observability(&mut self, obs: Arc<dyn ObservabilitySink>) {
775        self.observability = Some(obs);
776    }
777
778    /// Set fallback LLM clients to try if the primary fails.
779    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    /// Set fallback LLM clients with provider names for observability and billing metadata.
790    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    /// Set the run id to associate with token usage records.
801    pub fn set_run_id(&mut self, run_id: String) {
802        self.run_id = Some(run_id);
803    }
804
805    /// Attach multimodal parts and generation hints for the next `execute` call.
806    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    /// Resolves the per-session compactor for this execution, or `None`
833    /// when compaction is off / no Llm service is available. Silent
834    /// degradation: every failure path just disables compaction for the
835    /// current turn.
836    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    /// Try the primary LLM, then each fallback in order.
907    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    /// Try the primary LLM with tools, then each fallback in order.
998    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    /// Get tool definitions for this agent.
1170    ///
1171    /// If `allowed_tools` is set, returns only those tools (if enabled).
1172    /// Otherwise returns no tools: tool use is deny-by-default.
1173    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    /// Check if a specific tool is allowed for this agent.
1189    /// When no whitelist is set, no tool is allowed.
1190    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        // Presence of Tools matches the old empty-registry default-enabled path.
1199        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    /// Execute a single tool call via `Tools::resolve(ctx, name)`.
1245    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    /// Execute the agent with tool-calling support (multi-turn loop).
1290    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        // Inject external context if a ContextProvider is configured
1308        // OSS: NoOpContextProvider returns None. Managed: ErukaContextProvider returns knowledge states.
1309        let effective_prompt = self.effective_system_prompt();
1310        messages.push(ConversationMessage::system(&effective_prompt));
1311
1312        // History: compacted session context when enabled, else the naive
1313        // last-5 slice. Compaction degrades silently — any failure keeps
1314        // today's behavior.
1315        let session_compactor = if self.compaction {
1316            self.session_compactor(context).await
1317        } else {
1318            None
1319        };
1320        // Captured for the fire-and-forget persistence hook (postgres only).
1321        #[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            // Add recent conversation history (last 5 messages)
1351            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            // Log the LLM call
1401            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                // Fire-and-forget compaction of the completed turn. Failures
1435                // are logged at debug and never affect the response.
1436                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            // Add assistant message with tool calls
1463            messages.push(ConversationMessage::assistant(
1464                &response.content,
1465                response.tool_calls.clone(),
1466            ));
1467
1468            // Execute each tool call and add results
1469            for tc in &response.tool_calls {
1470                // Runtime enforcement of allowed_tools (DIR1-46): deny-by-default.
1471                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                // Log the tool call
1501                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        // Max iterations reached — make ONE final LLM call without tools to get synthesis
1524        // Bug #7 fix: the last assistant message has empty content (it was a tool-call message).
1525        // We need the LLM to synthesize a final response from all the tool results.
1526        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        // Log the final synthesis call
1562        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        // Fire-and-forget compaction of the completed turn when the final
1600        // synthesis succeeded. Failures are logged at debug only.
1601        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                // Final call also returned empty — find any non-empty assistant content
1624                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                // Still try to return something useful
1640                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        // Build context with conversation history if available
1678        // Inject external context if a ContextProvider is configured
1679        let effective_prompt = self.effective_system_prompt();
1680        let mut messages = vec![("system".to_string(), effective_prompt)];
1681
1682        // Add user memory if available
1683        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        // History: compacted session context when enabled, else the naive
1697        // last-5 slice. Compaction degrades silently — any failure keeps
1698        // today's behavior.
1699        let session_compactor = if self.compaction {
1700            self.session_compactor(context).await
1701        } else {
1702            None
1703        };
1704        // Captured for the fire-and-forget persistence hook (postgres only).
1705        #[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            // Add recent conversation history (last 5 messages)
1729            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        // Log the LLM call
1766        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        // Fire-and-forget compaction of the completed turn. Failures are
1792        // logged at debug and never affect the response (silent degradation).
1793        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/// Convert a resolved [`UserAgent`] row into an [`AgentConfig`] for agent creation.
1830///
1831/// Used by `Execute` and handlers to bridge DB resolution → agent instantiation.
1832#[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    // ============== Shared MockLLM ==============
1860
1861    /// Configurable mock LLM client shared by all tests.
1862    ///
1863    /// - `content` is returned for `generate_with_history` and simple methods.
1864    /// - `tool_responses` queue feeds `generate_with_tools_and_history` —
1865    ///   popped front-to-back on each call; falls back to default when empty.
1866    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        /// Supply a sequence of responses for `generate_with_tools_and_history`.
1888        /// Each call pops the front; when the queue is exhausted the default is used.
1889        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    // ============== Shared MockTool ==============
1998
1999    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    // ============== Helpers ==============
2050
2051    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    // ==========================================================
2118    //  2. default_system_prompt — all variants
2119    // ==========================================================
2120
2121    #[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    // ==========================================================
2179    //  3. name_to_type — more edge cases
2180    // ==========================================================
2181
2182    #[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    // ==========================================================
2264    //  4. Accessors
2265    // ==========================================================
2266
2267    #[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    // ==========================================================
2322    //  5. can_use_tool
2323    // ==========================================================
2324
2325    #[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    // ==========================================================
2348    //  6. get_filtered_tool_definitions
2349    // ==========================================================
2350
2351    #[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    // ==========================================================
2369    //  7. with_params constructor
2370    // ==========================================================
2371
2372    #[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    // ==========================================================
2410    //  8. new_with_provider constructor
2411    // ==========================================================
2412
2413    #[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); // system_prompt: None
2431        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    // ==========================================================
2458    //  9. Agent trait methods
2459    // ==========================================================
2460
2461    #[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    // ==========================================================
2476    //  10. Agent::execute — simple path (no tools)
2477    // ==========================================================
2478
2479    #[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"); // from AgentConfig.model
2507    }
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    // ==========================================================
2575    //  11. Agent::execute — tool path (with tools + registry)
2576    // ==========================================================
2577
2578    #[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        // MockLLM returns empty tool_calls → immediate return
2584        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        // First call: return a tool_call; second call: return final content
2604        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; // low limit to trigger synthesis
2630
2631        let tc = ToolCall {
2632            id: "tc_1".to_string(),
2633            name: "calculator".to_string(),
2634            arguments: serde_json::json!({}),
2635        };
2636        // Both calls return tool_calls → loop exhausts → synthesis call
2637        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        // After max iterations, synthesis call returns default content "mock"
2651        // (queue exhausted → fallback)
2652        assert!(!resp.content.is_empty());
2653    }
2654
2655    #[tokio::test]
2656    async fn test_execute_tool_path_tool_execution_error() {
2657        // Register a tool that always errors
2658        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        // The tool error is caught and returned as a tool result JSON;
2699        // the LLM then produces a final response.
2700        assert_eq!(resp.content, "Error handled");
2701    }
2702
2703    // ==========================================================
2704    //  12. Runtime allowed_tools enforcement (DIR1-46)
2705    // ==========================================================
2706
2707    #[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    // ============== Fallback tests ==============
2981
2982    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        // Closes the llm.generate_tools coverage gap: an around handler that
3202        // skips `next` must prevent any provider call.
3203        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        // A Tools capability on the bound ctx routes execute() into the
3219        // tool-calling path (has_tools), which dispatches llm.generate_tools.
3220        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}