feat: integrate persistent Subject behavior context

This commit is contained in:
2026-10-05 18:48:12 +09:00
28 changed files with 2288 additions and 194 deletions
+50 -3
View File
@@ -237,6 +237,7 @@ impl_openapi_schema!(
MemoryConsolidateStagingRequest, MemoryConsolidateStagingRequest,
MemoryConsolidationResponse, MemoryConsolidationResponse,
SubjektivSubjectCreateRequest, SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectListQuery, SubjektivSubjectListQuery,
SubjektivSubjectListResponse, SubjektivSubjectListResponse,
SubjektivSubjectResponse, SubjektivSubjectResponse,
@@ -1163,6 +1164,20 @@ pub trait ServerApi {
#[path] workspace_id: String, #[path] workspace_id: String,
#[path] subject_id: String, #[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>; ) -> 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( #[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
status = 200, status = 200,
@@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest { pub struct SubjektivSubjectCreateRequest {
pub role: String, 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_BROWSER_MAX_LIST_LIMIT: usize = 100;
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse {
pub struct SubjektivSubjectResponse { pub struct SubjektivSubjectResponse {
pub id: String, pub id: String,
pub role: 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, pub state: SubjektivSubjectState,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64, pub store_revision: u64,
@@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse {
pub status: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", content = "input", rename_all = "snake_case")] #[serde(tag = "operation", content = "input", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendOperation { pub enum SubjektivMemoryBackendOperation {
ResidentSummary(memory::backend::MemoryResidentSummaryOperation), ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
Query(SubjektivMemoryQueryRequest), Query(SubjektivMemoryQueryRequest),
Read(SubjektivMemoryReadRequest), Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest), ListRevisions(SubjektivMemoryListRevisionsRequest),
@@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
#[serde(tag = "result", content = "data", rename_all = "snake_case")] #[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendResponse { pub enum SubjektivMemoryBackendResponse {
ResidentSummary(memory::backend::MemoryResidentSummaryOutput), ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
ResidentContext(SubjektivResidentContextOutput),
Query(SubjektivMemoryQueryResponse), Query(SubjektivMemoryQueryResponse),
Read(SubjektivMemoryReadResponse), Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse), ListRevisions(SubjektivMemoryListRevisionsResponse),
@@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection. // `ts-rs` solely for this read-only projection.
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; 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, }; 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!( 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;" "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!( 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 declarations
.into_iter() .into_iter()
.map(|declaration| format!("export {declaration}")) .map(|declaration| format!("export {declaration}"))
+30
View File
@@ -167,6 +167,15 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>, 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 /// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the /// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short. /// previous turn was cut short.
@@ -192,6 +201,7 @@ impl SystemItem {
} }
SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(),
} }
} }
@@ -214,6 +224,7 @@ impl SystemItem {
SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt", 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] #[test]
fn notification_history_text_returns_stored_body() { fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification { let item = SystemItem::Notification {
+1
View File
@@ -2618,6 +2618,7 @@ impl App {
session_store::SystemItem::FileAttachment { body, .. } session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::ResidentSummaryRefresh { body, .. } | session_store::SystemItem::ResidentSummaryRefresh { body, .. }
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => { | session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body); self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body }); self.blocks.push(Block::SystemMessage { text: body });
+1
View File
@@ -1375,6 +1375,7 @@ where
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(), worker.manifest(),
worker.workspace_client_handle(), worker.workspace_client_handle(),
worker.prompts(),
)? )?
{ {
let workspace_id = worker let workspace_id = worker
+357 -62
View File
@@ -4,7 +4,8 @@
//! authority. In that case model-visible Memory tools must go through the //! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. //! 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 std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -24,11 +25,15 @@ use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration, 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::prompt::catalog::{PromptCatalog, WorkerPrompt};
use crate::worker::{ use crate::worker::{
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient, ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
}; };
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
Decode(#[from] serde_json::Error), Decode(#[from] serde_json::Error),
#[error("workspace memory backend rejected operation: {0}")] #[error("workspace memory backend rejected operation: {0}")]
Backend(String), Backend(String),
#[error("workspace memory backend returned an invalid response: {0}")]
InvalidResponse(String),
} }
impl dyn WorkspaceClient + '_ { impl dyn WorkspaceClient + '_ {
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
} }
struct WorkspaceSubjektivResidentSummarySource { #[derive(Default)]
client: Arc<dyn WorkspaceClient>, struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
} }
const READY_EMPTY_RESIDENT_SURFACE: &str = struct WorkspaceSubjektivResidentSummarySource {
"The current ready resident memory surface is intentionally empty."; client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
fn resident_summary_contribution( fn resident_summary_contribution(
output: memory::backend::MemoryResidentSummaryOutput, output: memory::backend::MemoryResidentSummaryOutput,
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
} }
} }
#[async_trait] impl WorkspaceSubjektivResidentSummarySource {
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { async fn fetch(
async fn load(&self) -> SystemPromptContribution { &self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with( match execute_subjektiv_memory_operation_with(
self.client.as_ref(), self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| { |client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT) client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
}, },
) { )? {
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
match resident_summary_contribution(output) { other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
SystemPromptContribution::Ready(content) if content.trim().is_empty() => { "unexpected subject resident context response: {other:?}"
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) ))),
} }
contribution => contribution, }
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)
} }
} Err(error) => {
Ok(other) => { tracing::error!(%error, "subject resident context render failed");
tracing::debug!(?other, "unexpected subject resident Memory response"); SystemPromptContribution::Ready(
SystemPromptContribution::Unavailable "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) => { Err(error) => {
tracing::debug!(%error, "subject resident Memory unavailable"); tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Unavailable 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_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 /// subjektiv-owned restore behavior. Initial prompt contribution remains a
/// shared host facility, while loading and rendering a post-restore refresh is /// shared host facility, while loading and rendering a post-restore refresh is
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
SUBJEKTIV_RESTORE_REFRESH_HOOK, SUBJEKTIV_RESTORE_REFRESH_HOOK,
FeatureHookPoint::WorkerRestored, 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> { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
prompts: Arc::clone(&self.prompts), prompts: Arc::clone(&self.prompts),
workspace_id: self.workspace_id.clone(), 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 { struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>, source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>, 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( pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest, manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> { ) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) { if !ordinary_subjektiv_features_enabled(manifest) {
return Ok(None); return Ok(None);
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
} }
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client, client,
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
}))) })))
} }
@@ -1120,6 +1293,10 @@ mod tests {
use agen::tool::ToolDefinition; use agen::tool::ToolDefinition;
use std::sync::Mutex; use std::sync::Mutex;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
fn test_client() -> Arc<dyn WorkspaceClient> { fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::TestWorkspaceHttpClient::new( Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace", "workspace",
@@ -1132,6 +1309,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability, availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>, content: Option<String>,
scope_allowed: bool, scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>, paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>, timeouts: Mutex<Vec<Duration>>,
} }
@@ -1145,6 +1323,7 @@ mod tests {
availability, availability,
content: content.map(str::to_string), content: content.map(str::to_string),
scope_allowed: true, scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()), paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()), timeouts: Mutex::new(Vec::new()),
} }
@@ -1155,6 +1334,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready, availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()), content: Some("must not be injected".into()),
scope_allowed: false, scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()), paths: Mutex::new(Vec::new()),
timeouts: 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(); serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!( assert!(matches!(
request.operation, request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
)); ));
if !self.scope_allowed { if !self.scope_allowed {
return Ok(WorkspaceResponse { return Ok(WorkspaceResponse {
@@ -1192,13 +1372,18 @@ mod tests {
body: "subject scope unavailable".into(), body: "subject scope unavailable".into(),
}); });
} }
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::to_string( body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary( &server_api::SubjektivMemoryBackendResponse::ResidentContext(
memory::backend::MemoryResidentSummaryOutput { server_api::SubjektivResidentContextOutput {
availability: self.availability, behavior_md,
content: self.content.clone(), behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
}, },
), ),
) )
@@ -1511,13 +1696,19 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready, memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"), Some("current subject surface"),
)); ));
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) let ready = ordinary_subjektiv_resident_summary_source(
.unwrap() &manifest,
.unwrap(); ready_client.clone(),
assert_eq!( test_prompts(),
ready.load().await, )
SystemPromptContribution::Ready("current subject surface".into()) .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!( assert_eq!(
ready_client.paths.lock().unwrap().clone(), ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()] vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1531,13 +1722,18 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready, memory::backend::MemoryResidentSummaryAvailability::Ready,
None, None,
)); ));
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) let ready_empty = ordinary_subjektiv_resident_summary_source(
.unwrap() &manifest,
.unwrap(); ready_empty_client,
assert_eq!( test_prompts(),
ready_empty.load().await, )
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) .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 [ for availability in [
memory::backend::MemoryResidentSummaryAvailability::Ungenerated, memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1550,22 +1746,107 @@ permission = "write"
availability, availability,
Some("must not be injected"), Some("must not be injected"),
)), )),
test_prompts(),
) )
.unwrap() .unwrap()
.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] #[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace"); let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied()); let client = Arc::new(SubjectResidentClient::denied());
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) let source =
.unwrap() ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap(); .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!( assert_eq!(
client.paths.lock().unwrap().clone(), client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()] vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1593,7 +1874,7 @@ permission = "write"
) )
.unwrap(); .unwrap();
assert!( assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client()) ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
.unwrap() .unwrap()
.is_none() .is_none()
); );
@@ -1605,22 +1886,30 @@ permission = "write"
Some("policy-only surface must not load"), Some("policy-only surface must not load"),
)); ));
assert!( assert!(
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone()) ordinary_subjektiv_resident_summary_source(
.unwrap() &policy_only,
.is_none() unattached_client.clone(),
test_prompts()
)
.unwrap()
.is_none()
); );
assert!(unattached_client.paths.lock().unwrap().is_empty()); assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace"); 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] #[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() { fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace"); let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) let source =
.unwrap() ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap(); .unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from( let prompts = Arc::new(ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)); ));
@@ -1634,9 +1923,10 @@ permission = "write"
#[test] #[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() { fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace"); let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) let source =
.unwrap() ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap(); .unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new( let feature = SubjektivResidentRestoreRefreshFeature::new(
source, source,
Arc::new(ArcSwap::from( Arc::new(ArcSwap::from(
@@ -1647,8 +1937,13 @@ permission = "write"
let descriptor = feature.descriptor(); let descriptor = feature.descriptor();
assert!(descriptor.tools.is_empty()); 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[0].point, FeatureHookPoint::WorkerRestored);
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
assert_eq!(
descriptor.hooks[2].point,
FeatureHookPoint::BeforeSessionRewrite
);
} }
#[test] #[test]
+14
View File
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
.expect("system-item append queue poisoned") .expect("system-item append queue poisoned")
.push(item); .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 /// Host-created narrow append authority exposed only at a restore lifecycle
+159 -1
View File
@@ -38,7 +38,7 @@ use crate::hook::{
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog; use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata; use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter; use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
use agen::HistoryEntry; use agen::HistoryEntry;
use agen::token_counter::total_tokens; 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 /// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached. /// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>, log_writer: Option<Arc<dyn SystemItemCommitter>>,
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>, pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`. /// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize, next_turn_index: AtomicUsize,
@@ -163,6 +164,7 @@ impl WorkerInterceptor {
prompts, prompts,
prompt_workspace_id: None, prompt_workspace_id: None,
log_writer, log_writer,
resident_context_source: None,
pending_committed_history, pending_committed_history,
next_turn_index: AtomicUsize::new(0), next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0),
@@ -179,11 +181,31 @@ impl WorkerInterceptor {
self 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 { pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
self.prompt_workspace_id = workspace_id; self.prompt_workspace_id = workspace_id;
self 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` /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is /// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching /// wired). Sync — writes complete before the matching
@@ -211,6 +233,7 @@ impl WorkerInterceptor {
.lock() .lock()
.expect("pending committed history poisoned") .expect("pending committed history poisoned")
.push_back(entry); .push_back(entry);
self.confirm_resident_context_item(item);
} }
Ok(()) Ok(())
} }
@@ -350,6 +373,11 @@ impl WorkerInterceptor {
} if prompt_provenance.is_none() => { } if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.task_reminder")); *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 { SystemItem::Interrupt {
prompt_provenance, .. prompt_provenance, ..
} if prompt_provenance.is_none() => { } if prompt_provenance.is_none() => {
@@ -763,6 +791,7 @@ mod tests {
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
}; };
use crate::session_history::{WorkerHistoryProvenance, history_entry}; use crate::session_history::{WorkerHistoryProvenance, history_entry};
use crate::worker::SystemPromptContribution;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> { fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) 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( fn task_tool_call_info(
name: &str, name: &str,
input: serde_json::Value, 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] #[tokio::test]
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() { async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
let saw_handle = Arc::new(AtomicBool::new(false)); let saw_handle = Arc::new(AtomicBool::new(false));
+18
View File
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
AgentsMdSection, AgentsMdSection,
ResidentMemorySummarySection, ResidentMemorySummarySection,
ResidentMemoryRestoreSection, ResidentMemoryRestoreSection,
SubjektivResidentContext,
WorkerOrchestrationGuidanceSection, WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription, SubWorkerSpawnToolDescription,
} }
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::AgentsMdSection => "internal.agents_md_section", Self::AgentsMdSection => "internal.agents_md_section",
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section", Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => { Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section" "internal.worker_orchestration_guidance_section"
} }
@@ -150,6 +152,7 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection, WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentMemoryRestoreSection, WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription, 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> { pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render( self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::WorkerOrchestrationGuidanceSection,
+53 -9
View File
@@ -2361,9 +2361,30 @@ pub(crate) enum SystemPromptContribution {
Unavailable, Unavailable,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResidentContextRefresh {
pub body: String,
pub revision: u64,
}
#[async_trait::async_trait] #[async_trait::async_trait]
pub(crate) trait SystemPromptContributionSource: Send + Sync { pub(crate) trait SystemPromptContributionSource: Send + Sync {
async fn load(&self) -> SystemPromptContribution; 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)] #[derive(Debug, Clone)]
@@ -3840,6 +3861,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
) )
.with_usage_tracker(self.usage_tracker.clone()) .with_usage_tracker(self.usage_tracker.clone())
.with_metrics_tracker(self.metrics_tracker.clone()) .with_metrics_tracker(self.metrics_tracker.clone())
.with_resident_context_source(self.feature_resident_summary_source.clone())
.with_prompt_workspace_id( .with_prompt_workspace_id(
self.workspace_context self.workspace_context
.workspace_id() .workspace_id()
@@ -9224,16 +9246,20 @@ mod build_summary_prompt_tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!( assert!(matches!(
request.operation, request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
)); ));
self.load_count.fetch_add(1, Ordering::SeqCst); self.load_count.fetch_add(1, Ordering::SeqCst);
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::to_string( body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary( &server_api::SubjektivMemoryBackendResponse::ResidentContext(
memory::backend::MemoryResidentSummaryOutput { server_api::SubjektivResidentContextOutput {
availability: self.availability, behavior_md: "restore behavior".to_string(),
content: self.content.clone(), 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), load_count: Arc::clone(&load_count),
}); });
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( 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()
.unwrap(); .unwrap();
@@ -12219,9 +12247,10 @@ permission = "write"
.unwrap() .unwrap()
.to_string(); .to_string();
assert!( 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); drop(empty_restore);
for availability in [ for availability in [
@@ -12273,7 +12302,22 @@ permission = "write"
.as_text() .as_text()
.unwrap() .unwrap()
.to_string(); .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("latest subject surface"));
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH")); assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
let entries = store.read_all(session_id, segment_id).unwrap(); let entries = store.read_all(session_id, segment_id).unwrap();
+241 -4
View File
@@ -5811,6 +5811,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::subjektiv_subject_get( .merge(server_api::server_api_axum::subjektiv_subject_get(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
.merge(server_api::server_api_axum::subjektiv_resident_surface( .merge(server_api::server_api_axum::subjektiv_resident_surface(
service.clone(), service.clone(),
)) ))
@@ -7486,6 +7487,26 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error) .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( async fn subjektiv_resident_surface(
&self, &self,
workspace_id: String, workspace_id: String,
@@ -17447,6 +17468,8 @@ fn subjektiv_subject_response(
server_api::SubjektivSubjectResponse { server_api::SubjektivSubjectResponse {
id: subject.id, id: subject.id,
role: subject.role.as_str().to_string(), role: subject.role.as_str().to_string(),
behavior_md: subject.behavior_md,
behavior_revision: subject.behavior_revision,
state: match subject.state { state: match subject.state {
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, 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)?; validate_workspace_scope(&api, &path.workspace_id)?;
let role = crate::subjektiv::SubjectRole::new(request.role) let role = crate::subjektiv::SubjectRole::new(request.role)
.map_err(|error| Error::InvalidInput(error.to_string()))?; .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)? let subject = open_subjektiv_store(&api)?
.create_subject(role) .create_subject_with_behavior(role, request.behavior_md)
.map_err(|error| Error::Store(error.to_string()))?; .map_err(subjektiv_store_error)?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(subjektiv_subject_response(&api, subject)), Json(subjektiv_subject_response(&api, subject)),
@@ -17560,6 +17585,24 @@ async fn scoped_get_subjektiv_subject(
Ok(Json(subjektiv_subject_response(&api, 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( fn require_subjektiv_subject(
store: &crate::subjektiv::SubjektivStore, store: &crate::subjektiv::SubjektivStore,
subject_id: &str, subject_id: &str,
@@ -19166,6 +19209,23 @@ async fn scoped_subjektiv_memory_backend(
resident, 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::SubjektivMemoryBackendOperation::Query(input) => {
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
&store, &store,
@@ -20540,6 +20600,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
} }
crate::subjektiv::SubjektivError::RevisionConflict { .. } crate::subjektiv::SubjektivError::RevisionConflict { .. }
| crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. }
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
Error::RepositoryConflict(format!("revision_conflict: {error}")) Error::RepositoryConflict(format!("revision_conflict: {error}"))
} }
@@ -39199,18 +39260,80 @@ mod tests {
app.clone(), app.clone(),
"POST", "POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), &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, &owner_token,
StatusCode::CREATED, StatusCode::CREATED,
) )
.await; .await;
assert_eq!(created["role"], " Browser author "); 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["state"], "active");
assert_eq!(created["store_revision"], 0); assert_eq!(created["store_revision"], 0);
assert!(created.get("current_worker").is_none()); assert!(created.get("current_worker").is_none());
let created_id = created["id"].as_str().unwrap(); let created_id = created["id"].as_str().unwrap();
let persisted = store.subject(created_id).unwrap().unwrap(); let persisted = store.subject(created_id).unwrap().unwrap();
assert_eq!(persisted.role.as_str(), " Browser author "); 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!( assert!(
api.store api.store
.current_worker_singleton_owner( .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] #[tokio::test]
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
@@ -39733,7 +39940,10 @@ mod tests {
assert_eq!(missing.status(), StatusCode::NOT_FOUND); assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let subject = open_subjektiv_store(&api) let subject = open_subjektiv_store(&api)
.unwrap() .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(); .unwrap();
let request = CreateWorkspaceWorkerRequest { let request = CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), 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( let missing_legacy = scoped_record_subjektiv_session(
State(api.clone()), State(api.clone()),
AxumPath(ScopedWorkspacePath { AxumPath(ScopedWorkspacePath {
+310 -64
View File
@@ -11,7 +11,7 @@ use std::collections::HashSet;
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef}; use memory::schema::{SourceEvidenceRef, SourceRef};
use rusqlite::{OptionalExtension, Transaction, params}; use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
pub const MAX_STAGING_ANCHORS: usize = 10; 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 /// Deterministic surface-generation policy. Input estimates use the repository's
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material /// 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 /// payload is capped below the whole-input budget to reserve room for the fixed
@@ -74,6 +76,14 @@ pub enum SubjektivError {
expected: u64, expected: u64,
actual: 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}")] #[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String), SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
pub schema_version: u32, pub schema_version: u32,
pub id: String, pub id: String,
pub role: SubjectRole, 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, pub state: SubjectState,
/// Monotonic generation incremented exactly once for every committed Memory /// Monotonic generation incremented exactly once for every committed Memory
/// creation or revision for this subject. /// creation or revision for this subject.
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
pub snapshot: Option<SurfaceSnapshot>, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryRevisionTarget { pub enum MemoryRevisionTarget {
@@ -986,6 +1010,17 @@ END;
Ok(()) 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] = &[ static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
FeatureMigration::new( FeatureMigration::new(
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add bounded surface generation state and runs", "add bounded surface generation state and runs",
add_surface_generation_state, add_surface_generation_state,
), ),
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
]; ];
pub const REGISTRATION: FeatureRegistration = pub const REGISTRATION: FeatureRegistration =
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
/// Issues a random host-owned subject id. It is never derived from Worker, /// Issues a random host-owned subject id. It is never derived from Worker,
/// Profile, Session, or Memory content. /// Profile, Session, or Memory content.
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> { 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 timestamp = now();
let record = SubjectRecord { let record = SubjectRecord {
schema_version: SUBJEKTIV_SCHEMA_VERSION, schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: issued_id("subject"), id: issued_id("subject"),
role, role,
behavior_md,
behavior_revision: 0,
state: SubjectState::Active, state: SubjectState::Active,
store_revision: 0, store_revision: 0,
created_at: timestamp.clone(), created_at: timestamp.clone(),
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
self.database.try_transaction(|transaction| { self.database.try_transaction(|transaction| {
transaction.execute( transaction.execute(
"INSERT INTO subjects ( "INSERT INTO subjects (
subject_id, role, state, store_revision, record_json, created_at, updated_at subject_id, role, behavior_md, behavior_revision, state,
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
params![ params![
record.id, record.id,
record.role.as_str(), record.role.as_str(),
record.behavior_md,
raw, raw,
record.created_at, record.created_at,
record.updated_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 /// Validates an explicit product launch connection against this Workspace's
/// subject store and the already-resolved Profile policy. Subject knowledge /// subject store and the already-resolved Profile policy. Subject knowledge
/// alone is insufficient: only an active local record produces host launch material. /// 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> { pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
self.database.try_with_connection(|connection| { self.database.try_with_connection(|connection| {
let subject = require_subject_in_connection(connection, subject_id)?; let subject = require_subject_in_connection(connection, subject_id)?;
let state = connection resident_surface_in_connection(connection, &subject)
.query_row( })
"SELECT store_revision, status, snapshot_id }
FROM surface_generation_state WHERE subject_id = ?1",
[subject_id], /// Atomically reads user-managed behavior and the independently-fresh
|row| { /// generated Memory surface from one SQLite connection snapshot.
Ok(( pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
row.get::<_, i64>(0)?, self.database.try_transaction(|transaction| {
row.get::<_, String>(1)?, let subject = require_subject(transaction, subject_id)?;
row.get::<_, Option<String>>(2)?, let surface = resident_surface_in_connection(transaction, &subject)?;
)) Ok(SubjectResidentContext { subject, surface })
},
)
.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),
})
}) })
} }
@@ -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<()> { fn validate_label(kind: &str, value: &str) -> Result<()> {
validate_nonempty(kind, value)?; validate_nonempty(kind, value)?;
if value.len() > 256 || value.chars().any(char::is_control) { if value.len() > 256 || value.chars().any(char::is_control) {
@@ -3668,6 +3801,67 @@ mod tests {
assert!(!complete.has_more); 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] #[test]
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -3892,10 +4086,38 @@ mod tests {
)) ))
.unwrap(); .unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap(); let store = SubjektivStore::open(&workspace, &registration).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 (_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( let lifecycle_attribution = attribution(
&subject_id, &subject_id,
"runtime-1", "runtime-1",
@@ -3935,26 +4157,50 @@ mod tests {
)) ))
.unwrap(); .unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap(); let store = SubjektivStore::open(&workspace, &registration).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 { let snapshot = SurfaceSnapshot {
schema_version: SUBJEKTIV_SCHEMA_VERSION, schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: "legacy-surface".into(), id: "legacy-surface".into(),
subject_id: subject.id.clone(), subject_id: subject_id.clone(),
body_md: "Legacy summary without generation-policy evidence.".into(), body_md: "Legacy summary without generation-policy evidence.".into(),
memory_refs: Vec::new(), memory_refs: Vec::new(),
built_from_store_revision: subject.store_revision, built_from_store_revision: 0,
created_at: now(), created_at: now(),
}; };
store store
.database .database
.try_transaction(|transaction| -> Result<()> { .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( transaction.execute(
"INSERT INTO surface_snapshots ( "INSERT INTO surface_snapshots (
subject_id, snapshot_id, built_from_store_revision, subject_id, snapshot_id, built_from_store_revision,
snapshot_json, created_at snapshot_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5)", ) VALUES (?1, ?2, ?3, ?4, ?5)",
params![ params![
subject.id, subject_id,
snapshot.id, snapshot.id,
to_i64(snapshot.built_from_store_revision)?, to_i64(snapshot.built_from_store_revision)?,
serde_json::to_string(&snapshot)?, serde_json::to_string(&snapshot)?,
@@ -3964,12 +4210,12 @@ mod tests {
transaction.execute( transaction.execute(
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
VALUES (?1, ?2)", VALUES (?1, ?2)",
params![subject.id, snapshot.id], params![subject_id, snapshot.id],
)?; )?;
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
(subject.id, snapshot.id) (subject_id, snapshot.id)
}; };
let (_manager, _workspace, store) = open_store(&root, "workspace-a"); let (_manager, _workspace, store) = open_store(&root, "workspace-a");
+152 -1
View File
@@ -9315,9 +9315,32 @@
], ],
"type": "object" "type": "object"
}, },
"SubjektivSubjectBehaviorUpdateRequest": {
"additionalProperties": false,
"properties": {
"behavior_md": {
"type": "string"
},
"expected_behavior_revision": {
"format": "uint64",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
}
},
"required": [
"expected_behavior_revision",
"behavior_md"
],
"type": "object"
},
"SubjektivSubjectCreateRequest": { "SubjektivSubjectCreateRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"behavior_md": {
"default": "",
"type": "string"
},
"role": { "role": {
"type": "string" "type": "string"
} }
@@ -9383,6 +9406,15 @@
"SubjektivSubjectResponse": { "SubjektivSubjectResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"behavior_md": {
"type": "string"
},
"behavior_revision": {
"format": "uint64",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"created_at": { "created_at": {
"type": "string" "type": "string"
}, },
@@ -9419,6 +9451,8 @@
"required": [ "required": [
"id", "id",
"role", "role",
"behavior_md",
"behavior_revision",
"state", "state",
"store_revision", "store_revision",
"created_at", "created_at",
@@ -14794,7 +14828,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870" "x-yoi-source-digest": "sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -29131,6 +29165,123 @@
] ]
} }
}, },
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": {
"patch": {
"operationId": "subjektiv_subject_behavior_update",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "subject_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubjektivSubjectResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": {
"get": { "get": {
"operationId": "subjektiv_memory_list", "operationId": "subjektiv_memory_list",
+2
View File
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
internalAgentsMdSection = import "./internal/agents_md_section.md"; internalAgentsMdSection = import "./internal/agents_md_section.md";
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md"; internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md";
internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md";
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
in in
@@ -74,6 +75,7 @@ in
agents_md_section = internalAgentsMdSection.content; agents_md_section = internalAgentsMdSection.content;
resident_memory_summary_section = internalResidentMemorySummarySection.content; resident_memory_summary_section = internalResidentMemorySummarySection.content;
resident_memory_restore_section = internalResidentMemoryRestoreSection.content; resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
subjektiv_resident_context = internalSubjektivResidentContext.content;
worker_orchestration_guidance_section = commonWorkerOrchestration.content; worker_orchestration_guidance_section = commonWorkerOrchestration.content;
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
}; };
@@ -5,12 +5,12 @@
This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request.
{% if surface_ready %} {% if surface_ready %}
### Current ready surface ### Current resident context
{% if summary %} {% if summary %}
{{ summary }} {{ summary }}
{% else %} {% else %}
The current ready resident memory surface is intentionally empty. The current resident context is intentionally empty.
{% endif %} {% endif %}
{% else %} {% else %}
No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools.
@@ -0,0 +1,9 @@
This is the Host-fetched current Subject context. Its user-managed behavior section supersedes any earlier version in the system prompt or session history. Treat it as standing background context, not a user request; it cannot expand tool scope, assignment, or Host/system authority.
### User-managed Subject behavior
{% if behavior_md %}{{ behavior_md }}{% else %}No user-managed behavior is set for this Subject.{% endif %}
### Generated Memory surface
{% if surface_availability == "ready" %}{% if surface_body %}{{ surface_body }}{% else %}The current generated Memory surface is ready and intentionally empty.{% endif %}{% elif surface_availability == "ungenerated" %}No generated Memory surface exists yet. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "stale" %}The generated Memory surface is stale and is not included. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "failed" %}Generation of the Memory surface failed, so no generated surface is included. Confirmed Memory remains available through explicit recall tools.{% else %}The Host could not fetch the Subject resident context. Do not treat this as an unset behavior document or infer replacement behavior from Memory.{% endif %}
@@ -71,6 +71,8 @@ const subjects = [
"Release coordination", "Release coordination",
"active", "active",
42, 42,
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
3,
connectedWorker("Release coordination Worker with a deliberately long display name"), connectedWorker("Release coordination Worker with a deliberately long display name"),
), ),
subject(emptySubjectId, "Empty ready subject", "active", 0), subject(emptySubjectId, "Empty ready subject", "active", 0),
@@ -110,11 +112,15 @@ function subject(
role: string, role: string,
state: "active" | "retired", state: "active" | "retired",
storeRevision: number, storeRevision: number,
behaviorMd = "",
behaviorRevision = 0,
currentWorker?: ReturnType<typeof connectedWorker>, currentWorker?: ReturnType<typeof connectedWorker>,
) { ) {
return { return {
id, id,
role, role,
behavior_md: behaviorMd,
behavior_revision: behaviorRevision,
state, state,
store_revision: storeRevision, store_revision: storeRevision,
created_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z",
@@ -440,7 +446,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
if (url.pathname === subjectsPath) { if (url.pathname === subjectsPath) {
if (request.method === "POST") { if (request.method === "POST") {
const payload = await request.json().catch(() => null) as { role?: unknown } | null; const payload = await request.json().catch(() => null) as {
role?: unknown;
behavior_md?: unknown;
} | null;
subjectCreateRequests.push(payload); subjectCreateRequests.push(payload);
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) { if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400); return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
@@ -448,9 +457,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (payload.role === "Permission denied") { if (payload.role === "Permission denied") {
return json({ error: "Forbidden", message: "workspace permission denied" }, 403); return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
} }
if (typeof payload.behavior_md !== "string") {
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
}
createdSubjectCount += 1; createdSubjectCount += 1;
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
const created = subject(id, payload.role, "active", 0); const created = subject(id, payload.role, "active", 0, payload.behavior_md);
createdSubjectIds.add(id); createdSubjectIds.add(id);
subjects.unshift(created); subjects.unshift(created);
return json(created, 201); return json(created, 201);
@@ -479,6 +491,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const foundSubject = subjects.find((item) => item.id === subjectId); const foundSubject = subjects.find((item) => item.id === subjectId);
if (!foundSubject) return json({}, 404); if (!foundSubject) return json({}, 404);
if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") {
const payload = await request.json().catch(() => null) as {
expected_behavior_revision?: unknown;
behavior_md?: unknown;
} | null;
if (
!payload ||
typeof payload.expected_behavior_revision !== "number" ||
typeof payload.behavior_md !== "string"
) {
return json({ error: "Bad Request", message: "invalid behavior update" }, 400);
}
if (payload.expected_behavior_revision !== foundSubject.behavior_revision) {
return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409);
}
foundSubject.behavior_md = payload.behavior_md;
foundSubject.behavior_revision += 1;
foundSubject.updated_at = "2026-01-03T04:05:06Z";
return json(foundSubject);
}
if (request.method !== "GET") return new Response("method not allowed", { status: 405 });
if (parts.length === 1) return json(foundSubject); if (parts.length === 1) return json(foundSubject);
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
if (parts.length === 2 && parts[1] === "memories") { if (parts.length === 2 && parts[1] === "memories") {
@@ -111,6 +111,7 @@ async function openSubjectPage(
await hideSidebar.click(); await hideSidebar.click();
await showSidebar.waitFor(); await showSidebar.waitFor();
await page.waitForTimeout(250);
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
assertEquals( assertEquals(
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
@@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
}); });
page.on("pageerror", (error) => errors.push(String(error))); page.on("pageerror", (error) => errors.push(String(error)));
await openSubjectPage(page, baseUrl, scenario.mobile); await openSubjectPage(page, baseUrl, scenario.mobile);
assertEquals(
await page.getByRole("article", { name: "User-managed Subject behavior" }).count(),
1,
);
await page.screenshot({
path: join(createInteractionArtifacts, `subject-${scenario.label}.png`),
});
const initial = await mainScrollState(page); const initial = await mainScrollState(page);
assert(initial.scrollHeight > initial.clientHeight); assert(initial.scrollHeight > initial.clientHeight);
@@ -434,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const createPage = await createContext.newPage(); const createPage = await createContext.newPage();
const mutationPaths: string[] = []; const mutationPaths: string[] = [];
createPage.on("request", (request) => { createPage.on("request", (request) => {
if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname); if (["POST", "PATCH"].includes(request.method())) {
mutationPaths.push(new URL(request.url()).pathname);
}
}); });
await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`); await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
await createPage.getByText("Subject on the next page").waitFor(); await createPage.getByText("Subject on the next page").waitFor();
@@ -453,9 +463,21 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await createPage.getByRole("button", { name: "Cancel" }).click(); await createPage.getByRole("button", { name: "Cancel" }).click();
await createPage.getByRole("button", { name: "New Subject" }).click(); await createPage.getByRole("button", { name: "New Subject" }).click();
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward "); await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
await createPage.getByRole("textbox", { name: /Behavior/ }).fill(
"Challenge unsupported assumptions before release.",
);
await createPage.getByRole("button", { name: "Create Subject" }).click(); await createPage.getByRole("button", { name: "Create Subject" }).click();
await createPage.waitForURL("**/memory/created-subject-0001"); await createPage.waitForURL("**/memory/created-subject-0001");
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor(); await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
assertEquals(
await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(),
1,
);
assert(
(await createPage.getByRole("article", {
name: "User-managed Subject behavior",
}).textContent())?.includes("Challenge unsupported assumptions before release."),
);
assertEquals( assertEquals(
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1, 1,
@@ -466,15 +488,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
assert((await currentWorker.textContent())?.includes("Not connected")); assert((await currentWorker.textContent())?.includes("Not connected"));
await createPage.getByText("Resident context has not been generated.").waitFor(); await createPage.getByText("Resident context has not been generated.").waitFor();
await createPage.getByText("No committed Memories yet.").waitFor(); await createPage.getByText("No committed Memories yet.").waitFor();
await createPage.getByRole("button", { name: "Edit" }).click();
const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" });
await behaviorEditor.fill(
"Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.",
);
await createPage.screenshot({
path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"),
});
await createPage.getByRole("button", { name: "Save behavior" }).click();
await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor();
assert(
(await createPage.getByRole("status").filter({
hasText: "This page does not confirm application",
}).textContent())?.includes("does not confirm application"),
);
await createPage.screenshot({
path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"),
});
assertEquals(mutationPaths, [ assertEquals(mutationPaths, [
`/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`,
`/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`,
`/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`,
]); ]);
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)) const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
.json(); .json();
assertEquals(createEvidence, { assertEquals(createEvidence, {
count: 2, count: 2,
requests: [{ role: "Permission denied" }, { role: " Release steward " }], requests: [
{ role: "Permission denied", behavior_md: "" },
{
role: " Release steward ",
behavior_md: "Challenge unsupported assumptions before release.",
},
],
}); });
await assertNoPageWideOverflow(createPage); await assertNoPageWideOverflow(createPage);
await createContext.close(); await createContext.close();
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) // Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
maxIdentifierBytes: 512, maxIdentifierBytes: 512,
} as const; } as const;
export const SUBJEKTIV_API_LIMITS = {
maxBehaviorBytes: 16384,
} as const;
export type DiagnosticSeverity = "info" | "warning" | "error"; export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
export type SubjektivSubjectState = "active" | "retired"; 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, }; export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) // Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) // Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) // Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) // Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
+107 -5
View File
@@ -1,4 +1,7 @@
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; import {
MEMORY_API_LIMITS,
SUBJEKTIV_API_LIMITS,
} from "#lib/generated/memory-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import type { import type {
Diagnostic, Diagnostic,
@@ -26,6 +29,7 @@ import type {
SubjektivResidentSurfaceAvailability, SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot, SubjektivResidentSurfaceSnapshot,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest, SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse, SubjektivSubjectListResponse,
SubjektivSubjectResponse, SubjektivSubjectResponse,
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
} as const; } as const;
const MAX_SUBJECT_ROLE_BYTES = 256; const MAX_SUBJECT_ROLE_BYTES = 256;
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024; const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
export type SubjektivSubjectCreateErrorKind = export type SubjektivSubjectCreateErrorKind =
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
"Subject role must be at most 256 bytes and contain no control characters.", "Subject role must be at most 256 bytes and contain no control characters.",
); );
} }
return { role: request.role }; const behaviorMd = request.behavior_md ?? "";
validateSubjectBehavior(behaviorMd);
return { role: request.role, behavior_md: behaviorMd };
}
function validateSubjectBehavior(behaviorMd: string): void {
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
throw new SubjektivSubjectCreateError(
"validation",
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
);
}
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior must be empty or contain non-whitespace text.",
);
}
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior contains an unsupported control character.",
);
}
} }
export async function createSubjektivSubject( export async function createSubjektivSubject(
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
const subject = parseSubjektivSubjectResponse( const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
); );
if (subject.role !== validated.role) { if (
throw new Error("created Subject role does not match the request"); subject.role !== validated.role ||
subject.behavior_md !== validated.behavior_md
) {
throw new Error("created Subject does not match the request");
} }
return subject; return subject;
} catch { } catch {
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
} }
} }
export async function updateSubjektivSubjectBehavior(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjektivSubjectBehaviorUpdateRequest,
): Promise<SubjektivSubjectResponse> {
validateSubjectBehavior(request.behavior_md);
if (
!Number.isSafeInteger(request.expected_behavior_revision) ||
request.expected_behavior_revision < 0
) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior revision is invalid.",
);
}
let response: Response;
try {
response = await fetchFn(
workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`,
),
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The save outcome is unknown. Reload the Subject before retrying.",
);
}
if (!response.ok) {
const message = await subjektivSubjectCreateErrorMessage(response);
throw new SubjektivSubjectCreateError(
response.status >= 500 ? "unknown_outcome" : "rejected",
response.status === 409
? "The behavior changed elsewhere. Reload the Subject and review the latest text."
: message,
response.status,
);
}
try {
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
subjectId,
);
if (subject.behavior_md !== request.behavior_md) {
throw new Error("saved Subject behavior does not match the request");
}
return subject;
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The behavior may have been saved, but the response could not be confirmed. Reload the Subject.",
);
}
}
async function subjektivSubjectCreateErrorMessage( async function subjektivSubjectCreateErrorMessage(
response: Response, response: Response,
): Promise<string> { ): Promise<string> {
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
} catch { } catch {
// A rejected request still has a known outcome even when its body is invalid. // A rejected request still has a known outcome even when its body is invalid.
} }
return `Subject creation failed with HTTP ${response.status}.`; return `Subject request failed with HTTP ${response.status}.`;
} }
export function parseMemoryDocumentResponse( export function parseMemoryDocumentResponse(
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
[ [
"id", "id",
"role", "role",
"behavior_md",
"behavior_revision",
"state", "state",
"store_revision", "store_revision",
"created_at", "created_at",
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
const result: SubjektivSubjectResponse = { const result: SubjektivSubjectResponse = {
id, id,
role: requiredString(record, "role"), role: requiredString(record, "role"),
behavior_md: requiredString(
record,
"behavior_md",
MAX_SUBJECT_BEHAVIOR_BYTES,
),
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
state, state,
store_revision: requiredNonNegativeInteger(record, "store_revision"), store_revision: requiredNonNegativeInteger(record, "store_revision"),
created_at: requiredString(record, "created_at"), created_at: requiredString(record, "created_at"),
@@ -14,13 +14,16 @@
const subjects = $derived(data.subjects.data?.items ?? []); const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false); let createExpanded = $state(false);
let role = $state(''); let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false); let submitting = $state(false);
let createError = $state<string | null>(null); let createError = $state<string | null>(null);
let roleInvalid = $state(false); let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false); let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null); let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>(); let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>(); let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string { function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`); return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -39,6 +42,7 @@
createExpanded = true; createExpanded = true;
createError = null; createError = null;
roleInvalid = false; roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false; createOutcomeUnknown = false;
createdSubject = null; createdSubject = null;
await tick(); await tick();
@@ -49,8 +53,10 @@
if (submitting) return; if (submitting) return;
createExpanded = false; createExpanded = false;
role = ''; role = '';
behaviorMd = '';
createError = null; createError = null;
roleInvalid = false; roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false; createOutcomeUnknown = false;
createdSubject = null; createdSubject = null;
await tick(); await tick();
@@ -62,9 +68,13 @@
submitting = true; submitting = true;
createError = null; createError = null;
roleInvalid = false; roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false; createOutcomeUnknown = false;
try { try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role }); const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject; createdSubject = subject;
try { try {
await goto(subjectHref(subject.id)); await goto(subjectHref(subject.id));
@@ -73,14 +83,16 @@
} }
} catch (error) { } catch (error) {
if (error instanceof SubjektivSubjectCreateError) { if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation'; roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
createOutcomeUnknown = error.kind === 'unknown_outcome'; createOutcomeUnknown = error.kind === 'unknown_outcome';
createError = error.status === 401 || error.status === 403 createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.' ? 'You do not have permission to create Subjects in this Workspace.'
: error.message; : error.message;
if (roleInvalid) { if (roleInvalid || behaviorInvalid) {
await tick(); await tick();
roleInput?.focus(); if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
} }
} else { } else {
createOutcomeUnknown = true; createOutcomeUnknown = true;
@@ -149,9 +161,22 @@
disabled={submitting || createdSubject !== null} disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown} onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined} aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'} aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/> />
</label> </label>
<label class="subject-role-field">
<span>Behavior <small>Optional, user-managed</small></span>
<textarea
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
></textarea>
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
</label>
{#if createError} {#if createError}
<div <div
id="subject-create-error" id="subject-create-error"
@@ -318,20 +343,25 @@
} }
.subject-create-actions { .subject-create-actions {
grid-column: 3; grid-column: 2;
grid-row: 1;
} }
.subject-create-panel { .subject-create-panel {
display: grid; display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto; grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: end; align-items: start;
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-4); padding: var(--space-4);
background: var(--bg-raised); background: var(--bg-raised);
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
} }
.subject-create-copy {
grid-column: 1;
grid-row: 1 / span 4;
align-self: start;
}
.subject-create-copy h2, .subject-create-copy h2,
.subject-create-copy p, .subject-create-copy p,
.subject-create-error p { .subject-create-error p {
@@ -353,7 +383,8 @@
font-weight: 700; font-weight: 700;
} }
.subject-role-field input { .subject-role-field input,
.subject-role-field textarea {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -363,7 +394,15 @@
color: var(--text-strong); color: var(--text-strong);
} }
.subject-role-field input[aria-invalid='true'] { .subject-role-field textarea {
min-height: 8rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.subject-role-field input[aria-invalid='true'],
.subject-role-field textarea[aria-invalid='true'] {
border-color: var(--danger); border-color: var(--danger);
} }
@@ -496,6 +535,7 @@
align-items: stretch; align-items: stretch;
} }
.subject-create-copy,
.subject-create-error, .subject-create-error,
.subject-create-actions { .subject-create-actions {
grid-column: 1; grid-column: 1;
@@ -1,14 +1,91 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte'; import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts'; import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type { import {
SubjektivMemoryState, SubjektivSubjectCreateError,
SubjektivResidentSurfaceAvailability, updateSubjektivSubjectBehavior,
} from '#lib/generated/memory-api.ts'; } from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
// SvelteKit reuses this page across Subject and Workspace routes. A new
// loader snapshot owns a new editor and fences all previous mutations.
$effect(() => {
const workspaceId = data.workspaceId;
const subjectId = data.subjectId;
const loadedSubject = data.subject.data;
untrack(() => {
routeGeneration += 1;
subjectWorkspaceId = workspaceId;
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
behaviorDraft = subject?.behavior_md ?? '';
editingBehavior = false;
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
});
});
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
const generation = routeGeneration;
const workspaceId = subjectWorkspaceId;
const subjectId = subject.id;
const request = {
expected_behavior_revision: subject.behavior_revision,
behavior_md: behaviorDraft,
};
const isCurrent = () => generation === routeGeneration &&
data.workspaceId === workspaceId && data.subjectId === subjectId;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
if (!isCurrent()) return;
subject = updated;
behaviorDraft = updated.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
if (!isCurrent()) return;
behaviorError = error instanceof SubjektivSubjectCreateError
? (error.status === 401 || error.status === 403
? 'You do not have permission to edit this Subject.'
: error.message)
: 'The save outcome is unknown. Reload the Subject before retrying.';
} finally {
if (isCurrent()) behaviorSaving = false;
}
}
function beginBehaviorEdit(): void {
if (!subject) return;
behaviorDraft = subject.behavior_md;
behaviorError = null;
behaviorSaveConfirmed = false;
editingBehavior = true;
}
function cancelBehaviorEdit(): void {
behaviorDraft = subject?.behavior_md ?? '';
behaviorError = null;
editingBehavior = false;
}
const surface = $derived(data.surface.data); const surface = $derived(data.surface.data);
const snapshot = $derived(surface?.snapshot ?? null); const snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []); const memories = $derived(data.memories.data?.items ?? []);
@@ -51,7 +128,7 @@
if (availability !== 'ready') return 'No current generated context is available to use.'; if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim() return snapshot?.body_md.trim()
? 'Current generated context is available.' ? 'Current generated context is available.'
: 'A current empty surface exists; there is no resident context to show.'; : 'A current empty surface exists; there is no generated Memory context to show.';
} }
function subjectStateLabel(state: 'active' | 'retired'): string { function subjectStateLabel(state: 'active' | 'retired'): string {
@@ -60,7 +137,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title> <title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<meta name="description" content="Subject resident surface and current committed Memories" /> <meta name="description" content="Subject resident surface and current committed Memories" />
</svelte:head> </svelte:head>
@@ -70,23 +147,23 @@
<header class="memory-page-header"> <header class="memory-page-header">
<div> <div>
<p class="memory-eyebrow">Memory subject</p> <p class="memory-eyebrow">Memory subject</p>
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1> <h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code> <code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div> </div>
<span class="read-only-label">Read-only</span> <span class="read-only-label">Behavior editable</span>
</header> </header>
{#if data.subject.data} {#if subject}
<section class="subject-overview" aria-label="Subject status"> <section class="subject-overview" aria-label="Subject status">
<div> <div>
<span>Subject status</span> <span>Subject status</span>
<strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong> <strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p> <p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div> </div>
<div> <div>
<span>Worker connection</span> <span>Worker connection</span>
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong> <strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p> <p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div> </div>
<div> <div>
<span>Committed Memories</span> <span>Committed Memories</span>
@@ -99,7 +176,7 @@
{/if} {/if}
</div> </div>
<div> <div>
<span>Resident context</span> <span>Generated Memory context</span>
{#if surface} {#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong> <strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p> <p>{surfaceSummary(surface.availability)}</p>
@@ -113,12 +190,12 @@
<details class="subject-technical-details"> <details class="subject-technical-details">
<summary>Technical details</summary> <summary>Technical details</summary>
<dl> <dl>
<div><dt>Subject ID</dt><dd><code>{data.subject.data.id}</code></dd></div> <div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div> <div>
<dt>Subject store revision</dt> <dt>Subject store revision</dt>
<dd>{data.subject.data.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd> <dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
</div> </div>
<div><dt>Last changed</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div> <div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl> </dl>
</details> </details>
{:else if data.subject.error} {:else if data.subject.error}
@@ -130,6 +207,40 @@
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div> <div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/if} {/if}
{#if subject}
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">User-managed context</p>
<h2 id="subject-behavior-heading">Behavior</h2>
</div>
{#if !editingBehavior}
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
{/if}
</header>
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.</p>
{#if editingBehavior}
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
<label>
<span class="sr-only">Subject behavior</span>
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
</label>
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
<div class="behavior-actions">
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
</div>
</form>
{:else if subject.behavior_md.length > 0}
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
{:else}
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
{/if}
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
</section>
{/if}
<section class="surface-section" aria-labelledby="resident-surface-heading"> <section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading"> <header class="section-heading">
<div> <div>
@@ -434,6 +545,7 @@
color: var(--danger); color: var(--danger);
} }
.behavior-section,
.surface-section, .surface-section,
.memories-section { .memories-section {
display: grid; display: grid;
@@ -442,6 +554,78 @@
padding-top: var(--space-3); padding-top: var(--space-3);
} }
.behavior-help,
.behavior-saved,
.behavior-error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.behavior-error { color: var(--danger); }
.behavior-saved { color: var(--success); }
.behavior-form,
.behavior-form label {
display: grid;
gap: var(--space-3);
}
.behavior-form textarea {
width: 100%;
min-height: 10rem;
padding: var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.behavior-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.behavior-edit,
.behavior-actions button {
padding: var(--space-2) var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
cursor: pointer;
}
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
.behavior-document pre {
margin: 0;
padding: var(--space-4);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.surface-meta, .surface-meta,
.pill-row { .pill-row {
display: flex; display: flex;
@@ -34,6 +34,8 @@ function subject() {
return { return {
id: "subject-1", id: "subject-1",
role: "Release coordinator", role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active" as const, state: "active" as const,
store_revision: 9, store_revision: 9,
created_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z",
@@ -99,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det
}) as HTMLInputElement; }) as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(roleInput)); await waitFor(() => expect(document.activeElement).toBe(roleInput));
await fireEvent.input(roleInput, { target: { value: " Review lead " } }); await fireEvent.input(roleInput, { target: { value: " Review lead " } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
const submit = screen.getByRole("button", { name: "Create Subject" }); const submit = screen.getByRole("button", { name: "Create Subject" });
await fireEvent.click(submit); await fireEvent.click(submit);
@@ -113,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det
resolveRequest(Response.json({ resolveRequest(Response.json({
id: "generated-subject-1", id: "generated-subject-1",
role: " Review lead ", role: " Review lead ",
behavior_md: "Challenge unsupported assumptions.",
behavior_revision: 0,
state: "active", state: "active",
store_revision: 0, store_revision: 0,
created_at: "2026-01-03T00:00:00Z", created_at: "2026-01-03T00:00:00Z",
@@ -130,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det
]; ];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects"); expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
expect(init.method).toBe("POST"); expect(init.method).toBe("POST");
expect(init.body).toBe('{"role":" Review lead "}'); expect(init.body).toBe(
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
);
}); });
test("keeps creation available for an empty list and restores focus on cancel", async () => { test("keeps creation available for an empty list and restores focus on cancel", async () => {
@@ -149,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull(); expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
}); });
test("preserves invalid and rejected role drafts with accessible errors", async () => { test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
const fetchMock = vi.fn(() => const fetchMock = vi.fn(() =>
Promise.resolve(Response.json( Promise.resolve(Response.json(
{ {
@@ -175,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(input, { target: { value: "Release reviewer" } }); await fireEvent.input(input, { target: { value: "Release reviewer" } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, { target: { value: " " } });
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
expect(screen.getByRole("alert").textContent).toContain(
"empty or contain non-whitespace",
);
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain( expect(screen.getByRole("alert").textContent).toContain(
@@ -273,6 +299,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
.not.toBeNull(); .not.toBeNull();
}); });
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident context" })).not expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull(); .toBeNull();
expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
@@ -292,6 +323,272 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.toContain("cursor=memory-next"); .toContain("cursor=memory-next");
}); });
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...subject(),
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
render(SubjectPage, {
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
subject: result(subject()),
surface: result({
subject_id: "subject-1",
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
},
} as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
await fireEvent.input(textarea, {
target: { value: "Ask before irreversible actions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
expect(init.method).toBe("PATCH");
expect(init.body).toBe(
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
);
await waitFor(() => {
expect(
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
)
.toContain("does not confirm application");
});
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Ask before irreversible actions.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const secondBody =
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
await waitFor(() =>
expect(screen.getByText("No behavior is set.")).not.toBeNull()
);
});
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
return {
workspaceId,
subjectId: record.id,
subject: result(record),
surface: result({
subject_id: record.id,
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
};
}
test.each([
["subject-2", "workspace-1"],
["subject-1", "workspace-2"],
])(
"resets reused Subject editor for %s in %s and saves only its own behavior",
async (id, workspaceId) => {
const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." };
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...nextSubject,
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Unsaved A draft" },
},
);
await view.rerender(
{ data: subjectPageData(nextSubject, workspaceId) } as never,
);
expect(screen.queryByRole("textbox", { name: "Subject behavior" }))
.toBeNull();
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Only B behavior.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Only B behavior.");
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe(
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
);
expect(JSON.parse(String(init.body))).toEqual({
expected_behavior_revision: 2,
behavior_md: "Only B behavior.",
});
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
},
);
test.each(["success", "failure"])(
"fences late %s from a previous Subject without clearing the new save state",
async (outcome) => {
const pending: Array<(response: Response) => void> = [];
const fetchMock = vi.fn(() =>
new Promise<Response>((resolve) => pending.push(resolve))
);
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const nextSubject = {
...subject(),
id: "subject-2",
behavior_md: "Only B behavior.",
};
await view.rerender({ data: subjectPageData(nextSubject) } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Updated B behavior." },
},
);
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
pending[0](
outcome === "success"
? Response.json({
...subject(),
behavior_md: "Late A response",
behavior_revision: 3,
})
: Response.json({
error: "Forbidden",
message: "A denied",
diagnostics: [],
}, { status: 403 }),
);
// Drain the previous response through its success/catch/finally branches.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"),
).toBe(true);
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Updated B behavior.");
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/storage confirmed/)).toBeNull();
pending[1](
Response.json({
...nextSubject,
behavior_md: "Updated B behavior.",
behavior_revision: 3,
}),
);
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Updated B behavior.");
},
);
test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => {
let resolveRequest!: (response: Response) => void;
vi.stubGlobal(
"fetch",
vi.fn(() =>
new Promise<Response>((resolve) => {
resolveRequest = resolve;
})
),
);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await view.rerender(
{ data: subjectPageData({ ...subject(), id: "subject-2" }) } as never,
);
const fresh = {
...subject(),
behavior_md: "Fresh A loader",
behavior_revision: 4,
};
await view.rerender({ data: subjectPageData(fresh) } as never);
resolveRequest(
Response.json({
...subject(),
behavior_md: "Stale A response",
behavior_revision: 3,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Fresh A loader");
expect(screen.queryByText(/storage confirmed/)).toBeNull();
await view.rerender(
{
data: {
...subjectPageData(),
subject: result(null, "Subject unavailable"),
},
} as never,
);
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
expect(
screen.queryByRole("article", { name: "User-managed Subject behavior" }),
).toBeNull();
});
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => { test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes // The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
// stages a candidate without committing a Memory. Its public read projection is // stages a candidate without committing a Memory. Its public read projection is
@@ -320,6 +617,65 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
).toBeNull(); ).toBeNull();
}); });
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
async (availability) => {
const record = { ...subject(), store_revision: 0 };
const view = render(SubjectPage, {
data: {
...subjectPageData(record),
surface: result({
subject_id: record.id,
availability,
...(availability === "ready"
? {
snapshot: {
snapshot_id: "empty-snapshot",
body_md: "",
memory_refs: [],
built_from_store_revision: 0,
created_at: "2026-01-02T03:04:05Z",
},
}
: {}),
}),
},
} as never);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(screen.getByText("Generated Memory context", { exact: true })).not
.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
const technical = view.container.querySelector(
"details.subject-technical-details",
) as HTMLDetailsElement;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain("Subject store revision");
if (availability === "ready") {
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/no generated Memory context to show/)).not
.toBeNull();
expect(
view.container.querySelector("details.surface-details")?.textContent,
)
.toContain("Generated from Subject revision");
}
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(technical.open).toBe(false);
},
);
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = { const base = {
workspaceId: "workspace-1", workspaceId: "workspace-1",
+75 -2
View File
@@ -12,6 +12,7 @@ import {
parseSubjektivSubjectListResponse, parseSubjektivSubjectListResponse,
parseSubjektivSubjectResponse, parseSubjektivSubjectResponse,
SubjektivSubjectCreateError, SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
validateSubjektivSubjectCreateRequest, validateSubjektivSubjectCreateRequest,
} from "../src/lib/workspace/memory/api.ts"; } from "../src/lib/workspace/memory/api.ts";
@@ -73,6 +74,8 @@ function subject(id = "subject-1") {
return { return {
id, id,
role: "Release coordinator", role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active", state: "active",
store_revision: 12, store_revision: 12,
created_at: "2026-09-01T00:00:00Z", created_at: "2026-09-01T00:00:00Z",
@@ -271,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => { Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
assertEquals( assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }), validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator " }, { role: " Release coordinator ", behavior_md: "" },
); );
assertThrows( assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }), () => validateSubjektivSubjectCreateRequest({ role: " " }),
@@ -285,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }), () => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
"at most 256 bytes", "at most 256 bytes",
); );
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: " ".repeat(3),
}),
"empty or contain non-whitespace",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: "x".repeat(16 * 1024 + 1),
}),
"at most 16384 bytes",
);
});
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
const requests: Array<{ path: string; init?: RequestInit }> = [];
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
requests.push({ path: String(path), init });
return Response.json({
...subject("subject/one"),
behavior_md: "Line one.\nLine two.",
behavior_revision: 3,
});
}) as typeof fetch;
const updated = await updateSubjektivSubjectBehavior(
fetchFn,
"workspace one",
"subject/one",
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
);
assertEquals(updated.behavior_revision, 3);
assertEquals(updated.behavior_md, "Line one.\nLine two.");
assertEquals(
requests[0].path,
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
);
assertEquals(requests[0].init?.method, "PATCH");
assertEquals(
requests[0].init?.body,
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
);
});
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
const conflictFetch =
(async () =>
Response.json({ message: "revision conflict" }, {
status: 409,
})) as typeof fetch;
await assertSubjectCreateRejects(
() =>
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
expected_behavior_revision: 1,
behavior_md: "new",
}),
"rejected",
"changed elsewhere",
409,
);
}); });
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => { Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
@@ -294,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
return Response.json({ return Response.json({
...subject("subject-created"), ...subject("subject-created"),
role: " Release coordinator ", role: " Release coordinator ",
behavior_md: "Stay concise.",
}, { status: 201 }); }, { status: 201 });
}) as typeof fetch; }) as typeof fetch;
const created = await createSubjektivSubject(fetchFn, "workspace one", { const created = await createSubjektivSubject(fetchFn, "workspace one", {
role: " Release coordinator ", role: " Release coordinator ",
behavior_md: "Stay concise.",
}); });
assertEquals(created, { assertEquals(created, {
...subject("subject-created"), ...subject("subject-created"),
role: " Release coordinator ", role: " Release coordinator ",
behavior_md: "Stay concise.",
}); });
assertEquals(requests.length, 1); assertEquals(requests.length, 1);
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects"); assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
assertEquals(requests[0].init?.method, "POST"); assertEquals(requests[0].init?.method, "POST");
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}'); assertEquals(
requests[0].init?.body,
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
);
}); });
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => { Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {