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),
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! become immutable staging candidates; T-670 remains the only authority that may
|
||||
//! adopt a candidate as a confirmed revision.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -15,7 +15,8 @@ use serde::Deserialize;
|
||||
use server_api::{
|
||||
SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse,
|
||||
SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest,
|
||||
SubjektivMemoryReceiptStatus, SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
|
||||
SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest,
|
||||
SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal,
|
||||
SubjektivMemoryStageExplicitRequest, SubjektivMemoryStageExplicitResponse,
|
||||
SubjektivMemoryValidateProposalRequest,
|
||||
};
|
||||
@@ -27,8 +28,9 @@ use crate::feature::{
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, RunCommitted,
|
||||
RunCommittedContext,
|
||||
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
|
||||
HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, HookPreRequestAction, PreLlmRequest,
|
||||
PreRequestContext, RunCommitted, RunCommittedContext,
|
||||
};
|
||||
use crate::session_capture::{SessionCapture, SessionEntryEvidence};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
|
||||
@@ -39,6 +41,9 @@ const LIST_REVISIONS_TOOL: &str = "SubjektivMemoryListRevisions";
|
||||
const REMEMBER_TOOL: &str = "SubjektivMemoryRemember";
|
||||
const PROPOSE_REVISION_TOOL: &str = "SubjektivMemoryProposeRevision";
|
||||
const COMMIT_HOOK: &str = "stage-explicit-memory-after-commit";
|
||||
const RETRY_HOOK: &str = "retry-committed-explicit-memory";
|
||||
const REWRITE_HOOK: &str = "flush-explicit-memory-before-rewrite";
|
||||
const MAX_EVIDENCE_REFS: usize = 20;
|
||||
|
||||
const QUERY_DESCRIPTION: &str = "Search the connected subject's current Memory revisions. Omitted states means active only; results are bounded and cursors are bound to the subject, filters, order, and store snapshot.";
|
||||
const READ_DESCRIPTION: &str = "Read one current or immutable historical Memory revision for the connected subject, with line-bounded Markdown and paged provenance anchors. Continue partial reads using the returned exact revision.";
|
||||
@@ -55,13 +60,11 @@ pub(crate) struct SubjektivMemoryFeature {
|
||||
struct SubjektivMemoryState {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
pending: Arc<Mutex<HashMap<String, PendingExplicit>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PendingExplicit {
|
||||
receipt_id: String,
|
||||
call_id: String,
|
||||
kind: CandidateKind,
|
||||
claim: String,
|
||||
why_useful: String,
|
||||
@@ -88,11 +91,7 @@ impl SubjektivMemoryFeature {
|
||||
));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
state: SubjektivMemoryState {
|
||||
client,
|
||||
capture,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
},
|
||||
state: SubjektivMemoryState { client, capture },
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -116,6 +115,14 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
COMMIT_HOOK,
|
||||
FeatureHookPoint::RunCommitted,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
RETRY_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
REWRITE_HOOK,
|
||||
FeatureHookPoint::BeforeSessionRewrite,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -157,6 +164,19 @@ impl FeatureModule for SubjektivMemoryFeature {
|
||||
PendingCommitHook {
|
||||
state: self.state.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_request(
|
||||
RETRY_HOOK,
|
||||
PendingRetryHook {
|
||||
state: self.state.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_before_session_rewrite(
|
||||
REWRITE_HOOK,
|
||||
HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 30_000),
|
||||
PendingRewriteHook {
|
||||
state: self.state.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +237,10 @@ impl Tool for SubjektivReadTool {
|
||||
LIST_REVISIONS_TOOL,
|
||||
)?),
|
||||
};
|
||||
let response = self.state.execute(operation)?;
|
||||
let response = match self.state.execute(operation) {
|
||||
Ok(response) => response,
|
||||
Err(error) => return structured_conflict_output(error),
|
||||
};
|
||||
let (summary, value) = match response {
|
||||
SubjektivMemoryBackendResponse::Query(value) => (
|
||||
format!("Found {} Memory item(s).", value.items.len()),
|
||||
@@ -346,14 +369,18 @@ impl Tool for SubjektivExplicitTool {
|
||||
));
|
||||
}
|
||||
let validation =
|
||||
self.state
|
||||
match self
|
||||
.state
|
||||
.execute(SubjektivMemoryBackendOperation::ValidateProposal(
|
||||
SubjektivMemoryValidateProposalRequest {
|
||||
memory_id: params.memory_id.clone(),
|
||||
expected_revision: params.expected_revision,
|
||||
intent: params.intent,
|
||||
},
|
||||
))?;
|
||||
)) {
|
||||
Ok(response) => response,
|
||||
Err(error) => return structured_conflict_output(error),
|
||||
};
|
||||
let validated = match validation {
|
||||
SubjektivMemoryBackendResponse::ProposalValidated(value) => value,
|
||||
other => {
|
||||
@@ -379,6 +406,11 @@ impl Tool for SubjektivExplicitTool {
|
||||
}
|
||||
};
|
||||
validate_candidate_text(&claim, &why_useful)?;
|
||||
if entry_refs.len() > MAX_EVIDENCE_REFS {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"entry_refs is limited to {MAX_EVIDENCE_REFS} committed references"
|
||||
)));
|
||||
}
|
||||
if proposal
|
||||
.as_ref()
|
||||
.is_some_and(|proposal| proposal.change_reason.trim().is_empty())
|
||||
@@ -395,28 +427,11 @@ impl Tool for SubjektivExplicitTool {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let pending = PendingExplicit {
|
||||
receipt_id: receipt_id.clone(),
|
||||
call_id,
|
||||
kind,
|
||||
claim,
|
||||
why_useful,
|
||||
staleness,
|
||||
proposal: proposal.clone(),
|
||||
};
|
||||
let mut queue = self.state.pending.lock().map_err(|_| {
|
||||
ToolError::ExecutionFailed("subjektiv pending queue poisoned".into())
|
||||
})?;
|
||||
match queue.get(&receipt_id) {
|
||||
Some(existing) if !pending_equivalent(existing, &pending) => {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"this tool operation was retried with different content".into(),
|
||||
));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
queue.insert(receipt_id.clone(), pending);
|
||||
}
|
||||
let status = self.state.receipt_status(&receipt_id)?;
|
||||
if status.status == SubjektivMemoryReceiptStatus::Staged {
|
||||
let value = serde_json::to_value(status)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
return json_output("Explicit Memory candidate is already staged.", &value);
|
||||
}
|
||||
let value = serde_json::json!({
|
||||
"receipt_id": receipt_id,
|
||||
@@ -436,7 +451,7 @@ impl Tool for SubjektivExplicitTool {
|
||||
|
||||
let entries = resolve_entry_refs(&capture, &entry_refs)?;
|
||||
ensure_preference_evidence(&kind, &entries)?;
|
||||
let response = self.state.stage(
|
||||
let response = match self.state.stage(
|
||||
&receipt_id,
|
||||
&capture.session_id,
|
||||
kind,
|
||||
@@ -445,7 +460,10 @@ impl Tool for SubjektivExplicitTool {
|
||||
staleness,
|
||||
entries,
|
||||
proposal,
|
||||
)?;
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => return structured_conflict_output(error),
|
||||
};
|
||||
staged_output(response)
|
||||
}
|
||||
}
|
||||
@@ -462,9 +480,31 @@ impl SubjektivMemoryState {
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
let parsed =
|
||||
serde_json::from_str::<server_api::RepositoryApiError>(&response.body).ok();
|
||||
if let Some((code, message)) = parsed.as_ref().and_then(|error| {
|
||||
error
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| {
|
||||
matches!(
|
||||
diagnostic.code.as_str(),
|
||||
"revision_conflict" | "stale_cursor"
|
||||
)
|
||||
})
|
||||
.map(|diagnostic| (diagnostic.code.as_str(), error.message.as_str()))
|
||||
}) {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
let detail = parsed
|
||||
.map(|error| error.message)
|
||||
.unwrap_or_else(|| response.body.clone());
|
||||
let message = format!(
|
||||
"subjektiv Memory backend returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
"subjektiv Memory backend returned HTTP {}: {detail}",
|
||||
response.status
|
||||
);
|
||||
return if matches!(response.status, 400 | 404 | 409 | 422) {
|
||||
Err(ToolError::InvalidArgument(message))
|
||||
@@ -477,6 +517,48 @@ impl SubjektivMemoryState {
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_receipt_status(
|
||||
&self,
|
||||
expected_receipt_id: &str,
|
||||
) -> Result<server_api::SubjektivMemoryReceiptStatusResponse, ToolError> {
|
||||
match self.execute(SubjektivMemoryBackendOperation::ReceiptStatus(
|
||||
SubjektivMemoryReceiptStatusRequest {
|
||||
receipt_id: expected_receipt_id.to_string(),
|
||||
},
|
||||
))? {
|
||||
SubjektivMemoryBackendResponse::ReceiptStatus(value) => Ok(value),
|
||||
other => Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected receipt status response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt_status(
|
||||
&self,
|
||||
expected_receipt_id: &str,
|
||||
) -> Result<server_api::SubjektivMemoryReceiptStatusResponse, ToolError> {
|
||||
let mut status = self.backend_receipt_status(expected_receipt_id)?;
|
||||
if status.status == SubjektivMemoryReceiptStatus::Missing {
|
||||
let capture = self
|
||||
.capture
|
||||
.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_REVISION_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;
|
||||
}
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn stage(
|
||||
&self,
|
||||
@@ -525,49 +607,81 @@ struct PendingCommitHook {
|
||||
#[async_trait]
|
||||
impl Hook<RunCommitted> for PendingCommitHook {
|
||||
async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> {
|
||||
let capture =
|
||||
self.state.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 pending = self
|
||||
.state
|
||||
.pending
|
||||
.lock()
|
||||
.map_err(|_| HookError::new(HookErrorCategory::Internal, "pending queue poisoned"))?
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut completed = Vec::new();
|
||||
replay_pending_committed_memory(&self.state)
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingRetryHook {
|
||||
state: SubjektivMemoryState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for PendingRetryHook {
|
||||
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||
replay_pending_committed_memory(&self.state)?;
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingRewriteHook {
|
||||
state: SubjektivMemoryState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<BeforeSessionRewrite> for PendingRewriteHook {
|
||||
async fn call(
|
||||
&self,
|
||||
_input: &BeforeSessionRewriteContext,
|
||||
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||
replay_pending_committed_memory(&self.state)?;
|
||||
Ok(BeforeSessionRewriteAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), HookError> {
|
||||
let capture = state
|
||||
.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 mut failures = Vec::new();
|
||||
for item in pending {
|
||||
let Some(evidence) = view.evidence_for_tool_call(&item.call_id) else {
|
||||
// The operation belongs to an uncommitted or different Session.
|
||||
for call in view.committed_pending_tool_calls(&[REMEMBER_TOOL, PROPOSE_REVISION_TOOL]) {
|
||||
let receipt_id = match receipt_id(&capture.session_id, &call.call_id) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
failures.push(error.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.state.stage(
|
||||
match state.backend_receipt_status(&receipt_id) {
|
||||
Ok(status) if status.status == SubjektivMemoryReceiptStatus::Staged => continue,
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
failures.push(format!("{receipt_id}: {error}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let item =
|
||||
match pending_from_committed_call(state, &receipt_id, &call.name, &call.arguments) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
failures.push(format!("{receipt_id}: {error}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = state.stage(
|
||||
&item.receipt_id,
|
||||
&capture.session_id,
|
||||
item.kind.clone(),
|
||||
item.kind,
|
||||
item.claim,
|
||||
item.why_useful,
|
||||
item.staleness,
|
||||
vec![evidence],
|
||||
vec![call.evidence],
|
||||
item.proposal,
|
||||
) {
|
||||
Ok(_) => completed.push(item.receipt_id),
|
||||
Err(error) => failures.push(error.to_string()),
|
||||
}
|
||||
}
|
||||
if !completed.is_empty() {
|
||||
let mut queue = self.state.pending.lock().map_err(|_| {
|
||||
HookError::new(HookErrorCategory::Internal, "pending queue poisoned")
|
||||
})?;
|
||||
for receipt_id in completed {
|
||||
queue.remove(&receipt_id);
|
||||
failures.push(format!("{receipt_id}: {error}"));
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
@@ -581,7 +695,76 @@ impl Hook<RunCommitted> for PendingCommitHook {
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_from_committed_call(
|
||||
state: &SubjektivMemoryState,
|
||||
receipt_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
) -> Result<Option<PendingExplicit>, ToolError> {
|
||||
let (kind, claim, why_useful, staleness, entry_refs, proposal) = match tool_name {
|
||||
REMEMBER_TOOL => {
|
||||
let params: RememberParams = parse(arguments, REMEMBER_TOOL)?;
|
||||
(
|
||||
params.kind,
|
||||
params.claim,
|
||||
params.why_useful,
|
||||
params.staleness,
|
||||
params.entry_refs,
|
||||
None,
|
||||
)
|
||||
}
|
||||
PROPOSE_REVISION_TOOL => {
|
||||
let params: ProposeRevisionParams = parse(arguments, PROPOSE_REVISION_TOOL)?;
|
||||
let response = state.execute(SubjektivMemoryBackendOperation::ValidateProposal(
|
||||
SubjektivMemoryValidateProposalRequest {
|
||||
memory_id: params.memory_id.clone(),
|
||||
expected_revision: params.expected_revision,
|
||||
intent: params.intent,
|
||||
},
|
||||
))?;
|
||||
let validated = match response {
|
||||
SubjektivMemoryBackendResponse::ProposalValidated(value) => value,
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected proposal validation response: {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
(
|
||||
validated.kind,
|
||||
params.claim,
|
||||
params.why_useful,
|
||||
params.staleness,
|
||||
params.entry_refs,
|
||||
Some(SubjektivMemoryRevisionProposal {
|
||||
memory_id: params.memory_id,
|
||||
expected_revision: params.expected_revision,
|
||||
intent: params.intent,
|
||||
change_reason: params.change_reason,
|
||||
}),
|
||||
)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
if !entry_refs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_candidate_text(&claim, &why_useful)?;
|
||||
if kind == CandidateKind::Preference {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"preference candidates cannot use model-origin implicit evidence".into(),
|
||||
));
|
||||
}
|
||||
Ok(Some(PendingExplicit {
|
||||
receipt_id: receipt_id.to_string(),
|
||||
kind,
|
||||
claim,
|
||||
why_useful,
|
||||
staleness,
|
||||
proposal,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_entry_refs(
|
||||
@@ -636,16 +819,6 @@ fn receipt_id(session_id: &str, call_id: &str) -> Result<String, ToolError> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn pending_equivalent(left: &PendingExplicit, right: &PendingExplicit) -> bool {
|
||||
left.receipt_id == right.receipt_id
|
||||
&& left.call_id == right.call_id
|
||||
&& left.kind == right.kind
|
||||
&& left.claim == right.claim
|
||||
&& left.why_useful == right.why_useful
|
||||
&& left.staleness == right.staleness
|
||||
&& left.proposal == right.proposal
|
||||
}
|
||||
|
||||
fn validate_candidate_text(claim: &str, why_useful: &str) -> Result<(), ToolError> {
|
||||
required(claim, "claim")?;
|
||||
required(why_useful, "why_useful")?;
|
||||
@@ -669,6 +842,22 @@ fn schema_for<T: JsonSchema>() -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({}))
|
||||
}
|
||||
|
||||
fn structured_conflict_output(error: ToolError) -> Result<ToolOutput, ToolError> {
|
||||
match error {
|
||||
ToolError::StructuredConflict { code, message } => json_output(
|
||||
format!("Subjektiv Memory operation rejected with {code}."),
|
||||
&serde_json::json!({
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}),
|
||||
),
|
||||
other => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn staged_output(response: SubjektivMemoryStageExplicitResponse) -> Result<ToolOutput, ToolError> {
|
||||
if !matches!(response.status, SubjektivMemoryReceiptStatus::Staged) {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
@@ -705,6 +894,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::feature::session::{CommittedRunExit, CommittedSessionCapture};
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestWorkspaceClient;
|
||||
@@ -722,19 +914,73 @@ mod tests {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let request: SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let SubjektivMemoryBackendOperation::ReceiptStatus(input) = request.operation else {
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"test client only supports receipt lookup".into(),
|
||||
));
|
||||
};
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&SubjektivMemoryBackendResponse::ReceiptStatus(
|
||||
server_api::SubjektivMemoryReceiptStatusResponse {
|
||||
receipt_id: input.receipt_id,
|
||||
status: SubjektivMemoryReceiptStatus::Missing,
|
||||
candidate_id: None,
|
||||
},
|
||||
))
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConflictWorkspaceClient(&'static str);
|
||||
|
||||
impl WorkspaceClient for ConflictWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-test")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"conflict-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Request(
|
||||
"test client has no backend".into(),
|
||||
Ok(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: serde_json::to_string(&server_api::RepositoryApiError::new(
|
||||
409,
|
||||
"Conflict",
|
||||
format!("{}: retry with a fresh snapshot", self.0),
|
||||
vec![server_api::Diagnostic {
|
||||
code: self.0.to_string(),
|
||||
severity: server_api::DiagnosticSeverity::Error,
|
||||
message: "typed conflict".into(),
|
||||
}],
|
||||
))
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingWorkspaceClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
staged: Mutex<HashSet<String>>,
|
||||
fail_stages: AtomicUsize,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingWorkspaceClient {
|
||||
@@ -754,19 +1000,52 @@ mod tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&SubjektivMemoryBackendResponse::Staged(
|
||||
SubjektivMemoryStageExplicitResponse {
|
||||
candidate_id: "explicit:session-1:call-commit".into(),
|
||||
receipt_id: "explicit:session-1:call-commit".into(),
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
let request: SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let response = match request.operation {
|
||||
SubjektivMemoryBackendOperation::ReceiptStatus(input) => {
|
||||
let staged = self.staged.lock().unwrap().contains(&input.receipt_id);
|
||||
SubjektivMemoryBackendResponse::ReceiptStatus(
|
||||
server_api::SubjektivMemoryReceiptStatusResponse {
|
||||
receipt_id: input.receipt_id.clone(),
|
||||
status: if staged {
|
||||
SubjektivMemoryReceiptStatus::Staged
|
||||
} else {
|
||||
SubjektivMemoryReceiptStatus::Missing
|
||||
},
|
||||
candidate_id: staged.then_some(input.receipt_id),
|
||||
},
|
||||
)
|
||||
}
|
||||
SubjektivMemoryBackendOperation::StageExplicit(input) => {
|
||||
if self
|
||||
.fail_stages
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||
remaining.checked_sub(1)
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 503,
|
||||
body: r#"{"error":"Unavailable","message":"retry","diagnostics":[]}"#
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
self.staged.lock().unwrap().insert(input.receipt_id.clone());
|
||||
SubjektivMemoryBackendResponse::Staged(SubjektivMemoryStageExplicitResponse {
|
||||
candidate_id: input.receipt_id.clone(),
|
||||
receipt_id: input.receipt_id,
|
||||
status: SubjektivMemoryReceiptStatus::Staged,
|
||||
target: None,
|
||||
intent: None,
|
||||
},
|
||||
))
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
other => panic!("unexpected test operation: {other:?}"),
|
||||
};
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&response).unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -788,7 +1067,6 @@ mod tests {
|
||||
state: SubjektivMemoryState {
|
||||
client: Arc::new(TestWorkspaceClient),
|
||||
capture,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -811,9 +1089,16 @@ mod tests {
|
||||
PROPOSE_REVISION_TOOL,
|
||||
]
|
||||
);
|
||||
assert_eq!(descriptor.hooks.len(), 1);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
assert_eq!(descriptor.hooks[0].name, COMMIT_HOOK);
|
||||
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::RunCommitted);
|
||||
assert_eq!(descriptor.hooks[1].name, RETRY_HOOK);
|
||||
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||
assert_eq!(descriptor.hooks[2].name, REWRITE_HOOK);
|
||||
assert_eq!(
|
||||
descriptor.hooks[2].point,
|
||||
FeatureHookPoint::BeforeSessionRewrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -845,6 +1130,27 @@ mod tests {
|
||||
assert!(parsed.entry_refs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_conflict_discriminants_survive_as_structured_tool_output() {
|
||||
for code in ["revision_conflict", "stale_cursor"] {
|
||||
let tool = SubjektivReadTool {
|
||||
state: SubjektivMemoryState {
|
||||
client: Arc::new(ConflictWorkspaceClient(code)),
|
||||
capture: feature().state.capture,
|
||||
},
|
||||
operation: ReadOperation::Query,
|
||||
};
|
||||
let output = tool
|
||||
.execute("{}", ToolExecutionContext::new("call", "batch", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["status"], "error");
|
||||
assert_eq!(value["error"]["code"], code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_identity_is_stable_and_rejects_invalid_values() {
|
||||
assert_eq!(
|
||||
@@ -872,12 +1178,9 @@ mod tests {
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["status"], "pending_commit");
|
||||
assert_eq!(value["receipt_id"], "explicit:session-1:call-1");
|
||||
assert_eq!(feature.state.pending.lock().unwrap().len(), 1);
|
||||
|
||||
tool.execute(input, ToolExecutionContext::new("call-1", "batch-1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(feature.state.pending.lock().unwrap().len(), 1);
|
||||
|
||||
let preference = tool
|
||||
.execute(
|
||||
@@ -890,16 +1193,153 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_tool_call_stages_pending_candidate_with_host_evidence() {
|
||||
async fn cancelled_or_uncommitted_calls_are_not_replayed() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 1,
|
||||
entry_count: 3,
|
||||
run_exit: CommittedRunExit::Interrupted,
|
||||
history: vec![
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_call(
|
||||
"call-uncommitted",
|
||||
REMEMBER_TOOL,
|
||||
r#"{"kind":"lesson","claim":"No result","why_useful":"Never stage"}"#,
|
||||
),
|
||||
crate::session_history::WorkerHistoryProvenance::ModelOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_call(
|
||||
"call-cancelled",
|
||||
REMEMBER_TOOL,
|
||||
r#"{"kind":"lesson","claim":"Cancelled","why_useful":"Never stage"}"#,
|
||||
),
|
||||
crate::session_history::WorkerHistoryProvenance::ModelOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_result_item("call-cancelled", "cancelled", None, true),
|
||||
crate::session_history::WorkerHistoryProvenance::ToolOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
],
|
||||
usage_history: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
})
|
||||
});
|
||||
PendingCommitHook {
|
||||
state: SubjektivMemoryState {
|
||||
client: client.clone(),
|
||||
capture,
|
||||
},
|
||||
}
|
||||
.call(&RunCommittedContext {
|
||||
invocation: crate::hook::HookInvocationContext::default(),
|
||||
exit: crate::hook::RunCommittedExit::Interrupted,
|
||||
committed_history: crate::hook::HookHistoryRange::default(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_commit_hook_retries_from_durable_history() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
client.fail_stages.store(1, Ordering::SeqCst);
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 2,
|
||||
entry_count: 2,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: vec![
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_call(
|
||||
"call-retry",
|
||||
REMEMBER_TOOL,
|
||||
r#"{"kind":"lesson","claim":"Retry","why_useful":"Recover after failure"}"#,
|
||||
),
|
||||
crate::session_history::WorkerHistoryProvenance::ModelOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_result_with_content(
|
||||
"call-retry",
|
||||
"pending commit",
|
||||
r#"{"status":"pending_commit"}"#,
|
||||
),
|
||||
crate::session_history::WorkerHistoryProvenance::ToolOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
],
|
||||
usage_history: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
})
|
||||
});
|
||||
let hook = PendingCommitHook {
|
||||
state: SubjektivMemoryState {
|
||||
client: client.clone(),
|
||||
capture,
|
||||
},
|
||||
};
|
||||
let context = RunCommittedContext {
|
||||
invocation: crate::hook::HookInvocationContext::default(),
|
||||
exit: crate::hook::RunCommittedExit::Finished,
|
||||
committed_history: crate::hook::HookHistoryRange::default(),
|
||||
};
|
||||
assert!(hook.call(&context).await.is_err());
|
||||
hook.call(&context).await.unwrap();
|
||||
hook.call(&context).await.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.staged
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains("explicit:session-1:call-retry")
|
||||
);
|
||||
let stage_attempts = client
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
let request: SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
matches!(
|
||||
request.operation,
|
||||
SubjektivMemoryBackendOperation::StageExplicit(_)
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
stage_attempts, 2,
|
||||
"a staged receipt must not be replayed again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_tool_call_is_replayed_after_restart_with_host_evidence() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
Ok(CommittedSessionCapture {
|
||||
session_id: "session-1".into(),
|
||||
segment_id: "segment-1".into(),
|
||||
session_revision: 2,
|
||||
entry_count: 1,
|
||||
entry_count: 2,
|
||||
run_exit: CommittedRunExit::Finished,
|
||||
history: vec![crate::session_history::history_entry(
|
||||
history: vec![
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_call(
|
||||
"call-commit",
|
||||
REMEMBER_TOOL,
|
||||
@@ -908,7 +1348,18 @@ mod tests {
|
||||
crate::session_history::WorkerHistoryProvenance::ModelOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
)],
|
||||
),
|
||||
crate::session_history::history_entry(
|
||||
agen::Item::tool_result_with_content(
|
||||
"call-commit",
|
||||
"pending commit",
|
||||
r#"{"status":"pending_commit"}"#,
|
||||
),
|
||||
crate::session_history::WorkerHistoryProvenance::ToolOutput {
|
||||
worker: crate::session_history::worker_subject(Default::default()),
|
||||
},
|
||||
),
|
||||
],
|
||||
usage_history: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
})
|
||||
@@ -916,47 +1367,52 @@ mod tests {
|
||||
let state = SubjektivMemoryState {
|
||||
client: client.clone(),
|
||||
capture,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let tool = SubjektivExplicitTool {
|
||||
state: state.clone(),
|
||||
operation: ExplicitOperation::Remember,
|
||||
};
|
||||
tool.execute(
|
||||
r#"{"kind":"lesson","claim":"Committed","why_useful":"Evidence"}"#,
|
||||
ToolExecutionContext::new("call-commit", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.pending.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.receipt_status("explicit:session-1:call-commit")
|
||||
.unwrap()
|
||||
.status,
|
||||
SubjektivMemoryReceiptStatus::PendingCommit
|
||||
);
|
||||
|
||||
PendingCommitHook {
|
||||
PendingRetryHook {
|
||||
state: state.clone(),
|
||||
}
|
||||
.call(&RunCommittedContext {
|
||||
invocation: crate::hook::HookInvocationContext::default(),
|
||||
exit: crate::hook::RunCommittedExit::Finished,
|
||||
committed_history: crate::hook::HookHistoryRange::default(),
|
||||
})
|
||||
.call(&crate::hook::PreRequestContext::new(
|
||||
crate::hook::PreRequestInfo {
|
||||
item_count: 2,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.pending.lock().unwrap().is_empty());
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let body = requests[0].body.as_deref().unwrap();
|
||||
let request: SubjektivMemoryBackendRequest = serde_json::from_str(body).unwrap();
|
||||
let SubjektivMemoryBackendOperation::StageExplicit(staged) = request.operation else {
|
||||
panic!("expected explicit stage request");
|
||||
};
|
||||
assert_eq!(staged.session_id, "session-1");
|
||||
assert_eq!(staged.evidence.len(), 1);
|
||||
let request = requests
|
||||
.iter()
|
||||
.filter_map(|request| {
|
||||
serde_json::from_str::<SubjektivMemoryBackendRequest>(
|
||||
request.body.as_deref().unwrap(),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.find_map(|request| match request.operation {
|
||||
SubjektivMemoryBackendOperation::StageExplicit(staged) => Some(staged),
|
||||
_ => None,
|
||||
})
|
||||
.expect("restart replay must stage the committed receipt");
|
||||
assert_eq!(request.session_id, "session-1");
|
||||
assert_eq!(request.evidence.len(), 1);
|
||||
assert_eq!(
|
||||
staged.evidence[0].origin.as_ref().unwrap().kind,
|
||||
request.evidence[0].origin.as_ref().unwrap().kind,
|
||||
memory::schema::EvidenceOriginKind::ModelOutput
|
||||
);
|
||||
assert_eq!(
|
||||
staged.source_refs[0].evidence_id.as_deref(),
|
||||
Some(staged.evidence[0].id.as_str())
|
||||
request.source_refs[0].evidence_id.as_deref(),
|
||||
Some(request.evidence[0].id.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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