Skip to main content

stasis/application/runtime/
handoff_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    HandoffPatternJobPayload, HandoffTurnJobPayload,
9};
10use crate::application::runtime::chat_options_resolver::validate_reasoning_effort;
11use crate::application::orchestration::handoff_pattern_pipeline::{
12    HandoffPatternExecutionRequest, HandoffPatternPipeline, HandoffPatternTurn,
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 HandoffPatternJobHandler {
23    pipeline: HandoffPatternPipeline,
24    thread_store: Option<Arc<dyn ThreadStore>>,
25}
26
27impl HandoffPatternJobHandler {
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: HandoffPatternPipeline::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("handoff".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<HandoffPatternJobPayload, String> {
86        let payload: HandoffPatternJobPayload = serde_json::from_str(raw).map_err(|err| {
87            format!("policy violation: invalid handoff-pattern payload json: {err}")
88        })?;
89
90        if payload.initial_user_prompt.trim().is_empty() {
91            return Err(
92                "policy violation: handoff-pattern payload.initial_user_prompt must be non-empty"
93                    .to_string(),
94            );
95        }
96        if payload.turns.is_empty() {
97            return Err(
98                "policy violation: handoff-pattern payload.turns must include at least one turn"
99                    .to_string(),
100            );
101        }
102
103        for turn in &payload.turns {
104            Self::validate_turn(turn)?;
105        }
106
107        validate_reasoning_effort(payload.reasoning_effort.as_deref())
108            .map_err(|err| format!("policy violation: {err}"))?;
109
110        Ok(payload)
111    }
112
113    fn validate_turn(turn: &HandoffTurnJobPayload) -> std::result::Result<(), String> {
114        if turn.actor_id.trim().is_empty() {
115            return Err(
116                "policy violation: handoff-pattern payload.turns[].actor_id must be non-empty"
117                    .to_string(),
118            );
119        }
120        if turn.user_prompt_template.trim().is_empty() {
121            return Err(
122                "policy violation: handoff-pattern payload.turns[].user_prompt_template must be non-empty"
123                    .to_string(),
124            );
125        }
126
127        validate_reasoning_effort(turn.reasoning_effort.as_deref())
128            .map_err(|err| format!("policy violation: {err}"))?;
129
130        Ok(())
131    }
132
133    fn build_failure(message: String) -> JobExecutionOutcome {
134        let diagnostics = json!({
135            "provider": "stasis-orchestration-handoff",
136            "status": "failure",
137            "pattern": "handoff",
138            "guardrail_code": "POLICY_VIOLATION",
139            "policy_reason": &message,
140        })
141        .to_string();
142
143        JobExecutionOutcome::FatalFailure {
144            message,
145            execution_id: None,
146            diagnostics: Some(diagnostics),
147        }
148    }
149}
150
151#[async_trait]
152impl JobHandler for HandoffPatternJobHandler {
153    fn job_type(&self) -> &'static str {
154        "workflow.stasis.orchestration.handoff"
155    }
156
157    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
158        let payload = match Self::parse_payload(&job.payload_ref) {
159            Ok(payload) => payload,
160            Err(message) => return Ok(Self::build_failure(message)),
161        };
162
163        let HandoffPatternJobPayload {
164            thread_id,
165            initial_user_prompt,
166            policy_profile,
167            model_hint,
168            reasoning_effort,
169            turns,
170        } = payload;
171
172        let now = Utc::now();
173        let thread_id = thread_id.unwrap_or_else(|| job.correlation_id.clone());
174        self.ensure_thread(&thread_id, now).await;
175        self.append_thread_event(
176            format!("{}:handoff:start", job.id),
177            &thread_id,
178            "orchestration.handoff.started",
179            initial_user_prompt.clone(),
180            now,
181        )
182        .await;
183
184        let request = HandoffPatternExecutionRequest {
185            initial_user_prompt,
186            trace_id: Some(job.trace_id.clone()),
187            correlation_id: Some(job.correlation_id.clone()),
188            policy_profile,
189            model_hint,
190            reasoning_effort,
191            turns: turns
192                .into_iter()
193                .map(|turn| HandoffPatternTurn {
194                    actor_id: turn.actor_id,
195                    user_prompt_template: turn.user_prompt_template,
196                    system_prompt: turn.system_prompt,
197                    policy_profile: turn.policy_profile,
198                    model_hint: turn.model_hint,
199                    reasoning_effort: turn.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-handoff",
214                            "status": "failure",
215                            "pattern": "handoff",
216                            "error": error,
217                        })
218                        .to_string(),
219                    ),
220                });
221            }
222        };
223
224        let actor_ids: Vec<String> = response
225            .turns
226            .iter()
227            .map(|turn| turn.actor_id.clone())
228            .collect();
229
230        let handoffs: Vec<_> = response
231            .handoffs
232            .iter()
233            .map(|handoff| {
234                json!({
235                    "from_actor_id": handoff.from_actor_id,
236                    "to_actor_id": handoff.to_actor_id,
237                })
238            })
239            .collect();
240
241        self.append_thread_event(
242            format!("{}:handoff:completed", job.id),
243            &thread_id,
244            "orchestration.handoff.completed",
245            response.final_text.clone(),
246            Utc::now(),
247        )
248        .await;
249
250        Ok(JobExecutionOutcome::Success {
251            sttp_output_node_id: format!("sttp:orchestration:handoff:{}", job.id),
252            execution_id: None,
253            diagnostics: Some(
254                json!({
255                    "provider": "stasis-orchestration-handoff",
256                    "status": "success",
257                    "pattern": "handoff",
258                    "turns_executed": response.turns.len(),
259                    "actor_ids": actor_ids,
260                    "handoffs": handoffs,
261                    "thread_id": thread_id,
262                    "final_text": response.final_text,
263                    "termination_reason": response.termination_reason,
264                })
265                .to_string(),
266            ),
267        })
268    }
269}