everruns_core/output_guardrail.rs
1// Streaming output guardrails.
2//
3// Capabilities can contribute guardrails that inspect the model's streamed
4// output as it arrives and, post factum, replace the entire response with a
5// canned message when a violation is detected. The client receives normal
6// `output.message.delta` events until a guardrail trips; at that point a
7// single `output.message.replaced` event tells the client to discard the
8// accumulated text and show the replacement instead.
9//
10// Design constraints:
11// - Guardrails run on every batched delta in the streaming hot path. The
12// `check` method is intentionally synchronous so slow checks cannot back
13// up the LLM stream. Heavy guardrails (e.g. an LLM-based moderator)
14// should run asynchronously elsewhere — this trait is for cheap, in-
15// process inspection.
16// - The model's original tokens are never persisted when a guardrail trips;
17// the replacement becomes the canonical assistant message so later turns
18// can never see what was blocked.
19
20use std::sync::Arc;
21
22use async_trait::async_trait;
23
24/// Provider-side definition of an output guardrail.
25///
26/// Contributed by capabilities via `Capability::output_guardrails()`. A single
27/// provider may serve multiple sessions concurrently; per-stream mutable
28/// state lives in the [`OutputGuardrailRun`] returned by [`Self::arm`].
29pub trait OutputGuardrail: Send + Sync {
30 /// Stable identifier (e.g. `"prompt_canary"`). Surfaced to clients in the
31 /// `output.message.replaced` event so they can localize messaging or
32 /// route to telemetry.
33 fn id(&self) -> &str;
34
35 /// Construct a per-stream guardrail. Called once at the start of an
36 /// assistant message stream with a snapshot of the runtime context.
37 /// Returning `None` skips the guardrail for this stream (e.g. no canary
38 /// could be derived from the system prompt).
39 fn arm(&self, ctx: &OutputGuardrailContext<'_>) -> Option<Box<dyn OutputGuardrailRun>>;
40}
41
42/// Per-stream guardrail instance. Holds whatever state the implementation
43/// needs across delta callbacks (e.g. precomputed needles, position cursors).
44pub trait OutputGuardrailRun: Send {
45 /// Inspect the latest accumulated output. Called after each batched
46 /// delta is appended. `accumulated` is the full assistant text so far;
47 /// `delta` is the chunk that was just added.
48 ///
49 /// Returning [`GuardrailDecision::Block`] aborts the stream and triggers
50 /// replacement. Subsequent calls are not made on a blocked stream.
51 fn check(&mut self, accumulated: &str, delta: &str) -> GuardrailDecision;
52}
53
54/// Snapshot of the runtime configuration available when arming a guardrail.
55///
56/// Borrowed for the duration of the `arm` call so guardrails can read the
57/// system prompt without owning a copy. Anything the guardrail needs after
58/// `arm` returns must be cloned into the returned `OutputGuardrailRun`.
59pub struct OutputGuardrailContext<'a> {
60 /// The fully assembled system prompt for this turn.
61 pub system_prompt: &'a str,
62 /// Per-capability config JSON (`AgentCapabilityConfig.config`).
63 pub config: &'a serde_json::Value,
64}
65
66/// Guardrail decision returned from `check`.
67#[derive(Debug, Clone)]
68pub enum GuardrailDecision {
69 /// Output is fine; keep streaming.
70 Pass,
71 /// Output violated the guardrail. The stream is aborted and the client
72 /// is told to replace the accumulated text with `replacement`.
73 Block(GuardrailBlock),
74}
75
76/// Details of a guardrail violation, surfaced to the client in the
77/// `output.message.replaced` event and persisted as the assistant message.
78#[derive(Debug, Clone)]
79pub struct GuardrailBlock {
80 /// Stable machine-readable code (e.g. `"system_prompt_leak"`). Clients
81 /// localize their copy from this rather than the human text.
82 pub reason_code: String,
83 /// Replacement text shown to the user and stored in the conversation.
84 pub replacement: String,
85}
86
87/// Convenience constructor.
88impl GuardrailDecision {
89 pub fn block(reason_code: impl Into<String>, replacement: impl Into<String>) -> Self {
90 GuardrailDecision::Block(GuardrailBlock {
91 reason_code: reason_code.into(),
92 replacement: replacement.into(),
93 })
94 }
95}
96
97/// One armed guardrail for a single stream. Carries the contributing
98/// capability id alongside the guardrail's own id so the
99/// `output.message.replaced` event can label both.
100pub struct ArmedGuardrail {
101 pub capability_id: String,
102 pub guardrail_id: String,
103 pub run: Box<dyn OutputGuardrailRun>,
104}
105
106/// Run all armed guardrails against the latest accumulated output. Returns
107/// the first block, in registration order. Pure helper — no I/O.
108pub fn evaluate_guardrails(
109 runs: &mut [ArmedGuardrail],
110 accumulated: &str,
111 delta: &str,
112) -> Option<TrippedGuardrail> {
113 for armed in runs.iter_mut() {
114 match armed.run.check(accumulated, delta) {
115 GuardrailDecision::Pass => continue,
116 GuardrailDecision::Block(block) => {
117 return Some(TrippedGuardrail {
118 capability_id: armed.capability_id.clone(),
119 guardrail_id: armed.guardrail_id.clone(),
120 block,
121 });
122 }
123 }
124 }
125 None
126}
127
128/// Result of [`evaluate_guardrails`]: which guardrail tripped and with what
129/// replacement.
130#[derive(Debug, Clone)]
131pub struct TrippedGuardrail {
132 pub capability_id: String,
133 pub guardrail_id: String,
134 pub block: GuardrailBlock,
135}
136
137/// Arm a set of providers for a stream. Providers that decline to arm
138/// (return `None`) are skipped. Each provider carries the contributing
139/// capability id so the resulting [`ArmedGuardrail`] can label events.
140pub fn arm_guardrails(
141 providers: &[(String, Arc<dyn OutputGuardrail>)],
142 ctx: &OutputGuardrailContext<'_>,
143) -> Vec<ArmedGuardrail> {
144 providers
145 .iter()
146 .filter_map(|(cap_id, p)| {
147 let guardrail_id = p.id().to_string();
148 p.arm(ctx).map(|run| ArmedGuardrail {
149 capability_id: cap_id.clone(),
150 guardrail_id,
151 run,
152 })
153 })
154 .collect()
155}
156
157// ---------------------------------------------------------------------------
158// End-of-message (post-generation) output seam (EVE-573)
159// ---------------------------------------------------------------------------
160
161/// Async, end-of-message output guardrail.
162///
163/// Unlike [`OutputGuardrail`] — which runs synchronously on every streamed
164/// delta in the hot path and must stay cheap — this seam runs **once** on the
165/// fully assembled assistant message after streaming completes and before the
166/// message is finalized into context. It may perform I/O (e.g. call a
167/// moderation classifier through the utility LLM).
168///
169/// Contract: implementations MUST be internally time-bounded and **fail open**
170/// — any timeout, transport error, or missing dependency must return
171/// [`GuardrailDecision::Pass`] so a guardrail outage never wedges a turn. A
172/// [`GuardrailDecision::Block`] reuses the streaming seam's plumbing: the
173/// finalized message is replaced with the block's `replacement` and a single
174/// `output.message.replaced` event is emitted. The model's original tokens are
175/// never persisted once a block fires.
176#[async_trait]
177pub trait PostGenerationOutputGuardrail: Send + Sync {
178 /// Stable identifier (e.g. `"moderation"`), surfaced to clients in the
179 /// `output.message.replaced` event.
180 fn id(&self) -> &str;
181
182 /// Inspect the finalized client-visible assistant output, including
183 /// annotation metadata. Returns
184 /// [`GuardrailDecision::Block`] to replace it; otherwise
185 /// [`GuardrailDecision::Pass`]. Must fail open on any error.
186 async fn check_message(&self, ctx: &PostGenerationOutputContext<'_>) -> GuardrailDecision;
187}
188
189/// Runtime context handed to a [`PostGenerationOutputGuardrail`]. Borrowed for
190/// the duration of the `check_message` call.
191pub struct PostGenerationOutputContext<'a> {
192 /// The fully assembled system prompt for this turn.
193 pub system_prompt: &'a str,
194 /// The finalized client-visible output (post-streaming, pre-context), with
195 /// persisted annotation metadata appended when present.
196 pub message_text: &'a str,
197 /// Utility LLM service for model-backed checks. `None` when the deployment
198 /// has no utility model configured — model-backed checks then fail open.
199 pub utility_llm_service: Option<&'a Arc<dyn crate::UtilityLlmService>>,
200}
201
202/// A post-generation guardrail provider paired with its contributing
203/// capability id, so a trip can label the `output.message.replaced` event.
204pub struct PostGenerationProvider {
205 pub capability_id: String,
206 pub provider: Arc<dyn PostGenerationOutputGuardrail>,
207}
208
209/// Build the complete client-visible output inspected by post-generation
210/// guardrails. Citation metadata is persisted and rendered alongside the
211/// assistant text, so it must cross the same output policy boundary.
212pub fn post_generation_guardrail_text(
213 message_text: &str,
214 annotations: &[crate::message::TextAnnotation],
215) -> String {
216 if annotations.is_empty() {
217 return message_text.to_string();
218 }
219
220 let mut output = String::from(message_text);
221 for annotation in annotations {
222 output.push('\n');
223 output.push_str(&annotation.source.uri);
224 if let Some(title) = &annotation.source.title {
225 output.push('\n');
226 output.push_str(title);
227 }
228 if let Some(snippet) = &annotation.source.snippet {
229 output.push('\n');
230 output.push_str(snippet);
231 }
232 if let Some(location) = &annotation.source.location {
233 output.push('\n');
234 output.push_str(&location.to_string());
235 }
236 if let Some(external_id) = &annotation.external_id {
237 output.push('\n');
238 output.push_str(external_id);
239 }
240 }
241 output
242}
243
244/// Run post-generation guardrails in registration order, returning the first
245/// block. Pure orchestration: each provider owns its timeout / fail-open
246/// behavior, so a provider that errors simply yields `Pass` and the next runs.
247pub async fn evaluate_post_generation_guardrails(
248 providers: &[PostGenerationProvider],
249 ctx: &PostGenerationOutputContext<'_>,
250) -> Option<TrippedGuardrail> {
251 for p in providers {
252 match p.provider.check_message(ctx).await {
253 GuardrailDecision::Pass => continue,
254 GuardrailDecision::Block(block) => {
255 return Some(TrippedGuardrail {
256 capability_id: p.capability_id.clone(),
257 guardrail_id: p.provider.id().to_string(),
258 block,
259 });
260 }
261 }
262 }
263 None
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::message::{AnnotationSource, TextAnnotation};
270
271 struct AlwaysBlock;
272 impl OutputGuardrailRun for AlwaysBlock {
273 fn check(&mut self, _accumulated: &str, _delta: &str) -> GuardrailDecision {
274 GuardrailDecision::block("test_block", "[blocked]")
275 }
276 }
277
278 struct NeverBlock;
279 impl OutputGuardrailRun for NeverBlock {
280 fn check(&mut self, _accumulated: &str, _delta: &str) -> GuardrailDecision {
281 GuardrailDecision::Pass
282 }
283 }
284
285 fn armed(cap: &str, guard: &str, run: Box<dyn OutputGuardrailRun>) -> ArmedGuardrail {
286 ArmedGuardrail {
287 capability_id: cap.to_string(),
288 guardrail_id: guard.to_string(),
289 run,
290 }
291 }
292
293 #[test]
294 fn evaluate_returns_first_block_in_order() {
295 let mut runs = vec![
296 armed("cap_a", "g_a", Box::new(NeverBlock)),
297 armed("cap_b", "g_b", Box::new(AlwaysBlock)),
298 armed("cap_c", "g_c", Box::new(AlwaysBlock)),
299 ];
300 let tripped = evaluate_guardrails(&mut runs, "any text", "delta").expect("blocked");
301 assert_eq!(tripped.capability_id, "cap_b");
302 assert_eq!(tripped.guardrail_id, "g_b");
303 assert_eq!(tripped.block.reason_code, "test_block");
304 assert_eq!(tripped.block.replacement, "[blocked]");
305 }
306
307 #[test]
308 fn evaluate_returns_none_when_all_pass() {
309 let mut runs = vec![
310 armed("cap_a", "g_a", Box::new(NeverBlock)),
311 armed("cap_b", "g_b", Box::new(NeverBlock)),
312 ];
313 assert!(evaluate_guardrails(&mut runs, "txt", "d").is_none());
314 }
315
316 struct PostPass;
317 #[async_trait]
318 impl PostGenerationOutputGuardrail for PostPass {
319 fn id(&self) -> &str {
320 "pass"
321 }
322 async fn check_message(&self, _ctx: &PostGenerationOutputContext<'_>) -> GuardrailDecision {
323 GuardrailDecision::Pass
324 }
325 }
326
327 struct PostBlock;
328 #[async_trait]
329 impl PostGenerationOutputGuardrail for PostBlock {
330 fn id(&self) -> &str {
331 "block"
332 }
333 async fn check_message(&self, _ctx: &PostGenerationOutputContext<'_>) -> GuardrailDecision {
334 GuardrailDecision::block("guardrail.moderation", "[removed]")
335 }
336 }
337
338 fn post_ctx<'a>(text: &'a str) -> PostGenerationOutputContext<'a> {
339 PostGenerationOutputContext {
340 system_prompt: "",
341 message_text: text,
342 utility_llm_service: None,
343 }
344 }
345
346 #[tokio::test]
347 async fn post_generation_returns_first_block_in_order() {
348 let providers = vec![
349 PostGenerationProvider {
350 capability_id: "cap_a".to_string(),
351 provider: Arc::new(PostPass),
352 },
353 PostGenerationProvider {
354 capability_id: "cap_b".to_string(),
355 provider: Arc::new(PostBlock),
356 },
357 ];
358 let ctx = post_ctx("hello");
359 let tripped = evaluate_post_generation_guardrails(&providers, &ctx)
360 .await
361 .expect("blocked");
362 assert_eq!(tripped.capability_id, "cap_b");
363 assert_eq!(tripped.guardrail_id, "block");
364 assert_eq!(tripped.block.reason_code, "guardrail.moderation");
365 assert_eq!(tripped.block.replacement, "[removed]");
366 }
367
368 #[tokio::test]
369 async fn post_generation_returns_none_when_all_pass() {
370 let providers = vec![PostGenerationProvider {
371 capability_id: "cap_a".to_string(),
372 provider: Arc::new(PostPass),
373 }];
374 let ctx = post_ctx("hello");
375 assert!(
376 evaluate_post_generation_guardrails(&providers, &ctx)
377 .await
378 .is_none()
379 );
380 }
381
382 #[test]
383 fn post_generation_text_includes_client_visible_citation_metadata() {
384 let annotations = vec![TextAnnotation {
385 start: 0,
386 end: 5,
387 origin: "citation_retrieval".to_string(),
388 source: AnnotationSource {
389 uri: "https://example.com/private".to_string(),
390 title: Some("Private roadmap".to_string()),
391 snippet: Some("password S3CR3T".to_string()),
392 location: Some(serde_json::json!({ "page": 4 })),
393 },
394 external_id: None,
395 verified: None,
396 }];
397
398 let output = post_generation_guardrail_text("An answer.", &annotations);
399
400 assert!(output.contains("An answer."));
401 assert!(output.contains("https://example.com/private"));
402 assert!(output.contains("Private roadmap"));
403 assert!(output.contains("password S3CR3T"));
404 assert!(output.contains(r#"{"page":4}"#));
405 }
406}