mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory): smart multi-strategy retrieval walk (#3077)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user