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