Skip to main content

ares_agent/
execution.rs

1//! Agent execution service — single place handling conversation history loading,
2//! memory injection, tool coordination, observability, usage/cost, token budget,
3//! and loop detection.
4
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8
9use ares_types::types::{AppError, ContentPart, Message};
10use cordis::{Context, CordisError, EventsService, Service};
11
12/// Result of `Execute::run` including resolution metadata.
13///
14/// This allows callers (v1/chat, scheduler, pipeline) to record which source the agent
15/// came from and what config was used, without re-resolving.
16/// Resolution tier label returned alongside the executed agent.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum AgentSource {
20    User,
21    Community,
22    System,
23}
24
25impl AgentSource {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::User => "user",
29            Self::Community => "community",
30            Self::System => "system",
31        }
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct ExecutionResult {
37    /// The agent's response.
38    pub response: crate::AgentResponse,
39    /// Source tier where the agent was resolved (tenant/community/system).
40    pub source: AgentSource,
41    /// Name of the agent that was executed.
42    pub agent_name: String,
43    /// Run ID for correlation with ActiveRuns.
44    pub run_id: String,
45}
46
47use crate::AgentResponse;
48
49pub use ares_tools::Tools;
50
51/// Canonical per-request model override used by the LLM interceptor.
52///
53/// Re-exporting the LLM type keeps context interception and provider policy
54/// enforcement on the same `TypeId` across the agent and server crates.
55pub use ares_llm::ModelOverride;
56
57/// Request for unified agent execution.
58///
59/// Carries the minimal fields needed to execute any agent via the single
60/// `Execute::run` entry-point.
61#[derive(Clone, Default)]
62pub struct AgentRequest {
63    /// Agent name to execute.
64    pub agent_name: String,
65    /// Current user message.
66    pub message: String,
67    /// Prior conversation history (explicitly passed; may be augmented by
68    /// `TenantDb` when available).
69    pub history: Vec<Message>,
70    /// Optional per-request context provider override (overrides service-level
71    /// provider when `Some`).
72    pub ctx_provider: Option<Arc<dyn crate::context_provider::ContextProvider>>,
73    /// Multimodal parts for the current user turn (in-flight only; not persisted).
74    pub parts: Vec<ContentPart>,
75    /// OpenAI Responses continuation id for this turn.
76    pub previous_response_id: Option<String>,
77    /// When true, enable the LLM provider's built-in web search.
78    pub web_search: bool,
79}
80
81/// Internal marker for skill-triggered executions.
82///
83/// Background engines attach this marker to their tenant-scoped request
84/// context and still cross the same public `Execute::run` boundary as regular
85/// agent requests. Keeping the marker in the context avoids a second public
86/// execution API or changes to the request shape used by downstream crates.
87#[derive(Clone)]
88pub(crate) struct SkillDispatch {
89    pub(crate) skill_id: String,
90    pub(crate) tenant_id: String,
91    pub(crate) input: serde_json::Value,
92    pub(crate) run_id: String,
93}
94
95impl SkillDispatch {
96    pub(crate) fn new(
97        skill_id: impl Into<String>,
98        tenant_id: impl Into<String>,
99        input: serde_json::Value,
100        run_id: impl Into<String>,
101    ) -> Self {
102        Self {
103            skill_id: skill_id.into(),
104            tenant_id: tenant_id.into(),
105            input,
106            run_id: run_id.into(),
107        }
108    }
109}
110
111impl Service for SkillDispatch {}
112
113impl std::fmt::Debug for AgentRequest {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("AgentRequest")
116            .field("agent_name", &self.agent_name)
117            .field("message", &self.message)
118            .field("history_len", &self.history.len())
119            .field(
120                "ctx_provider",
121                &self.ctx_provider.as_ref().map(|_| "Some(ContextProvider)"),
122            )
123            .field("parts_len", &self.parts.len())
124            .field("previous_response_id", &self.previous_response_id)
125            .field("web_search", &self.web_search)
126            .finish()
127    }
128}
129
130/// Apply per-turn generation hints on a resolved LLM client.
131pub fn apply_generation_hints(
132    client: &dyn ares_llm::LLMClient,
133    web_search: bool,
134    previous_response_id: Option<String>,
135) {
136    if !web_search && previous_response_id.is_none() {
137        return;
138    }
139    client.set_hints(ares_llm::GenerationHints {
140        web_search,
141        previous_response_id,
142        ..Default::default()
143    });
144}
145
146/// Build the in-flight user turn, attaching multimodal parts and a continuation id.
147pub fn user_message_with_parts(
148    content: impl Into<String>,
149    parts: Vec<ContentPart>,
150    previous_response_id: Option<String>,
151) -> ares_llm::coordinator::ConversationMessage {
152    let mut msg = ares_llm::coordinator::ConversationMessage::user(content);
153    msg.parts = parts;
154    msg.previous_response_id = previous_response_id;
155    msg
156}
157
158/// Unified agent execution service — the single place handling:
159///
160/// - conversation history loading (`TenantDb`)
161/// - memory injection (`ContextProvider`)
162/// - `ToolCoordinator` loop
163/// - fallback LLM chain (`Coordinator`)
164/// - observability sink (`run_history` + `agent_runs`)
165/// - usage/cost aggregation
166/// - token budget check
167/// - loop detection
168///
169/// Reachable via `ctx.get::<Execute>()` (see `Service` impl).
170#[derive(Clone)]
171pub struct Execute {
172    context_provider: Option<Arc<dyn crate::context_provider::ContextProvider>>,
173    /// Agent registry for creating agents from config (Phase 4 §15).
174    agent_registry: Option<Arc<crate::registry::AgentRegistry>>,
175    /// Run tracker for observability (Phase 4: extracted from root crate ActiveRuns).
176    run_tracker: Option<Arc<dyn RunTracker>>,
177    /// Fail closed instead of falling back: when `true`, every echo/fallback
178    /// path in `execute` returns `Err` so consumers never receive echoed
179    /// input or fallback-LLM content mistaken for a real agent run.
180    strict_fallbacks: bool,
181}
182
183impl Execute {
184    /// Create a new service with no backing stores (useful for tests and
185    /// `cargo check --no-default-features`).
186    pub fn new() -> Self {
187        Self {
188            context_provider: None,
189            agent_registry: None,
190            run_tracker: None,
191            strict_fallbacks: false,
192        }
193    }
194
195    /// Enable strict fallback mode: every echo/fallback entry in `execute`
196    /// returns `Err(AppError::Unavailable)` whose message starts with
197    /// `strict_fallbacks:` instead of echoing the request.
198    pub fn with_strict_fallbacks(mut self, strict: bool) -> Self {
199        self.strict_fallbacks = strict;
200        self
201    }
202
203    /// Whether strict fallback mode is enabled.
204    pub fn strict_fallbacks(&self) -> bool {
205        self.strict_fallbacks
206    }
207
208    /// Emit the `agent.started` event through the Cordis event bus with
209    /// `Dispatch::Parallel`, which fans out to every registered observer
210    /// concurrently and awaits all of them before returning (join-all).
211    ///
212    /// If no `EventsService` is present in the context, or the dispatch
213    /// errors, the original `payload` is returned unchanged so callers never
214    /// lose data.
215    pub async fn emit_agent_started(
216        &self,
217        ctx: &Arc<Context>,
218        payload: cordis::AgentStartedPayload,
219    ) -> serde_json::Value {
220        let value = match serde_json::to_value(&payload) {
221            Ok(v) => v,
222            Err(_) => return serde_json::to_value(payload).unwrap_or(serde_json::Value::Null),
223        };
224        let Some(events) = ctx.get::<cordis::EventsService>() else {
225            return value;
226        };
227        events
228            .dispatch_typed::<cordis::AgentStartedEvent>(&payload)
229            .await
230            .unwrap_or(value)
231    }
232
233    /// Fire-and-forget observability event via Cordis `Dispatch::Emit`.
234    ///
235    /// Returns immediately without waiting for handlers. Missing `EventsService`
236    /// is a no-op. Usage snapshot recording stays in server middleware
237    /// (`UsageContext` is not in this crate).
238    pub async fn emit_observability(
239        &self,
240        ctx: &Arc<Context>,
241        event: impl Into<String>,
242        payload: serde_json::Value,
243    ) {
244        let Some(events) = ctx.get::<cordis::EventsService>() else {
245            return;
246        };
247        let _ = events
248            .dispatch(event.into(), payload, cordis::Dispatch::Emit)
249            .await;
250    }
251
252    /// Typed fire-and-forget variant of [`emit_observability`]: dispatches the
253    /// payload struct for its catalog-bound event via `Dispatch::Emit`.
254    pub async fn emit_observability_typed<E: cordis::TypedEvent>(
255        &self,
256        ctx: &Arc<Context>,
257        payload: &E::Payload,
258    ) {
259        let Some(events) = ctx.get::<cordis::EventsService>() else {
260            return;
261        };
262        let _ = events.dispatch_typed::<E>(payload).await;
263    }
264
265    /// Attach a context provider for memory injection.
266    pub fn with_context_provider(
267        mut self,
268        provider: Arc<dyn crate::context_provider::ContextProvider>,
269    ) -> Self {
270        self.context_provider = Some(provider);
271        self
272    }
273
274    /// Attach an agent registry for creating agents from resolved configs.
275    pub fn with_agent_registry(mut self, registry: Arc<crate::registry::AgentRegistry>) -> Self {
276        self.agent_registry = Some(registry);
277        self
278    }
279
280    /// Attach a run tracker for observability.
281    pub fn with_run_tracker(mut self, tracker: Arc<dyn RunTracker>) -> Self {
282        self.run_tracker = Some(tracker);
283        self
284    }
285
286    /// Host-injected run tracker, if any.
287    pub fn run_tracker(&self) -> Option<&Arc<dyn RunTracker>> {
288        self.run_tracker.as_ref()
289    }
290
291    /// Execute an agent by name using the full pipeline: resolve → create → execute.
292    ///
293    /// This is the PRIMARY entry point that handlers should call. It:
294    /// 1. Resolves the agent via crate-private `Resolver` (3-tier: tenant → community → system)
295    /// 2. Creates the agent via `AgentRegistry::create_agent_from_config_with_fallbacks`
296    /// 3. Calls `agent.execute(message, context)` with the request ctx bound
297    /// 4. Returns `ExecutionResult` with response + resolution metadata
298    ///
299    /// Run tracking (start/finish) is handled internally via `RunTracker`.
300    pub async fn run(
301        &self,
302        req: &AgentRequest,
303        ctx: &Arc<Context>,
304    ) -> std::result::Result<ExecutionResult, AppError> {
305        crate::admit(ctx).await?;
306        let Some(events) = ctx.get::<EventsService>() else {
307            return self.run_resolved_or_execute(req, ctx).await;
308        };
309        let payload = serde_json::to_value(cordis::AgentRunRequest {
310            agent_name: req.agent_name.clone(),
311            message: req.message.clone(),
312        })
313        .unwrap_or(serde_json::Value::Null);
314        let execute = self.clone();
315        let ctx_owned = Arc::clone(ctx);
316        let orig = req.clone();
317        let out = events
318            .waterfall_around(
319                cordis::events_catalog::ev::AGENT_RUN.to_string(),
320                payload,
321                move |payload| async move {
322                    let mut run_req = orig;
323                    if let Some(name) = payload.get("agent_name").and_then(|v| v.as_str()) {
324                        run_req.agent_name = name.to_string();
325                    }
326                    if let Some(msg) = payload.get("message").and_then(|v| v.as_str()) {
327                        run_req.message = msg.to_string();
328                    }
329                    match execute.run_resolved_or_execute(&run_req, &ctx_owned).await {
330                        Ok(er) => Ok(serde_json::json!({
331                            "content": er.response.content,
332                            "usage": er.response.usage,
333                            "metadata": er.response.metadata.as_ref().map(|m| {
334                                serde_json::json!({
335                                    "model_name": m.model_name,
336                                    "provider_name": m.provider_name,
337                                })
338                            }),
339                            "source": er.source,
340                            "agent_name": er.agent_name,
341                            "run_id": er.run_id,
342                        })),
343                        Err(e) => Err(CordisError::Fiber(e.to_string())),
344                    }
345                },
346            )
347            .await
348            .map_err(|e| AppError::Internal(e.to_string()))?;
349        if out.get("deny").and_then(|v| v.as_bool()) == Some(true) {
350            let reason = out
351                .get("reason")
352                .and_then(|v| v.as_str())
353                .unwrap_or("agent.run denied");
354            return Err(AppError::InvalidInput(reason.to_string()));
355        }
356        let content = out
357            .get("content")
358            .and_then(|v| v.as_str())
359            .unwrap_or("")
360            .to_string();
361        let agent_name = out
362            .get("agent_name")
363            .and_then(|v| v.as_str())
364            .unwrap_or(&req.agent_name)
365            .to_string();
366        let run_id = out
367            .get("run_id")
368            .and_then(|v| v.as_str())
369            .unwrap_or("")
370            .to_string();
371        let source = out
372            .get("source")
373            .cloned()
374            .and_then(|v| serde_json::from_value(v).ok())
375            .unwrap_or(AgentSource::System);
376        let usage = out
377            .get("usage")
378            .cloned()
379            .and_then(|v| serde_json::from_value(v).ok());
380        let metadata = out.get("metadata").and_then(|v| {
381            Some(crate::ExecutionMetadata {
382                model_name: v.get("model_name")?.as_str()?.to_string(),
383                provider_name: v.get("provider_name")?.as_str()?.to_string(),
384            })
385        });
386        Ok(ExecutionResult {
387            response: AgentResponse {
388                content,
389                usage,
390                metadata,
391            },
392            source,
393            agent_name,
394            run_id,
395        })
396    }
397
398    async fn run_resolved_or_execute(
399        &self,
400        req: &AgentRequest,
401        ctx: &Arc<Context>,
402    ) -> std::result::Result<ExecutionResult, AppError> {
403        if let Some(dispatch) = ctx.get::<SkillDispatch>() {
404            return self.run_skill(req, ctx, &dispatch).await;
405        }
406        if let Some(result) = self.try_run_resolved(req, ctx).await {
407            return result;
408        }
409        let response = self.execute(req.clone(), ctx).await?;
410        Ok(ExecutionResult {
411            response,
412            source: AgentSource::System,
413            agent_name: req.agent_name.clone(),
414            run_id: uuid::Uuid::new_v4().to_string(),
415        })
416    }
417
418    #[cfg(feature = "postgres")]
419    async fn run_skill(
420        &self,
421        req: &AgentRequest,
422        ctx: &Arc<Context>,
423        dispatch: &SkillDispatch,
424    ) -> std::result::Result<ExecutionResult, AppError> {
425        let skill_engine = ctx
426            .get::<crate::skills::SkillEngine>()
427            .ok_or_else(|| AppError::Unavailable("SkillEngine is not provided".to_string()))?;
428        let value = skill_engine
429            .execute_skill(
430                &dispatch.skill_id,
431                &dispatch.tenant_id,
432                dispatch.input.clone(),
433                &dispatch.run_id,
434                ctx,
435            )
436            .await
437            .map_err(AppError::Internal)?;
438        Ok(ExecutionResult {
439            response: AgentResponse {
440                content: serde_json::to_string(&value)
441                    .map_err(|e| AppError::Internal(e.to_string()))?,
442                usage: None,
443                metadata: None,
444            },
445            source: AgentSource::System,
446            agent_name: req.agent_name.clone(),
447            run_id: dispatch.run_id.clone(),
448        })
449    }
450
451    #[cfg(not(feature = "postgres"))]
452    async fn run_skill(
453        &self,
454        _req: &AgentRequest,
455        _ctx: &Arc<Context>,
456        _dispatch: &SkillDispatch,
457    ) -> std::result::Result<ExecutionResult, AppError> {
458        Err(AppError::Unavailable(
459            "SkillEngine requires postgres".to_string(),
460        ))
461    }
462
463    async fn try_run_resolved(
464        &self,
465        req: &AgentRequest,
466        ctx: &Arc<Context>,
467    ) -> Option<std::result::Result<ExecutionResult, AppError>> {
468        #[cfg(feature = "postgres")]
469        {
470            return self.run_resolved(req, ctx).await;
471        }
472        #[cfg(not(feature = "postgres"))]
473        {
474            let _ = (req, ctx);
475            None
476        }
477    }
478
479    #[cfg(feature = "postgres")]
480    async fn run_resolved(
481        &self,
482        req: &AgentRequest,
483        ctx: &Arc<Context>,
484    ) -> Option<std::result::Result<ExecutionResult, AppError>> {
485        use crate::Agent;
486
487        let registry_owned = self
488            .agent_registry
489            .clone()
490            .or_else(|| ctx.get::<crate::registry::AgentRegistry>());
491        let registry = registry_owned.as_ref()?;
492        let resolver = ctx.get::<crate::resolver::Resolver>().or_else(|| {
493            crate::resolver::Resolver::from_ctx(ctx, Arc::clone(registry)).map(Arc::new)
494        })?;
495        let resolved = resolver.resolve(ctx, &req.agent_name).await;
496        let (user_agent, source) = match resolved {
497            Ok(v) => v,
498            Err(e) => return Some(Err(e)),
499        };
500        let user_id = user_id_from_ctx(ctx, "");
501
502        let mut config = crate::configurable::agent_config_from_user_agent(&user_agent);
503        if let (Some(policy), Some(ovr)) = (
504            ctx.get::<ares_llm::TenantModelPolicy>(),
505            ctx.get::<ModelOverride>(),
506        ) {
507            if let Err(e) = policy.authorize(&ovr.model) {
508                return Some(Err(e));
509            }
510        }
511        if let Some(ovr) = ctx.get::<ModelOverride>() {
512            tracing::info!(model=%ovr.model, agent=%req.agent_name, "model overridden via Cordis intercept");
513            config.model = ovr.model.clone();
514        }
515
516        let tenant_db = ctx.get::<ares_store::TenantDb>()?;
517        let fleet_secrets = ctx.get::<ares_store::FleetSecrets>()?;
518
519        let mut agent = match registry
520            .create_agent_from_config_with_fallbacks(
521                &req.agent_name,
522                &config,
523                &user_id,
524                tenant_db.pool(),
525                &fleet_secrets,
526            )
527            .await
528        {
529            Ok(a) => a,
530            Err(e) => return Some(Err(e)),
531        };
532        if let Some(tools) = ctx.get::<ares_tools::Tools>() {
533            agent.set_tools(tools);
534        }
535        agent.bind_request_ctx(ctx.clone());
536        agent.set_user_turn(
537            req.parts.clone(),
538            req.previous_response_id.clone(),
539            req.web_search,
540        );
541
542        let run_id = uuid::Uuid::new_v4().to_string();
543        if let Some(tracker) = &self.run_tracker {
544            tracker.start_run(
545                &run_id,
546                &user_id,
547                &req.agent_name,
548                Some("execution_service"),
549            );
550        }
551
552        if ctx.get::<cordis::EventsService>().is_some() {
553            let _ = self
554                .emit_agent_started(
555                    ctx,
556                    cordis::AgentStartedPayload {
557                        agent_name: req.agent_name.clone(),
558                        run_id: run_id.clone(),
559                        tenant: user_id.to_string(),
560                        event: cordis::events_catalog::ev::AGENT_STARTED.to_string(),
561                    },
562                )
563                .await;
564        }
565
566        let agent_context = ares_types::types::AgentContext {
567            user_id: user_id.to_string(),
568            session_id: format!("exec-{}", uuid::Uuid::new_v4()),
569            conversation_history: req.history.clone(),
570            user_memory: None,
571        };
572
573        let result = agent.execute(&req.message, &agent_context).await;
574
575        if let Some(tracker) = &self.run_tracker {
576            let status = if result.is_ok() {
577                "completed"
578            } else {
579                "failed"
580            };
581            tracker.finish_run(&run_id, status);
582        }
583
584        if let Ok(response) = result.as_ref() {
585            if let Some(usage) = &response.usage {
586                self.emit_observability_typed::<cordis::AgentUsageEvent>(
587                    ctx,
588                    &cordis::AgentUsagePayload {
589                        tenant: Some(user_id.to_string()),
590                        prompt: usage.prompt_tokens as i64,
591                        completion: usage.completion_tokens as i64,
592                        total: usage.total_tokens as i64,
593                    },
594                )
595                .await;
596            }
597        }
598
599        self.emit_observability_typed::<cordis::AgentCompletedEvent>(
600            ctx,
601            &cordis::AgentCompletedPayload {
602                agent_name: req.agent_name.clone(),
603                run_id: run_id.clone(),
604                status: if result.is_ok() {
605                    "completed"
606                } else {
607                    "failed"
608                }
609                .to_string(),
610                event: cordis::events_catalog::ev::AGENT_COMPLETED.to_string(),
611            },
612        )
613        .await;
614        if result.is_err() {
615            self.emit_observability_typed::<cordis::AgentFailedEvent>(
616                ctx,
617                &cordis::AgentFailedPayload {
618                    agent_name: req.agent_name.clone(),
619                    run_id: run_id.clone(),
620                    tenant: user_id.to_string(),
621                    event: cordis::events_catalog::ev::AGENT_FAILED.to_string(),
622                },
623            )
624            .await;
625        }
626
627        Some(result.map(|response| ExecutionResult {
628            response,
629            source,
630            agent_name: req.agent_name.clone(),
631            run_id,
632        }))
633    }
634
635    /// LLM/tools path used when Resolver/TenantDb are absent on ctx.
636    async fn execute(
637        &self,
638        req: AgentRequest,
639        ctx: &Arc<Context>,
640    ) -> Result<AgentResponse, AppError> {
641        if let Some(tenant_db) = tenant_db(ctx) {
642            let _pool = tenant_db.pool();
643            tracing::debug!(history_len = req.history.len(), "history load via TenantDb");
644            let _ = _pool;
645        }
646
647        if let (Some(policy), Some(ovr)) = (
648            ctx.get::<ares_llm::TenantModelPolicy>(),
649            ctx.get::<ModelOverride>(),
650        ) {
651            policy.authorize(&ovr.model)?;
652        }
653
654        let tenant = tenant_from_request_ctx(ctx, None);
655
656        let mut injected_context: Option<String> = None;
657        let provider_opt: Option<Arc<dyn crate::context_provider::ContextProvider>> = req
658            .ctx_provider
659            .clone()
660            .or_else(|| self.context_provider.clone());
661        if let Some(provider) = provider_opt {
662            let tid = tenant.clone().unwrap_or_default();
663            let rt_ctx = crate::context_provider::AgentRuntimeContext::new(
664                tid.clone(),
665                &req.agent_name,
666                "agent_execution",
667            );
668            if let Some(s) = provider.get_context_for_run(&rt_ctx).await {
669                tracing::debug!(
670                    len = s.len(),
671                    "memory injected via ContextProvider::get_context_for_run"
672                );
673                injected_context = Some(s);
674            } else if let Some(s) = provider.get_context(&req.agent_name, &tid).await {
675                tracing::debug!(
676                    len = s.len(),
677                    "memory injected via ContextProvider::get_context"
678                );
679                injected_context = Some(s);
680            }
681        }
682
683        let tools = ctx.get::<ares_tools::Tools>().unwrap_or_else(|| {
684            Arc::new(ares_tools::Tools::from_static(std::iter::empty::<
685                Arc<dyn ares_tools::Tool>,
686            >()))
687        });
688        let tool_definitions = tools.list(ctx);
689        tracing::debug!(
690            count = tool_definitions.len(),
691            has_service = true,
692            "tools resolved via Tools::list"
693        );
694        let _resolve_probe = tools.resolve(ctx, "__probe__");
695
696        let system_prompt = if let Some(extra) = injected_context.clone() {
697            format!(
698                "{}
699
700You are {}.",
701                extra, req.agent_name
702            )
703        } else {
704            format!("You are {}.", req.agent_name)
705        };
706
707        let mut base_messages: Vec<ares_llm::coordinator::ConversationMessage> = Vec::new();
708        base_messages.push(ares_llm::coordinator::ConversationMessage::system(
709            system_prompt.clone(),
710        ));
711        for msg in &req.history {
712            let cm = match msg.role {
713                ares_types::types::MessageRole::User => {
714                    ares_llm::coordinator::ConversationMessage::user(&msg.content)
715                }
716                ares_types::types::MessageRole::Assistant => {
717                    ares_llm::coordinator::ConversationMessage::assistant(&msg.content, vec![])
718                }
719                _ => ares_llm::coordinator::ConversationMessage::system(&msg.content),
720            };
721            base_messages.push(cm);
722        }
723        base_messages.push(user_message_with_parts(
724            req.message.clone(),
725            req.parts.clone(),
726            req.previous_response_id.clone(),
727        ));
728
729        let llm = match ctx.get::<ares_llm::Llm>() {
730            Some(llm) => Some(llm),
731            None if self.strict_fallbacks => {
732                return Err(AppError::Unavailable(
733                    "strict_fallbacks: no Llm service on context".into(),
734                ));
735            }
736            None => None,
737        };
738        if let Some(llm) = llm {
739            match llm
740                .get_client_boxed(ctx, ares_llm::CapabilityRequirements::default())
741                .await
742            {
743                Ok(client) => {
744                    apply_generation_hints(
745                        client.as_ref(),
746                        req.web_search,
747                        req.previous_response_id.clone(),
748                    );
749                    if !req.parts.is_empty() || req.previous_response_id.is_some() {
750                        match client
751                            .generate_with_tools_and_history(&base_messages, &tool_definitions)
752                            .await
753                        {
754                            Ok(resp) => {
755                                return Ok(AgentResponse {
756                                    content: resp.content,
757                                    usage: resp.usage,
758                                    metadata: None,
759                                });
760                            }
761                            Err(e) => {
762                                if self.strict_fallbacks {
763                                    return Err(AppError::Unavailable(format!(
764                                        "strict_fallbacks: generate_with_tools_and_history failed: {e}"
765                                    )));
766                                }
767                                tracing::warn!(
768                                    error = %e,
769                                    "multimodal generate failed, trying fallback LLM chain"
770                                );
771                            }
772                        }
773                    } else {
774                    let config = ares_llm::coordinator::ToolCallingConfig::default();
775                    let coordinator = ares_llm::coordinator::ToolCoordinator::new(
776                        client,
777                        Arc::clone(&tools),
778                        config,
779                    );
780                    match coordinator
781                        .execute(Some(&system_prompt), &req.message, ctx)
782                        .await
783                    {
784                        Ok(coord_result) => {
785                            if let Some(_db) = tenant_db(ctx) {
786                                tracing::debug!(
787                                    content_len = coord_result.content.len(),
788                                    "observability sink run_history/agent_runs via TenantDb"
789                                );
790                                let _ = _db;
791                            }
792                            let usage = coord_result.total_usage.clone();
793                            if let Some(tdb) = tenant_db(ctx) {
794                                let _pool = tdb.pool();
795                                tracing::debug!(
796                                    tenant = ?tenant,
797                                    prompt = usage.prompt_tokens,
798                                    completion = usage.completion_tokens,
799                                    total = usage.total_tokens,
800                                    "token budget check via TenantDb and usage aggregation"
801                                );
802                                let _ = _pool;
803                            }
804                            self.emit_observability_typed::<cordis::AgentUsageEvent>(
805                                ctx,
806                                &cordis::AgentUsagePayload {
807                                    tenant: tenant.clone(),
808                                    prompt: usage.prompt_tokens as i64,
809                                    completion: usage.completion_tokens as i64,
810                                    total: usage.total_tokens as i64,
811                                },
812                            )
813                            .await;
814                            let mut detector = crate::loop_detector::LoopDetector::new();
815                            match detector.check(&coord_result.content) {
816                                crate::loop_detector::LoopStatus::LoopDetected {
817                                    repeats,
818                                    action,
819                                    kind,
820                                } => {
821                                    tracing::warn!(
822                                        repeats,
823                                        ?action,
824                                        ?kind,
825                                        "loop_detector triggered in Execute"
826                                    );
827                                }
828                                crate::loop_detector::LoopStatus::Ok => {}
829                            }
830                            return Ok(AgentResponse {
831                                content: coord_result.content,
832                                usage: Some(usage),
833                                metadata: None,
834                            });
835                        }
836                        Err(e) => {
837                            if self.strict_fallbacks {
838                                return Err(AppError::Unavailable(format!(
839                                    "strict_fallbacks: ToolCoordinator::execute failed: {e}"
840                                )));
841                            }
842                            tracing::warn!(error = %e, "ToolCoordinator loop failed, trying fallback LLM chain");
843                        }
844                    }
845                    }
846                }
847                Err(e) => {
848                    if self.strict_fallbacks {
849                        return Err(AppError::Unavailable(format!(
850                            "strict_fallbacks: Llm::get_client_boxed failed: {e}"
851                        )));
852                    }
853                    tracing::warn!(error = %e, "Llm::get_client failed");
854                }
855            }
856
857            // Strict mode never reaches here (both Err arms above return
858            // early), but guard anyway: fallback-LLM content is refused.
859            if self.strict_fallbacks {
860                return Err(AppError::Unavailable(
861                    "strict_fallbacks: fallback LLM chain unavailable".into(),
862                ));
863            }
864            if let Ok(fb_client) = llm
865                .get_client(ctx, ares_llm::CapabilityRequirements::default())
866                .await
867            {
868                apply_generation_hints(
869                    fb_client.as_ref(),
870                    req.web_search,
871                    req.previous_response_id.clone(),
872                );
873                if let Ok(content) = fb_client.generate(&req.message).await {
874                    if let Some(_db) = tenant_db(ctx) {
875                        tracing::debug!("fallback observability run_history/agent_runs");
876                        let _ = _db;
877                    }
878                    let mut detector = crate::loop_detector::LoopDetector::new();
879                    let _ = detector.check(&content);
880                    return Ok(AgentResponse {
881                        content,
882                        usage: None,
883                        metadata: None,
884                    });
885                }
886            }
887        }
888
889        if let Some(_db) = tenant_db(ctx) {
890            tracing::debug!("echo fallback observability run_history/agent_runs");
891            let _ = _db;
892        }
893        let mut detector = crate::loop_detector::LoopDetector::new();
894        let _status = detector.check(&req.message);
895        let _ = crate::loop_detector::LoopConfig::default();
896
897        if self.strict_fallbacks {
898            return Err(AppError::Unavailable(
899                "strict_fallbacks: no LLM response available".into(),
900            ));
901        }
902
903        Ok(AgentResponse {
904            content: if req.message.is_empty() {
905                system_prompt
906            } else {
907                req.message.clone()
908            },
909            usage: None,
910            metadata: None,
911        })
912    }
913}
914
915impl Default for Execute {
916    fn default() -> Self {
917        Self::new()
918    }
919}
920
921/// Derive tenant for `execute` without requiring the postgres-only resolver module.
922/// Scope tools and execution to one tenant. Isolate wins over intercept.
923pub fn tenant_scope(ctx: &Arc<Context>, tenant_id: &str) -> Arc<Context> {
924    #[cfg(feature = "postgres")]
925    if let Some(realms) = ctx.get::<ares_store::TenantRealms>() {
926        return realms.open(ctx, tenant_id);
927    }
928    // Only data-bearing services are realm-isolated. `Execute` is a shared
929    // stateless engine; isolating it hid the root instance and broke every
930    // request path resolving it post-scope (v1/chat 503 regression).
931    ctx.isolate::<ares_tools::Tools>(tenant_id)
932}
933
934/// Request-path tenant: open the realm (or isolate) then intercept `TenantContext`.
935/// Background jobs keep using [`tenant_scope`] (isolate only, no intercept).
936pub fn request_tenant_ctx(
937    ctx: &Arc<Context>,
938    tc: ares_types::models::TenantContext,
939) -> Arc<Context> {
940    tenant_scope(ctx, &tc.tenant_id).with_intercept(tc)
941}
942
943/// JWT `user:` isolate when no tenant is present. Does not invent `TenantContext`.
944pub fn request_user_scope(ctx: &Arc<Context>, user_id: &str) -> Arc<Context> {
945    let label = format!("user:{user_id}");
946    ctx.isolate::<ares_tools::Tools>(&label)
947}
948
949/// Derive user/tenant scope: `Execute` isolate label (strip `tenant:`/`user:`),
950/// then `TenantContext` intercept, then `fallback`.
951pub fn user_id_from_ctx(ctx: &Arc<Context>, fallback: &str) -> String {
952    // Legacy label first (realms created before Execute stopped being
953    // isolated), then the live realm boundary on `Tools`.
954    for tid in [
955        std::any::TypeId::of::<Execute>(),
956        std::any::TypeId::of::<ares_tools::Tools>(),
957    ] {
958        if let Some(label) = ctx.isolate_label(tid) {
959            let trimmed = label
960                .strip_prefix("tenant:")
961                .or_else(|| label.strip_prefix("user:"))
962                .unwrap_or(&label);
963            if !trimmed.is_empty() {
964                return trimmed.to_string();
965            }
966        }
967    }
968    if let Some(tc) = ctx.get::<ares_types::models::TenantContext>() {
969        if !tc.tenant_id.is_empty() {
970            return tc.tenant_id.clone();
971        }
972    }
973    fallback.to_string()
974}
975
976#[cfg(feature = "postgres")]
977fn tenant_db(ctx: &Arc<Context>) -> Option<Arc<ares_store::TenantDb>> {
978    ctx.get::<ares_store::TenantDb>()
979}
980
981#[cfg(not(feature = "postgres"))]
982struct NoTenantDb;
983
984#[cfg(not(feature = "postgres"))]
985impl NoTenantDb {
986    fn pool(&self) -> &() {
987        &()
988    }
989}
990
991#[cfg(not(feature = "postgres"))]
992fn tenant_db(_ctx: &Arc<Context>) -> Option<Arc<NoTenantDb>> {
993    None
994}
995
996fn tenant_from_request_ctx(ctx: &Arc<Context>, fallback: Option<&str>) -> Option<String> {
997    let id = user_id_from_ctx(ctx, fallback.unwrap_or(""));
998    if id.is_empty() {
999        None
1000    } else {
1001        Some(id)
1002    }
1003}
1004
1005impl Service for Execute {
1006    fn name(&self) -> &'static str {
1007        "Execute"
1008    }
1009
1010    fn init(
1011        &self,
1012        _ctx: &Arc<Context>,
1013    ) -> Pin<
1014        Box<
1015            dyn Future<Output = Result<Option<Box<dyn cordis::Disposable>>, CordisError>>
1016                + Send
1017                + '_,
1018        >,
1019    > {
1020        Box::pin(async move { Ok(None) })
1021    }
1022
1023    fn check(&self) -> bool {
1024        true
1025    }
1026}
1027
1028/// Trait for tracking active agent runs. Implemented by the root crate's `ActiveRuns`
1029/// and injected into `Execute` via the Context.
1030///
1031/// This allows `ares-agent` (a leaf crate) to track runs without depending on root-crate types.
1032pub trait RunTracker: Send + Sync + 'static {
1033    /// Register a new run as active.
1034    fn start_run(&self, run_id: &str, tenant_id: &str, agent_name: &str, source: Option<&str>);
1035    /// Update run progress.
1036    fn update_run(&self, run_id: &str, status: &str, step: i32);
1037    /// Mark run as finished with terminal status.
1038    fn finish_run(&self, run_id: &str, status: &str);
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1045
1046    /// RED contract: the `agent.started` event must be fanned out to every
1047    /// registered handler via Cordis `Dispatch::Parallel` (join-all), so the
1048    /// dispatch awaits all handlers before returning. A fire-and-forget
1049    /// `Dispatch::Emit` returns immediately and may not have run any handler,
1050    /// so this assertion would be flaky/false under the old implementation.
1051    ///
1052    /// The harness calls the not-yet-existing public seam `emit_agent_started`,
1053    /// which the implement phase adds and wires into `run` in place
1054    /// of the `Dispatch::Emit` at line ~272.
1055    #[tokio::test]
1056    async fn agent_started_fans_out_via_parallel() {
1057        let svc = Execute::new();
1058        let ctx = Context::new_root();
1059        let events = ctx.provide(cordis::EventsService::new());
1060
1061        let count = Arc::new(AtomicUsize::new(0));
1062
1063        // Handler 1 — `Dispatch::Parallel` must run it before returning.
1064        let c1 = count.clone();
1065        let _d1 = events.on(
1066            cordis::events_catalog::ev::AGENT_STARTED.to_string(),
1067            move |payload: serde_json::Value| {
1068                let c = c1.clone();
1069                async move {
1070                    c.fetch_add(1, Ordering::SeqCst);
1071                    Ok(payload)
1072                }
1073            },
1074        );
1075
1076        // Handler 2 — also must be run before the dispatch returns.
1077        let c2 = count.clone();
1078        let _d2 = events.on(
1079            cordis::events_catalog::ev::AGENT_STARTED.to_string(),
1080            move |payload: serde_json::Value| {
1081                let c = c2.clone();
1082                async move {
1083                    c.fetch_add(1, Ordering::SeqCst);
1084                    Ok(payload)
1085                }
1086            },
1087        );
1088
1089        // Seam the implement phase adds: dispatches "agent.started" with
1090        // `Dispatch::Parallel` and returns the resulting value.
1091        svc.emit_agent_started(
1092            &ctx,
1093            cordis::AgentStartedPayload {
1094                agent_name: "a".into(),
1095                run_id: String::new(),
1096                tenant: String::new(),
1097                event: "agent.started".into(),
1098            },
1099        )
1100        .await;
1101
1102        assert_eq!(
1103            count.load(Ordering::SeqCst),
1104            2,
1105            "Dispatch::Parallel must join both 'agent.started' handlers before returning"
1106        );
1107    }
1108
1109    /// `Dispatch::Emit` must return before a slow handler finishes, then the
1110    /// handler still runs on the runtime after the call returns.
1111    #[tokio::test]
1112    async fn emit_observability_returns_without_waiting_for_slow_handler() {
1113        let svc = Execute::new();
1114        let ctx = Context::new_root();
1115        let events = ctx.provide(cordis::EventsService::new());
1116
1117        let ran = Arc::new(AtomicBool::new(false));
1118        let flag = ran.clone();
1119        let _d = events.on(
1120            cordis::events_catalog::ev::AGENT_USAGE.to_string(),
1121            move |payload: serde_json::Value| {
1122                let flag = flag.clone();
1123                async move {
1124                    tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1125                    flag.store(true, Ordering::SeqCst);
1126                    Ok(payload)
1127                }
1128            },
1129        );
1130
1131        let start = std::time::Instant::now();
1132        svc.emit_observability(
1133            &ctx,
1134            cordis::events_catalog::ev::AGENT_USAGE,
1135            serde_json::json!({}),
1136        )
1137        .await;
1138        let elapsed = start.elapsed();
1139        assert!(
1140            elapsed < std::time::Duration::from_millis(40),
1141            "emit_observability must return without awaiting handlers, elapsed {elapsed:?}"
1142        );
1143
1144        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1145        assert!(
1146            ran.load(Ordering::SeqCst),
1147            "slow agent.usage handler must still run after emit returns"
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn emit_agent_completed_and_failed_return_without_waiting() {
1153        let svc = Execute::new();
1154        let ctx = Context::new_root();
1155        let events = ctx.provide(cordis::EventsService::new());
1156
1157        let ran = Arc::new(AtomicBool::new(false));
1158        let mut _guards = Vec::new();
1159        for event in [cordis::events_catalog::ev::AGENT_COMPLETED, "agent.failed"] {
1160            let flag = ran.clone();
1161            _guards.push(events.on(event.into(), move |payload: serde_json::Value| {
1162                let flag = flag.clone();
1163                async move {
1164                    tokio::time::sleep(std::time::Duration::from_millis(80)).await;
1165                    flag.store(true, Ordering::SeqCst);
1166                    Ok(payload)
1167                }
1168            }));
1169        }
1170
1171        let start = std::time::Instant::now();
1172        svc.emit_observability(
1173            &ctx,
1174            cordis::events_catalog::ev::AGENT_COMPLETED,
1175            serde_json::json!({}),
1176        )
1177        .await;
1178        svc.emit_observability(
1179            &ctx,
1180            cordis::events_catalog::ev::AGENT_FAILED,
1181            serde_json::json!({}),
1182        )
1183        .await;
1184        let elapsed = start.elapsed();
1185        assert!(
1186            elapsed < std::time::Duration::from_millis(40),
1187            "completed/failed must Emit without awaiting handlers, elapsed {elapsed:?}"
1188        );
1189    }
1190
1191    struct ProbeTool {
1192        name: String,
1193    }
1194
1195    #[async_trait::async_trait]
1196    impl ares_tools::Tool for ProbeTool {
1197        fn name(&self) -> &str {
1198            &self.name
1199        }
1200        fn description(&self) -> &str {
1201            "probe"
1202        }
1203        fn parameters_schema(&self) -> serde_json::Value {
1204            serde_json::json!({"type": "object", "properties": {}})
1205        }
1206        async fn execute(
1207            &self,
1208            _args: serde_json::Value,
1209        ) -> ares_types::types::Result<serde_json::Value> {
1210            Ok(serde_json::json!({"ok": true}))
1211        }
1212    }
1213
1214    fn tools_with_probe() -> ares_tools::Tools {
1215        ares_tools::Tools::from_static([Arc::new(ProbeTool {
1216            name: "probe".into(),
1217        }) as Arc<dyn ares_tools::Tool>])
1218    }
1219
1220    async fn execute_with_tenant_context_intercept(tenant_id: &str) {
1221        let svc = Execute::new();
1222        let ctx = Context::new_root().with_intercept(ares_types::models::TenantContext::new(
1223            tenant_id.into(),
1224            ares_types::models::TenantTier::Pro,
1225        ));
1226        let _ = ctx.provide(tools_with_probe());
1227        let req = AgentRequest {
1228            agent_name: "echo".into(),
1229            message: "hi".into(),
1230            ..Default::default()
1231        };
1232        svc.run(&req, &ctx).await.expect("echo fallback");
1233        let tools = ctx.get::<ares_tools::Tools>().expect("Tools on ctx");
1234        let names: Vec<_> = tools.list(&ctx).into_iter().map(|d| d.name).collect();
1235        assert!(
1236            names.contains(&"probe".to_string()),
1237            "Tools::list(ctx) sees intercept tenant tools"
1238        );
1239    }
1240
1241    #[tokio::test]
1242    async fn execute_lists_tools_using_tenant_context_intercept() {
1243        execute_with_tenant_context_intercept("acme").await;
1244    }
1245
1246    /// When `Tools` is on ctx, `run` must call `Tools::list(ctx)` /
1247    /// `Tools::resolve(ctx, name)` (isolate+intercept).
1248    #[tokio::test]
1249    async fn execute_lists_tools_via_tools_on_ctx() {
1250        let svc = Execute::new();
1251        let ctx = Context::new_root().with_intercept(ares_types::models::TenantContext::new(
1252            "acme".into(),
1253            ares_types::models::TenantTier::Pro,
1254        ));
1255        let _ = ctx.provide(tools_with_probe());
1256        let req = AgentRequest {
1257            agent_name: "echo".into(),
1258            message: "hi".into(),
1259            ..Default::default()
1260        };
1261        svc.run(&req, &ctx).await.expect("echo fallback");
1262        let tools = ctx.get::<ares_tools::Tools>().expect("Tools on ctx");
1263        assert!(tools.resolve(&ctx, "probe").is_some());
1264        assert!(tools.list(&ctx).iter().any(|d| d.name == "probe"));
1265    }
1266
1267    /// Intercept path without an `AgentRequest` tenant field (aliases the listing test).
1268    #[tokio::test]
1269    async fn execute_uses_ctx_tenant_without_request_field() {
1270        execute_with_tenant_context_intercept("acme").await;
1271    }
1272
1273    #[tokio::test]
1274    async fn request_tenant_ctx_keeps_root_execute_resolvable() {
1275        // Regression guard for v1/chat: the handler resolves `Execute` from the
1276        // tenant-scoped context. Root-provided Execute must stay visible inside
1277        // the realm (Tools stays isolated; Execute is the shared engine).
1278        let root = Context::new_root();
1279        let _fid = root.plugin(cordis::EventsService::new()).await;
1280        root.provide_arc(Arc::new(Execute::new()) as Arc<Execute>);
1281        let tc = ares_types::models::TenantContext::new(
1282            "acme".into(),
1283            ares_types::models::TenantTier::Pro,
1284        );
1285        let scoped = crate::request_tenant_ctx(&root, tc);
1286        assert!(
1287            scoped.get::<Execute>().is_some(),
1288            "root-provided Execute must resolve inside tenant scope"
1289        );
1290    }
1291
1292    #[tokio::test]
1293    async fn execute_isolate_label_wins_over_intercept_for_tools() {
1294        let svc = Execute::new();
1295        let intercepted =
1296            Context::new_root().with_intercept(ares_types::models::TenantContext::new(
1297                "from-intercept".into(),
1298                ares_types::models::TenantTier::Pro,
1299            ));
1300        let ctx = tenant_scope(&intercepted, "from-isolate");
1301        let _ = ctx.provide(tools_with_probe());
1302        let req = AgentRequest {
1303            agent_name: "echo".into(),
1304            message: "hi".into(),
1305            ..Default::default()
1306        };
1307        svc.run(&req, &ctx).await.expect("echo fallback");
1308        assert_eq!(user_id_from_ctx(&ctx, "anon"), "from-isolate");
1309        let tools = ctx.get::<ares_tools::Tools>().expect("Tools on ctx");
1310        assert!(tools.list(&ctx).iter().any(|d| d.name == "probe"));
1311    }
1312
1313    #[tokio::test]
1314    async fn execute_admit_denies_without_http() {
1315        let execute = Execute::new();
1316        let quota = ares_types::models::TenantQuota {
1317            tier: ares_types::models::TenantTier::Free,
1318            requests_per_month: 0,
1319            tokens_per_month: 0,
1320            max_agents: 1,
1321            requests_per_day: 0,
1322        };
1323        let tc = ares_types::models::TenantContext {
1324            tenant_id: "capped".into(),
1325            tier: ares_types::models::TenantTier::Free,
1326            quota,
1327        };
1328        let ctx = Context::new_root().with_intercept(tc);
1329        let _ = ctx.provide(cordis::EventsService::new());
1330        let req = AgentRequest {
1331            agent_name: "echo".into(),
1332            message: "should not run".into(),
1333            ..Default::default()
1334        };
1335        let err = execute.run(&req, &ctx).await.expect_err("quota deny");
1336        match err {
1337            AppError::RateLimited(msg) => {
1338                assert_eq!(msg, "Monthly request quota exceeded");
1339            }
1340            other => panic!("expected RateLimited, got {other:?}"),
1341        }
1342    }
1343
1344    #[tokio::test(flavor = "multi_thread")]
1345    async fn agent_run_waterfall_rewrites_message() {
1346        let svc = Execute::new();
1347        let ctx = Context::new_root();
1348        let events = ctx.provide(cordis::EventsService::new());
1349        events.on_waterfall(
1350            cordis::events_catalog::ev::AGENT_RUN.to_string(),
1351            |mut payload, next| async move {
1352                payload["message"] = serde_json::json!("rewritten-hello");
1353                next(payload).await
1354            },
1355        );
1356        let req = AgentRequest {
1357            agent_name: "echo".into(),
1358            message: "original".into(),
1359            ..Default::default()
1360        };
1361        let result = svc.run(&req, &ctx).await.expect("echo fallback");
1362        assert!(
1363            result.response.content.contains("rewritten-hello"),
1364            "waterfall rewrite of message must reach echo execute, got {:?}",
1365            result.response.content
1366        );
1367    }
1368
1369    #[tokio::test]
1370    async fn strict_fallbacks_errors_without_llm() {
1371        let svc = Execute::new().with_strict_fallbacks(true);
1372        let ctx = Context::new_root();
1373        let req = AgentRequest {
1374            agent_name: "echo".into(),
1375            message: "hi".into(),
1376            ..Default::default()
1377        };
1378        let err = svc.run(&req, &ctx).await.expect_err("strict must refuse");
1379        match err {
1380            AppError::Unavailable(m) => {
1381                assert!(
1382                    m.starts_with("strict_fallbacks:"),
1383                    "unexpected refusal message: {m}"
1384                );
1385            }
1386            other => panic!("expected Unavailable, got {other:?}"),
1387        }
1388    }
1389
1390    #[tokio::test]
1391    async fn default_execute_still_echoes_without_llm() {
1392        let svc = Execute::new();
1393        assert!(!svc.strict_fallbacks());
1394        let ctx = Context::new_root();
1395        let req = AgentRequest {
1396            agent_name: "echo".into(),
1397            message: "hello-echo".into(),
1398            ..Default::default()
1399        };
1400        let result = svc.run(&req, &ctx).await.expect("echo fallback");
1401        assert_eq!(result.response.content, "hello-echo");
1402    }
1403
1404    #[tokio::test]
1405    async fn agent_run_short_circuit_skips_execute() {
1406        let svc = Execute::new();
1407        let ctx = Context::new_root();
1408        let events = ctx.provide(cordis::EventsService::new());
1409        events.on_waterfall(
1410            cordis::events_catalog::ev::AGENT_RUN.to_string(),
1411            |_payload, _next| async move {
1412                Ok(serde_json::json!({
1413                    "content": "short-circuit",
1414                    "source": "system",
1415                    "agent_name": "echo",
1416                    "run_id": "test-run",
1417                }))
1418            },
1419        );
1420        let req = AgentRequest {
1421            agent_name: "echo".into(),
1422            message: "would-echo-this-if-core-ran".into(),
1423            ..Default::default()
1424        };
1425        let result = svc.run(&req, &ctx).await.expect("short-circuit");
1426        assert_eq!(result.response.content, "short-circuit");
1427        assert_eq!(result.run_id, "test-run");
1428        assert_ne!(
1429            result.response.content, req.message,
1430            "skipping next must not run echo execute"
1431        );
1432    }
1433
1434    #[tokio::test]
1435    async fn request_tenant_ctx_intercepts_after_scope() {
1436        let root = Context::new_root();
1437        #[cfg(feature = "postgres")]
1438        {
1439            root.provide(ares_store::TenantRealms::new(
1440                std::any::TypeId::of::<ares_tools::Tools>(),
1441                std::any::TypeId::of::<Execute>(),
1442            ));
1443        }
1444        let tc = ares_types::models::TenantContext::new(
1445            "acme".into(),
1446            ares_types::models::TenantTier::Pro,
1447        );
1448        let scoped = request_tenant_ctx(&root, tc);
1449        let got = scoped
1450            .get::<ares_types::models::TenantContext>()
1451            .expect("TenantContext intercept");
1452        assert_eq!(got.tenant_id, "acme");
1453        assert_eq!(
1454            scoped
1455                .isolate_label(std::any::TypeId::of::<ares_tools::Tools>())
1456                .as_deref(),
1457            Some("acme")
1458        );
1459        // Execute is the shared engine: no realm label, always resolvable.
1460        assert_eq!(
1461            scoped
1462                .isolate_label(std::any::TypeId::of::<Execute>())
1463                .as_deref(),
1464            None
1465        );
1466        #[cfg(feature = "postgres")]
1467        {
1468            let realms = root
1469                .get::<ares_store::TenantRealms>()
1470                .expect("TenantRealms");
1471            let realm = realms.open(&root, "acme");
1472            assert!(
1473                realm.get::<ares_types::models::TenantContext>().is_none(),
1474                "cached realm must stay intercept-free"
1475            );
1476            let realm2 = realms.open(&root, "acme");
1477            assert!(std::sync::Arc::ptr_eq(&realm, &realm2));
1478        }
1479    }
1480
1481    #[tokio::test]
1482    async fn request_user_scope_does_not_invent_tenant_context() {
1483        let root = Context::new_root();
1484        let scoped = request_user_scope(&root, "user-1");
1485        assert!(scoped.get::<ares_types::models::TenantContext>().is_none());
1486        // Execute stays unlabeled (shared engine); Tools carries the realm.
1487        assert_eq!(
1488            scoped
1489                .isolate_label(std::any::TypeId::of::<Execute>())
1490                .as_deref(),
1491            None
1492        );
1493        assert_eq!(
1494            scoped
1495                .isolate_label(std::any::TypeId::of::<ares_tools::Tools>())
1496                .as_deref(),
1497            Some("user:user-1")
1498        );
1499    }
1500}