yoi/crates/worker/src/feature/builtin/session_explore.rs

674 lines
22 KiB
Rust

use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use memory::extract::{
CandidateKind, ExtractedCandidate, StagingWriteResult, write_staging_candidate,
};
use memory::schema::SourceRef;
use memory::workspace::WorkspaceLayout;
use schemars::JsonSchema;
use serde::Deserialize;
use uuid::Uuid;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::session_reference::{
ReadDetail, ReadOptions, ReadSelector, ReferenceKind, SearchOptions, SessionReferenceView,
ToolPart,
};
const SEARCH_EVIDENCE_DESCRIPTION: &str = "Search the host-created session evidence index. Use this to find stable evidence ids before staging a memory candidate. Supports kind=user|assistant|system|tool and tool_part=input|output|both.";
const READ_EVIDENCE_DESCRIPTION: &str = "Read bounded session evidence by evidence_id or entry_range. Use compact mode for normal verification and full mode only when exact tool arguments or result content are necessary.";
const STAGE_CANDIDATE_DESCRIPTION: &str = "Stage one memory candidate as a flat staging record. The candidate must cite one or more evidence_ids returned by search_evidence/read_evidence.";
const FINISH_EXTRACTION_DESCRIPTION: &str = "Finish the extract worker run after all useful candidates have been staged, or state why no candidates were useful.";
#[derive(Clone)]
pub(crate) struct SessionExploreState {
view: Arc<SessionReferenceView>,
layout: WorkspaceLayout,
source: SourceRef,
extract_run_id: String,
staged: Arc<Mutex<Vec<StagingWriteResult>>>,
finished: Arc<Mutex<Option<FinishExtractionParams>>>,
}
impl SessionExploreState {
pub(crate) fn new(
view: SessionReferenceView,
layout: WorkspaceLayout,
source: SourceRef,
) -> Self {
Self {
view: Arc::new(view),
layout,
source,
extract_run_id: Uuid::now_v7().to_string(),
staged: Arc::new(Mutex::new(Vec::new())),
finished: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn view(&self) -> &SessionReferenceView {
&self.view
}
pub(crate) fn staged(&self) -> Vec<StagingWriteResult> {
self.staged
.lock()
.expect("session explore staged state poisoned")
.clone()
}
pub(crate) fn is_finished(&self) -> bool {
self.finished
.lock()
.expect("session explore finished state poisoned")
.is_some()
}
}
#[derive(Clone)]
pub(crate) struct SessionExploreFeature {
state: SessionExploreState,
}
impl SessionExploreFeature {
pub(crate) fn new(state: SessionExploreState) -> Self {
Self { state }
}
}
impl FeatureModule for SessionExploreFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("session-explore", "Session Explore")
.with_description(
"Host-bounded session evidence search/read tools plus explicit extraction staging.",
)
.with_tool(ToolDeclaration::new(
"search_evidence",
SEARCH_EVIDENCE_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(
"read_evidence",
READ_EVIDENCE_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(
"stage_candidate",
STAGE_CANDIDATE_DESCRIPTION,
))
.with_tool(ToolDeclaration::new(
"finish_extraction",
FINISH_EXTRACTION_DESCRIPTION,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
"search_evidence",
search_evidence_definition(self.state.clone()),
))?;
context.tools().register(ToolContribution::new(
"read_evidence",
read_evidence_definition(self.state.clone()),
))?;
context.tools().register(ToolContribution::new(
"stage_candidate",
stage_candidate_definition(self.state.clone()),
))?;
context.tools().register(ToolContribution::new(
"finish_extraction",
finish_extraction_definition(self.state.clone()),
))?;
Ok(())
}
}
fn search_evidence_definition(state: SessionExploreState) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(SearchEvidenceParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("search_evidence")
.description(SEARCH_EVIDENCE_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SearchEvidenceTool {
state: state.clone(),
});
(meta, tool)
})
}
fn read_evidence_definition(state: SessionExploreState) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(ReadEvidenceParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("read_evidence")
.description(READ_EVIDENCE_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(ReadEvidenceTool {
state: state.clone(),
});
(meta, tool)
})
}
fn stage_candidate_definition(state: SessionExploreState) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(StageCandidateParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("stage_candidate")
.description(STAGE_CANDIDATE_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(StageCandidateTool {
state: state.clone(),
});
(meta, tool)
})
}
fn finish_extraction_definition(state: SessionExploreState) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(FinishExtractionParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("finish_extraction")
.description(FINISH_EXTRACTION_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(FinishExtractionTool {
state: state.clone(),
});
(meta, tool)
})
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchEvidenceParams {
/// Case-insensitive substring. Empty query lists bounded index entries matching filters.
#[serde(default)]
query: String,
/// Optional evidence kind: user, assistant, system, tool.
#[serde(default)]
kind: Option<String>,
/// Optional tool part filter for tool evidence: input, output, both.
#[serde(default)]
tool_part: Option<String>,
/// Optional tool name filter for tool input entries.
#[serde(default)]
tool_name: Option<String>,
/// 0-based session item offset to start from.
#[serde(default)]
offset: Option<usize>,
/// Maximum number of hits.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ReadEvidenceParams {
/// Evidence id returned by search_evidence, e.g. M0001, T0002i, T0003o.
#[serde(default)]
evidence_id: Option<String>,
/// Inclusive 0-based session item range. Use when search returned an entry_range instead of a single id.
#[serde(default)]
entry_range: Option<[u64; 2]>,
/// compact omits tool arguments/results; full includes them within host bounds.
#[serde(default = "default_read_mode")]
mode: String,
/// Maximum entries to return.
#[serde(default)]
max_items: Option<usize>,
}
fn default_read_mode() -> String {
"compact".to_string()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct StageCandidateParams {
kind: CandidateKind,
claim: String,
why_useful: String,
#[serde(default)]
staleness: Option<String>,
#[serde(default)]
evidence_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
struct FinishExtractionParams {
staged_count: usize,
#[serde(default)]
no_candidates_reason: Option<String>,
}
struct SearchEvidenceTool {
state: SessionExploreState,
}
#[async_trait]
impl Tool for SearchEvidenceTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SearchEvidenceParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid search_evidence input: {e}"))
})?;
let kind = params
.kind
.as_deref()
.map(parse_reference_kind)
.transpose()?;
let tool_part = params
.tool_part
.as_deref()
.map(parse_tool_part)
.transpose()?;
let hits = self.state.view.search(&SearchOptions {
query: params.query,
kind,
tool_part,
tool_name: params.tool_name,
limit: params.limit,
min_entry_index: params.offset.map(|offset| offset as u64),
});
let content = 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::<Vec<_>>()
.join("\n\n");
Ok(ToolOutput {
summary: format!("Found {} evidence hit(s).", hits.len()),
content: (!content.is_empty()).then_some(content),
})
}
}
struct ReadEvidenceTool {
state: SessionExploreState,
}
#[async_trait]
impl Tool for ReadEvidenceTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadEvidenceParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid read_evidence input: {e}")))?;
let selector = match (params.evidence_id.as_deref(), params.entry_range) {
(Some(id), None) => ReadSelector::Id(id),
(None, Some(range)) => ReadSelector::EntryRange(range),
_ => {
return Err(ToolError::InvalidArgument(
"read_evidence requires exactly one of evidence_id or entry_range".to_string(),
));
}
};
let detail = parse_read_detail(&params.mode)?;
let read = self.state.view.read(
selector,
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail,
max_items: params.max_items.unwrap_or(10),
max_bytes: 16 * 1024,
},
);
let content = read
.entries
.iter()
.map(|entry| {
let part = entry
.tool_part
.map(|part| format!(" {part:?}"))
.unwrap_or_default();
let tool = entry
.tool_name
.as_ref()
.map(|name| format!(" {name}"))
.unwrap_or_default();
format!(
"[{} {}{}{} {:?}] {}\n{}",
entry.id,
entry.kind.as_str(),
part,
tool,
entry.entry_range,
entry.label,
entry.text
)
})
.collect::<Vec<_>>()
.join("\n\n");
let summary = if read.truncated {
format!(
"Read {} evidence entrie(s); output truncated.",
read.entries.len()
)
} else {
format!("Read {} evidence entrie(s).", read.entries.len())
};
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
})
}
}
struct StageCandidateTool {
state: SessionExploreState,
}
#[async_trait]
impl Tool for StageCandidateTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: StageCandidateParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid stage_candidate input: {e}"))
})?;
if params.evidence_ids.is_empty() {
return Err(ToolError::InvalidArgument(
"stage_candidate requires at least one evidence_id".to_string(),
));
}
let mut evidence = Vec::with_capacity(params.evidence_ids.len());
let mut source_refs = Vec::with_capacity(params.evidence_ids.len());
for id in &params.evidence_ids {
let staging =
self.state.view.staging_evidence_for(id).ok_or_else(|| {
ToolError::InvalidArgument(format!("unknown evidence_id {id:?}"))
})?;
let source_ref =
self.state.view.source_ref_for(id).ok_or_else(|| {
ToolError::InvalidArgument(format!("unknown evidence_id {id:?}"))
})?;
evidence.push(staging);
source_refs.push(source_ref);
}
let candidate = ExtractedCandidate {
kind: params.kind,
claim: params.claim,
why_useful: params.why_useful,
staleness: params.staleness,
evidence_ids: params.evidence_ids,
};
let written = write_staging_candidate(
&self.state.layout,
self.state.source.clone(),
&self.state.extract_run_id,
candidate,
evidence,
source_refs,
)
.map_err(|e| ToolError::ExecutionFailed(format!("write staging failed: {e}")))?;
let id = written.id.to_string();
self.state
.staged
.lock()
.expect("session explore staged state poisoned")
.push(written);
Ok(ToolOutput {
summary: format!("Staged memory candidate {id}."),
content: Some(format!("staging_id: {id}")),
})
}
}
struct FinishExtractionTool {
state: SessionExploreState,
}
#[async_trait]
impl Tool for FinishExtractionTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: FinishExtractionParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid finish_extraction input: {e}"))
})?;
let actual = self
.state
.staged
.lock()
.expect("session explore staged state poisoned")
.len();
if params.staged_count != actual {
return Err(ToolError::InvalidArgument(format!(
"finish_extraction staged_count {} does not match actual staged count {actual}",
params.staged_count
)));
}
let reason = params.no_candidates_reason.clone();
*self
.state
.finished
.lock()
.expect("session explore finished state poisoned") = Some(params);
Ok(ToolOutput {
summary: reason
.map(|reason| {
format!("Finished extraction with {actual} staged candidate(s): {reason}")
})
.unwrap_or_else(|| {
format!("Finished extraction with {actual} staged candidate(s).")
}),
content: None,
})
}
}
fn parse_reference_kind(value: &str) -> Result<ReferenceKind, ToolError> {
ReferenceKind::parse(value).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"invalid kind {value:?}; expected user, assistant/agent, system, or tool"
))
})
}
fn parse_tool_part(value: &str) -> Result<ToolPart, ToolError> {
ToolPart::parse(value).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"invalid tool_part {value:?}; expected input, output, or both"
))
})
}
fn parse_read_detail(value: &str) -> Result<ReadDetail, ToolError> {
match value {
"compact" => Ok(ReadDetail::Compact),
"full" => Ok(ReadDetail::Full),
other => Err(ToolError::InvalidArgument(format!(
"invalid read mode {other:?}; expected compact or full"
))),
}
}
pub(crate) fn render_extract_input(view: &SessionReferenceView) -> String {
let mut out = String::new();
out.push_str("# Session overview\n\n");
if view.overview().is_empty() {
out.push_str("No user/assistant overview entries are available.\n\n");
} else {
for item in view.overview() {
out.push_str(&format!(
"- [{} {} {:?}] {}\n {}\n",
item.id,
item.kind.as_str(),
item.entry_range,
item.label,
truncate_line(&item.text, 500)
));
}
out.push('\n');
}
out.push_str("# Initial evidence index\n\n");
out.push_str(
"Use search_evidence/read_evidence to inspect details. Cite only M/T evidence ids in stage_candidate.evidence_ids.\n\n",
);
let hits = view.search(&SearchOptions {
query: String::new(),
kind: None,
tool_part: None,
tool_name: None,
limit: Some(50),
min_entry_index: None,
});
for hit in hits {
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();
out.push_str(&format!(
"- [{} {}{}{} {:?}] {}{}\n",
hit.id,
hit.kind.as_str(),
part,
tool,
hit.entry_range,
hit.label,
hit.summary
));
}
out
}
fn truncate_line(text: &str, max_chars: usize) -> String {
let normalized = text.replace('\n', " ");
if normalized.chars().count() <= max_chars {
normalized
} else {
let mut out = normalized.chars().take(max_chars).collect::<String>();
out.push_str("");
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use llm_engine::Item;
use tempfile::TempDir;
#[test]
fn descriptor_declares_session_explore_tools() {
let temp = TempDir::new().unwrap();
let state = SessionExploreState::new(
SessionReferenceView::new("segment-1", vec![Item::user_message("remember this")]),
WorkspaceLayout::new(temp.path()),
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
);
let descriptor = SessionExploreFeature::new(state).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:session-explore");
let names = descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"search_evidence",
"read_evidence",
"stage_candidate",
"finish_extraction"
]
);
}
#[test]
fn render_extract_input_exposes_overview_and_evidence_ids() {
let view = SessionReferenceView::new(
"segment-1",
vec![
Item::user_message("user preference"),
Item::assistant_message("assistant reply"),
Item::tool_call("c1", "Read", "{}"),
Item::tool_result_with_content("c1", "read ok", "file body"),
],
);
let input = render_extract_input(&view);
assert!(input.contains("# Session overview"));
assert!(input.contains("M0000"));
assert!(input.contains("T0002i"));
assert!(input.contains("T0003o"));
assert!(input.contains("stage_candidate.evidence_ids"));
}
#[tokio::test]
async fn stage_candidate_writes_staging_record_with_source_evidence() {
let temp = TempDir::new().unwrap();
let state = SessionExploreState::new(
SessionReferenceView::new("segment-1", vec![Item::user_message("durable decision")]),
WorkspaceLayout::new(temp.path()),
SourceRef {
segment_id: "segment-1".to_string(),
range: [0, 0],
},
);
let tool = StageCandidateTool {
state: state.clone(),
};
tool.execute(
&serde_json::json!({
"kind": "decision",
"claim": "Use host-created evidence anchors for extract staging.",
"why_useful": "Future consolidation can trust bounded source refs.",
"evidence_ids": ["M0000"]
})
.to_string(),
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.unwrap();
let staged = state.staged();
assert_eq!(staged.len(), 1);
let bytes = std::fs::read(&staged[0].path).unwrap();
let record: memory::extract::StagingRecord = serde_json::from_slice(&bytes).unwrap();
assert_eq!(record.kind, CandidateKind::Decision);
assert_eq!(record.evidence.len(), 1);
assert_eq!(record.evidence[0].id, "M0000");
assert_eq!(record.source_refs.len(), 1);
assert_eq!(record.source_refs[0].evidence_id.as_deref(), Some("M0000"));
}
}