1use async_trait::async_trait;
9
10use agent_framework_core::error::Result;
11use agent_framework_core::middleware::{AgentContext, ChatContext, Middleware, Next};
12use agent_framework_core::types::{AgentResponse, ChatResponse, Message, Role};
13
14use crate::auth::TokenProvider;
15use crate::client::PurviewClient;
16use crate::processor::ContentProcessor;
17use crate::settings::PurviewSettings;
18
19struct PurviewPolicyCore {
25 processor: ContentProcessor,
26 settings: PurviewSettings,
27}
28
29impl PurviewPolicyCore {
30 fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
31 let client = PurviewClient::new(token_provider, &settings);
32 Self {
33 processor: ContentProcessor::new(client),
34 settings,
35 }
36 }
37
38 async fn check(
48 &self,
49 messages: &[Message],
50 provided_user_id: Option<&str>,
51 phase: &'static str,
52 ) -> Result<(bool, Option<String>)> {
53 match self
54 .processor
55 .evaluate(messages, &self.settings, provided_user_id)
56 .await
57 {
58 Ok(outcome) => Ok(outcome),
59 Err(e) => {
60 let suppress = if e.status() == Some(402) {
61 self.settings.ignore_payment_required
62 } else {
63 self.settings.ignore_exceptions
64 };
65 if suppress {
66 tracing::error!(error = %e, phase, "Purview policy check failed; continuing (ignored per settings)");
67 Ok((false, provided_user_id.map(str::to_string)))
68 } else {
69 Err(e)
70 }
71 }
72 }
73 }
74
75 fn blocked_prompt_message(&self) -> Message {
76 Message::new(Role::system(), self.settings.blocked_prompt_message.clone())
77 }
78
79 fn blocked_response_message(&self) -> Message {
80 Message::new(
81 Role::system(),
82 self.settings.blocked_response_message.clone(),
83 )
84 }
85}
86
87pub struct PurviewAgentMiddleware(PurviewPolicyCore);
126
127impl PurviewAgentMiddleware {
128 pub fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
129 Self(PurviewPolicyCore::new(token_provider, settings))
130 }
131}
132
133#[async_trait]
134impl Middleware<AgentContext> for PurviewAgentMiddleware {
135 async fn process(
136 &self,
137 mut ctx: AgentContext,
138 next: Next<AgentContext>,
139 ) -> Result<AgentContext> {
140 let (should_block, resolved_user_id) = self.0.check(&ctx.messages, None, "prompt").await?;
141 if should_block {
142 ctx.result = Some(AgentResponse {
143 messages: vec![self.0.blocked_prompt_message()],
144 ..Default::default()
145 });
146 ctx.terminate = true;
147 return Ok(ctx);
148 }
149
150 let mut ctx = next.run(ctx).await?;
151
152 if !ctx.is_streaming {
153 let post_messages = ctx.result.as_ref().map(|r| r.messages.clone());
154 if let Some(messages) = post_messages {
155 let (should_block, _) = self
156 .0
157 .check(&messages, resolved_user_id.as_deref(), "response")
158 .await?;
159 if should_block {
160 ctx.result = Some(AgentResponse {
161 messages: vec![self.0.blocked_response_message()],
162 ..Default::default()
163 });
164 }
165 }
166 }
167 Ok(ctx)
168 }
169}
170
171pub struct PurviewChatMiddleware(PurviewPolicyCore);
178
179impl PurviewChatMiddleware {
180 pub fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
181 Self(PurviewPolicyCore::new(token_provider, settings))
182 }
183}
184
185#[async_trait]
186impl Middleware<ChatContext> for PurviewChatMiddleware {
187 async fn process(&self, mut ctx: ChatContext, next: Next<ChatContext>) -> Result<ChatContext> {
188 let (should_block, resolved_user_id) = self.0.check(&ctx.messages, None, "prompt").await?;
189 if should_block {
190 ctx.result = Some(ChatResponse {
191 messages: vec![self.0.blocked_prompt_message()],
192 ..Default::default()
193 });
194 ctx.terminate = true;
195 return Ok(ctx);
196 }
197
198 let mut ctx = next.run(ctx).await?;
199
200 if !ctx.is_streaming {
201 let post_messages = ctx.result.as_ref().map(|r| r.messages.clone());
202 if let Some(messages) = post_messages {
203 let (should_block, _) = self
204 .0
205 .check(&messages, resolved_user_id.as_deref(), "response")
206 .await?;
207 if should_block {
208 ctx.result = Some(ChatResponse {
209 messages: vec![self.0.blocked_response_message()],
210 ..Default::default()
211 });
212 }
213 }
214 }
215 Ok(ctx)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::auth::StaticTokenProvider;
223 use crate::settings::{PurviewAppLocation, PurviewLocationType};
224 use agent_framework_core::middleware::{MiddlewarePipeline, Terminal};
225 use agent_framework_core::tools::BoxFuture;
226 use std::sync::atomic::{AtomicBool, Ordering};
227 use std::sync::Arc;
228
229 fn valid_settings() -> PurviewSettings {
230 PurviewSettings::new("Test App")
231 .with_tenant_id("12345678-1234-1234-1234-123456789012")
232 .with_purview_app_location(PurviewAppLocation::new(
233 PurviewLocationType::Application,
234 "app-1",
235 ))
236 }
237
238 fn agent_middleware(settings: PurviewSettings) -> PurviewAgentMiddleware {
251 PurviewAgentMiddleware::new(StaticTokenProvider::new("token"), settings)
252 }
253
254 fn chat_middleware(settings: PurviewSettings) -> PurviewChatMiddleware {
255 PurviewChatMiddleware::new(StaticTokenProvider::new("token"), settings)
256 }
257
258 fn agent_terminal(called: Arc<AtomicBool>, text: &'static str) -> Terminal<AgentContext> {
259 Box::new(move |mut ctx: AgentContext| {
260 called.store(true, Ordering::SeqCst);
261 Box::pin(async move {
262 ctx.result = Some(AgentResponse {
263 messages: vec![Message::assistant(text)],
264 ..Default::default()
265 });
266 Ok(ctx)
267 }) as BoxFuture<Result<AgentContext>>
268 })
269 }
270
271 fn chat_terminal(called: Arc<AtomicBool>, text: &'static str) -> Terminal<ChatContext> {
272 Box::new(move |mut ctx: ChatContext| {
273 called.store(true, Ordering::SeqCst);
274 Box::pin(async move {
275 ctx.result = Some(ChatResponse::from_text(text));
276 Ok(ctx)
277 }) as BoxFuture<Result<ChatContext>>
278 })
279 }
280
281 fn expect_err<T>(result: Result<T>) -> agent_framework_core::error::Error {
286 match result {
287 Ok(_) => panic!("expected Err, got Ok"),
288 Err(e) => e,
289 }
290 }
291
292 #[tokio::test]
295 async fn agent_middleware_propagates_config_error_without_ignore_exceptions() {
296 let settings = PurviewSettings::new("Test App"); let middleware = agent_middleware(settings);
298 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
299 let called = Arc::new(AtomicBool::new(false));
300 let ctx = AgentContext::new(vec![Message::user("hi")], false);
301
302 let err = expect_err(
303 pipeline
304 .execute(ctx, agent_terminal(called.clone(), "should not be reached"))
305 .await,
306 );
307 assert!(err.to_string().contains("tenant_id"));
308 assert!(
309 !called.load(Ordering::SeqCst),
310 "next must not run on a pre-check error"
311 );
312 }
313
314 #[tokio::test]
315 async fn agent_middleware_ignore_exceptions_suppresses_config_error_and_allows() {
316 let settings = PurviewSettings::new("Test App").with_ignore_exceptions(true);
317 let middleware = agent_middleware(settings);
318 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
319 let called = Arc::new(AtomicBool::new(false));
320 let ctx = AgentContext::new(vec![Message::user("hi")], false);
321
322 let result_ctx = pipeline
323 .execute(ctx, agent_terminal(called.clone(), "real response"))
324 .await
325 .unwrap();
326 assert!(
327 called.load(Ordering::SeqCst),
328 "next must run once the error is suppressed"
329 );
330 assert!(!result_ctx.terminate);
331 assert_eq!(result_ctx.result.unwrap().text(), "real response");
332 }
333
334 #[tokio::test]
337 async fn agent_middleware_allows_when_no_resolvable_user_id() {
338 let middleware = agent_middleware(valid_settings());
344 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
345 let called = Arc::new(AtomicBool::new(false));
346 let ctx = AgentContext::new(
347 vec![Message::user("hello, nothing identifying here")],
348 false,
349 );
350
351 let result_ctx = pipeline
352 .execute(ctx, agent_terminal(called.clone(), "real response"))
353 .await
354 .unwrap();
355 assert!(called.load(Ordering::SeqCst));
356 assert!(!result_ctx.terminate);
357 assert_eq!(result_ctx.result.unwrap().text(), "real response");
358 }
359
360 #[tokio::test]
361 async fn agent_middleware_skips_post_check_when_streaming() {
362 let middleware = agent_middleware(valid_settings());
365 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
366 let called = Arc::new(AtomicBool::new(false));
367 let ctx = AgentContext::new(vec![Message::user("hello, nothing identifying here")], true);
368
369 let result_ctx = pipeline
370 .execute(ctx, agent_terminal(called.clone(), "streamed response"))
371 .await
372 .unwrap();
373 assert!(called.load(Ordering::SeqCst));
374 assert_eq!(result_ctx.result.unwrap().text(), "streamed response");
375 }
376
377 #[tokio::test]
378 async fn chat_middleware_allows_when_no_resolvable_user_id() {
379 let middleware = chat_middleware(valid_settings());
380 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
381 let called = Arc::new(AtomicBool::new(false));
382 let ctx = ChatContext::new(
383 vec![Message::user("hello, nothing identifying here")],
384 agent_framework_core::types::ChatOptions::new(),
385 false,
386 );
387
388 let result_ctx = pipeline
389 .execute(ctx, chat_terminal(called.clone(), "real response"))
390 .await
391 .unwrap();
392 assert!(called.load(Ordering::SeqCst));
393 assert!(!result_ctx.terminate);
394 assert_eq!(result_ctx.result.unwrap().text(), "real response");
395 }
396
397 #[tokio::test]
398 async fn chat_middleware_propagates_config_error_without_ignore_exceptions() {
399 let settings = PurviewSettings::new("Test App");
400 let middleware = chat_middleware(settings);
401 let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
402 let called = Arc::new(AtomicBool::new(false));
403 let ctx = ChatContext::new(
404 vec![Message::user("hi")],
405 agent_framework_core::types::ChatOptions::new(),
406 false,
407 );
408
409 let err = expect_err(
410 pipeline
411 .execute(ctx, chat_terminal(called.clone(), "should not be reached"))
412 .await,
413 );
414 assert!(err.to_string().contains("tenant_id"));
415 assert!(!called.load(Ordering::SeqCst));
416 }
417
418 #[test]
421 fn blocked_messages_use_system_role_and_configured_text() {
422 let core = PurviewPolicyCore::new(StaticTokenProvider::new("t"), valid_settings());
423 let prompt_msg = core.blocked_prompt_message();
424 let response_msg = core.blocked_response_message();
425 assert_eq!(prompt_msg.role, Role::system());
426 assert_eq!(prompt_msg.text(), "Prompt blocked by policy");
427 assert_eq!(response_msg.role, Role::system());
428 assert_eq!(response_msg.text(), "Response blocked by policy");
429 }
430
431 #[test]
432 fn blocked_messages_honor_custom_text() {
433 let settings = valid_settings()
434 .with_blocked_prompt_message("custom prompt block")
435 .with_blocked_response_message("custom response block");
436 let core = PurviewPolicyCore::new(StaticTokenProvider::new("t"), settings);
437 assert_eq!(core.blocked_prompt_message().text(), "custom prompt block");
438 assert_eq!(
439 core.blocked_response_message().text(),
440 "custom response block"
441 );
442 }
443}