worker: add session reference view
This commit is contained in:
parent
39fa8827da
commit
2dd9ef95dd
|
|
@ -1,9 +1,11 @@
|
||||||
---
|
---
|
||||||
title: 'Session reference viewを共通基盤として実装する'
|
title: 'Session reference viewを共通基盤として実装する'
|
||||||
state: 'ready'
|
state: 'closed'
|
||||||
created_at: '2026-07-17T23:04:40Z'
|
created_at: '2026-07-17T23:04:40Z'
|
||||||
updated_at: '2026-07-17T23:20:00Z'
|
updated_at: '2026-07-17T23:27:20Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
|
queued_by: 'yoi ticket'
|
||||||
|
queued_at: '2026-07-17T23:15:15Z'
|
||||||
---
|
---
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
|
||||||
11
.yoi/tickets/00001KXS56AS5/resolution.md
Normal file
11
.yoi/tickets/00001KXS56AS5/resolution.md
Normal file
|
|
@ -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`
|
||||||
|
|
@ -4,4 +4,84 @@
|
||||||
|
|
||||||
LocalTicketBackend によって作成されました。
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:15:15Z from: ready to: queued reason: queued field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を `yoi ticket` が queued にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:15:15Z from: queued to: inprogress reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `inprogress`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-17T23:27:20Z -->
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: "yoi ticket" at: 2026-07-17T23:27:20Z from: inprogress to: done reason: cli_state field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
State changed to `done`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: state_changed author: hare at: 2026-07-17T23:27:20Z from: done to: closed reason: closed field: state -->
|
||||||
|
|
||||||
|
## State changed
|
||||||
|
|
||||||
|
Ticket を closed にしました。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: close author: hare at: 2026-07-17T23:27:20Z status: 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`
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ use tools::ScopedFs;
|
||||||
|
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::fs_view::{ReadRequirement, slice_lines};
|
use crate::fs_view::{ReadRequirement, slice_lines};
|
||||||
|
use crate::session_reference::{ReferenceKind, SearchOptions, SessionReferenceView};
|
||||||
|
|
||||||
/// Aggregated output of a compact worker run.
|
/// Aggregated output of a compact worker run.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
|
|
@ -143,6 +144,7 @@ this to verify details before writing the summary.";
|
||||||
|
|
||||||
struct SessionLogToolState {
|
struct SessionLogToolState {
|
||||||
items: Arc<Vec<Item>>,
|
items: Arc<Vec<Item>>,
|
||||||
|
view: SessionReferenceView,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchSessionLogTool {
|
struct SearchSessionLogTool {
|
||||||
|
|
@ -165,27 +167,44 @@ impl Tool for SearchSessionLogTool {
|
||||||
"search_session_log query must not be empty".to_string(),
|
"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
|
let limit = params
|
||||||
.limit
|
.limit
|
||||||
.unwrap_or(20)
|
.unwrap_or(20)
|
||||||
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
|
||||||
let mut hits = Vec::new();
|
let hits = self.state.view.search(&SearchOptions {
|
||||||
for (idx, item) in self.state.items.iter().enumerate().skip(offset) {
|
query: params.query.clone(),
|
||||||
let haystack = session_item_search_text(item).to_lowercase();
|
kind: None,
|
||||||
if haystack.contains(&query) {
|
tool_part: None,
|
||||||
hits.push(format_session_item(
|
tool_name: None,
|
||||||
idx,
|
limit: Some(limit),
|
||||||
item,
|
min_entry_index: Some(offset as u64),
|
||||||
SessionReadMode::Compact,
|
});
|
||||||
600,
|
let blocks = hits
|
||||||
));
|
.iter()
|
||||||
if hits.len() >= limit {
|
.map(|hit| {
|
||||||
break;
|
let part = hit
|
||||||
}
|
.tool_part
|
||||||
}
|
.map(|part| format!(" {part:?}"))
|
||||||
}
|
.unwrap_or_default();
|
||||||
let mut content = hits.join("\n\n");
|
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::<Vec<_>>();
|
||||||
|
let mut content = blocks.join("\n\n");
|
||||||
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
|
||||||
let summary = if hits.is_empty() {
|
let summary = if hits.is_empty() {
|
||||||
format!("No session log hits for {query:?} from item offset {offset}.")
|
format!("No session log hits for {query:?} from item offset {offset}.")
|
||||||
|
|
@ -531,7 +550,8 @@ 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 state = Arc::new(SessionLogToolState { items });
|
let view = SessionReferenceView::new("compact-target", (*items).clone());
|
||||||
|
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);
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
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<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 state = Arc::new(SessionLogToolState { items });
|
let view = SessionReferenceView::new("compact-target", (*items).clone());
|
||||||
|
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);
|
||||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
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",
|
"very large raw trace body with secret detail",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
let view = SessionReferenceView::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 }),
|
state: Arc::new(SessionLogToolState { items, view }),
|
||||||
});
|
});
|
||||||
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
|
||||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
let out = tool.execute(&input, Default::default()).await.unwrap();
|
||||||
|
|
@ -858,8 +880,9 @@ mod tests {
|
||||||
"read trace",
|
"read trace",
|
||||||
"raw trace detail",
|
"raw trace detail",
|
||||||
)]);
|
)]);
|
||||||
|
let view = SessionReferenceView::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 }),
|
state: Arc::new(SessionLogToolState { items, view }),
|
||||||
});
|
});
|
||||||
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
|
||||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
let out = tool.execute(&input, Default::default()).await.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +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;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
|
|
||||||
514
crates/worker/src/session_reference.rs
Normal file
514
crates/worker/src/session_reference.rs
Normal file
|
|
@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<ToolPart>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
pub label: String,
|
||||||
|
pub summary: String,
|
||||||
|
search_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct SearchOptions {
|
||||||
|
pub query: String,
|
||||||
|
pub kind: Option<ReferenceKind>,
|
||||||
|
pub tool_part: Option<ToolPart>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
pub min_entry_index: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct SearchHit {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: ReferenceKind,
|
||||||
|
pub tool_part: Option<ToolPart>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
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<ToolPart>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
pub entry_range: [u64; 2],
|
||||||
|
pub label: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ReadResult {
|
||||||
|
pub entries: Vec<ReadEntry>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct SessionReferenceView {
|
||||||
|
segment_id: String,
|
||||||
|
items: Arc<Vec<Item>>,
|
||||||
|
overview: Vec<OverviewItem>,
|
||||||
|
index: Vec<ReferenceEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionReferenceView {
|
||||||
|
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> 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::<Vec<_>>()
|
||||||
|
.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<SearchHit> {
|
||||||
|
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<SourceEvidenceRef> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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::<String>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user