Add persistent Subject behavior context

This commit is contained in:
2026-10-05 16:27:23 +09:00
parent cc97987482
commit 8891236db3
28 changed files with 1999 additions and 190 deletions
+50 -3
View File
@@ -237,6 +237,7 @@ impl_openapi_schema!(
MemoryConsolidateStagingRequest,
MemoryConsolidationResponse,
SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectListQuery,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -1163,6 +1164,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn subjektiv_subject_behavior_update(
&self,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
status = 200,
@@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState {
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest {
pub role: String,
#[serde(default)]
pub behavior_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectBehaviorUpdateRequest {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub expected_behavior_revision: u64,
pub behavior_md: String,
}
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse {
pub struct SubjektivSubjectResponse {
pub id: String,
pub role: String,
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
pub state: SubjektivSubjectState,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64,
@@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivResidentContextOutput {
/// Exact user-managed document. Empty means explicitly unset.
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
/// Independently-fresh generated Memory projection.
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendOperation {
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
Query(SubjektivMemoryQueryRequest),
Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest),
@@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendResponse {
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
ResidentContext(SubjektivResidentContextOutput),
Query(SubjektivMemoryQueryResponse),
Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse),
@@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection.
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -9896,8 +9940,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
let limits = format!(
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
);
let subjektiv_limits = format!(
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
);
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
+30
View File
@@ -167,6 +167,15 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh {
body: String,
behavior_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
@@ -192,6 +201,7 @@ impl SystemItem {
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(),
}
}
@@ -214,6 +224,7 @@ impl SystemItem {
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt",
}
}
@@ -270,6 +281,25 @@ mod tests {
));
}
#[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(),
behavior_revision: 7,
prompt_provenance: None,
};
let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!(
parsed,
SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
..
}
));
}
#[test]
fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification {
+1
View File
@@ -2618,6 +2618,7 @@ impl App {
session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
+1
View File
@@ -1375,6 +1375,7 @@ where
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts(),
)?
{
let workspace_id = worker
+352 -63
View File
@@ -4,7 +4,8 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use arc_swap::ArcSwap;
@@ -23,11 +24,15 @@ use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
use crate::hook::{
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
WorkerRestored,
};
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
use crate::worker::{
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
#[derive(Clone, Debug)]
@@ -73,6 +78,8 @@ pub enum WorkspaceMemoryBackendError {
Decode(#[from] serde_json::Error),
#[error("workspace memory backend rejected operation: {0}")]
Backend(String),
#[error("workspace memory backend returned an invalid response: {0}")]
InvalidResponse(String),
}
impl dyn WorkspaceClient + '_ {
@@ -653,12 +660,17 @@ struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
}
const READY_EMPTY_RESIDENT_SURFACE: &str =
"The current ready resident memory surface is intentionally empty.";
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
fn resident_summary_contribution(
output: memory::backend::MemoryResidentSummaryOutput,
@@ -696,36 +708,132 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
}
}
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
)
.await?
{
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
"unexpected subject resident context response: {other:?}"
))),
}
}
fn render(
&self,
output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability {
Availability::Ready => "ready",
Availability::Ungenerated => "ungenerated",
Availability::Stale => "stale",
Availability::Failed => "failed",
Availability::Unavailable => "unavailable",
};
self.prompts
.load()
.subjektiv_resident_context(
&output.behavior_md,
availability,
output.memory_surface.content.as_deref(),
)
.map_err(|error| error.to_string())
}
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
}
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
match execute_subjektiv_memory_operation(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
)
.await
{
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
match resident_summary_contribution(output) {
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
}
contribution => contribution,
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok(rendered) => {
self.remember_behavior(&output);
SystemPromptContribution::Ready(rendered)
}
}
Ok(other) => {
tracing::debug!(?other, "unexpected subject resident Memory response");
SystemPromptContribution::Unavailable
}
Err(error) => {
tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready(
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
},
Err(error) => {
tracing::debug!(%error, "subject resident Memory unavailable");
SystemPromptContribution::Unavailable
tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready(
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
}
}
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
let output = self.fetch().await.map_err(|error| error.to_string())?;
let changed = self
.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
}
fn invalidate_resident_context_representation(&self) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = None;
}
}
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
/// shared host facility, while loading and rendering a post-restore refresh is
@@ -773,6 +881,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
SUBJEKTIV_RESTORE_REFRESH_HOOK,
FeatureHookPoint::WorkerRestored,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
FeatureHookPoint::PreLlmRequest,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
FeatureHookPoint::BeforeSessionRewrite,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
@@ -784,10 +900,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
prompts: Arc::clone(&self.prompts),
workspace_id: self.workspace_id.clone(),
},
)?;
context.hooks().add_pre_llm_request(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRefreshHook {
source: Arc::clone(&self.source),
},
)?;
context.hooks().add_before_session_rewrite(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRewriteHook {
source: Arc::clone(&self.source),
},
)
}
}
struct SubjektivBehaviorRewriteHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
async fn call(
&self,
_context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
self.source.invalidate_resident_context_representation();
Ok(BeforeSessionRewriteAction::Continue)
}
}
struct SubjektivBehaviorRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
let refresh = self
.source
.load_changed_resident_context()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
if let Some(refresh) = refresh {
let system_items = context.system_items().ok_or_else(|| {
HookError::new(
HookErrorCategory::Dependency,
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
}
Ok(HookPreRequestAction::Continue)
}
}
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -931,6 +1101,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
return Ok(None);
@@ -968,6 +1139,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
}
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
}
@@ -1100,6 +1273,10 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -1112,6 +1289,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>,
}
@@ -1124,6 +1302,7 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()),
}
}
@@ -1133,6 +1312,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()),
}
}
@@ -1161,7 +1341,7 @@ mod tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
if !self.scope_allowed {
return Ok(WorkspaceResponse {
@@ -1169,13 +1349,18 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
},
),
)
@@ -1479,13 +1664,19 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
.unwrap()
.unwrap();
assert_eq!(
ready.load().await,
SystemPromptContribution::Ready("current subject surface".into())
);
let ready = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_client.clone(),
test_prompts(),
)
.unwrap()
.unwrap();
let loaded = ready.load().await;
let SystemPromptContribution::Ready(loaded) = loaded else {
panic!("subject resident context must be present");
};
assert!(loaded.contains("Be deliberate."));
assert!(loaded.contains("current subject surface"));
assert_eq!(
ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1495,13 +1686,18 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
.unwrap()
.unwrap();
assert_eq!(
ready_empty.load().await,
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
);
let ready_empty = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_empty_client,
test_prompts(),
)
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
panic!("empty surface must retain Subject behavior");
};
assert!(ready_empty.contains("Be deliberate."));
assert!(ready_empty.contains("intentionally empty"));
for availability in [
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1514,22 +1710,101 @@ permission = "write"
availability,
Some("must not be injected"),
)),
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(loaded) = source.load().await else {
panic!("Subject behavior must survive unavailable surfaces");
};
assert!(loaded.contains("Be deliberate."));
assert!(!loaded.contains("must not be injected"));
}
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let initial = source.load().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
let replay_after_rewrite = source
.load_changed_resident_context()
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert!(
replay_after_rewrite
.body
.contains("No user-managed behavior is set")
);
}
#[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied());
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
};
assert!(failure.contains("could not fetch"));
assert_eq!(
client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1557,7 +1832,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1569,22 +1844,30 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
.unwrap()
.is_none()
ordinary_subjektiv_resident_summary_source(
&policy_only,
unattached_client.clone(),
test_prompts()
)
.unwrap()
.is_none()
);
assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace");
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
assert!(
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
.is_err()
);
}
#[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
));
@@ -1598,9 +1881,10 @@ permission = "write"
#[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
source,
Arc::new(ArcSwap::from(
@@ -1611,8 +1895,13 @@ permission = "write"
let descriptor = feature.descriptor();
assert!(descriptor.tools.is_empty());
assert_eq!(descriptor.hooks.len(), 1);
assert_eq!(descriptor.hooks.len(), 3);
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
assert_eq!(
descriptor.hooks[2].point,
FeatureHookPoint::BeforeSessionRewrite
);
}
#[test]
+14
View File
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
.expect("system-item append queue poisoned")
.push(item);
}
/// Queue a revisioned Subject behavior refresh. The caller receives this
/// narrow operation only at a pre-request boundary, and the host commits it
/// before exposing it to the model.
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(SystemItem::SubjectBehaviorRefresh {
body: body.into(),
behavior_revision,
prompt_provenance: None,
});
}
}
/// Host-created narrow append authority exposed only at a restore lifecycle
+159 -1
View File
@@ -38,7 +38,7 @@ use crate::hook::{
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter;
use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
use agen::HistoryEntry;
use agen::token_counter::total_tokens;
@@ -109,6 +109,7 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
@@ -163,6 +164,7 @@ impl WorkerInterceptor {
prompts,
prompt_workspace_id: None,
log_writer,
resident_context_source: None,
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
@@ -179,11 +181,31 @@ impl WorkerInterceptor {
self
}
pub(crate) fn with_resident_context_source(
mut self,
source: Option<Arc<dyn SystemPromptContributionSource>>,
) -> Self {
self.resident_context_source = source;
self
}
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
self.prompt_workspace_id = workspace_id;
self
}
fn confirm_resident_context_item(&self, item: &SystemItem) {
let Some(source) = self.resident_context_source.as_ref() else {
return;
};
if let SystemItem::SubjectBehaviorRefresh {
behavior_revision, ..
} = item
{
source.confirm_resident_context_revision(*behavior_revision);
}
}
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@@ -211,6 +233,7 @@ impl WorkerInterceptor {
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
self.confirm_resident_context_item(item);
}
Ok(())
}
@@ -350,6 +373,11 @@ impl WorkerInterceptor {
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.task_reminder"));
}
SystemItem::SubjectBehaviorRefresh {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
}
SystemItem::Interrupt {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
@@ -763,6 +791,7 @@ mod tests {
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
};
use crate::session_history::{WorkerHistoryProvenance, history_entry};
use crate::worker::SystemPromptContribution;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
@@ -859,6 +888,53 @@ mod tests {
}
}
struct AppendingSubjectBehaviorHook;
#[async_trait]
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
async fn call(
&self,
input: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
input
.system_items()
.expect("test interceptor has durable append authority")
.append_subject_behavior_refresh("current Subject context", 7);
Ok(HookPreRequestAction::Continue)
}
}
struct ConfirmRecordingResidentSource {
confirmed: Arc<Mutex<Vec<u64>>>,
}
#[async_trait]
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
async fn load(&self) -> SystemPromptContribution {
SystemPromptContribution::Unavailable
}
fn confirm_resident_context_revision(&self, revision: u64) {
self.confirmed.lock().unwrap().push(revision);
}
}
struct FailingSystemItemCommitter;
impl SystemItemCommitter for FailingSystemItemCommitter {
fn commit_log_entry(
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) {
return Err(session_store::StoreError::Io(std::io::Error::other(
"synthetic system-item failure",
)));
}
Ok(())
}
}
fn task_tool_call_info(
name: &str,
input: serde_json::Value,
@@ -1346,6 +1422,88 @@ mod tests {
}
}
#[tokio::test]
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let committed = Arc::new(Mutex::new(Vec::new()));
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
assert_eq!(*confirmed.lock().unwrap(), [7]);
assert!(matches!(
committed.lock().unwrap().as_slice(),
[SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
prompt_provenance: Some(_),
..
}]
));
}
#[tokio::test]
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(FailingSystemItemCommitter)),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(
action,
PreRequestAction::Cancel(reason) if reason.contains("session persistence failed")
));
assert!(confirmed.lock().unwrap().is_empty());
}
#[tokio::test]
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
let saw_handle = Arc::new(AtomicBool::new(false));
+18
View File
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
AgentsMdSection,
ResidentMemorySummarySection,
ResidentMemoryRestoreSection,
SubjektivResidentContext,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::AgentsMdSection => "internal.agents_md_section",
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
}
@@ -150,6 +152,7 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -518,6 +521,21 @@ impl PromptCatalog {
},
)
}
pub fn subjektiv_resident_context(
&self,
behavior_md: &str,
surface_availability: &str,
surface_body: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivResidentContext,
minijinja::context! {
behavior_md => behavior_md,
surface_availability => surface_availability,
surface_body => surface_body.unwrap_or_default(),
},
)
}
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection,
+53 -9
View File
@@ -2346,9 +2346,30 @@ pub(crate) enum SystemPromptContribution {
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResidentContextRefresh {
pub body: String,
pub revision: u64,
}
#[async_trait::async_trait]
pub(crate) trait SystemPromptContributionSource: Send + Sync {
async fn load(&self) -> SystemPromptContribution;
/// Returns a durable model-visible refresh only when user-managed resident
/// context changed since the last successful load.
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
Ok(None)
}
/// Acknowledges that the revision's typed refresh was durably committed.
fn confirm_resident_context_revision(&self, _revision: u64) {}
/// Invalidates the in-memory representation fence before history may be
/// compacted or rewound.
fn invalidate_resident_context_representation(&self) {}
}
#[derive(Debug, Clone)]
@@ -3825,6 +3846,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
)
.with_usage_tracker(self.usage_tracker.clone())
.with_metrics_tracker(self.metrics_tracker.clone())
.with_resident_context_source(self.feature_resident_summary_source.clone())
.with_prompt_workspace_id(
self.workspace_context
.workspace_id()
@@ -9191,16 +9213,20 @@ mod build_summary_prompt_tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
self.load_count.fetch_add(1, Ordering::SeqCst);
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md: "restore behavior".to_string(),
behavior_revision: 1,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
},
),
)
@@ -9221,7 +9247,9 @@ mod build_summary_prompt_tests {
load_count: Arc::clone(&load_count),
});
let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
manifest, client,
manifest,
client,
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
)
.unwrap()
.unwrap();
@@ -12186,9 +12214,10 @@ permission = "write"
.unwrap()
.to_string();
assert!(
empty_boundary.contains("current ready resident memory surface is intentionally empty")
empty_boundary
.contains("current generated Memory surface is ready and intentionally empty")
);
assert!(!empty_boundary.contains("No current ready resident memory surface"));
assert!(!empty_boundary.contains("No generated Memory surface exists yet"));
drop(empty_restore);
for availability in [
@@ -12240,7 +12269,22 @@ permission = "write"
.as_text()
.unwrap()
.to_string();
assert!(tombstone.contains("No current ready resident memory surface"));
let unavailable_message = match availability {
memory::backend::MemoryResidentSummaryAvailability::Ungenerated => {
"No generated Memory surface exists yet"
}
memory::backend::MemoryResidentSummaryAvailability::Stale => {
"generated Memory surface is stale"
}
memory::backend::MemoryResidentSummaryAvailability::Failed => {
"Generation of the Memory surface failed"
}
memory::backend::MemoryResidentSummaryAvailability::Unavailable => {
"Host could not fetch the Subject resident context"
}
memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(),
};
assert!(tombstone.contains(unavailable_message));
assert!(!tombstone.contains("latest subject surface"));
assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH"));
let entries = store.read_all(session_id, segment_id).unwrap();
+241 -4
View File
@@ -5760,6 +5760,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::subjektiv_subject_get(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
.merge(server_api::server_api_axum::subjektiv_resident_surface(
service.clone(),
))
@@ -7435,6 +7436,26 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_subject_behavior_update(
&self,
workspace_id: String,
subject_id: String,
request: server_api::SubjektivSubjectBehaviorUpdateRequest,
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
{
scoped_update_subjektiv_subject_behavior(
State(self.workspace_api()?.clone()),
AxumPath(ScopedSubjektivSubjectPath {
workspace_id,
subject_id,
}),
Json(request),
)
.await
.map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_resident_surface(
&self,
workspace_id: String,
@@ -17328,6 +17349,8 @@ fn subjektiv_subject_response(
server_api::SubjektivSubjectResponse {
id: subject.id,
role: subject.role.as_str().to_string(),
behavior_md: subject.behavior_md,
behavior_revision: subject.behavior_revision,
state: match subject.state {
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired,
@@ -17347,9 +17370,11 @@ async fn scoped_create_subjektiv_subject(
validate_workspace_scope(&api, &path.workspace_id)?;
let role = crate::subjektiv::SubjectRole::new(request.role)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
let subject = open_subjektiv_store(&api)?
.create_subject(role)
.map_err(|error| Error::Store(error.to_string()))?;
.create_subject_with_behavior(role, request.behavior_md)
.map_err(subjektiv_store_error)?;
Ok((
StatusCode::CREATED,
Json(subjektiv_subject_response(&api, subject)),
@@ -17441,6 +17466,24 @@ async fn scoped_get_subjektiv_subject(
Ok(Json(subjektiv_subject_response(&api, subject)))
}
async fn scoped_update_subjektiv_subject_behavior(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
Json(request): Json<server_api::SubjektivSubjectBehaviorUpdateRequest>,
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
let subject = open_subjektiv_store(&api)?
.update_subject_behavior(
&path.subject_id,
request.expected_behavior_revision,
request.behavior_md,
)
.map_err(subjektiv_store_error)?;
Ok(Json(subjektiv_subject_response(&api, subject)))
}
fn require_subjektiv_subject(
store: &crate::subjektiv::SubjektivStore,
subject_id: &str,
@@ -19047,6 +19090,23 @@ async fn scoped_subjektiv_memory_backend(
resident,
))
}
server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"resident subject context read",
)?;
let resident = store
.resident_context(&subject_id)
.map_err(subjektiv_store_error)?;
server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md: resident.subject.behavior_md,
behavior_revision: resident.subject.behavior_revision,
memory_surface: resident_summary_output(resident.surface),
},
)
}
server_api::SubjektivMemoryBackendOperation::Query(input) => {
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
&store,
@@ -20421,6 +20481,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
}
crate::subjektiv::SubjektivError::RevisionConflict { .. }
| crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. }
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
Error::RepositoryConflict(format!("revision_conflict: {error}"))
}
@@ -39008,18 +39069,80 @@ mod tests {
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
Some(serde_json::json!({ "role": " Browser author " })),
Some(serde_json::json!({
"role": " Browser author ",
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
})),
&owner_token,
StatusCode::CREATED,
)
.await;
assert_eq!(created["role"], " Browser author ");
assert_eq!(
created["behavior_md"],
"Prefer explicit evidence.\nAsk when uncertain."
);
assert_eq!(created["behavior_revision"], 0);
assert_eq!(created["state"], "active");
assert_eq!(created["store_revision"], 0);
assert!(created.get("current_worker").is_none());
let created_id = created["id"].as_str().unwrap();
let persisted = store.subject(created_id).unwrap().unwrap();
assert_eq!(persisted.role.as_str(), " Browser author ");
assert_eq!(persisted.behavior_revision, 0);
let behavior_uri =
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior");
request_json(
app.clone(),
"PATCH",
&behavior_uri,
Some(serde_json::json!({
"expected_behavior_revision": 0,
"behavior_md": "Updated without rewriting history."
})),
StatusCode::UNAUTHORIZED,
)
.await;
let updated = request_json_authenticated(
app.clone(),
"PATCH",
&behavior_uri,
Some(serde_json::json!({
"expected_behavior_revision": 0,
"behavior_md": "Updated without rewriting history."
})),
&owner_token,
StatusCode::OK,
)
.await;
assert_eq!(updated["behavior_md"], "Updated without rewriting history.");
assert_eq!(updated["behavior_revision"], 1);
request_json_authenticated(
app.clone(),
"PATCH",
&behavior_uri,
Some(serde_json::json!({
"expected_behavior_revision": 0,
"behavior_md": "stale write"
})),
&owner_token,
StatusCode::CONFLICT,
)
.await;
let cleared = request_json_authenticated(
app.clone(),
"PATCH",
&behavior_uri,
Some(serde_json::json!({
"expected_behavior_revision": 1,
"behavior_md": ""
})),
&owner_token,
StatusCode::OK,
)
.await;
assert_eq!(cleared["behavior_md"], "");
assert_eq!(cleared["behavior_revision"], 2);
assert!(
api.store
.current_worker_singleton_owner(
@@ -39120,6 +39243,90 @@ mod tests {
);
}
#[tokio::test]
async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let (status, Json(created)) = scoped_create_subjektiv_subject(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(server_api::SubjektivSubjectCreateRequest {
role: "companion".to_string(),
behavior_md: "Prefer explicit evidence.".to_string(),
}),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created.behavior_revision, 0);
assert_eq!(created.behavior_md, "Prefer explicit evidence.");
let Json(updated) = scoped_update_subjektiv_subject_behavior(
State(api.clone()),
AxumPath(ScopedSubjektivSubjectPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
subject_id: created.id.clone(),
}),
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
expected_behavior_revision: 0,
behavior_md: "Ask when uncertain.".to_string(),
}),
)
.await
.unwrap();
assert_eq!(updated.behavior_revision, 1);
assert_eq!(updated.behavior_md, "Ask when uncertain.");
let conflict = scoped_update_subjektiv_subject_behavior(
State(api.clone()),
AxumPath(ScopedSubjektivSubjectPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
subject_id: created.id.clone(),
}),
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
expected_behavior_revision: 0,
behavior_md: "Stale edit".to_string(),
}),
)
.await
.unwrap_err();
assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT);
let Json(cleared) = scoped_update_subjektiv_subject_behavior(
State(api.clone()),
AxumPath(ScopedSubjektivSubjectPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
subject_id: created.id,
}),
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
expected_behavior_revision: 1,
behavior_md: String::new(),
}),
)
.await
.unwrap();
assert_eq!(cleared.behavior_revision, 2);
assert!(cleared.behavior_md.is_empty());
let oversized = scoped_create_subjektiv_subject(
State(api),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(server_api::SubjektivSubjectCreateRequest {
role: "reviewer".to_string(),
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
}),
)
.await
.unwrap_err();
assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
let workspace = tempfile::tempdir().unwrap();
@@ -39517,7 +39724,10 @@ mod tests {
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
.create_subject_with_behavior(
crate::subjektiv::SubjectRole::new("companion").unwrap(),
"Prefer explicit evidence.".to_string(),
)
.unwrap();
let request = CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
@@ -39636,6 +39846,33 @@ mod tests {
}
)
));
let Json(resident_context) = scoped_subjektiv_memory_backend(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
context.clone(),
Json(server_api::SubjektivMemoryBackendRequest {
operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
Default::default(),
),
}),
)
.await
.unwrap();
assert!(matches!(
resident_context,
server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision: 0,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
content: None,
},
}
) if behavior_md == "Prefer explicit evidence."
));
let missing_legacy = scoped_record_subjektiv_session(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
+310 -64
View File
@@ -11,7 +11,7 @@ use std::collections::HashSet;
use chrono::{SecondsFormat, Utc};
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef};
use rusqlite::{OptionalExtension, Transaction, params};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
pub const MAX_STAGING_ANCHORS: usize = 10;
/// Maximum UTF-8 size of the user-managed Subject behavior document.
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
/// Deterministic surface-generation policy. Input estimates use the repository's
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
/// payload is capped below the whole-input budget to reserve room for the fixed
@@ -74,6 +76,14 @@ pub enum SubjektivError {
expected: u64,
actual: u64,
},
#[error(
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
)]
SubjectBehaviorConflict {
subject_id: String,
expected: u64,
actual: u64,
},
#[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
pub schema_version: u32,
pub id: String,
pub role: SubjectRole,
/// User-managed, host-injected behavior document. This is not Memory and
/// Memory lifecycle operations must preserve it byte-for-byte.
#[serde(default)]
pub behavior_md: String,
/// Monotonic CAS generation for behavior updates, independent of Memory's
/// `store_revision`.
#[serde(default)]
pub behavior_revision: u64,
pub state: SubjectState,
/// Monotonic generation incremented exactly once for every committed Memory
/// creation or revision for this subject.
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
pub snapshot: Option<SurfaceSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectResidentContext {
pub subject: SubjectRecord,
pub surface: ResidentSurface,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryRevisionTarget {
@@ -986,6 +1010,17 @@ END;
Ok(())
}
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
transaction.execute_batch(
r#"
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
CHECK (behavior_revision >= 0);
"#,
)?;
Ok(())
}
static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
FeatureMigration::new(
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add bounded surface generation state and runs",
add_surface_generation_state,
),
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
];
pub const REGISTRATION: FeatureRegistration =
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
/// Issues a random host-owned subject id. It is never derived from Worker,
/// Profile, Session, or Memory content.
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
self.create_subject_with_behavior(role, String::new())
}
/// Creates a Subject with its optional user-managed behavior document.
pub fn create_subject_with_behavior(
&self,
role: SubjectRole,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
let timestamp = now();
let record = SubjectRecord {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: issued_id("subject"),
role,
behavior_md,
behavior_revision: 0,
state: SubjectState::Active,
store_revision: 0,
created_at: timestamp.clone(),
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
self.database.try_transaction(|transaction| {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
subject_id, role, behavior_md, behavior_revision, state,
store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
params![
record.id,
record.role.as_str(),
record.behavior_md,
raw,
record.created_at,
record.updated_at
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
})
}
/// Replaces or clears the user-managed behavior document using its own CAS
/// generation. Memory revisions do not participate in this generation.
pub fn update_subject_behavior(
&self,
subject_id: &str,
expected_behavior_revision: u64,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
self.database.try_transaction(|transaction| {
let mut subject = require_subject(transaction, subject_id)?;
if subject.behavior_revision != expected_behavior_revision {
return Err(SubjektivError::SubjectBehaviorConflict {
subject_id: subject_id.to_string(),
expected: expected_behavior_revision,
actual: subject.behavior_revision,
});
}
if subject.behavior_md == behavior_md {
return Ok(subject);
}
subject.behavior_md = behavior_md;
subject.behavior_revision =
subject.behavior_revision.checked_add(1).ok_or_else(|| {
SubjektivError::InvalidRecord("subject behavior revision overflow".to_string())
})?;
subject.updated_at = now();
let raw = serde_json::to_string(&subject)?;
transaction.execute(
"UPDATE subjects
SET behavior_md = ?2, behavior_revision = ?3,
record_json = ?4, updated_at = ?5
WHERE subject_id = ?1",
params![
subject.id,
subject.behavior_md,
to_i64(subject.behavior_revision)?,
raw,
subject.updated_at
],
)?;
Ok(subject)
})
}
/// Validates an explicit product launch connection against this Workspace's
/// subject store and the already-resolved Profile policy. Subject knowledge
/// alone is insufficient: only an active local record produces host launch material.
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
self.database.try_with_connection(|connection| {
let subject = require_subject_in_connection(connection, subject_id)?;
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[subject_id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision).map_err(|_| {
SubjektivError::InvalidRecord("surface state revision is negative".into())
})?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject_id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
resident_surface_in_connection(connection, &subject)
})
}
/// Atomically reads user-managed behavior and the independently-fresh
/// generated Memory surface from one SQLite connection snapshot.
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
self.database.try_transaction(|transaction| {
let subject = require_subject(transaction, subject_id)?;
let surface = resident_surface_in_connection(transaction, &subject)?;
Ok(SubjectResidentContext { subject, surface })
})
}
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
}
}
fn resident_surface_in_connection(
connection: &Connection,
subject: &SubjectRecord,
) -> Result<ResidentSurface> {
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[&subject.id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision)
.map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject.id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
}
pub fn validate_subject_behavior(value: &str) -> Result<()> {
if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES {
return Err(SubjektivError::InvalidRecord(format!(
"subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes"
)));
}
if !value.is_empty() && value.trim().is_empty() {
return Err(SubjektivError::InvalidRecord(
"subject behavior must be empty or contain non-whitespace text".to_string(),
));
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(SubjektivError::InvalidRecord(
"subject behavior contains an unsupported control character".to_string(),
));
}
Ok(())
}
fn validate_label(kind: &str, value: &str) -> Result<()> {
validate_nonempty(kind, value)?;
if value.len() > 256 || value.chars().any(char::is_control) {
@@ -3668,6 +3801,67 @@ mod tests {
assert!(!complete.has_more);
}
#[test]
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
let subject = store
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
.unwrap();
assert_eq!(subject.behavior_revision, 0);
assert_eq!(subject.store_revision, 0);
let updated = store
.update_subject_behavior(
&subject.id,
0,
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
)
.unwrap();
assert_eq!(updated.behavior_revision, 1);
assert_eq!(updated.store_revision, 0);
store
.create_memory(
&subject.id,
draft(
"A confirmed observation",
"exercise Memory lifecycle isolation",
),
)
.unwrap();
let after_memory = store.subject(&subject.id).unwrap().unwrap();
assert_eq!(after_memory.behavior_md, updated.behavior_md);
assert_eq!(after_memory.behavior_revision, 1);
assert_eq!(after_memory.store_revision, 1);
let (_reopened_manager, _reopened_workspace, reopened) =
open_store(temp.path(), "workspace-a");
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
assert_eq!(persisted.behavior_md, updated.behavior_md);
assert_eq!(persisted.behavior_revision, 1);
assert!(matches!(
store.update_subject_behavior(&subject.id, 0, String::new()),
Err(SubjektivError::SubjectBehaviorConflict {
expected: 0,
actual: 1,
..
})
));
let unchanged = store
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
.unwrap();
assert_eq!(unchanged.behavior_revision, 1);
let cleared = store
.update_subject_behavior(&subject.id, 1, String::new())
.unwrap();
assert_eq!(cleared.behavior_revision, 2);
assert!(cleared.behavior_md.is_empty());
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
assert!(validate_subject_behavior(" ").is_err());
}
#[test]
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
let temp = tempfile::tempdir().unwrap();
@@ -3892,10 +4086,38 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &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 migrated_subject = store.subject(&subject_id).unwrap().unwrap();
assert!(migrated_subject.behavior_md.is_empty());
assert_eq!(migrated_subject.behavior_revision, 0);
let lifecycle_attribution = attribution(
&subject_id,
"runtime-1",
@@ -3935,26 +4157,50 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &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 {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: "legacy-surface".into(),
subject_id: subject.id.clone(),
subject_id: subject_id.clone(),
body_md: "Legacy summary without generation-policy evidence.".into(),
memory_refs: Vec::new(),
built_from_store_revision: subject.store_revision,
built_from_store_revision: 0,
created_at: now(),
};
store
.database
.try_transaction(|transaction| -> Result<()> {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision,
record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
params![
subject_id,
role().as_str(),
subject_json,
timestamp,
timestamp
],
)?;
transaction.execute(
"INSERT INTO surface_snapshots (
subject_id, snapshot_id, built_from_store_revision,
snapshot_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
subject.id,
subject_id,
snapshot.id,
to_i64(snapshot.built_from_store_revision)?,
serde_json::to_string(&snapshot)?,
@@ -3964,12 +4210,12 @@ mod tests {
transaction.execute(
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
VALUES (?1, ?2)",
params![subject.id, snapshot.id],
params![subject_id, snapshot.id],
)?;
Ok(())
})
.unwrap();
(subject.id, snapshot.id)
(subject_id, snapshot.id)
};
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
+152 -1
View File
@@ -9315,9 +9315,32 @@
],
"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": {
"additionalProperties": false,
"properties": {
"behavior_md": {
"default": "",
"type": "string"
},
"role": {
"type": "string"
}
@@ -9383,6 +9406,15 @@
"SubjektivSubjectResponse": {
"additionalProperties": false,
"properties": {
"behavior_md": {
"type": "string"
},
"behavior_revision": {
"format": "uint64",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"created_at": {
"type": "string"
},
@@ -9419,6 +9451,8 @@
"required": [
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -14794,7 +14828,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870"
"x-yoi-source-digest": "sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4"
},
"openapi": "3.1.0",
"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": {
"get": {
"operationId": "subjektiv_memory_list",
+2
View File
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
internalAgentsMdSection = import "./internal/agents_md_section.md";
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_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";
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
in
@@ -74,6 +75,7 @@ in
agents_md_section = internalAgentsMdSection.content;
resident_memory_summary_section = internalResidentMemorySummarySection.content;
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
subjektiv_resident_context = internalSubjektivResidentContext.content;
worker_orchestration_guidance_section = commonWorkerOrchestration.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.
{% if surface_ready %}
### Current ready surface
### Current resident context
{% if summary %}
{{ summary }}
{% else %}
The current ready resident memory surface is intentionally empty.
The current resident context is intentionally empty.
{% endif %}
{% else %}
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 %}
@@ -66,7 +66,14 @@ ${"detail-wide-code-".repeat(18)}
<script>alert("not rendered")</script>`;
const subjects = [
subject(representativeSubjectId, "Release coordination", "active", 42),
subject(
representativeSubjectId,
"Release coordination",
"active",
42,
"Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.",
3,
),
subject(emptySubjectId, "Empty ready subject", "active", 0),
subject(staleSubjectId, "Stale surface subject", "active", 19),
subject(failedSubjectId, "Failed surface subject", "active", 8),
@@ -75,10 +82,19 @@ const subjects = [
];
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1);
function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) {
function subject(
id: string,
role: string,
state: "active" | "retired",
storeRevision: number,
behaviorMd = "",
behaviorRevision = 0,
) {
return {
id,
role,
behavior_md: behaviorMd,
behavior_revision: behaviorRevision,
state,
store_revision: storeRevision,
created_at: "2026-01-01T00:00:00Z",
@@ -403,7 +419,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
if (url.pathname === subjectsPath) {
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);
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
@@ -411,9 +430,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (payload.role === "Permission denied") {
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;
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);
subjects.unshift(created);
return json(created, 201);
@@ -442,6 +464,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const foundSubject = subjects.find((item) => item.id === subjectId);
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 === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
if (parts.length === 2 && parts[1] === "memories") {
@@ -16,7 +16,7 @@ const memoryId =
const subjectPageCursor = "fixture-subject-page-2";
const createInteractionArtifacts = join(
repositoryRoot,
"target/web-ux/t-689-browser-interactions",
"target/web-ux/t-693-subject-behavior",
);
async function freePort(): Promise<number> {
@@ -111,6 +111,7 @@ async function openSubjectPage(
await hideSidebar.click();
await showSidebar.waitFor();
await page.waitForTimeout(250);
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
assertEquals(
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)));
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);
assert(initial.scrollHeight > initial.clientHeight);
@@ -425,7 +433,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const createPage = await createContext.newPage();
const mutationPaths: string[] = [];
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.getByText("Subject on the next page").waitFor();
@@ -444,9 +454,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: "New Subject" }).click();
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.waitForURL("**/memory/created-subject-0001");
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(
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1,
@@ -455,15 +477,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
hasText: "Current worker",
});
assert((await currentWorker.textContent())?.includes("None"));
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, [
`/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`))
.json();
assertEquals(createEvidence, {
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 createContext.close();
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/companion-api.ts
// 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,
} as const;
export const SUBJEKTIV_API_LIMITS = {
maxBehaviorBytes: 16384,
} as const;
export type DiagnosticSeverity = "info" | "warning" | "error";
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 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, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/repository-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/runtime-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/ticket-api.ts
// 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.
// 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
// 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
+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 type {
Diagnostic,
@@ -26,6 +29,7 @@ import type {
SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
} as const;
const MAX_SUBJECT_ROLE_BYTES = 256;
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
export type SubjektivSubjectCreateErrorKind =
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
"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(
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
);
if (subject.role !== validated.role) {
throw new Error("created Subject role does not match the request");
if (
subject.role !== validated.role ||
subject.behavior_md !== validated.behavior_md
) {
throw new Error("created Subject does not match the request");
}
return subject;
} 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(
response: Response,
): Promise<string> {
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
} catch {
// 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(
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
[
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
const result: SubjektivSubjectResponse = {
id,
role: requiredString(record, "role"),
behavior_md: requiredString(
record,
"behavior_md",
MAX_SUBJECT_BEHAVIOR_BYTES,
),
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
state,
store_revision: requiredNonNegativeInteger(record, "store_revision"),
created_at: requiredString(record, "created_at"),
@@ -14,13 +14,16 @@
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -35,6 +38,7 @@
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -45,8 +49,10 @@
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -58,9 +64,13 @@
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
@@ -69,14 +79,16 @@
}
} catch (error) {
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';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid) {
if (roleInvalid || behaviorInvalid) {
await tick();
roleInput?.focus();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
@@ -145,9 +157,22 @@
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
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 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}
<div
id="subject-create-error"
@@ -309,20 +334,25 @@
}
.subject-create-actions {
grid-column: 3;
grid-row: 1;
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
align-items: end;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
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 p,
.subject-create-error p {
@@ -344,7 +374,8 @@
font-weight: 700;
}
.subject-role-field input {
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
@@ -354,7 +385,15 @@
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);
}
@@ -480,6 +519,7 @@
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
@@ -1,11 +1,61 @@
<script lang="ts">
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
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);
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving) return;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
subject = await updateSubjektivSubjectBehavior(fetch, data.workspaceId, data.subjectId, {
expected_behavior_revision: subject.behavior_revision,
behavior_md: behaviorDraft,
});
behaviorDraft = subject.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
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 {
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 snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
@@ -39,7 +89,7 @@
</script>
<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" />
</svelte:head>
@@ -49,19 +99,19 @@
<header class="memory-page-header">
<div>
<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>
</div>
<span class="read-only-label">Read-only</span>
<span class="read-only-label">Behavior editable</span>
</header>
{#if data.subject.data}
{#if subject}
<dl class="subject-facts" aria-label="Subject details">
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
<div><dt>Role</dt><dd>{subject.role}</dd></div>
<div><dt>State</dt><dd><span class="subject-state is-{subject.state}">{subject.state}</span></dd></div>
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
<div><dt>Current worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
</dl>
{:else if data.subject.error}
<div class="memory-state is-error" role="alert">
@@ -72,6 +122,40 @@
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/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">
<header class="section-heading">
<div>
@@ -320,6 +404,7 @@
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
@@ -328,6 +413,78 @@
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,
.pill-row {
display: flex;
@@ -34,6 +34,8 @@ function subject() {
return {
id: "subject-1",
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active" as const,
store_revision: 9,
created_at: "2026-01-01T00:00:00Z",
@@ -96,6 +98,12 @@ test("creates one Subject with the exact role and navigates to its generated det
}) as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(roleInput));
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" });
await fireEvent.click(submit);
@@ -110,6 +118,8 @@ test("creates one Subject with the exact role and navigates to its generated det
resolveRequest(Response.json({
id: "generated-subject-1",
role: " Review lead ",
behavior_md: "Challenge unsupported assumptions.",
behavior_revision: 0,
state: "active",
store_revision: 0,
created_at: "2026-01-03T00:00:00Z",
@@ -127,7 +137,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(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 () => {
@@ -146,7 +158,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
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(() =>
Promise.resolve(Response.json(
{
@@ -172,6 +184,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
expect(fetchMock).not.toHaveBeenCalled();
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 waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -267,6 +293,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
.not.toBeNull();
});
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident surface" })).not
.toBeNull();
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
@@ -280,6 +311,69 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.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()
);
});
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspaceId: "workspace-1",
+75 -2
View File
@@ -12,6 +12,7 @@ import {
parseSubjektivSubjectListResponse,
parseSubjektivSubjectResponse,
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
validateSubjektivSubjectCreateRequest,
} from "../src/lib/workspace/memory/api.ts";
@@ -75,6 +76,8 @@ function subject(id = "subject-1") {
return {
id,
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active",
store_revision: 12,
created_at: "2026-09-01T00:00:00Z",
@@ -228,7 +231,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator " },
{ role: " Release coordinator ", behavior_md: "" },
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }),
@@ -242,6 +245,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
"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 () => {
@@ -251,20 +318,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
return Response.json({
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
}, { status: 201 });
}) as typeof fetch;
const created = await createSubjektivSubject(fetchFn, "workspace one", {
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(created, {
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(requests.length, 1);
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
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 () => {