use async_trait::async_trait;
use agent_framework_core::error::Result;
use agent_framework_core::middleware::{AgentContext, ChatContext, Middleware, Next};
use agent_framework_core::types::{AgentResponse, ChatResponse, Message, Role};
use crate::auth::TokenProvider;
use crate::client::PurviewClient;
use crate::processor::ContentProcessor;
use crate::settings::PurviewSettings;
struct PurviewPolicyCore {
processor: ContentProcessor,
settings: PurviewSettings,
}
impl PurviewPolicyCore {
fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
let client = PurviewClient::new(token_provider, &settings);
Self {
processor: ContentProcessor::new(client),
settings,
}
}
async fn check(
&self,
messages: &[Message],
provided_user_id: Option<&str>,
phase: &'static str,
) -> Result<(bool, Option<String>)> {
match self
.processor
.evaluate(messages, &self.settings, provided_user_id)
.await
{
Ok(outcome) => Ok(outcome),
Err(e) => {
let suppress = if e.status() == Some(402) {
self.settings.ignore_payment_required
} else {
self.settings.ignore_exceptions
};
if suppress {
tracing::error!(error = %e, phase, "Purview policy check failed; continuing (ignored per settings)");
Ok((false, provided_user_id.map(str::to_string)))
} else {
Err(e)
}
}
}
}
fn blocked_prompt_message(&self) -> Message {
Message::new(Role::system(), self.settings.blocked_prompt_message.clone())
}
fn blocked_response_message(&self) -> Message {
Message::new(
Role::system(),
self.settings.blocked_response_message.clone(),
)
}
}
pub struct PurviewAgentMiddleware(PurviewPolicyCore);
impl PurviewAgentMiddleware {
pub fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
Self(PurviewPolicyCore::new(token_provider, settings))
}
}
#[async_trait]
impl Middleware<AgentContext> for PurviewAgentMiddleware {
async fn process(
&self,
mut ctx: AgentContext,
next: Next<AgentContext>,
) -> Result<AgentContext> {
let (should_block, resolved_user_id) = self.0.check(&ctx.messages, None, "prompt").await?;
if should_block {
ctx.result = Some(AgentResponse {
messages: vec![self.0.blocked_prompt_message()],
..Default::default()
});
ctx.terminate = true;
return Ok(ctx);
}
let mut ctx = next.run(ctx).await?;
if !ctx.is_streaming {
let post_messages = ctx.result.as_ref().map(|r| r.messages.clone());
if let Some(messages) = post_messages {
let (should_block, _) = self
.0
.check(&messages, resolved_user_id.as_deref(), "response")
.await?;
if should_block {
ctx.result = Some(AgentResponse {
messages: vec![self.0.blocked_response_message()],
..Default::default()
});
}
}
}
Ok(ctx)
}
}
pub struct PurviewChatMiddleware(PurviewPolicyCore);
impl PurviewChatMiddleware {
pub fn new(token_provider: impl TokenProvider + 'static, settings: PurviewSettings) -> Self {
Self(PurviewPolicyCore::new(token_provider, settings))
}
}
#[async_trait]
impl Middleware<ChatContext> for PurviewChatMiddleware {
async fn process(&self, mut ctx: ChatContext, next: Next<ChatContext>) -> Result<ChatContext> {
let (should_block, resolved_user_id) = self.0.check(&ctx.messages, None, "prompt").await?;
if should_block {
ctx.result = Some(ChatResponse {
messages: vec![self.0.blocked_prompt_message()],
..Default::default()
});
ctx.terminate = true;
return Ok(ctx);
}
let mut ctx = next.run(ctx).await?;
if !ctx.is_streaming {
let post_messages = ctx.result.as_ref().map(|r| r.messages.clone());
if let Some(messages) = post_messages {
let (should_block, _) = self
.0
.check(&messages, resolved_user_id.as_deref(), "response")
.await?;
if should_block {
ctx.result = Some(ChatResponse {
messages: vec![self.0.blocked_response_message()],
..Default::default()
});
}
}
}
Ok(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::StaticTokenProvider;
use crate::settings::{PurviewAppLocation, PurviewLocationType};
use agent_framework_core::middleware::{MiddlewarePipeline, Terminal};
use agent_framework_core::tools::BoxFuture;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
fn valid_settings() -> PurviewSettings {
PurviewSettings::new("Test App")
.with_tenant_id("12345678-1234-1234-1234-123456789012")
.with_purview_app_location(PurviewAppLocation::new(
PurviewLocationType::Application,
"app-1",
))
}
fn agent_middleware(settings: PurviewSettings) -> PurviewAgentMiddleware {
PurviewAgentMiddleware::new(StaticTokenProvider::new("token"), settings)
}
fn chat_middleware(settings: PurviewSettings) -> PurviewChatMiddleware {
PurviewChatMiddleware::new(StaticTokenProvider::new("token"), settings)
}
fn agent_terminal(called: Arc<AtomicBool>, text: &'static str) -> Terminal<AgentContext> {
Box::new(move |mut ctx: AgentContext| {
called.store(true, Ordering::SeqCst);
Box::pin(async move {
ctx.result = Some(AgentResponse {
messages: vec![Message::assistant(text)],
..Default::default()
});
Ok(ctx)
}) as BoxFuture<Result<AgentContext>>
})
}
fn chat_terminal(called: Arc<AtomicBool>, text: &'static str) -> Terminal<ChatContext> {
Box::new(move |mut ctx: ChatContext| {
called.store(true, Ordering::SeqCst);
Box::pin(async move {
ctx.result = Some(ChatResponse::from_text(text));
Ok(ctx)
}) as BoxFuture<Result<ChatContext>>
})
}
fn expect_err<T>(result: Result<T>) -> agent_framework_core::error::Error {
match result {
Ok(_) => panic!("expected Err, got Ok"),
Err(e) => e,
}
}
#[tokio::test]
async fn agent_middleware_propagates_config_error_without_ignore_exceptions() {
let settings = PurviewSettings::new("Test App"); let middleware = agent_middleware(settings);
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = AgentContext::new(vec![Message::user("hi")], false);
let err = expect_err(
pipeline
.execute(ctx, agent_terminal(called.clone(), "should not be reached"))
.await,
);
assert!(err.to_string().contains("tenant_id"));
assert!(
!called.load(Ordering::SeqCst),
"next must not run on a pre-check error"
);
}
#[tokio::test]
async fn agent_middleware_ignore_exceptions_suppresses_config_error_and_allows() {
let settings = PurviewSettings::new("Test App").with_ignore_exceptions(true);
let middleware = agent_middleware(settings);
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = AgentContext::new(vec![Message::user("hi")], false);
let result_ctx = pipeline
.execute(ctx, agent_terminal(called.clone(), "real response"))
.await
.unwrap();
assert!(
called.load(Ordering::SeqCst),
"next must run once the error is suppressed"
);
assert!(!result_ctx.terminate);
assert_eq!(result_ctx.result.unwrap().text(), "real response");
}
#[tokio::test]
async fn agent_middleware_allows_when_no_resolvable_user_id() {
let middleware = agent_middleware(valid_settings());
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = AgentContext::new(
vec![Message::user("hello, nothing identifying here")],
false,
);
let result_ctx = pipeline
.execute(ctx, agent_terminal(called.clone(), "real response"))
.await
.unwrap();
assert!(called.load(Ordering::SeqCst));
assert!(!result_ctx.terminate);
assert_eq!(result_ctx.result.unwrap().text(), "real response");
}
#[tokio::test]
async fn agent_middleware_skips_post_check_when_streaming() {
let middleware = agent_middleware(valid_settings());
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = AgentContext::new(vec![Message::user("hello, nothing identifying here")], true);
let result_ctx = pipeline
.execute(ctx, agent_terminal(called.clone(), "streamed response"))
.await
.unwrap();
assert!(called.load(Ordering::SeqCst));
assert_eq!(result_ctx.result.unwrap().text(), "streamed response");
}
#[tokio::test]
async fn chat_middleware_allows_when_no_resolvable_user_id() {
let middleware = chat_middleware(valid_settings());
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = ChatContext::new(
vec![Message::user("hello, nothing identifying here")],
agent_framework_core::types::ChatOptions::new(),
false,
);
let result_ctx = pipeline
.execute(ctx, chat_terminal(called.clone(), "real response"))
.await
.unwrap();
assert!(called.load(Ordering::SeqCst));
assert!(!result_ctx.terminate);
assert_eq!(result_ctx.result.unwrap().text(), "real response");
}
#[tokio::test]
async fn chat_middleware_propagates_config_error_without_ignore_exceptions() {
let settings = PurviewSettings::new("Test App");
let middleware = chat_middleware(settings);
let pipeline = MiddlewarePipeline::new(vec![Arc::new(middleware)]);
let called = Arc::new(AtomicBool::new(false));
let ctx = ChatContext::new(
vec![Message::user("hi")],
agent_framework_core::types::ChatOptions::new(),
false,
);
let err = expect_err(
pipeline
.execute(ctx, chat_terminal(called.clone(), "should not be reached"))
.await,
);
assert!(err.to_string().contains("tenant_id"));
assert!(!called.load(Ordering::SeqCst));
}
#[test]
fn blocked_messages_use_system_role_and_configured_text() {
let core = PurviewPolicyCore::new(StaticTokenProvider::new("t"), valid_settings());
let prompt_msg = core.blocked_prompt_message();
let response_msg = core.blocked_response_message();
assert_eq!(prompt_msg.role, Role::system());
assert_eq!(prompt_msg.text(), "Prompt blocked by policy");
assert_eq!(response_msg.role, Role::system());
assert_eq!(response_msg.text(), "Response blocked by policy");
}
#[test]
fn blocked_messages_honor_custom_text() {
let settings = valid_settings()
.with_blocked_prompt_message("custom prompt block")
.with_blocked_response_message("custom response block");
let core = PurviewPolicyCore::new(StaticTokenProvider::new("t"), settings);
assert_eq!(core.blocked_prompt_message().text(), "custom prompt block");
assert_eq!(
core.blocked_response_message().text(),
"custom response block"
);
}
}