feat(session): share immutable captures and bounded body transport

This commit is contained in:
2026-10-22 22:25:47 +09:00
parent 8b6e3070dd
commit 994e75233b
13 changed files with 2923 additions and 955 deletions
+2
View File
@@ -11,5 +11,7 @@ checks or Runtime catalog lifecycle management.
- [`worker_session_store`]: storage boundary for a Session bound to a Worker
- [`worker_metadata`]: current Worker state and active Segment switching
- [`system_item`]: records of system input presented to the model
- [`capture`]: Worker-independent immutable public values and bounded body transport;
authorization, source I/O, and delivery lifecycle stay with the Host
- [`public_snapshot`] / [`public_index`]: public projections of stored history
- [`event_trace`]: diagnostic records independent of history replay
+948
View File
@@ -0,0 +1,948 @@
//! Exploration domain for immutable Session captures, with no Workspace/Memory mutation capability.
//!
//! A capture is an ordered projection of committed entries; exploration excludes reasoning and
//! system prompts, including Host compaction summaries and other derived context. Full inherited
//! summary input belongs to compaction, not public observation or Memory evidence. Overview,
//! search, bounded reads, and evidence conversion share `SessionEntryRef`.
//! `SessionEntryRef` is a contextual UI reference. `ScopedSessionEntryRef` additionally binds
//! the Session and exact immutable capture, and is required for portable checked operations.
//! Appends preserve existing entry identities. Sparse real user/assistant anchors and intervening
//! entry counts index long histories. Search with an empty filter also provides bounded range lists.
//!
//! Host-fixed snapshot exploration, Memory candidate evidence validation, and current Worker
//! observation share only this domain; they do not enable or modify each other's Features.
//! The provider owns authorization to recapture a current Worker. A capture itself proves neither
//! completion nor approval and grants no business-mutation authority.
use std::sync::Arc;
use crate::{
LoggedSessionHistoryMetadata as SessionHistoryMetadata,
LoggedSessionHistoryOrigin as HistoryOrigin,
};
mod body;
use agen::{HistoryEntry, Item, Role};
pub use body::*;
use protocol::{
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntryData,
};
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
const MAX_SEARCH_LIMIT: usize = 50;
const DEFAULT_READ_MAX_ITEMS: usize = 40;
const MAX_READ_MAX_ITEMS: usize = 80;
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
const OVERVIEW_ANCHOR_STRIDE: usize = 8;
/// Append-stable contextual UI reference derived from a logical entry identity.
/// Portable consumers must bind it with `SessionCapture::scoped_ref` before use.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionEntryRef(String);
impl SessionEntryRef {
pub fn from_history_entry_id(entry_id: &crate::LoggedSessionHistoryEntryId) -> Self {
Self(format!("E{}", entry_id.0))
}
pub fn parse(value: &str) -> Option<Self> {
let suffix = value.strip_prefix('E')?;
if suffix.is_empty()
|| suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SessionEntryRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceKind {
User,
Assistant,
System,
Tool,
}
impl ReferenceKind {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
Self::Tool => "tool",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"assistant" | "agent" => Some(Self::Assistant),
"system" => Some(Self::System),
"tool" => Some(Self::Tool),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolPart {
Input,
Output,
Both,
}
impl ToolPart {
pub fn parse(value: &str) -> Option<Self> {
match value {
"input" => Some(Self::Input),
"output" => Some(Self::Output),
"both" => Some(Self::Both),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
Self::Both => "both",
}
}
pub fn matches(self, actual: ToolPart) -> bool {
matches!(self, ToolPart::Both) || self == actual
}
}
#[derive(Debug, Clone)]
pub struct OverviewItem {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
pub text: String,
pub intervening_entries: usize,
}
#[derive(Debug, Clone)]
pub struct ReferenceEntry {
pub attachment_count: usize,
use_policy: Option<protocol::SessionEntryUsePolicy>,
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub label: String,
pub summary: String,
search_text: String,
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub query: String,
pub kind: Option<ReferenceKind>,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub limit: Option<usize>,
pub min_entry_index: Option<u64>,
pub from: Option<SessionEntryRef>,
pub through: Option<SessionEntryRef>,
pub offset: usize,
}
#[derive(Debug, Clone)]
pub struct SearchHit {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub entry_range: [u64; 2],
pub label: String,
pub summary: String,
}
#[derive(Debug, Clone, Copy)]
pub enum ReadSelector<'a> {
Id(&'a str),
EntryRange([u64; 2]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadDetail {
Compact,
Full,
}
#[derive(Debug, Clone)]
pub struct ReadOptions {
pub include_tools: bool,
pub tool_part: ToolPart,
pub detail: ReadDetail,
pub max_items: usize,
pub max_bytes: usize,
}
impl Default for ReadOptions {
fn default() -> Self {
Self {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: DEFAULT_READ_MAX_ITEMS,
max_bytes: DEFAULT_READ_MAX_BYTES,
}
}
}
#[derive(Debug, Clone)]
pub struct ReadEntry {
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
pub entry_range: [u64; 2],
pub label: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct ReadResult {
pub entries: Vec<ReadEntry>,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub label: String,
pub summary: String,
pub excerpt: String,
}
#[derive(Debug, Clone)]
struct CapturedHistoryEntry {
use_policy: Option<protocol::SessionEntryUsePolicy>,
item: Item,
entry_id: crate::LoggedSessionHistoryEntryId,
origin: SessionEntryProvenance,
dto: CaptureEntryDto,
}
#[derive(Debug, Clone)]
pub struct SessionCapture {
session_id: String,
segment_id: String,
capture_id: String,
entries: Arc<Vec<CapturedHistoryEntry>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionCapture {
pub fn from_session_snapshot(
session_id: impl Into<String>,
segment_id: impl Into<String>,
snapshot: SessionSnapshot,
) -> Self {
let segment_id = segment_id.into();
let entries = snapshot
.entries
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
let policy = CaptureUsePolicy {
recording_material: false,
memory_evidence: entry.use_policy.is_none_or(|p| p.memory_evidence),
observation_readable: entry.use_policy.is_none_or(|p| p.observation_readable),
};
let explicit = entry.use_policy.is_some();
let item = match entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
Item::user_message(protocol::Segment::flatten_to_text(&segments))
}
SessionSnapshotEntryData::Message { role, content } => Item::Message {
id: None,
status: None,
role: match role {
SessionMessageRole::User => Role::User,
SessionMessageRole::Assistant => Role::Assistant,
},
content: content
.into_iter()
.map(|part| match part {
SessionContentPart::Text { text } => {
agen::ContentPart::Text { text }
}
SessionContentPart::Refusal { refusal } => {
agen::ContentPart::Refusal { refusal }
}
})
.collect(),
},
SessionSnapshotEntryData::ToolCall {
call_id,
name,
arguments,
} => Item::tool_call(call_id, name, arguments),
SessionSnapshotEntryData::ToolResult {
call_id,
summary,
content,
is_error,
..
} => Item::tool_result_item(call_id, summary, content, is_error),
SessionSnapshotEntryData::SystemItem { content, .. } if explicit => {
Item::system_message(content)
}
_ => return None,
};
let body = CaptureBody::project(&item, &entry.provenance, explicit)?;
if !policy.any() {
return None;
}
Some(CaptureEntryDto {
entry_id: entry.entry_id,
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: entry.provenance,
history_origin: None,
derived_from: entry.derived_from,
},
policy,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, entries)
}
pub fn new(
session_id: impl Into<String>,
segment_id: impl Into<String>,
items: Vec<Item>,
) -> Self {
let segment_id = segment_id.into();
let entries = items
.into_iter()
.enumerate()
.filter_map(|(index, item)| {
let origin = SessionEntryProvenance::LegacyUnknown;
let body = CaptureBody::project(&item, &origin, false)?;
Some(CaptureEntryDto {
entry_id: format!("{index:08}"),
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: origin,
history_origin: None,
derived_from: Vec::new(),
},
policy: CaptureUsePolicy::PUBLIC,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, entries)
}
pub fn from_history_entries(
session_id: impl Into<String>,
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into();
let entries = entries
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
let origin = public_provenance(&entry.annotation.origin);
let mut item = entry.item;
let mut explicit = false;
let policy = if let Some(input) = entry.annotation.input_metadata {
explicit = true;
if matches!(
item,
Item::Message {
role: Role::System,
..
}
) {
// Only the explicit public projection crosses this boundary.
let public = input.public_item()?;
item = Item::system_message(public.body.clone());
} else if input.feature_payload().is_some() || input.public_item().is_some() {
return None;
}
CaptureUsePolicy::from(input.usage())
} else {
if matches!(entry.annotation.origin, HistoryOrigin::Provided { .. }) {
return None;
}
CaptureUsePolicy::PUBLIC
};
if !policy.any() {
return None;
}
let body = CaptureBody::project(&item, &origin, explicit)?;
Some(CaptureEntryDto {
entry_id: entry.annotation.entry_id.0,
order: index as u64,
source: CaptureSource {
segment_id: segment_id.clone(),
entry_index: index as u64,
provenance: origin,
history_origin: Some(entry.annotation.origin),
derived_from: entry
.annotation
.derivation
.map(|d| d.sources.into_iter().map(|id| id.0).collect())
.unwrap_or_default(),
},
policy,
body,
})
})
.collect();
Self::from_projected(session_id.into(), segment_id, entries)
}
fn from_projected(session_id: String, segment_id: String, dtos: Vec<CaptureEntryDto>) -> Self {
let dto = CaptureDto {
version: CAPTURE_VERSION,
session_id: session_id.clone(),
segment_id: segment_id.clone(),
entries: dtos,
};
let capture_id = dto.fingerprint();
let entries: Vec<_> = dto
.entries
.into_iter()
.map(|dto| CapturedHistoryEntry {
use_policy: Some(protocol::SessionEntryUsePolicy {
observation_readable: dto.policy.observation_readable,
memory_evidence: dto.policy.memory_evidence,
}),
item: dto.body.to_item(),
entry_id: crate::LoggedSessionHistoryEntryId(dto.entry_id.clone()),
origin: dto.source.provenance.clone(),
dto,
})
.collect();
let mut overview = Vec::new();
let mut index = Vec::new();
for entry in &entries {
let item = &entry.item;
let entry_range = [entry.dto.order; 2];
let attachment_count = match entry.dto.body {
CaptureBody::ToolResult {
attachment_count, ..
} => attachment_count,
_ => 0,
};
match item {
Item::Message { role, content, .. } => {
let Some(kind) = message_reference_kind(&entry.origin, role, entry.use_policy)
else {
continue;
};
let text = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::from_history_entry_id(&entry.entry_id);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: id.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
tool_part: None,
tool_name: None,
label: label.clone(),
summary: summary.clone(),
search_text: text.clone(),
});
if entry.use_policy.is_none_or(|p| p.observation_readable)
&& matches!(kind, ReferenceKind::User | ReferenceKind::Assistant)
{
overview.push(OverviewItem {
id: id.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
label,
text,
intervening_entries: 0,
});
}
}
Item::ToolCall {
name, arguments, ..
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
tool_name: Some(name.clone()),
label: format!("tool input: {name}"),
summary: format!("Tool call {name}"),
search_text: text,
});
}
Item::ToolResult {
summary,
content,
attachments: _,
..
} => {
let attachment_marker = if attachment_count == 0 {
String::new()
} else {
format!("\n[{} image attachment(s)]", attachment_count)
};
let text = format!(
"{summary}\n{}{attachment_marker}",
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
attachment_count,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
tool_name: None,
label: "tool output".to_string(),
summary: truncate_chars(summary, 240),
search_text: text,
});
}
Item::AssistantResponseBoundary { .. } | Item::Reasoning { .. } => {}
}
}
if overview.len() > 2 {
let last = overview.len() - 1;
overview = overview
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
(index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0)
.then_some(entry)
})
.collect();
}
for overview_index in 0..overview.len().saturating_sub(1) {
let current_entry = overview[overview_index].entry_range[0];
let next_entry = overview[overview_index + 1].entry_range[0];
overview[overview_index].intervening_entries = index
.iter()
.filter(|entry| {
let entry_index = entry.entry_range[0];
entry_index > current_entry && entry_index < next_entry
})
.count();
}
Self {
session_id,
segment_id,
capture_id,
entries: Arc::new(entries),
overview,
index,
}
}
/// Public source entries are immutable; callers retain their own business decisions.
pub fn entries(&self) -> impl ExactSizeIterator<Item = &CaptureEntryDto> {
self.entries.iter().map(|entry| &entry.dto)
}
pub fn overview(&self) -> &[OverviewItem] {
&self.overview
}
pub fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
}
pub fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
if self.validate_search(options).is_err() {
return Vec::new();
}
let query = options.query.trim().to_lowercase();
let limit = options
.limit
.unwrap_or(DEFAULT_SEARCH_LIMIT)
.clamp(1, MAX_SEARCH_LIMIT);
let tool_name = options.tool_name.as_deref();
let min_entry_index = options
.from
.as_ref()
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new();
for entry in &self.index {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
continue;
}
if let Some(kind) = options.kind {
if entry.kind != kind {
continue;
}
}
if let Some(part) = options.tool_part {
if entry.kind != ReferenceKind::Tool {
continue;
}
let Some(actual) = entry.tool_part else {
continue;
};
if !part.matches(actual) {
continue;
}
}
if let Some(name) = tool_name {
if entry.tool_name.as_deref() != Some(name) {
continue;
}
}
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
continue;
}
if skipped < options.offset {
skipped += 1;
continue;
}
hits.push(SearchHit {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
entry_range: entry.entry_range,
label: entry.label.clone(),
summary: entry.summary.clone(),
});
if hits.len() >= limit {
break;
}
}
hits
}
pub fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult {
let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS);
let max_bytes = options.max_bytes.max(1);
let mut entries = Vec::new();
let mut bytes = 0usize;
let mut truncated = false;
let selected: Vec<&ReferenceEntry> = match selector {
ReadSelector::Id(id) => self
.index
.iter()
.filter(|entry| entry.id.as_str() == id)
.collect(),
ReadSelector::EntryRange([start, end]) => self
.index
.iter()
.filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end)
.collect(),
};
for entry in selected {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entries.len() >= max_items {
truncated = true;
break;
}
if entry.kind == ReferenceKind::Tool {
if !options.include_tools {
continue;
}
if let Some(actual) = entry.tool_part {
if !options.tool_part.matches(actual) {
continue;
}
}
}
let Some(item) = self
.entries
.iter()
.find(|captured| captured.dto.order == entry.entry_range[0])
.map(|entry| &entry.item)
else {
continue;
};
let remaining = max_bytes.saturating_sub(bytes);
if remaining == 0 {
truncated = true;
break;
}
let rendered = render_item(item, entry, options.detail, usize::MAX);
truncated |= rendered.len() > remaining;
let text = truncate_bytes(&rendered, remaining);
bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
entry_range: entry.entry_range,
label: entry.label.clone(),
text,
});
if truncated {
break;
}
}
ReadResult { entries, truncated }
}
/// Resolves the committed tool-call input for a provider call id. This is
/// used by explicit-memory receipts only after the containing run is durable,
/// so a model-authored claim can cite its own committed tool invocation
/// without inventing a SessionEntryRef.
pub fn evidence_for_tool_call(&self, call_id: &str) -> Option<SessionEntryEvidence> {
let captured = self.entries.iter().find(|entry| {
matches!(
&entry.item,
Item::ToolCall {
call_id: stored_call_id,
..
} if stored_call_id == call_id
)
})?;
let reference = self.index.iter().find(|entry| {
entry.entry_range[0] == captured.dto.order
&& entry.kind == ReferenceKind::Tool
&& entry.tool_part == Some(ToolPart::Input)
})?;
self.evidence_for(reference.id.as_str())
}
pub fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
if entry.use_policy.is_some_and(|p| !p.memory_evidence) {
return None;
}
let item = &self
.entries
.iter()
.find(|captured| captured.dto.order == entry.entry_range[0])?
.item;
let excerpt = render_item(item, entry, ReadDetail::Compact, 2 * 1024);
Some(SessionEntryEvidence {
segment_id: self
.entries
.iter()
.find(|captured| captured.dto.order == entry.entry_range[0])?
.dto
.source
.segment_id
.clone(),
entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: entry.entry_range,
kind: entry.kind,
tool_part: entry.tool_part,
label: entry.label.clone(),
summary: entry.summary.clone(),
excerpt,
})
}
}
fn public_provenance(origin: &HistoryOrigin) -> SessionEntryProvenance {
match origin {
HistoryOrigin::HumanInput { .. } | HistoryOrigin::LocalHumanInput => {
SessionEntryProvenance::HumanInput
}
HistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
HistoryOrigin::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
HistoryOrigin::BackendInstruction { .. } | HistoryOrigin::Provided { .. } => {
SessionEntryProvenance::BackendInstruction
}
HistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
HistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
HistoryOrigin::CompactionSummary
| HistoryOrigin::DerivedSummary
| HistoryOrigin::JournalContext => SessionEntryProvenance::DerivedSummary,
HistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
fn message_reference_kind(
origin: &SessionEntryProvenance,
provider_role: &Role,
use_policy: Option<protocol::SessionEntryUsePolicy>,
) -> Option<ReferenceKind> {
if use_policy.is_some() {
return match provider_role {
Role::System => Some(ReferenceKind::System),
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
};
}
match origin {
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
Some(ReferenceKind::User)
}
SessionEntryProvenance::ModelOutput => Some(ReferenceKind::Assistant),
SessionEntryProvenance::ToolOutput => Some(ReferenceKind::Tool),
SessionEntryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
SessionEntryProvenance::FlowInstruction
| SessionEntryProvenance::BackendInstruction
| SessionEntryProvenance::DerivedSummary => None,
}
}
fn render_item(
item: &Item,
entry: &ReferenceEntry,
detail: ReadDetail,
max_bytes: usize,
) -> String {
let text = match item {
Item::Message { role, content, .. } => {
let text = content
.iter()
.map(|p| p.as_text())
.collect::<Vec<_>>()
.join("");
format!("[{} {:?}] {text}", entry.id, role)
}
Item::ToolCall {
name, arguments, ..
} => match detail {
ReadDetail::Compact => format!("[{} ToolInput {name}] (arguments omitted)", entry.id),
ReadDetail::Full => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id),
},
Item::ToolResult {
summary,
content,
attachments: _,
is_error,
..
} => {
let attachment_line = if entry.attachment_count == 0 {
String::new()
} else {
format!("\nattachments: {} image(s)", entry.attachment_count)
};
match detail {
ReadDetail::Compact => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: (omitted){attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
),
ReadDetail::Full => format!(
"[{} ToolOutput{}]\nsummary: {summary}\ncontent: {}{attachment_line}",
entry.id,
if *is_error { " error" } else { "" },
content.as_deref().unwrap_or_default(),
),
}
}
Item::AssistantResponseBoundary { .. } => {
format!("[{} Assistant response boundary omitted]", entry.id)
}
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
};
truncate_bytes(&text, max_bytes)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return "… [truncated]".to_string();
}
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut out = text.chars().take(max_chars).collect::<String>();
out.push_str("… [truncated]");
out
}
fn truncate_bytes(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_owned();
}
let marker = "… [truncated]";
let mut end = max_bytes.saturating_sub(marker.len());
while !text.is_char_boundary(end) {
end -= 1;
}
let mut out = text[..end].to_owned();
if max_bytes >= marker.len() {
out.push_str(marker);
}
out
}
+934
View File
@@ -0,0 +1,934 @@
//! Portable public capture values. DTOs are transport data, not authorization handles.
//! Checked construction validates an explicitly public projection; adapters must never put
//! a provider System body or private Feature payload in `PublicSystem`.
use super::*;
use serde::de::Error as _;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::io::Write;
pub const CAPTURE_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureUsePolicy {
pub recording_material: bool,
pub memory_evidence: bool,
pub observation_readable: bool,
}
impl CaptureUsePolicy {
pub const PUBLIC: Self = Self {
recording_material: true,
memory_evidence: true,
observation_readable: true,
};
pub fn any(self) -> bool {
self.recording_material || self.memory_evidence || self.observation_readable
}
pub fn allows(self, purpose: CapturePurpose) -> bool {
match purpose {
CapturePurpose::Recording => self.recording_material,
CapturePurpose::MemoryEvidence => self.memory_evidence,
CapturePurpose::Observation => self.observation_readable,
}
}
}
impl From<crate::HistoryInputUsePolicy> for CaptureUsePolicy {
fn from(policy: crate::HistoryInputUsePolicy) -> Self {
Self {
recording_material: policy.recording_material == crate::InputEvidenceUse::Include,
memory_evidence: policy.memory_evidence == crate::InputEvidenceUse::Include,
observation_readable: policy.observation == crate::InputObservationUse::Readable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapturePurpose {
Recording,
MemoryEvidence,
Observation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptureRole {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CaptureContentPart {
Text { text: String },
Refusal { refusal: String },
}
impl CaptureContentPart {
fn text(&self) -> &str {
match self {
Self::Text { text } => text,
Self::Refusal { refusal } => refusal,
}
}
}
/// The type has no reasoning, raw System prompt, Feature payload, or image bytes variant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CaptureBody {
Message {
role: CaptureRole,
content: Vec<CaptureContentPart>,
},
PublicSystem {
text: String,
},
ToolCall {
call_id: String,
name: String,
arguments: String,
},
ToolResult {
call_id: String,
summary: String,
content: Option<String>,
is_error: bool,
attachment_count: usize,
},
}
impl CaptureBody {
pub(super) fn project(
item: &Item,
origin: &SessionEntryProvenance,
explicit: bool,
) -> Option<Self> {
match item {
Item::Message { role, content, .. } => {
message_reference_kind(
origin,
role,
explicit.then_some(protocol::SessionEntryUsePolicy {
observation_readable: true,
memory_evidence: true,
}),
)?;
if *role == Role::System {
if !explicit {
return None;
}
return Some(Self::PublicSystem {
text: content.iter().map(|p| p.as_text()).collect::<String>(),
});
}
Some(Self::Message {
role: if *role == Role::User {
CaptureRole::User
} else {
CaptureRole::Assistant
},
content: content
.iter()
.map(|p| match p {
agen::ContentPart::Text { text } => {
CaptureContentPart::Text { text: text.clone() }
}
agen::ContentPart::Refusal { refusal } => CaptureContentPart::Refusal {
refusal: refusal.clone(),
},
})
.collect(),
})
}
Item::ToolCall {
call_id,
name,
arguments,
..
} => Some(Self::ToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
}),
Item::ToolResult {
call_id,
summary,
content,
is_error,
attachments,
..
} => Some(Self::ToolResult {
call_id: call_id.clone(),
summary: summary.clone(),
content: content.clone(),
is_error: *is_error,
attachment_count: attachments.len(),
}),
Item::Reasoning { .. } | Item::AssistantResponseBoundary { .. } => None,
}
}
pub(super) fn to_item(&self) -> Item {
match self {
Self::Message { role, content } => Item::Message {
id: None,
status: None,
role: match role {
CaptureRole::User => Role::User,
CaptureRole::Assistant => Role::Assistant,
},
content: content
.iter()
.map(|p| match p {
CaptureContentPart::Text { text } => {
agen::ContentPart::Text { text: text.clone() }
}
CaptureContentPart::Refusal { refusal } => agen::ContentPart::Refusal {
refusal: refusal.clone(),
},
})
.collect(),
},
Self::PublicSystem { text } => Item::system_message(text.clone()),
Self::ToolCall {
call_id,
name,
arguments,
} => Item::tool_call(call_id.clone(), name.clone(), arguments.clone()),
Self::ToolResult {
call_id,
summary,
content,
is_error,
..
} => {
Item::tool_result_item(call_id.clone(), summary.clone(), content.clone(), *is_error)
}
}
}
fn fields(&self) -> Vec<(RawField, &str)> {
match self {
Self::Message { content, .. } => content
.iter()
.enumerate()
.map(|(i, p)| (RawField::MessagePart(i), p.text()))
.collect(),
Self::PublicSystem { text } => vec![(RawField::PublicSystem, text)],
Self::ToolCall { arguments, .. } => vec![(RawField::ToolArguments, arguments)],
Self::ToolResult {
summary, content, ..
} => {
let mut fields = vec![(RawField::ToolSummary, summary.as_str())];
if let Some(content) = content {
fields.push((RawField::ToolContent, content));
}
fields
}
}
}
fn metadata(&self, field: RawField) -> CaptureBodyMetadata {
match self {
Self::Message { role, content } => CaptureBodyMetadata::Message {
role: *role,
refusal: matches!(field, RawField::MessagePart(i) if matches!(content.get(i), Some(CaptureContentPart::Refusal { .. }))),
},
Self::PublicSystem { .. } => CaptureBodyMetadata::PublicSystem,
Self::ToolCall { call_id, name, .. } => CaptureBodyMetadata::ToolCall {
call_id: call_id.clone(),
name: name.clone(),
},
Self::ToolResult {
call_id,
content,
is_error,
attachment_count,
..
} => CaptureBodyMetadata::ToolResult {
call_id: call_id.clone(),
has_content: content.is_some(),
is_error: *is_error,
attachment_count: *attachment_count,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureSource {
pub segment_id: String,
pub entry_index: u64,
pub provenance: SessionEntryProvenance,
/// Full original origin when supplied by the committed-history adapter.
#[serde(deserialize_with = "strict_origin")]
pub history_origin: Option<crate::LoggedSessionHistoryOrigin>,
pub derived_from: Vec<String>,
}
// Existing history origin types predate strict DTOs. Comparing their canonical value
// rejects unknown nested keys instead of silently accepting/losing them at this boundary.
fn strict_origin<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<crate::LoggedSessionHistoryOrigin>, D::Error> {
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
value
.map(|value| {
let origin: crate::LoggedSessionHistoryOrigin =
serde_json::from_value(value.clone()).map_err(D::Error::custom)?;
if serde_json::to_value(&origin).map_err(D::Error::custom)? != value {
return Err(D::Error::custom("noncanonical or unknown origin fields"));
}
Ok(origin)
})
.transpose()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureEntryDto {
pub entry_id: String,
/// Position in this captured view, independent of the original segment position.
pub order: u64,
pub source: CaptureSource,
pub policy: CaptureUsePolicy,
pub body: CaptureBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureDto {
pub version: u32,
pub session_id: String,
pub segment_id: String,
pub entries: Vec<CaptureEntryDto>,
}
#[derive(Debug, Clone, Copy)]
pub struct CaptureLimits {
pub max_bytes: usize,
pub max_entries: usize,
pub max_text_bytes: usize,
pub max_identity_bytes: usize,
pub max_derivation_sources: usize,
}
impl Default for CaptureLimits {
fn default() -> Self {
Self {
max_bytes: 16 * 1024 * 1024,
max_entries: 100_000,
max_text_bytes: 8 * 1024 * 1024,
max_identity_bytes: 4096,
max_derivation_sources: 1024,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CaptureError {
#[error("unsupported capture representation version")]
UnsupportedVersion,
#[error("capture exceeds caller resource limits")]
ResourceLimit,
#[error("invalid capture: {0}")]
Invalid(&'static str),
#[error("reference or cursor belongs to another capture/session/purpose")]
ForeignReference,
#[error("unknown entry reference")]
UnknownReference,
#[error("entry is not eligible for this purpose")]
PolicyDenied,
#[error("invalid entry range or UTF-8 byte offset")]
InvalidRange,
#[error("budget cannot fit the next item and continuation metadata")]
BudgetTooSmall,
#[error("invalid capture JSON: {0}")]
Json(#[from] serde_json::Error),
}
struct ByteCounter {
bytes: usize,
limit: usize,
}
impl Write for ByteCounter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.bytes = self
.bytes
.checked_add(buf.len())
.ok_or_else(|| std::io::Error::other("capture byte limit"))?;
if self.bytes > self.limit {
return Err(std::io::Error::other("capture byte limit"));
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn json_size<T: Serialize>(value: &T, limit: usize) -> Result<usize, CaptureError> {
let mut counter = ByteCounter { bytes: 0, limit };
serde_json::to_writer(&mut counter, value).map_err(|_| CaptureError::ResourceLimit)?;
Ok(counter.bytes)
}
fn identity(value: &str, limits: CaptureLimits) -> Result<(), CaptureError> {
if value.is_empty()
|| value.len() > limits.max_identity_bytes
|| value.chars().any(char::is_control)
{
return Err(CaptureError::Invalid("identity"));
}
Ok(())
}
impl CaptureDto {
pub fn validate(&self, limits: CaptureLimits) -> Result<(), CaptureError> {
if self.version != CAPTURE_VERSION {
return Err(CaptureError::UnsupportedVersion);
}
if self.entries.len() > limits.max_entries {
return Err(CaptureError::ResourceLimit);
}
identity(&self.session_id, limits)?;
identity(&self.segment_id, limits)?;
let mut ids = HashSet::new();
let mut sources = HashSet::new();
let mut previous = None;
for entry in &self.entries {
identity(&entry.entry_id, limits)?;
if SessionEntryRef::parse(&format!("E{}", entry.entry_id)).is_none()
|| !ids.insert(&entry.entry_id)
{
return Err(CaptureError::Invalid("entry identity"));
}
if previous.is_some_and(|order| order >= entry.order) {
return Err(CaptureError::Invalid("entry order"));
}
previous = Some(entry.order);
identity(&entry.source.segment_id, limits)?;
if !sources.insert((&entry.source.segment_id, entry.source.entry_index)) {
return Err(CaptureError::Invalid("duplicate source position"));
}
if !entry.policy.any() {
return Err(CaptureError::Invalid("unusable retained entry"));
}
if entry.source.derived_from.len() > limits.max_derivation_sources {
return Err(CaptureError::ResourceLimit);
}
for source in &entry.source.derived_from {
identity(source, limits)?;
}
if let Some(origin) = &entry.source.history_origin {
if public_provenance(origin) != entry.source.provenance {
return Err(CaptureError::Invalid("origin mismatch"));
}
json_size(origin, limits.max_text_bytes)?;
}
for (_, text) in entry.body.fields() {
if text.len() > limits.max_text_bytes {
return Err(CaptureError::ResourceLimit);
}
}
match &entry.body {
CaptureBody::Message { content, .. } if content.is_empty() => {
return Err(CaptureError::Invalid("empty message parts"));
}
CaptureBody::ToolCall { call_id, name, .. } => {
identity(call_id, limits)?;
identity(name, limits)?;
}
CaptureBody::ToolResult { call_id, .. } => identity(call_id, limits)?,
_ => {}
}
}
json_size(self, limits.max_bytes)?;
Ok(())
}
pub fn encode(&self, limits: CaptureLimits) -> Result<Vec<u8>, CaptureError> {
self.validate(limits)?;
Ok(serde_json::to_vec(self)?)
}
pub fn decode(bytes: &[u8], limits: CaptureLimits) -> Result<Self, CaptureError> {
if bytes.len() > limits.max_bytes {
return Err(CaptureError::ResourceLimit);
}
let dto: Self = serde_json::from_slice(bytes)?;
dto.validate(limits)?;
Ok(dto)
}
pub(super) fn fingerprint(&self) -> String {
// These DTOs have only finite, infallibly serializable JSON primitives.
let bytes = serde_json::to_vec(self).expect("capture DTO serialization");
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScopedSessionEntryRef {
pub session_id: String,
pub capture_id: String,
pub entry_ref: SessionEntryRef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RawField {
MessagePart(usize),
PublicSystem,
ToolArguments,
ToolSummary,
ToolContent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawRead {
pub reference: ScopedSessionEntryRef,
pub purpose: CapturePurpose,
pub field: RawField,
pub offset: usize,
pub text: String,
pub next_offset: Option<usize>,
pub total_bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CaptureBodyMetadata {
Message {
role: CaptureRole,
refusal: bool,
},
PublicSystem,
ToolCall {
call_id: String,
name: String,
},
ToolResult {
call_id: String,
has_content: bool,
is_error: bool,
attachment_count: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureCursor {
pub session_id: String,
pub capture_id: String,
pub purpose: CapturePurpose,
pub entry_position: usize,
pub field_index: usize,
pub byte_offset: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureSliceItem {
pub reference: ScopedSessionEntryRef,
pub order: u64,
pub source: CaptureSource,
pub policy: CaptureUsePolicy,
pub metadata: CaptureBodyMetadata,
pub field: RawField,
pub offset: usize,
pub total_bytes: usize,
/// Raw source bytes, not a rendered preview or ellipsis-bearing truncation.
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureSlice {
pub version: u32,
pub session_id: String,
pub capture_id: String,
pub purpose: CapturePurpose,
pub items: Vec<CaptureSliceItem>,
pub next_cursor: Option<CaptureCursor>,
}
/// Bytes and value remain paired and immutable so the size proof cannot go stale.
#[derive(Debug, Clone)]
pub struct BudgetedCaptureSlice {
slice: CaptureSlice,
json: Vec<u8>,
}
impl BudgetedCaptureSlice {
pub fn slice(&self) -> &CaptureSlice {
&self.slice
}
pub fn as_json(&self) -> &[u8] {
&self.json
}
pub fn encoded_len(&self) -> usize {
self.json.len()
}
}
impl SessionCapture {
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn segment_id(&self) -> &str {
&self.segment_id
}
pub fn capture_id(&self) -> &str {
&self.capture_id
}
pub fn to_dto(&self) -> CaptureDto {
CaptureDto {
version: CAPTURE_VERSION,
session_id: self.session_id.clone(),
segment_id: self.segment_id.clone(),
entries: self.entries.iter().map(|e| e.dto.clone()).collect(),
}
}
pub fn try_from_dto(dto: CaptureDto, limits: CaptureLimits) -> Result<Self, CaptureError> {
dto.validate(limits)?;
Ok(Self::from_projected(
dto.session_id,
dto.segment_id,
dto.entries,
))
}
pub fn encode(&self, limits: CaptureLimits) -> Result<Vec<u8>, CaptureError> {
self.to_dto().encode(limits)
}
pub fn decode(bytes: &[u8], limits: CaptureLimits) -> Result<Self, CaptureError> {
Self::try_from_dto(CaptureDto::decode(bytes, limits)?, limits)
}
pub fn scoped_ref(
&self,
reference: &SessionEntryRef,
) -> Result<ScopedSessionEntryRef, CaptureError> {
if self.source_index_for_ref(reference).is_none() {
return Err(CaptureError::UnknownReference);
}
Ok(ScopedSessionEntryRef {
session_id: self.session_id.clone(),
capture_id: self.capture_id.clone(),
entry_ref: reference.clone(),
})
}
pub fn resolve_ref(
&self,
reference: &ScopedSessionEntryRef,
purpose: CapturePurpose,
) -> Result<&CaptureEntryDto, CaptureError> {
if reference.session_id != self.session_id || reference.capture_id != self.capture_id {
return Err(CaptureError::ForeignReference);
}
let entry = self
.entries
.iter()
.find(|entry| {
SessionEntryRef::from_history_entry_id(&entry.entry_id) == reference.entry_ref
})
.ok_or(CaptureError::UnknownReference)?;
if !entry.dto.policy.allows(purpose) {
return Err(CaptureError::PolicyDenied);
}
Ok(&entry.dto)
}
pub fn checked_range(
&self,
from: &ScopedSessionEntryRef,
through: &ScopedSessionEntryRef,
purpose: CapturePurpose,
) -> Result<Vec<&CaptureEntryDto>, CaptureError> {
let start = self.resolve_ref(from, purpose)?.order;
let end = self.resolve_ref(through, purpose)?.order;
if start > end {
return Err(CaptureError::InvalidRange);
}
Ok(self
.entries
.iter()
.map(|entry| &entry.dto)
.filter(|entry| {
entry.order >= start && entry.order <= end && entry.policy.allows(purpose)
})
.collect())
}
pub(super) fn validate_search(&self, options: &SearchOptions) -> Result<(), CaptureError> {
let from = options
.from
.as_ref()
.map(|r| {
self.source_index_for_ref(r)
.ok_or(CaptureError::UnknownReference)
})
.transpose()?;
let through = options
.through
.as_ref()
.map(|r| {
self.source_index_for_ref(r)
.ok_or(CaptureError::UnknownReference)
})
.transpose()?;
if from.unwrap_or(options.min_entry_index.unwrap_or(0)) > through.unwrap_or(u64::MAX) {
return Err(CaptureError::InvalidRange);
}
Ok(())
}
pub fn search_checked(&self, options: &SearchOptions) -> Result<Vec<SearchHit>, CaptureError> {
self.validate_search(options)?;
Ok(self.search(options))
}
pub fn read_checked(
&self,
selector: ReadSelector<'_>,
options: ReadOptions,
) -> Result<ReadResult, CaptureError> {
match selector {
ReadSelector::Id(id) => {
let reference = SessionEntryRef::parse(id).ok_or(CaptureError::UnknownReference)?;
self.source_index_for_ref(&reference)
.ok_or(CaptureError::UnknownReference)?;
}
ReadSelector::EntryRange([start, end]) => {
if start > end
|| !self.index.iter().any(|entry| entry.entry_range[0] == start)
|| !self.index.iter().any(|entry| entry.entry_range[0] == end)
{
return Err(CaptureError::InvalidRange);
}
}
}
Ok(self.read(selector, options))
}
pub fn read_raw(
&self,
reference: &ScopedSessionEntryRef,
purpose: CapturePurpose,
field: RawField,
offset: usize,
max_bytes: usize,
) -> Result<RawRead, CaptureError> {
let entry = self.resolve_ref(reference, purpose)?;
let text = entry
.body
.fields()
.into_iter()
.find(|(f, _)| *f == field)
.map(|(_, text)| text)
.ok_or(CaptureError::InvalidRange)?;
if offset > text.len() || !text.is_char_boundary(offset) {
return Err(CaptureError::InvalidRange);
}
let mut end = offset.saturating_add(max_bytes).min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
if end == offset && offset < text.len() {
return Err(CaptureError::BudgetTooSmall);
}
Ok(RawRead {
reference: reference.clone(),
purpose,
field,
offset,
text: text[offset..end].to_owned(),
next_offset: (end < text.len()).then_some(end),
total_bytes: text.len(),
})
}
fn cursor(
&self,
purpose: CapturePurpose,
entry_position: usize,
field_index: usize,
byte_offset: usize,
) -> CaptureCursor {
CaptureCursor {
session_id: self.session_id.clone(),
capture_id: self.capture_id.clone(),
purpose,
entry_position,
field_index,
byte_offset,
}
}
fn normalize_cursor(&self, mut cursor: CaptureCursor) -> Option<CaptureCursor> {
while let Some(entry) = self.entries.get(cursor.entry_position) {
if entry.dto.policy.allows(cursor.purpose)
&& cursor.field_index < entry.dto.body.fields().len()
{
return Some(cursor);
}
cursor.entry_position += 1;
cursor.field_index = 0;
cursor.byte_offset = 0;
}
None
}
/// The returned JSON includes every item's source/policy, escaped body, and continuation.
/// Both the item count and the exact encoded length are bounded, with no Job-specific limit.
pub fn slice(
&self,
purpose: CapturePurpose,
cursor: Option<&CaptureCursor>,
max_items: usize,
max_bytes: usize,
) -> Result<BudgetedCaptureSlice, CaptureError> {
if max_items == 0 {
return Err(CaptureError::BudgetTooSmall);
}
let start = if let Some(cursor) = cursor {
if cursor.session_id != self.session_id
|| cursor.capture_id != self.capture_id
|| cursor.purpose != purpose
{
return Err(CaptureError::ForeignReference);
}
let entry = self
.entries
.get(cursor.entry_position)
.ok_or(CaptureError::InvalidRange)?;
if !entry.dto.policy.allows(purpose) {
return Err(CaptureError::PolicyDenied);
}
let fields = entry.dto.body.fields();
let text = fields
.get(cursor.field_index)
.ok_or(CaptureError::InvalidRange)?
.1;
if cursor.byte_offset > text.len() || !text.is_char_boundary(cursor.byte_offset) {
return Err(CaptureError::InvalidRange);
}
Some(cursor.clone())
} else {
self.normalize_cursor(self.cursor(purpose, 0, 0, 0))
};
let mut slice = CaptureSlice {
version: CAPTURE_VERSION,
session_id: self.session_id.clone(),
capture_id: self.capture_id.clone(),
purpose,
items: Vec::new(),
next_cursor: start,
};
if json_size(&slice, max_bytes).is_err() {
return Err(CaptureError::BudgetTooSmall);
}
while slice.items.len() < max_items {
let Some(current) = slice.next_cursor.clone() else {
break;
};
let dto = &self.entries[current.entry_position].dto;
let fields = dto.body.fields();
let (field, text) = fields[current.field_index];
let reference = ScopedSessionEntryRef {
session_id: self.session_id.clone(),
capture_id: self.capture_id.clone(),
entry_ref: SessionEntryRef(format!("E{}", dto.entry_id)),
};
let item = CaptureSliceItem {
reference,
order: dto.order,
source: dto.source.clone(),
policy: dto.policy,
metadata: dto.body.metadata(field),
field,
offset: current.byte_offset,
total_bytes: text.len(),
text: String::new(),
};
slice.items.push(item);
let item_index = slice.items.len() - 1;
// Search only the partial interval: its cursor stays in the same field,
// making encoded size monotonic even when completing a field drops metadata.
let remaining = &text[current.byte_offset..];
let mut candidate_bytes = remaining.len().min(max_bytes);
while !remaining.is_char_boundary(candidate_bytes) {
candidate_bytes -= 1;
}
let mut boundaries: Vec<usize> = remaining[..candidate_bytes]
.char_indices()
.map(|(i, _)| i)
.collect();
boundaries.push(candidate_bytes);
let mut accepted = None;
let mut low = 0;
let mut high = boundaries.len();
while low < high {
let mid = low + (high - low) / 2;
let end = current.byte_offset + boundaries[mid];
slice.items[item_index].text = text[current.byte_offset..end].to_owned();
slice.next_cursor =
Some(self.cursor(purpose, current.entry_position, current.field_index, end));
if json_size(&slice, max_bytes).is_ok() {
accepted = Some(end);
low = mid + 1;
} else {
high = mid;
}
}
// A completed field can fit even when its partial continuation does not.
let completed = self.normalize_cursor(self.cursor(
purpose,
current.entry_position,
current.field_index + 1,
0,
));
if remaining.len() <= max_bytes {
slice.items[item_index].text = remaining.to_owned();
slice.next_cursor = completed.clone();
if json_size(&slice, max_bytes).is_ok() {
accepted = Some(text.len());
}
}
let Some(end) = accepted.filter(|end| *end > current.byte_offset || text.is_empty())
else {
slice.items.pop();
slice.next_cursor = Some(current);
if slice.items.is_empty() {
return Err(CaptureError::BudgetTooSmall);
}
break;
};
slice.items[item_index].text = text[current.byte_offset..end].to_owned();
slice.next_cursor = if end == text.len() {
completed
} else {
Some(self.cursor(purpose, current.entry_position, current.field_index, end))
};
}
let json = serde_json::to_vec(&slice)?;
if json.len() > max_bytes {
return Err(CaptureError::BudgetTooSmall);
}
Ok(BudgetedCaptureSlice { slice, json })
}
}
+1
View File
@@ -38,6 +38,7 @@ pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Res
}
}
pub mod capture;
pub mod event_trace;
pub mod fs_store;
pub mod history;
@@ -0,0 +1,594 @@
//! Public consumer boundary: real capture adapters, checked DTO codec and pure readers.
//! No Worker, persistence, live Session provider, or substituted capture implementation.
use agen::{HistoryEntry, Item};
use protocol::{
SessionEntryProvenance, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData,
};
use session_store::capture::*;
use session_store::{
FeatureInputPayload, HistoryInputMetadata, HistoryInputUsePolicy, InputCompactionUse,
InputEvidenceUse, InputObservationUse, LoggedHistoryDerivation, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, PublicSystemItem,
PublicSystemPresentation,
};
fn history(
id: &str,
item: Item,
input_metadata: Option<HistoryInputMetadata>,
) -> HistoryEntry<LoggedSessionHistoryMetadata> {
HistoryEntry::new(
item,
LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId(id.into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: Some(LoggedHistoryDerivation {
sources: vec![LoggedSessionHistoryEntryId("earlier-entry".into())],
}),
input_metadata,
},
)
}
fn usage(recording: bool, memory: bool, observation: bool) -> HistoryInputUsePolicy {
HistoryInputUsePolicy {
recording_material: if recording {
InputEvidenceUse::Include
} else {
InputEvidenceUse::Exclude
},
memory_evidence: if memory {
InputEvidenceUse::Include
} else {
InputEvidenceUse::Exclude
},
observation: if observation {
InputObservationUse::Readable
} else {
InputObservationUse::Hidden
},
compaction: InputCompactionUse::Retain,
}
}
fn reference(capture: &SessionCapture, id: &str) -> ScopedSessionEntryRef {
capture
.scoped_ref(&SessionEntryRef::parse(id).unwrap())
.unwrap()
}
#[test]
fn public_consumer_roundtrip_retains_body_original_source_order_origin_and_independent_policy() {
let entries = vec![
history(
"recording",
Item::user_message("record-only"),
Some(HistoryInputMetadata::new(usage(true, false, false), None, None).unwrap()),
),
history(
"memory",
Item::user_message("memory-only"),
Some(HistoryInputMetadata::new(usage(false, true, false), None, None).unwrap()),
),
history(
"observation",
Item::user_message("observation-only"),
Some(HistoryInputMetadata::new(usage(false, false, true), None, None).unwrap()),
),
];
let capture = SessionCapture::from_history_entries("synthetic-session", "selected", entries);
let mut dto = capture.to_dto();
// The checked boundary also accepts entries retained from different original segments.
dto.entries[0].source.segment_id = "original-a".into();
dto.entries[0].source.entry_index = 80;
dto.entries[1].source.segment_id = "original-b".into();
dto.entries[1].source.entry_index = 3;
let capture = SessionCapture::try_from_dto(dto.clone(), CaptureLimits::default()).unwrap();
let encoded = capture.encode(CaptureLimits::default()).unwrap();
let restored = SessionCapture::decode(&encoded, CaptureLimits::default()).unwrap();
assert_eq!(restored.to_dto(), dto);
assert_eq!(restored.capture_id(), capture.capture_id());
for (id, allowed) in [
("Erecording", CapturePurpose::Recording),
("Ememory", CapturePurpose::MemoryEvidence),
("Eobservation", CapturePurpose::Observation),
] {
let reference = reference(&capture, id);
assert_eq!(
restored.resolve_ref(&reference, allowed).unwrap(),
capture.resolve_ref(&reference, allowed).unwrap()
);
for purpose in [
CapturePurpose::Recording,
CapturePurpose::MemoryEvidence,
CapturePurpose::Observation,
] {
assert_eq!(
restored.resolve_ref(&reference, purpose).is_ok(),
purpose == allowed
);
}
}
assert_eq!(restored.overview().len(), 1);
assert_eq!(restored.search(&SearchOptions::default()).len(), 1);
assert!(restored.evidence_for("Erecording").is_none());
let evidence = restored.evidence_for("Ememory").unwrap();
assert_eq!(evidence.segment_id, "original-b");
assert!(evidence.excerpt.contains("memory-only"));
let recorded = restored
.slice(CapturePurpose::Recording, None, 10, 16_000)
.unwrap();
assert_eq!(recorded.slice().items.len(), 1);
assert_eq!(recorded.slice().items[0].text, "record-only");
for (purpose, expected) in [
(CapturePurpose::MemoryEvidence, "memory-only"),
(CapturePurpose::Observation, "observation-only"),
] {
let slice = restored.slice(purpose, None, 10, 16_000).unwrap();
assert_eq!(slice.slice().items.len(), 1);
assert_eq!(slice.slice().items[0].text, expected);
assert!(
restored
.read_raw(
&reference(&restored, "Erecording"),
purpose,
RawField::MessagePart(0),
0,
100
)
.is_err()
);
}
}
#[test]
fn common_data_drops_reasoning_raw_system_and_private_payload_even_when_unindexed() {
let payload = FeatureInputPayload::encode(
"capture-test",
1,
&serde_json::json!({"secret": "PRIVATE-PAYLOAD"}),
)
.unwrap();
let public = PublicSystemItem {
kind: "notice".into(),
body: "public notice".into(),
presentation: PublicSystemPresentation::Ignored,
};
let system =
HistoryInputMetadata::new(usage(true, true, true), Some(payload.clone()), Some(public))
.unwrap();
let private = HistoryInputMetadata::new(usage(true, true, true), Some(payload), None).unwrap();
let capture = SessionCapture::from_history_entries(
"s",
"segment",
vec![
history("raw", Item::system_message("RAW-SYSTEM-PROMPT"), None),
history("reasoning", Item::reasoning("HIDDEN-REASONING"), None),
history(
"private",
Item::system_message("PRIVATE-SYSTEM-BODY"),
Some(private),
),
history(
"public",
Item::system_message("MODEL-SYSTEM-BODY"),
Some(system),
),
history("ordinary", Item::user_message("ordinary"), None),
],
);
let encoded = String::from_utf8(capture.encode(CaptureLimits::default()).unwrap()).unwrap();
let retained_debug = format!("{capture:?}");
for secret in [
"RAW-SYSTEM-PROMPT",
"HIDDEN-REASONING",
"PRIVATE-SYSTEM-BODY",
"MODEL-SYSTEM-BODY",
"PRIVATE-PAYLOAD",
] {
assert!(!encoded.contains(secret));
assert!(!retained_debug.contains(secret));
}
assert_eq!(capture.to_dto().entries.len(), 2);
assert!(encoded.contains("public notice"));
// Filtered entries do not renumber the public source positions.
assert_eq!(capture.to_dto().entries[0].order, 3);
assert_eq!(
capture.source_index_for_ref(&SessionEntryRef::parse("Eordinary").unwrap()),
Some(4)
);
}
#[test]
fn protocol_projection_does_not_invent_recording_permission() {
let capture = SessionCapture::from_session_snapshot(
"s",
"seg",
SessionSnapshot {
pending_submissions: Default::default(),
entries: vec![SessionSnapshotEntry {
entry_id: "projected".into(),
timestamp: 0,
provenance: SessionEntryProvenance::HumanInput,
derived_from: vec!["source".into()],
use_policy: Some(protocol::SessionEntryUsePolicy {
observation_readable: true,
memory_evidence: false,
}),
data: SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content: vec![protocol::SessionContentPart::Text {
text: "projected".into(),
}],
},
}],
},
);
let reference = reference(&capture, "Eprojected");
assert!(matches!(
capture.resolve_ref(&reference, CapturePurpose::Recording),
Err(CaptureError::PolicyDenied)
));
assert!(
capture
.resolve_ref(&reference, CapturePurpose::Observation)
.is_ok()
);
assert!(capture.evidence_for("Eprojected").is_none());
}
#[test]
fn malformed_dtos_and_oversized_representations_are_rejected() {
let capture = SessionCapture::new("s", "seg", vec![Item::user_message("body")]);
let original = serde_json::to_value(capture.to_dto()).unwrap();
let mut invalids = Vec::new();
let mut value = original.clone();
value["version"] = 999.into();
invalids.push(value);
let mut value = original.clone();
value["unexpected"] = true.into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["policy"]["recording_material"] = "include".into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["policy"]
.as_object_mut()
.unwrap()
.remove("memory_evidence");
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"] = serde_json::json!({"kind":"reasoning","text":"secret"});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"]["feature_payload"] = serde_json::json!({"private":true});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["body"]["role"] = "system".into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["source"]["unknown"] = true.into();
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["source"]["history_origin"] =
serde_json::json!({"kind":"legacy_unknown","unknown":true});
invalids.push(value);
let mut value = original.clone();
value["entries"][0]["entry_id"] = "bad/id".into();
invalids.push(value);
let mut value = original.clone();
value["entries"]
.as_array_mut()
.unwrap()
.push(original["entries"][0].clone());
invalids.push(value);
for invalid in invalids {
let bytes = serde_json::to_vec(&invalid).unwrap();
assert!(
SessionCapture::decode(&bytes, CaptureLimits::default()).is_err(),
"accepted {invalid}"
);
}
let bytes = capture.encode(CaptureLimits::default()).unwrap();
assert!(
SessionCapture::decode(
&bytes,
CaptureLimits {
max_bytes: bytes.len() - 1,
..CaptureLimits::default()
}
)
.is_err()
);
assert!(
capture
.encode(CaptureLimits {
max_entries: 0,
..CaptureLimits::default()
})
.is_err()
);
assert!(
capture
.encode(CaptureLimits {
max_text_bytes: 3,
..CaptureLimits::default()
})
.is_err()
);
let mut reversed = capture.to_dto();
let mut second = reversed.entries[0].clone();
second.entry_id = "second".into();
second.source.entry_index = 4;
second.order = 0;
reversed.entries[0].order = 1;
reversed.entries.push(second);
assert!(SessionCapture::try_from_dto(reversed, CaptureLimits::default()).is_err());
}
#[test]
fn raw_utf8_tool_input_and_result_are_lossless_without_preview_markers() {
let arguments = "\"quoted\"\\path\n日本語🙂".repeat(300);
let result = "result\n\t\\\"λ🙂".repeat(400);
let capture = SessionCapture::new(
"s",
"seg",
vec![
Item::tool_call("call", "tool", arguments.clone()),
Item::tool_result_item("call", "summary", Some(result.clone()), false),
],
);
for (id, field, expected) in [
("E00000000", RawField::ToolArguments, arguments),
("E00000001", RawField::ToolContent, result),
] {
let reference = reference(&capture, id);
let mut offset = 0;
let mut actual = String::new();
loop {
let part = capture
.read_raw(&reference, CapturePurpose::Observation, field, offset, 17)
.unwrap();
assert!(part.text.len() <= 17);
assert_eq!(part.offset, offset);
actual.push_str(&part.text);
match part.next_offset {
Some(next) => {
assert!(next > offset);
offset = next;
}
None => break,
}
}
assert_eq!(actual, expected);
}
let unicode = SessionCapture::new("s", "seg", vec![Item::tool_call("call", "tool", "🙂")]);
let reference = reference(&unicode, "E00000000");
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
1,
8
),
Err(CaptureError::InvalidRange)
));
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
0,
3
),
Err(CaptureError::BudgetTooSmall)
));
assert!(matches!(
unicode.read_raw(
&reference,
CapturePurpose::Observation,
RawField::ToolArguments,
5,
8
),
Err(CaptureError::InvalidRange)
));
}
#[test]
fn escaped_json_slice_budget_includes_metadata_and_bound_continuation() {
let body = "\"\\\n\t🙂日本語".repeat(500);
let capture = SessionCapture::new(
"synthetic-session",
"segment",
vec![Item::tool_call("call", "tool", body.clone())],
);
let other_session = SessionCapture::new(
"other-session",
"segment",
vec![Item::tool_call("call", "tool", body.clone())],
);
let changed = SessionCapture::new(
"synthetic-session",
"segment",
vec![Item::tool_call("call", "tool", "changed")],
);
let mut cursor = None;
let mut actual = String::new();
let budget = 1400;
loop {
let part = capture
.slice(CapturePurpose::Recording, cursor.as_ref(), 1, budget)
.unwrap();
assert!(part.encoded_len() <= budget);
assert_eq!(part.encoded_len(), part.as_json().len());
assert_eq!(serde_json::to_vec(part.slice()).unwrap(), part.as_json());
assert_eq!(part.slice().items.len(), 1);
let item = &part.slice().items[0];
assert_eq!(item.offset, actual.len());
actual.push_str(&item.text);
cursor = part.slice().next_cursor.clone();
if let Some(cursor) = &cursor {
assert!(matches!(
other_session.slice(CapturePurpose::Recording, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
changed.slice(CapturePurpose::Recording, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
capture.slice(CapturePurpose::Observation, Some(cursor), 1, budget),
Err(CaptureError::ForeignReference)
));
} else {
break;
}
}
assert_eq!(actual, body);
assert!(matches!(
capture.slice(CapturePurpose::Recording, None, 1, 8),
Err(CaptureError::BudgetTooSmall)
));
assert!(matches!(
capture.slice(CapturePurpose::Recording, None, 0, budget),
Err(CaptureError::BudgetTooSmall)
));
}
#[test]
fn snapshot_immutability_and_scoped_operations_reject_foreign_or_unknown_refs_and_bad_ranges() {
let mut source = vec![
Item::user_message("before"),
Item::assistant_message("answer"),
];
let capture = SessionCapture::new("s", "seg", source.clone());
let fixed_bytes = capture.encode(CaptureLimits::default()).unwrap();
let first = reference(&capture, "E00000000");
let second = reference(&capture, "E00000001");
source.push(Item::user_message("appended"));
source[0] = Item::user_message("compacted");
source.clear();
assert_eq!(
capture.encode(CaptureLimits::default()).unwrap(),
fixed_bytes
);
assert_eq!(
capture.clone().encode(CaptureLimits::default()).unwrap(),
fixed_bytes
);
let foreign = SessionCapture::new(
"different",
"seg",
vec![
Item::user_message("before"),
Item::assistant_message("answer"),
],
);
assert!(matches!(
foreign.resolve_ref(&first, CapturePurpose::Observation),
Err(CaptureError::ForeignReference)
));
assert!(matches!(
capture.checked_range(&second, &first, CapturePurpose::Observation),
Err(CaptureError::InvalidRange)
));
assert_eq!(
capture
.checked_range(&first, &second, CapturePurpose::Observation)
.unwrap()
.len(),
2
);
let unknown = SessionEntryRef::parse("E99999999").unwrap();
assert_eq!(capture.source_index_for_ref(&unknown), None);
assert!(matches!(
capture.scoped_ref(&unknown),
Err(CaptureError::UnknownReference)
));
assert!(
capture
.search_checked(&SearchOptions {
from: Some(unknown.clone()),
..Default::default()
})
.is_err()
);
assert!(
capture
.search(&SearchOptions {
through: Some(unknown),
..Default::default()
})
.is_empty()
);
assert!(
capture
.read_checked(ReadSelector::EntryRange([1, 0]), ReadOptions::default())
.is_err()
);
assert!(
capture
.read_checked(ReadSelector::EntryRange([0, 99]), ReadOptions::default())
.is_err()
);
}
#[test]
fn slices_preserve_empty_and_long_result_fields_under_item_and_metadata_budgets() {
let summary = "summary\n\"🙂".repeat(100);
let content = "content\t\\日本語".repeat(200);
let capture = SessionCapture::new(
"s",
"seg",
vec![
Item::tool_result_item("first", summary.clone(), Some(content.clone()), true),
Item::tool_result_item("empty", "", Some(String::new()), false),
],
);
let mut cursor = None;
let mut fields: Vec<(String, RawField, String)> = Vec::new();
loop {
let part = capture
.slice(CapturePurpose::Recording, cursor.as_ref(), 2, 1500)
.unwrap();
assert!(part.slice().items.len() <= 2);
assert!(serde_json::to_vec(part.slice()).unwrap().len() <= 1500);
for item in &part.slice().items {
let id = item.reference.entry_ref.to_string();
if let Some((last_id, last_field, text)) = fields.last_mut() {
if *last_id == id && *last_field == item.field {
assert_eq!(text.len(), item.offset);
text.push_str(&item.text);
continue;
}
}
assert_eq!(item.offset, 0);
fields.push((id, item.field, item.text.clone()));
}
cursor = part.slice().next_cursor.clone();
if cursor.is_none() {
break;
}
}
assert_eq!(
fields,
vec![
("E00000000".into(), RawField::ToolSummary, summary),
("E00000000".into(), RawField::ToolContent, content),
("E00000001".into(), RawField::ToolSummary, String::new()),
("E00000001".into(), RawField::ToolContent, String::new()),
]
);
let metadata_heavy = SessionCapture::new(
"s",
"seg",
vec![Item::tool_call("call", "name".repeat(200), "x")],
);
assert!(matches!(
metadata_heavy.slice(CapturePurpose::Recording, None, 1, 700),
Err(CaptureError::BudgetTooSmall)
));
}
@@ -26,7 +26,7 @@ use flow::{
use manifest::{Scope, WorkerManifest};
use schemars::JsonSchema;
use serde::Deserialize;
use session_store::{SegmentId, SessionId, Store, collect_state};
use session_store::{SegmentId, SessionId, Store};
use uuid::Uuid;
use crate::feature::builtin::session_explore::{SessionExploreFeature, SessionExploreState};
@@ -469,10 +469,15 @@ where
.store
.read_all(self.session_id, self.segment_id)
.map_err(|error| format!("read committed parent session: {error}"))?;
let restored = collect_state(&entries);
Ok(SessionCapture::new(
let history = crate::session_history::restore_history_entries(
self.session_id,
self.segment_id,
&entries,
)?;
Ok(SessionCapture::from_history_entries(
self.session_id.to_string(),
self.segment_id.to_string(),
restored.history,
history,
))
}
}
@@ -457,6 +457,7 @@ impl MemoryLifecycleTask {
return Ok(());
}
let view = SessionCapture::from_history_entries(
capture.session_id.clone(),
capture.segment_id.clone(),
capture.history[history_start..history_end].to_vec(),
);
@@ -1556,11 +1557,14 @@ permission = "write"
#[tokio::test]
async fn run_committed_background_task_stages_non_empty_extraction_and_commits_pointer() {
let source = capture(2, 250);
let entry_ref =
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
.overview()[0]
.id
.to_string();
let entry_ref = SessionCapture::from_history_entries(
source.session_id.clone(),
source.segment_id.clone(),
source.history.clone(),
)
.overview()[0]
.id
.to_string();
let client = ScriptClient::new(vec![
stage_candidate_events("stage-1", &entry_ref),
finish_events("finish-1", 1),
@@ -1609,11 +1613,14 @@ permission = "write"
#[tokio::test]
async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() {
let source = capture(2, 250);
let entry_ref =
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
.overview()[0]
.id
.to_string();
let entry_ref = SessionCapture::from_history_entries(
source.session_id.clone(),
source.segment_id.clone(),
source.history.clone(),
)
.overview()[0]
.id
.to_string();
let client = ScriptClient::new(vec![
stage_candidate_events("stage-1", &entry_ref),
finish_events("finish-1", 1),
@@ -1715,11 +1722,14 @@ permission = "write"
#[tokio::test]
async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() {
let source = capture(2, 250);
let entry_ref =
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
.overview()[0]
.id
.to_string();
let entry_ref = SessionCapture::from_history_entries(
source.session_id.clone(),
source.segment_id.clone(),
source.history.clone(),
)
.overview()[0]
.id
.to_string();
let client = ScriptClient::new(vec![stage_candidate_events("stage-1", &entry_ref)]);
let extension_writes = Arc::new(Mutex::new(Vec::new()));
let (event_tx, _) = broadcast::channel(32);
@@ -510,7 +510,11 @@ mod tests {
fn state() -> MemoryStagingOutputState {
MemoryStagingOutputState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
SessionCapture::new(
"session",
"segment-1",
vec![Item::user_message("durable decision")],
),
crate::worker::marker_workspace_client(None, "test-backend"),
SourceRef {
segment_id: "segment-1".to_string(),
@@ -537,6 +541,7 @@ mod tests {
#[test]
fn render_input_uses_session_entry_refs_and_new_tool_names() {
let view = SessionCapture::new(
"session",
"segment-1",
vec![
Item::user_message("preference"),
@@ -586,7 +591,11 @@ mod tests {
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
let client = Arc::new(RecordingSubjektivClient::default());
let state = MemoryStagingOutputState::new_subjektiv(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
SessionCapture::new(
"session",
"segment-1",
vec![Item::user_message("durable decision")],
),
{
let mut host = crate::subjektiv::test_connection(client.clone());
host.context.session_id = "session-committed".into();
@@ -17,14 +17,14 @@ use crate::feature::{
ToolDeclaration,
};
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionCapture,
SessionEntryRef, ToolPart,
CapturePurpose, RawField, ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions,
SessionCapture, SessionEntryRef, ToolPart,
};
const SHOW_OVERVIEW_DESCRIPTION: &str =
"Show a sparse, bounded index of real user and assistant session entries.";
const SEARCH_ENTRIES_DESCRIPTION: &str = "Search or compactly list a bounded range of committed session entries. Reasoning entries are never exposed.";
const READ_ENTRY_DESCRIPTION: &str = "Read one committed session entry by stable SessionEntryRef. Compact mode is the default; full mode includes bounded tool input or output.";
const READ_ENTRY_DESCRIPTION: &str = "Read one committed session entry by stable SessionEntryRef. Compact mode is the default; full mode is a bounded rendering, not raw source. Raw mode requires raw_field and supports UTF-8 byte_offset/next_offset without omission; use capture_id to bind continuation to this exact capture.";
const DEFAULT_PAGE_LIMIT: usize = 20;
const MAX_PAGE_LIMIT: usize = 100;
const DEFAULT_READ_ITEMS: usize = 10;
@@ -173,6 +173,38 @@ struct ReadEntryParams {
mode: String,
#[serde(default)]
max_items: Option<usize>,
#[serde(default)]
raw_field: Option<RawEntryField>,
#[serde(default)]
part_index: Option<usize>,
#[serde(default)]
byte_offset: Option<usize>,
#[serde(default)]
max_bytes: Option<usize>,
#[serde(default)]
capture_id: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum RawEntryField {
MessagePart,
PublicSystem,
ToolArguments,
ToolSummary,
ToolContent,
}
impl RawEntryField {
fn into_common(self, part_index: usize) -> RawField {
match self {
Self::MessagePart => RawField::MessagePart(part_index),
Self::PublicSystem => RawField::PublicSystem,
Self::ToolArguments => RawField::ToolArguments,
Self::ToolSummary => RawField::ToolSummary,
Self::ToolContent => RawField::ToolContent,
}
}
}
fn default_read_mode() -> String {
@@ -243,25 +275,20 @@ impl Tool for SearchEntriesTool {
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
let view = self.state.view();
if let (Some(from), Some(through)) = (&from, &through) {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchEntries from must not be after through".to_string(),
));
}
}
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
let hits = view.search(&SearchOptions {
query: params.query,
kind,
tool_part,
tool_name: params.tool_name,
limit: Some(limit),
min_entry_index: None,
from,
through,
offset: params.offset,
});
let hits = view
.search_checked(&SearchOptions {
query: params.query,
kind,
tool_part,
tool_name: params.tool_name,
limit: Some(limit),
min_entry_index: None,
from,
through,
offset: params.offset,
})
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let entries = hits
.iter()
.map(|hit| {
@@ -272,6 +299,7 @@ impl Tool for SearchEntriesTool {
"tool_name": hit.tool_name,
"label": hit.label,
"text": hit.summary,
"text_kind": "preview",
})
})
.collect::<Vec<_>>();
@@ -299,12 +327,62 @@ impl Tool for ReadEntryTool {
) -> Result<ToolOutput, ToolError> {
let params: ReadEntryParams = parse_input("ReadEntry", input_json)?;
let entry_ref = parse_entry_ref(&params.entry_ref)?;
let view = self.state.view();
if params
.capture_id
.as_deref()
.is_some_and(|id| id != view.capture_id())
{
return Err(ToolError::InvalidArgument(
"capture identity does not match".into(),
));
}
if params.mode == "raw" {
let field = params
.raw_field
.ok_or_else(|| ToolError::InvalidArgument("raw mode requires raw_field".into()))?;
if params.part_index.is_some() && !matches!(field, RawEntryField::MessagePart) {
return Err(ToolError::InvalidArgument(
"part_index is only valid for message_part".into(),
));
}
let reference = view
.scoped_ref(&entry_ref)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let raw = view
.read_raw(
&reference,
CapturePurpose::Observation,
field.into_common(params.part_index.unwrap_or(0)),
params.byte_offset.unwrap_or(0),
params
.max_bytes
.unwrap_or(MAX_READ_BYTES)
.clamp(1, MAX_READ_BYTES),
)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
return json_output(
"Read raw session source bytes.".into(),
serde_json::json!({
"text_kind": "raw", "raw": raw,
}),
);
}
if params.raw_field.is_some()
|| params.part_index.is_some()
|| params.byte_offset.is_some()
|| params.max_bytes.is_some()
{
return Err(ToolError::InvalidArgument(
"raw fields require raw mode".into(),
));
}
let detail = match params.mode.as_str() {
"compact" => ReadDetail::Compact,
"full" => ReadDetail::Full,
other => {
return Err(ToolError::InvalidArgument(format!(
"invalid ReadEntry mode {other:?}; expected compact or full"
"invalid ReadEntry mode {other:?}; expected compact, full, or raw"
)));
}
};
@@ -347,6 +425,7 @@ impl Tool for ReadEntryTool {
serde_json::json!({
"entries": entries,
"truncated": read.truncated,
"text_kind": "rendered",
}),
)
}
@@ -408,6 +487,7 @@ mod tests {
fn state() -> SessionExploreState {
SessionExploreState::new(SessionCapture::new(
"session",
"segment-1",
vec![
Item::user_message("first"),
@@ -479,4 +559,64 @@ mod tests {
.unwrap();
assert!(output.content.unwrap().contains("second"));
}
#[tokio::test]
async fn search_rejects_unknown_or_reversed_capture_endpoints() {
let search = search_entries_definition(state())().1;
for input in [
r#"{"from":"E99999999"}"#,
r#"{"through":"E99999999"}"#,
r#"{"from":"E00000003","through":"E00000000"}"#,
] {
assert!(
search
.execute(input, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
}
}
#[tokio::test]
async fn raw_tool_arguments_reassemble_without_preview_or_utf8_loss() {
let arguments = "日本語\n\"\\".repeat(100);
let state = SessionExploreState::new(SessionCapture::new(
"session",
"segment",
vec![Item::tool_call("call", "Read", arguments.clone())],
));
let capture_id = state.view().capture_id().to_owned();
let tool = read_entry_definition(state)().1;
let mut offset = 0;
let mut assembled = String::new();
loop {
let input = serde_json::json!({
"entry_ref": "E00000000", "mode": "raw", "raw_field": "tool_arguments",
"byte_offset": offset, "max_bytes": 17, "capture_id": capture_id,
});
let output = tool
.execute(
&input.to_string(),
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
let value: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(value["text_kind"], "raw");
let raw = &value["raw"];
assert_eq!(raw["offset"], offset);
let text = raw["text"].as_str().unwrap();
assert!(text.len() <= 17);
assembled.push_str(text);
match raw["next_offset"].as_u64() {
Some(next) => {
assert!(next > offset as u64);
offset = next as usize;
}
None => break,
}
}
assert_eq!(assembled, arguments);
assert!(tool.execute(r#"{"entry_ref":"E00000000","mode":"raw","raw_field":"tool_arguments","capture_id":"other"}"#,
agen::tool::ToolExecutionContext::direct()).await.is_err());
}
}
@@ -39,13 +39,66 @@ use crate::hook::{
HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, HookPreRequestAction, PreLlmRequest,
PreRequestContext, RunCommitted, RunCommittedContext,
};
use crate::session_capture::{SessionCapture, SessionEntryEvidence};
use crate::session_capture::{CaptureBody, SessionCapture, SessionEntryEvidence};
use crate::subjektiv::SubjektivHostConnection;
#[cfg(test)]
use crate::worker::WorkspaceClient;
#[cfg(test)]
use server_api::SubjektivMemoryBackendRequest;
// Pending-commit receipt interpretation belongs to Memory, not the shared capture.
struct CommittedMemoryCall {
call_id: String,
name: String,
arguments: String,
evidence: SessionEntryEvidence,
}
fn committed_pending_tool_calls(view: &SessionCapture, names: &[&str]) -> Vec<CommittedMemoryCall> {
let pending: HashSet<_> = view
.entries()
.filter_map(|entry| {
let CaptureBody::ToolResult {
call_id,
content: Some(content),
is_error: false,
..
} = &entry.body
else {
return None;
};
(serde_json::from_str::<serde_json::Value>(content)
.ok()
.and_then(|value| value.get("status").cloned())
.as_ref()
.and_then(serde_json::Value::as_str)
== Some("pending_commit"))
.then_some(call_id.as_str())
})
.collect();
view.entries()
.filter_map(|entry| {
let CaptureBody::ToolCall {
call_id,
name,
arguments,
} = &entry.body
else {
return None;
};
if !names.contains(&name.as_str()) || !pending.contains(call_id.as_str()) {
return None;
}
Some(CommittedMemoryCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
evidence: view.evidence_for_tool_call(call_id)?,
})
})
.collect()
}
const QUERY_TOOL: &str = "SubjektivMemoryQuery";
const READ_TOOL: &str = "SubjektivMemoryRead";
const LIST_CHANGES_TOOL: &str = "SubjektivMemoryListChanges";
@@ -518,14 +571,18 @@ impl SubjektivMemoryState {
.capture()
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
let session_id = capture.session_id.clone();
let view = SessionCapture::from_history_entries(capture.segment_id, capture.history);
let committed = view
.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
.into_iter()
.any(|call| {
receipt_id(&session_id, &call.call_id)
.is_ok_and(|committed_receipt| committed_receipt == expected_receipt_id)
});
let view = SessionCapture::from_history_entries(
capture.session_id.clone(),
capture.segment_id,
capture.history,
);
let committed =
committed_pending_tool_calls(&view, &[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL])
.into_iter()
.any(|call| {
receipt_id(&session_id, &call.call_id)
.is_ok_and(|committed_receipt| committed_receipt == expected_receipt_id)
});
if committed {
status.status = SubjektivMemoryReceiptStatus::PendingCommit;
}
@@ -623,10 +680,13 @@ fn replay_pending_committed_memory_with_policy(
.capture
.capture()
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
let view =
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
let view = SessionCapture::from_history_entries(
capture.session_id.clone(),
capture.segment_id.clone(),
capture.history.clone(),
);
let mut failures = Vec::new();
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) {
for call in committed_pending_tool_calls(&view, &[REMEMBER_TOOL, PROPOSE_CHANGE_TOOL]) {
let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
Ok(value) => value,
Err(error) => {
@@ -755,8 +815,11 @@ fn resolve_entry_refs(
capture: &crate::feature::session::CommittedSessionCapture,
entry_refs: &[String],
) -> Result<Vec<SessionEntryEvidence>, ToolError> {
let view =
SessionCapture::from_history_entries(capture.segment_id.clone(), capture.history.clone());
let view = SessionCapture::from_history_entries(
capture.session_id.clone(),
capture.segment_id.clone(),
capture.history.clone(),
);
let mut seen = HashSet::new();
entry_refs
.iter()
@@ -570,15 +570,8 @@ impl Tool for SearchSessionEntriesTool {
let view = latest_view(&*self.provider, &params.subject).await?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(),
));
}
}
let entries = view
.search(&SearchOptions {
.search_checked(&SearchOptions {
query: params.query,
kind: params.kind.as_deref().map(parse_kind).transpose()?,
tool_part: params
@@ -593,6 +586,7 @@ impl Tool for SearchSessionEntriesTool {
through,
offset: params.offset,
})
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?
.into_iter()
.map(|entry| {
serde_json::json!({
@@ -604,6 +598,7 @@ impl Tool for SearchSessionEntriesTool {
"tool_name": entry.tool_name,
"label": entry.label,
"text": entry.summary,
"text_kind": "preview",
})
})
.collect::<Vec<_>>();
@@ -687,7 +682,10 @@ async fn latest_view(
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
// Observation providers expose a stable subject scope rather than a raw
// Session handle. This scope is never used to select recording material.
Ok(SessionCapture::from_session_snapshot(
format!("observation:{}", capture.segment_id),
capture.segment_id,
capture.session,
))
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -7501,6 +7501,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
crate::feature::builtin::session_explore::SessionExploreFeature::new(
crate::feature::builtin::session_explore::SessionExploreState::new(
crate::session_capture::SessionCapture::from_history_entries(
self.session_id().to_string(),
self.segment_id().to_string(),
entries_to_summarise.clone(),
),
@@ -17374,8 +17375,9 @@ permission = "write"
producer: "arbitrary-context-provider".into()
}
);
let view =
crate::session_capture::SessionCapture::from_history_entries("startup", replay);
let view = crate::session_capture::SessionCapture::from_history_entries(
"session", "startup", replay,
);
let extract =
crate::feature::builtin::memory_staging_output::render_extract_input(&view);
assert!(!extract.contains("PRIOR_JOURNAL_REFERENCE"));