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