Skip to main content

stasis/application/runtime/
orchestrator_pattern_job_handler.rs

1use 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}