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