Skip to main content

stasis/application/runtime/
prompt_chat_job_handler.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use crate::application::orchestration::runtime_job_payloads::{
7    PromptJobPayload,
8};
9use crate::application::runtime::identity_context_compiler::{
10    load_identity_context_summary, prepend_identity_snapshot,
11};
12use crate::application::runtime::memory_persistence_helpers::{
13    SttpPromptNodeFormat, memory_query_fingerprint, memory_query_id, memory_scope_hash,
14    render_prompt_response_sttp_node, resolve_sttp_output_node_id, should_store,
15};
16use crate::application::runtime::memory_recall_request_builder::build_memory_recall_request;
17use crate::application::orchestration::prompt_pipeline::{
18    PromptExecutionPipeline, PromptExecutionRequest,
19};
20use crate::application::runtime::in_memory_runtime::{JobExecutionOutcome, JobHandler};
21use crate::application::runtime::runtime_diagnostics_helpers::{
22    build_runtime_failure_identity_context_section, build_runtime_failure_memory_recall_section,
23    build_runtime_memory_diagnostics_bundle, RuntimeIdentityDiagnosticsInput,
24    RuntimeMemoryRecallDiagnosticsInput, RuntimeMemoryStoreDiagnosticsInput,
25};
26use crate::application::runtime::runtime_handler_execution_context::RuntimeHandlerExecutionContext;
27use crate::domain::errors::Result;
28use crate::domain::runtime::job::Job;
29use crate::ports::outbound::ai_chat_client::AiChatClient;
30use crate::ports::outbound::memory::identity_memory_store::IdentityMemoryStore;
31use crate::ports::outbound::memory::memory_context_reader::MemoryContextReader;
32use crate::ports::outbound::memory::memory_context_writer::MemoryContextWriter;
33use crate::ports::outbound::memory::memory_models::MemoryStoreRequest;
34
35pub struct PromptChatJobHandler {
36    pipeline: PromptExecutionPipeline,
37    memory_reader: Option<Arc<dyn MemoryContextReader>>,
38    memory_writer: Option<Arc<dyn MemoryContextWriter>>,
39    identity_memory_store: Option<Arc<dyn IdentityMemoryStore>>,
40}
41
42impl PromptChatJobHandler {
43    pub fn new(chat_client: Arc<dyn AiChatClient>) -> Self {
44        Self::new_with_memory_and_identity(chat_client, None, None, None)
45    }
46
47    pub fn new_with_memory(
48        chat_client: Arc<dyn AiChatClient>,
49        memory_reader: Option<Arc<dyn MemoryContextReader>>,
50        memory_writer: Option<Arc<dyn MemoryContextWriter>>,
51    ) -> Self {
52        Self::new_with_memory_and_identity(chat_client, memory_reader, memory_writer, None)
53    }
54
55    pub fn new_with_memory_and_identity(
56        chat_client: Arc<dyn AiChatClient>,
57        memory_reader: Option<Arc<dyn MemoryContextReader>>,
58        memory_writer: Option<Arc<dyn MemoryContextWriter>>,
59        identity_memory_store: Option<Arc<dyn IdentityMemoryStore>>,
60    ) -> Self {
61        Self {
62            pipeline: PromptExecutionPipeline::new(chat_client),
63            memory_reader,
64            memory_writer,
65            identity_memory_store,
66        }
67    }
68
69    fn parse_payload(raw: &str) -> std::result::Result<PromptJobPayload, String> {
70        let payload: PromptJobPayload = serde_json::from_str(raw)
71            .map_err(|err| format!("policy violation: invalid prompt job payload json: {err}"))?;
72
73        if payload.user_prompt.trim().is_empty() {
74            return Err(
75                "policy violation: prompt payload.user_prompt must be non-empty".to_string(),
76            );
77        }
78
79        Ok(payload)
80    }
81
82    fn build_failure(message: String) -> JobExecutionOutcome {
83        let diagnostics = json!({
84            "provider": "stasis-pipeline",
85            "status": "failure",
86            "guardrail_code": "POLICY_VIOLATION",
87            "policy_reason": &message,
88        })
89        .to_string();
90
91        JobExecutionOutcome::FatalFailure {
92            message,
93            execution_id: None,
94            diagnostics: Some(diagnostics),
95        }
96    }
97
98}
99
100#[async_trait]
101impl JobHandler for PromptChatJobHandler {
102    fn job_type(&self) -> &'static str {
103        "workflow.stasis.prompt"
104    }
105
106    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
107        let payload = match Self::parse_payload(&job.payload_ref) {
108            Ok(payload) => payload,
109            Err(message) => return Ok(Self::build_failure(message)),
110        };
111
112        let execution_context = RuntimeHandlerExecutionContext::new(
113            job,
114            payload.policy_profile.clone(),
115            payload.model_hint.clone(),
116            self.memory_reader.is_some(),
117            self.memory_writer.is_some(),
118            self.identity_memory_store.is_some(),
119        );
120
121        let mut memory_recall = None;
122        let mut memory_recall_error = None;
123        let mut input_memory_query_id = None;
124        let mut input_memory_query_fingerprint = None;
125
126        let memory_policy = payload.memory_policy.as_ref();
127        let (identity_summary, identity_error) = load_identity_context_summary(
128            self.identity_memory_store.as_ref(),
129            execution_context.correlation_id(),
130            execution_context.policy_profile(),
131        )
132        .await;
133
134        let effective_user_prompt =
135            prepend_identity_snapshot(&payload.user_prompt, identity_summary.as_deref());
136
137        if let Some(reader) = &self.memory_reader {
138            let recall_request = build_memory_recall_request(
139                execution_context.correlation_id(),
140                Some(&effective_user_prompt),
141                memory_policy,
142            );
143            input_memory_query_id = Some(memory_query_id(
144                execution_context.correlation_id(),
145                &recall_request,
146            ));
147            input_memory_query_fingerprint = Some(memory_query_fingerprint(&recall_request));
148
149            match reader.recall(&recall_request).await {
150                Ok(response) => memory_recall = Some(response),
151                Err(err) => memory_recall_error = Some(err.to_string()),
152            }
153        }
154
155        let context = execution_context.prompt_context_clone();
156
157        let user_prompt = effective_user_prompt;
158        let mut request = PromptExecutionRequest::from_user_prompt(user_prompt.clone())
159            .with_context(context.clone());
160        if let Some(system_prompt) = payload.system_prompt {
161            request = request.with_system_prompt(system_prompt);
162        }
163
164        let response = match self.pipeline.execute(request).await {
165            Ok(response) => response,
166            Err(err) => {
167                let error = err.to_string();
168                return Ok(JobExecutionOutcome::FatalFailure {
169                    message: error.clone(),
170                    execution_id: None,
171                    diagnostics: Some(
172                        json!({
173                            "provider": "stasis-pipeline",
174                            "status": "failure",
175                            "error": error,
176                            "policy_profile": execution_context.policy_profile_clone(),
177                            "model_hint": execution_context.model_hint_clone(),
178                            "memory_recall": build_runtime_failure_memory_recall_section(
179                                execution_context.memory_reader_enabled(),
180                                memory_recall_error,
181                            ),
182                            "identity_context": build_runtime_failure_identity_context_section(
183                                execution_context.identity_enabled(),
184                                identity_summary,
185                                identity_error,
186                            ),
187                        })
188                        .to_string(),
189                    ),
190                });
191            }
192        };
193
194        let mut memory_store = None;
195        let mut memory_store_error = None;
196        if should_store(memory_policy)
197            && let Some(writer) = &self.memory_writer
198        {
199            let store_request = MemoryStoreRequest {
200                session_id: execution_context.correlation_id().to_string(),
201                raw_node: render_prompt_response_sttp_node(
202                    execution_context.correlation_id(),
203                    &user_prompt,
204                    &response.text,
205                    SttpPromptNodeFormat::UntaggedNoSchema,
206                ),
207            };
208
209            match writer.store_context(&store_request).await {
210                Ok(stored) => memory_store = Some(stored),
211                Err(err) => memory_store_error = Some(err.to_string()),
212            }
213        }
214
215        let sttp_output_node_id =
216            resolve_sttp_output_node_id(memory_store.as_ref(), format!("sttp:prompt:{}", job.id));
217        let memory_scope_hash = memory_scope_hash(execution_context.correlation_id(), memory_policy);
218        let input_memory_query_id_for_top_level = input_memory_query_id.clone();
219        let input_memory_query_fingerprint_for_top_level =
220            input_memory_query_fingerprint.clone();
221        let diagnostics_bundle = build_runtime_memory_diagnostics_bundle(
222            RuntimeMemoryRecallDiagnosticsInput {
223                attempted: execution_context.memory_reader_enabled(),
224                response: memory_recall,
225                query_id: input_memory_query_id,
226                query_fingerprint: input_memory_query_fingerprint,
227                error: memory_recall_error,
228            },
229            RuntimeMemoryStoreDiagnosticsInput {
230                attempted: execution_context.memory_writer_enabled(),
231                response: memory_store,
232                error: memory_store_error,
233            },
234            RuntimeIdentityDiagnosticsInput {
235                attempted: execution_context.identity_enabled(),
236                summary: identity_summary,
237                error: identity_error,
238            },
239        );
240
241        let diagnostics = json!({
242            "provider": "stasis-pipeline",
243            "status": "success",
244            "trace_id": context.trace_id,
245            "correlation_id": context.correlation_id,
246            "policy_profile": response.metadata.policy_profile,
247            "model_hint": response.metadata.model_hint,
248            "output_text": response.text,
249            "output_preview": response.text.chars().take(160).collect::<String>(),
250            "memory_retrieved_count": diagnostics_bundle.retrieved_count,
251            "memory_retrieval_path": diagnostics_bundle.retrieval_path,
252            "memory_fallback_triggered": diagnostics_bundle.fallback_triggered,
253            "memory_fallback_reason": diagnostics_bundle.fallback_reason,
254            "memory_scope_hash": memory_scope_hash,
255            "memory_store_valid": diagnostics_bundle.store_valid,
256            "memory_store_node_id": diagnostics_bundle.store_node_id,
257            "input_memory_query_id": input_memory_query_id_for_top_level,
258            "input_memory_query_fingerprint": input_memory_query_fingerprint_for_top_level,
259            "output_memory_node_id": diagnostics_bundle.store_node_id,
260            "memory_recall": diagnostics_bundle.memory_recall,
261            "memory_store": diagnostics_bundle.memory_store,
262            "identity_context": diagnostics_bundle.identity_context,
263        })
264        .to_string();
265
266        Ok(JobExecutionOutcome::Success {
267            sttp_output_node_id,
268            execution_id: None,
269            diagnostics: Some(diagnostics),
270        })
271    }
272}