feat: add provenance-aware worker history

This commit is contained in:
2026-08-27 14:54:24 +09:00
parent 116d610ad0
commit e365189276
46 changed files with 2560 additions and 658 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// 5. Extract the assistant's reply from history
let history = worker.engine().history();
let history = worker.history();
if let Some(text) = history
.iter()
.rev()
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
};
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Enable prune projection on the underlying Engine.
///
/// Registers the config and token/savings-estimator closures on the Engine.
+4 -4
View File
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
// ── Worker に生やす公開 API ───────────────────────────────────────────────
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// 現在の history 全体の推定トークン数。
///
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
pub fn total_tokens(&self) -> TokenEstimate {
let usage = self.usage_history();
agen::token_counter::total_tokens(self.history(), &usage)
agen::token_counter::total_tokens(&self.history(), &usage)
}
/// 任意の history index 時点でのプロンプト全長推定。
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// pointer 以降に増えたプロンプト長を測るのに使う。
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
let usage = self.usage_history();
agen::token_counter::total_tokens_at(self.history(), &usage, history_len)
agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
}
/// 末尾から `retained` トークン以上を残すための分割位置。
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
let usage = self.usage_history();
split_for_retained_impl(self.history(), &usage, retained)
split_for_retained_impl(&self.history(), &usage, retained)
}
}
+3 -3
View File
@@ -1780,7 +1780,7 @@ where
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
match worker.list_rewind_targets() {
@@ -1806,7 +1806,7 @@ fn apply_rewind<C, St>(
expected_head_entries: usize,
) -> bool
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
match worker.rewind_to(target, expected_head_entries) {
@@ -1854,7 +1854,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
let manifest = worker.manifest();
+2 -2
View File
@@ -1795,9 +1795,9 @@ impl FeatureRegistryBuilder {
}
/// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient>(
pub(crate) fn install_into_engine<C: LlmClient, A>(
self,
worker: &mut Engine<C, Mutable>,
worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new();
@@ -6,7 +6,9 @@ use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use memory::schema::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
};
use schemars::JsonSchema;
use serde::Deserialize;
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
"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());
let mut entries = 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(|| {
entries.push(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));
})?);
}
if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| {
!matches!(
entry.origin,
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
{
return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
.to_string(),
));
}
let evidence = entries.iter().map(staging_evidence).collect();
let source_refs = entries.iter().map(source_evidence_ref).collect();
let candidate = ExtractedCandidate {
kind: params.kind,
claim: params.claim,
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
}
}
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin;
let mut evidence = EvidenceOrigin {
kind: EvidenceOriginKind::LegacyUnknown,
account_id: None,
workspace_id: None,
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
}
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence {
id: entry.entry_ref.to_string(),
kind: evidence_kind(entry),
entry_range: Some(entry.entry_range),
origin: Some(evidence_origin(&entry.origin)),
excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()),
}
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()),
origin: Some(evidence_origin(&entry.origin)),
evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()),
@@ -432,6 +501,15 @@ mod tests {
assert!(input.contains("StageMemoryCandidate.entry_refs"));
}
#[test]
fn human_origin_projects_account_authority_into_evidence() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
});
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
}
#[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
@@ -445,6 +523,19 @@ mod tests {
assert!(matches!(http, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
.transpose()?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
let view = self.state.view();
if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchEntries from must not be after through".to_string(),
));
}
}
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
let hits = self.state.view().search(&SearchOptions {
let hits = view.search(&SearchOptions {
query: params.query,
kind,
tool_part,
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -1,11 +1,12 @@
use std::sync::Arc;
#[cfg(test)]
use agen::Item;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::collect_state;
use session_store::{LogEntry, collect_state};
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
use crate::feature::{
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
#[derive(Debug, Clone)]
pub struct WorkerSessionCapture {
pub segment_id: String,
pub items: Vec<Item>,
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
}
impl WorkerSessionCapture {
pub fn from_log_entries(
segment_id: impl Into<String>,
log_entries: &[LogEntry],
) -> Result<Self, String> {
let segment_id = segment_id.into();
let state = collect_state(log_entries);
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
log_entries,
)?;
Ok(Self {
segment_id,
entries,
})
}
}
#[derive(Debug, thiserror::Error)]
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
})
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries);
let segment_id = response.segment_id;
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id: response.segment_id,
items: state.history,
segment_id,
entries: typed_entries,
})
}
}
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
.ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries();
let state = collect_state(&entries);
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
Default::default(),
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"),
items: state.history,
entries: typed_entries,
})
}
}
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
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() {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(),
));
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -661,7 +699,10 @@ async fn latest_view(
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items))
Ok(SessionCapture::from_history_entries(
capture.segment_id,
capture.entries,
))
}
fn parse_input<T: serde::de::DeserializeOwned>(
@@ -751,9 +792,23 @@ mod tests {
if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound);
}
let entries = self
.captures
.lock()
.unwrap()
.clone()
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
agen::HistoryEntry::new(item, metadata)
})
.collect();
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(),
entries,
})
}
}
@@ -796,7 +851,7 @@ mod tests {
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"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
@@ -810,7 +865,7 @@ mod tests {
.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"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
@@ -819,7 +874,7 @@ mod tests {
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
+20 -4
View File
@@ -55,7 +55,17 @@ pub(crate) struct InternalWorkerSpec {
pub input: String,
pub cache_key: Option<String>,
pub max_turns: Option<u32>,
pub engine_configurator: Option<Box<dyn FnOnce(&mut Engine<Box<dyn LlmClient>>) + Send>>,
pub engine_configurator: Option<
Box<
dyn FnOnce(
&mut Engine<
Box<dyn LlmClient>,
agen::state::Mutable,
crate::SessionHistoryMetadata,
>,
) + Send,
>,
>,
pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority,
@@ -124,7 +134,9 @@ where
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt);
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone());
@@ -536,7 +548,9 @@ pub(crate) async fn spawn_internal_worker_session(
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt);
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone());
@@ -633,7 +647,9 @@ pub(crate) fn prepare_internal_worker_from_spec(
manifest.compaction = None;
manifest.memory = None;
let mut engine = Engine::new(client).system_prompt(system_prompt);
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
+39 -2
View File
@@ -8,6 +8,7 @@
//! decisions (continue / skip / abort / pause).
use std::borrow::Cow;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
@@ -33,7 +34,9 @@ use crate::hook::{
};
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter;
use agen::HistoryEntry;
use agen::token_counter::total_tokens;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
}
impl WorkerInterceptor {
#[cfg(test)]
pub(crate) fn new(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
) -> Self {
Self::new_with_history_queue(
registry,
compact_state,
usage_history,
pending_notifies,
pending_attachments,
prompts,
log_writer,
Arc::new(Mutex::new(VecDeque::new())),
)
}
pub(crate) fn new_with_history_queue(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifies: NotifyBuffer,
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self {
Self {
registry,
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
prompts,
prompt_workspace_id: None,
log_writer,
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
return Ok(());
};
for item in items {
writer.commit_system_item(item.clone())?;
let entry = writer.commit_system_item(item.clone())?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
}
Ok(())
}
@@ -507,7 +539,12 @@ mod tests {
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
if let session_store::LogEntry::SystemItem { item, .. } = entry {
let item = match entry {
session_store::LogEntry::SystemItem { item, .. } => Some(item),
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
_ => None,
};
if let Some(item) = item {
self.committed
.lock()
.expect("committed system-item list poisoned")
+8 -2
View File
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
match entry {
LogEntry::SegmentStart { .. } => {
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value })
}
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }),
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
+5
View File
@@ -12,6 +12,7 @@ pub mod prompt;
pub mod runtime;
pub mod segment_log_sink;
mod session_capture;
mod session_history;
pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
@@ -42,6 +43,10 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use session_history::{
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
WorkerHistoryProvenance, WorkerSubjectSnapshot,
};
pub use shared_state::WorkerSharedState;
pub use worker::{
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
+1 -1
View File
@@ -34,7 +34,7 @@ impl PermissionHook {
}
}
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
pub(crate) fn apply_permissions_from_manifest(&mut self) {
let Some(permissions) = self.manifest().permissions.clone() else {
return;
+3
View File
@@ -121,8 +121,11 @@ impl SegmentLogSink {
matches!(
entry,
LogEntry::SegmentStart { .. }
| LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::UserInput { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::SystemItem { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
)
}
+144 -20
View File
@@ -6,7 +6,8 @@
use std::sync::Arc;
use agen::{Item, Role};
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
use agen::{HistoryEntry, Item, Role};
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self {
Self(format!("E{source_index:08}"))
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
Self(format!("E{}", entry_id.0))
}
pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string());
reference.source_index()?;
Some(reference)
let suffix = value.strip_prefix('E')?;
if suffix.is_empty()
|| suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
@@ -97,6 +105,7 @@ impl ToolPart {
#[derive(Debug, Clone)]
pub(crate) struct OverviewItem {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
#[derive(Debug, Clone)]
pub(crate) struct SearchHit {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -177,6 +188,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)]
pub(crate) struct ReadEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -195,6 +207,7 @@ pub(crate) struct ReadResult {
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -206,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
#[derive(Debug, Clone)]
pub(crate) struct SessionCapture {
segment_id: String,
items: Arc<Vec<Item>>,
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let entries = items
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
HistoryEntry::new(item, metadata)
})
.collect();
Self::from_history_entries(segment_id, entries)
}
pub(crate) fn from_history_entries(
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into();
let items = Arc::new(items);
let entries = Arc::new(entries);
let mut overview = Vec::new();
let mut index = Vec::new();
for (idx, item) in items.iter().enumerate() {
for (idx, entry) in entries.iter().enumerate() {
let item = &entry.item;
let entry_range = [idx as u64, idx as u64];
match item {
Item::Message { role, content, .. } => {
let kind = match role {
Role::User => ReferenceKind::User,
Role::Assistant => ReferenceKind::Assistant,
Role::System => continue,
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
continue;
};
let text = content
.iter()
@@ -234,9 +263,10 @@ impl SessionCapture {
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::new(idx);
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
index.push(ReferenceEntry {
id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range,
kind,
tool_part: None,
@@ -248,6 +278,7 @@ impl SessionCapture {
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem {
id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range,
kind,
label,
@@ -261,7 +292,8 @@ impl SessionCapture {
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
id: SessionEntryRef::new(idx),
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
@@ -287,7 +319,8 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
id: SessionEntryRef::new(idx),
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
@@ -327,7 +360,7 @@ impl SessionCapture {
Self {
segment_id,
items,
entries,
overview,
index,
}
@@ -337,6 +370,14 @@ impl SessionCapture {
&self.overview
}
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
.or_else(|| reference.source_index())
}
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
let query = options.query.trim().to_lowercase();
let limit = options
@@ -347,12 +388,12 @@ impl SessionCapture {
let min_entry_index = options
.from
.as_ref()
.and_then(SessionEntryRef::source_index)
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(SessionEntryRef::source_index)
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new();
@@ -391,6 +432,7 @@ impl SessionCapture {
}
hits.push(SearchHit {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
@@ -442,13 +484,18 @@ impl SessionCapture {
}
}
}
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
let Some(item) = self
.entries
.get(entry.entry_range[0] as usize)
.map(|entry| &entry.item)
else {
continue;
};
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
@@ -485,6 +532,7 @@ impl SessionCapture {
Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: entry.entry_range,
kind: entry.kind,
tool_part: entry.tool_part,
@@ -495,6 +543,28 @@ impl SessionCapture {
}
}
fn message_reference_kind(
origin: &WorkerHistoryProvenance,
provider_role: &Role,
) -> Option<ReferenceKind> {
match origin {
WorkerHistoryProvenance::HumanInput { .. }
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
WorkerHistoryProvenance::FlowInstruction { .. }
| WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::DerivedSummary => None,
}
}
fn render_item(
item: &Item,
entry: &ReferenceEntry,
@@ -563,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
#[test]
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
let entries = vec![
crate::session_history::history_entry(
Item::user_message("trusted flow instruction"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "coder-review".into(),
definition_revision: 3,
instance_id: "instance".into(),
state_id: "implement".into(),
},
),
crate::session_history::history_entry(
Item::user_message("remember my preference"),
WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
},
),
];
let capture = SessionCapture::from_history_entries("segment", entries);
let overview = capture.overview();
assert_eq!(overview.len(), 1);
assert!(matches!(
overview[0].origin,
WorkerHistoryProvenance::HumanInput { .. }
));
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
assert!(evidence.excerpt.ends_with("remember my preference"));
assert!(matches!(
evidence.origin,
WorkerHistoryProvenance::HumanInput { .. }
));
}
#[test]
fn stable_logical_ref_survives_retention_and_restore_projection() {
let retained = crate::session_history::history_entry(
Item::assistant_message("retained"),
WorkerHistoryProvenance::ModelOutput {
worker: crate::session_history::worker_subject(Default::default()),
},
);
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
let after = SessionCapture::from_history_entries("new", vec![retained]);
assert_eq!(before.overview()[0].id, expected_ref);
assert_eq!(after.overview()[0].id, expected_ref);
assert_eq!(
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
expected_ref
);
}
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new(
+219
View File
@@ -0,0 +1,219 @@
//! Restore-authoritative metadata for model-visible Worker history.
//!
//! Agen transports this annotation without interpreting it. Session Log v2
//! stores each item and metadata in one typed record; legacy records are
//! retained only as explicit `LegacyUnknown` entries.
use agen::{HistoryEntry, Item};
use protocol::Segment;
use session_store::{
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
SessionId,
};
pub type SessionHistoryEntryId = LoggedSessionHistoryEntryId;
pub type SessionHistoryMetadata = LoggedSessionHistoryMetadata;
pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
WorkerSubjectSnapshot {
workspace_id: None,
runtime_id: None,
worker_id: session_id.to_string(),
}
}
pub(crate) fn metadata(
origin: WorkerHistoryProvenance,
derivation: Option<SessionHistoryDerivation>,
) -> SessionHistoryMetadata {
SessionHistoryMetadata {
entry_id: SessionHistoryEntryId::new(),
origin,
derivation,
}
}
pub(crate) fn history_entry(
item: Item,
origin: WorkerHistoryProvenance,
) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, metadata(origin, None))
}
pub(crate) fn to_logged_history_entry(
entry: &HistoryEntry<SessionHistoryMetadata>,
) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: entry.item.clone().into(),
metadata: entry.annotation.clone(),
}
}
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
}
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
}
/// Rebuild typed Worker history directly from the append-only Session Log.
/// Missing legacy metadata is never inferred from role or plaintext.
pub(crate) fn restore_history_entries(
_session_id: SessionId,
_segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<Vec<HistoryEntry<SessionHistoryMetadata>>, String> {
let mut history = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
history = seed.iter().map(from_logged).collect();
}
LogEntry::SegmentStart { history: seed, .. } => {
history = seed
.iter()
.cloned()
.map(Item::from)
.map(legacy_entry)
.collect();
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
}
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
Segment::flatten_to_text(segments),
))),
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
history.push(legacy_entry(Item::from(item.clone())));
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::SystemItem { item, .. } => {
history.push(legacy_entry(item.to_history_item()));
}
_ => {}
}
}
Ok(history)
}
#[cfg(test)]
mod tests {
use super::*;
use agen::llm_client::RequestConfig;
use session_store::LogEntry;
#[test]
fn legacy_user_role_is_not_inferred_as_human_authority() {
let entries = vec![LogEntry::UserInput {
ts: 1,
segments: vec![Segment::text("legacy")],
extensions: Vec::new(),
}];
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
assert!(matches!(
restored[0].annotation.origin,
WorkerHistoryProvenance::LegacyUnknown
));
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
let projected = vec![
history_entry(
Item::user_message("flow instructions"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".to_string(),
definition_id: "coder-review".to_string(),
definition_revision: 7,
instance_id: "flow-instance".to_string(),
state_id: "implement".to_string(),
},
),
history_entry(
Item::user_message("implement"),
WorkerHistoryProvenance::LegacyUnknown,
),
];
let entries = vec![
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
},
LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("implement"),
],
extensions: Vec::new(),
history: projected.iter().map(to_logged_history_entry).collect(),
},
];
let restored = restore_history_entries(session_id, SegmentId::now_v7(), &entries).unwrap();
assert_eq!(restored, projected);
}
#[test]
fn annotated_restore_preserves_logical_ids_across_reboot() {
let session_id = SessionId::now_v7();
let entry = history_entry(
Item::assistant_message("persisted"),
WorkerHistoryProvenance::ModelOutput {
worker: worker_subject(session_id),
},
);
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&entry)],
forked_from: None,
compacted_from: None,
}];
let first = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
let second = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
assert_eq!(first[0].annotation.entry_id, entry.annotation.entry_id);
assert_eq!(second[0].annotation.entry_id, entry.annotation.entry_id);
}
#[test]
fn compacted_derivation_uses_stable_logical_entry_ids() {
let source = history_entry(
Item::user_message("source"),
WorkerHistoryProvenance::LegacyUnknown,
);
let summary = HistoryEntry::new(
Item::system_message("summary"),
metadata(
WorkerHistoryProvenance::DerivedSummary,
Some(SessionHistoryDerivation {
sources: vec![source.annotation.entry_id.clone()],
}),
),
);
assert_eq!(
summary.annotation.derivation.unwrap().sources,
vec![source.annotation.entry_id]
);
}
}
+2 -2
View File
@@ -1252,7 +1252,7 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(first_capture.items.iter().any(|item| {
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
}));
@@ -1274,7 +1274,7 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(latest_capture.items.len() > first_capture.items.len());
assert!(latest_capture.entries.len() > first_capture.entries.len());
fail_requests.store(true, Ordering::SeqCst);
send.execute(
File diff suppressed because it is too large Load Diff
+35 -23
View File
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -204,28 +205,34 @@ fn system_texts_in_sink_session_start(
) -> Vec<String> {
let (entries, _rx) = worker.sink().subscribe_with_snapshot();
for entry in entries.into_iter().rev() {
if let session_store::LogEntry::SegmentStart { history, .. } = entry {
return history
let history = match entry {
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
.into_iter()
.filter_map(|logged| {
let item: Item = logged.into();
match item {
Item::Message {
role: agen::Role::System,
content,
..
} => Some(
content
.iter()
.map(|p| p.as_text().to_owned())
.collect::<Vec<_>>()
.join(""),
),
_ => None,
}
})
.collect();
}
.map(|entry| entry.item)
.collect::<Vec<_>>(),
session_store::LogEntry::SegmentStart { history, .. } => history,
_ => continue,
};
return history
.into_iter()
.filter_map(|logged| {
let item: Item = logged.into();
match item {
Item::Message {
role: agen::Role::System,
content,
..
} => Some(
content
.iter()
.map(|p| p.as_text().to_owned())
.collect::<Vec<_>>()
.join(""),
),
_ => None,
}
})
.collect();
}
Vec::new()
}
@@ -337,7 +344,12 @@ permission = "write"
// New segment records forked_from pointing at the source.
let new_entries = store.read_all(session_id, new_segment_id).unwrap();
match &new_entries[0] {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
}
| LogEntry::SegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
+45 -26
View File
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
let mut items = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::SegmentStart { history, .. } => {
items.extend(history.into_iter().map(Item::from));
}
LogEntry::AnnotatedUserInput { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(&segments);
items.push(Item::user_message(text));
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
items.push(Item::from(entry.item));
}
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
items.push(Item::from(item));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
items.push(entry.item.to_history_item());
}
LogEntry::SystemItem { item, .. } => {
items.push(item.to_history_item());
}
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
items
}
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
LogEntry::SystemItem { item, .. } => Some(item),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Mock LLM Client
// ---------------------------------------------------------------------------
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
let scope = manifest::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
let worker = Worker::new(
manifest,
@@ -804,10 +826,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
// Walk the entries, find a `LogEntry::UserInput` and
// confirm its segments flatten to our submitted text.
let mut found = false;
for value in entries {
for value in &entries {
let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. } = entry {
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
{
let text = protocol::Segment::flatten_to_text(&segments);
if text == "hello in-flight" {
found = true;
@@ -815,7 +839,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
}
}
}
assert!(found, "snapshot must carry the in-flight UserInput entry");
assert!(
found,
"snapshot must carry the in-flight UserInput entry: {entries:?}"
);
return;
}
Event::Alert(_) => continue,
@@ -1086,7 +1113,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
_ => {}
},
entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. }) => {
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
user_input_segments = Some(segments);
if saw_turn_end {
break;
@@ -1317,11 +1344,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_notify_in_mirror = entries.iter().any(|e| {
matches!(
e,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::Notification { message, .. },
..
} if message == "turn finished"
system_item(e),
Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
)
});
assert!(
@@ -1463,14 +1487,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_worker_event_in_mirror = entries.iter().any(|e| {
matches!(
e,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name },
..
},
system_item(e),
Some(session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name },
..
} if worker_name == "child"
}) if worker_name == "child"
)
});
assert!(
@@ -1552,14 +1573,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
matches!(
entry,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
..
},
system_item(entry),
Some(session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
..
}
})
)
});
assert!(
@@ -2373,7 +2391,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise");
match entry {
session_store::LogEntry::UserInput { segments, .. } => {
session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
_ => false,
+6 -3
View File
@@ -188,7 +188,8 @@ async fn make_worker(
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
let mut worker = Engine::new(client);
let mut worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
worker.register_tool(big_content_tool_definition(tool_name));
let worker = Worker::new(
@@ -460,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
// protected token budget covers the only user message). That is enough to drive
// the failure path: at least one metric attempts to write.
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -536,7 +538,8 @@ permission = "write"
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,