PicoBot/src/delivery/policy.rs

139 lines
4.1 KiB
Rust

use crate::session::{TurnBlock, TurnSnapshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningVisibility {
Hidden,
Collapsed,
Expanded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolVisibility {
Hidden,
Compact,
Detailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PresentationPolicy {
pub live: bool,
pub reasoning: ReasoningVisibility,
pub tools: ToolVisibility,
}
impl PresentationPolicy {
pub const fn interactive() -> Self {
Self {
live: true,
reasoning: ReasoningVisibility::Collapsed,
tools: ToolVisibility::Detailed,
}
}
pub const fn external(live: bool) -> Self {
Self {
live,
reasoning: ReasoningVisibility::Hidden,
tools: ToolVisibility::Compact,
}
}
pub const fn unattended() -> Self {
Self {
live: false,
reasoning: ReasoningVisibility::Hidden,
tools: ToolVisibility::Hidden,
}
}
}
/// Produce the immutable view that is allowed to leave the Gateway core.
///
/// Presentation filtering deliberately clones the snapshot. Conversation
/// history and the authoritative TurnController state remain untouched.
pub fn project_snapshot(snapshot: &TurnSnapshot, policy: PresentationPolicy) -> TurnSnapshot {
let mut projected = snapshot.clone();
projected.blocks.retain_mut(|block| match block {
TurnBlock::Reasoning { .. } => policy.reasoning != ReasoningVisibility::Hidden,
TurnBlock::Assistant { .. } => true,
TurnBlock::Tool {
arguments, preview, ..
} => match policy.tools {
ToolVisibility::Hidden => false,
ToolVisibility::Compact => {
*arguments = serde_json::Value::Null;
*preview = None;
true
}
ToolVisibility::Detailed => true,
},
});
projected
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{BlockId, ToolStatus, TurnId, TurnPhase, TurnState, TurnStatus};
fn snapshot() -> TurnSnapshot {
TurnState {
id: TurnId("turn".into()),
session_id: "session".into(),
message_id: "message".into(),
revision: 3,
status: TurnStatus::Running,
phase: TurnPhase::Acting,
blocks: vec![
TurnBlock::Reasoning {
id: BlockId("reasoning".into()),
iteration: 0,
text: "private chain".into(),
},
TurnBlock::Assistant {
id: BlockId("text".into()),
iteration: 0,
text: "visible".into(),
},
TurnBlock::Tool {
id: "tool".into(),
iteration: 0,
name: "bash".into(),
arguments: serde_json::json!({"token": "secret"}),
status: ToolStatus::Completed,
preview: Some("sensitive output".into()),
},
],
usage: None,
error: None,
}
}
#[test]
fn external_projection_removes_reasoning_and_tool_details_without_mutating_source() {
let source = snapshot();
let projected = project_snapshot(&source, PresentationPolicy::external(true));
assert_eq!(projected.blocks.len(), 2);
assert!(matches!(projected.blocks[0], TurnBlock::Assistant { .. }));
assert!(matches!(
&projected.blocks[1],
TurnBlock::Tool {
arguments: serde_json::Value::Null,
preview: None,
..
}
));
assert_eq!(source.blocks.len(), 3);
assert!(matches!(source.blocks[0], TurnBlock::Reasoning { .. }));
}
#[test]
fn unattended_projection_keeps_only_assistant_blocks() {
let projected = project_snapshot(&snapshot(), PresentationPolicy::unattended());
assert_eq!(projected.blocks.len(), 1);
assert!(matches!(projected.blocks[0], TurnBlock::Assistant { .. }));
}
}