From 2dd9ef95dd9c03c8b23ce878a1b8fbde63813234 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 18 Jul 2026 08:27:42 +0900 Subject: [PATCH] worker: add session reference view --- .yoi/tickets/00001KXS56AS5/item.md | 6 +- .yoi/tickets/00001KXS56AS5/resolution.md | 11 + .yoi/tickets/00001KXS56AS5/thread.md | 80 ++++ crates/worker/src/compact/worker.rs | 65 ++- crates/worker/src/lib.rs | 1 + crates/worker/src/session_reference.rs | 514 +++++++++++++++++++++++ 6 files changed, 654 insertions(+), 23 deletions(-) create mode 100644 .yoi/tickets/00001KXS56AS5/resolution.md create mode 100644 crates/worker/src/session_reference.rs diff --git a/.yoi/tickets/00001KXS56AS5/item.md b/.yoi/tickets/00001KXS56AS5/item.md index 1e4a64a6..12745eee 100644 --- a/.yoi/tickets/00001KXS56AS5/item.md +++ b/.yoi/tickets/00001KXS56AS5/item.md @@ -1,9 +1,11 @@ --- title: 'Session reference viewを共通基盤として実装する' -state: 'ready' +state: 'closed' created_at: '2026-07-17T23:04:40Z' -updated_at: '2026-07-17T23:20:00Z' +updated_at: '2026-07-17T23:27:20Z' assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-17T23:15:15Z' --- ## 背景 diff --git a/.yoi/tickets/00001KXS56AS5/resolution.md b/.yoi/tickets/00001KXS56AS5/resolution.md new file mode 100644 index 00000000..d5d4b1ca --- /dev/null +++ b/.yoi/tickets/00001KXS56AS5/resolution.md @@ -0,0 +1,11 @@ +Implemented `SessionReferenceView` common substrate. + +The Worker crate now has an immutable session reference view that can build User/Assistant overview items, index user/assistant/system/tool references, search with kind and tool input/output filters, perform bounded reads with truncation metadata, and resolve source refs for future extract staging anchors. Compaction search now uses this common view; read-session behavior remains unchanged for compatibility. + +Validation passed: +- `cargo fmt --check` +- `cargo test -p worker session_reference` +- `cargo test -p worker compact::worker` +- `cargo test -p worker` +- `cargo test -p memory` +- `nix build .#yoi` diff --git a/.yoi/tickets/00001KXS56AS5/thread.md b/.yoi/tickets/00001KXS56AS5/thread.md index 4747a7ff..0ec5b095 100644 --- a/.yoi/tickets/00001KXS56AS5/thread.md +++ b/.yoi/tickets/00001KXS56AS5/thread.md @@ -4,4 +4,84 @@ LocalTicketBackend によって作成されました。 +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +Implemented `SessionReferenceView` common substrate. + +Changes: +- Added `crates/worker/src/session_reference.rs` with immutable view construction over a session history slice. +- The view produces User/Assistant overview items and a searchable evidence index over user/assistant/system/tool references. +- Added text search with `kind` filter and tool input/output/both filtering via `tool_part`. +- Added bounded read by id or `entry_range` with truncation metadata. +- Added source-ref resolution by evidence id for future extract staging anchors. +- Rewired compaction `search_session_log` to search through `SessionReferenceView` while leaving `read_session_items` behavior unchanged for now. + +Validation: +- `cargo fmt --check` +- `cargo test -p worker session_reference` +- `cargo test -p worker compact::worker` +- `cargo test -p worker` +- `cargo test -p memory` +- `nix build .#yoi` + + +--- + + + +## State changed + +State changed to `done`. + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Implemented `SessionReferenceView` common substrate. + +The Worker crate now has an immutable session reference view that can build User/Assistant overview items, index user/assistant/system/tool references, search with kind and tool input/output filters, perform bounded reads with truncation metadata, and resolve source refs for future extract staging anchors. Compaction search now uses this common view; read-session behavior remains unchanged for compatibility. + +Validation passed: +- `cargo fmt --check` +- `cargo test -p worker session_reference` +- `cargo test -p worker compact::worker` +- `cargo test -p worker` +- `cargo test -p memory` +- `nix build .#yoi` + + --- diff --git a/crates/worker/src/compact/worker.rs b/crates/worker/src/compact/worker.rs index 385d37a9..3001eb06 100644 --- a/crates/worker/src/compact/worker.rs +++ b/crates/worker/src/compact/worker.rs @@ -30,6 +30,7 @@ use tools::ScopedFs; use crate::compact::usage_tracker::UsageTracker; use crate::fs_view::{ReadRequirement, slice_lines}; +use crate::session_reference::{ReferenceKind, SearchOptions, SessionReferenceView}; /// Aggregated output of a compact worker run. #[derive(Debug, Default, Clone)] @@ -143,6 +144,7 @@ this to verify details before writing the summary."; struct SessionLogToolState { items: Arc>, + view: SessionReferenceView, } struct SearchSessionLogTool { @@ -165,27 +167,44 @@ impl Tool for SearchSessionLogTool { "search_session_log query must not be empty".to_string(), )); } - let offset = params.offset.unwrap_or(0).min(self.state.items.len()); + let offset = params.offset.unwrap_or(0); let limit = params .limit .unwrap_or(20) .clamp(1, SESSION_SEARCH_MAX_RESULTS); - let mut hits = Vec::new(); - for (idx, item) in self.state.items.iter().enumerate().skip(offset) { - let haystack = session_item_search_text(item).to_lowercase(); - if haystack.contains(&query) { - hits.push(format_session_item( - idx, - item, - SessionReadMode::Compact, - 600, - )); - if hits.len() >= limit { - break; - } - } - } - let mut content = hits.join("\n\n"); + let hits = self.state.view.search(&SearchOptions { + query: params.query.clone(), + kind: None, + tool_part: None, + tool_name: None, + limit: Some(limit), + min_entry_index: Some(offset as u64), + }); + let blocks = hits + .iter() + .map(|hit| { + let part = hit + .tool_part + .map(|part| format!(" {part:?}")) + .unwrap_or_default(); + let tool = hit + .tool_name + .as_ref() + .map(|name| format!(" {name}")) + .unwrap_or_default(); + format!( + "[{} {}{}{} {:?}] {}\n{}", + hit.id, + hit.kind.as_str(), + part, + tool, + hit.entry_range, + hit.label, + hit.summary + ) + }) + .collect::>(); + let mut content = blocks.join("\n\n"); let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS); let summary = if hits.is_empty() { format!("No session log hits for {query:?} from item offset {offset}.") @@ -531,7 +550,8 @@ pub(crate) fn write_summary_tool(ctx: Arc>) -> ToolD } pub(crate) fn search_session_log_tool(items: Arc>) -> ToolDefinition { - let state = Arc::new(SessionLogToolState { items }); + let view = SessionReferenceView::new("compact-target", (*items).clone()); + let state = Arc::new(SessionLogToolState { items, view }); Arc::new(move || { let schema = schemars::schema_for!(SearchSessionParams); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); @@ -546,7 +566,8 @@ pub(crate) fn search_session_log_tool(items: Arc>) -> ToolDefinition { } pub(crate) fn read_session_items_tool(items: Arc>) -> ToolDefinition { - let state = Arc::new(SessionLogToolState { items }); + let view = SessionReferenceView::new("compact-target", (*items).clone()); + let state = Arc::new(SessionLogToolState { items, view }); Arc::new(move || { let schema = schemars::schema_for!(ReadSessionParams); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); @@ -839,8 +860,9 @@ mod tests { "very large raw trace body with secret detail", ), ]); + let view = SessionReferenceView::new("test", (*items).clone()); let tool: Arc = Arc::new(SearchSessionLogTool { - state: Arc::new(SessionLogToolState { items }), + state: Arc::new(SessionLogToolState { items, view }), }); let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string(); let out = tool.execute(&input, Default::default()).await.unwrap(); @@ -858,8 +880,9 @@ mod tests { "read trace", "raw trace detail", )]); + let view = SessionReferenceView::new("test", (*items).clone()); let tool: Arc = Arc::new(ReadSessionItemsTool { - state: Arc::new(SessionLogToolState { items }), + state: Arc::new(SessionLogToolState { items, view }), }); let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string(); let out = tool.execute(&input, Default::default()).await.unwrap(); diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 25425ba8..3cf5ed2c 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -11,6 +11,7 @@ pub mod model_client; pub mod prompt; pub mod runtime; pub mod segment_log_sink; +mod session_reference; pub mod shared_state; mod shutdown_after_idle; pub mod skill; diff --git a/crates/worker/src/session_reference.rs b/crates/worker/src/session_reference.rs new file mode 100644 index 00000000..49784617 --- /dev/null +++ b/crates/worker/src/session_reference.rs @@ -0,0 +1,514 @@ +//! Immutable reference view over a session history slice. +//! +//! This module is shared substrate for internal workers that need to inspect a +//! bounded, host-created view of session history without reading the live +//! foreground Worker state directly. + +use std::sync::Arc; + +use llm_engine::{Item, Role}; +use memory::schema::{EvidenceKind, SourceEvidenceRef}; + +const DEFAULT_SEARCH_LIMIT: usize = 20; +const MAX_SEARCH_LIMIT: usize = 50; +const DEFAULT_READ_MAX_ITEMS: usize = 40; +const MAX_READ_MAX_ITEMS: usize = 80; +const DEFAULT_READ_MAX_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReferenceKind { + User, + Assistant, + System, + Tool, +} + +impl ReferenceKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Assistant => "assistant", + Self::System => "system", + Self::Tool => "tool", + } + } + + pub(crate) fn parse(value: &str) -> Option { + match value { + "user" => Some(Self::User), + "assistant" => Some(Self::Assistant), + "system" => Some(Self::System), + "tool" => Some(Self::Tool), + _ => None, + } + } + + fn evidence_kind(self) -> EvidenceKind { + match self { + Self::User | Self::Assistant | Self::System => EvidenceKind::new(EvidenceKind::MESSAGE), + Self::Tool => EvidenceKind::new(EvidenceKind::TOOL_RESULT), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolPart { + Input, + Output, + Both, +} + +impl ToolPart { + pub(crate) fn parse(value: &str) -> Option { + match value { + "input" => Some(Self::Input), + "output" => Some(Self::Output), + "both" => Some(Self::Both), + _ => None, + } + } + + fn matches(self, actual: ToolPart) -> bool { + matches!(self, ToolPart::Both) || self == actual + } +} + +#[derive(Debug, Clone)] +pub(crate) struct OverviewItem { + pub id: String, + pub entry_range: [u64; 2], + pub kind: ReferenceKind, + pub label: String, + pub text: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ReferenceEntry { + pub id: String, + pub entry_range: [u64; 2], + pub kind: ReferenceKind, + pub tool_part: Option, + pub tool_name: Option, + pub label: String, + pub summary: String, + search_text: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SearchOptions { + pub query: String, + pub kind: Option, + pub tool_part: Option, + pub tool_name: Option, + pub limit: Option, + pub min_entry_index: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SearchHit { + pub id: String, + pub kind: ReferenceKind, + pub tool_part: Option, + pub tool_name: Option, + pub entry_range: [u64; 2], + pub label: String, + pub summary: String, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum ReadSelector<'a> { + Id(&'a str), + EntryRange([u64; 2]), +} + +#[derive(Debug, Clone)] +pub(crate) struct ReadOptions { + pub include_tools: bool, + pub tool_part: ToolPart, + pub max_items: usize, + pub max_bytes: usize, +} + +impl Default for ReadOptions { + fn default() -> Self { + Self { + include_tools: true, + tool_part: ToolPart::Both, + max_items: DEFAULT_READ_MAX_ITEMS, + max_bytes: DEFAULT_READ_MAX_BYTES, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ReadEntry { + pub id: String, + pub kind: ReferenceKind, + pub tool_part: Option, + pub tool_name: Option, + pub entry_range: [u64; 2], + pub label: String, + pub text: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ReadResult { + pub entries: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionReferenceView { + segment_id: String, + items: Arc>, + overview: Vec, + index: Vec, +} + +impl SessionReferenceView { + pub(crate) fn new(segment_id: impl Into, items: Vec) -> Self { + let segment_id = segment_id.into(); + let items = Arc::new(items); + let mut overview = Vec::new(); + let mut index = Vec::new(); + + for (idx, item) in items.iter().enumerate() { + 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 => ReferenceKind::System, + }; + let text = content + .iter() + .map(|p| p.as_text()) + .collect::>() + .join(""); + let label = format!("{} message", kind.as_str()); + let summary = truncate_chars(&text, 240); + let id = format!("M{idx:04}"); + index.push(ReferenceEntry { + id: id.clone(), + entry_range, + kind, + tool_part: None, + tool_name: None, + label: label.clone(), + summary: summary.clone(), + search_text: text.clone(), + }); + if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { + overview.push(OverviewItem { + id: format!("O{:04}", overview.len()), + entry_range, + kind, + label, + text, + }); + } + } + Item::ToolCall { + name, arguments, .. + } => { + let text = format!("{name}\n{arguments}"); + index.push(ReferenceEntry { + id: format!("T{idx:04}i"), + entry_range, + kind: ReferenceKind::Tool, + tool_part: Some(ToolPart::Input), + tool_name: Some(name.clone()), + label: format!("tool input: {name}"), + summary: format!("Tool call {name}"), + search_text: text, + }); + } + Item::ToolResult { + summary, content, .. + } => { + let text = format!("{summary}\n{}", content.as_deref().unwrap_or_default()); + index.push(ReferenceEntry { + id: format!("T{idx:04}o"), + entry_range, + kind: ReferenceKind::Tool, + tool_part: Some(ToolPart::Output), + tool_name: None, + label: "tool output".to_string(), + summary: truncate_chars(summary, 240), + search_text: text, + }); + } + Item::Reasoning { .. } => {} + } + } + + Self { + segment_id, + items, + overview, + index, + } + } + + pub(crate) fn overview(&self) -> &[OverviewItem] { + &self.overview + } + + pub(crate) fn search(&self, options: &SearchOptions) -> Vec { + let query = options.query.trim().to_lowercase(); + let limit = options + .limit + .unwrap_or(DEFAULT_SEARCH_LIMIT) + .clamp(1, MAX_SEARCH_LIMIT); + let tool_name = options.tool_name.as_deref(); + let min_entry_index = options.min_entry_index.unwrap_or(0); + let mut hits = Vec::new(); + + for entry in &self.index { + if entry.entry_range[0] < min_entry_index { + continue; + } + if let Some(kind) = options.kind { + if entry.kind != kind { + continue; + } + } + if let Some(part) = options.tool_part { + if entry.kind != ReferenceKind::Tool { + continue; + } + let Some(actual) = entry.tool_part else { + continue; + }; + if !part.matches(actual) { + continue; + } + } + if let Some(name) = tool_name { + if entry.tool_name.as_deref() != Some(name) { + continue; + } + } + if !query.is_empty() && !entry.search_text.to_lowercase().contains(&query) { + continue; + } + hits.push(SearchHit { + id: entry.id.clone(), + kind: entry.kind, + tool_part: entry.tool_part, + tool_name: entry.tool_name.clone(), + entry_range: entry.entry_range, + label: entry.label.clone(), + summary: entry.summary.clone(), + }); + if hits.len() >= limit { + break; + } + } + + hits + } + + pub(crate) fn read(&self, selector: ReadSelector<'_>, options: ReadOptions) -> ReadResult { + let max_items = options.max_items.clamp(1, MAX_READ_MAX_ITEMS); + let max_bytes = options.max_bytes.max(1); + let mut entries = Vec::new(); + let mut bytes = 0usize; + let mut truncated = false; + + let selected: Vec<&ReferenceEntry> = match selector { + ReadSelector::Id(id) => self.index.iter().filter(|entry| entry.id == id).collect(), + ReadSelector::EntryRange([start, end]) => self + .index + .iter() + .filter(|entry| entry.entry_range[0] >= start && entry.entry_range[0] <= end) + .collect(), + }; + + for entry in selected { + if entries.len() >= max_items { + truncated = true; + break; + } + if entry.kind == ReferenceKind::Tool { + if !options.include_tools { + continue; + } + if let Some(actual) = entry.tool_part { + if !options.tool_part.matches(actual) { + continue; + } + } + } + let Some(item) = self.items.get(entry.entry_range[0] as usize) else { + continue; + }; + let text = render_item(item, entry, max_bytes.saturating_sub(bytes)); + bytes = bytes.saturating_add(text.len()); + entries.push(ReadEntry { + id: entry.id.clone(), + kind: entry.kind, + tool_part: entry.tool_part, + tool_name: entry.tool_name.clone(), + entry_range: entry.entry_range, + label: entry.label.clone(), + text, + }); + if bytes >= max_bytes { + truncated = true; + break; + } + } + + ReadResult { entries, truncated } + } + + pub(crate) fn source_ref_for(&self, id: &str) -> Option { + let entry = self.index.iter().find(|entry| entry.id == id)?; + Some(SourceEvidenceRef { + segment_id: Some(self.segment_id.clone()), + entry_range: Some(entry.entry_range), + evidence_id: Some(entry.id.clone()), + evidence_kind: Some(entry.kind.evidence_kind()), + label: Some(entry.label.clone()), + summary: Some(entry.summary.clone()), + ..Default::default() + }) + } +} + +fn render_item(item: &Item, entry: &ReferenceEntry, max_bytes: usize) -> String { + let text = match item { + Item::Message { role, content, .. } => { + let text = content + .iter() + .map(|p| p.as_text()) + .collect::>() + .join(""); + format!("[{} {:?}] {text}", entry.id, role) + } + Item::ToolCall { + name, arguments, .. + } => format!("[{} ToolInput {name}]\narguments: {arguments}", entry.id), + Item::ToolResult { + summary, content, .. + } => format!( + "[{} ToolOutput]\nsummary: {summary}\ncontent: {}", + entry.id, + content.as_deref().unwrap_or_default() + ), + Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id), + }; + truncate_chars(&text, max_bytes) +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + if max_chars == 0 { + return "… [truncated]".to_string(); + } + if text.chars().count() <= max_chars { + return text.to_string(); + } + let mut out = text.chars().take(max_chars).collect::(); + out.push_str("… [truncated]"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overview_contains_user_and_assistant_only() { + let view = SessionReferenceView::new( + "segment-1", + vec![ + Item::system_message("sys"), + Item::user_message("hello"), + Item::assistant_message("progress"), + Item::tool_call("c1", "Read", "{\"file\":\"x\"}"), + Item::tool_result("c1", "read ok"), + ], + ); + + let overview = view.overview(); + assert_eq!(overview.len(), 2); + assert_eq!(overview[0].kind, ReferenceKind::User); + assert_eq!(overview[1].kind, ReferenceKind::Assistant); + assert!(overview[1].text.contains("progress")); + } + + #[test] + fn search_filters_tool_input_and_output() { + let view = SessionReferenceView::new( + "segment-1", + vec![ + Item::tool_call("c1", "Read", "{\"file\":\"Cargo.toml\"}"), + Item::tool_result_with_content("c1", "read ok", "package metadata"), + ], + ); + + let input_hits = view.search(&SearchOptions { + query: "Cargo.toml".into(), + kind: Some(ReferenceKind::Tool), + tool_part: Some(ToolPart::Input), + tool_name: Some("Read".into()), + limit: None, + min_entry_index: None, + }); + assert_eq!(input_hits.len(), 1); + assert_eq!(input_hits[0].tool_part, Some(ToolPart::Input)); + + let output_hits = view.search(&SearchOptions { + query: "package metadata".into(), + kind: Some(ReferenceKind::Tool), + tool_part: Some(ToolPart::Output), + tool_name: None, + limit: None, + min_entry_index: None, + }); + assert_eq!(output_hits.len(), 1); + assert_eq!(output_hits[0].tool_part, Some(ToolPart::Output)); + } + + #[test] + fn read_by_entry_range_is_bounded_and_can_skip_tools() { + let view = SessionReferenceView::new( + "segment-1", + vec![ + Item::user_message("one"), + Item::tool_call("c1", "Read", "{}"), + Item::tool_result_with_content("c1", "ok", "large-content".repeat(100)), + Item::assistant_message("two"), + ], + ); + + let result = view.read( + ReadSelector::EntryRange([0, 3]), + ReadOptions { + include_tools: false, + max_items: 10, + max_bytes: 1_000, + ..ReadOptions::default() + }, + ); + + assert_eq!(result.entries.len(), 2); + assert!( + result + .entries + .iter() + .all(|entry| entry.kind != ReferenceKind::Tool) + ); + } + + #[test] + fn source_ref_uses_entry_range_and_evidence_id() { + let view = SessionReferenceView::new("segment-1", vec![Item::user_message("hello")]); + let source = view.source_ref_for("M0000").unwrap(); + assert_eq!(source.segment_id.as_deref(), Some("segment-1")); + assert_eq!(source.entry_range, Some([0, 0])); + assert_eq!(source.evidence_id.as_deref(), Some("M0000")); + } +}