stasis/application/runtime/
handoff_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 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}