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