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