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