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, layout: WorkspaceLayout, source: SourceRef, extract_run_id: String, staged: Arc>>, finished: Arc>>, } 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 { 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 = 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 = 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 = 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 = 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, /// Optional tool part filter for tool evidence: input, output, both. #[serde(default)] tool_part: Option, /// Optional tool name filter for tool input entries. #[serde(default)] tool_name: Option, /// 0-based session item offset to start from. #[serde(default)] offset: Option, /// Maximum number of hits. #[serde(default)] limit: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct ReadEvidenceParams { /// Evidence id returned by search_evidence, e.g. M0001, T0002i, T0003o. #[serde(default)] evidence_id: Option, /// 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, } 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, #[serde(default)] evidence_ids: Vec, } #[derive(Debug, Clone, Deserialize, JsonSchema)] struct FinishExtractionParams { staged_count: usize, #[serde(default)] no_candidates_reason: Option, } struct SearchEvidenceTool { state: SessionExploreState, } #[async_trait] impl Tool for SearchEvidenceTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { 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::>() .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 { 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(¶ms.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::>() .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 { 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 ¶ms.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 { 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::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::parse(value).ok_or_else(|| { ToolError::InvalidArgument(format!( "invalid tool_part {value:?}; expected input, output, or both" )) }) } fn parse_read_detail(value: &str) -> Result { 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::(); 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::>(); 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")); } }