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