use async_trait::async_trait;
use crate::source::{Contribution, ContributionContent, Source, SourceError};
use crate::types::{BriefContext, Priority, Role, SourceId};
#[async_trait]
pub trait PerceptionSnapshot: Send + Sync {
async fn as_ax_tree(&self) -> Result<String, PerceptionError>;
async fn as_focus_only(&self) -> Result<String, PerceptionError>;
async fn as_screen_summary(&self) -> Result<String, PerceptionError>;
}
#[derive(Debug, thiserror::Error)]
pub enum PerceptionError {
#[error("perception backend error: {0}")]
Backend(String),
#[error("perception unavailable: {0}")]
Unavailable(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PerceptionMode {
AxTree,
#[default]
FocusOnly,
ScreenSummary,
}
pub struct PerceptionSource<P: PerceptionSnapshot> {
id: SourceId,
backend: std::sync::Arc<P>,
mode: PerceptionMode,
}
impl<P: PerceptionSnapshot> std::fmt::Debug for PerceptionSource<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PerceptionSource")
.field("id", &self.id)
.field("mode", &self.mode)
.finish()
}
}
impl<P: PerceptionSnapshot> PerceptionSource<P> {
pub fn new(backend: std::sync::Arc<P>) -> Self {
PerceptionSource {
id: SourceId::new("perception"),
backend,
mode: PerceptionMode::default(),
}
}
pub fn with_id(mut self, id: impl Into<SourceId>) -> Self {
self.id = id.into();
self
}
pub fn with_mode(mut self, mode: PerceptionMode) -> Self {
self.mode = mode;
self
}
}
#[async_trait]
impl<P: PerceptionSnapshot + 'static> Source for PerceptionSource<P> {
fn id(&self) -> SourceId {
self.id.clone()
}
fn priority(&self) -> Priority {
Priority::High
}
async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
let raw = match self.mode {
PerceptionMode::AxTree => self.backend.as_ax_tree().await,
PerceptionMode::FocusOnly => self.backend.as_focus_only().await,
PerceptionMode::ScreenSummary => self.backend.as_screen_summary().await,
};
let body = match raw {
Ok(s) => s,
Err(PerceptionError::Unavailable(reason)) => {
return Err(SourceError::Skipped(reason));
}
Err(PerceptionError::Backend(msg)) => {
return Err(SourceError::Backend(msg));
}
};
if body.trim().is_empty() {
return Err(SourceError::Skipped("empty perception snapshot".into()));
}
let label = match self.mode {
PerceptionMode::AxTree => "[perception:ax_tree]",
PerceptionMode::FocusOnly => "[perception:focus_only]",
PerceptionMode::ScreenSummary => "[perception:screen_summary]",
};
let content = format!("{label}\n{body}");
let est = content.len().div_ceil(4);
Ok(vec![Contribution {
content: ContributionContent::Text {
role: Role::System,
content,
},
estimated_tokens: est,
importance: 0.8,
redactable: true,
tags: vec!["perception".into()],
}])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TokenBudget;
use std::sync::Arc;
struct FixedSnapshot {
ax_tree: String,
focus_only: String,
summary: String,
}
#[async_trait]
impl PerceptionSnapshot for FixedSnapshot {
async fn as_ax_tree(&self) -> Result<String, PerceptionError> {
Ok(self.ax_tree.clone())
}
async fn as_focus_only(&self) -> Result<String, PerceptionError> {
Ok(self.focus_only.clone())
}
async fn as_screen_summary(&self) -> Result<String, PerceptionError> {
Ok(self.summary.clone())
}
}
fn fixture() -> Arc<FixedSnapshot> {
Arc::new(FixedSnapshot {
ax_tree: "<window>...</window>".into(),
focus_only: "<button id=submit>".into(),
summary: "Safari: example.com — search field focused".into(),
})
}
#[tokio::test]
async fn default_mode_is_focus_only() {
let src = PerceptionSource::new(fixture());
assert_eq!(src.priority(), Priority::High);
let ctx = BriefContext::new(TokenBudget::default());
let cs = src.contribute(&ctx).await.expect("ok");
assert_eq!(cs.len(), 1);
match &cs[0].content {
ContributionContent::Text { role, content } => {
assert_eq!(*role, Role::System);
assert!(content.contains("[perception:focus_only]"));
assert!(content.contains("submit"));
}
other => panic!("expected Text, got {other:?}"),
}
assert!(cs[0].redactable);
assert_eq!(cs[0].tags, vec!["perception".to_owned()]);
}
#[tokio::test]
async fn ax_tree_mode_calls_ax_tree() {
let src = PerceptionSource::new(fixture()).with_mode(PerceptionMode::AxTree);
let ctx = BriefContext::new(TokenBudget::default());
let cs = src.contribute(&ctx).await.expect("ok");
match &cs[0].content {
ContributionContent::Text { content, .. } => {
assert!(content.contains("[perception:ax_tree]"));
assert!(content.contains("<window>"));
}
other => panic!("expected Text, got {other:?}"),
}
}
#[tokio::test]
async fn screen_summary_mode_calls_summary() {
let src = PerceptionSource::new(fixture()).with_mode(PerceptionMode::ScreenSummary);
let ctx = BriefContext::new(TokenBudget::default());
let cs = src.contribute(&ctx).await.expect("ok");
match &cs[0].content {
ContributionContent::Text { content, .. } => {
assert!(content.contains("[perception:screen_summary]"));
assert!(content.contains("Safari"));
}
other => panic!("expected Text, got {other:?}"),
}
}
#[tokio::test]
async fn empty_snapshot_is_skipped() {
struct Empty;
#[async_trait]
impl PerceptionSnapshot for Empty {
async fn as_ax_tree(&self) -> Result<String, PerceptionError> {
Ok(String::new())
}
async fn as_focus_only(&self) -> Result<String, PerceptionError> {
Ok(" ".into())
}
async fn as_screen_summary(&self) -> Result<String, PerceptionError> {
Ok(String::new())
}
}
let src = PerceptionSource::new(Arc::new(Empty));
let ctx = BriefContext::new(TokenBudget::default());
match src.contribute(&ctx).await {
Err(SourceError::Skipped(_)) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn unavailable_maps_to_skipped() {
struct Unavailable;
#[async_trait]
impl PerceptionSnapshot for Unavailable {
async fn as_ax_tree(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Unavailable("locked".into()))
}
async fn as_focus_only(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Unavailable("locked".into()))
}
async fn as_screen_summary(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Unavailable("locked".into()))
}
}
let src = PerceptionSource::new(Arc::new(Unavailable));
let ctx = BriefContext::new(TokenBudget::default());
match src.contribute(&ctx).await {
Err(SourceError::Skipped(_)) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn backend_error_propagates() {
struct Broken;
#[async_trait]
impl PerceptionSnapshot for Broken {
async fn as_ax_tree(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Backend("io".into()))
}
async fn as_focus_only(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Backend("io".into()))
}
async fn as_screen_summary(&self) -> Result<String, PerceptionError> {
Err(PerceptionError::Backend("io".into()))
}
}
let src = PerceptionSource::new(Arc::new(Broken));
let ctx = BriefContext::new(TokenBudget::default());
match src.contribute(&ctx).await {
Err(SourceError::Backend(_)) => {}
other => panic!("expected Backend, got {other:?}"),
}
}
#[tokio::test]
async fn with_id_overrides_default() {
let src = PerceptionSource::new(fixture()).with_id("perception");
assert_eq!(src.id(), SourceId::new("perception"));
}
}