Skip to main content

ares_agent/
execution.rs

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