Skip to main content

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}