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