stasis/application/runtime/
orchestrator_pattern_job_handler.rs1use std::sync::Arc;
2
3use async_trait::async_trait;
4use chrono::Utc;
5use serde_json::json;
6
7use crate::application::orchestration::runtime_job_payloads::{
8 OrchestratorPatternJobPayload, OrchestratorRouteJobPayload,
9};
10use crate::application::runtime::chat_options_resolver::validate_reasoning_effort;
11use crate::application::orchestration::orchestrator_pattern_pipeline::{
12 OrchestratorPatternExecutionRequest, OrchestratorPatternPipeline, OrchestratorPatternRoute,
13};
14use crate::application::orchestration::prompt_pipeline::PromptExecutionPipeline;
15use crate::application::runtime::in_memory_runtime::{JobExecutionOutcome, JobHandler};
16use crate::domain::errors::Result;
17use crate::domain::runtime::job::Job;
18use crate::domain::runtime::thread::{NewThread, NewThreadEvent};
19use crate::ports::outbound::ai_chat_client::AiChatClient;
20use crate::ports::outbound::runtime::thread_store::ThreadStore;
21
22pub struct OrchestratorPatternJobHandler {
23 pipeline: OrchestratorPatternPipeline,
24 thread_store: Option<Arc<dyn ThreadStore>>,
25}
26
27impl OrchestratorPatternJobHandler {
28 pub fn new(chat_client: Arc<dyn AiChatClient>) -> Self {
29 Self::new_with_thread_store(chat_client, None)
30 }
31
32 pub fn new_with_thread_store(
33 chat_client: Arc<dyn AiChatClient>,
34 thread_store: Option<Arc<dyn ThreadStore>>,
35 ) -> Self {
36 let prompt_pipeline = PromptExecutionPipeline::new(chat_client);
37 Self {
38 pipeline: OrchestratorPatternPipeline::new(prompt_pipeline),
39 thread_store,
40 }
41 }
42
43 async fn ensure_thread(&self, thread_id: &str, now: chrono::DateTime<Utc>) {
44 let Some(store) = &self.thread_store else {
45 return;
46 };
47 let exists = store.get_thread(thread_id).await.ok().flatten().is_some();
48 if exists {
49 return;
50 }
51
52 let _ = store
53 .create_thread(NewThread {
54 thread_id: thread_id.to_string(),
55 parent_thread_id: None,
56 branch_label: Some("orchestrator".to_string()),
57 created_at: now,
58 })
59 .await;
60 }
61
62 async fn append_thread_event(
63 &self,
64 event_id: String,
65 thread_id: &str,
66 event_kind: &str,
67 payload_ref: String,
68 occurred_at: chrono::DateTime<Utc>,
69 ) {
70 let Some(store) = &self.thread_store else {
71 return;
72 };
73
74 let _ = store
75 .append_event(NewThreadEvent {
76 event_id,
77 thread_id: thread_id.to_string(),
78 event_kind: event_kind.to_string(),
79 payload_ref,
80 occurred_at,
81 })
82 .await;
83 }
84
85 fn parse_payload(raw: &str) -> std::result::Result<OrchestratorPatternJobPayload, String> {
86 let payload: OrchestratorPatternJobPayload = serde_json::from_str(raw).map_err(|err| {
87 format!("policy violation: invalid orchestrator-pattern payload json: {err}")
88 })?;
89
90 if payload.initial_user_prompt.trim().is_empty() {
91 return Err(
92 "policy violation: orchestrator-pattern payload.initial_user_prompt must be non-empty"
93 .to_string(),
94 );
95 }
96 if payload.routes.is_empty() {
97 return Err(
98 "policy violation: orchestrator-pattern payload.routes must include at least one route"
99 .to_string(),
100 );
101 }
102 for route in &payload.routes {
103 Self::validate_route(route)?;
104 }
105
106 validate_reasoning_effort(payload.reasoning_effort.as_deref())
107 .map_err(|err| format!("policy violation: {err}"))?;
108
109 Ok(payload)
110 }
111
112 fn validate_route(route: &OrchestratorRouteJobPayload) -> std::result::Result<(), String> {
113 if route.route_id.trim().is_empty() {
114 return Err(
115 "policy violation: orchestrator-pattern payload.routes[].route_id must be non-empty"
116 .to_string(),
117 );
118 }
119 if route.user_prompt_template.trim().is_empty() {
120 return Err(
121 "policy violation: orchestrator-pattern payload.routes[].user_prompt_template must be non-empty"
122 .to_string(),
123 );
124 }
125
126 validate_reasoning_effort(route.reasoning_effort.as_deref())
127 .map_err(|err| format!("policy violation: {err}"))?;
128
129 Ok(())
130 }
131
132 fn build_failure(message: String) -> JobExecutionOutcome {
133 let diagnostics = json!({
134 "provider": "stasis-orchestration-orchestrator",
135 "status": "failure",
136 "pattern": "orchestrator",
137 "guardrail_code": "POLICY_VIOLATION",
138 "policy_reason": &message,
139 })
140 .to_string();
141
142 JobExecutionOutcome::FatalFailure {
143 message,
144 execution_id: None,
145 diagnostics: Some(diagnostics),
146 }
147 }
148}
149
150#[async_trait]
151impl JobHandler for OrchestratorPatternJobHandler {
152 fn job_type(&self) -> &'static str {
153 "workflow.stasis.orchestration.orchestrator"
154 }
155
156 async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
157 let payload = match Self::parse_payload(&job.payload_ref) {
158 Ok(payload) => payload,
159 Err(message) => return Ok(Self::build_failure(message)),
160 };
161
162 let OrchestratorPatternJobPayload {
163 thread_id,
164 initial_user_prompt,
165 policy_profile,
166 model_hint,
167 reasoning_effort,
168 routes,
169 } = payload;
170
171 let now = Utc::now();
172 let thread_id = thread_id.unwrap_or_else(|| job.correlation_id.clone());
173 self.ensure_thread(&thread_id, now).await;
174 self.append_thread_event(
175 format!("{}:orchestrator:start", job.id),
176 &thread_id,
177 "orchestration.orchestrator.started",
178 initial_user_prompt.clone(),
179 now,
180 )
181 .await;
182
183 let request = OrchestratorPatternExecutionRequest {
184 initial_user_prompt,
185 trace_id: Some(job.trace_id.clone()),
186 correlation_id: Some(job.correlation_id.clone()),
187 policy_profile,
188 model_hint,
189 reasoning_effort,
190 routes: routes
191 .into_iter()
192 .map(|route| OrchestratorPatternRoute {
193 route_id: route.route_id,
194 selector_keywords: route.selector_keywords,
195 user_prompt_template: route.user_prompt_template,
196 system_prompt: route.system_prompt,
197 policy_profile: route.policy_profile,
198 model_hint: route.model_hint,
199 reasoning_effort: route.reasoning_effort,
200 })
201 .collect(),
202 };
203
204 let response = match self.pipeline.execute(request).await {
205 Ok(response) => response,
206 Err(err) => {
207 let error = err.to_string();
208 return Ok(JobExecutionOutcome::FatalFailure {
209 message: error.clone(),
210 execution_id: None,
211 diagnostics: Some(
212 json!({
213 "provider": "stasis-orchestration-orchestrator",
214 "status": "failure",
215 "pattern": "orchestrator",
216 "error": error,
217 })
218 .to_string(),
219 ),
220 });
221 }
222 };
223
224 self.append_thread_event(
225 format!("{}:orchestrator:selected", job.id),
226 &thread_id,
227 "orchestration.orchestrator.completed",
228 format!(
229 "route={} reason={}",
230 response.selected_route_id, response.selection_reason
231 ),
232 Utc::now(),
233 )
234 .await;
235
236 Ok(JobExecutionOutcome::Success {
237 sttp_output_node_id: format!("sttp:orchestration:orchestrator:{}", job.id),
238 execution_id: None,
239 diagnostics: Some(
240 json!({
241 "provider": "stasis-orchestration-orchestrator",
242 "status": "success",
243 "pattern": "orchestrator",
244 "thread_id": thread_id,
245 "selected_route_id": response.selected_route_id,
246 "selection_reason": response.selection_reason,
247 "rendered_prompt": response.rendered_prompt,
248 "final_text": response.output_text,
249 "termination_reason": response.termination_reason,
250 })
251 .to_string(),
252 ),
253 })
254 }
255}