feat: integrate persistent Subject behavior context
This commit is contained in:
@@ -237,6 +237,7 @@ impl_openapi_schema!(
|
||||
MemoryConsolidateStagingRequest,
|
||||
MemoryConsolidationResponse,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectBehaviorUpdateRequest,
|
||||
SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
@@ -1163,6 +1164,20 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[patch(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_subject_behavior_update(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||
status = 200,
|
||||
@@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectCreateRequest {
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectBehaviorUpdateRequest {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub expected_behavior_revision: u64,
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse {
|
||||
pub struct SubjektivSubjectResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjektivSubjectState,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub store_revision: u64,
|
||||
@@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivResidentContextOutput {
|
||||
/// Exact user-managed document. Empty means explicitly unset.
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
/// Independently-fresh generated Memory projection.
|
||||
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendOperation {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
|
||||
Query(SubjektivMemoryQueryRequest),
|
||||
Read(SubjektivMemoryReadRequest),
|
||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||
@@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendResponse {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||
ResidentContext(SubjektivResidentContextOutput),
|
||||
Query(SubjektivMemoryQueryResponse),
|
||||
Read(SubjektivMemoryReadResponse),
|
||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||
@@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String {
|
||||
// `ts-rs` solely for this read-only projection.
|
||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -9896,8 +9940,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
|
||||
let limits = format!(
|
||||
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
|
||||
);
|
||||
let subjektiv_limits = format!(
|
||||
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
|
||||
);
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
|
||||
@@ -167,6 +167,15 @@ pub enum SystemItem {
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// A Host-fetched change to user-managed Subject behavior. Each changed
|
||||
/// revision is appended rather than rewriting prior prompt/session content.
|
||||
SubjectBehaviorRefresh {
|
||||
body: String,
|
||||
behavior_revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// Synthetic note inserted after an interrupted turn before the next
|
||||
/// user input. `body` is the exact LLM-context text explaining that the
|
||||
/// previous turn was cut short.
|
||||
@@ -192,6 +201,7 @@ impl SystemItem {
|
||||
}
|
||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
|
||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||
}
|
||||
}
|
||||
@@ -214,6 +224,7 @@ impl SystemItem {
|
||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
|
||||
SystemItem::Interrupt { .. } => "interrupt",
|
||||
}
|
||||
}
|
||||
@@ -270,6 +281,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
|
||||
let item = SystemItem::SubjectBehaviorRefresh {
|
||||
body: "current Subject context".to_string(),
|
||||
behavior_revision: 7,
|
||||
prompt_provenance: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&item).unwrap();
|
||||
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.history_text(), "current Subject context");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_history_text_returns_stored_body() {
|
||||
let item = SystemItem::Notification {
|
||||
|
||||
@@ -2618,6 +2618,7 @@ impl App {
|
||||
session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::SystemMessage { text: body });
|
||||
|
||||
@@ -1375,6 +1375,7 @@ where
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts(),
|
||||
)?
|
||||
{
|
||||
let workspace_id = worker
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! authority. In that case model-visible Memory tools must go through the
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -24,11 +25,15 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
|
||||
use crate::hook::{
|
||||
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
|
||||
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
|
||||
WorkerRestored,
|
||||
};
|
||||
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||
use crate::worker::{
|
||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
||||
Decode(#[from] serde_json::Error),
|
||||
#[error("workspace memory backend rejected operation: {0}")]
|
||||
Backend(String),
|
||||
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
#[derive(Default)]
|
||||
struct SubjectBehaviorRefreshState {
|
||||
applied: Option<(u64, String)>,
|
||||
pending: HashMap<u64, String>,
|
||||
}
|
||||
|
||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
||||
"The current ready resident memory surface is intentionally empty.";
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||
}
|
||||
|
||||
fn resident_summary_contribution(
|
||||
output: memory::backend::MemoryResidentSummaryOutput,
|
||||
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
impl WorkspaceSubjektivResidentSummarySource {
|
||||
async fn fetch(
|
||||
&self,
|
||||
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||
match execute_subjektiv_memory_operation_with(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||
|client, request| {
|
||||
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||
},
|
||||
) {
|
||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
||||
match resident_summary_contribution(output) {
|
||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
||||
}
|
||||
contribution => contribution,
|
||||
)? {
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||
"unexpected subject resident context response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
output: &server_api::SubjektivResidentContextOutput,
|
||||
) -> Result<String, String> {
|
||||
use memory::backend::MemoryResidentSummaryAvailability as Availability;
|
||||
let availability = match output.memory_surface.availability {
|
||||
Availability::Ready => "ready",
|
||||
Availability::Ungenerated => "ungenerated",
|
||||
Availability::Stale => "stale",
|
||||
Availability::Failed => "failed",
|
||||
Availability::Unavailable => "unavailable",
|
||||
};
|
||||
self.prompts
|
||||
.load()
|
||||
.subjektiv_resident_context(
|
||||
&output.behavior_md,
|
||||
availability,
|
||||
output.memory_surface.content.as_deref(),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
|
||||
state.pending.remove(&output.behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
match self.fetch().await {
|
||||
Ok(output) => match self.render(&output) {
|
||||
Ok(rendered) => {
|
||||
self.remember_behavior(&output);
|
||||
SystemPromptContribution::Ready(rendered)
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "subject resident context render failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
||||
SystemPromptContribution::Unavailable
|
||||
tracing::error!(%error, "subject resident context fetch failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<Option<ResidentContextRefresh>, String> {
|
||||
let output = self.fetch().await.map_err(|error| error.to_string())?;
|
||||
let changed = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied
|
||||
.as_ref()
|
||||
.is_none_or(|(revision, body)| {
|
||||
*revision != output.behavior_revision || body != &output.behavior_md
|
||||
});
|
||||
if !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
let rendered = self.render(&output)?;
|
||||
let revision = output.behavior_revision;
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.pending
|
||||
.insert(revision, output.behavior_md);
|
||||
Ok(Some(ResidentContextRefresh {
|
||||
body: rendered,
|
||||
revision,
|
||||
}))
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
if let Some(body) = state.pending.remove(&revision) {
|
||||
state.applied = Some((revision, body));
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_resident_context_representation(&self) {
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied = None;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
|
||||
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
|
||||
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
|
||||
|
||||
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||
FeatureHookPoint::WorkerRestored,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
FeatureHookPoint::BeforeSessionRewrite,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
prompts: Arc::clone(&self.prompts),
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_llm_request(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRefreshHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_before_session_rewrite(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRewriteHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRewriteHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
|
||||
async fn call(
|
||||
&self,
|
||||
_context: &BeforeSessionRewriteContext,
|
||||
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||
self.source.invalidate_resident_context_representation();
|
||||
Ok(BeforeSessionRewriteAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
|
||||
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||
let refresh = self
|
||||
.source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
|
||||
if let Some(refresh) = refresh {
|
||||
let system_items = context.system_items().ok_or_else(|| {
|
||||
HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"Subject behavior refresh requires durable session append authority",
|
||||
)
|
||||
})?;
|
||||
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
|
||||
}
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivResidentRestoreRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
@@ -951,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
|
||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||
return Ok(None);
|
||||
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
}
|
||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||
client,
|
||||
prompts,
|
||||
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1120,6 +1293,10 @@ mod tests {
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
}
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
@@ -1132,6 +1309,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
behavior: Mutex<(u64, String)>,
|
||||
paths: Mutex<Vec<String>>,
|
||||
timeouts: Mutex<Vec<Duration>>,
|
||||
}
|
||||
@@ -1145,6 +1323,7 @@ mod tests {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1155,6 +1334,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1184,7 +1364,7 @@ mod tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
if !self.scope_allowed {
|
||||
return Ok(WorkspaceResponse {
|
||||
@@ -1192,13 +1372,18 @@ mod tests {
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1511,13 +1696,19 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready.load().await,
|
||||
SystemPromptContribution::Ready("current subject surface".into())
|
||||
);
|
||||
let ready = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_client.clone(),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded = ready.load().await;
|
||||
let SystemPromptContribution::Ready(loaded) = loaded else {
|
||||
panic!("subject resident context must be present");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(loaded.contains("current subject surface"));
|
||||
assert_eq!(
|
||||
ready_client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1531,13 +1722,18 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready_empty.load().await,
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
||||
);
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_empty_client,
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
|
||||
panic!("empty surface must retain Subject behavior");
|
||||
};
|
||||
assert!(ready_empty.contains("Be deliberate."));
|
||||
assert!(ready_empty.contains("intentionally empty"));
|
||||
|
||||
for availability in [
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
@@ -1550,22 +1746,107 @@ permission = "write"
|
||||
availability,
|
||||
Some("must not be injected"),
|
||||
)),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(loaded) = source.load().await else {
|
||||
panic!("Subject behavior must survive unavailable surfaces");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(!loaded.contains("must not be injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let initial = source.load().await;
|
||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
|
||||
let refresh = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("changed behavior must produce one refresh");
|
||||
assert_eq!(refresh.revision, 4);
|
||||
assert!(refresh.body.contains("Ask when uncertain."));
|
||||
source.confirm_resident_context_revision(refresh.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (5, String::new());
|
||||
let cleared = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("cleared behavior must produce one refresh");
|
||||
assert_eq!(cleared.revision, 5);
|
||||
assert!(cleared.body.contains("No user-managed behavior is set"));
|
||||
source.confirm_resident_context_revision(cleared.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
source.invalidate_resident_context_representation();
|
||||
let replay_after_rewrite = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("history rewrite must require a durable current representation");
|
||||
assert_eq!(replay_after_rewrite.revision, 5);
|
||||
assert!(
|
||||
replay_after_rewrite
|
||||
.body
|
||||
.contains("No user-managed behavior is set")
|
||||
);
|
||||
// Initial load and every live update/clear/replay probe retain the
|
||||
// target-side bounded resident request contract.
|
||||
assert_eq!(
|
||||
client.timeouts.lock().unwrap().as_slice(),
|
||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::denied());
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(failure) = source.load().await else {
|
||||
panic!("fetch failure must be explicit in resident context");
|
||||
};
|
||||
assert!(failure.contains("could not fetch"));
|
||||
assert_eq!(
|
||||
client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1593,7 +1874,7 @@ permission = "write"
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1605,22 +1886,30 @@ permission = "write"
|
||||
Some("policy-only surface must not load"),
|
||||
));
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
ordinary_subjektiv_resident_summary_source(
|
||||
&policy_only,
|
||||
unattached_client.clone(),
|
||||
test_prompts()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||
|
||||
let foreign = subject_manifest("other-workspace");
|
||||
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = Arc::new(ArcSwap::from(
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
));
|
||||
@@ -1634,9 +1923,10 @@ permission = "write"
|
||||
#[test]
|
||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||
source,
|
||||
Arc::new(ArcSwap::from(
|
||||
@@ -1647,8 +1937,13 @@ permission = "write"
|
||||
let descriptor = feature.descriptor();
|
||||
|
||||
assert!(descriptor.tools.is_empty());
|
||||
assert_eq!(descriptor.hooks.len(), 1);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
|
||||
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||
assert_eq!(
|
||||
descriptor.hooks[2].point,
|
||||
FeatureHookPoint::BeforeSessionRewrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(item);
|
||||
}
|
||||
|
||||
/// Queue a revisioned Subject behavior refresh. The caller receives this
|
||||
/// narrow operation only at a pre-request boundary, and the host commits it
|
||||
/// before exposing it to the model.
|
||||
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(SystemItem::SubjectBehaviorRefresh {
|
||||
body: body.into(),
|
||||
behavior_revision,
|
||||
prompt_provenance: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-created narrow append authority exposed only at a restore lifecycle
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::hook::{
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::session_history::SessionHistoryMetadata;
|
||||
use crate::worker::SystemItemCommitter;
|
||||
use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
|
||||
use agen::HistoryEntry;
|
||||
use agen::token_counter::total_tokens;
|
||||
|
||||
@@ -109,6 +109,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
next_turn_index: AtomicUsize,
|
||||
@@ -163,6 +164,7 @@ impl WorkerInterceptor {
|
||||
prompts,
|
||||
prompt_workspace_id: None,
|
||||
log_writer,
|
||||
resident_context_source: None,
|
||||
pending_committed_history,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
tool_calls_this_turn: AtomicUsize::new(0),
|
||||
@@ -179,11 +181,31 @@ impl WorkerInterceptor {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_resident_context_source(
|
||||
mut self,
|
||||
source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
) -> Self {
|
||||
self.resident_context_source = source;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||
self.prompt_workspace_id = workspace_id;
|
||||
self
|
||||
}
|
||||
|
||||
fn confirm_resident_context_item(&self, item: &SystemItem) {
|
||||
let Some(source) = self.resident_context_source.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision, ..
|
||||
} = item
|
||||
{
|
||||
source.confirm_resident_context_revision(*behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||
/// entry through the attached writer (no-op when no writer is
|
||||
/// wired). Sync — writes complete before the matching
|
||||
@@ -211,6 +233,7 @@ impl WorkerInterceptor {
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(entry);
|
||||
self.confirm_resident_context_item(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -350,6 +373,11 @@ impl WorkerInterceptor {
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.task_reminder"));
|
||||
}
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
|
||||
}
|
||||
SystemItem::Interrupt {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
@@ -763,6 +791,7 @@ mod tests {
|
||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||
};
|
||||
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||
use crate::worker::SystemPromptContribution;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
@@ -859,6 +888,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppendingSubjectBehaviorHook;
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
|
||||
async fn call(
|
||||
&self,
|
||||
input: &PreRequestContext,
|
||||
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||
input
|
||||
.system_items()
|
||||
.expect("test interceptor has durable append authority")
|
||||
.append_subject_behavior_refresh("current Subject context", 7);
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmRecordingResidentSource {
|
||||
confirmed: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
self.confirmed.lock().unwrap().push(revision);
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingSystemItemCommitter;
|
||||
|
||||
impl SystemItemCommitter for FailingSystemItemCommitter {
|
||||
fn commit_log_entry(
|
||||
&self,
|
||||
entry: session_store::LogEntry,
|
||||
) -> Result<(), session_store::StoreError> {
|
||||
if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) {
|
||||
return Err(session_store::StoreError::Io(std::io::Error::other(
|
||||
"synthetic system-item failure",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn task_tool_call_info(
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
@@ -1346,6 +1422,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let confirmed = Arc::new(Mutex::new(Vec::new()));
|
||||
let source: Arc<dyn SystemPromptContributionSource> =
|
||||
Arc::new(ConfirmRecordingResidentSource {
|
||||
confirmed: Arc::clone(&confirmed),
|
||||
});
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
Arc::new(builder.build()),
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
test_prompts(),
|
||||
Some(Arc::new(RecordingSystemItemCommitter {
|
||||
committed: Arc::clone(&committed),
|
||||
})),
|
||||
)
|
||||
.with_resident_context_source(Some(source));
|
||||
|
||||
let mut ctx = Vec::new();
|
||||
let action = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: Default::default(),
|
||||
items: &mut ctx,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
|
||||
assert_eq!(*confirmed.lock().unwrap(), [7]);
|
||||
assert!(matches!(
|
||||
committed.lock().unwrap().as_slice(),
|
||||
[SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
prompt_provenance: Some(_),
|
||||
..
|
||||
}]
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
|
||||
let confirmed = Arc::new(Mutex::new(Vec::new()));
|
||||
let source: Arc<dyn SystemPromptContributionSource> =
|
||||
Arc::new(ConfirmRecordingResidentSource {
|
||||
confirmed: Arc::clone(&confirmed),
|
||||
});
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
Arc::new(builder.build()),
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
test_prompts(),
|
||||
Some(Arc::new(FailingSystemItemCommitter)),
|
||||
)
|
||||
.with_resident_context_source(Some(source));
|
||||
|
||||
let mut ctx = Vec::new();
|
||||
let action = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: Default::default(),
|
||||
items: &mut ctx,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
PreRequestAction::Cancel(reason) if reason.contains("session persistence failed")
|
||||
));
|
||||
assert!(confirmed.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
ResidentMemoryRestoreSection,
|
||||
SubjektivResidentContext,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
@@ -128,6 +129,7 @@ impl WorkerPrompt {
|
||||
Self::AgentsMdSection => "internal.agents_md_section",
|
||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
@@ -150,6 +152,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||
WorkerPrompt::SubjektivResidentContext,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
@@ -518,6 +521,21 @@ impl PromptCatalog {
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn subjektiv_resident_context(
|
||||
&self,
|
||||
behavior_md: &str,
|
||||
surface_availability: &str,
|
||||
surface_body: Option<&str>,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::SubjektivResidentContext,
|
||||
minijinja::context! {
|
||||
behavior_md => behavior_md,
|
||||
surface_availability => surface_availability,
|
||||
surface_body => surface_body.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
|
||||
@@ -2361,9 +2361,30 @@ pub(crate) enum SystemPromptContribution {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ResidentContextRefresh {
|
||||
pub body: String,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait SystemPromptContributionSource: Send + Sync {
|
||||
async fn load(&self) -> SystemPromptContribution;
|
||||
|
||||
/// Returns a durable model-visible refresh only when user-managed resident
|
||||
/// context changed since the last successful load.
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<Option<ResidentContextRefresh>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Acknowledges that the revision's typed refresh was durably committed.
|
||||
fn confirm_resident_context_revision(&self, _revision: u64) {}
|
||||
|
||||
/// Invalidates the in-memory representation fence before history may be
|
||||
/// compacted or rewound.
|
||||
fn invalidate_resident_context_representation(&self) {}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -3840,6 +3861,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
)
|
||||
.with_usage_tracker(self.usage_tracker.clone())
|
||||
.with_metrics_tracker(self.metrics_tracker.clone())
|
||||
.with_resident_context_source(self.feature_resident_summary_source.clone())
|
||||
.with_prompt_workspace_id(
|
||||
self.workspace_context
|
||||
.workspace_id()
|
||||
@@ -9224,16 +9246,20 @@ mod build_summary_prompt_tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
self.load_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: "restore behavior".to_string(),
|
||||
behavior_revision: 1,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -9254,7 +9280,9 @@ mod build_summary_prompt_tests {
|
||||
load_count: Arc::clone(&load_count),
|
||||
});
|
||||
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
manifest, client,
|
||||
manifest,
|
||||
client,
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
@@ -12219,9 +12247,10 @@ permission = "write"
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
empty_boundary.contains("current ready resident memory surface is intentionally empty")
|
||||
empty_boundary
|
||||
.contains("current generated Memory surface is ready and intentionally empty")
|
||||
);
|
||||
assert!(!empty_boundary.contains("No current ready resident memory surface"));
|
||||
assert!(!empty_boundary.contains("No generated Memory surface exists yet"));
|
||||
drop(empty_restore);
|
||||
|
||||
for availability in [
|
||||
@@ -12273,7 +12302,22 @@ permission = "write"
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(tombstone.contains("No current ready resident memory surface"));
|
||||
let unavailable_message = match availability {
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated => {
|
||||
"No generated Memory surface exists yet"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Stale => {
|
||||
"generated Memory surface is stale"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Failed => {
|
||||
"Generation of the Memory surface failed"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Unavailable => {
|
||||
"Host could not fetch the Subject resident context"
|
||||
}
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(),
|
||||
};
|
||||
assert!(tombstone.contains(unavailable_message));
|
||||
assert!(!tombstone.contains("latest subject surface"));
|
||||
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
|
||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||
|
||||
@@ -5811,6 +5811,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
|
||||
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -7486,6 +7487,26 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_subject_behavior_update(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
request: server_api::SubjektivSubjectBehaviorUpdateRequest,
|
||||
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_update_subjektiv_subject_behavior(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
}),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_resident_surface(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -17447,6 +17468,8 @@ fn subjektiv_subject_response(
|
||||
server_api::SubjektivSubjectResponse {
|
||||
id: subject.id,
|
||||
role: subject.role.as_str().to_string(),
|
||||
behavior_md: subject.behavior_md,
|
||||
behavior_revision: subject.behavior_revision,
|
||||
state: match subject.state {
|
||||
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
|
||||
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired,
|
||||
@@ -17466,9 +17489,11 @@ async fn scoped_create_subjektiv_subject(
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let role = crate::subjektiv::SubjectRole::new(request.role)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.create_subject(role)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
.create_subject_with_behavior(role, request.behavior_md)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(subjektiv_subject_response(&api, subject)),
|
||||
@@ -17560,6 +17585,24 @@ async fn scoped_get_subjektiv_subject(
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
}
|
||||
|
||||
async fn scoped_update_subjektiv_subject_behavior(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
Json(request): Json<server_api::SubjektivSubjectBehaviorUpdateRequest>,
|
||||
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.update_subject_behavior(
|
||||
&path.subject_id,
|
||||
request.expected_behavior_revision,
|
||||
request.behavior_md,
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
}
|
||||
|
||||
fn require_subjektiv_subject(
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
subject_id: &str,
|
||||
@@ -19166,6 +19209,23 @@ async fn scoped_subjektiv_memory_backend(
|
||||
resident,
|
||||
))
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => {
|
||||
require_subjektiv_worker_authority(
|
||||
authority,
|
||||
SubjektivWorkerAuthority::Subject,
|
||||
"resident subject context read",
|
||||
)?;
|
||||
let resident = store
|
||||
.resident_context(&subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: resident.subject.behavior_md,
|
||||
behavior_revision: resident.subject.behavior_revision,
|
||||
memory_surface: resident_summary_output(resident.surface),
|
||||
},
|
||||
)
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::Query(input) => {
|
||||
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
|
||||
&store,
|
||||
@@ -20540,6 +20600,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
||||
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
|
||||
}
|
||||
crate::subjektiv::SubjektivError::RevisionConflict { .. }
|
||||
| crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. }
|
||||
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
|
||||
Error::RepositoryConflict(format!("revision_conflict: {error}"))
|
||||
}
|
||||
@@ -39199,18 +39260,80 @@ mod tests {
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
Some(serde_json::json!({ "role": " Browser author " })),
|
||||
Some(serde_json::json!({
|
||||
"role": " Browser author ",
|
||||
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created["role"], " Browser author ");
|
||||
assert_eq!(
|
||||
created["behavior_md"],
|
||||
"Prefer explicit evidence.\nAsk when uncertain."
|
||||
);
|
||||
assert_eq!(created["behavior_revision"], 0);
|
||||
assert_eq!(created["state"], "active");
|
||||
assert_eq!(created["store_revision"], 0);
|
||||
assert!(created.get("current_worker").is_none());
|
||||
let created_id = created["id"].as_str().unwrap();
|
||||
let persisted = store.subject(created_id).unwrap().unwrap();
|
||||
assert_eq!(persisted.role.as_str(), " Browser author ");
|
||||
assert_eq!(persisted.behavior_revision, 0);
|
||||
let behavior_uri =
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior");
|
||||
request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "Updated without rewriting history."
|
||||
})),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let updated = request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "Updated without rewriting history."
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(updated["behavior_md"], "Updated without rewriting history.");
|
||||
assert_eq!(updated["behavior_revision"], 1);
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "stale write"
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let cleared = request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 1,
|
||||
"behavior_md": ""
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cleared["behavior_md"], "");
|
||||
assert_eq!(cleared["behavior_revision"], 2);
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
@@ -39311,6 +39434,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
|
||||
let (status, Json(created)) = scoped_create_subjektiv_subject(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
role: "companion".to_string(),
|
||||
behavior_md: "Prefer explicit evidence.".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created.behavior_revision, 0);
|
||||
assert_eq!(created.behavior_md, "Prefer explicit evidence.");
|
||||
|
||||
let Json(updated) = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id.clone(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 0,
|
||||
behavior_md: "Ask when uncertain.".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.behavior_revision, 1);
|
||||
assert_eq!(updated.behavior_md, "Ask when uncertain.");
|
||||
|
||||
let conflict = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id.clone(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 0,
|
||||
behavior_md: "Stale edit".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT);
|
||||
|
||||
let Json(cleared) = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id,
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 1,
|
||||
behavior_md: String::new(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cleared.behavior_revision, 2);
|
||||
assert!(cleared.behavior_md.is_empty());
|
||||
|
||||
let oversized = scoped_create_subjektiv_subject(
|
||||
State(api),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
role: "reviewer".to_string(),
|
||||
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -39733,7 +39940,10 @@ mod tests {
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
let subject = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new("companion").unwrap(),
|
||||
"Prefer explicit evidence.".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let request = CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
@@ -39852,6 +40062,33 @@ mod tests {
|
||||
}
|
||||
)
|
||||
));
|
||||
let Json(resident_context) = 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::ResidentContext(
|
||||
Default::default(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
resident_context,
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision: 0,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
content: None,
|
||||
},
|
||||
}
|
||||
) if behavior_md == "Prefer explicit evidence."
|
||||
));
|
||||
let missing_legacy = scoped_record_subjektiv_session(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::HashSet;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
||||
/// Maximum UTF-8 size of the user-managed Subject behavior document.
|
||||
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
|
||||
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
||||
/// payload is capped below the whole-input budget to reserve room for the fixed
|
||||
@@ -74,6 +76,14 @@ pub enum SubjektivError {
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error(
|
||||
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
|
||||
)]
|
||||
SubjectBehaviorConflict {
|
||||
subject_id: String,
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error("surface generation conflict: {0}")]
|
||||
SurfaceGenerationConflict(String),
|
||||
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
||||
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub role: SubjectRole,
|
||||
/// User-managed, host-injected behavior document. This is not Memory and
|
||||
/// Memory lifecycle operations must preserve it byte-for-byte.
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
/// Monotonic CAS generation for behavior updates, independent of Memory's
|
||||
/// `store_revision`.
|
||||
#[serde(default)]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjectState,
|
||||
/// Monotonic generation incremented exactly once for every committed Memory
|
||||
/// creation or revision for this subject.
|
||||
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
|
||||
pub snapshot: Option<SurfaceSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubjectResidentContext {
|
||||
pub subject: SubjectRecord,
|
||||
pub surface: ResidentSurface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryRevisionTarget {
|
||||
@@ -986,6 +1010,17 @@ END;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
|
||||
transaction.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (behavior_revision >= 0);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static MIGRATIONS: &[FeatureMigration] = &[
|
||||
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||
FeatureMigration::new(
|
||||
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
||||
"add bounded surface generation state and runs",
|
||||
add_surface_generation_state,
|
||||
),
|
||||
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
|
||||
];
|
||||
|
||||
pub const REGISTRATION: FeatureRegistration =
|
||||
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
|
||||
/// Issues a random host-owned subject id. It is never derived from Worker,
|
||||
/// Profile, Session, or Memory content.
|
||||
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
|
||||
self.create_subject_with_behavior(role, String::new())
|
||||
}
|
||||
|
||||
/// Creates a Subject with its optional user-managed behavior document.
|
||||
pub fn create_subject_with_behavior(
|
||||
&self,
|
||||
role: SubjectRole,
|
||||
behavior_md: String,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
let timestamp = now();
|
||||
let record = SubjectRecord {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: issued_id("subject"),
|
||||
role,
|
||||
behavior_md,
|
||||
behavior_revision: 0,
|
||||
state: SubjectState::Active,
|
||||
store_revision: 0,
|
||||
created_at: timestamp.clone(),
|
||||
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
|
||||
self.database.try_transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
subject_id, role, behavior_md, behavior_revision, state,
|
||||
store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
|
||||
params![
|
||||
record.id,
|
||||
record.role.as_str(),
|
||||
record.behavior_md,
|
||||
raw,
|
||||
record.created_at,
|
||||
record.updated_at
|
||||
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces or clears the user-managed behavior document using its own CAS
|
||||
/// generation. Memory revisions do not participate in this generation.
|
||||
pub fn update_subject_behavior(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
expected_behavior_revision: u64,
|
||||
behavior_md: String,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
self.database.try_transaction(|transaction| {
|
||||
let mut subject = require_subject(transaction, subject_id)?;
|
||||
if subject.behavior_revision != expected_behavior_revision {
|
||||
return Err(SubjektivError::SubjectBehaviorConflict {
|
||||
subject_id: subject_id.to_string(),
|
||||
expected: expected_behavior_revision,
|
||||
actual: subject.behavior_revision,
|
||||
});
|
||||
}
|
||||
if subject.behavior_md == behavior_md {
|
||||
return Ok(subject);
|
||||
}
|
||||
subject.behavior_md = behavior_md;
|
||||
subject.behavior_revision =
|
||||
subject.behavior_revision.checked_add(1).ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("subject behavior revision overflow".to_string())
|
||||
})?;
|
||||
subject.updated_at = now();
|
||||
let raw = serde_json::to_string(&subject)?;
|
||||
transaction.execute(
|
||||
"UPDATE subjects
|
||||
SET behavior_md = ?2, behavior_revision = ?3,
|
||||
record_json = ?4, updated_at = ?5
|
||||
WHERE subject_id = ?1",
|
||||
params![
|
||||
subject.id,
|
||||
subject.behavior_md,
|
||||
to_i64(subject.behavior_revision)?,
|
||||
raw,
|
||||
subject.updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates an explicit product launch connection against this Workspace's
|
||||
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||
/// alone is insufficient: only an active local record produces host launch material.
|
||||
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
|
||||
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
let subject = require_subject_in_connection(connection, subject_id)?;
|
||||
let state = connection
|
||||
.query_row(
|
||||
"SELECT store_revision, status, snapshot_id
|
||||
FROM surface_generation_state WHERE subject_id = ?1",
|
||||
[subject_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((revision, status, snapshot_id)) = state else {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ungenerated,
|
||||
snapshot: None,
|
||||
});
|
||||
};
|
||||
let revision = u64::try_from(revision).map_err(|_| {
|
||||
SubjektivError::InvalidRecord("surface state revision is negative".into())
|
||||
})?;
|
||||
if revision != subject.store_revision || status == "dirty" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Stale,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
if status == "failed" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Failed,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
let snapshot_id = snapshot_id.ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
|
||||
})?;
|
||||
let raw = connection.query_row(
|
||||
"SELECT snapshot_json FROM surface_snapshots
|
||||
WHERE subject_id = ?1 AND snapshot_id = ?2",
|
||||
params![subject_id, snapshot_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
let snapshot = parse_surface_snapshot(&raw)?;
|
||||
if snapshot.built_from_store_revision != subject.store_revision {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"ready surface snapshot generation does not match subject".into(),
|
||||
));
|
||||
}
|
||||
Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ready,
|
||||
snapshot: Some(snapshot),
|
||||
})
|
||||
resident_surface_in_connection(connection, &subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically reads user-managed behavior and the independently-fresh
|
||||
/// generated Memory surface from one SQLite connection snapshot.
|
||||
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
|
||||
self.database.try_transaction(|transaction| {
|
||||
let subject = require_subject(transaction, subject_id)?;
|
||||
let surface = resident_surface_in_connection(transaction, &subject)?;
|
||||
Ok(SubjectResidentContext { subject, surface })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_surface_in_connection(
|
||||
connection: &Connection,
|
||||
subject: &SubjectRecord,
|
||||
) -> Result<ResidentSurface> {
|
||||
let state = connection
|
||||
.query_row(
|
||||
"SELECT store_revision, status, snapshot_id
|
||||
FROM surface_generation_state WHERE subject_id = ?1",
|
||||
[&subject.id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((revision, status, snapshot_id)) = state else {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ungenerated,
|
||||
snapshot: None,
|
||||
});
|
||||
};
|
||||
let revision = u64::try_from(revision)
|
||||
.map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?;
|
||||
if revision != subject.store_revision || status == "dirty" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Stale,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
if status == "failed" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Failed,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
let snapshot_id = snapshot_id.ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
|
||||
})?;
|
||||
let raw = connection.query_row(
|
||||
"SELECT snapshot_json FROM surface_snapshots
|
||||
WHERE subject_id = ?1 AND snapshot_id = ?2",
|
||||
params![subject.id, snapshot_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
let snapshot = parse_surface_snapshot(&raw)?;
|
||||
if snapshot.built_from_store_revision != subject.store_revision {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"ready surface snapshot generation does not match subject".into(),
|
||||
));
|
||||
}
|
||||
Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ready,
|
||||
snapshot: Some(snapshot),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_subject_behavior(value: &str) -> Result<()> {
|
||||
if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES {
|
||||
return Err(SubjektivError::InvalidRecord(format!(
|
||||
"subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if !value.is_empty() && value.trim().is_empty() {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject behavior must be empty or contain non-whitespace text".to_string(),
|
||||
));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject behavior contains an unsupported control character".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_label(kind: &str, value: &str) -> Result<()> {
|
||||
validate_nonempty(kind, value)?;
|
||||
if value.len() > 256 || value.chars().any(char::is_control) {
|
||||
@@ -3668,6 +3801,67 @@ mod tests {
|
||||
assert!(!complete.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store
|
||||
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(subject.behavior_revision, 0);
|
||||
assert_eq!(subject.store_revision, 0);
|
||||
|
||||
let updated = store
|
||||
.update_subject_behavior(
|
||||
&subject.id,
|
||||
0,
|
||||
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.behavior_revision, 1);
|
||||
assert_eq!(updated.store_revision, 0);
|
||||
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
draft(
|
||||
"A confirmed observation",
|
||||
"exercise Memory lifecycle isolation",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let after_memory = store.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(after_memory.behavior_md, updated.behavior_md);
|
||||
assert_eq!(after_memory.behavior_revision, 1);
|
||||
assert_eq!(after_memory.store_revision, 1);
|
||||
|
||||
let (_reopened_manager, _reopened_workspace, reopened) =
|
||||
open_store(temp.path(), "workspace-a");
|
||||
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(persisted.behavior_md, updated.behavior_md);
|
||||
assert_eq!(persisted.behavior_revision, 1);
|
||||
assert!(matches!(
|
||||
store.update_subject_behavior(&subject.id, 0, String::new()),
|
||||
Err(SubjektivError::SubjectBehaviorConflict {
|
||||
expected: 0,
|
||||
actual: 1,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let unchanged = store
|
||||
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
|
||||
.unwrap();
|
||||
assert_eq!(unchanged.behavior_revision, 1);
|
||||
let cleared = store
|
||||
.update_subject_behavior(&subject.id, 1, String::new())
|
||||
.unwrap();
|
||||
assert_eq!(cleared.behavior_revision, 2);
|
||||
assert!(cleared.behavior_md.is_empty());
|
||||
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
|
||||
assert!(validate_subject_behavior(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -3892,10 +4086,38 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
store.create_subject(role()).unwrap().id
|
||||
let timestamp = now();
|
||||
let subject_id = issued_id("subject");
|
||||
let mut raw = serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp.clone(),
|
||||
"updated_at": timestamp.clone(),
|
||||
});
|
||||
let raw = serde_json::to_string(&raw.take()).unwrap();
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![subject_id, role().as_str(), raw, timestamp, timestamp],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
subject_id
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
|
||||
assert!(migrated_subject.behavior_md.is_empty());
|
||||
assert_eq!(migrated_subject.behavior_revision, 0);
|
||||
let lifecycle_attribution = attribution(
|
||||
&subject_id,
|
||||
"runtime-1",
|
||||
@@ -3935,26 +4157,50 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
let subject_id = issued_id("subject");
|
||||
let timestamp = now();
|
||||
let subject_json = serde_json::to_string(&serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}))
|
||||
.unwrap();
|
||||
let snapshot = SurfaceSnapshot {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: "legacy-surface".into(),
|
||||
subject_id: subject.id.clone(),
|
||||
subject_id: subject_id.clone(),
|
||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||
memory_refs: Vec::new(),
|
||||
built_from_store_revision: subject.store_revision,
|
||||
built_from_store_revision: 0,
|
||||
created_at: now(),
|
||||
};
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject_id,
|
||||
role().as_str(),
|
||||
subject_json,
|
||||
timestamp,
|
||||
timestamp
|
||||
],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshots (
|
||||
subject_id, snapshot_id, built_from_store_revision,
|
||||
snapshot_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject.id,
|
||||
subject_id,
|
||||
snapshot.id,
|
||||
to_i64(snapshot.built_from_store_revision)?,
|
||||
serde_json::to_string(&snapshot)?,
|
||||
@@ -3964,12 +4210,12 @@ mod tests {
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
||||
VALUES (?1, ?2)",
|
||||
params![subject.id, snapshot.id],
|
||||
params![subject_id, snapshot.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(subject.id, snapshot.id)
|
||||
(subject_id, snapshot.id)
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
|
||||
Reference in New Issue
Block a user