feat: connect subjektiv extraction lifecycle
This commit is contained in:
@@ -1242,6 +1242,18 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||
if memory_profile.enabled
|
||||
&& memory_profile.extraction.enabled
|
||||
&& subjektiv_profile.enabled
|
||||
&& subjektiv_profile.extraction.enabled
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker",
|
||||
));
|
||||
}
|
||||
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
@@ -1276,6 +1288,22 @@ where
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
}
|
||||
if let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_lifecycle);
|
||||
}
|
||||
if sub_worker_enabled && !worker_enabled {
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||
|
||||
@@ -39,8 +39,16 @@ use manifest::WorkerManifest;
|
||||
use protocol::Event;
|
||||
|
||||
const TASK_NAME: &str = "memory-lifecycle";
|
||||
const SUBJEKTIV_TASK_NAME: &str = "subjektiv-memory-lifecycle";
|
||||
const SUBJEKTIV_EXTRACT_DOMAIN: &str = "subjektiv.extract.v1";
|
||||
const TASK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExtractionTarget {
|
||||
WorkspaceMemory,
|
||||
Subjektiv,
|
||||
}
|
||||
|
||||
/// Parent-Worker lifecycle Feature that observes committed runs and schedules
|
||||
/// bounded extraction work. It owns the Memory pointer, audit, restricted
|
||||
/// Internal Worker, and staging disposition; Worker core owns only generic
|
||||
@@ -50,9 +58,17 @@ pub(crate) struct MemoryLifecycleFeature {
|
||||
task: MemoryLifecycleTask,
|
||||
}
|
||||
|
||||
/// Subject-scoped extraction is a distinct Feature module even though it reuses
|
||||
/// the mature committed-capture/extract/pointer lifecycle implementation.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivLifecycleFeature {
|
||||
task: MemoryLifecycleTask,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MemoryLifecycleTask {
|
||||
config: manifest::ResolvedMemoryFeatureConfig,
|
||||
target: ExtractionTarget,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
extensions: SessionExtensionHandle,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
@@ -120,6 +136,7 @@ impl MemoryLifecycleFeature {
|
||||
Self {
|
||||
task: MemoryLifecycleTask {
|
||||
config,
|
||||
target: ExtractionTarget::WorkspaceMemory,
|
||||
capture,
|
||||
extensions,
|
||||
workspace_client,
|
||||
@@ -133,6 +150,63 @@ impl MemoryLifecycleFeature {
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivLifecycleFeature {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn from_resolved_config(
|
||||
lifecycle_enabled: bool,
|
||||
config: manifest::ResolvedSubjektivFeatureConfig,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
extensions: SessionExtensionHandle,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
event_tx: Option<broadcast::Sender<Event>>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
config
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv extraction requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
let memory_config = manifest::ResolvedMemoryFeatureConfig {
|
||||
profile: manifest::MemoryFeatureProfileConfig {
|
||||
enabled: true,
|
||||
staging_tools: false,
|
||||
resident: manifest::MemoryResidentProfileConfig {
|
||||
inject_summary: false,
|
||||
},
|
||||
extraction: config.profile.extraction,
|
||||
consolidation: manifest::MemoryConsolidationProfileConfig {
|
||||
request_enabled: false,
|
||||
},
|
||||
},
|
||||
workspace_settings: config.workspace_settings,
|
||||
};
|
||||
Ok(Some(Self {
|
||||
task: MemoryLifecycleTask {
|
||||
config: memory_config,
|
||||
target: ExtractionTarget::Subjektiv,
|
||||
capture,
|
||||
extensions,
|
||||
workspace_client,
|
||||
manifest,
|
||||
client,
|
||||
prompts,
|
||||
workspace_context,
|
||||
event_tx,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MemoryLifecycleFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("memory-lifecycle", "Memory Lifecycle")
|
||||
@@ -152,17 +226,54 @@ impl FeatureModule for MemoryLifecycleFeature {
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SubjektivLifecycleFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("subjektiv-memory-lifecycle", "subjektiv Memory Lifecycle")
|
||||
.with_description(
|
||||
"Observes terminal committed parent runs and stages bounded Memory candidates for the host-connected subject.",
|
||||
)
|
||||
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||
SUBJEKTIV_TASK_NAME,
|
||||
"Extract provenance-preserving candidates into subject-scoped staging after committed runs.",
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.background_tasks()
|
||||
.register(subjektiv_lifecycle_task_spec(), self.task.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_lifecycle_task_spec() -> BackgroundTaskSpec {
|
||||
let declaration = BackgroundTaskDeclaration::worker_managed(
|
||||
lifecycle_task_spec(
|
||||
TASK_NAME,
|
||||
"Extract provenance-preserving Memory candidates and request Backend consolidation after committed runs.",
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
fn subjektiv_lifecycle_task_spec() -> BackgroundTaskSpec {
|
||||
lifecycle_task_spec(
|
||||
SUBJEKTIV_TASK_NAME,
|
||||
"Extract provenance-preserving candidates into subject-scoped staging after committed runs.",
|
||||
)
|
||||
}
|
||||
|
||||
fn lifecycle_task_spec(name: &'static str, description: &'static str) -> BackgroundTaskSpec {
|
||||
let declaration = BackgroundTaskDeclaration::worker_managed(name, description);
|
||||
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
|
||||
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
||||
spec
|
||||
}
|
||||
|
||||
impl MemoryLifecycleTask {
|
||||
fn pointer_domain(&self) -> &'static str {
|
||||
match self.target {
|
||||
ExtractionTarget::WorkspaceMemory => extract::EXTRACT_DOMAIN,
|
||||
ExtractionTarget::Subjektiv => SUBJEKTIV_EXTRACT_DOMAIN,
|
||||
}
|
||||
}
|
||||
|
||||
fn extraction_manifest(&self) -> WorkerManifest {
|
||||
let mut manifest = self.manifest.clone();
|
||||
if let Some(model) = self.config.profile.extraction.model.clone() {
|
||||
@@ -192,6 +303,11 @@ impl MemoryLifecycleTask {
|
||||
.map(model_audit_from_manifest),
|
||||
)
|
||||
.with_memory_settings(&self.config);
|
||||
let audit = if self.target == ExtractionTarget::Subjektiv {
|
||||
audit.without_legacy_backend()
|
||||
} else {
|
||||
audit
|
||||
};
|
||||
let capture = match self.capture.capture() {
|
||||
Ok(capture) => capture,
|
||||
Err(error) => {
|
||||
@@ -223,7 +339,7 @@ impl MemoryLifecycleTask {
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
let pointer = match extract_pointer(&capture) {
|
||||
let pointer = match extract_pointer(&capture, self.pointer_domain()) {
|
||||
Ok(pointer) => pointer,
|
||||
Err(error) => {
|
||||
audit
|
||||
@@ -333,12 +449,21 @@ impl MemoryLifecycleTask {
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let output_state = MemoryStagingOutputState::new(
|
||||
view.clone(),
|
||||
Arc::clone(&self.workspace_client),
|
||||
source,
|
||||
audit.run_id.to_string(),
|
||||
);
|
||||
let output_state = match self.target {
|
||||
ExtractionTarget::WorkspaceMemory => MemoryStagingOutputState::new(
|
||||
view.clone(),
|
||||
Arc::clone(&self.workspace_client),
|
||||
source,
|
||||
audit.run_id.to_string(),
|
||||
),
|
||||
ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv(
|
||||
view.clone(),
|
||||
Arc::clone(&self.workspace_client),
|
||||
source,
|
||||
audit.run_id.to_string(),
|
||||
capture.session_id.clone(),
|
||||
),
|
||||
};
|
||||
let client = if let Some(model) = self.config.profile.extraction.model.as_ref() {
|
||||
match crate::model_client::build_client(model) {
|
||||
Ok(client) => client,
|
||||
@@ -485,6 +610,23 @@ impl MemoryLifecycleTask {
|
||||
ExtractionDisposition::Completed => {}
|
||||
}
|
||||
|
||||
if self.target == ExtractionTarget::Subjektiv
|
||||
&& let Err(reason) = self.record_subjektiv_session(&capture.session_id)
|
||||
{
|
||||
audit
|
||||
.emit(
|
||||
self.workspace_client.as_ref(),
|
||||
self.event_tx.as_ref(),
|
||||
memory::audit::WorkerLifecycleStatus::Failed,
|
||||
reason,
|
||||
usage_audit,
|
||||
extract_audit,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
context.generation_fence.ensure_current()?;
|
||||
let next_pointer = memory::ExtractPointerPayload {
|
||||
processed_through_entry: capture.entry_count - 1,
|
||||
@@ -494,7 +636,7 @@ impl MemoryLifecycleTask {
|
||||
let payload = serde_json::to_value(&next_pointer).map_err(hook_internal)?;
|
||||
if !self
|
||||
.extensions
|
||||
.append_if_current(&capture.location(), extract::EXTRACT_DOMAIN, payload)
|
||||
.append_if_current(&capture.location(), self.pointer_domain(), payload)
|
||||
.map_err(hook_internal)?
|
||||
{
|
||||
audit
|
||||
@@ -549,6 +691,35 @@ impl FeatureBackgroundTask for MemoryLifecycleTask {
|
||||
}
|
||||
|
||||
impl MemoryLifecycleTask {
|
||||
fn record_subjektiv_session(&self, session_id: &str) -> Result<(), String> {
|
||||
let response = self
|
||||
.workspace_client
|
||||
.execute_server_operation(
|
||||
crate::worker::WorkspaceServerOperation::SubjektivRecordSession(
|
||||
server_api::SubjektivRecordSessionRequest {
|
||||
session_id: session_id.to_string(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.map_err(|error| format!("record subjektiv Session attribution failed: {error}"))?;
|
||||
if !response.is_success() {
|
||||
return Err(format!(
|
||||
"record subjektiv Session attribution returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
));
|
||||
}
|
||||
let output: server_api::SubjektivRecordSessionResponse =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
format!("decode subjektiv Session attribution response: {error}")
|
||||
})?;
|
||||
if output.session_id != session_id {
|
||||
return Err(
|
||||
"subjektiv Session attribution response changed session identity".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_consolidation(&self) {
|
||||
let audit = WorkerAuditBase::new(
|
||||
memory::audit::AuditWorker::MemoryConsolidation,
|
||||
@@ -668,13 +839,19 @@ fn hook_internal(error: impl std::fmt::Display) -> HookError {
|
||||
|
||||
fn extract_pointer(
|
||||
capture: &CommittedSessionCapture,
|
||||
domain: &str,
|
||||
) -> Result<Option<memory::ExtractPointerPayload>, HookError> {
|
||||
let pointer = memory::extract::fold_pointer(&capture.extensions);
|
||||
let pointer = capture
|
||||
.extensions
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(candidate, _)| candidate == domain)
|
||||
.and_then(|(_, payload)| serde_json::from_value(payload.clone()).ok());
|
||||
if pointer.is_none()
|
||||
&& capture
|
||||
.extensions
|
||||
.iter()
|
||||
.any(|(domain, _)| domain == extract::EXTRACT_DOMAIN)
|
||||
.any(|(candidate, _)| candidate == domain)
|
||||
{
|
||||
return Err(hook_internal(
|
||||
"latest committed Memory extraction pointer is malformed",
|
||||
@@ -732,6 +909,7 @@ struct WorkerAuditBase {
|
||||
trigger: memory::audit::AuditTrigger,
|
||||
memory_settings: Option<memory::audit::MemorySettingsAudit>,
|
||||
model: Option<memory::audit::ModelAudit>,
|
||||
persist_legacy_backend: bool,
|
||||
}
|
||||
|
||||
impl WorkerAuditBase {
|
||||
@@ -746,9 +924,15 @@ impl WorkerAuditBase {
|
||||
trigger,
|
||||
memory_settings: None,
|
||||
model,
|
||||
persist_legacy_backend: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn without_legacy_backend(mut self) -> Self {
|
||||
self.persist_legacy_backend = false;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self {
|
||||
self.memory_settings =
|
||||
config
|
||||
@@ -784,15 +968,19 @@ impl WorkerAuditBase {
|
||||
extract,
|
||||
consolidation,
|
||||
};
|
||||
let _ = workspace_client
|
||||
.execute_memory_backend_operation(memory::backend::MemoryBackendOperation::AppendAudit(
|
||||
memory::backend::MemoryAppendAuditOperation {
|
||||
event: memory::audit::AuditEvent::new(
|
||||
memory::audit::AuditPayload::WorkerLifecycle(payload),
|
||||
if self.persist_legacy_backend {
|
||||
let _ = workspace_client
|
||||
.execute_memory_backend_operation(
|
||||
memory::backend::MemoryBackendOperation::AppendAudit(
|
||||
memory::backend::MemoryAppendAuditOperation {
|
||||
event: memory::audit::AuditEvent::new(
|
||||
memory::audit::AuditPayload::WorkerLifecycle(payload),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
||||
worker: self.worker.label().to_string(),
|
||||
@@ -859,6 +1047,8 @@ mod tests {
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||
let is_subjektiv_stage = request.path.ends_with("/subjektiv/staging");
|
||||
let is_subjektiv_session = request.path.ends_with("/subjektiv/sessions");
|
||||
let is_stage_candidate = request
|
||||
.body
|
||||
.as_deref()
|
||||
@@ -868,6 +1058,25 @@ mod tests {
|
||||
.as_deref()
|
||||
.is_some_and(|body| body.contains("append_audit"));
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if is_subjektiv_session {
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&server_api::SubjektivRecordSessionResponse {
|
||||
subject_id: "subject-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
});
|
||||
}
|
||||
if is_subjektiv_stage {
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
|
||||
staging_id: "subject-candidate-1".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
});
|
||||
}
|
||||
if is_stage_candidate {
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
@@ -1072,6 +1281,7 @@ permission = "write"
|
||||
});
|
||||
MemoryLifecycleTask {
|
||||
config: test_config(),
|
||||
target: ExtractionTarget::WorkspaceMemory,
|
||||
capture: capture_handle,
|
||||
extensions,
|
||||
workspace_client,
|
||||
@@ -1122,6 +1332,30 @@ permission = "write"
|
||||
registry.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_subjektiv_feature_installs_nothing_without_workspace_side_effects() {
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
panic!("disabled subjektiv must not capture a Session")
|
||||
});
|
||||
let extensions = SessionExtensionHandle::new(|_, _, _| {
|
||||
panic!("disabled subjektiv must not write a pointer")
|
||||
});
|
||||
let feature = SubjektivLifecycleFeature::from_resolved_config(
|
||||
true,
|
||||
manifest::ResolvedSubjektivFeatureConfig::default(),
|
||||
capture,
|
||||
extensions,
|
||||
crate::worker::marker_workspace_client(None, "disabled"),
|
||||
test_manifest(),
|
||||
Box::new(ScriptClient::new(Vec::new())),
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(feature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_manifest_applies_memory_model_and_reasoning_overrides() {
|
||||
use agen::llm_client::capability::{ReasoningControl, ReasoningEffort};
|
||||
@@ -1236,6 +1470,136 @@ permission = "write"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() {
|
||||
let source = capture(2, 250);
|
||||
let entry_ref =
|
||||
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
|
||||
.overview()[0]
|
||||
.id
|
||||
.to_string();
|
||||
let client = ScriptClient::new(vec![
|
||||
stage_candidate_events("stage-1", &entry_ref),
|
||||
finish_events("finish-1", 1),
|
||||
completed_events(),
|
||||
]);
|
||||
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||
let (event_tx, _) = broadcast::channel(64);
|
||||
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let mut task = test_task(
|
||||
source,
|
||||
Box::new(client),
|
||||
Arc::clone(&extension_writes),
|
||||
event_tx,
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
let writes = extension_writes.lock().unwrap();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN);
|
||||
let pointer: memory::ExtractPointerPayload =
|
||||
serde_json::from_value(writes[0].2.clone()).unwrap();
|
||||
assert_eq!(pointer.staging_id, "subject-candidate-1");
|
||||
let requests = workspace_client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.path.ends_with("/subjektiv/staging"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.path.ends_with("/subjektiv/sessions"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(requests.iter().all(|request| {
|
||||
request
|
||||
.body
|
||||
.as_deref()
|
||||
.is_none_or(|body| !body.contains("append_audit") && body != "{\"force\":false}")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_subjektiv_extraction_records_session_and_commits_pointer_without_candidate() {
|
||||
let client = ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]);
|
||||
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||
let (event_tx, _) = broadcast::channel(32);
|
||||
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let mut task = test_task(
|
||||
capture(2, 250),
|
||||
Box::new(client),
|
||||
Arc::clone(&extension_writes),
|
||||
event_tx,
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
let writes = extension_writes.lock().unwrap();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN);
|
||||
let pointer: memory::ExtractPointerPayload =
|
||||
serde_json::from_value(writes[0].2.clone()).unwrap();
|
||||
assert!(pointer.staging_id.is_empty());
|
||||
let requests = workspace_client.requests.lock().unwrap();
|
||||
assert!(
|
||||
!requests
|
||||
.iter()
|
||||
.any(|request| request.path.ends_with("/subjektiv/staging"))
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.any(|request| request.path.ends_with("/subjektiv/sessions"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() {
|
||||
let source = capture(2, 250);
|
||||
let entry_ref =
|
||||
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
|
||||
.overview()[0]
|
||||
.id
|
||||
.to_string();
|
||||
let client = ScriptClient::new(vec![stage_candidate_events("stage-1", &entry_ref)]);
|
||||
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||
let (event_tx, _) = broadcast::channel(32);
|
||||
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let mut task = test_task(
|
||||
source,
|
||||
Box::new(client),
|
||||
Arc::clone(&extension_writes),
|
||||
event_tx,
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
assert!(extension_writes.lock().unwrap().is_empty());
|
||||
let requests = workspace_client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.path.ends_with("/subjektiv/staging"))
|
||||
.count(),
|
||||
1,
|
||||
"the immutable partial candidate remains staged for retry/deduplication"
|
||||
);
|
||||
assert!(!requests
|
||||
.iter()
|
||||
.any(|request| request.path.ends_with("/subjektiv/sessions")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_extraction_emits_failure_event_and_durable_audit_without_pointer() {
|
||||
let client = ScriptClient::new(Vec::new());
|
||||
@@ -1562,7 +1926,10 @@ permission = "write"
|
||||
serde_json::to_value(&latest).unwrap(),
|
||||
),
|
||||
];
|
||||
assert_eq!(extract_pointer(&capture).unwrap(), Some(latest));
|
||||
assert_eq!(
|
||||
extract_pointer(&capture, extract::EXTRACT_DOMAIN).unwrap(),
|
||||
Some(latest)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1583,7 +1950,7 @@ permission = "write"
|
||||
serde_json::json!({"invalid": true}),
|
||||
),
|
||||
];
|
||||
assert!(extract_pointer(&capture).is_err());
|
||||
assert!(extract_pointer(&capture, extract::EXTRACT_DOMAIN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,10 +33,17 @@ pub(crate) struct MemoryStagingOutputState {
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
destination: MemoryStagingDestination,
|
||||
staged: Arc<Mutex<Vec<String>>>,
|
||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MemoryStagingDestination {
|
||||
WorkspaceMemory,
|
||||
Subjektiv { session_id: String },
|
||||
}
|
||||
|
||||
impl MemoryStagingOutputState {
|
||||
pub(crate) fn new(
|
||||
view: SessionCapture,
|
||||
@@ -49,6 +56,25 @@ impl MemoryStagingOutputState {
|
||||
workspace_client,
|
||||
source,
|
||||
extract_run_id,
|
||||
destination: MemoryStagingDestination::WorkspaceMemory,
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
finished: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_subjektiv(
|
||||
view: SessionCapture,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
session_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
view: Arc::new(view),
|
||||
workspace_client,
|
||||
source,
|
||||
extract_run_id,
|
||||
destination: MemoryStagingDestination::Subjektiv { session_id },
|
||||
staged: Arc::new(Mutex::new(Vec::new())),
|
||||
finished: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
@@ -201,34 +227,72 @@ impl Tool for StageMemoryCandidateTool {
|
||||
staleness: params.staleness,
|
||||
evidence_ids: params.entry_refs,
|
||||
};
|
||||
let result = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||
MemoryStageCandidateOperation {
|
||||
source: self.state.source.clone(),
|
||||
extract_run_id: self.state.extract_run_id.clone(),
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(map_memory_stage_error)?;
|
||||
let staging_ids = match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
|
||||
output.staging_ids
|
||||
let operation = MemoryStageCandidateOperation {
|
||||
source: self.state.source.clone(),
|
||||
extract_run_id: self.state.extract_run_id.clone(),
|
||||
candidate,
|
||||
evidence,
|
||||
source_refs,
|
||||
};
|
||||
let staging_ids = match &self.state.destination {
|
||||
MemoryStagingDestination::WorkspaceMemory => {
|
||||
let result = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||
operation,
|
||||
))
|
||||
.await
|
||||
.map_err(map_memory_stage_error)?;
|
||||
match result {
|
||||
MemoryBackendOperationResult::StagingWritten(output)
|
||||
if output.staging_count == 1 =>
|
||||
{
|
||||
output.staging_ids
|
||||
}
|
||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"StageMemoryCandidate expected one staging record, backend wrote {}",
|
||||
output.staging_count
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"StageMemoryCandidate expected one staging record, backend wrote {}",
|
||||
output.staging_count
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
||||
)));
|
||||
MemoryStagingDestination::Subjektiv { session_id } => {
|
||||
let response = self
|
||||
.state
|
||||
.workspace_client
|
||||
.execute_server_operation(
|
||||
crate::worker::WorkspaceServerOperation::SubjektivStageCandidate(
|
||||
server_api::SubjektivStageCandidateRequest {
|
||||
session_id: session_id.clone(),
|
||||
operation,
|
||||
},
|
||||
),
|
||||
)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"write subjektiv staging failed: {error}"
|
||||
))
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"write subjektiv staging returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
let output: server_api::SubjektivStageCandidateResponse =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"decode subjektiv staging response: {error}"
|
||||
))
|
||||
})?;
|
||||
vec![output.staging_id]
|
||||
}
|
||||
};
|
||||
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
|
||||
@@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use agen::Item;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingSubjektivClient {
|
||||
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingSubjektivClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"subjektiv-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
|
||||
staging_id: "subject-candidate-1".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn state() -> MemoryStagingOutputState {
|
||||
MemoryStagingOutputState::new(
|
||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
@@ -503,6 +602,39 @@ mod tests {
|
||||
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
|
||||
let client = Arc::new(RecordingSubjektivClient::default());
|
||||
let state = MemoryStagingOutputState::new_subjektiv(
|
||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||
client.clone(),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
},
|
||||
"run-1".to_string(),
|
||||
"session-committed".to_string(),
|
||||
);
|
||||
let tool = StageMemoryCandidateTool {
|
||||
state: state.clone(),
|
||||
};
|
||||
tool.execute(
|
||||
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.staged(), vec!["subject-candidate-1"]);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].path.ends_with("/subjektiv/staging"));
|
||||
let body = requests[0].body.as_deref().unwrap();
|
||||
assert!(body.contains("session-committed"));
|
||||
assert!(body.contains("why_useful"));
|
||||
assert!(!body.contains("subject_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||
let tool = StageMemoryCandidateTool { state: state() };
|
||||
|
||||
@@ -624,6 +624,8 @@ pub enum WorkspaceServerOperation {
|
||||
},
|
||||
WorkerObservationSessions,
|
||||
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
||||
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
||||
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
||||
}
|
||||
|
||||
fn workspace_server_json_request<T: serde::Serialize>(
|
||||
@@ -702,6 +704,12 @@ fn workspace_server_operation_request(
|
||||
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
||||
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/staging"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user