worker: split session capture observation features

This commit is contained in:
2026-08-07 16:31:15 +09:00
parent a9bb806387
commit ff50baec99
22 changed files with 2102 additions and 879 deletions
+4 -1
View File
@@ -1600,9 +1600,12 @@ fn tool_kind(name: &str) -> &'static str {
"WebFetch" | "WebSearch" => "web", "WebFetch" | "WebSearch" => "web",
"SubWorkerSpawn" "SubWorkerSpawn"
| "SubWorkerSend" | "SubWorkerSend"
| "SubWorkerReadOutput"
| "SubWorkerList" | "SubWorkerList"
| "SubWorkerStop" | "SubWorkerStop"
| "ListWorkerSessions"
| "ViewSessionOverview"
| "SearchSessionEntries"
| "ReadSessionEntry"
| "WorkerList" | "WorkerList"
| "WorkerSpawn" | "WorkerSpawn"
| "WorkerStop" | "WorkerStop"
+11 -8
View File
@@ -34,8 +34,8 @@ use crate::compact::usage_tracker::UsageTracker;
use crate::fs_view::ReadRequirement; use crate::fs_view::ReadRequirement;
#[cfg(test)] #[cfg(test)]
use crate::fs_view::slice_lines; use crate::fs_view::slice_lines;
use crate::session_reference::{ use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionReferenceView, ToolPart, ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
}; };
/// Aggregated output of a compact worker run. /// Aggregated output of a compact worker run.
@@ -150,7 +150,7 @@ this to verify details before writing the summary.";
struct SessionLogToolState { struct SessionLogToolState {
items: Arc<Vec<Item>>, items: Arc<Vec<Item>>,
view: SessionReferenceView, view: SessionCapture,
} }
struct SearchSessionLogTool { struct SearchSessionLogTool {
@@ -185,6 +185,9 @@ impl Tool for SearchSessionLogTool {
tool_name: None, tool_name: None,
limit: Some(limit), limit: Some(limit),
min_entry_index: Some(offset as u64), min_entry_index: Some(offset as u64),
from: None,
through: None,
offset: 0,
}); });
let blocks = hits let blocks = hits
.iter() .iter()
@@ -252,7 +255,7 @@ impl Tool for ReadSessionItemsTool {
SessionReadMode::Full => ReadDetail::Full, SessionReadMode::Full => ReadDetail::Full,
}; };
let read = if offset >= end { let read = if offset >= end {
crate::session_reference::ReadResult { crate::session_capture::ReadResult {
entries: Vec::new(), entries: Vec::new(),
truncated: false, truncated: false,
} }
@@ -496,7 +499,7 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
} }
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition { pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionReferenceView::new("compact-target", (*items).clone()); let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view }); let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(SearchSessionParams); let schema = schemars::schema_for!(SearchSessionParams);
@@ -512,7 +515,7 @@ pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
} }
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition { pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionReferenceView::new("compact-target", (*items).clone()); let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view }); let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(ReadSessionParams); let schema = schemars::schema_for!(ReadSessionParams);
@@ -808,7 +811,7 @@ mod tests {
"very large raw trace body with secret detail", "very large raw trace body with secret detail",
), ),
]); ]);
let view = SessionReferenceView::new("test", (*items).clone()); let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool { let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: Arc::new(SessionLogToolState { items, view }), state: Arc::new(SessionLogToolState { items, view }),
}); });
@@ -828,7 +831,7 @@ mod tests {
"read trace", "read trace",
"raw trace detail", "raw trace detail",
)]); )]);
let view = SessionReferenceView::new("test", (*items).clone()); let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool { let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
state: Arc::new(SessionLogToolState { items, view }), state: Arc::new(SessionLogToolState { items, view }),
}); });
+31 -6
View File
@@ -21,9 +21,7 @@ use crate::shutdown_after_idle::{
ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role, ShutdownAfterIdleRequest, TicketIntakeReadyShutdownHook, is_ticket_intake_role,
take_shutdown_request_after_status, take_shutdown_request_after_status,
}; };
use crate::spawn::comm_tools::{ use crate::spawn::comm_tools::{sub_worker_list_tool, sub_worker_send_tool, sub_worker_stop_tool};
sub_worker_list_tool, sub_worker_read_output_tool, sub_worker_send_tool, sub_worker_stop_tool,
};
use crate::spawn::registry::SpawnedWorkerRegistry; use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::spawn::tool::sub_worker_spawn_tool; use crate::spawn::tool::sub_worker_spawn_tool;
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult}; use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
@@ -59,6 +57,10 @@ impl WorkerHandle {
self.event_tx.subscribe() self.event_tx.subscribe()
} }
pub fn committed_entries(&self) -> Vec<LogEntry> {
self.sink.subscribe_with_snapshot().0
}
pub fn snapshot_event(&self) -> Event { pub fn snapshot_event(&self) -> Event {
self.snapshot_event_with_entry_subscription().0 self.snapshot_event_with_entry_subscription().0
} }
@@ -725,6 +727,7 @@ where
worker.register_worker_orchestration_instruction(); worker.register_worker_orchestration_instruction();
} }
let host_worker_observation_provider = worker.worker_observation_provider();
{ {
let workspace_client = worker.workspace_client_handle(); let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut(); let engine = worker.engine_mut();
@@ -779,7 +782,11 @@ where
} }
} }
// Worker-orchestration tools (SubWorkerSpawn + the four comm tools) share let mut observation_providers: Vec<
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
> = Vec::new();
// Worker-orchestration tools (SubWorkerSpawn + three control tools) share
// the Worker-scoped `SpawnedWorkerRegistry` (also consumed by the main // the Worker-scoped `SpawnedWorkerRegistry` (also consumed by the main
// loop's `WorkerEvent` handler). Expose them only behind the explicit // loop's `WorkerEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling // profile feature and require delegation authority up front so enabling
@@ -814,8 +821,26 @@ where
)); ));
engine.register_tool(sub_worker_list_tool(spawned_registry.clone())); engine.register_tool(sub_worker_list_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_send_tool(spawned_registry.clone())); engine.register_tool(sub_worker_send_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_read_output_tool(spawned_registry.clone())); engine.register_tool(sub_worker_stop_tool(spawned_registry.clone()));
engine.register_tool(sub_worker_stop_tool(spawned_registry)); observation_providers.push(Arc::new(
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
spawned_registry,
),
));
}
if let Some(provider) = host_worker_observation_provider {
observation_providers.push(provider);
}
if !observation_providers.is_empty() {
feature_registry = feature_registry.with_module(
crate::feature::builtin::worker_observation::WorkerObservationFeature::new(
Arc::new(
crate::feature::builtin::worker_observation::CompositeWorkerObservationProvider::new(
observation_providers,
),
),
),
);
} }
} }
let _feature_install_report = worker.install_features(feature_registry); let _feature_install_report = worker.install_features(feature_registry);
+9 -3
View File
@@ -7,16 +7,22 @@
pub mod manage_workdir; pub mod manage_workdir;
pub mod manage_worker; pub mod manage_worker;
pub mod memory; pub mod memory;
pub mod memory_extract;
pub mod objective; pub mod objective;
pub mod session_explore; pub mod session_explore;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;
pub mod worker_observation;
pub(crate) use session_explore::{ pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
SessionExploreFeature, SessionExploreState, render_extract_input, pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
};
pub use task::{TaskFeature, task_tools_feature}; pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{ pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
ticket_tools_feature_with_backend, ticket_tools_feature_with_backend,
}; };
pub use worker_observation::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationFeature,
WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef,
WorkerSessionCapture, WorkspaceClientWorkerObservationProvider,
};
@@ -0,0 +1,458 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::session_capture::{
ReferenceKind, SearchOptions, SessionCapture, SessionEntryEvidence, ToolPart,
};
use crate::worker::WorkspaceClient;
use super::memory::WorkspaceMemoryBackendError;
const STAGE_DESCRIPTION: &str = "Stage one durable Memory candidate using SessionEntryRef values from the co-installed session-explore capture.";
const FINISH_DESCRIPTION: &str =
"Finish Memory extraction after validating the number of candidates staged during this run.";
#[derive(Clone)]
pub(crate) struct MemoryExtractState {
view: Arc<SessionCapture>,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
staged: Arc<Mutex<Vec<String>>>,
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
}
impl MemoryExtractState {
pub(crate) fn new(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
extract_run_id: String,
) -> Self {
Self {
view: Arc::new(view),
workspace_client,
source,
extract_run_id,
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn staged(&self) -> Vec<String> {
self.staged
.lock()
.expect("memory extract staged state poisoned")
.clone()
}
pub(crate) fn is_finished(&self) -> bool {
self.finished
.lock()
.expect("memory extract finished state poisoned")
.is_some()
}
}
#[derive(Clone)]
pub(crate) struct MemoryExtractFeature {
state: MemoryExtractState,
}
impl MemoryExtractFeature {
pub(crate) fn new(state: MemoryExtractState) -> Self {
Self { state }
}
}
impl FeatureModule for MemoryExtractFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-extract", "Memory Extract")
.with_description(
"Memory staging and extraction completion, independent from session exploration.",
)
.with_tool(ToolDeclaration::new(
"StageMemoryCandidate",
STAGE_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(
"FinishMemoryExtraction",
FINISH_DESCRIPTION,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
"StageMemoryCandidate",
stage_definition(self.state.clone()),
))?;
context.tools().register(ToolContribution::new(
"FinishMemoryExtraction",
finish_definition(self.state.clone()),
))?;
Ok(())
}
}
fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("StageMemoryCandidate")
.description(STAGE_DESCRIPTION)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(StageMemoryCandidateTool {
state: state.clone(),
});
(meta, tool)
})
}
fn finish_definition(state: MemoryExtractState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("FinishMemoryExtraction")
.description(FINISH_DESCRIPTION)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(FinishMemoryExtractionTool {
state: state.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct StageMemoryCandidateParams {
kind: CandidateKind,
claim: String,
why_useful: String,
#[serde(default)]
staleness: Option<String>,
entry_refs: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct FinishMemoryExtractionParams {
staged_count: usize,
#[serde(default)]
no_candidates_reason: Option<String>,
}
struct StageMemoryCandidateTool {
state: MemoryExtractState,
}
#[async_trait]
impl Tool for StageMemoryCandidateTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: StageMemoryCandidateParams =
serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid StageMemoryCandidate input: {error}"))
})?;
if params.entry_refs.is_empty() {
return Err(ToolError::InvalidArgument(
"StageMemoryCandidate requires at least one entry_ref".to_string(),
));
}
let mut evidence = Vec::with_capacity(params.entry_refs.len());
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
for entry_ref in &params.entry_refs {
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
))
})?;
evidence.push(staging_evidence(&projection));
source_refs.push(source_evidence_ref(&projection));
}
let candidate = ExtractedCandidate {
kind: params.kind,
claim: params.claim,
why_useful: params.why_useful,
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
}
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:?}"
)));
}
};
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
ToolError::ExecutionFailed(
"StageMemoryCandidate backend did not return a staging id".to_string(),
)
})?;
self.state
.staged
.lock()
.expect("memory extract staged state poisoned")
.push(staging_id.clone());
Ok(ToolOutput {
summary: format!("Staged Memory candidate {staging_id}."),
content: Some(format!("staging_id: {staging_id}")),
})
}
}
struct FinishMemoryExtractionTool {
state: MemoryExtractState,
}
#[async_trait]
impl Tool for FinishMemoryExtractionTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: FinishMemoryExtractionParams =
serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid FinishMemoryExtraction input: {error}"))
})?;
let actual = self
.state
.staged
.lock()
.expect("memory extract staged state poisoned")
.len();
if params.staged_count != actual {
return Err(ToolError::InvalidArgument(format!(
"FinishMemoryExtraction staged_count {} does not match actual staged count {actual}",
params.staged_count
)));
}
let reason = params.no_candidates_reason.clone();
*self
.state
.finished
.lock()
.expect("memory extract finished state poisoned") = Some(params);
Ok(ToolOutput {
summary: reason
.map(|reason| {
format!("Finished extraction with {actual} staged candidate(s): {reason}")
})
.unwrap_or_else(|| {
format!("Finished extraction with {actual} staged candidate(s).")
}),
content: None,
})
}
}
fn map_memory_stage_error(error: WorkspaceMemoryBackendError) -> ToolError {
match error {
WorkspaceMemoryBackendError::Backend(message) => ToolError::InvalidArgument(message),
WorkspaceMemoryBackendError::Http { status, body }
if matches!(
status,
reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY
) =>
{
ToolError::InvalidArgument(body)
}
error => ToolError::ExecutionFailed(format!("write Memory staging failed: {error}")),
}
}
fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
match (entry.kind, entry.tool_part) {
(ReferenceKind::Tool, Some(ToolPart::Input)) => EvidenceKind::new(EvidenceKind::TOOL_CALL),
(ReferenceKind::Tool, _) => EvidenceKind::new(EvidenceKind::TOOL_RESULT),
_ => EvidenceKind::new(EvidenceKind::MESSAGE),
}
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence {
id: entry.entry_ref.to_string(),
kind: evidence_kind(entry),
entry_range: Some(entry.entry_range),
excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()),
}
}
fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()),
evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()),
..Default::default()
}
}
pub(crate) fn render_extract_input(view: &SessionCapture) -> String {
let mut output = String::from("# Session overview\n\n");
if view.overview().is_empty() {
output.push_str("No user/assistant overview entries are available.\n\n");
} else {
for item in view.overview() {
output.push_str(&format!(
"- [{} {}] {}\n {}\n intervening_entries: {}\n",
item.id,
item.kind.as_str(),
item.label,
truncate_line(&item.text, 500),
item.intervening_entries,
));
}
output.push('\n');
}
output.push_str("# Initial session entry index\n\n");
output.push_str("Use ShowOverview, SearchEntries, and ReadEntry to inspect details. Cite only SessionEntryRef values in StageMemoryCandidate.entry_refs.\n\n");
let hits = view.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: Some(50),
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
for hit in hits {
output.push_str(&format!(
"- [{} {}] {}{}\n",
hit.id,
hit.kind.as_str(),
hit.label,
hit.summary
));
}
output
}
fn truncate_line(text: &str, max_chars: usize) -> String {
let normalized = text.replace('\n', " ");
if normalized.chars().count() <= max_chars {
normalized
} else {
let mut output = normalized.chars().take(max_chars).collect::<String>();
output.push('…');
output
}
}
#[cfg(test)]
mod tests {
use llm_engine::Item;
use super::*;
fn state() -> MemoryExtractState {
MemoryExtractState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
crate::worker::marker_workspace_client(None, "test-backend"),
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
"run-1".to_string(),
)
}
#[test]
fn memory_extract_declares_only_memory_mutation_tools() {
let descriptor = MemoryExtractFeature::new(state()).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract");
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
vec!["StageMemoryCandidate", "FinishMemoryExtraction"]
);
}
#[test]
fn render_input_uses_session_entry_refs_and_new_tool_names() {
let view = SessionCapture::new(
"segment-1",
vec![
Item::user_message("preference"),
Item::tool_call("call-1", "Read", "{}"),
],
);
let input = render_extract_input(&view);
assert!(input.contains("E00000000"));
assert!(input.contains("E00000001"));
assert!(input.contains("StageMemoryCandidate.entry_refs"));
}
#[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
"invalid candidate".to_string(),
));
assert!(matches!(backend, ToolError::InvalidArgument(_)));
let http = map_memory_stage_error(WorkspaceMemoryBackendError::Http {
status: reqwest::StatusCode::UNPROCESSABLE_ENTITY,
body: "invalid candidate".to_string(),
});
assert!(matches!(http, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000009"]}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("unknown SessionEntryRef"));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,881 @@
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::Item;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::collect_state;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionCapture,
SessionEntryRef, ToolPart,
};
use crate::spawn::registry::SpawnedWorkerRegistry;
const MAX_SUBJECTS: usize = 100;
const DEFAULT_PAGE_LIMIT: usize = 20;
const MAX_PAGE_LIMIT: usize = 100;
const MAX_READ_BYTES: usize = 16 * 1024;
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation";
#[cfg(test)]
const OBSERVATION_PROMPT_SOURCE: &str =
include_str!("../../../../../resources/prompts/common/worker-observation.md");
fn observation_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(OBSERVATION_INSTRUCTION_ID),
OBSERVATION_PROMPT_REF,
"Worker session observation authority and privacy policy",
)
.expect("static worker-observation instruction declaration is valid")
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkerObservationSubjectRef {
RuntimeWorker {
runtime_id: String,
worker_id: String,
},
SubWorker {
name: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerObservationSubject {
pub subject: WorkerObservationSubjectRef,
pub display_name: String,
pub relation: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct WorkerSessionCapture {
pub segment_id: String,
pub items: Vec<Item>,
}
#[derive(Debug, thiserror::Error)]
pub enum WorkerObservationError {
#[error("worker session was not found or is not accessible")]
NotFound,
#[error("worker session observation failed: {0}")]
Unavailable(String),
}
#[async_trait]
pub trait WorkerObservationProvider: Send + Sync {
/// Returns only subjects already authorized for the current Worker.
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError>;
/// Reauthorizes and captures the latest committed session for one subject.
/// Unauthorized and missing subjects must both return `NotFound`.
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError>;
}
#[derive(Debug, Deserialize)]
struct WorkspaceWorkerObservationListResponse {
sessions: Vec<WorkerObservationSubject>,
}
#[derive(Debug, Deserialize)]
struct WorkspaceWorkerObservationCaptureResponse {
segment_id: String,
entries: Vec<serde_json::Value>,
}
pub struct WorkspaceClientWorkerObservationProvider {
client: Arc<dyn crate::worker::WorkspaceClient>,
}
impl WorkspaceClientWorkerObservationProvider {
pub fn new(client: Arc<dyn crate::worker::WorkspaceClient>) -> Self {
Self { client }
}
}
#[async_trait]
impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let response = self
.client
.execute(crate::worker::WorkspaceRequest::get(
"/worker-observation/sessions",
))
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
serde_json::from_str::<WorkspaceWorkerObservationListResponse>(&body)
.map(|response| response.sessions)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let body = serde_json::to_string(subject)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let response = self
.client
.execute(crate::worker::WorkspaceRequest::json(
crate::worker::WorkspaceRequestMethod::Post,
"/worker-observation/session",
body,
))
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let entries = response
.entries
.into_iter()
.map(|entry| {
serde_json::from_value(entry)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
})
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: response.segment_id,
items: state.history,
})
}
}
fn workspace_response_body(
response: crate::worker::WorkspaceResponse,
) -> Result<String, WorkerObservationError> {
match response.status {
200..=299 => Ok(response.body),
403 | 404 => Err(WorkerObservationError::NotFound),
status => Err(WorkerObservationError::Unavailable(format!(
"Workspace observation request failed with status {status}: {}",
response.body
))),
}
}
fn workspace_client_error(error: crate::worker::WorkspaceClientError) -> WorkerObservationError {
WorkerObservationError::Unavailable(error.to_string())
}
#[derive(Clone)]
pub struct WorkerObservationFeature {
provider: Arc<dyn WorkerObservationProvider>,
}
impl WorkerObservationFeature {
pub fn new(provider: Arc<dyn WorkerObservationProvider>) -> Self {
Self { provider }
}
}
impl FeatureModule for WorkerObservationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("worker-observation", "Worker Observation")
.with_description(
"Read-only exploration of explicitly granted active Worker sessions.",
)
.with_instruction(observation_instruction())
.with_tool(ToolDeclaration::new(
"ListWorkerSessions",
"List bounded summaries of active Worker sessions granted to this Worker.",
))
.with_tool(ToolDeclaration::new(
"ViewSessionOverview",
"Show a sparse overview of the latest committed capture for one granted Worker session.",
))
.with_tool(ToolDeclaration::new(
"SearchSessionEntries",
"Search or compactly list a bounded range in one granted Worker session.",
))
.with_tool(ToolDeclaration::new(
"ReadSessionEntry",
"Read one committed entry from one granted Worker session by SessionEntryRef.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context
.instructions()
.register(FeatureInstructionContribution::new(
observation_instruction(),
))?;
context.tools().register(ToolContribution::new(
"ListWorkerSessions",
list_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"ViewSessionOverview",
overview_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"SearchSessionEntries",
search_definition(self.provider.clone()),
))?;
context.tools().register(ToolContribution::new(
"ReadSessionEntry",
read_definition(self.provider.clone()),
))?;
Ok(())
}
}
pub struct CompositeWorkerObservationProvider {
providers: Vec<Arc<dyn WorkerObservationProvider>>,
}
impl CompositeWorkerObservationProvider {
pub fn new(providers: Vec<Arc<dyn WorkerObservationProvider>>) -> Self {
Self { providers }
}
}
#[async_trait]
impl WorkerObservationProvider for CompositeWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let mut seen = std::collections::HashSet::new();
let mut subjects = Vec::new();
let mut unavailable = None;
for provider in &self.providers {
let provider_subjects = match provider.list_worker_sessions().await {
Ok(subjects) => subjects,
Err(WorkerObservationError::NotFound) => continue,
Err(error) => {
unavailable.get_or_insert(error);
continue;
}
};
for subject in provider_subjects {
if seen.insert(subject.subject.clone()) {
subjects.push(subject);
if subjects.len() == MAX_SUBJECTS {
return Ok(subjects);
}
}
}
}
if subjects.is_empty() {
if let Some(error) = unavailable {
return Err(error);
}
}
Ok(subjects)
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
for provider in &self.providers {
match provider.capture_worker_session(subject).await {
Ok(capture) => return Ok(capture),
Err(WorkerObservationError::NotFound) => continue,
Err(error) => return Err(error),
}
}
Err(WorkerObservationError::NotFound)
}
}
pub(crate) struct SpawnedSubWorkerObservationProvider {
registry: Arc<SpawnedWorkerRegistry>,
}
impl SpawnedSubWorkerObservationProvider {
pub(crate) fn new(registry: Arc<SpawnedWorkerRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let subjects = self
.registry
.list_internal()
.into_iter()
.take(MAX_SUBJECTS)
.map(|record| WorkerObservationSubject {
subject: WorkerObservationSubjectRef::SubWorker {
name: record.worker_name.clone(),
},
display_name: record.worker_name,
relation: "subworker".to_string(),
status: format!("{:?}", record.session.status()).to_lowercase(),
})
.collect();
Ok(subjects)
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let WorkerObservationSubjectRef::SubWorker { name } = subject else {
return Err(WorkerObservationError::NotFound);
};
let record = self
.registry
.get_internal(name)
.ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries();
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"),
items: state.history,
})
}
}
fn list_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ListWorkerSessionsParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ListWorkerSessions")
.description("List active Worker sessions explicitly granted to this Worker.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ListWorkerSessionsTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn overview_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ViewSessionOverviewParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ViewSessionOverview")
.description("Show a sparse bounded index for one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ViewSessionOverviewTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn search_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(SearchSessionEntriesParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("SearchSessionEntries")
.description("Search or list a bounded range in one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SearchSessionEntriesTool {
provider: provider.clone(),
});
(meta, tool)
})
}
fn read_definition(provider: Arc<dyn WorkerObservationProvider>) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(ReadSessionEntryParams))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new("ReadSessionEntry")
.description("Read one entry by SessionEntryRef from one granted Worker session.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(ReadSessionEntryTool {
provider: provider.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListWorkerSessionsParams {
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ViewSessionOverviewParams {
subject: WorkerObservationSubjectRef,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SearchSessionEntriesParams {
subject: WorkerObservationSubjectRef,
#[serde(default)]
query: String,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
tool_part: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
through: Option<String>,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReadSessionEntryParams {
subject: WorkerObservationSubjectRef,
entry_ref: String,
#[serde(default = "default_read_mode")]
mode: String,
}
fn default_read_mode() -> String {
"compact".to_string()
}
struct ListWorkerSessionsTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ListWorkerSessionsTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ListWorkerSessionsParams = parse_input("ListWorkerSessions", input_json)?;
let limit = bounded_limit(params.limit);
let mut subjects = self
.provider
.list_worker_sessions()
.await
.map_err(tool_error)?;
subjects.truncate(limit);
let sessions = subjects
.iter()
.map(|subject| {
serde_json::json!({
"subject": bounded_subject(&subject.subject),
"display_name": truncate_text(&subject.display_name, 200),
"relation": truncate_text(&subject.relation, 64),
"status": truncate_text(&subject.status, 64),
})
})
.collect::<Vec<_>>();
json_output(
format!("Listed {} Worker session(s).", sessions.len()),
serde_json::json!({ "sessions": sessions }),
)
}
}
struct ViewSessionOverviewTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ViewSessionOverviewTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ViewSessionOverviewParams = parse_input("ViewSessionOverview", input_json)?;
let view = latest_view(&*self.provider, &params.subject).await?;
let limit = bounded_limit(params.limit);
let entries = view
.overview()
.iter()
.skip(params.offset)
.take(limit)
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
"text": entry.text,
"intervening_entries": entry.intervening_entries,
})
})
.collect::<Vec<_>>();
let has_more = params.offset.saturating_add(entries.len()) < view.overview().len();
json_output(
format!(
"Showing {} Worker session overview entrie(s).",
entries.len()
),
serde_json::json!({
"subject": params.subject,
"entries": entries,
"next_offset": has_more.then_some(params.offset + entries.len()),
}),
)
}
}
struct SearchSessionEntriesTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for SearchSessionEntriesTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SearchSessionEntriesParams = parse_input("SearchSessionEntries", input_json)?;
let view = latest_view(&*self.provider, &params.subject).await?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() {
return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(),
));
}
}
let entries = view
.search(&SearchOptions {
query: params.query,
kind: params.kind.as_deref().map(parse_kind).transpose()?,
tool_part: params
.tool_part
.as_deref()
.map(parse_tool_part)
.transpose()?,
tool_name: params.tool_name,
limit: Some(bounded_limit(params.limit)),
min_entry_index: None,
from,
through,
offset: params.offset,
})
.into_iter()
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
"tool_name": entry.tool_name,
"label": entry.label,
"text": entry.summary,
})
})
.collect::<Vec<_>>();
json_output(
format!("Found {} Worker session entrie(s).", entries.len()),
serde_json::json!({ "subject": params.subject, "entries": entries }),
)
}
}
struct ReadSessionEntryTool {
provider: Arc<dyn WorkerObservationProvider>,
}
#[async_trait]
impl Tool for ReadSessionEntryTool {
async fn execute(
&self,
input_json: &str,
_context: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadSessionEntryParams = parse_input("ReadSessionEntry", input_json)?;
let entry_ref = parse_entry_ref(&params.entry_ref)?;
let detail = match params.mode.as_str() {
"compact" => ReadDetail::Compact,
"full" => ReadDetail::Full,
other => {
return Err(ToolError::InvalidArgument(format!(
"invalid mode {other:?}; expected compact or full"
)));
}
};
let view = latest_view(&*self.provider, &params.subject).await?;
let read = view.read(
ReadSelector::Id(entry_ref.as_str()),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail,
max_items: 1,
max_bytes: MAX_READ_BYTES,
},
);
let entries = read
.entries
.into_iter()
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
"tool_name": entry.tool_name,
"label": entry.label,
"text": entry.text,
})
})
.collect::<Vec<_>>();
if entries.is_empty() {
return Err(ToolError::ExecutionFailed(
"worker session was not found or is not accessible".to_string(),
));
}
json_output(
format!("Read {} Worker session entry.", entries.len()),
serde_json::json!({
"subject": params.subject,
"entries": entries,
"truncated": read.truncated,
}),
)
}
}
async fn latest_view(
provider: &dyn WorkerObservationProvider,
subject: &WorkerObservationSubjectRef,
) -> Result<SessionCapture, ToolError> {
let capture = provider
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items))
}
fn parse_input<T: serde::de::DeserializeOwned>(
tool_name: &str,
input_json: &str,
) -> Result<T, ToolError> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool_name} input: {error}")))
}
fn parse_entry_ref(value: &str) -> Result<SessionEntryRef, ToolError> {
SessionEntryRef::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid SessionEntryRef {value:?}")))
}
fn parse_kind(value: &str) -> Result<ReferenceKind, ToolError> {
ReferenceKind::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid entry kind {value:?}")))
}
fn parse_tool_part(value: &str) -> Result<ToolPart, ToolError> {
ToolPart::parse(value)
.ok_or_else(|| ToolError::InvalidArgument(format!("invalid tool_part {value:?}")))
}
fn bounded_subject(subject: &WorkerObservationSubjectRef) -> WorkerObservationSubjectRef {
match subject {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id,
worker_id,
} => WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: truncate_text(runtime_id, 200),
worker_id: truncate_text(worker_id, 200),
},
WorkerObservationSubjectRef::SubWorker { name } => WorkerObservationSubjectRef::SubWorker {
name: truncate_text(name, 200),
},
}
}
fn truncate_text(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
let mut truncated = value.chars().take(max_chars).collect::<String>();
truncated.push('…');
truncated
}
}
fn bounded_limit(limit: Option<usize>) -> usize {
limit.unwrap_or(DEFAULT_PAGE_LIMIT).clamp(1, MAX_PAGE_LIMIT)
}
fn tool_error(error: WorkerObservationError) -> ToolError {
match error {
WorkerObservationError::NotFound => ToolError::ExecutionFailed(
"worker session was not found or is not accessible".to_string(),
),
WorkerObservationError::Unavailable(message) => ToolError::ExecutionFailed(message),
}
}
fn json_output(summary: String, value: serde_json::Value) -> Result<ToolOutput, ToolError> {
let content = serde_json::to_string_pretty(&value)
.map_err(|error| ToolError::ExecutionFailed(format!("serialize tool output: {error}")))?;
Ok(ToolOutput {
summary,
content: Some(content),
})
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use llm_engine::Role;
use crate::feature::{FeatureRegistryBuilder, HookRegistryBuilder};
use super::*;
struct FakeProvider {
captures: Mutex<Vec<Item>>,
}
fn granted_subject() -> WorkerObservationSubjectRef {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: "runtime-1".to_string(),
worker_id: "granted".to_string(),
}
}
#[async_trait]
impl WorkerObservationProvider for FakeProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
Ok(vec![WorkerObservationSubject {
subject: granted_subject(),
display_name: "Granted".to_string(),
relation: "peer".to_string(),
status: "idle".to_string(),
}])
}
async fn capture_worker_session(
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound);
}
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(),
})
}
}
fn message(_id: &str, role: Role, content: &str) -> Item {
match role {
Role::User => Item::user_message(content),
Role::Assistant => Item::assistant_message(content),
Role::System => Item::system_message(content),
}
}
#[test]
fn prompt_source_names_the_worker_observation_contract() {
for token in [
"ListWorkerSessions",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SessionEntryRef",
] {
assert!(OBSERVATION_PROMPT_SOURCE.contains(token), "missing {token}");
}
}
#[test]
fn worker_observation_installs_without_session_explore_or_memory_extract() {
let provider = Arc::new(FakeProvider {
captures: Mutex::new(Vec::new()),
});
let mut pending_tools = Vec::new();
let mut hook_builder = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(WorkerObservationFeature::new(provider))
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.reports[0].installed);
assert_eq!(
report.installed_tool_names(),
[
"ListWorkerSessions",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
]
);
}
#[tokio::test]
async fn provider_grants_hide_unauthorized_subjects_and_latest_capture_preserves_refs() {
let provider = Arc::new(FakeProvider {
captures: Mutex::new(vec![message("u1", Role::User, "first")]),
});
let list = list_definition(provider.clone())().1;
let listed = list
.execute("{}", llm_engine::tool::ToolExecutionContext::direct())
.await
.unwrap();
assert!(listed.content.unwrap().contains("granted"));
let read = read_definition(provider.clone())().1;
let hidden = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{hidden:?}").contains("not found or is not accessible"));
provider
.captures
.lock()
.unwrap()
.push(message("a1", Role::Assistant, "second"));
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(output.content.unwrap().contains("first"));
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
assert!(output.content.unwrap().contains("second"));
}
}
+2 -2
View File
@@ -58,8 +58,8 @@ pub fn fire_and_forget(socket: Option<PathBuf>, event: WorkerEvent) {
/// Only events classified by `WorkerEvent::should_notify_agent` are injected /// Only events classified by `WorkerEvent::should_notify_agent` are injected
/// into the parent's LLM context as system messages; control-plane-only events /// into the parent's LLM context as system messages; control-plane-only events
/// keep this renderer for diagnostics/tests. Agent-visible summaries are kept /// keep this renderer for diagnostics/tests. Agent-visible summaries are kept
/// deliberately short — the LLM can always call `SubWorkerReadOutput` to fetch more /// deliberately short — the LLM can use worker-observation tools to inspect the committed
/// detail if the event summary is not enough. /// session when the event summary is not enough.
pub fn render_event(event: &WorkerEvent) -> String { pub fn render_event(event: &WorkerEvent) -> String {
match event { match event {
WorkerEvent::TurnEnded { worker_name } => { WorkerEvent::TurnEnded { worker_name } => {
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod model_client;
pub mod prompt; pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod segment_log_sink; pub mod segment_log_sink;
mod session_reference; mod session_capture;
pub mod shared_state; pub mod shared_state;
mod shutdown_after_idle; mod shutdown_after_idle;
pub mod skill; pub mod skill;
-3
View File
@@ -208,7 +208,6 @@ struct ToolCapabilities {
memory_update_document: bool, memory_update_document: bool,
sub_worker_spawn: bool, sub_worker_spawn: bool,
sub_worker_send: bool, sub_worker_send: bool,
sub_worker_read_output: bool,
sub_worker_stop: bool, sub_worker_stop: bool,
sub_worker_list: bool, sub_worker_list: bool,
sub_worker_restore: bool, sub_worker_restore: bool,
@@ -224,7 +223,6 @@ impl ToolCapabilities {
"MemoryUpdateDocument" => capabilities.memory_update_document = true, "MemoryUpdateDocument" => capabilities.memory_update_document = true,
"SubWorkerSpawn" => capabilities.sub_worker_spawn = true, "SubWorkerSpawn" => capabilities.sub_worker_spawn = true,
"SubWorkerSend" => capabilities.sub_worker_send = true, "SubWorkerSend" => capabilities.sub_worker_send = true,
"SubWorkerReadOutput" => capabilities.sub_worker_read_output = true,
"SubWorkerStop" => capabilities.sub_worker_stop = true, "SubWorkerStop" => capabilities.sub_worker_stop = true,
"SubWorkerList" => capabilities.sub_worker_list = true, "SubWorkerList" => capabilities.sub_worker_list = true,
_ => {} _ => {}
@@ -248,7 +246,6 @@ impl ToolCapabilities {
fn sub_worker_management(self) -> bool { fn sub_worker_management(self) -> bool {
self.sub_worker_spawn self.sub_worker_spawn
|| self.sub_worker_send || self.sub_worker_send
|| self.sub_worker_read_output
|| self.sub_worker_stop || self.sub_worker_stop
|| self.sub_worker_list || self.sub_worker_list
|| self.sub_worker_restore || self.sub_worker_restore
@@ -1,26 +1,55 @@
//! Immutable reference view over a session history slice. //! Workspace- and Memory-independent exploration of an immutable ordered session capture.
//! //!
//! This module is shared substrate for internal workers that need to inspect a //! Hosts construct a capture from committed session items. The capture excludes reasoning,
//! bounded, host-created view of session history without reading the live //! assigns append-stable `SessionEntryRef` values, and provides sparse overview, bounded
//! foreground Worker state directly. //! range/search, read, and generic evidence projections without granting mutation authority.
use std::sync::Arc; use std::sync::Arc;
use llm_engine::{Item, Role}; use llm_engine::{Item, Role};
use memory::extract::StagingEvidence; use serde::{Deserialize, Serialize};
use memory::schema::{EvidenceKind, SourceEvidenceRef};
const DEFAULT_SEARCH_LIMIT: usize = 20; const DEFAULT_SEARCH_LIMIT: usize = 20;
const MAX_SEARCH_LIMIT: usize = 50; const MAX_SEARCH_LIMIT: usize = 50;
const DEFAULT_READ_MAX_ITEMS: usize = 40; const DEFAULT_READ_MAX_ITEMS: usize = 40;
const MAX_READ_MAX_ITEMS: usize = 80; const MAX_READ_MAX_ITEMS: usize = 80;
const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024; const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024;
const OVERVIEW_ANCHOR_STRIDE: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self {
Self(format!("E{source_index:08}"))
}
pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string());
reference.source_index()?;
Some(reference)
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn source_index(&self) -> Option<u64> {
self.0.strip_prefix('E')?.parse().ok()
}
}
impl std::fmt::Display for SessionEntryRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReferenceKind { pub(crate) enum ReferenceKind {
User, User,
Assistant, Assistant,
System,
Tool, Tool,
} }
@@ -29,7 +58,6 @@ impl ReferenceKind {
match self { match self {
Self::User => "user", Self::User => "user",
Self::Assistant => "assistant", Self::Assistant => "assistant",
Self::System => "system",
Self::Tool => "tool", Self::Tool => "tool",
} }
} }
@@ -38,15 +66,10 @@ impl ReferenceKind {
match value { match value {
"user" => Some(Self::User), "user" => Some(Self::User),
"assistant" | "agent" => Some(Self::Assistant), "assistant" | "agent" => Some(Self::Assistant),
"system" => Some(Self::System),
"tool" => Some(Self::Tool), "tool" => Some(Self::Tool),
_ => None, _ => None,
} }
} }
fn evidence_kind(self) -> EvidenceKind {
EvidenceKind::new(EvidenceKind::MESSAGE)
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -73,16 +96,17 @@ impl ToolPart {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct OverviewItem { pub(crate) struct OverviewItem {
pub id: String, pub id: SessionEntryRef,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub label: String, pub label: String,
pub text: String, pub text: String,
pub intervening_entries: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry { pub(crate) struct ReferenceEntry {
pub id: String, pub id: SessionEntryRef,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
@@ -92,20 +116,6 @@ pub(crate) struct ReferenceEntry {
search_text: String, search_text: String,
} }
impl ReferenceEntry {
fn evidence_kind(&self) -> EvidenceKind {
match (self.kind, self.tool_part) {
(ReferenceKind::Tool, Some(ToolPart::Input)) => {
EvidenceKind::new(EvidenceKind::TOOL_CALL)
}
(ReferenceKind::Tool, Some(ToolPart::Output | ToolPart::Both) | None) => {
EvidenceKind::new(EvidenceKind::TOOL_RESULT)
}
_ => self.kind.evidence_kind(),
}
}
}
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub(crate) struct SearchOptions { pub(crate) struct SearchOptions {
pub query: String, pub query: String,
@@ -114,11 +124,14 @@ pub(crate) struct SearchOptions {
pub tool_name: Option<String>, pub tool_name: Option<String>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub min_entry_index: Option<u64>, pub min_entry_index: Option<u64>,
pub from: Option<SessionEntryRef>,
pub through: Option<SessionEntryRef>,
pub offset: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SearchHit { pub(crate) struct SearchHit {
pub id: String, pub id: SessionEntryRef,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -162,7 +175,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReadEntry { pub(crate) struct ReadEntry {
pub id: String, pub id: SessionEntryRef,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -178,14 +191,26 @@ pub(crate) struct ReadResult {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SessionReferenceView { pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub label: String,
pub summary: String,
pub excerpt: String,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionCapture {
segment_id: String, segment_id: String,
items: Arc<Vec<Item>>, items: Arc<Vec<Item>>,
overview: Vec<OverviewItem>, overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>, index: Vec<ReferenceEntry>,
} }
impl SessionReferenceView { impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self { pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let segment_id = segment_id.into(); let segment_id = segment_id.into();
let items = Arc::new(items); let items = Arc::new(items);
@@ -199,7 +224,7 @@ impl SessionReferenceView {
let kind = match role { let kind = match role {
Role::User => ReferenceKind::User, Role::User => ReferenceKind::User,
Role::Assistant => ReferenceKind::Assistant, Role::Assistant => ReferenceKind::Assistant,
Role::System => ReferenceKind::System, Role::System => continue,
}; };
let text = content let text = content
.iter() .iter()
@@ -208,7 +233,7 @@ impl SessionReferenceView {
.join(""); .join("");
let label = format!("{} message", kind.as_str()); let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240); let summary = truncate_chars(&text, 240);
let id = format!("M{idx:04}"); let id = SessionEntryRef::new(idx);
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: id.clone(), id: id.clone(),
entry_range, entry_range,
@@ -221,11 +246,12 @@ impl SessionReferenceView {
}); });
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem { overview.push(OverviewItem {
id: format!("O{:04}", overview.len()), id: id.clone(),
entry_range, entry_range,
kind, kind,
label, label,
text, text,
intervening_entries: 0,
}); });
} }
} }
@@ -234,7 +260,7 @@ impl SessionReferenceView {
} => { } => {
let text = format!("{name}\n{arguments}"); let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: format!("T{idx:04}i"), id: SessionEntryRef::new(idx),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input), tool_part: Some(ToolPart::Input),
@@ -249,7 +275,7 @@ impl SessionReferenceView {
} => { } => {
let text = format!("{summary}\n{}", content.as_deref().unwrap_or_default()); let text = format!("{summary}\n{}", content.as_deref().unwrap_or_default());
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: format!("T{idx:04}o"), id: SessionEntryRef::new(idx),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output), tool_part: Some(ToolPart::Output),
@@ -263,6 +289,30 @@ impl SessionReferenceView {
} }
} }
if overview.len() > 2 {
let last = overview.len() - 1;
overview = overview
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
(index == 0 || index == last || index % OVERVIEW_ANCHOR_STRIDE == 0)
.then_some(entry)
})
.collect();
}
for overview_index in 0..overview.len().saturating_sub(1) {
let current_entry = overview[overview_index].entry_range[0];
let next_entry = overview[overview_index + 1].entry_range[0];
overview[overview_index].intervening_entries = index
.iter()
.filter(|entry| {
let entry_index = entry.entry_range[0];
entry_index > current_entry && entry_index < next_entry
})
.count();
}
Self { Self {
segment_id, segment_id,
items, items,
@@ -282,11 +332,21 @@ impl SessionReferenceView {
.unwrap_or(DEFAULT_SEARCH_LIMIT) .unwrap_or(DEFAULT_SEARCH_LIMIT)
.clamp(1, MAX_SEARCH_LIMIT); .clamp(1, MAX_SEARCH_LIMIT);
let tool_name = options.tool_name.as_deref(); let tool_name = options.tool_name.as_deref();
let min_entry_index = options.min_entry_index.unwrap_or(0); let min_entry_index = options
.from
.as_ref()
.and_then(SessionEntryRef::source_index)
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(SessionEntryRef::source_index)
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new(); let mut hits = Vec::new();
for entry in &self.index { for entry in &self.index {
if entry.entry_range[0] < min_entry_index { if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
continue; continue;
} }
if let Some(kind) = options.kind { if let Some(kind) = options.kind {
@@ -313,6 +373,10 @@ impl SessionReferenceView {
if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) { if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) {
continue; continue;
} }
if skipped < options.offset {
skipped += 1;
continue;
}
hits.push(SearchHit { hits.push(SearchHit {
id: entry.id.clone(), id: entry.id.clone(),
kind: entry.kind, kind: entry.kind,
@@ -338,7 +402,11 @@ impl SessionReferenceView {
let mut truncated = false; let mut truncated = false;
let selected: Vec<&ReferenceEntry> = match selector { let selected: Vec<&ReferenceEntry> = match selector {
ReadSelector::Id(id) => self.index.iter().filter(|entry| entry.id == id).collect(), ReadSelector::Id(id) => self
.index
.iter()
.filter(|entry| entry.id.as_str() == id)
.collect(),
ReadSelector::EntryRange([start, end]) => self ReadSelector::EntryRange([start, end]) => self
.index .index
.iter() .iter()
@@ -384,42 +452,32 @@ impl SessionReferenceView {
ReadResult { entries, truncated } ReadResult { entries, truncated }
} }
pub(crate) fn source_ref_for(&self, id: &str) -> Option<SourceEvidenceRef> { pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id == id)?; let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
Some(SourceEvidenceRef { let excerpt = self
segment_id: Some(self.segment_id.clone()), .read(
entry_range: Some(entry.entry_range), ReadSelector::Id(id),
evidence_id: Some(entry.id.clone()), ReadOptions {
evidence_kind: Some(entry.evidence_kind()), include_tools: true,
label: Some(entry.label.clone()), tool_part: ToolPart::Both,
summary: Some(entry.summary.clone()), detail: ReadDetail::Compact,
..Default::default() max_items: 1,
}) max_bytes: 2 * 1024,
} },
)
pub(crate) fn staging_evidence_for(&self, id: &str) -> Option<StagingEvidence> {
let entry = self.index.iter().find(|entry| entry.id == id)?;
let read = self.read(
ReadSelector::Id(id),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: 1,
max_bytes: 2 * 1024,
},
);
let excerpt = read
.entries .entries
.first() .first()
.map(|entry| entry.text.clone()) .map(|entry| entry.text.clone())
.unwrap_or_else(|| entry.summary.clone()); .unwrap_or_else(|| entry.summary.clone());
Some(StagingEvidence { Some(SessionEntryEvidence {
id: entry.id.clone(), segment_id: self.segment_id.clone(),
kind: entry.evidence_kind(), entry_ref: entry.id.clone(),
entry_range: Some(entry.entry_range), entry_range: entry.entry_range,
excerpt: Some(excerpt), kind: entry.kind,
summary: Some(entry.summary.clone()), tool_part: entry.tool_part,
label: entry.label.clone(),
summary: entry.summary.clone(),
excerpt,
}) })
} }
} }
@@ -486,7 +544,7 @@ mod tests {
#[test] #[test]
fn overview_contains_user_and_assistant_only() { fn overview_contains_user_and_assistant_only() {
let view = SessionReferenceView::new( let view = SessionCapture::new(
"segment-1", "segment-1",
vec![ vec![
Item::system_message("sys"), Item::system_message("sys"),
@@ -506,7 +564,7 @@ mod tests {
#[test] #[test]
fn search_filters_tool_input_and_output() { fn search_filters_tool_input_and_output() {
let view = SessionReferenceView::new( let view = SessionCapture::new(
"segment-1", "segment-1",
vec![ vec![
Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"), Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"),
@@ -521,6 +579,9 @@ mod tests {
tool_name: Some("Read".into()), tool_name: Some("Read".into()),
limit: None, limit: None,
min_entry_index: None, min_entry_index: None,
from: None,
through: None,
offset: 0,
}); });
assert_eq!(input_hits.len(), 1); assert_eq!(input_hits.len(), 1);
assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input)); assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input));
@@ -532,6 +593,9 @@ mod tests {
tool_name: None, tool_name: None,
limit: None, limit: None,
min_entry_index: None, min_entry_index: None,
from: None,
through: None,
offset: 0,
}); });
assert_eq!(output_hits.len(), 1); assert_eq!(output_hits.len(), 1);
assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output)); assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output));
@@ -539,7 +603,7 @@ mod tests {
#[test] #[test]
fn read_by_entry_range_is_bounded_and_can_skip_tools() { fn read_by_entry_range_is_bounded_and_can_skip_tools() {
let view = SessionReferenceView::new( let view = SessionCapture::new(
"segment-1", "segment-1",
vec![ vec![
Item::user_message("one"), Item::user_message("one"),
@@ -570,11 +634,117 @@ mod tests {
} }
#[test] #[test]
fn source_ref_uses_entry_range_and_evidence_id() { fn system_prompt_and_reasoning_are_excluded_from_every_projection() {
let view = SessionReferenceView::new("segment-1", vec![Item::user_message("hello")]); let view = SessionCapture::new(
let source = view.source_ref_for("M0000").unwrap(); "segment-1",
assert_eq!(source.segment_id.as_deref(), Some("segment-1")); vec![
assert_eq!(source.entry_range, Some([0, 0])); Item::system_message("raw secret system prompt"),
assert_eq!(source.evidence_id.as_deref(), Some("M0000")); Item::reasoning("private chain of thought"),
Item::user_message("visible user entry"),
],
);
let hits = view.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
});
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id.as_str(), "E00000002");
assert!(!hits[0].summary.contains("secret"));
assert!(!hits[0].summary.contains("chain of thought"));
assert!(
view.read(ReadSelector::Id("E00000000"), ReadOptions::default())
.entries
.is_empty()
);
assert!(view.evidence_for("E00000000").is_none());
assert_eq!(view.overview().len(), 1);
assert_eq!(view.overview()[0].id.as_str(), "E00000002");
}
#[test]
fn overview_is_sparse_and_reports_intervening_non_reasoning_entries() {
let items = (0..20)
.map(|index| Item::user_message(format!("message-{index}")))
.collect::<Vec<_>>();
let view = SessionCapture::new("segment-1", items);
let refs = view
.overview()
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
refs,
vec!["E00000000", "E00000008", "E00000016", "E00000019"]
);
assert_eq!(view.overview()[0].intervening_entries, 7);
assert_eq!(view.overview()[1].intervening_entries, 7);
assert_eq!(view.overview()[2].intervening_entries, 2);
}
#[test]
fn append_preserves_existing_session_entry_refs() {
let first = SessionCapture::new(
"segment-1",
vec![
Item::user_message("first"),
Item::assistant_message("second"),
],
);
let appended = SessionCapture::new(
"segment-1",
vec![
Item::user_message("first"),
Item::assistant_message("second"),
Item::user_message("third"),
],
);
let first_refs = first
.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
})
.into_iter()
.map(|entry| entry.id)
.collect::<Vec<_>>();
let appended_refs = appended
.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: None,
min_entry_index: None,
from: None,
through: None,
offset: 0,
})
.into_iter()
.take(2)
.map(|entry| entry.id)
.collect::<Vec<_>>();
assert_eq!(first_refs, appended_refs);
}
#[test]
fn evidence_projection_uses_entry_range_and_session_entry_ref() {
let view = SessionCapture::new("segment-1", vec![Item::user_message("hello")]);
let source = view.evidence_for("E00000000").unwrap();
assert_eq!(source.segment_id, "segment-1");
assert_eq!(source.entry_range, [0, 0]);
assert_eq!(source.entry_ref.as_str(), "E00000000");
} }
} }
+2 -115
View File
@@ -1,6 +1,6 @@
//! Parent-facing tools for in-process Internal SubWorker sessions. //! Parent-facing tools for in-process Internal SubWorker sessions.
//! //!
//! All five tools share the same parent-owned `SpawnedWorkerRegistry` of typed session handles. //! All four tools share the same parent-owned `SpawnedWorkerRegistry` of typed session handles.
//! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on //! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on
//! its direct Internal children. The socket helper at the bottom remains solely for the legacy //! its direct Internal children. The socket helper at the bottom remains solely for the legacy
//! top-level Worker callback protocol and is not part of SubWorker communication. //! top-level Worker callback protocol and is not part of SubWorker communication.
@@ -10,12 +10,10 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use llm_engine::llm_client::types::{ContentPart, Item, Role};
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method}; use protocol::{Event, Method};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::LogEntry;
use tokio::net::UnixStream; use tokio::net::UnixStream;
use crate::spawn::registry::SpawnedWorkerRegistry; use crate::spawn::registry::SpawnedWorkerRegistry;
@@ -96,7 +94,7 @@ pub fn sub_worker_list_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinit
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned SubWorker. The SubWorker \ const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned SubWorker. The SubWorker \
processes it as a user turn. Fails if the SubWorker is already executing a \ processes it as a user turn. Fails if the SubWorker is already executing a \
turn retry after it finishes. Does not wait for the turn to complete; \ turn retry after it finishes. Does not wait for the turn to complete; \
use `SubWorkerReadOutput` to fetch results afterwards."; use worker-observation tools to inspect its committed session.";
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SubWorkerSendInput { struct SubWorkerSendInput {
@@ -146,76 +144,6 @@ pub fn sub_worker_send_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinit
}) })
} }
// ---------------------------------------------------------------------------
// SubWorkerReadOutput
// ---------------------------------------------------------------------------
const READ_POD_OUTPUT_DESCRIPTION: &str = "Fetch new assistant text from a SubWorker since the last read. \
Uses an internal cursor per-SubWorker so consecutive calls return only \
newly-produced output. Returns the SubWorker's current status and the new \
text, or reports `stopped` if the SubWorker can no longer be reached.";
struct SubWorkerReadOutputTool {
registry: Arc<SpawnedWorkerRegistry>,
}
#[async_trait]
impl Tool for SubWorkerReadOutputTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: NameInput = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid SubWorkerReadOutput input: {e}"))
})?;
if let Some(record) = self.registry.get_internal(&input.name) {
let entries = record.session.entries();
let cursor = self.registry.cursor(&input.name).await;
let new_entries = if cursor >= entries.len() {
&[] as &[LogEntry]
} else {
&entries[cursor..]
};
let values = new_entries
.iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect::<Vec<_>>();
let new_text = extract_assistant_text(&values);
self.registry.set_cursor(&input.name, entries.len()).await;
let status = format!("{:?}", record.session.status()).to_lowercase();
let summary = if new_text.is_empty() {
format!("worker `{}` {status}; no new assistant text", input.name)
} else {
format!(
"worker `{}` {status}: {} new line(s) of assistant text",
input.name,
new_text.lines().count()
)
};
return Ok(ToolOutput {
summary,
content: (!new_text.is_empty()).then_some(new_text),
});
}
Err(unknown_worker_err(&input.name))
}
}
pub fn sub_worker_read_output_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(NameInput);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("SubWorkerReadOutput")
.description(READ_POD_OUTPUT_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SubWorkerReadOutputTool {
registry: registry.clone(),
});
(meta, tool)
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SubWorkerStop // SubWorkerStop
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -323,47 +251,6 @@ where
} }
} }
fn extract_assistant_text(entries: &[serde_json::Value]) -> String {
let mut out = String::new();
for value in entries {
// The wire payload is the JSON form of `session_store::LogEntry`.
// Walk current singular assistant items and the seeded history in
// post-compaction `SegmentStart` entries.
let Ok(entry) = serde_json::from_value::<LogEntry>(value.clone()) else {
continue;
};
match entry {
LogEntry::SegmentStart { history, .. } => {
for logged in history {
push_assistant_text(&mut out, logged);
}
}
LogEntry::AssistantItem { item, .. } => push_assistant_text(&mut out, item),
_ => continue,
}
}
out
}
fn push_assistant_text(out: &mut String, logged: session_store::LoggedItem) {
let item: Item = logged.into();
if let Item::Message {
role: Role::Assistant,
content,
..
} = item
{
for part in content {
if let ContentPart::Text { text } = part {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&text);
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+5 -24
View File
@@ -1,15 +1,13 @@
//! Parent-owned registry of direct Internal SubWorker sessions. //! Parent-owned registry of direct Internal SubWorker sessions.
//! //!
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/ReadOutput/Stop use //! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
//! the same in-memory authority. Internal children are not persisted, restored, discovered as //! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process //! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
//! child records only to reclaim their delegated scope and clear obsolete metadata. //! child records only to reclaim their delegated scope and clear obsolete metadata.
//! //! Parent registry drop closes all session handles and synchronously returns delegated Write deny
//! `SubWorkerReadOutput` owns a per-child, process-lifetime history cursor so consecutive reads //! rules to the parent scope.
//! yield only new assistant text. Parent registry drop closes all session handles and synchronously
//! returns delegated Write deny rules to the parent scope.
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use std::io; use std::io;
use std::sync::{ use std::sync::{
Arc, Arc,
@@ -20,7 +18,6 @@ use manifest::{Permission, ScopeRule, SharedScope};
use session_store::{ use session_store::{
WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
}; };
use tokio::sync::Mutex;
use tracing::warn; use tracing::warn;
use crate::internal_worker::InternalWorkerSessionHandle; use crate::internal_worker::InternalWorkerSessionHandle;
@@ -95,7 +92,6 @@ impl Drop for InternalSpawnReservation {
pub struct SpawnedWorkerRegistry { pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>, internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
internal_names: std::sync::Mutex<HashSet<String>>, internal_names: std::sync::Mutex<HashSet<String>>,
cursors: Mutex<HashMap<String, usize>>,
parent_scope: Option<SharedScope>, parent_scope: Option<SharedScope>,
} }
@@ -111,7 +107,6 @@ impl SpawnedWorkerRegistry {
Arc::new(Self { Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
cursors: Mutex::new(HashMap::new()),
parent_scope: None, parent_scope: None,
}) })
} }
@@ -120,7 +115,6 @@ impl SpawnedWorkerRegistry {
Arc::new(Self { Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
cursors: Mutex::new(HashMap::new()),
parent_scope: Some(parent_scope), parent_scope: Some(parent_scope),
}) })
} }
@@ -198,7 +192,6 @@ impl SpawnedWorkerRegistry {
registry: Arc::new(Self { registry: Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()), internal_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()), internal_names: std::sync::Mutex::new(HashSet::new()),
cursors: Mutex::new(HashMap::new()),
parent_scope, parent_scope,
}), }),
reclaimed_unreachable: !persisted_children.is_empty(), reclaimed_unreachable: !persisted_children.is_empty(),
@@ -292,20 +285,8 @@ impl SpawnedWorkerRegistry {
} }
removed removed
}; };
self.cursors.lock().await.remove(worker_name);
Ok(removed) Ok(removed)
} }
pub async fn cursor(&self, worker_name: &str) -> usize {
*self.cursors.lock().await.get(worker_name).unwrap_or(&0)
}
pub async fn set_cursor(&self, worker_name: &str, value: usize) {
self.cursors
.lock()
.await
.insert(worker_name.to_owned(), value);
}
} }
impl Drop for SpawnedWorkerRegistry { impl Drop for SpawnedWorkerRegistry {
+28 -18
View File
@@ -409,7 +409,7 @@ impl Tool for SubWorkerSpawnTool {
} }
} }
parent_notifies.push_notify( parent_notifies.push_notify(
format!("SubWorker `{child_name}` turn ended with status {status:?}. Read its output before making completion decisions."), format!("SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions."),
true, true,
); );
})), })),
@@ -850,7 +850,9 @@ mod tests {
use async_trait::async_trait; use async_trait::async_trait;
use futures::Stream; use futures::Stream;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::types::ContentPart;
use llm_engine::llm_client::{ClientError, LlmClient, Request}; use llm_engine::llm_client::{ClientError, LlmClient, Request};
use llm_engine::{Item, Role};
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest}; use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
use tempfile::TempDir; use tempfile::TempDir;
@@ -1028,22 +1030,23 @@ extract_threshold = 4000
.contains("reviewer-child") .contains("reviewer-child")
); );
let read = (crate::spawn::comm_tools::sub_worker_read_output_tool(registry.clone()))().1; let observation =
let first_output = read crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
.execute(r#"{"name":"reviewer-child"}"#, context.clone()) registry.clone(),
.await );
.unwrap(); let observed_child =
assert!( crate::feature::builtin::worker_observation::WorkerObservationSubjectRef::SubWorker {
first_output name: "reviewer-child".to_string(),
.content };
.unwrap_or_default() let first_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
.contains("reviewed") &observation,
); &observed_child,
let second_output = read )
.execute(r#"{"name":"reviewer-child"}"#, context.clone()) .await
.await .unwrap();
.unwrap(); assert!(first_capture.items.iter().any(|item| {
assert!(second_output.content.is_none()); matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
}));
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1; let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
send.execute( send.execute(
@@ -1057,6 +1060,13 @@ extract_threshold = 4000
crate::internal_worker::InternalWorkerSessionStatus::Idle crate::internal_worker::InternalWorkerSessionStatus::Idle
); );
assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(calls.load(Ordering::SeqCst), 2);
let latest_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
&observation,
&observed_child,
)
.await
.unwrap();
assert!(latest_capture.items.len() > first_capture.items.len());
fail_requests.store(true, Ordering::SeqCst); fail_requests.store(true, Ordering::SeqCst);
send.execute( send.execute(
@@ -1094,9 +1104,9 @@ extract_threshold = 4000
.unwrap(); .unwrap();
assert!(!spawner_scope.snapshot().is_writable(&workspace_root)); assert!(!spawner_scope.snapshot().is_writable(&workspace_root));
drop(list); drop(list);
drop(read);
drop(send); drop(send);
drop(stop); drop(stop);
drop(observation);
drop(tool); drop(tool);
drop(registry); drop(registry);
assert!(spawner_scope.snapshot().is_writable(&workspace_root)); assert!(spawner_scope.snapshot().is_writable(&workspace_root));
+42 -12
View File
@@ -32,7 +32,8 @@ use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker; use crate::compact::usage_tracker::UsageTracker;
use crate::feature::builtin::memory::WorkspaceMemoryBackendError; use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
use crate::feature::builtin::{ use crate::feature::builtin::{
SessionExploreFeature, SessionExploreState, TaskFeature, render_extract_input, MemoryExtractFeature, MemoryExtractState, SessionExploreFeature, SessionExploreState,
TaskFeature, WorkerObservationProvider, render_extract_input,
}; };
use crate::feature::{ use crate::feature::{
FeatureInstructionDeclaration, FeatureInstructionId, FeatureRegistryBuilder, FeatureInstructionDeclaration, FeatureInstructionId, FeatureRegistryBuilder,
@@ -686,6 +687,9 @@ pub struct Worker<C: LlmClient, St: Store> {
/// the narrow snapshot/restore surface Worker needs for compaction and rewind. /// the narrow snapshot/restore surface Worker needs for compaction and rewind.
/// Store/reminder ownership stays inside the Task feature module. /// Store/reminder ownership stays inside the Task feature module.
task_feature: TaskFeature, task_feature: TaskFeature,
/// Host-owned projection of Worker sessions explicitly granted to this Worker.
/// The provider reauthorizes every capture and never derives authority from model input.
worker_observation_provider: Option<Arc<dyn WorkerObservationProvider>>,
/// Parsed system-prompt template awaiting first-turn materialisation. /// Parsed system-prompt template awaiting first-turn materialisation.
/// `Some` until `ensure_system_prompt_materialized` renders it once, /// `Some` until `ensure_system_prompt_materialized` renders it once,
/// then `None` forever — including after compaction. /// then `None` forever — including after compaction.
@@ -839,6 +843,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
usage_history: self.usage_history.clone(), usage_history: self.usage_history.clone(),
tracker: None, tracker: None,
task_feature: self.task_feature.clone(), task_feature: self.task_feature.clone(),
worker_observation_provider: None,
system_prompt_template: None, system_prompt_template: None,
feature_instructions: self.feature_instructions.clone(), feature_instructions: self.feature_instructions.clone(),
alerter: self.alerter.clone(), alerter: self.alerter.clone(),
@@ -1036,6 +1041,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())), usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None, tracker: None,
task_feature: TaskFeature::new(), task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: None, system_prompt_template: None,
feature_instructions: Vec::new(), feature_instructions: Vec::new(),
alerter: None, alerter: None,
@@ -1170,6 +1176,19 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.workspace_context.client_handle() self.workspace_context.client_handle()
} }
/// Bind the host-owned Worker-session observation projection. The provider
/// is responsible for workspace authorization and per-capture revalidation.
pub fn bind_worker_observation_provider(
&mut self,
provider: Option<Arc<dyn WorkerObservationProvider>>,
) {
self.worker_observation_provider = provider;
}
pub(crate) fn worker_observation_provider(&self) -> Option<Arc<dyn WorkerObservationProvider>> {
self.worker_observation_provider.clone()
}
async fn resident_summary_from_workspace_authority( async fn resident_summary_from_workspace_authority(
&self, &self,
) -> Result<Option<String>, WorkerError> { ) -> Result<Option<String>, WorkerError> {
@@ -3445,15 +3464,21 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
segment_id: source_segment_id.to_string(), segment_id: source_segment_id.to_string(),
range: [start_entry as u64, end_entry as u64], range: [start_entry as u64, end_entry as u64],
}; };
let session_view = crate::session_reference::SessionReferenceView::new( let session_view = crate::session_capture::SessionCapture::new(
source_segment_id.to_string(), source_segment_id.to_string(),
items_to_extract, items_to_extract,
); );
let session_explore_state = let session_explore_state = SessionExploreState::new(session_view.clone());
SessionExploreState::new(session_view, self.workspace_client_handle(), source); let memory_extract_state = MemoryExtractState::new(
session_view,
self.workspace_client_handle(),
source,
audit.run_id.to_string(),
);
let input_text = render_extract_input(session_explore_state.view()); let input_text = render_extract_input(session_explore_state.view());
let features = FeatureRegistryBuilder::new() let features = FeatureRegistryBuilder::new()
.with_module(SessionExploreFeature::new(session_explore_state.clone())); .with_module(SessionExploreFeature::new(session_explore_state.clone()))
.with_module(MemoryExtractFeature::new(memory_extract_state.clone()));
let mut internal_manifest = self.manifest.clone(); let mut internal_manifest = self.manifest.clone();
internal_manifest.model = model.clone(); internal_manifest.model = model.clone();
let internal_spec = InternalWorkerSpec { let internal_spec = InternalWorkerSpec {
@@ -3469,10 +3494,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
max_turns: extract_worker_max_turns, max_turns: extract_worker_max_turns,
features, features,
required_tools: &[ required_tools: &[
"search_evidence", "ShowOverview",
"read_evidence", "SearchEntries",
"stage_candidate", "ReadEntry",
"finish_extraction", "StageMemoryCandidate",
"FinishMemoryExtraction",
], ],
authority: InternalWorkerAuthority { authority: InternalWorkerAuthority {
workspace: self.workspace_context.clone(), workspace: self.workspace_context.clone(),
@@ -3533,11 +3559,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
} }
}; };
let staging_results = session_explore_state.staged(); let staging_results = memory_extract_state.staged();
if !session_explore_state.is_finished() { if !memory_extract_state.is_finished() {
tracing::warn!( tracing::warn!(
staged_count = staging_results.len(), staged_count = staging_results.len(),
"extract worker did not call finish_extraction; advancing pointer with staged output" "extract worker did not call FinishMemoryExtraction; advancing pointer with staged output"
); );
} }
let staging_id = staging_results.first().cloned().unwrap_or_default(); let staging_id = staging_results.first().cloned().unwrap_or_default();
@@ -3925,6 +3951,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
task_feature: TaskFeature::new(), task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template, system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions, feature_instructions: common.feature_instructions,
alerter: None, alerter: None,
@@ -3999,6 +4026,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
task_feature: TaskFeature::new(), task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template, system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions, feature_instructions: common.feature_instructions,
alerter: None, alerter: None,
@@ -4107,6 +4135,7 @@ where
usage_history: Arc::new(Mutex::new(Vec::new())), usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None, tracker: None,
task_feature: TaskFeature::new(), task_feature: TaskFeature::new(),
worker_observation_provider: None,
system_prompt_template: common.system_prompt_template, system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions, feature_instructions: common.feature_instructions,
alerter: None, alerter: None,
@@ -4399,6 +4428,7 @@ where
usage_history: Arc::new(Mutex::new(state.usage_history)), usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None, tracker: None,
task_feature, task_feature,
worker_observation_provider: None,
// Restore replays the saved system_prompt verbatim — no // Restore replays the saved system_prompt verbatim — no
// template re-render on resume. // template re-render on resume.
system_prompt_template: None, system_prompt_template: None,
+7 -7
View File
@@ -539,14 +539,14 @@ target = "./"
permission = "write" permission = "write"
"#; "#;
fn finish_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> { fn finish_memory_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
let input = serde_json::json!({ let input = serde_json::json!({
"staged_count": 0, "staged_count": 0,
"no_candidates_reason": "test run has no durable candidates" "no_candidates_reason": "test run has no durable candidates"
}) })
.to_string(); .to_string();
vec![ vec![
LlmEvent::tool_use_start(0, call_id, "finish_extraction"), LlmEvent::tool_use_start(0, call_id, "FinishMemoryExtraction"),
LlmEvent::tool_input_delta(0, input), LlmEvent::tool_input_delta(0, input),
LlmEvent::tool_use_stop(0), LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent { LlmEvent::Status(StatusEvent {
@@ -559,13 +559,13 @@ fn finish_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
async fn compact_resets_extract_pointer_so_extract_can_fire_again() { async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
// Mock LLM responses, in call order: // Mock LLM responses, in call order:
// [0] first run with usage(1000) so extract threshold (=1) fires. // [0] first run with usage(1000) so extract threshold (=1) fires.
// [1] extract worker invokes finish_extraction with empty output. // [1] extract worker invokes FinishMemoryExtraction with empty output.
// [2] extract worker closes after the tool result. // [2] extract worker closes after the tool result.
// [3] compact worker invokes write_summary. // [3] compact worker invokes write_summary.
// [4] compact worker closes after the tool result. // [4] compact worker closes after the tool result.
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("hi", 1000), text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"), finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"), single_text_events("done"),
write_summary_tool_use_events("sc1", "summary"), write_summary_tool_use_events("sc1", "summary"),
single_text_events("done"), single_text_events("done"),
@@ -701,7 +701,7 @@ permission = "write"
async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() { async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("recorded", 1000), text_events_with_usage("recorded", 1000),
finish_extraction_tool_use_events("ec-large"), finish_memory_extraction_tool_use_events("ec-large"),
single_text_events("done"), single_text_events("done"),
]); ]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await; let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
@@ -722,7 +722,7 @@ async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
async fn spawn_and_wait_drives_extract_to_completion() { async fn spawn_and_wait_drives_extract_to_completion() {
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("hi", 1000), text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"), finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"), single_text_events("done"),
]); ]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await; let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
@@ -750,7 +750,7 @@ async fn detached_extract_does_not_fork_session_log() {
// `ensure_head_or_fork` does not spawn a new session. // `ensure_head_or_fork` does not spawn a new session.
let client = MockClient::new(vec![ let client = MockClient::new(vec![
text_events_with_usage("hi", 1000), text_events_with_usage("hi", 1000),
finish_extraction_tool_use_events("ec1"), finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"), single_text_events("done"),
text_events_with_usage("ok", 1000), text_events_with_usage("ok", 1000),
]); ]);
+12 -11
View File
@@ -9,17 +9,18 @@ It is not a dumping ground for external research, old plans, API inventories, or
1. [`design/overview.md`](design/overview.md) — the system map. 1. [`design/overview.md`](design/overview.md) — the system map.
2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context. 2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints. 3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 4. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 5. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. 6. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. 7. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. 8. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
9. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 9. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 10. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
11. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. 11. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
12. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 12. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
13. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 13. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
14. [`development/validation.md`](development/validation.md) — how to check changes. 14. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
15. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here ## What belongs here
+47
View File
@@ -0,0 +1,47 @@
# Session capture and Worker observation
Yoi uses one session-entry exploration domain for host-provided snapshots, Memory extraction evidence, and authorized observation of active Workers. The domain is implemented in `worker::session_capture` and has no Workspace or Memory mutation authority.
## Common capture contract
A host constructs an immutable ordered `SessionCapture` from committed session items. The capture:
- excludes reasoning before overview, search, read, or evidence projection;
- does not include the session system-prompt field;
- assigns an append-stable `SessionEntryRef` (`E...`) from the committed source position;
- uses the same reference in sparse overview anchors, search results, bounded reads, and Memory evidence conversion;
- pages sparse real user/assistant anchors and reports the number of non-reasoning entries between anchors;
- supports bounded range search and compact range listing when no filter is supplied;
- bounds read item count and bytes.
`SessionEntryRef` is local to the selected session subject. Runtime peers use the canonical `{ kind: "runtime_worker", runtime_id, worker_id }` reference; parent-owned children use `{ kind: "sub_worker", name }`. A model must first select a host-projected session and must reuse both the structured subject and entry references returned for that session.
## Independent features
The three feature modules share only the capture domain:
- `session-explore` installs `ShowOverview`, `SearchEntries`, and `ReadEntry` for one immutable host snapshot. It has no Workspace client or Memory state.
- `memory-extract` installs `StageMemoryCandidate` and `FinishMemoryExtraction`. It validates every staged `entry_ref` against its co-installed capture before converting it to typed Memory evidence.
- `worker-observation` installs `ListWorkerSessions`, `ViewSessionOverview`, `SearchSessionEntries`, and `ReadSessionEntry`. It captures the selected Worker again on every operation, so newly committed entries become visible while existing append-only references remain stable.
The features do not enable or mutate each other. Feature-registry collision checks remain authoritative for tool names.
## Observation authority
`WorkerObservationProvider` is a host-injected authority boundary. The Worker receives only an `Arc<dyn WorkerObservationProvider>`; model input never supplies grants, Workspace credentials, Runtime URLs, session handles, repository paths, or provider clients.
The provider must:
1. list only active subjects already granted to the current Worker;
2. reauthorize every capture instead of trusting a previous list result;
3. return the same not-found result for missing and unauthorized subjects;
4. return only committed session items;
5. keep subject identifiers opaque and bounded.
Runtime/Backend integrations enable the feature through the Backend-only `WorkerSpawnRequest.resolved_worker_observation_enabled` field, forwarded as `CreateWorkerRequest.worker_observation_enabled`. Canonical same-Runtime peers may also be supplied through `resolved_worker_observation_grants`; Runtime revalidates those against live weak handles and Workspace scope. For cross-Runtime peers and dynamically added Workers, `WorkspaceClientWorkerObservationProvider` calls the Workspace-scoped Server projection on every list/capture. Server authorizes that route against the current Workspace Orchestrator identity, recomputes the active Workspace Worker set, and reads the selected Workers committed protocol snapshot. Runtime binds these providers through `Worker::bind_worker_observation_provider` on spawn and restore. Parent-owned SubWorkers use the same provider contract through `SpawnedSubWorkerObservationProvider`; their subjects use the tagged `sub_worker` variant.
Observation is read-only evidence access. It does not authorize Ticket, Memory, Worker, or Workdir mutations and is not completion or approval authority.
## SubWorker output
SubWorkers no longer expose a separate output cursor tool. `SubWorkerList`, `SubWorkerSend`, and `SubWorkerStop` retain parent-owned lifecycle control, while committed child output is read through `worker-observation`. Turn-completion notifications carry no transcript and only tell the parent to inspect the authoritative committed session at a natural boundary.
+1 -1
View File
@@ -253,7 +253,7 @@ Unless explicitly authorized otherwise, final merge, cleanup, design-boundary de
Before closing, verify concrete evidence: Before closing, verify concrete evidence:
- SubWorker output via `SubWorkerReadOutput`; - SubWorker committed session via worker-observation tools;
- worktree state and diff; - worktree state and diff;
- validation command output; - validation command output;
- review result; - review result;
@@ -0,0 +1,11 @@
## Worker session observation
Worker-session tools are a read-only exploration surface over host-granted active Worker sessions.
- Use `ListWorkerSessions` to discover only the sessions already granted to you. Reuse the returned structured `subject` exactly: Runtime peers use `{ kind: "runtime_worker", runtime_id, worker_id }`, while parent-owned children use `{ kind: "sub_worker", name }`. Do not guess subject identifiers.
- Use `ViewSessionOverview` for sparse orientation, `SearchSessionEntries` for bounded range/filter queries, and `ReadSessionEntry` for one bounded entry.
- `SessionEntryRef` is the common entry identity across overview, search, reads, and evidence conversion. Reuse returned `E...` values; never invent them.
- Every operation rereads the latest committed capture. Existing references remain stable when entries append.
- Reasoning and raw system prompts are not exposed. Do not ask another tool or filesystem path to bypass this projection.
- A missing subject and an unauthorized subject intentionally produce the same result. Treat either as inaccessible.
- Observation is not mutation authority and does not prove approval, completion, or Ticket state. Reread the relevant domain authority before acting.
@@ -5,8 +5,8 @@ When SubWorker-management tools are available, SubWorker notifications are backg
The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks. The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks.
Before treating delegated SubWorker work as complete, read the SubWorker output and inspect concrete evidence such as worktree state, diff, and test results. Notifications are hints, not proof of completion. Before treating delegated SubWorker work as complete, inspect its committed session through worker-observation and verify concrete evidence such as worktree state, diff, and test results. Notifications are hints, not proof of completion.
Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant scope, produce a child output cursor, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers. Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant session-observation authority, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers.
This guidance is not scheduler or auto-maintain authorization. Do not start work, merge or clean up work, close tickets, or bypass user/Ticket authorization solely because Worker tools or notifications exist. This guidance is not scheduler or auto-maintain authorization. Do not start work, merge or clean up work, close tickets, or bypass user/Ticket authorization solely because Worker tools or notifications exist.
@@ -11,16 +11,17 @@ Your job is to inspect the supplied host-created session reference view and stag
## Tools ## Tools
Use the session-explore tools only: Use the co-installed `session-explore` and `memory-extract` tools only:
- `search_evidence`: find bounded evidence ids in the host-created session index. Optional `kind` accepts `user`, `assistant`/`agent`, `system`, or `tool`. - `ShowOverview`: inspect sparse real user/assistant anchors and intervening-entry counts.
- `read_evidence`: inspect a bounded evidence id or entry range before staging when the overview/index is not enough. - `SearchEntries`: find bounded `SessionEntryRef` values in the host-created session capture. Optional `kind` accepts `user`, `assistant`/`agent`, or `tool`.
- `stage_candidate`: write one flat staging record for one memory candidate. - `ReadEntry`: inspect one bounded `SessionEntryRef` before staging when the overview/index is not enough.
- `finish_extraction`: finish the run after all useful candidates are staged, or after deciding there are no useful candidates. - `StageMemoryCandidate`: write one flat staging record for one memory candidate.
- `FinishMemoryExtraction`: finish the run after all useful candidates are staged, or after deciding there are no useful candidates.
Do not invent evidence ids. Stage candidates only with `M...` or `T...` ids returned by `search_evidence` / `read_evidence` or shown in the initial evidence index. Overview `O...` ids are orientation labels, not source evidence ids. Do not invent `SessionEntryRef` values. Stage candidates only with `E...` references returned by `ShowOverview`, `SearchEntries`, or `ReadEntry`. The same `SessionEntryRef` identifies an entry across overview, search, reads, and Memory evidence conversion.
Call `stage_candidate` once per useful candidate with this shape: Call `StageMemoryCandidate` once per useful candidate with this shape:
```json ```json
{ {
@@ -28,11 +29,11 @@ Call `stage_candidate` once per useful candidate with this shape:
"claim": "...", "claim": "...",
"why_useful": "...", "why_useful": "...",
"staleness": "...", "staleness": "...",
"evidence_ids": ["M0001"] "entry_refs": ["E00000001"]
} }
``` ```
Then call `finish_extraction` exactly once: Then call `FinishMemoryExtraction` exactly once:
```json ```json
{ {
@@ -40,7 +41,7 @@ Then call `finish_extraction` exactly once:
} }
``` ```
If nothing is worth staging, do not call `stage_candidate`; call `finish_extraction` with `{"staged_count": 0, "no_candidates_reason": "..."}`. If nothing is worth staging, do not call `StageMemoryCandidate`; call `FinishMemoryExtraction` with `{"staged_count": 0, "no_candidates_reason": "..."}`.
Allowed candidate kinds: Allowed candidate kinds:
@@ -56,7 +57,7 @@ Required fields per candidate:
- `kind`: one of the allowed candidate kinds. - `kind`: one of the allowed candidate kinds.
- `claim`: concise statement of the candidate. - `claim`: concise statement of the candidate.
- `why_useful`: why this candidate may be useful for future consolidation. - `why_useful`: why this candidate may be useful for future consolidation.
- `evidence_ids`: one or more host-issued source evidence ids. - `entry_refs`: one or more host-issued `SessionEntryRef` values.
Optional fields: Optional fields: