fix: harden subject memory delivery
This commit is contained in:
@@ -30,6 +30,9 @@ pub enum ToolError {
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
/// Machine-readable domain conflict that a tool adapter may project as structured output.
|
||||
#[error("{code}: {message}")]
|
||||
StructuredConflict { code: String, message: String },
|
||||
/// Cooperative cancellation completed with bounded terminal output.
|
||||
#[error("Tool execution cancelled")]
|
||||
Cancelled(ToolOutput),
|
||||
|
||||
@@ -4619,6 +4619,12 @@ pub struct SubjektivMemoryValidateProposalRequest {
|
||||
pub intent: SubjektivMemoryRevisionIntent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivMemoryReceiptStatusRequest {
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
/// Host-resolved staging input. Subject scope and evidence origin are never
|
||||
/// accepted from model-visible tool arguments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -4646,6 +4652,7 @@ pub enum SubjektivMemoryBackendOperation {
|
||||
Read(SubjektivMemoryReadRequest),
|
||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||
ValidateProposal(SubjektivMemoryValidateProposalRequest),
|
||||
ReceiptStatus(SubjektivMemoryReceiptStatusRequest),
|
||||
StageExplicit(SubjektivMemoryStageExplicitRequest),
|
||||
}
|
||||
|
||||
@@ -4682,7 +4689,13 @@ pub struct SubjektivMemoryQueryResponse {
|
||||
pub struct SubjektivMemoryEvidenceCandidate {
|
||||
pub candidate_id: String,
|
||||
pub evidence: Vec<memory::extract::StagingEvidence>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub evidence_total: usize,
|
||||
pub evidence_truncated: bool,
|
||||
pub source_refs: Vec<memory::schema::SourceEvidenceRef>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub source_refs_total: usize,
|
||||
pub source_refs_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -4756,10 +4769,21 @@ pub struct SubjektivMemoryProposalValidationResponse {
|
||||
pub intent: SubjektivMemoryRevisionIntent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryReceiptStatus {
|
||||
PendingCommit,
|
||||
Staged,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivMemoryReceiptStatusResponse {
|
||||
pub receipt_id: String,
|
||||
pub status: SubjektivMemoryReceiptStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -4781,6 +4805,7 @@ pub enum SubjektivMemoryBackendResponse {
|
||||
Read(SubjektivMemoryReadResponse),
|
||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||
ProposalValidated(SubjektivMemoryProposalValidationResponse),
|
||||
ReceiptStatus(SubjektivMemoryReceiptStatusResponse),
|
||||
Staged(SubjektivMemoryStageExplicitResponse),
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
//! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
|
||||
//! range/search, read, and generic evidence projections without granting mutation authority.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||
@@ -207,6 +208,14 @@ pub(crate) struct ReadResult {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CommittedToolCall {
|
||||
pub call_id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
pub evidence: SessionEntryEvidence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionEntryEvidence {
|
||||
pub segment_id: String,
|
||||
@@ -622,6 +631,54 @@ impl SessionCapture {
|
||||
self.evidence_for(reference.id.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn committed_pending_tool_calls(&self, names: &[&str]) -> Vec<CommittedToolCall> {
|
||||
let pending_call_ids = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let Item::ToolResult {
|
||||
call_id,
|
||||
content: Some(content),
|
||||
is_error: false,
|
||||
..
|
||||
} = &entry.item
|
||||
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::<HashSet<_>>();
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} = &entry.item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !names.contains(&name.as_str()) || !pending_call_ids.contains(call_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some(CommittedToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
evidence: self.evidence_for_tool_call(call_id)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
|
||||
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
|
||||
let excerpt = self
|
||||
|
||||
@@ -16776,6 +16776,9 @@ const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100;
|
||||
const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200;
|
||||
const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000;
|
||||
const SUBJEKTIV_EVIDENCE_PAGE_SIZE: usize = 20;
|
||||
const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = 20;
|
||||
const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64;
|
||||
const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -16840,6 +16843,23 @@ async fn scoped_subjektiv_memory_backend(
|
||||
subjektiv_memory_validate_proposal(&store, &subject_id, input)?,
|
||||
)
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => {
|
||||
validate_subjektiv_receipt_id(&input.receipt_id)?;
|
||||
let candidate = store
|
||||
.staging_candidate(&subject_id, &input.receipt_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
server_api::SubjektivMemoryBackendResponse::ReceiptStatus(
|
||||
server_api::SubjektivMemoryReceiptStatusResponse {
|
||||
receipt_id: input.receipt_id,
|
||||
status: if candidate.is_some() {
|
||||
server_api::SubjektivMemoryReceiptStatus::Staged
|
||||
} else {
|
||||
server_api::SubjektivMemoryReceiptStatus::Missing
|
||||
},
|
||||
candidate_id: candidate.map(|record| record.id),
|
||||
},
|
||||
)
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => {
|
||||
// Re-derive the same subject and immutable Session attribution for the
|
||||
// write. The request never carries subject, Worker, Runtime, origin,
|
||||
@@ -16891,7 +16911,7 @@ fn subjektiv_memory_query(
|
||||
}
|
||||
if cursor.store_revision != subject.store_revision {
|
||||
return Err(Error::RepositoryConflict(
|
||||
"subjektiv query cursor is stale because Memory changed; restart the query".into(),
|
||||
"stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -16983,6 +17003,12 @@ fn subjektiv_memory_read(
|
||||
.into());
|
||||
}
|
||||
let body_offset = input.offset.unwrap_or(0);
|
||||
if body_offset > 0 && input.revision.is_none() {
|
||||
return Err(Error::InvalidInput(
|
||||
"body continuation requires the exact revision returned by the first read".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let lines = memory_body_lines(&record.body_md);
|
||||
if body_offset > lines.len() {
|
||||
return Err(Error::InvalidInput("body line offset exceeds document length".into()).into());
|
||||
@@ -17021,6 +17047,8 @@ fn subjektiv_memory_read(
|
||||
let mut source_candidate_ids = Vec::new();
|
||||
let mut source_candidates = Vec::new();
|
||||
let mut derived_from = Vec::new();
|
||||
let mut evidence_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT;
|
||||
let mut source_ref_budget = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT;
|
||||
for index in evidence_offset..evidence_end {
|
||||
if let Some(candidate_id) = record.source_candidate_ids.get(index) {
|
||||
let candidate = store
|
||||
@@ -17032,10 +17060,30 @@ fn subjektiv_memory_read(
|
||||
))
|
||||
})?;
|
||||
source_candidate_ids.push(candidate_id.clone());
|
||||
let evidence_total = candidate.evidence.len();
|
||||
let source_refs_total = candidate.source_refs.len();
|
||||
let evidence_take = evidence_total.min(evidence_budget);
|
||||
let source_refs_take = source_refs_total.min(source_ref_budget);
|
||||
evidence_budget -= evidence_take;
|
||||
source_ref_budget -= source_refs_take;
|
||||
source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate {
|
||||
candidate_id: candidate.id,
|
||||
evidence: candidate.evidence,
|
||||
source_refs: candidate.source_refs,
|
||||
evidence: candidate
|
||||
.evidence
|
||||
.into_iter()
|
||||
.take(evidence_take)
|
||||
.map(bounded_subjektiv_staging_evidence)
|
||||
.collect(),
|
||||
evidence_total,
|
||||
evidence_truncated: evidence_total > evidence_take,
|
||||
source_refs: candidate
|
||||
.source_refs
|
||||
.into_iter()
|
||||
.take(source_refs_take)
|
||||
.map(bounded_subjektiv_source_ref)
|
||||
.collect(),
|
||||
source_refs_total,
|
||||
source_refs_truncated: source_refs_total > source_refs_take,
|
||||
});
|
||||
} else {
|
||||
let derived_index = index - record.source_candidate_ids.len();
|
||||
@@ -17046,6 +17094,7 @@ fn subjektiv_memory_read(
|
||||
});
|
||||
}
|
||||
}
|
||||
bound_subjektiv_nested_evidence(&mut source_candidates)?;
|
||||
let evidence_has_more = evidence_end < total_evidence;
|
||||
let evidence_next_cursor = evidence_has_more
|
||||
.then(|| {
|
||||
@@ -17180,21 +17229,35 @@ fn subjektiv_memory_validate_proposal(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_subjektiv_receipt_id(receipt_id: &str) -> ApiResult<()> {
|
||||
let receipt_id = receipt_id.trim();
|
||||
if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subjektiv_memory_stage_explicit(
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
mut input: server_api::SubjektivMemoryStageExplicitRequest,
|
||||
attribution: crate::subjektiv::SubjectSessionAttribution,
|
||||
) -> ApiResult<server_api::SubjektivMemoryStageExplicitResponse> {
|
||||
validate_subjektiv_receipt_id(&input.receipt_id)?;
|
||||
let receipt_id = input.receipt_id.trim();
|
||||
if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) {
|
||||
return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into());
|
||||
}
|
||||
if input.evidence.is_empty() || input.source_refs.is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"explicit Memory staging requires host-resolved committed evidence".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if input.evidence.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT
|
||||
|| input.source_refs.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT
|
||||
{
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"explicit Memory evidence/source_refs are limited to {SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT} items each"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if matches!(input.kind, memory::extract::CandidateKind::Preference)
|
||||
&& input.evidence.iter().any(|evidence| {
|
||||
!matches!(
|
||||
@@ -17454,6 +17517,103 @@ fn memory_body_lines(body: &str) -> Vec<&str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn bound_subjektiv_nested_evidence(
|
||||
candidates: &mut [server_api::SubjektivMemoryEvidenceCandidate],
|
||||
) -> ApiResult<()> {
|
||||
loop {
|
||||
let serialized = serde_json::to_vec(candidates)
|
||||
.map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?;
|
||||
if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let mut removed = false;
|
||||
for candidate in candidates.iter_mut().rev() {
|
||||
if candidate.source_refs.pop().is_some() {
|
||||
candidate.source_refs_truncated = true;
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
if candidate.evidence.pop().is_some() {
|
||||
candidate.evidence_truncated = true;
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
return Err(Error::Store(
|
||||
"subjektiv evidence metadata exceeds its response byte budget".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_subjektiv_staging_evidence(
|
||||
mut evidence: memory::extract::StagingEvidence,
|
||||
) -> memory::extract::StagingEvidence {
|
||||
evidence.kind = memory::schema::EvidenceKind::new(bounded_utf8_bytes(
|
||||
evidence.kind.as_str().to_string(),
|
||||
SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES,
|
||||
));
|
||||
evidence.origin = evidence.origin.map(bounded_subjektiv_evidence_origin);
|
||||
evidence.excerpt = evidence
|
||||
.excerpt
|
||||
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
evidence.summary = evidence
|
||||
.summary
|
||||
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
evidence
|
||||
}
|
||||
|
||||
fn bounded_subjektiv_source_ref(
|
||||
mut source: memory::schema::SourceEvidenceRef,
|
||||
) -> memory::schema::SourceEvidenceRef {
|
||||
source.origin = source.origin.map(bounded_subjektiv_evidence_origin);
|
||||
source.evidence_kind = source.evidence_kind.map(|kind| {
|
||||
memory::schema::EvidenceKind::new(bounded_utf8_bytes(
|
||||
kind.as_str().to_string(),
|
||||
SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES,
|
||||
))
|
||||
});
|
||||
source.label = source
|
||||
.label
|
||||
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
source.summary = source
|
||||
.summary
|
||||
.map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
source
|
||||
}
|
||||
|
||||
fn bounded_subjektiv_evidence_origin(
|
||||
mut origin: memory::schema::EvidenceOrigin,
|
||||
) -> memory::schema::EvidenceOrigin {
|
||||
for field in [
|
||||
&mut origin.account_id,
|
||||
&mut origin.workspace_id,
|
||||
&mut origin.runtime_id,
|
||||
&mut origin.worker_id,
|
||||
&mut origin.flow_selector,
|
||||
&mut origin.flow_definition_id,
|
||||
] {
|
||||
if let Some(value) = field.take() {
|
||||
*field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES));
|
||||
}
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String {
|
||||
if value.len() <= max_bytes {
|
||||
return value;
|
||||
}
|
||||
let marker = "…";
|
||||
let mut end = max_bytes.saturating_sub(marker.len());
|
||||
while end > 0 && !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}{marker}", &value[..end])
|
||||
}
|
||||
|
||||
fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String {
|
||||
let normalized = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let mut chars = normalized.chars();
|
||||
@@ -28772,12 +28932,27 @@ impl From<Error> for ApiError {
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(&ticket_error.to_string()),
|
||||
}],
|
||||
Error::RepositoryConflict(message) => subjektiv_conflict_code(message)
|
||||
.map(|code| {
|
||||
vec![RuntimeDiagnostic {
|
||||
code: code.to_string(),
|
||||
severity: HostDiagnosticSeverity::Error,
|
||||
message: sanitize_backend_error(message),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Self { error, diagnostics }
|
||||
}
|
||||
}
|
||||
|
||||
fn subjektiv_conflict_code(message: &str) -> Option<&'static str> {
|
||||
["revision_conflict", "stale_cursor"]
|
||||
.into_iter()
|
||||
.find(|code| message.starts_with(code))
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
|
||||
Self { error, diagnostics }
|
||||
@@ -34968,12 +35143,46 @@ mod tests {
|
||||
excerpt: Some("Correct the first rule".to_string()),
|
||||
summary: Some("Human correction".to_string()),
|
||||
};
|
||||
let oversized = scoped_subjektiv_memory_backend(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context.clone(),
|
||||
Json(server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::StageExplicit(
|
||||
server_api::SubjektivMemoryStageExplicitRequest {
|
||||
receipt_id: "explicit:session-1:oversized".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
kind: memory::extract::CandidateKind::Decision,
|
||||
claim: "Too many anchors".to_string(),
|
||||
why_useful: "Prove response bounds".to_string(),
|
||||
staleness: None,
|
||||
evidence: vec![
|
||||
explicit_evidence.clone();
|
||||
SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 1
|
||||
],
|
||||
source_refs: vec![memory::schema::SourceEvidenceRef {
|
||||
segment_id: Some("segment-1".to_string()),
|
||||
entry_range: Some([0, 0]),
|
||||
evidence_id: Some("E00000000".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
proposal: None,
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(oversized.error, Error::InvalidInput(_)));
|
||||
|
||||
let Json(explicit) = scoped_subjektiv_memory_backend(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context,
|
||||
context.clone(),
|
||||
Json(server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::StageExplicit(
|
||||
server_api::SubjektivMemoryStageExplicitRequest {
|
||||
@@ -35007,6 +35216,34 @@ mod tests {
|
||||
other => panic!("unexpected explicit response: {other:?}"),
|
||||
};
|
||||
assert_eq!(staged.candidate_id, "explicit:session-1:call-1");
|
||||
let Json(status) = scoped_subjektiv_memory_backend(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context,
|
||||
Json(server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::ReceiptStatus(
|
||||
server_api::SubjektivMemoryReceiptStatusRequest {
|
||||
receipt_id: staged.receipt_id.clone(),
|
||||
},
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = match status {
|
||||
server_api::SubjektivMemoryBackendResponse::ReceiptStatus(status) => status,
|
||||
other => panic!("unexpected receipt response: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
status.status,
|
||||
server_api::SubjektivMemoryReceiptStatus::Staged
|
||||
);
|
||||
assert_eq!(
|
||||
status.candidate_id.as_deref(),
|
||||
Some(staged.candidate_id.as_str())
|
||||
);
|
||||
let candidate = store
|
||||
.staging_candidate(&subject.id, &staged.candidate_id)
|
||||
.unwrap()
|
||||
@@ -35067,6 +35304,18 @@ mod tests {
|
||||
store
|
||||
.revise_memory(&subject.id, &second.id, 1, resolved)
|
||||
.unwrap();
|
||||
let revision_conflict = subjektiv_memory_validate_proposal(
|
||||
&store,
|
||||
&subject.id,
|
||||
server_api::SubjektivMemoryValidateProposalRequest {
|
||||
memory_id: second.id.clone(),
|
||||
expected_revision: 1,
|
||||
intent: server_api::SubjektivMemoryRevisionIntent::Revise,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
let revision_api = ApiError::from(revision_conflict.error).into_repository_api_error();
|
||||
assert_eq!(revision_api.diagnostics[0].code, "revision_conflict");
|
||||
|
||||
let default_page = subjektiv_memory_query(
|
||||
&store,
|
||||
@@ -35142,7 +35391,51 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(stale.error, Error::RepositoryConflict(_)));
|
||||
assert!(matches!(&stale.error, Error::RepositoryConflict(_)));
|
||||
let stale_api = ApiError::from(stale.error).into_repository_api_error();
|
||||
assert_eq!(stale_api.diagnostics[0].code, "stale_cursor");
|
||||
|
||||
let initial_read = subjektiv_memory_read(
|
||||
&store,
|
||||
&subject.id,
|
||||
server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: first.id.clone(),
|
||||
revision: None,
|
||||
offset: None,
|
||||
limit: Some(1),
|
||||
evidence_cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(initial_read.revision, 1);
|
||||
assert_eq!(initial_read.body_next_offset, Some(1));
|
||||
store
|
||||
.revise_memory(
|
||||
&subject.id,
|
||||
&first.id,
|
||||
1,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Decision,
|
||||
"Choose stable cursors",
|
||||
"replacement one\nreplacement two\n",
|
||||
"Prevents mixed snapshots",
|
||||
"replace current body",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let unbound_continuation = subjektiv_memory_read(
|
||||
&store,
|
||||
&subject.id,
|
||||
server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: first.id.clone(),
|
||||
revision: None,
|
||||
offset: initial_read.body_next_offset,
|
||||
limit: Some(1),
|
||||
evidence_cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(unbound_continuation.error, Error::InvalidInput(_)));
|
||||
|
||||
let read = subjektiv_memory_read(
|
||||
&store,
|
||||
@@ -35160,7 +35453,147 @@ mod tests {
|
||||
assert_eq!(read.body_next_offset, Some(2));
|
||||
assert!(read.body_truncated);
|
||||
assert_eq!(read.revision, 1);
|
||||
assert_eq!(read.current_revision, 1);
|
||||
assert_eq!(read.current_revision, 2);
|
||||
|
||||
let nested_count = SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + 5;
|
||||
let nested_evidence = (0..nested_count)
|
||||
.map(|index| memory::extract::StagingEvidence {
|
||||
id: format!("nested-{index}-{}", "i".repeat(220)),
|
||||
kind: memory::schema::EvidenceKind::new(format!(
|
||||
"custom-{index}-{}",
|
||||
"k".repeat(4_000)
|
||||
)),
|
||||
entry_range: Some([index as u64, index as u64]),
|
||||
origin: Some(memory::schema::EvidenceOrigin {
|
||||
kind: memory::schema::EvidenceOriginKind::ModelOutput,
|
||||
account_id: Some(format!("account-{index}-{}", "a".repeat(4_000))),
|
||||
workspace_id: None,
|
||||
runtime_id: Some(format!("runtime-{index}-{}", "r".repeat(4_000))),
|
||||
worker_id: Some(format!("worker-{index}-{}", "w".repeat(4_000))),
|
||||
flow_selector: Some(format!("flow-{index}-{}", "f".repeat(4_000))),
|
||||
flow_definition_id: Some(format!("definition-{index}-{}", "d".repeat(4_000))),
|
||||
flow_definition_revision: None,
|
||||
}),
|
||||
excerpt: Some(format!("evidence-{index}-{}", "x".repeat(4_000))),
|
||||
summary: Some(format!("evidence-summary-{index}-{}", "q".repeat(4_000))),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let nested_refs = (0..nested_count)
|
||||
.map(|index| memory::schema::SourceEvidenceRef {
|
||||
session_id: Some(format!("session-{index}-{}", "u".repeat(220))),
|
||||
segment_id: Some(format!("segment-{index}-{}", "g".repeat(220))),
|
||||
entry_range: Some([index as u64, index as u64]),
|
||||
evidence_id: Some(format!("nested-{index}-{}", "i".repeat(220))),
|
||||
evidence_kind: Some(memory::schema::EvidenceKind::new(format!(
|
||||
"source-custom-{index}-{}",
|
||||
"s".repeat(4_000)
|
||||
))),
|
||||
label: Some(format!("source-{index}-{}", "y".repeat(4_000))),
|
||||
summary: Some(format!("summary-{index}-{}", "z".repeat(4_000))),
|
||||
origin: Some(memory::schema::EvidenceOrigin {
|
||||
kind: memory::schema::EvidenceOriginKind::ModelOutput,
|
||||
account_id: Some(format!("source-account-{index}-{}", "a".repeat(4_000))),
|
||||
workspace_id: None,
|
||||
runtime_id: Some(format!("source-runtime-{index}-{}", "r".repeat(4_000))),
|
||||
worker_id: Some(format!("source-worker-{index}-{}", "w".repeat(4_000))),
|
||||
flow_selector: Some(format!("source-flow-{index}-{}", "f".repeat(4_000))),
|
||||
flow_definition_id: Some(format!(
|
||||
"source-definition-{index}-{}",
|
||||
"d".repeat(4_000)
|
||||
)),
|
||||
flow_definition_revision: Some(index as u64),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let nested_candidate = memory::extract::StagingRecord::from_candidate(
|
||||
"candidate-nested",
|
||||
"nested-dedup",
|
||||
memory::schema::SourceRef {
|
||||
segment_id: "segment-nested".to_string(),
|
||||
range: [0, nested_count as u64 - 1],
|
||||
},
|
||||
memory::extract::ExtractedCandidate {
|
||||
kind: memory::extract::CandidateKind::Lesson,
|
||||
claim: "Keep nested provenance bounded".to_string(),
|
||||
why_useful: "Prevents unbounded read responses".to_string(),
|
||||
staleness: None,
|
||||
evidence_ids: nested_evidence.iter().map(|item| item.id.clone()).collect(),
|
||||
},
|
||||
nested_evidence,
|
||||
nested_refs,
|
||||
);
|
||||
let nested_candidate = store
|
||||
.stage_candidate(crate::subjektiv::SubjectStagingRecord::attach(
|
||||
&subject.id,
|
||||
nested_candidate,
|
||||
))
|
||||
.unwrap();
|
||||
let (nested_memory, _) = store
|
||||
.apply_candidate(
|
||||
&subject.id,
|
||||
&nested_candidate.id,
|
||||
crate::subjektiv::MemoryRevisionTarget::Create,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Lesson,
|
||||
"Keep nested provenance bounded",
|
||||
"bounded body",
|
||||
"Prevents unbounded read responses",
|
||||
"adopt oversized historical candidate",
|
||||
),
|
||||
"adopt for response-bound regression",
|
||||
)
|
||||
.unwrap();
|
||||
let nested_read = subjektiv_memory_read(
|
||||
&store,
|
||||
&subject.id,
|
||||
server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: nested_memory.id,
|
||||
revision: Some(1),
|
||||
offset: None,
|
||||
limit: None,
|
||||
evidence_cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(nested_read.source_candidates.len(), 1);
|
||||
let nested = &nested_read.source_candidates[0];
|
||||
assert_eq!(nested.evidence.len(), SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
||||
assert_eq!(nested.evidence_total, nested_count);
|
||||
assert!(nested.evidence_truncated);
|
||||
assert!(!nested.source_refs.is_empty());
|
||||
assert!(nested.source_refs.len() <= SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT);
|
||||
assert_eq!(nested.source_refs_total, nested_count);
|
||||
assert!(nested.source_refs_truncated);
|
||||
assert!(nested.evidence.iter().all(|item| {
|
||||
item.excerpt
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
|
||||
}));
|
||||
assert!(nested.evidence.iter().all(|item| {
|
||||
item.kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES
|
||||
&& item
|
||||
.origin
|
||||
.as_ref()
|
||||
.and_then(|origin| origin.worker_id.as_ref())
|
||||
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
|
||||
}));
|
||||
assert!(nested.source_refs.iter().all(|item| {
|
||||
item.label
|
||||
.as_ref()
|
||||
.is_none_or(|value| value.len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
|
||||
&& item
|
||||
.evidence_kind
|
||||
.as_ref()
|
||||
.is_none_or(|kind| kind.as_str().len() <= SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)
|
||||
}));
|
||||
assert!(
|
||||
serde_json::to_vec(&nested_read.source_candidates)
|
||||
.unwrap()
|
||||
.len()
|
||||
<= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES
|
||||
);
|
||||
assert!(serde_json::to_vec(&nested_read).unwrap().len() < 64 * 1024);
|
||||
|
||||
let revisions = subjektiv_memory_list_revisions(
|
||||
&store,
|
||||
|
||||
@@ -175,28 +175,41 @@ empty `states` or `kinds` is invalid. Output defaults to 20 and is capped at 100
|
||||
ordered by `updated_at DESC, memory_id ASC`. Its opaque cursor binds the subject,
|
||||
canonical filters, offset, and subject `store_revision`; any confirmed-Memory
|
||||
change makes it a typed stale-cursor conflict rather than silently mixing
|
||||
snapshots. Revision history is ordered by revision descending. Its first page
|
||||
snapshots. Revision conflicts and stale cursors retain their diagnostic code as a
|
||||
structured `{status: "error", error: {code, message}}` tool result instead of
|
||||
requiring models to parse prose. Revision history is ordered by revision
|
||||
descending. Its first page
|
||||
fixes the maximum revision, so later revisions neither duplicate nor displace old
|
||||
page members.
|
||||
|
||||
Read accepts a positive exact revision or resolves the current revision once. A
|
||||
missing historical revision never falls back to current. Markdown pagination is
|
||||
line-based (default 200, maximum 1000) and reports `body_truncated` plus
|
||||
`body_next_offset`. Provenance uses a separate immutable-revision-bound cursor and
|
||||
returns at most 20 candidate/derivation references per page. Candidate evidence is
|
||||
the bounded host-resolved anchor saved in staging; raw Session bodies are not
|
||||
copied into Memory responses. Resolved, retracted, and historical revisions remain
|
||||
`body_next_offset`; every nonzero continuation offset must also supply the exact
|
||||
revision returned by the first page, so a current-revision change cannot mix body
|
||||
versions. Provenance uses a separate immutable-revision-bound cursor and returns at
|
||||
most 20 candidate/derivation references per page. Expanded candidates share one
|
||||
page-wide budget of 20 evidence records and 20 source references, plus a hard 32 KiB
|
||||
serialized budget for the expanded candidate array; displayed anchor text is capped
|
||||
at 64 UTF-8 bytes and total/truncation metadata reports omissions.
|
||||
Explicit staging rejects larger anchor sets. Candidate evidence is the bounded host-resolved anchor saved in staging; raw Session bodies are not copied
|
||||
into Memory responses. Resolved, retracted, and historical revisions remain
|
||||
addressable by ID.
|
||||
|
||||
Remember and ProposeRevision only stage candidates. Entry references are resolved
|
||||
against the host's committed Session capture, and preference candidates continue
|
||||
to require exclusively `HumanInput` evidence. When no entry is supplied, a
|
||||
non-preference request returns `pending_commit`; after the run commits, the host
|
||||
uses the committed tool-call entry itself as model-origin evidence. An operation
|
||||
that never commits is not staged. Receipt identity is derived from Session and
|
||||
tool-call identity, and exact backend retries return the first candidate rather
|
||||
than creating another one. Neither path changes `memory_records`,
|
||||
`memory_revisions`, `store_revision`, or a surface snapshot.
|
||||
reconstructs pending receipts from the durable tool-call/tool-result history and
|
||||
uses the committed tool-call entry itself as model-origin evidence. Receipt lookup
|
||||
reports `pending_commit`, `staged`, or `missing`; the post-commit hook retries
|
||||
immediately, the pre-request hook replays durable pending receipts after Worker
|
||||
restore, and the fail-closed pre-rewrite hook prevents compaction from dropping an
|
||||
unstaged receipt. Hook failure and process restart therefore retain an idempotent
|
||||
retry path, while an operation that never commits is not staged. Receipt identity is derived from Session and tool-call identity,
|
||||
and exact backend retries return the first candidate rather than creating another
|
||||
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
|
||||
or a surface snapshot.
|
||||
|
||||
Revision proposals add optional `revision_proposal` metadata to the existing v2
|
||||
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
|
||||
|
||||
Reference in New Issue
Block a user