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::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}