fix: integrate develop and preserve invocation notification recovery
This commit is contained in:
+102
-24
@@ -489,11 +489,29 @@ fn stage_pending_notifications<St: Store + Clone>(
|
||||
count
|
||||
}
|
||||
|
||||
/// The durable input append is the activation boundary. Clear live state in
|
||||
/// the same poll, before invocation preparation can checkpoint it again; the
|
||||
/// oneshot only publishes the already-completed transition to the Controller.
|
||||
pub(crate) fn input_committed_hook<St: Store + Clone>(
|
||||
pending: crate::worker::PendingSubmissionHandle<St>,
|
||||
submission_id: String,
|
||||
sender: oneshot::Sender<()>,
|
||||
) -> impl FnOnce() {
|
||||
move || {
|
||||
pending.finish_activation(&submission_id);
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_coalesce_remaining<St: Store + Clone>(
|
||||
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
||||
notify_buffer: &NotifyBuffer,
|
||||
delay: Duration,
|
||||
can_schedule_run: bool,
|
||||
) -> Option<Duration> {
|
||||
if !can_schedule_run {
|
||||
return None;
|
||||
}
|
||||
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
|
||||
else {
|
||||
return notify_buffer
|
||||
@@ -1434,6 +1452,7 @@ where
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts(),
|
||||
)?
|
||||
{
|
||||
let workspace_id = worker
|
||||
@@ -1563,7 +1582,13 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.manage_workdir.enabled {
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let mut workdir_wip_feature = None;
|
||||
if feature_config.manage_workdir.enabled
|
||||
|| (wip_mode
|
||||
&& feature_config.workdir_catalog.enabled
|
||||
&& worker.workspace_client_handle().is_available())
|
||||
{
|
||||
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||
// repository paths, materializer handles, or cleanup sessions.
|
||||
@@ -1579,7 +1604,7 @@ where
|
||||
}
|
||||
let shutdown_registry = spawned_registry.clone();
|
||||
let reopen_registry = spawned_registry.clone();
|
||||
feature_registry.add_module(
|
||||
let module =
|
||||
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||
workspace_client,
|
||||
worker.workdir_sessions(),
|
||||
@@ -1588,8 +1613,11 @@ where
|
||||
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||
}),
|
||||
Arc::new(move || reopen_registry.reopen_internal()),
|
||||
),
|
||||
);
|
||||
);
|
||||
if feature_config.manage_workdir.enabled {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
workdir_wip_feature = Some(module);
|
||||
}
|
||||
if feature_config.workspace_worker_discovery.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
@@ -1644,20 +1672,34 @@ where
|
||||
}
|
||||
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut wip_mount_registry,
|
||||
feature,
|
||||
feature_config.workdir_catalog.enabled,
|
||||
feature_config.manage_workdir.enabled,
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Workdir WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
{
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
if feature_config.merge_request.any() && wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("merge-request", "merge-requests")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Merge Request WIP route: {error}"),
|
||||
format!("allocate Merge Request WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
@@ -1665,7 +1707,7 @@ where
|
||||
workspace_client.clone(),
|
||||
feature_config.merge_request,
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -1682,12 +1724,12 @@ where
|
||||
intake: feature_config.ticket.intake,
|
||||
workflow: feature_config.ticket.workflow,
|
||||
};
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("ticket")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("ticket", "tickets")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Ticket WIP route: {error}"),
|
||||
format!("allocate Ticket WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
@@ -1695,7 +1737,7 @@ where
|
||||
workspace_client.clone(),
|
||||
ticket_access,
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -1716,19 +1758,19 @@ where
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
if wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("objective")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("objective", "objectives")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Objective WIP route: {error}"),
|
||||
format!("allocate Objective WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
|
||||
&mut wip_mount_registry,
|
||||
workspace_client.clone(),
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -2140,9 +2182,11 @@ async fn controller_loop<C, St>(
|
||||
submission.input,
|
||||
vec![extension],
|
||||
submission.provenance,
|
||||
move || {
|
||||
let _ = input_commit_tx.send(());
|
||||
},
|
||||
input_committed_hook(
|
||||
pending_submissions.clone(),
|
||||
committed_submission.submission_id.clone(),
|
||||
input_commit_tx,
|
||||
),
|
||||
),
|
||||
&mut method_rx,
|
||||
&working_event_tx,
|
||||
@@ -2199,10 +2243,6 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
}
|
||||
};
|
||||
if notify_buffer.is_empty() {
|
||||
pending_submissions.finish_notification_batch();
|
||||
}
|
||||
|
||||
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
|
||||
match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) {
|
||||
Ok(Some(next)) => {
|
||||
@@ -2251,12 +2291,16 @@ async fn controller_loop<C, St>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// An expired Notify deadline must not repeatedly schedule a run that
|
||||
// cannot cross the invocation fence. Keep the accepted notification for
|
||||
// a fresh input/recovery boundary instead of retrying business effects.
|
||||
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
|
||||
.then(|| {
|
||||
notification_coalesce_remaining(
|
||||
&pending_submissions,
|
||||
¬ify_buffer,
|
||||
notification_coalesce_delay,
|
||||
worker.can_schedule_notification_run(),
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
@@ -3930,6 +3974,40 @@ mod tests {
|
||||
assert!(notify_buffer.has_notification_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
|
||||
let buffer = NotifyBuffer::new();
|
||||
pending
|
||||
.accept_notification("held".into(), "held".into())
|
||||
.unwrap();
|
||||
let before = pending.persisted_entries_for_test().len();
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(pending.persisted_entries_for_test().len(), before);
|
||||
assert_eq!(pending.snapshot().notification_count, 1);
|
||||
// A busy-time staged notification is equally retained behind the fence.
|
||||
stage_pending_notifications(&pending, &buffer);
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
assert!(buffer.has_notification_pending());
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_mixed_activations_preserve_global_fifo_order() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||
//! behind [`WorkspaceClient`].
|
||||
|
||||
pub mod wip;
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
|
||||
session_router: Arc<workdir::WorkdirSessionRouter>,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
|
||||
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
||||
before_workdir_release: None,
|
||||
after_workdir_attach: None,
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
|
||||
session_router,
|
||||
before_workdir_release: Some(before_workdir_release),
|
||||
after_workdir_attach: Some(after_workdir_attach),
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
|
||||
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
|
||||
backend
|
||||
}
|
||||
}
|
||||
|
||||
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
|
||||
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
let backend = self.backend();
|
||||
for (name, definition) in [
|
||||
(
|
||||
LIST_TOOL,
|
||||
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
}
|
||||
|
||||
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.detach_expected(alias, None)
|
||||
}
|
||||
|
||||
fn detach_expected(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let alias_path = encode_path_segment(alias);
|
||||
let suffix = expected_connection_id
|
||||
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
|
||||
.unwrap_or_default();
|
||||
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Delete,
|
||||
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
|
||||
path: format!(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
|
||||
),
|
||||
body: None,
|
||||
})?;
|
||||
workdir_output(
|
||||
@@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
)
|
||||
}
|
||||
|
||||
async fn detach_managed(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let _mutation_guard = self.attachment_mutation_lock.lock().await;
|
||||
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
|
||||
if let Some(expected) = expected_connection_id {
|
||||
// Under the shared local mutation lock, check lifetime before any
|
||||
// local route exclusion or child cleanup. Backend DELETE still performs
|
||||
// the authoritative conditional mutation under its lifecycle lock.
|
||||
let workspace = encode_path_segment(self.workspace_id()?);
|
||||
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
|
||||
encode_path_segment(expected)
|
||||
)))?;
|
||||
let current = page
|
||||
.get("items")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("connection_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(expected)
|
||||
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
|
||||
})
|
||||
});
|
||||
if !current {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: "workspace_http_409".into(),
|
||||
message: "attachment lifetime changed before local cleanup".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.detach_expected(alias, expected_connection_id) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
||||
result
|
||||
}
|
||||
WorkdirOperation::Detach => {
|
||||
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
|
||||
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.backend
|
||||
.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.backend.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.backend.detach(alias) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.backend
|
||||
.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
self.backend.detach_managed(&input.alias, None).await
|
||||
}
|
||||
|
||||
WorkdirOperation::Delete => self
|
||||
.backend
|
||||
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
|
||||
@@ -747,6 +804,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
|
||||
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
response: WorkspaceResponse,
|
||||
) -> Result<T, ToolError> {
|
||||
if matches!(response.status, 401 | 403 | 404 | 409) {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: format!("workspace_http_{}", response.status),
|
||||
message: "Workspace lifecycle authority rejected the request before execution".into(),
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Workdir API returned HTTP {}: {}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
//! authority. In that case model-visible Memory tools must go through the
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -23,11 +25,15 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
|
||||
use crate::hook::{
|
||||
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
|
||||
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
|
||||
WorkerRestored,
|
||||
};
|
||||
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||
use crate::worker::{
|
||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -73,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
||||
Decode(#[from] serde_json::Error),
|
||||
#[error("workspace memory backend rejected operation: {0}")]
|
||||
Backend(String),
|
||||
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
@@ -150,9 +158,24 @@ impl dyn WorkspaceClient + '_ {
|
||||
}
|
||||
}
|
||||
|
||||
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
async fn execute_subjektiv_memory_operation(
|
||||
client: &dyn WorkspaceClient,
|
||||
operation: server_api::SubjektivMemoryBackendOperation,
|
||||
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
|
||||
execute_subjektiv_memory_operation_with(client, operation, |client, request| {
|
||||
client.execute(request)
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_subjektiv_memory_operation_with(
|
||||
client: &dyn WorkspaceClient,
|
||||
operation: server_api::SubjektivMemoryBackendOperation,
|
||||
execute: impl FnOnce(
|
||||
&dyn WorkspaceClient,
|
||||
WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, WorkspaceClientError>,
|
||||
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
|
||||
let workspace_id =
|
||||
client
|
||||
@@ -163,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
|
||||
client.kind()
|
||||
),
|
||||
})?;
|
||||
let response = client.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/subjektiv/memory"),
|
||||
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
|
||||
))?;
|
||||
let response = execute(
|
||||
client,
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/subjektiv/memory"),
|
||||
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
|
||||
),
|
||||
)?;
|
||||
let status = reqwest::StatusCode::from_u16(response.status)
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !response.is_success() {
|
||||
@@ -653,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
#[derive(Default)]
|
||||
struct SubjectBehaviorRefreshState {
|
||||
applied: Option<(u64, String)>,
|
||||
pending: HashMap<u64, String>,
|
||||
}
|
||||
|
||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
||||
"The current ready resident memory surface is intentionally empty.";
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||
}
|
||||
|
||||
fn resident_summary_contribution(
|
||||
output: memory::backend::MemoryResidentSummaryOutput,
|
||||
@@ -696,36 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceSubjektivResidentSummarySource {
|
||||
async fn fetch(
|
||||
&self,
|
||||
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||
match execute_subjektiv_memory_operation_with(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||
|client, request| {
|
||||
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||
},
|
||||
)? {
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||
"unexpected subject resident context response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
output: &server_api::SubjektivResidentContextOutput,
|
||||
) -> Result<String, String> {
|
||||
use memory::backend::MemoryResidentSummaryAvailability as Availability;
|
||||
let availability = match output.memory_surface.availability {
|
||||
Availability::Ready => "ready",
|
||||
Availability::Ungenerated => "ungenerated",
|
||||
Availability::Stale => "stale",
|
||||
Availability::Failed => "failed",
|
||||
Availability::Unavailable => "unavailable",
|
||||
};
|
||||
self.prompts
|
||||
.load()
|
||||
.subjektiv_resident_context(
|
||||
&output.behavior_md,
|
||||
availability,
|
||||
output.memory_surface.content.as_deref(),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
|
||||
state.pending.remove(&output.behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
match execute_subjektiv_memory_operation(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
||||
match resident_summary_contribution(output) {
|
||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
||||
}
|
||||
contribution => contribution,
|
||||
match self.fetch().await {
|
||||
Ok(output) => match self.render(&output) {
|
||||
Ok(rendered) => {
|
||||
self.remember_behavior(&output);
|
||||
SystemPromptContribution::Ready(rendered)
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "subject resident context render failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
||||
SystemPromptContribution::Unavailable
|
||||
tracing::error!(%error, "subject resident context fetch failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<Option<ResidentContextRefresh>, String> {
|
||||
let output = self.fetch().await.map_err(|error| error.to_string())?;
|
||||
let changed = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied
|
||||
.as_ref()
|
||||
.is_none_or(|(revision, body)| {
|
||||
*revision != output.behavior_revision || body != &output.behavior_md
|
||||
});
|
||||
if !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
let rendered = self.render(&output)?;
|
||||
let revision = output.behavior_revision;
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.pending
|
||||
.insert(revision, output.behavior_md);
|
||||
Ok(Some(ResidentContextRefresh {
|
||||
body: rendered,
|
||||
revision,
|
||||
}))
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
if let Some(body) = state.pending.remove(&revision) {
|
||||
state.applied = Some((revision, body));
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_resident_context_representation(&self) {
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied = None;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
|
||||
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
|
||||
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
|
||||
|
||||
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||
@@ -773,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||
FeatureHookPoint::WorkerRestored,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
FeatureHookPoint::BeforeSessionRewrite,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -784,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
prompts: Arc::clone(&self.prompts),
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_llm_request(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRefreshHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_before_session_rewrite(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRewriteHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRewriteHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
|
||||
async fn call(
|
||||
&self,
|
||||
_context: &BeforeSessionRewriteContext,
|
||||
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||
self.source.invalidate_resident_context_representation();
|
||||
Ok(BeforeSessionRewriteAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
|
||||
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||
let refresh = self
|
||||
.source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
|
||||
if let Some(refresh) = refresh {
|
||||
let system_items = context.system_items().ok_or_else(|| {
|
||||
HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"Subject behavior refresh requires durable session append authority",
|
||||
)
|
||||
})?;
|
||||
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
|
||||
}
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivResidentRestoreRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
@@ -931,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
|
||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||
return Ok(None);
|
||||
@@ -968,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
}
|
||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||
client,
|
||||
prompts,
|
||||
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1100,6 +1293,10 @@ mod tests {
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
}
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
@@ -1112,7 +1309,9 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
behavior: Mutex<(u64, String)>,
|
||||
paths: Mutex<Vec<String>>,
|
||||
timeouts: Mutex<Vec<Duration>>,
|
||||
}
|
||||
|
||||
impl SubjectResidentClient {
|
||||
@@ -1124,7 +1323,9 @@ mod tests {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,7 +1334,9 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1364,7 @@ mod tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
if !self.scope_allowed {
|
||||
return Ok(WorkspaceResponse {
|
||||
@@ -1169,19 +1372,33 @@ mod tests {
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.timeouts.lock().unwrap().push(timeout);
|
||||
self.execute(request)
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||
@@ -1479,29 +1696,44 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready.load().await,
|
||||
SystemPromptContribution::Ready("current subject surface".into())
|
||||
);
|
||||
let ready = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_client.clone(),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded = ready.load().await;
|
||||
let SystemPromptContribution::Ready(loaded) = loaded else {
|
||||
panic!("subject resident context must be present");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(loaded.contains("current subject surface"));
|
||||
assert_eq!(
|
||||
ready_client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
ready_client.timeouts.lock().unwrap().as_slice(),
|
||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT]
|
||||
);
|
||||
|
||||
let ready_empty_client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready_empty.load().await,
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
||||
);
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_empty_client,
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
|
||||
panic!("empty surface must retain Subject behavior");
|
||||
};
|
||||
assert!(ready_empty.contains("Be deliberate."));
|
||||
assert!(ready_empty.contains("intentionally empty"));
|
||||
|
||||
for availability in [
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
@@ -1514,22 +1746,107 @@ permission = "write"
|
||||
availability,
|
||||
Some("must not be injected"),
|
||||
)),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(loaded) = source.load().await else {
|
||||
panic!("Subject behavior must survive unavailable surfaces");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(!loaded.contains("must not be injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let initial = source.load().await;
|
||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
|
||||
let refresh = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("changed behavior must produce one refresh");
|
||||
assert_eq!(refresh.revision, 4);
|
||||
assert!(refresh.body.contains("Ask when uncertain."));
|
||||
source.confirm_resident_context_revision(refresh.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (5, String::new());
|
||||
let cleared = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("cleared behavior must produce one refresh");
|
||||
assert_eq!(cleared.revision, 5);
|
||||
assert!(cleared.body.contains("No user-managed behavior is set"));
|
||||
source.confirm_resident_context_revision(cleared.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
source.invalidate_resident_context_representation();
|
||||
let replay_after_rewrite = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("history rewrite must require a durable current representation");
|
||||
assert_eq!(replay_after_rewrite.revision, 5);
|
||||
assert!(
|
||||
replay_after_rewrite
|
||||
.body
|
||||
.contains("No user-managed behavior is set")
|
||||
);
|
||||
// Initial load and every live update/clear/replay probe retain the
|
||||
// target-side bounded resident request contract.
|
||||
assert_eq!(
|
||||
client.timeouts.lock().unwrap().as_slice(),
|
||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::denied());
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(failure) = source.load().await else {
|
||||
panic!("fetch failure must be explicit in resident context");
|
||||
};
|
||||
assert!(failure.contains("could not fetch"));
|
||||
assert_eq!(
|
||||
client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1557,7 +1874,7 @@ permission = "write"
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1569,22 +1886,30 @@ permission = "write"
|
||||
Some("policy-only surface must not load"),
|
||||
));
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
ordinary_subjektiv_resident_summary_source(
|
||||
&policy_only,
|
||||
unattached_client.clone(),
|
||||
test_prompts()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||
|
||||
let foreign = subject_manifest("other-workspace");
|
||||
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = Arc::new(ArcSwap::from(
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
));
|
||||
@@ -1598,9 +1923,10 @@ permission = "write"
|
||||
#[test]
|
||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||
source,
|
||||
Arc::new(ArcSwap::from(
|
||||
@@ -1611,8 +1937,13 @@ permission = "write"
|
||||
let descriptor = feature.descriptor();
|
||||
|
||||
assert!(descriptor.tools.is_empty());
|
||||
assert_eq!(descriptor.hooks.len(), 1);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
|
||||
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||
assert_eq!(
|
||||
descriptor.hooks[2].point,
|
||||
FeatureHookPoint::BeforeSessionRewrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::feature::{
|
||||
};
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/merge-request/merge-requests".into(),
|
||||
route: "/merge-requests".into(),
|
||||
message: format!("Merge Request tool schema cannot be retained: {error}"),
|
||||
}
|
||||
})?;
|
||||
@@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
config: MergeRequestFeatureConfig,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("merge-requests")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let tools = native_merge_request_tools(client, config)?;
|
||||
let claimed_tools = tools
|
||||
.iter()
|
||||
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||
route: "/features/merge-request/merge-requests".into(),
|
||||
route: "/merge-requests".into(),
|
||||
message: format!(
|
||||
"Merge Request tool `{}` schema has no properties",
|
||||
tool.name
|
||||
@@ -1474,7 +1474,9 @@ mod tests {
|
||||
#[test]
|
||||
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("merge-request").unwrap();
|
||||
let namespace_route = registry
|
||||
.allocate_namespace("merge-request", "merge-requests")
|
||||
.unwrap();
|
||||
mount_workspace_http_merge_request_wip(
|
||||
&mut registry,
|
||||
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
|
||||
@@ -1484,17 +1486,14 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/merge-request/merge-requests"]
|
||||
);
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/merge-request/merge-requests".into(),
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
|
||||
};
|
||||
assert!(
|
||||
@@ -1645,7 +1644,7 @@ mod tests {
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/merge-request/merge-requests".into(),
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let first = resolver.resolve("MR-1").unwrap().object.validator;
|
||||
|
||||
@@ -21,9 +21,9 @@ use wip_protocol::{
|
||||
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
|
||||
registry: &mut WipMountRegistry,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("objectives")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
let collection_descriptor = objective_collection_descriptor();
|
||||
@@ -1775,7 +1775,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("objective").unwrap();
|
||||
let namespace_route = registry
|
||||
.allocate_namespace("objective", "objectives")
|
||||
.unwrap();
|
||||
mount_workspace_http_objective_wip(
|
||||
&mut registry,
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
@@ -1783,18 +1785,16 @@ mod tests {
|
||||
"http://backend",
|
||||
)),
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/objective/objectives"]
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
|
||||
assert_eq!(namespace_route.root(), "/objectives");
|
||||
assert!(
|
||||
registry
|
||||
.allocate_namespace("objective", "../tickets")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
feature_route.child("objectives").unwrap(),
|
||||
"/features/objective/objectives"
|
||||
);
|
||||
assert!(feature_route.child("../tickets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1805,7 +1805,7 @@ mod tests {
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let initial = resolver.resolve("O-3").unwrap();
|
||||
@@ -1971,7 +1971,7 @@ mod tests {
|
||||
let collection = ObjectiveCollectionWipHandler {
|
||||
backend,
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let result = collection
|
||||
@@ -2013,7 +2013,7 @@ mod tests {
|
||||
let collection = ObjectiveCollectionWipHandler {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let context = || WipCallContext {
|
||||
@@ -2170,7 +2170,7 @@ mod tests {
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let output = match handler
|
||||
@@ -2191,10 +2191,7 @@ mod tests {
|
||||
let output = crate::wip::wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(output["next_cursor"], "next-page");
|
||||
assert_eq!(output["has_more"], true);
|
||||
assert_eq!(
|
||||
output["objectives"][0]["path"],
|
||||
"/features/objective/objectives/O-3"
|
||||
);
|
||||
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
|
||||
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
|
||||
assert!(!output.to_string().contains("00001OBJECTIVE"));
|
||||
assert!(!output.to_string().contains("00001TICKET"));
|
||||
@@ -2243,7 +2240,7 @@ mod tests {
|
||||
let resolver = ObjectiveItemResolver {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let before = resolver.resolve("O-3").unwrap().object.validator;
|
||||
|
||||
@@ -34,9 +34,9 @@ use crate::feature::{
|
||||
};
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
|
||||
let (meta, tool) = definition();
|
||||
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!(
|
||||
"enabled Ticket tool `{}` has no native projection",
|
||||
meta.name
|
||||
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
|
||||
})?;
|
||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!("Ticket tool schema cannot be retained: {error}"),
|
||||
}
|
||||
})?;
|
||||
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
access: TicketFeatureAccess,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("tickets")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let tools = native_ticket_tools(client, access)?;
|
||||
let claimed_tools = tools
|
||||
.iter()
|
||||
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!("Ticket tool `{}` schema has no properties", tool.name),
|
||||
})?;
|
||||
let required = tool
|
||||
@@ -2103,7 +2103,7 @@ mod tests {
|
||||
#[test]
|
||||
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("ticket").unwrap();
|
||||
let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap();
|
||||
mount_workspace_http_ticket_wip(
|
||||
&mut registry,
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
@@ -2112,20 +2112,21 @@ mod tests {
|
||||
)),
|
||||
TicketFeatureAccess::review(),
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/ticket/tickets"]
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
|
||||
assert!(
|
||||
registry
|
||||
.allocate_namespace("ticket", "../objectives")
|
||||
.is_err()
|
||||
);
|
||||
assert!(feature_route.child("../objectives").is_err());
|
||||
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions,
|
||||
};
|
||||
assert!(resolver.resolve("T-42").is_some());
|
||||
@@ -2189,7 +2190,7 @@ mod tests {
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
|
||||
@@ -2243,7 +2244,7 @@ mod tests {
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
|
||||
|
||||
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(item);
|
||||
}
|
||||
|
||||
/// Queue a revisioned Subject behavior refresh. The caller receives this
|
||||
/// narrow operation only at a pre-request boundary, and the host commits it
|
||||
/// before exposing it to the model.
|
||||
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("system-item append queue poisoned")
|
||||
.push(SystemItem::SubjectBehaviorRefresh {
|
||||
body: body.into(),
|
||||
behavior_revision,
|
||||
prompt_provenance: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-created narrow append authority exposed only at a restore lifecycle
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Recovery tests use the normal Worker execution and append-log commit gate.
|
||||
use super::*;
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::{ClientError, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RejectImages {
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl LlmClient for RejectImages {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let has_image = request.items.iter().any(
|
||||
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty()),
|
||||
);
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if has_image {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
|
||||
cancel.try_send(()).unwrap();
|
||||
return Ok(Box::pin(futures::stream::pending()));
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
Ok(LlmEvent::text_block_start(0)),
|
||||
Ok(LlmEvent::text_delta(0, "Resize the source image")),
|
||||
Ok(LlmEvent::text_block_stop(0, None)),
|
||||
Ok(LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_worker() -> (
|
||||
tempfile::TempDir,
|
||||
Worker<RejectImages, session_store::FsStore>,
|
||||
RejectImages,
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = session_store::FsStore::new(dir.path().join("session")).unwrap();
|
||||
let manifest = WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "image-recovery-test"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test"
|
||||
[engine]
|
||||
[scope]
|
||||
allow = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let client = RejectImages::default();
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
|
||||
store,
|
||||
WorkerWorkspaceContext::local_filesystem(None),
|
||||
WorkerFilesystemAuthority::None,
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.disable_manifest_lifecycle_features();
|
||||
worker.set_history_for_test(vec![
|
||||
Item::user_message("inspect this image"),
|
||||
Item::tool_call("image-call", "ViewImage", "{}"),
|
||||
Item::tool_result_item_with_disposition_and_attachments(
|
||||
"image-call",
|
||||
"Attached image",
|
||||
None,
|
||||
agen::ToolResultDisposition::Success,
|
||||
vec![Attachment::Image(ImageAttachment::new(
|
||||
"image/png",
|
||||
vec![1_u8, 2, 3],
|
||||
))],
|
||||
),
|
||||
]);
|
||||
worker.wire_history_persistence();
|
||||
(dir, worker, client)
|
||||
}
|
||||
fn rejected(item: &Item) -> bool {
|
||||
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
assert_eq!(
|
||||
worker.run_text("continue").await.unwrap(),
|
||||
WorkerRunResult::Finished
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
let correction = raw
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
|
||||
assert_eq!(
|
||||
restored
|
||||
.iter()
|
||||
.filter(|entry| rejected(&entry.item))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
session_store::collect_state(&raw)
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| rejected(item))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let LogEntry::ToolResultCorrected {
|
||||
entry: corrected, ..
|
||||
} = &correction
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let original = match &raw[0] {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. } => history
|
||||
.iter()
|
||||
.find(|entry| entry.metadata.entry_id == corrected.metadata.entry_id)
|
||||
.unwrap(),
|
||||
_ => panic!("missing seed"),
|
||||
};
|
||||
assert!(
|
||||
matches!(&original.item, session_store::LoggedItem::ToolResult { attachments, is_error: false, .. } if attachments.len() == 1)
|
||||
);
|
||||
assert_eq!(
|
||||
restored
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.unwrap()
|
||||
.annotation,
|
||||
original.metadata
|
||||
);
|
||||
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&raw);
|
||||
let projected = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.entry_id == corrected.metadata.entry_id.0)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&projected.data, protocol::SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
|
||||
);
|
||||
assert!(matches!(
|
||||
crate::ipc::protocol_session::live_log_entry_event(correction),
|
||||
Some(Event::SessionEntryCommitted { .. })
|
||||
));
|
||||
|
||||
// Fork/compact seed writers serialize the effective annotated history.
|
||||
let seed = LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id: worker.session_id(),
|
||||
system_prompt: None,
|
||||
config: agen::llm_client::RequestConfig::default(),
|
||||
history: restored.iter().map(to_logged_history_entry).collect(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let restored_again =
|
||||
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
|
||||
assert_eq!(
|
||||
restored_again
|
||||
.iter()
|
||||
.filter(|entry| rejected(&entry.item))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let mut history = History::from_entries(restored_again);
|
||||
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
|
||||
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
|
||||
Ok(SessionHistoryMetadata::legacy_unknown())
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(out.result, EngineRunExit::Finished));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
|
||||
let result = worker.run_text("continue").await.unwrap();
|
||||
assert_eq!(result, WorkerRunResult::Cancelled);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(
|
||||
raw.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
);
|
||||
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
|
||||
assert_eq!(
|
||||
restored
|
||||
.iter()
|
||||
.filter(|entry| rejected(&entry.item))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,9 @@ use crate::hook::{
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::session_history::SessionHistoryMetadata;
|
||||
use crate::worker::SystemItemCommitter;
|
||||
use crate::worker::{
|
||||
DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource,
|
||||
};
|
||||
use agen::HistoryEntry;
|
||||
use agen::token_counter::total_tokens;
|
||||
|
||||
@@ -109,6 +111,8 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
notification_committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
next_turn_index: AtomicUsize,
|
||||
@@ -163,12 +167,22 @@ impl WorkerInterceptor {
|
||||
prompts,
|
||||
prompt_workspace_id: None,
|
||||
log_writer,
|
||||
notification_committer: None,
|
||||
resident_context_source: None,
|
||||
pending_committed_history,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
tool_calls_this_turn: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_notification_committer(
|
||||
mut self,
|
||||
committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||
) -> Self {
|
||||
self.notification_committer = committer;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
|
||||
self.usage_tracker = Some(usage_tracker);
|
||||
self
|
||||
@@ -179,11 +193,31 @@ impl WorkerInterceptor {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_resident_context_source(
|
||||
mut self,
|
||||
source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
) -> Self {
|
||||
self.resident_context_source = source;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
|
||||
self.prompt_workspace_id = workspace_id;
|
||||
self
|
||||
}
|
||||
|
||||
fn confirm_resident_context_item(&self, item: &SystemItem) {
|
||||
let Some(source) = self.resident_context_source.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision, ..
|
||||
} = item
|
||||
{
|
||||
source.confirm_resident_context_revision(*behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||
/// entry through the attached writer (no-op when no writer is
|
||||
/// wired). Sync — writes complete before the matching
|
||||
@@ -211,6 +245,7 @@ impl WorkerInterceptor {
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(entry);
|
||||
self.confirm_resident_context_item(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -350,6 +385,11 @@ impl WorkerInterceptor {
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.task_reminder"));
|
||||
}
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
|
||||
}
|
||||
SystemItem::Interrupt {
|
||||
prompt_provenance, ..
|
||||
} if prompt_provenance.is_none() => {
|
||||
@@ -452,19 +492,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
projection_digest: projection.catalog_digest.clone(),
|
||||
logical_name: "internal.notify_wrapper".to_string(),
|
||||
};
|
||||
let mut system_items: Vec<(
|
||||
SystemItem,
|
||||
Vec<session_store::SessionExtension>,
|
||||
Option<session_store::LoggedSessionHistoryOrigin>,
|
||||
)> = Vec::with_capacity(drained.len());
|
||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
||||
let mut system_items = Vec::with_capacity(drained.len());
|
||||
for entry in &drained {
|
||||
let system_item = match build_system_item_with_provenance(
|
||||
entry,
|
||||
&prompts,
|
||||
Some(provenance.clone()),
|
||||
) {
|
||||
Ok(system_item) => system_item,
|
||||
match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) {
|
||||
Ok(item) => system_items.push(item),
|
||||
Err(error) => {
|
||||
self.pending_notifies.requeue_front(drained);
|
||||
return Err(InterceptorError::new(
|
||||
@@ -472,16 +503,50 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
format!("failed to render notify_wrapper: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
items.push(system_item.to_history_item());
|
||||
system_items.push((system_item, entry.extensions(), entry.history_provenance()));
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.commit_system_items_with_extensions(&system_items) {
|
||||
self.pending_notifies.requeue_front(drained);
|
||||
return Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
format!("session persistence failed: {error}"),
|
||||
));
|
||||
let mut items = Vec::with_capacity(drained.len());
|
||||
let mut remaining = drained.into_iter();
|
||||
for item in system_items {
|
||||
let entry = remaining.next().expect("rendered notification must exist");
|
||||
let committed = if let Some(identity) = entry.notification_commit() {
|
||||
match self.notification_committer.as_ref() {
|
||||
Some(committer) => {
|
||||
committer.commit_notification(identity, item, entry.history_provenance())
|
||||
}
|
||||
None => Err(session_store::StoreError::Io(std::io::Error::other(
|
||||
"durable notification commit authority is missing",
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
self.commit_system_items_with_extensions(&[(
|
||||
item.clone(),
|
||||
Vec::new(),
|
||||
entry.history_provenance(),
|
||||
)])
|
||||
.map(|()| {
|
||||
items.push(item.to_history_item());
|
||||
None
|
||||
})
|
||||
};
|
||||
match committed {
|
||||
Ok(Some(history)) => {
|
||||
items.push(history.item.clone());
|
||||
self.pending_committed_history
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(history);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.pending_notifies
|
||||
.requeue_front(std::iter::once(entry).chain(remaining).collect());
|
||||
return Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
format!("session persistence failed: {error}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -781,6 +846,7 @@ mod tests {
|
||||
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
|
||||
};
|
||||
use crate::session_history::{WorkerHistoryProvenance, history_entry};
|
||||
use crate::worker::SystemPromptContribution;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
@@ -877,6 +943,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppendingSubjectBehaviorHook;
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
|
||||
async fn call(
|
||||
&self,
|
||||
input: &PreRequestContext,
|
||||
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||
input
|
||||
.system_items()
|
||||
.expect("test interceptor has durable append authority")
|
||||
.append_subject_behavior_refresh("current Subject context", 7);
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmRecordingResidentSource {
|
||||
confirmed: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
self.confirmed.lock().unwrap().push(revision);
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingSystemItemCommitter;
|
||||
|
||||
impl SystemItemCommitter for FailingSystemItemCommitter {
|
||||
fn commit_log_entry(
|
||||
&self,
|
||||
entry: session_store::LogEntry,
|
||||
) -> Result<(), session_store::StoreError> {
|
||||
if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) {
|
||||
return Err(session_store::StoreError::Io(std::io::Error::other(
|
||||
"synthetic system-item failure",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn task_tool_call_info(
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
@@ -1364,6 +1477,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let confirmed = Arc::new(Mutex::new(Vec::new()));
|
||||
let source: Arc<dyn SystemPromptContributionSource> =
|
||||
Arc::new(ConfirmRecordingResidentSource {
|
||||
confirmed: Arc::clone(&confirmed),
|
||||
});
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
Arc::new(builder.build()),
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
test_prompts(),
|
||||
Some(Arc::new(RecordingSystemItemCommitter {
|
||||
committed: Arc::clone(&committed),
|
||||
})),
|
||||
)
|
||||
.with_resident_context_source(Some(source));
|
||||
|
||||
let mut ctx = Vec::new();
|
||||
let action = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: Default::default(),
|
||||
items: &mut ctx,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
|
||||
assert_eq!(*confirmed.lock().unwrap(), [7]);
|
||||
assert!(matches!(
|
||||
committed.lock().unwrap().as_slice(),
|
||||
[SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
prompt_provenance: Some(_),
|
||||
..
|
||||
}]
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
|
||||
let mut builder = HookRegistryBuilder::new();
|
||||
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
|
||||
let confirmed = Arc::new(Mutex::new(Vec::new()));
|
||||
let source: Arc<dyn SystemPromptContributionSource> =
|
||||
Arc::new(ConfirmRecordingResidentSource {
|
||||
confirmed: Arc::clone(&confirmed),
|
||||
});
|
||||
let interceptor = WorkerInterceptor::new(
|
||||
Arc::new(builder.build()),
|
||||
None,
|
||||
None,
|
||||
NotifyBuffer::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
test_prompts(),
|
||||
Some(Arc::new(FailingSystemItemCommitter)),
|
||||
)
|
||||
.with_resident_context_source(Some(source));
|
||||
|
||||
let mut ctx = Vec::new();
|
||||
let action = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: Default::default(),
|
||||
items: &mut ctx,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
PreRequestAction::Cancel(reason) if reason.contains("session persistence failed")
|
||||
));
|
||||
assert!(confirmed.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
|
||||
let saw_handle = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::worker::NotificationCommitIdentity;
|
||||
use protocol::WorkerEvent;
|
||||
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
|
||||
use session_store::{LoggedSessionHistoryOrigin, SystemItem};
|
||||
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
|
||||
@@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
pub enum PendingNotify {
|
||||
Notify {
|
||||
message: String,
|
||||
extensions: Vec<SessionExtension>,
|
||||
notification_commit: Option<NotificationCommitIdentity>,
|
||||
history_provenance: Option<LoggedSessionHistoryOrigin>,
|
||||
},
|
||||
WorkerEvent {
|
||||
@@ -48,10 +49,13 @@ pub enum PendingNotify {
|
||||
}
|
||||
|
||||
impl PendingNotify {
|
||||
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
|
||||
pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> {
|
||||
match self {
|
||||
PendingNotify::Notify { extensions, .. } => extensions.clone(),
|
||||
PendingNotify::WorkerEvent { .. } => Vec::new(),
|
||||
PendingNotify::Notify {
|
||||
notification_commit,
|
||||
..
|
||||
} => notification_commit.as_ref(),
|
||||
PendingNotify::WorkerEvent { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +89,7 @@ impl NotifyBuffer {
|
||||
pub fn push_notify(&self, message: String) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
extensions: Vec::new(),
|
||||
notification_commit: None,
|
||||
history_provenance: None,
|
||||
});
|
||||
}
|
||||
@@ -94,11 +98,11 @@ impl NotifyBuffer {
|
||||
&self,
|
||||
message: String,
|
||||
history_provenance: LoggedSessionHistoryOrigin,
|
||||
extension: SessionExtension,
|
||||
identity: NotificationCommitIdentity,
|
||||
) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
extensions: vec![extension],
|
||||
notification_commit: Some(identity),
|
||||
history_provenance: Some(history_provenance),
|
||||
});
|
||||
}
|
||||
@@ -233,7 +237,7 @@ mod tests {
|
||||
fn build_system_item_for_notify_carries_wrapper_body() {
|
||||
let entry = PendingNotify::Notify {
|
||||
message: "hello".into(),
|
||||
extensions: Vec::new(),
|
||||
notification_commit: None,
|
||||
history_provenance: None,
|
||||
};
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
}
|
||||
entry @ (LogEntry::AnnotatedAssistantItem { .. }
|
||||
| LogEntry::AnnotatedToolResult { .. }
|
||||
| LogEntry::ToolResultCorrected { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
| LogEntry::RunResumed { .. }
|
||||
| LogEntry::RunCancelled { .. }
|
||||
|
||||
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
|
||||
AgentsMdSection,
|
||||
ResidentMemorySummarySection,
|
||||
ResidentMemoryRestoreSection,
|
||||
SubjektivResidentContext,
|
||||
WorkerOrchestrationGuidanceSection,
|
||||
SubWorkerSpawnToolDescription,
|
||||
}
|
||||
@@ -128,6 +129,7 @@ impl WorkerPrompt {
|
||||
Self::AgentsMdSection => "internal.agents_md_section",
|
||||
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
|
||||
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
|
||||
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
|
||||
Self::WorkerOrchestrationGuidanceSection => {
|
||||
"internal.worker_orchestration_guidance_section"
|
||||
}
|
||||
@@ -150,6 +152,7 @@ impl WorkerPrompt {
|
||||
WorkerPrompt::AgentsMdSection,
|
||||
WorkerPrompt::ResidentMemorySummarySection,
|
||||
WorkerPrompt::ResidentMemoryRestoreSection,
|
||||
WorkerPrompt::SubjektivResidentContext,
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
WorkerPrompt::SubWorkerSpawnToolDescription,
|
||||
];
|
||||
@@ -518,6 +521,21 @@ impl PromptCatalog {
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn subjektiv_resident_context(
|
||||
&self,
|
||||
behavior_md: &str,
|
||||
surface_availability: &str,
|
||||
surface_body: Option<&str>,
|
||||
) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::SubjektivResidentContext,
|
||||
minijinja::context! {
|
||||
behavior_md => behavior_md,
|
||||
surface_availability => surface_availability,
|
||||
surface_body => surface_body.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||
self.render(
|
||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||
|
||||
@@ -121,6 +121,7 @@ impl SegmentLogSink {
|
||||
| LogEntry::AnnotatedUserInput { .. }
|
||||
| LogEntry::AnnotatedAssistantItem { .. }
|
||||
| LogEntry::AnnotatedToolResult { .. }
|
||||
| LogEntry::ToolResultCorrected { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
|
||||
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
|
||||
LogEntry::ToolResultCorrected { entry, .. } => {
|
||||
let target = history
|
||||
.iter_mut()
|
||||
.find(|existing| existing.annotation.entry_id == entry.metadata.entry_id)
|
||||
.ok_or_else(|| "tool result correction target is missing".to_string())?;
|
||||
if !matches!((&target.item, &entry.item), (Item::ToolResult { call_id, .. }, session_store::LoggedItem::ToolResult { call_id: replacement, .. }) if call_id == replacement)
|
||||
{
|
||||
return Err("tool result correction changed call identity".to_string());
|
||||
}
|
||||
*target = from_logged(entry);
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||
entry.item.to_history_item(),
|
||||
entry.metadata.clone(),
|
||||
|
||||
+2856
-147
File diff suppressed because it is too large
Load Diff
+967
-43
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user