From 13ce5ad5f4f53cc0f7192e115c632a56ec8efbcf Mon Sep 17 00:00:00 2001 From: Nigel Coleman Date: Sun, 22 Mar 2026 14:55:31 +0000 Subject: [PATCH 1/3] feat(workspace): add tiered context summaries --- FEATURE_PARITY.md | 1 + .../V14__workspace_tiered_summaries.sql | 81 ++++++ migrations/V1__initial.sql | 4 +- src/app.rs | 17 +- src/db/libsql/mod.rs | 8 +- src/db/libsql/workspace.rs | 42 ++- src/db/libsql_migrations.rs | 2 + src/db/mod.rs | 6 + src/db/postgres.rs | 11 + src/tools/builtin/memory.rs | 33 ++- src/workspace/README.md | 3 +- src/workspace/document.rs | 10 +- src/workspace/mod.rs | 247 +++++++++++++++++- src/workspace/repository.rs | 40 ++- src/workspace/search.rs | 134 +++++++++- tests/workspace_integration.rs | 135 ++++++++++ 16 files changed, 748 insertions(+), 26 deletions(-) create mode 100644 migrations/V14__workspace_tiered_summaries.sql diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 34040379bc8..f20093dfa0b 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -346,6 +346,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Vector memory | ✅ | ✅ | pgvector | | Session-based memory | ✅ | ✅ | | | Hybrid search (BM25 + vector) | ✅ | ✅ | RRF algorithm | +| Tiered context summaries (L0/L1) | ✅ | 🚧 | Workspace docs now store/generate summaries and search defaults to L1 | | Temporal decay (hybrid search) | ✅ | ❌ | Opt-in time-based scoring factor | | MMR re-ranking | ✅ | ❌ | Maximal marginal relevance for result diversity | | LLM-based query expansion | ✅ | ❌ | Expand FTS queries via LLM | diff --git a/migrations/V14__workspace_tiered_summaries.sql b/migrations/V14__workspace_tiered_summaries.sql new file mode 100644 index 00000000000..43c37687ad2 --- /dev/null +++ b/migrations/V14__workspace_tiered_summaries.sql @@ -0,0 +1,81 @@ +ALTER TABLE memory_documents ADD COLUMN summary_l0 TEXT; +ALTER TABLE memory_documents ADD COLUMN summary_l1 TEXT; + +CREATE OR REPLACE FUNCTION list_workspace_files( + p_user_id TEXT, + p_agent_id UUID, + p_directory TEXT DEFAULT '' +) +RETURNS TABLE ( + path TEXT, + is_directory BOOLEAN, + updated_at TIMESTAMPTZ, + content_preview TEXT +) AS $$ +BEGIN + -- Normalize directory path (ensure trailing slash for non-root) + IF p_directory != '' AND NOT p_directory LIKE '%/' THEN + p_directory := p_directory || '/'; + END IF; + + RETURN QUERY + WITH files AS ( + SELECT + d.path, + d.updated_at, + COALESCE(d.summary_l0, LEFT(d.content, 200)) as content_preview, + -- Extract the immediate child name + CASE + WHEN p_directory = '' THEN + CASE + WHEN position('/' in d.path) > 0 + THEN substring(d.path from 1 for position('/' in d.path) - 1) + ELSE d.path + END + ELSE + CASE + WHEN position('/' in substring(d.path from length(p_directory) + 1)) > 0 + THEN substring( + substring(d.path from length(p_directory) + 1) + from 1 + for position('/' in substring(d.path from length(p_directory) + 1)) - 1 + ) + ELSE substring(d.path from length(p_directory) + 1) + END + END as child_name + FROM memory_documents d + WHERE d.user_id = p_user_id + AND d.agent_id IS NOT DISTINCT FROM p_agent_id + AND (p_directory = '' OR d.path LIKE p_directory || '%') + ) + SELECT DISTINCT ON (f.child_name) + CASE + WHEN p_directory = '' THEN f.child_name + ELSE p_directory || f.child_name + END as path, + EXISTS ( + SELECT 1 FROM memory_documents d2 + WHERE d2.user_id = p_user_id + AND d2.agent_id IS NOT DISTINCT FROM p_agent_id + AND d2.path LIKE + CASE WHEN p_directory = '' THEN f.child_name ELSE p_directory || f.child_name END + || '/%' + ) as is_directory, + MAX(f.updated_at) as updated_at, + CASE + WHEN EXISTS ( + SELECT 1 FROM memory_documents d2 + WHERE d2.user_id = p_user_id + AND d2.agent_id IS NOT DISTINCT FROM p_agent_id + AND d2.path LIKE + CASE WHEN p_directory = '' THEN f.child_name ELSE p_directory || f.child_name END + || '/%' + ) THEN NULL + ELSE MAX(f.content_preview) + END as content_preview + FROM files f + WHERE f.child_name != '' AND f.child_name IS NOT NULL + GROUP BY f.child_name + ORDER BY f.child_name, is_directory DESC; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/V1__initial.sql b/migrations/V1__initial.sql index ea04b9b89c7..85db04c6754 100644 --- a/migrations/V1__initial.sql +++ b/migrations/V1__initial.sql @@ -172,6 +172,8 @@ CREATE TABLE memory_documents ( -- File path within workspace (e.g., "context/vision.md") path TEXT NOT NULL, content TEXT NOT NULL, + summary_l0 TEXT, + summary_l1 TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -266,7 +268,7 @@ BEGIN SELECT d.path, d.updated_at, - LEFT(d.content, 200) as content_preview, + COALESCE(d.summary_l0, LEFT(d.content, 200)) as content_preview, -- Extract the immediate child name CASE WHEN p_directory = '' THEN diff --git a/src/app.rs b/src/app.rs index 41dbd95fd95..f021756c465 100644 --- a/src/app.rs +++ b/src/app.rs @@ -310,6 +310,7 @@ impl AppBuilder { pub async fn init_tools( &self, llm: &Arc, + cheap_llm: Option<&Arc>, ) -> Result< ( Arc, @@ -374,6 +375,7 @@ impl AppBuilder { "Workspace configured with multi-scope reads" ); } + ws = ws.with_llm(cheap_llm.cloned().unwrap_or_else(|| Arc::clone(llm))); ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone()); let ws = Arc::new(ws); @@ -860,7 +862,7 @@ impl AppBuilder { self.init_llm().await? }; let (safety, tools, embeddings, workspace, builder, credential_registry) = - self.init_tools(&llm).await?; + self.init_tools(&llm, cheap_llm.as_ref()).await?; // Create hook registry early so runtime extension activation can register hooks. let hooks = Arc::new(HookRegistry::new()); @@ -935,6 +937,19 @@ impl AppBuilder { } }); } + + let ws_bg = Arc::clone(ws); + tokio::spawn(async move { + match ws_bg.backfill_summaries().await { + Ok(count) if count > 0 => { + tracing::debug!("Backfilled tiered summaries for {} documents", count); + } + Ok(_) => {} + Err(e) => { + tracing::warn!("Failed to backfill tiered summaries: {}", e); + } + } + }); } // Skills system diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 8db9c7d69e8..feab84cd444 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -410,9 +410,11 @@ pub(crate) fn row_to_memory_document(row: &libsql::Row) -> MemoryDocument { agent_id: get_opt_text(row, 2).and_then(|s| s.parse().ok()), path: get_text(row, 3), content: get_text(row, 4), - created_at: get_ts(row, 5), - updated_at: get_ts(row, 6), - metadata: get_json(row, 7), + summary_l0: get_opt_text(row, 5), + summary_l1: get_opt_text(row, 6), + created_at: get_ts(row, 7), + updated_at: get_ts(row, 8), + metadata: get_json(row, 9), } } diff --git a/src/db/libsql/workspace.rs b/src/db/libsql/workspace.rs index 67c897e1885..e82fcd8798e 100644 --- a/src/db/libsql/workspace.rs +++ b/src/db/libsql/workspace.rs @@ -259,7 +259,7 @@ impl WorkspaceStore for LibSqlBackend { .query( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 AND path = ?3 "#, @@ -295,7 +295,7 @@ impl WorkspaceStore for LibSqlBackend { .query( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE id = ?1 "#, params![id.to_string()], @@ -366,7 +366,7 @@ impl WorkspaceStore for LibSqlBackend { })?; let now = fmt_ts(&Utc::now()); conn.execute( - "UPDATE memory_documents SET content = ?2, updated_at = ?3 WHERE id = ?1", + "UPDATE memory_documents SET content = ?2, summary_l0 = NULL, summary_l1 = NULL, updated_at = ?3 WHERE id = ?1", params![id.to_string(), content, now], ) .await @@ -376,6 +376,29 @@ impl WorkspaceStore for LibSqlBackend { Ok(()) } + async fn update_document_summaries( + &self, + id: Uuid, + summary_l0: Option<&str>, + summary_l1: Option<&str>, + ) -> Result<(), WorkspaceError> { + let conn = self + .connect() + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: e.to_string(), + })?; + conn.execute( + "UPDATE memory_documents SET summary_l0 = ?2, summary_l1 = ?3 WHERE id = ?1", + params![id.to_string(), summary_l0, summary_l1], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Summary update failed: {}", e), + })?; + Ok(()) + } + async fn delete_document_by_path( &self, user_id: &str, @@ -431,7 +454,8 @@ impl WorkspaceStore for LibSqlBackend { let mut rows = conn .query( r#" - SELECT path, updated_at, substr(content, 1, 200) as content_preview + SELECT path, updated_at, + COALESCE(summary_l0, substr(content, 1, 200)) as content_preview FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 AND (?3 = '%' OR path LIKE ?3) @@ -559,7 +583,7 @@ impl WorkspaceStore for LibSqlBackend { .query( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 ORDER BY updated_at DESC @@ -739,7 +763,7 @@ impl WorkspaceStore for LibSqlBackend { let mut rows = conn .query( r#" - SELECT c.id, c.document_id, d.path, c.content + SELECT c.id, c.document_id, d.path, c.content, d.summary_l0, d.summary_l1 FROM memory_chunks_fts fts JOIN memory_chunks c ON c._rowid = fts.rowid JOIN memory_documents d ON d.id = c.document_id @@ -768,6 +792,8 @@ impl WorkspaceStore for LibSqlBackend { document_id: get_text(&row, 1).parse().unwrap_or_default(), document_path: get_text(&row, 2), content: get_text(&row, 3), + summary_l0: get_opt_text(&row, 4), + summary_l1: get_opt_text(&row, 5), rank: results.len() as u32 + 1, }); } @@ -791,7 +817,7 @@ impl WorkspaceStore for LibSqlBackend { match conn .query( r#" - SELECT c.id, c.document_id, d.path, c.content + SELECT c.id, c.document_id, d.path, c.content, d.summary_l0, d.summary_l1 FROM vector_top_k('idx_memory_chunks_embedding', vector(?1), ?2) AS top_k JOIN memory_chunks c ON c._rowid = top_k.id JOIN memory_documents d ON d.id = c.document_id @@ -815,6 +841,8 @@ impl WorkspaceStore for LibSqlBackend { document_id: get_text(&row, 1).parse().unwrap_or_default(), document_path: get_text(&row, 2), content: get_text(&row, 3), + summary_l0: get_opt_text(&row, 4), + summary_l1: get_opt_text(&row, 5), rank: results.len() as u32 + 1, }); } diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index 7fcb689901d..7575c186ca8 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -208,6 +208,8 @@ CREATE TABLE IF NOT EXISTS memory_documents ( agent_id TEXT, path TEXT NOT NULL, content TEXT NOT NULL, + summary_l0 TEXT, + summary_l1 TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), metadata TEXT NOT NULL DEFAULT '{}', diff --git a/src/db/mod.rs b/src/db/mod.rs index 5895c042f8b..4467ce18750 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -692,6 +692,12 @@ pub trait WorkspaceStore: Send + Sync { path: &str, ) -> Result; async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError>; + async fn update_document_summaries( + &self, + id: Uuid, + summary_l0: Option<&str>, + summary_l1: Option<&str>, + ) -> Result<(), WorkspaceError>; async fn delete_document_by_path( &self, user_id: &str, diff --git a/src/db/postgres.rs b/src/db/postgres.rs index b6c11b4cbaa..96c53ef244c 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -744,6 +744,17 @@ impl WorkspaceStore for PgBackend { self.repo.update_document(id, content).await } + async fn update_document_summaries( + &self, + id: Uuid, + summary_l0: Option<&str>, + summary_l1: Option<&str>, + ) -> Result<(), WorkspaceError> { + self.repo + .update_document_summaries(id, summary_l0, summary_l1) + .await + } + async fn delete_document_by_path( &self, user_id: &str, diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index 003ba45154c..8a795b7c476 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -19,7 +19,7 @@ use async_trait::async_trait; use crate::context::JobContext; use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; -use crate::workspace::{Workspace, paths}; +use crate::workspace::{SearchDetailLevel, Workspace, paths}; // ── WorkspaceResolver ────────────────────────────────────────────── @@ -127,7 +127,7 @@ impl Tool for MemorySearchTool { fn description(&self) -> &str { "Search past memories, decisions, and context. MUST be called before answering \ questions about prior work, decisions, dates, people, preferences, or todos. \ - Returns relevant snippets with relevance scores." + Returns relevant snippets with relevance scores and tiered summaries." } fn parameters_schema(&self) -> serde_json::Value { @@ -144,6 +144,12 @@ impl Tool for MemorySearchTool { "default": 5, "minimum": 1, "maximum": 20 + }, + "detail": { + "type": "string", + "enum": ["l0", "l1", "l2"], + "description": "Which summary tier to return as the content field (default: l1)", + "default": "l1" } }, "required": ["query"] @@ -166,8 +172,27 @@ impl Tool for MemorySearchTool { .min(20) as usize; let workspace = self.resolver.resolve(&ctx.user_id).await; + let detail = match params + .get("detail") + .and_then(|v| v.as_str()) + .unwrap_or("l1") + { + "l0" => SearchDetailLevel::L0, + "l1" => SearchDetailLevel::L1, + "l2" => SearchDetailLevel::L2, + other => { + return Err(ToolError::InvalidParameters(format!( + "invalid detail '{other}', expected one of: l0, l1, l2" + ))); + } + }; + + let mut search_config = workspace.search_defaults().clone(); + search_config.limit = limit; + search_config.detail = detail; + let results = workspace - .search(query, limit) + .search_with_config(query, search_config) .await .map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {}", e)))?; @@ -176,6 +201,8 @@ impl Tool for MemorySearchTool { "query": query, "results": results.into_iter().map(|r| serde_json::json!({ "content": r.content, + "summary_l0": r.summary_l0, + "summary_l1": r.summary_l1, "score": r.score, "path": r.document_path, "document_id": r.document_id.to_string(), diff --git a/src/workspace/README.md b/src/workspace/README.md index 88cc2566a4d..b0ebba47073 100644 --- a/src/workspace/README.md +++ b/src/workspace/README.md @@ -8,6 +8,7 @@ Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace prov 2. **Flexible structure** - Create any directory/file hierarchy you need 3. **Self-documenting** - Use README.md files to describe directory structure 4. **Hybrid search** - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion +5. **Tiered summaries** - Search can return L0 abstracts, L1 overviews, or L2 raw chunks ## Filesystem Structure @@ -61,7 +62,7 @@ workspace.append_daily_log("Session note").await?; // List directory contents let entries = workspace.list("projects/").await?; -// Search (hybrid FTS + vector) +// Search (hybrid FTS + vector, L1 summaries by default) let results = workspace.search("dark mode preference", 5).await?; // Get system prompt from identity files diff --git a/src/workspace/document.rs b/src/workspace/document.rs index 93a12215221..ae8ed4bac16 100644 --- a/src/workspace/document.rs +++ b/src/workspace/document.rs @@ -217,6 +217,10 @@ pub struct MemoryDocument { pub path: String, /// Full document content. pub content: String, + /// Short one-line summary for directory listings and L0 search. + pub summary_l0: Option, + /// Structured overview for default search results. + pub summary_l1: Option, /// Creation timestamp. pub created_at: DateTime, /// Last update timestamp. @@ -239,6 +243,8 @@ impl MemoryDocument { agent_id, path: path.into(), content: String::new(), + summary_l0: None, + summary_l1: None, created_at: now, updated_at: now, metadata: serde_json::Value::Object(serde_json::Map::new()), @@ -281,7 +287,7 @@ pub struct WorkspaceEntry { pub is_directory: bool, /// Last update timestamp (latest among children for directories). pub updated_at: Option>, - /// Preview of content (first ~200 chars, None for directories). + /// Preview of content or L0 summary (None for directories). pub content_preview: Option, } @@ -375,6 +381,8 @@ mod tests { assert_eq!(doc.user_id, "user1"); assert_eq!(doc.path, "context/vision.md"); assert!(doc.content.is_empty()); + assert!(doc.summary_l0.is_none()); + assert!(doc.summary_l1.is_none()); } #[test] diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 199c9bf7385..7825f033f04 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -66,7 +66,8 @@ pub use embeddings::{ #[cfg(feature = "postgres")] pub use repository::Repository; pub use search::{ - FusionStrategy, RankedResult, SearchConfig, SearchResult, fuse_results, reciprocal_rank_fusion, + FusionStrategy, RankedResult, SearchConfig, SearchDetailLevel, SearchResult, fuse_results, + reciprocal_rank_fusion, }; /// Result of a layer-aware write operation. @@ -88,6 +89,7 @@ use deadpool_postgres::Pool; use uuid::Uuid; use crate::error::WorkspaceError; +use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; use ironclaw_safety::{Sanitizer, Severity}; /// Files injected into the system prompt. Writes to these are scanned for @@ -210,6 +212,25 @@ impl WorkspaceStorage { } } + async fn update_document_summaries( + &self, + id: Uuid, + summary_l0: Option<&str>, + summary_l1: Option<&str>, + ) -> Result<(), WorkspaceError> { + match self { + #[cfg(feature = "postgres")] + Self::Repo(repo) => { + repo.update_document_summaries(id, summary_l0, summary_l1) + .await + } + Self::Db(db) => { + db.update_document_summaries(id, summary_l0, summary_l1) + .await + } + } + } + async fn delete_document_by_path( &self, user_id: &str, @@ -370,6 +391,18 @@ impl WorkspaceStorage { } } + async fn list_documents( + &self, + user_id: &str, + agent_id: Option, + ) -> Result, WorkspaceError> { + match self { + #[cfg(feature = "postgres")] + Self::Repo(repo) => repo.list_documents(user_id, agent_id).await, + Self::Db(db) => db.list_documents(user_id, agent_id).await, + } + } + // ==================== Metadata ==================== async fn update_document_metadata( @@ -505,6 +538,8 @@ pub struct Workspace { storage: WorkspaceStorage, /// Embedding provider for semantic search. embeddings: Option>, + /// Optional LLM provider for summary generation/backfill. + summary_llm: Option>, /// Set by `seed_if_empty()` when BOOTSTRAP.md is freshly seeded. /// The agent loop checks and clears this to send a proactive greeting. bootstrap_pending: std::sync::atomic::AtomicBool, @@ -532,6 +567,7 @@ impl Workspace { agent_id: None, storage: WorkspaceStorage::Repo(Repository::new(pool)), embeddings: None, + summary_llm: None, bootstrap_pending: std::sync::atomic::AtomicBool::new(false), bootstrap_completed: std::sync::atomic::AtomicBool::new(false), search_defaults: SearchConfig::default(), @@ -552,6 +588,7 @@ impl Workspace { agent_id: None, storage: WorkspaceStorage::Db(db), embeddings: None, + summary_llm: None, bootstrap_pending: std::sync::atomic::AtomicBool::new(false), bootstrap_completed: std::sync::atomic::AtomicBool::new(false), search_defaults: SearchConfig::default(), @@ -618,6 +655,12 @@ impl Workspace { self } + /// Set the optional LLM provider used for summary generation. + pub fn with_llm(mut self, provider: Arc) -> Self { + self.summary_llm = Some(provider); + self + } + /// Set the default search configuration from workspace search config. pub fn with_search_config(mut self, config: &crate::config::WorkspaceSearchConfig) -> Self { self.search_defaults = SearchConfig::default() @@ -628,6 +671,11 @@ impl Workspace { self } + /// Get the default search configuration applied to workspace queries. + pub fn search_defaults(&self) -> &SearchConfig { + &self.search_defaults + } + /// Configure memory layers for this workspace. /// /// Also updates read_user_ids to include all layer scopes. @@ -712,6 +760,7 @@ impl Workspace { agent_id: self.agent_id, storage: self.storage.clone(), embeddings: self.embeddings.clone(), + summary_llm: self.summary_llm.clone(), bootstrap_pending: std::sync::atomic::AtomicBool::new(if preserve_flags { self.bootstrap_pending .load(std::sync::atomic::Ordering::Acquire) @@ -1039,6 +1088,7 @@ impl Workspace { self.storage.update_document(doc.id, content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; + self.schedule_summary_refresh(doc.id, path.clone(), content.to_string()); // Return updated doc self.storage.get_document_by_id(doc.id).await @@ -1088,6 +1138,7 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; + self.schedule_summary_refresh(doc.id, path.clone(), new_content); Ok(()) } @@ -1182,6 +1233,7 @@ impl Workspace { self.storage.update_document(doc.id, content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; + self.schedule_summary_refresh(doc.id, path.clone(), content.to_string()); let document = self.storage.get_document_by_id(doc.id).await?; Ok(WriteResult { document, @@ -1234,6 +1286,7 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; + self.schedule_summary_refresh(doc.id, path.clone(), new_content); let document = self.storage.get_document_by_id(doc.id).await?; Ok(WriteResult { document, @@ -1448,6 +1501,7 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; + self.schedule_summary_refresh(doc.id, paths::MEMORY.to_string(), new_content); Ok(()) } @@ -1866,8 +1920,14 @@ impl Workspace { query: &str, limit: usize, ) -> Result, WorkspaceError> { - self.search_with_config(query, self.search_defaults.clone().with_limit(limit)) - .await + self.search_with_config( + query, + self.search_defaults + .clone() + .with_limit(limit) + .with_detail(SearchDetailLevel::L1), + ) + .await } /// Search with custom configuration. @@ -1992,6 +2052,31 @@ impl Workspace { Ok(()) } + /// Schedule a background refresh of summary tiers for a document. + fn schedule_summary_refresh(&self, document_id: Uuid, path: String, content: String) { + let Some(llm) = self.summary_llm.as_ref() else { + return; + }; + if content.split_whitespace().count() < SUMMARY_MIN_WORDS { + return; + } + + let storage = self.storage.clone(); + let llm = Arc::clone(llm); + + tokio::spawn(async move { + if let Err(e) = + refresh_document_summaries(storage, llm, document_id, path, content).await + { + tracing::warn!( + document_id = %document_id, + "Failed to refresh workspace summaries: {}", + e + ); + } + }); + } + // ==================== Seeding ==================== /// Seed any missing core identity files in the workspace. @@ -2242,6 +2327,145 @@ impl Workspace { Ok(count) } + + /// Backfill missing summary tiers for existing documents. + pub async fn backfill_summaries(&self) -> Result { + let Some(ref llm) = self.summary_llm else { + return Ok(0); + }; + + let docs = self + .storage + .list_documents(&self.user_id, self.agent_id) + .await?; + + let mut count = 0; + for doc in docs { + if doc.word_count() < SUMMARY_MIN_WORDS { + continue; + } + if doc.summary_l0.is_some() && doc.summary_l1.is_some() { + continue; + } + + match refresh_document_summaries( + self.storage.clone(), + Arc::clone(llm), + doc.id, + doc.path.clone(), + doc.content.clone(), + ) + .await + { + Ok(()) => { + count += 1; + } + Err(e) => { + tracing::warn!( + document_id = %doc.id, + path = %doc.path, + "Failed to backfill workspace summaries: {}", + e + ); + } + } + } + + Ok(count) + } +} + +const SUMMARY_MIN_WORDS: usize = 200; + +const SUMMARY_PROMPT: &str = r#"You are summarizing a document from a personal knowledge base. + +Produce two summaries in JSON only. Be specific and concise. + +- l0: exactly one sentence, at most 30 words +- l1: structured overview, at most 500 words, with the most important facts, decisions, entities, dates, and action items + +Return exactly: +{"l0":"...","l1":"..."}"#; + +fn extract_json_object(text: &str) -> Option<&str> { + let start = text.find('{')?; + let end = text.rfind('}')?; + if end < start { + return None; + } + Some(text[start..=end].trim()) +} + +async fn generate_document_summaries( + llm: &Arc, + path: &str, + content: &str, +) -> Result<(String, String), WorkspaceError> { + let prompt = format!( + "{SUMMARY_PROMPT}\n\nDOCUMENT PATH: {path}\nDOCUMENT CONTENT:\n---\n{content}\n---\n" + ); + let request = CompletionRequest::new(vec![ + ChatMessage::system("You produce compact JSON summaries for workspace documents."), + ChatMessage::user(prompt), + ]) + .with_temperature(0.2) + .with_max_tokens(1024); + + let response = llm + .complete(request) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Summary generation failed: {e}"), + })?; + + let json_text = + extract_json_object(&response.content).ok_or_else(|| WorkspaceError::SearchFailed { + reason: "Summary generation returned no JSON object".to_string(), + })?; + + let parsed: serde_json::Value = + serde_json::from_str(json_text).map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Summary generation returned invalid JSON: {e}"), + })?; + + let l0 = + parsed + .get("l0") + .and_then(|v| v.as_str()) + .ok_or_else(|| WorkspaceError::SearchFailed { + reason: "Summary generation missing l0".to_string(), + })?; + let l1 = + parsed + .get("l1") + .and_then(|v| v.as_str()) + .ok_or_else(|| WorkspaceError::SearchFailed { + reason: "Summary generation missing l1".to_string(), + })?; + + Ok((l0.trim().to_string(), l1.trim().to_string())) +} + +async fn refresh_document_summaries( + storage: WorkspaceStorage, + llm: Arc, + document_id: Uuid, + path: String, + content: String, +) -> Result<(), WorkspaceError> { + if content.split_whitespace().count() < SUMMARY_MIN_WORDS { + return Ok(()); + } + + let (summary_l0, summary_l1) = generate_document_summaries(&llm, &path, &content).await?; + + reject_if_injected(&format!("{path}#summary_l0"), &summary_l0)?; + reject_if_injected(&format!("{path}#summary_l1"), &summary_l1)?; + + storage + .update_document_summaries(document_id, Some(&summary_l0), Some(&summary_l1)) + .await?; + Ok(()) } /// Find the nearest ancestor `.config` document for a given path. @@ -2299,7 +2523,10 @@ fn normalize_directory(path: &str) -> String { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use crate::testing::StubLlm; #[test] fn test_normalize_path() { @@ -2318,6 +2545,20 @@ mod tests { assert_eq!(normalize_directory(""), ""); } + #[tokio::test] + async fn test_generate_document_summaries_parses_json_response() { + let llm: Arc = Arc::new(StubLlm::new( + "ignored preface {\"l0\":\"one line\",\"l1\":\"structured overview\"} trailing noise", + )); + + let (l0, l1) = generate_document_summaries(&llm, "docs/test.md", "alpha beta gamma") + .await + .expect("summary generation"); + + assert_eq!(l0, "one line"); + assert_eq!(l1, "structured overview"); + } + // ── Fix 1: merge_profile_section tests ───────────────────────── #[test] diff --git a/src/workspace/repository.rs b/src/workspace/repository.rs index 56bd804bfc7..3fb22e52831 100644 --- a/src/workspace/repository.rs +++ b/src/workspace/repository.rs @@ -53,7 +53,7 @@ impl Repository { .query_opt( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2 AND path = $3 "#, @@ -81,7 +81,7 @@ impl Repository { .query_opt( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE id = $1 "#, &[&id], @@ -142,7 +142,7 @@ impl Repository { let conn = self.conn().await?; conn.execute( - "UPDATE memory_documents SET content = $2, updated_at = NOW() WHERE id = $1", + "UPDATE memory_documents SET content = $2, summary_l0 = NULL, summary_l1 = NULL, updated_at = NOW() WHERE id = $1", &[&id, &content], ) .await @@ -255,7 +255,7 @@ impl Repository { .query( r#" SELECT id, user_id, agent_id, path, content, - created_at, updated_at, metadata + summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2 ORDER BY updated_at DESC @@ -277,6 +277,8 @@ impl Repository { agent_id: row.get("agent_id"), path: row.get("path"), content: row.get("content"), + summary_l0: row.get("summary_l0"), + summary_l1: row.get("summary_l1"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), metadata: row.get("metadata"), @@ -435,6 +437,7 @@ impl Repository { .query( r#" SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content, + d.summary_l0, d.summary_l1, ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank FROM memory_chunks c JOIN memory_documents d ON d.id = c.document_id @@ -458,6 +461,8 @@ impl Repository { document_id: row.get("document_id"), document_path: row.get("document_path"), content: row.get("content"), + summary_l0: row.get("summary_l0"), + summary_l1: row.get("summary_l1"), rank: (i + 1) as u32, }) .collect()) @@ -478,6 +483,7 @@ impl Repository { .query( r#" SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content, + d.summary_l0, d.summary_l1, 1 - (c.embedding <=> $3) as similarity FROM memory_chunks c JOIN memory_documents d ON d.id = c.document_id @@ -501,6 +507,8 @@ impl Repository { document_id: row.get("document_id"), document_path: row.get("document_path"), content: row.get("content"), + summary_l0: row.get("summary_l0"), + summary_l1: row.get("summary_l1"), rank: (i + 1) as u32, }) .collect()) @@ -578,6 +586,8 @@ impl Repository { document_id: row.get("document_id"), document_path: row.get("document_path"), content: row.get("content"), + summary_l0: row.get("summary_l0"), + summary_l1: row.get("summary_l1"), rank: (i + 1) as u32, }) .collect()) @@ -621,6 +631,8 @@ impl Repository { document_id: row.get("document_id"), document_path: row.get("document_path"), content: row.get("content"), + summary_l0: row.get("summary_l0"), + summary_l1: row.get("summary_l1"), rank: (i + 1) as u32, }) .collect()) @@ -933,4 +945,24 @@ impl Repository { })?; Ok(result) } + /// Update summary fields on a document. + pub async fn update_document_summaries( + &self, + id: Uuid, + summary_l0: Option<&str>, + summary_l1: Option<&str>, + ) -> Result<(), WorkspaceError> { + let conn = self.conn().await?; + + conn.execute( + "UPDATE memory_documents SET summary_l0 = $2, summary_l1 = $3 WHERE id = $1", + &[&id, &summary_l0, &summary_l1], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Summary update failed: {}", e), + })?; + + Ok(()) + } } diff --git a/src/workspace/search.rs b/src/workspace/search.rs index 8b78a125deb..4d3731a86f1 100644 --- a/src/workspace/search.rs +++ b/src/workspace/search.rs @@ -13,8 +13,22 @@ use std::collections::HashMap; +use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Detail level requested from memory search results. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SearchDetailLevel { + /// Return a short one-line abstract. + L0, + /// Return a structured overview. + #[default] + L1, + /// Return the raw chunk content. + L2, +} + /// Strategy used to fuse FTS and vector search results. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum FusionStrategy { @@ -50,6 +64,8 @@ pub struct SearchConfig { /// Ignored by `Rrf` fusion. For env-based config via /// `WorkspaceSearchConfig::resolve`, defaults are per-strategy. pub vector_weight: f32, + /// Detail level for returned content. + pub detail: SearchDetailLevel, } impl Default for SearchConfig { @@ -64,6 +80,7 @@ impl Default for SearchConfig { fusion_strategy: FusionStrategy::default(), fts_weight: 0.5, vector_weight: 0.5, + detail: SearchDetailLevel::default(), } } } @@ -126,6 +143,12 @@ impl SearchConfig { } self } + + /// Set the detail level returned by search results. + pub fn with_detail(mut self, detail: SearchDetailLevel) -> Self { + self.detail = detail; + self + } } /// A search result with hybrid scoring. @@ -139,6 +162,10 @@ pub struct SearchResult { pub chunk_id: Uuid, /// Chunk content. pub content: String, + /// Optional one-line summary of the document. + pub summary_l0: Option, + /// Optional structured summary of the document. + pub summary_l1: Option, /// Combined fusion score (0.0-1.0 normalized). Strategy-dependent (RRF or WeightedScore). pub score: f32, /// Rank in FTS results (1-based, None if not in FTS results). @@ -172,6 +199,8 @@ pub struct RankedResult { /// File path of the source document. pub document_path: String, pub content: String, + pub summary_l0: Option, + pub summary_l1: Option, pub rank: u32, // 1-based rank } @@ -191,6 +220,24 @@ pub fn fuse_results( } } +fn choose_search_content( + summary_l0: &Option, + summary_l1: &Option, + raw_content: &str, + detail: SearchDetailLevel, +) -> String { + match detail { + SearchDetailLevel::L0 => summary_l0 + .clone() + .or_else(|| summary_l1.clone()) + .unwrap_or_else(|| raw_content.to_string()), + SearchDetailLevel::L1 => summary_l1 + .clone() + .unwrap_or_else(|| raw_content.to_string()), + SearchDetailLevel::L2 => raw_content.to_string(), + } +} + /// Reciprocal Rank Fusion algorithm. /// /// Combines ranked results from multiple retrieval methods using the formula: @@ -217,6 +264,8 @@ pub fn reciprocal_rank_fusion( document_id: Uuid, document_path: String, content: String, + summary_l0: Option, + summary_l1: Option, score: f32, fts_rank: Option, vector_rank: Option, @@ -232,11 +281,19 @@ pub fn reciprocal_rank_fusion( .and_modify(|info| { info.score += rrf_score; info.fts_rank = Some(result.rank); + if info.summary_l0.is_none() { + info.summary_l0 = result.summary_l0.clone(); + } + if info.summary_l1.is_none() { + info.summary_l1 = result.summary_l1.clone(); + } }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, + summary_l0: result.summary_l0, + summary_l1: result.summary_l1, score: rrf_score, fts_rank: Some(result.rank), vector_rank: None, @@ -251,11 +308,19 @@ pub fn reciprocal_rank_fusion( .and_modify(|info| { info.score += rrf_score; info.vector_rank = Some(result.rank); + if info.summary_l0.is_none() { + info.summary_l0 = result.summary_l0.clone(); + } + if info.summary_l1.is_none() { + info.summary_l1 = result.summary_l1.clone(); + } }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, + summary_l0: result.summary_l0, + summary_l1: result.summary_l1, score: rrf_score, fts_rank: None, vector_rank: Some(result.rank), @@ -269,7 +334,14 @@ pub fn reciprocal_rank_fusion( document_id: info.document_id, document_path: info.document_path, chunk_id, - content: info.content, + content: choose_search_content( + &info.summary_l0, + &info.summary_l1, + &info.content, + config.detail, + ), + summary_l0: info.summary_l0, + summary_l1: info.summary_l1, score: info.score, fts_rank: info.fts_rank, vector_rank: info.vector_rank, @@ -321,6 +393,8 @@ pub fn weighted_score_fusion( document_id: Uuid, document_path: String, content: String, + summary_l0: Option, + summary_l1: Option, score: f32, fts_rank: Option, vector_rank: Option, @@ -336,11 +410,19 @@ pub fn weighted_score_fusion( .and_modify(|info| { info.score += score; info.fts_rank = Some(result.rank); + if info.summary_l0.is_none() { + info.summary_l0 = result.summary_l0.clone(); + } + if info.summary_l1.is_none() { + info.summary_l1 = result.summary_l1.clone(); + } }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, + summary_l0: result.summary_l0, + summary_l1: result.summary_l1, score, fts_rank: Some(result.rank), vector_rank: None, @@ -355,11 +437,19 @@ pub fn weighted_score_fusion( .and_modify(|info| { info.score += score; info.vector_rank = Some(result.rank); + if info.summary_l0.is_none() { + info.summary_l0 = result.summary_l0.clone(); + } + if info.summary_l1.is_none() { + info.summary_l1 = result.summary_l1.clone(); + } }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, + summary_l0: result.summary_l0, + summary_l1: result.summary_l1, score, fts_rank: None, vector_rank: Some(result.rank), @@ -372,7 +462,14 @@ pub fn weighted_score_fusion( document_id: info.document_id, document_path: info.document_path, chunk_id, - content: info.content, + content: choose_search_content( + &info.summary_l0, + &info.summary_l1, + &info.content, + config.detail, + ), + summary_l0: info.summary_l0, + summary_l1: info.summary_l1, score: info.score, fts_rank: info.fts_rank, vector_rank: info.vector_rank, @@ -416,6 +513,8 @@ mod tests { document_id: doc_id, document_path: format!("docs/{}.md", doc_id), content: format!("content for chunk {}", chunk_id), + summary_l0: None, + summary_l1: None, rank, } } @@ -426,10 +525,41 @@ mod tests { document_id: doc_id, document_path: path.to_string(), content: format!("content for chunk {}", chunk_id), + summary_l0: None, + summary_l1: None, rank, } } + #[test] + fn test_search_detail_levels_choose_content() { + let doc = Uuid::new_v4(); + let chunk = Uuid::new_v4(); + let mut config = SearchConfig::default().with_limit(10); + + let result = RankedResult { + chunk_id: chunk, + document_id: doc, + document_path: "docs/test.md".to_string(), + content: "raw content".to_string(), + summary_l0: Some("one line".to_string()), + summary_l1: Some("structured overview".to_string()), + rank: 1, + }; + + config.detail = SearchDetailLevel::L0; + let l0 = reciprocal_rank_fusion(vec![result.clone()], Vec::new(), &config); + assert_eq!(l0[0].content, "one line"); + + config.detail = SearchDetailLevel::L1; + let l1 = reciprocal_rank_fusion(vec![result.clone()], Vec::new(), &config); + assert_eq!(l1[0].content, "structured overview"); + + config.detail = SearchDetailLevel::L2; + let l2 = reciprocal_rank_fusion(vec![result], Vec::new(), &config); + assert_eq!(l2[0].content, "raw content"); + } + #[test] fn test_rrf_propagates_document_path() { // Regression test: search results must carry the source document's diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs index 2184d8f2dbd..7b0eaa25161 100644 --- a/tests/workspace_integration.rs +++ b/tests/workspace_integration.rs @@ -6,7 +6,13 @@ use std::sync::Arc; +use async_trait::async_trait; +use ironclaw::llm::{ + CompletionRequest, CompletionResponse, FinishReason, LlmError, LlmProvider, + ToolCompletionRequest, ToolCompletionResponse, +}; use ironclaw::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; +use rust_decimal::Decimal; fn get_pool() -> deadpool_postgres::Pool { let database_url = std::env::var("DATABASE_URL") @@ -43,6 +49,50 @@ async fn cleanup_user(pool: &deadpool_postgres::Pool, user_id: &str) { .ok(); } +struct StubLlm { + response: String, +} + +impl StubLlm { + fn new(response: impl Into) -> Self { + Self { + response: response.into(), + } + } +} + +#[async_trait] +impl LlmProvider for StubLlm { + fn model_name(&self) -> &str { + "stub-model" + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + (Decimal::ZERO, Decimal::ZERO) + } + + async fn complete(&self, _request: CompletionRequest) -> Result { + Ok(CompletionResponse { + content: self.response.clone(), + input_tokens: 0, + output_tokens: 0, + finish_reason: FinishReason::Stop, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }) + } + + async fn complete_with_tools( + &self, + _request: ToolCompletionRequest, + ) -> Result { + Err(LlmError::RequestFailed { + provider: "stub-model".to_string(), + reason: "tool calls not supported in integration stub".to_string(), + }) + } +} + #[tokio::test] async fn test_workspace_write_and_read() { let pool = get_pool(); @@ -339,6 +389,91 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() { cleanup_user(&pool, user_id).await; } +#[tokio::test] +async fn test_workspace_tiered_summaries_default_search_returns_l1() { + let pool = get_pool(); + if try_connect(&pool).await.is_none() { + return; + } + let user_id = "test_tiered_summaries"; + cleanup_user(&pool, user_id).await; + + let llm = Arc::new(StubLlm::new( + r#"{"l0":"Project Alpha overview.","l1":"Topic: Project Alpha\n\nKey facts:\n- The roadmap is stable.\n- The owner prefers concise updates.\n\nEntities: Project Alpha, Alice\nDates: 2026-03-22\nAction items: Review next milestone."}"#, + )); + let workspace = Workspace::new(user_id, pool.clone()).with_llm(llm); + + let mut body = String::new(); + for _ in 0..80 { + body.push_str("Project Alpha status remains stable and the roadmap stays on track. "); + } + + workspace + .write("projects/alpha/status.md", &body) + .await + .expect("write failed"); + + let default_result = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let results = workspace + .search("Project Alpha", 5) + .await + .expect("search failed"); + if let Some(first) = results.first() + && first.content.contains("Topic: Project Alpha") + { + break first.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await + .expect("timed out waiting for summary generation"); + + assert!( + default_result.content.contains("Topic: Project Alpha"), + "default search should return L1 summary" + ); + + let l0_result = workspace + .search_with_config( + "Project Alpha", + SearchConfig::default() + .fts_only() + .with_detail(ironclaw::workspace::SearchDetailLevel::L0), + ) + .await + .expect("search l0 failed"); + assert!( + l0_result + .first() + .expect("expected l0 result") + .content + .contains("Project Alpha overview"), + "L0 search should return the abstract" + ); + + let l2_result = workspace + .search_with_config( + "Project Alpha", + SearchConfig::default() + .fts_only() + .with_detail(ironclaw::workspace::SearchDetailLevel::L2), + ) + .await + .expect("search l2 failed"); + assert!( + l2_result + .first() + .expect("expected l2 result") + .content + .contains("roadmap stays on track"), + "L2 search should return the raw chunk content" + ); + + cleanup_user(&pool, user_id).await; +} + #[tokio::test] async fn test_workspace_list_all() { let pool = get_pool(); From 252d1410e277086d13fb71e641ef6a34c29dfb58 Mon Sep 17 00:00:00 2001 From: Nigel Coleman Date: Mon, 6 Apr 2026 21:11:40 +0100 Subject: [PATCH 2/3] fix(workspace): harden tiered summaries --- .env.example | 3 + .../V14__workspace_tiered_summaries.sql | 2 +- migrations/V1__initial.sql | 2 +- src/agent/routine_engine.rs | 8 +- src/db/libsql/workspace.rs | 34 ++- src/db/mod.rs | 1 + src/db/postgres.rs | 3 +- src/error.rs | 3 + src/workspace/mod.rs | 240 ++++++++++++++---- src/workspace/repository.rs | 24 +- src/workspace/search.rs | 78 +++--- tests/import_openclaw_integration.rs | 6 +- 12 files changed, 302 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index 2395fee70b4..6bb2b3c5d7f 100644 --- a/.env.example +++ b/.env.example @@ -130,6 +130,9 @@ NEARAI_AUTH_URL=https://private.near.ai # GEMINI_RESPONSE_JSON_SCHEMA={"type":"object"} # GEMINI_CACHED_CONTENT=cachedContents/abc123 +# Workspace summary backfill on startup. +# SUMMARY_BACKFILL_LIMIT=50 # 0 disables startup backfill + # For full provider setup guide see docs/LLM_PROVIDERS.md # Channel Configuration diff --git a/migrations/V14__workspace_tiered_summaries.sql b/migrations/V14__workspace_tiered_summaries.sql index 43c37687ad2..15e417abfec 100644 --- a/migrations/V14__workspace_tiered_summaries.sql +++ b/migrations/V14__workspace_tiered_summaries.sql @@ -23,7 +23,7 @@ BEGIN SELECT d.path, d.updated_at, - COALESCE(d.summary_l0, LEFT(d.content, 200)) as content_preview, + COALESCE(d.summary_l0, LEFT(d.content, 120)) as content_preview, -- Extract the immediate child name CASE WHEN p_directory = '' THEN diff --git a/migrations/V1__initial.sql b/migrations/V1__initial.sql index 85db04c6754..6b46c4c8a56 100644 --- a/migrations/V1__initial.sql +++ b/migrations/V1__initial.sql @@ -268,7 +268,7 @@ BEGIN SELECT d.path, d.updated_at, - COALESCE(d.summary_l0, LEFT(d.content, 200)) as content_preview, + COALESCE(d.summary_l0, LEFT(d.content, 120)) as content_preview, -- Extract the immediate child name CASE WHEN p_directory = '' THEN diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 497a6eee4b7..8f2ff921f51 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -606,10 +606,12 @@ impl RoutineEngine { /// Finalize a dispatched routine run: update DB, update routine runtime, /// persist to conversation thread, and send notification. async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) { + let summary = sanitize_summary(summary); + // Complete the run record in DB if let Err(e) = self .store - .complete_routine_run(run.id, status, Some(summary), None) + .complete_routine_run(run.id, status, Some(summary.as_str()), None) .await { tracing::error!( @@ -739,7 +741,7 @@ impl RoutineEngine { &routine.user_id, &routine.name, status, - Some(summary), + Some(summary.as_str()), thread_id.as_deref(), ) .await; @@ -2136,7 +2138,6 @@ fn truncate(s: &str, max: usize) -> String { /// 2. Strip HTML tags to prevent injection in web-rendered notifications /// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output /// 4. Truncate to 500 chars to prevent oversized notifications -#[cfg(test)] fn sanitize_summary(s: &str) -> String { // Strip control characters (keep newline for now, collapse later) let no_control: String = s @@ -2164,7 +2165,6 @@ fn sanitize_summary(s: &str) -> String { } /// Remove HTML/XML tags from a string. -#[cfg(test)] fn strip_html_tags(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut in_tag = false; diff --git a/src/db/libsql/workspace.rs b/src/db/libsql/workspace.rs index e82fcd8798e..b75ddeed6aa 100644 --- a/src/db/libsql/workspace.rs +++ b/src/db/libsql/workspace.rs @@ -455,7 +455,7 @@ impl WorkspaceStore for LibSqlBackend { .query( r#" SELECT path, updated_at, - COALESCE(summary_l0, substr(content, 1, 200)) as content_preview + COALESCE(summary_l0, substr(content, 1, 120)) as content_preview FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 AND (?3 = '%' OR path LIKE ?3) @@ -571,6 +571,7 @@ impl WorkspaceStore for LibSqlBackend { &self, user_id: &str, agent_id: Option, + limit: Option, ) -> Result, WorkspaceError> { let conn = self .connect() @@ -579,21 +580,40 @@ impl WorkspaceStore for LibSqlBackend { reason: e.to_string(), })?; let agent_id_str = agent_id.map(|id| id.to_string()); - let mut rows = conn - .query( - r#" + let query = if limit.is_some() { + r#" SELECT id, user_id, agent_id, path, content, summary_l0, summary_l1, created_at, updated_at, metadata FROM memory_documents WHERE user_id = ?1 AND agent_id IS ?2 ORDER BY updated_at DESC - "#, - params![user_id, agent_id_str.as_deref()], + LIMIT ?3 + "# + } else { + r#" + SELECT id, user_id, agent_id, path, content, + summary_l0, summary_l1, created_at, updated_at, metadata + FROM memory_documents + WHERE user_id = ?1 AND agent_id IS ?2 + ORDER BY updated_at DESC + "# + }; + let mut rows = if let Some(limit) = limit { + conn.query( + query, + params![user_id, agent_id_str.as_deref(), limit as i64], ) .await .map_err(|e| WorkspaceError::SearchFailed { reason: format!("Query failed: {}", e), - })?; + })? + } else { + conn.query(query, params![user_id, agent_id_str.as_deref()]) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Query failed: {}", e), + })? + }; let mut docs = Vec::new(); while let Some(row) = rows diff --git a/src/db/mod.rs b/src/db/mod.rs index 4467ce18750..f2d1c603a83 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -719,6 +719,7 @@ pub trait WorkspaceStore: Send + Sync { &self, user_id: &str, agent_id: Option, + limit: Option, ) -> Result, WorkspaceError>; async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError>; async fn insert_chunk( diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 96c53ef244c..7ae1fae50ae 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -787,8 +787,9 @@ impl WorkspaceStore for PgBackend { &self, user_id: &str, agent_id: Option, + limit: Option, ) -> Result, WorkspaceError> { - self.repo.list_documents(user_id, agent_id).await + self.repo.list_documents(user_id, agent_id, limit).await } async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> { diff --git a/src/error.rs b/src/error.rs index 2ff0fd50d90..8f01c801318 100644 --- a/src/error.rs +++ b/src/error.rs @@ -289,6 +289,9 @@ pub enum WorkspaceError { #[error("Search failed: {reason}")] SearchFailed { reason: String }, + #[error("Summary generation failed: {reason}")] + SummaryGenerationFailed { reason: String }, + #[error("Embedding generation failed: {reason}")] EmbeddingFailed { reason: String }, diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 7825f033f04..87b1a0b7036 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -89,8 +89,12 @@ use deadpool_postgres::Pool; use uuid::Uuid; use crate::error::WorkspaceError; +use crate::llm::error::LlmError; +use crate::llm::retry::{is_retryable, retry_backoff_delay}; use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; use ironclaw_safety::{Sanitizer, Severity}; +use crate::util::floor_char_boundary; +use tokio::time::sleep; /// Files injected into the system prompt. Writes to these are scanned for /// prompt injection patterns and rejected if high-severity matches are found. @@ -117,6 +121,11 @@ fn is_system_prompt_file(path: &str) -> bool { /// Shared sanitizer instance — avoids rebuilding Aho-Corasick + regexes on every write. static SANITIZER: std::sync::LazyLock = std::sync::LazyLock::new(Sanitizer::new); +/// Default number of documents to backfill summaries for on startup. +const DEFAULT_SUMMARY_BACKFILL_LIMIT: usize = 50; +/// Upper bound on document content passed to the summarizer. +const SUMMARY_INPUT_MAX_BYTES: usize = 32 * 1024; + /// Scan content for prompt injection. Returns `Err` if high-severity patterns /// are detected, otherwise logs warnings and returns `Ok(())`. fn reject_if_injected(path: &str, content: &str) -> Result<(), WorkspaceError> { @@ -395,11 +404,12 @@ impl WorkspaceStorage { &self, user_id: &str, agent_id: Option, + limit: Option, ) -> Result, WorkspaceError> { match self { #[cfg(feature = "postgres")] - Self::Repo(repo) => repo.list_documents(user_id, agent_id).await, - Self::Db(db) => db.list_documents(user_id, agent_id).await, + Self::Repo(repo) => repo.list_documents(user_id, agent_id, limit).await, + Self::Db(db) => db.list_documents(user_id, agent_id, limit).await, } } @@ -1088,7 +1098,8 @@ impl Workspace { self.storage.update_document(doc.id, content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; - self.schedule_summary_refresh(doc.id, path.clone(), content.to_string()); + self.refresh_summaries_after_write(doc.id, path.clone(), content.to_string()) + .await; // Return updated doc self.storage.get_document_by_id(doc.id).await @@ -1138,7 +1149,8 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; - self.schedule_summary_refresh(doc.id, path.clone(), new_content); + self.refresh_summaries_after_write(doc.id, path.clone(), new_content) + .await; Ok(()) } @@ -1233,7 +1245,8 @@ impl Workspace { self.storage.update_document(doc.id, content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; - self.schedule_summary_refresh(doc.id, path.clone(), content.to_string()); + self.refresh_summaries_after_write(doc.id, path.clone(), content.to_string()) + .await; let document = self.storage.get_document_by_id(doc.id).await?; Ok(WriteResult { document, @@ -1286,7 +1299,8 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; - self.schedule_summary_refresh(doc.id, path.clone(), new_content); + self.refresh_summaries_after_write(doc.id, path.clone(), new_content) + .await; let document = self.storage.get_document_by_id(doc.id).await?; Ok(WriteResult { document, @@ -1501,7 +1515,8 @@ impl Workspace { self.storage.update_document(doc.id, &new_content).await?; self.reindex_document_with_metadata(doc.id, Some(&metadata)) .await?; - self.schedule_summary_refresh(doc.id, paths::MEMORY.to_string(), new_content); + self.refresh_summaries_after_write(doc.id, paths::MEMORY.to_string(), new_content) + .await; Ok(()) } @@ -2052,8 +2067,13 @@ impl Workspace { Ok(()) } - /// Schedule a background refresh of summary tiers for a document. - fn schedule_summary_refresh(&self, document_id: Uuid, path: String, content: String) { + /// Refresh summary tiers for a document after a content write. + async fn refresh_summaries_after_write( + &self, + document_id: Uuid, + path: String, + content: String, + ) { let Some(llm) = self.summary_llm.as_ref() else { return; }; @@ -2061,20 +2081,21 @@ impl Workspace { return; } - let storage = self.storage.clone(); - let llm = Arc::clone(llm); - - tokio::spawn(async move { - if let Err(e) = - refresh_document_summaries(storage, llm, document_id, path, content).await - { - tracing::warn!( - document_id = %document_id, - "Failed to refresh workspace summaries: {}", - e - ); - } - }); + if let Err(e) = refresh_document_summaries( + self.storage.clone(), + Arc::clone(llm), + document_id, + path, + content, + ) + .await + { + tracing::warn!( + document_id = %document_id, + "Failed to refresh workspace summaries: {}", + e + ); + } } // ==================== Seeding ==================== @@ -2334,10 +2355,17 @@ impl Workspace { return Ok(0); }; + let backfill_limit = summary_backfill_limit(); + if backfill_limit == 0 { + tracing::info!("Summary backfill disabled via SUMMARY_BACKFILL_LIMIT=0"); + return Ok(0); + } + let docs = self .storage - .list_documents(&self.user_id, self.agent_id) + .list_documents(&self.user_id, self.agent_id, Some(backfill_limit)) .await?; + let docs_len = docs.len(); let mut count = 0; for doc in docs { @@ -2371,6 +2399,13 @@ impl Workspace { } } + if docs_len == backfill_limit { + tracing::info!( + limit = backfill_limit, + "Summary backfill reached configured startup limit" + ); + } + Ok(count) } } @@ -2401,8 +2436,17 @@ async fn generate_document_summaries( path: &str, content: &str, ) -> Result<(String, String), WorkspaceError> { + let (content_for_prompt, truncated) = truncate_summary_input(content); + let truncation_note = if truncated { + format!( + "\nNOTE: Document content was truncated to the first {} bytes before summarization.\n", + SUMMARY_INPUT_MAX_BYTES + ) + } else { + String::new() + }; let prompt = format!( - "{SUMMARY_PROMPT}\n\nDOCUMENT PATH: {path}\nDOCUMENT CONTENT:\n---\n{content}\n---\n" + "{SUMMARY_PROMPT}\n\nDOCUMENT PATH: {path}\n{truncation_note}DOCUMENT CONTENT:\n---\n{content_for_prompt}\n---\n" ); let request = CompletionRequest::new(vec![ ChatMessage::system("You produce compact JSON summaries for workspace documents."), @@ -2411,37 +2455,29 @@ async fn generate_document_summaries( .with_temperature(0.2) .with_max_tokens(1024); - let response = llm - .complete(request) - .await - .map_err(|e| WorkspaceError::SearchFailed { - reason: format!("Summary generation failed: {e}"), - })?; + let response = complete_summary_request(llm, request).await?; - let json_text = - extract_json_object(&response.content).ok_or_else(|| WorkspaceError::SearchFailed { + let json_text = extract_json_object(&response.content).ok_or_else(|| { + WorkspaceError::SummaryGenerationFailed { reason: "Summary generation returned no JSON object".to_string(), - })?; + } + })?; let parsed: serde_json::Value = - serde_json::from_str(json_text).map_err(|e| WorkspaceError::SearchFailed { + serde_json::from_str(json_text).map_err(|e| WorkspaceError::SummaryGenerationFailed { reason: format!("Summary generation returned invalid JSON: {e}"), })?; - let l0 = - parsed - .get("l0") - .and_then(|v| v.as_str()) - .ok_or_else(|| WorkspaceError::SearchFailed { - reason: "Summary generation missing l0".to_string(), - })?; - let l1 = - parsed - .get("l1") - .and_then(|v| v.as_str()) - .ok_or_else(|| WorkspaceError::SearchFailed { - reason: "Summary generation missing l1".to_string(), - })?; + let l0 = parsed.get("l0").and_then(|v| v.as_str()).ok_or_else(|| { + WorkspaceError::SummaryGenerationFailed { + reason: "Summary generation missing l0".to_string(), + } + })?; + let l1 = parsed.get("l1").and_then(|v| v.as_str()).ok_or_else(|| { + WorkspaceError::SummaryGenerationFailed { + reason: "Summary generation missing l1".to_string(), + } + })?; Ok((l0.trim().to_string(), l1.trim().to_string())) } @@ -2459,12 +2495,37 @@ async fn refresh_document_summaries( let (summary_l0, summary_l1) = generate_document_summaries(&llm, &path, &content).await?; - reject_if_injected(&format!("{path}#summary_l0"), &summary_l0)?; - reject_if_injected(&format!("{path}#summary_l1"), &summary_l1)?; + let current_content = storage + .get_document_by_id(document_id) + .await + .map_err(|e| WorkspaceError::SummaryGenerationFailed { + reason: format!("Failed to re-read document before storing summaries: {}", e), + })? + .content; + if current_content != content { + return Err(WorkspaceError::SummaryGenerationFailed { + reason: "Document changed during summary generation; skipping stale summaries" + .to_string(), + }); + } + + reject_if_injected(&format!("{path}#summary_l0"), &summary_l0).map_err(|e| { + WorkspaceError::SummaryGenerationFailed { + reason: format!("Summary L0 rejected by prompt-injection guard: {}", e), + } + })?; + reject_if_injected(&format!("{path}#summary_l1"), &summary_l1).map_err(|e| { + WorkspaceError::SummaryGenerationFailed { + reason: format!("Summary L1 rejected by prompt-injection guard: {}", e), + } + })?; storage .update_document_summaries(document_id, Some(&summary_l0), Some(&summary_l1)) - .await?; + .await + .map_err(|e| WorkspaceError::SummaryGenerationFailed { + reason: format!("Summary persistence failed: {}", e), + })?; Ok(()) } @@ -2495,6 +2556,70 @@ fn find_nearest_config(path: &str, configs: &[MemoryDocument]) -> Option, + request: CompletionRequest, +) -> Result { + let mut last_error: Option = None; + + for attempt in 0..3u32 { + match llm.complete(request.clone()).await { + Ok(response) => return Ok(response), + Err(err) if is_retryable(&err) && attempt < 2 => { + let delay = retry_backoff_delay(attempt); + tracing::warn!( + attempt = attempt + 1, + delay_ms = delay.as_millis(), + error = %err, + "Summary generation failed transiently; retrying" + ); + last_error = Some(err); + sleep(delay).await; + } + Err(err) => { + return Err(WorkspaceError::SummaryGenerationFailed { + reason: format!("LLM request failed: {}", err), + }); + } + } + } + + Err(WorkspaceError::SummaryGenerationFailed { + reason: format!( + "LLM request failed after retries: {}", + last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown error".to_string()) + ), + }) +} + +fn truncate_summary_input(content: &str) -> (&str, bool) { + if content.len() <= SUMMARY_INPUT_MAX_BYTES { + (content, false) + } else { + let end = floor_char_boundary(content, SUMMARY_INPUT_MAX_BYTES); + (&content[..end], true) + } +} + +fn summary_backfill_limit() -> usize { + match std::env::var("SUMMARY_BACKFILL_LIMIT") { + Ok(value) => match value.trim().parse::() { + Ok(limit) => limit, + Err(e) => { + tracing::warn!( + value = %value, + error = %e, + "Invalid SUMMARY_BACKFILL_LIMIT; using default" + ); + DEFAULT_SUMMARY_BACKFILL_LIMIT + } + }, + Err(_) => DEFAULT_SUMMARY_BACKFILL_LIMIT, + } +} + /// Normalize a file path (remove leading/trailing slashes, collapse //). fn normalize_path(path: &str) -> String { let path = path.trim().trim_matches('/'); @@ -2559,6 +2684,17 @@ mod tests { assert_eq!(l1, "structured overview"); } + #[test] + fn test_truncate_summary_input_respects_char_boundaries() { + let prefix = "a".repeat(SUMMARY_INPUT_MAX_BYTES - 1); + let content = format!("{prefix}é"); + + let (truncated, was_truncated) = truncate_summary_input(&content); + + assert!(was_truncated); + assert_eq!(truncated, prefix); + } + // ── Fix 1: merge_profile_section tests ───────────────────────── #[test] diff --git a/src/workspace/repository.rs b/src/workspace/repository.rs index 3fb22e52831..4f8adf10051 100644 --- a/src/workspace/repository.rs +++ b/src/workspace/repository.rs @@ -248,11 +248,28 @@ impl Repository { &self, user_id: &str, agent_id: Option, + limit: Option, ) -> Result, WorkspaceError> { let conn = self.conn().await?; - let rows = conn - .query( + let rows = if let Some(limit) = limit.and_then(|n| i64::try_from(n).ok()) { + conn.query( + r#" + SELECT id, user_id, agent_id, path, content, + summary_l0, summary_l1, created_at, updated_at, metadata + FROM memory_documents + WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2 + ORDER BY updated_at DESC + LIMIT $3 + "#, + &[&user_id, &agent_id, &limit], + ) + .await + .map_err(|e| WorkspaceError::SearchFailed { + reason: format!("Query failed: {}", e), + })? + } else { + conn.query( r#" SELECT id, user_id, agent_id, path, content, summary_l0, summary_l1, created_at, updated_at, metadata @@ -265,7 +282,8 @@ impl Repository { .await .map_err(|e| WorkspaceError::SearchFailed { reason: format!("Query failed: {}", e), - })?; + })? + }; Ok(rows.iter().map(|r| self.row_to_document(r)).collect()) } diff --git a/src/workspace/search.rs b/src/workspace/search.rs index 4d3731a86f1..799837f10f9 100644 --- a/src/workspace/search.rs +++ b/src/workspace/search.rs @@ -14,6 +14,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use tracing::warn; use uuid::Uuid; /// Detail level requested from memory search results. @@ -221,21 +222,28 @@ pub fn fuse_results( } fn choose_search_content( + document_path: &str, summary_l0: &Option, summary_l1: &Option, raw_content: &str, detail: SearchDetailLevel, ) -> String { + let missing_summaries = summary_l0.is_none() && summary_l1.is_none(); match detail { - SearchDetailLevel::L0 => summary_l0 - .clone() - .or_else(|| summary_l1.clone()) - .unwrap_or_else(|| raw_content.to_string()), - SearchDetailLevel::L1 => summary_l1 - .clone() - .unwrap_or_else(|| raw_content.to_string()), - SearchDetailLevel::L2 => raw_content.to_string(), + SearchDetailLevel::L0 => summary_l0.clone().or_else(|| summary_l1.clone()), + SearchDetailLevel::L1 => summary_l1.clone(), + SearchDetailLevel::L2 => None, } + .unwrap_or_else(|| { + if missing_summaries && detail != SearchDetailLevel::L2 { + warn!( + path = %document_path, + detail = ?detail, + "Search result missing summaries; falling back to raw content" + ); + } + raw_content.to_string() + }) } /// Reciprocal Rank Fusion algorithm. @@ -330,21 +338,26 @@ pub fn reciprocal_rank_fusion( // Convert to SearchResult and sort by score let mut results: Vec = chunk_scores .into_iter() - .map(|(chunk_id, info)| SearchResult { - document_id: info.document_id, - document_path: info.document_path, - chunk_id, - content: choose_search_content( + .map(|(chunk_id, info)| { + let document_path = info.document_path; + let content = choose_search_content( + &document_path, &info.summary_l0, &info.summary_l1, &info.content, config.detail, - ), - summary_l0: info.summary_l0, - summary_l1: info.summary_l1, - score: info.score, - fts_rank: info.fts_rank, - vector_rank: info.vector_rank, + ); + SearchResult { + document_id: info.document_id, + document_path, + chunk_id, + content, + summary_l0: info.summary_l0, + summary_l1: info.summary_l1, + score: info.score, + fts_rank: info.fts_rank, + vector_rank: info.vector_rank, + } }) .collect(); @@ -458,21 +471,26 @@ pub fn weighted_score_fusion( let mut results: Vec = chunk_scores .into_iter() - .map(|(chunk_id, info)| SearchResult { - document_id: info.document_id, - document_path: info.document_path, - chunk_id, - content: choose_search_content( + .map(|(chunk_id, info)| { + let document_path = info.document_path; + let content = choose_search_content( + &document_path, &info.summary_l0, &info.summary_l1, &info.content, config.detail, - ), - summary_l0: info.summary_l0, - summary_l1: info.summary_l1, - score: info.score, - fts_rank: info.fts_rank, - vector_rank: info.vector_rank, + ); + SearchResult { + document_id: info.document_id, + document_path, + chunk_id, + content, + summary_l0: info.summary_l0, + summary_l1: info.summary_l1, + score: info.score, + fts_rank: info.fts_rank, + vector_rank: info.vector_rank, + } }) .collect(); diff --git a/tests/import_openclaw_integration.rs b/tests/import_openclaw_integration.rs index 2a694098267..791ed60f9a8 100644 --- a/tests/import_openclaw_integration.rs +++ b/tests/import_openclaw_integration.rs @@ -157,7 +157,7 @@ mod import_integration_tests { // Verify DB starts empty let before_docs = db - .list_documents("test_user", None) + .list_documents("test_user", None, None) .await .expect("list docs failed"); assert_eq!(before_docs.len(), 0); @@ -239,7 +239,7 @@ mod import_integration_tests { // Count documents before import let before_import = db - .list_documents(user_id, None) + .list_documents(user_id, None, None) .await .expect("list docs before failed"); let before_count = before_import.len(); @@ -257,7 +257,7 @@ mod import_integration_tests { // Count documents after (in dry-run mode, no writes should occur) let after_import = db - .list_documents(user_id, None) + .list_documents(user_id, None, None) .await .expect("list docs after failed"); let after_count = after_import.len(); From 6d626caf5085242429c2e85eba385977ebacf1a6 Mon Sep 17 00:00:00 2001 From: Nigel Coleman Date: Mon, 6 Apr 2026 21:40:20 +0100 Subject: [PATCH 3/3] fix(workspace): format import ordering --- src/workspace/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 87b1a0b7036..fa95bda4652 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -92,8 +92,8 @@ use crate::error::WorkspaceError; use crate::llm::error::LlmError; use crate::llm::retry::{is_retryable, retry_backoff_delay}; use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; -use ironclaw_safety::{Sanitizer, Severity}; use crate::util::floor_char_boundary; +use ironclaw_safety::{Sanitizer, Severity}; use tokio::time::sleep; /// Files injected into the system prompt. Writes to these are scanned for