feat(memory): smart multi-strategy retrieval walk (#3077)

This commit is contained in:
Steven Enamakel
2026-05-31 02:05:58 -07:00
committed by GitHub
parent fbe89ba7b6
commit faf2fd8df3
6 changed files with 1918 additions and 3 deletions
+9
View File
@@ -1936,6 +1936,15 @@ impl Config {
};
}
if let Some(raw) = env.get("OPENHUMAN_MEMORY_TREE_SMART_WALK_MODEL") {
let trimmed = raw.trim();
self.memory_tree.smart_walk_model = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
// Auto-update overrides
if let Some(flag) = env.get("OPENHUMAN_AUTO_UPDATE_ENABLED") {
let normalized = flag.trim().to_ascii_lowercase();
@@ -338,6 +338,15 @@ pub struct MemoryTreeConfig {
/// Env override: `OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL`.
#[serde(default = "default_cloud_llm_model")]
pub cloud_llm_model: Option<String>,
/// Provider:model string for the smart_walk retrieval agent (e.g.
/// `"deepseek:deepseek-chat"`). When set, the smart walk loop uses this
/// model instead of the general memory/chat provider. Fast, cheap models
/// work best here since the walker makes many short-turn calls.
///
/// Env override: `OPENHUMAN_MEMORY_TREE_SMART_WALK_MODEL`.
#[serde(default)]
pub smart_walk_model: Option<String>,
}
/// Returns `None` so that existing installs that never opted into Phase 4
@@ -438,6 +447,7 @@ impl Default for MemoryTreeConfig {
content_dir: default_memory_tree_content_dir(),
llm_backend: default_llm_backend(),
cloud_llm_model: default_cloud_llm_model(),
smart_walk_model: None,
}
}
}
+13 -3
View File
@@ -12,6 +12,7 @@ mod fetch_leaves;
mod ingest_document;
mod query_source;
mod search_entities;
pub mod smart_walk;
pub mod walk;
// Re-export individual tool types for callers that need them directly
@@ -21,6 +22,10 @@ pub use fetch_leaves::MemoryTreeFetchLeavesTool;
pub use ingest_document::MemoryTreeIngestDocumentTool;
pub use query_source::MemoryTreeQuerySourceTool;
pub use search_entities::MemoryTreeSearchEntitiesTool;
pub use smart_walk::{
run_smart_walk, SmartMemoryWalkTool, SmartWalkOptions, SmartWalkOutcome, SmartWalkStep,
SmartWalkStopReason,
};
pub use walk::MemoryTreeWalkTool as MemoryQueryWalkTool;
pub use walk::{run_walk, MemoryTreeWalkTool, WalkOptions, WalkOutcome, WalkStep, WalkStopReason};
pub use MemoryTreeTool as MemoryQueryTool;
@@ -47,7 +52,9 @@ impl Tool for MemoryTreeTool {
`query_source` (filter by source type + time window), \
`drill_down` (expand a coarse summary one level), \
`fetch_leaves` (pull raw chunks for citation), `ingest_document` (write a document into the tree for future retrieval), \
`walk` (agentic multi-turn walk — LLM navigates summaries and returns a synthesized answer for a natural-language query)."
`walk` (agentic multi-turn walk — LLM navigates summaries and returns a synthesized answer for a natural-language query), \
`smart_walk` (multi-strategy retrieval — combines vector search, keyword search, entity lookup, \
and tree browsing across raw files, wiki summaries, documents, and episodic memories)."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -57,7 +64,8 @@ impl Tool for MemoryTreeTool {
"mode": {
"type": "string",
"enum": ["search_entities", "query_source",
"drill_down", "fetch_leaves", "ingest_document", "walk"],
"drill_down", "fetch_leaves", "ingest_document", "walk",
"smart_walk"],
"description": "Which operation to run (retrieval or write)."
},
// search_entities params
@@ -138,10 +146,11 @@ impl Tool for MemoryTreeTool {
"fetch_leaves" => MemoryTreeFetchLeavesTool.execute(args).await,
"ingest_document" => MemoryTreeIngestDocumentTool.execute(args).await,
"walk" => MemoryTreeWalkTool.execute(args).await,
"smart_walk" => SmartMemoryWalkTool.execute(args).await,
other => {
log::debug!("[tool][memory_tree] unknown_mode mode={other}");
Err(anyhow::anyhow!(
"memory_tree: unknown mode `{other}`. Valid: search_entities, query_source, drill_down, fetch_leaves, ingest_document, walk"
"memory_tree: unknown mode `{other}`. Valid: search_entities, query_source, drill_down, fetch_leaves, ingest_document, walk, smart_walk"
))
}
}
@@ -187,6 +196,7 @@ mod memory_tree_dispatcher_tests {
assert!(modes.contains(&"fetch_leaves"));
assert!(modes.contains(&"ingest_document"));
assert!(modes.contains(&"walk"));
assert!(modes.contains(&"smart_walk"));
// Removed with the global/topic trees.
assert!(!modes.contains(&"query_topic"));
assert!(!modes.contains(&"query_global"));
File diff suppressed because it is too large Load Diff
+190
View File
@@ -45,6 +45,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("reset_tree"),
schemas("pipeline_status"),
schemas("set_enabled"),
schemas("smart_walk"),
]
}
@@ -128,6 +129,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("set_enabled"),
handler: handle_set_enabled,
},
RegisteredController {
schema: schemas("smart_walk"),
handler: handle_smart_walk,
},
]
}
@@ -788,6 +793,78 @@ pub fn schemas(function: &str) -> ControllerSchema {
},
],
},
"smart_walk" => ControllerSchema {
namespace: NAMESPACE,
function: "smart_walk",
description: "Multi-strategy memory retrieval — combines vector \
search, keyword search, entity lookup, and tree browsing to \
answer natural-language queries across raw files, wiki \
summaries, documents, and episodic memories.",
inputs: vec![
FieldSchema {
name: "query",
ty: TypeSchema::String,
comment: "Natural-language question to answer.",
required: true,
},
FieldSchema {
name: "namespace",
ty: TypeSchema::String,
comment: "Memory namespace. Default: \"default\".",
required: false,
},
FieldSchema {
name: "max_turns",
ty: TypeSchema::U64,
comment: "Max LLM turns. Default 12, hard cap 25.",
required: false,
},
FieldSchema {
name: "model",
ty: TypeSchema::String,
comment: "Provider:model override (e.g. 'deepseek:deepseek-chat').",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "answer",
ty: TypeSchema::String,
comment: "Synthesized answer with evidence citations.",
required: true,
},
FieldSchema {
name: "turns_used",
ty: TypeSchema::U64,
comment: "Number of LLM turns consumed.",
required: true,
},
FieldSchema {
name: "evidence_count",
ty: TypeSchema::U64,
comment: "Number of evidence items collected.",
required: true,
},
FieldSchema {
name: "stopped_reason",
ty: TypeSchema::String,
comment: "Why the walk stopped (answered/max_turns/llm_gave_up/error).",
required: true,
},
FieldSchema {
name: "evidence",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Array of {source_path, snippet, relevance} evidence items.",
required: true,
},
FieldSchema {
name: "trace",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment: "Array of {turn, action, args_summary, result_preview} trace steps.",
required: true,
},
],
},
_ => ControllerSchema {
namespace: NAMESPACE,
function: "unknown",
@@ -1004,6 +1081,119 @@ fn handle_set_enabled(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_smart_walk(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::chat::build_chat_provider;
use crate::openhuman::memory::query::smart_walk::{
run_smart_walk, SmartWalkOptions, SmartWalkStopReason,
};
#[derive(serde::Deserialize)]
struct Req {
query: String,
#[serde(default = "default_namespace")]
namespace: String,
#[serde(default)]
max_turns: Option<u64>,
#[serde(default)]
model: Option<String>,
}
fn default_namespace() -> String {
"default".into()
}
let req = parse_value::<Req>(Value::Object(params))?;
let config = config_rpc::load_config_with_timeout().await?;
let chat_provider = build_chat_provider(&config)
.map_err(|e| format!("smart_walk: build chat provider failed: {e}"))?;
struct Adapter {
inner: std::sync::Arc<dyn crate::openhuman::memory::chat::ChatProvider>,
}
#[async_trait::async_trait]
impl crate::openhuman::inference::provider::traits::Provider for Adapter {
async fn chat_with_system(
&self,
system: Option<&str>,
message: &str,
_model: &str,
temperature: f64,
) -> anyhow::Result<String> {
let prompt = crate::openhuman::memory::chat::ChatPrompt {
system: system.unwrap_or("").to_string(),
user: message.to_string(),
temperature,
kind: "memory_smart_walk_rpc",
};
self.inner.chat_for_text(&prompt).await
}
async fn chat_with_history(
&self,
messages: &[crate::openhuman::inference::provider::traits::ChatMessage],
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
let system = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.as_str());
let user: String = messages
.iter()
.filter(|m| m.role != "system")
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
self.chat_with_system(system, &user, model, temperature)
.await
}
}
let adapter = Adapter {
inner: chat_provider,
};
let opts = SmartWalkOptions {
max_turns: req.max_turns.map(|n| n as usize).unwrap_or(12),
namespace: req.namespace,
model: req.model,
content_root: None,
};
let outcome = run_smart_walk(&config, &adapter, &req.query, opts)
.await
.map_err(|e| format!("smart_walk error: {e}"))?;
let stopped = match outcome.stopped_reason {
SmartWalkStopReason::Answered => "answered",
SmartWalkStopReason::MaxTurnsReached => "max_turns",
SmartWalkStopReason::LlmGaveUp => "llm_gave_up",
SmartWalkStopReason::Error(_) => "error",
};
let result = serde_json::json!({
"answer": outcome.answer,
"turns_used": outcome.turns_used,
"evidence_count": outcome.evidence.len(),
"stopped_reason": stopped,
"evidence": outcome.evidence.iter().map(|e| serde_json::json!({
"source_path": e.source_path,
"snippet": e.snippet,
"relevance": e.relevance,
})).collect::<Vec<_>>(),
"trace": outcome.trace.iter().map(|s| serde_json::json!({
"turn": s.turn,
"action": s.action,
"args_summary": s.args_summary,
"result_preview": s.result_preview,
})).collect::<Vec<_>>(),
});
to_json(RpcOutcome::new(result, vec![]))
})
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
+1
View File
@@ -197,6 +197,7 @@ pub fn all_tools_with_runtime(
Box::new(MemoryForgetTool::new(memory.clone(), security.clone())),
Box::new(MemoryQueryTool),
Box::new(MemoryQueryWalkTool),
Box::new(SmartMemoryWalkTool),
// Explicit user-preference pinning — always registered so the model
// can save user-stated preferences regardless of whether the full
// inference-based learning subsystem is enabled. The preference