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