mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory): expose compiled flavour profiles via a read-only memory_flavour tool (#5175)
This commit is contained in:
@@ -28,5 +28,10 @@ named = [
|
||||
"memory_tree",
|
||||
"query_memory",
|
||||
"memory_doctor",
|
||||
# Compiled persona distillation profile for one facet (communication,
|
||||
# coding_style, stack, workflow, environment, directives,
|
||||
# anti_preferences) — read-only, distinct from the fact/episodic search
|
||||
# above.
|
||||
"memory_flavour",
|
||||
"ask_user_clarification",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,10 @@ Use the right tool for the job:
|
||||
2. **`memory_recall`** — legacy key-value memory search. Good for exact preference/fact lookups.
|
||||
3. **`query_memory`** — simple text search across stored memories.
|
||||
4. **`memory_doctor`** — diagnose tree health issues.
|
||||
5. **`memory_flavour`** — the user's distilled style/preference profile for one
|
||||
facet (communication, coding_style, stack, workflow, environment,
|
||||
directives, anti_preferences). Use it when the question is about how the
|
||||
user prefers to work rather than a specific remembered fact or event.
|
||||
|
||||
## Performance contract
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ named = [
|
||||
# mutate memory ("remember this document") despite running read-only.
|
||||
# Retrieval-only `memory_recall` covers the scout's needs safely.
|
||||
"memory_recall",
|
||||
# Compiled persona distillation profile for one facet (communication,
|
||||
# coding_style, stack, workflow, environment, directives,
|
||||
# anti_preferences) — the user's distilled style/preference summary, read
|
||||
# only, complements `memory_recall`'s raw-fact search.
|
||||
"memory_flavour",
|
||||
# Transcripts: recall what the user said/decided in earlier chats. Backed
|
||||
# by the cross-thread trigram index; read-only and workspace-scoped.
|
||||
"transcript_search",
|
||||
|
||||
@@ -10,6 +10,11 @@ read at a glance — and tell it which of the caller's visible tools to call nex
|
||||
2. Gather only what's actually needed to act on it, drawing on:
|
||||
- **Memory** — `memory_recall` for relevant facts (search by namespace +
|
||||
query). This is read-only; you cannot and must not write to memory.
|
||||
`memory_flavour` retrieves the user's distilled style/preference profile
|
||||
for one facet (communication, coding_style, stack, workflow,
|
||||
environment, directives, anti_preferences) — reach for it when the
|
||||
request depends on how the user likes to work rather than a specific
|
||||
remembered fact.
|
||||
- **Past conversations (transcripts)** — `transcript_search` finds messages
|
||||
the user sent in *earlier* chats (keyword/substring, recency-ranked). Use
|
||||
it when the request leans on something the user said, asked, or decided
|
||||
|
||||
@@ -48,5 +48,11 @@ named = [
|
||||
"people_add_alias",
|
||||
"people_record_interaction",
|
||||
"people_refresh_address_book",
|
||||
# Read-only: the compiled persona distillation profile for one facet
|
||||
# (communication, coding_style, stack, workflow, environment, directives,
|
||||
# anti_preferences) — complements `learning_get_facet` when the agent
|
||||
# needs the agent's-eye-view style/preference summary rather than the raw
|
||||
# facet cache.
|
||||
"memory_flavour",
|
||||
"ask_user_clarification",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ You own the assistant's remembered profile, persona files, explicit preferences,
|
||||
|
||||
Memory and profile changes are persistent. Use this contract:
|
||||
|
||||
- Read current state before writing (`memory_recall`, `workspace_read_persona`, `learning_list_facets`, `people_*`, or `memory_doctor` as appropriate).
|
||||
- Read current state before writing (`memory_recall`, `workspace_read_persona`, `learning_list_facets`, `people_*`, or `memory_doctor` as appropriate). Use `memory_flavour` to read the user's distilled style/preference profile for a facet (communication, coding_style, stack, workflow, environment, directives, anti_preferences) when a request needs that distilled view rather than a raw facet cache entry.
|
||||
- Only persist stable user preferences, identity/profile facts, explicit instructions, named contacts, or user-approved corrections. Do not store secrets, transient task details, or unverified guesses.
|
||||
- Preserve existing persona/profile content unless the user explicitly asks for a rewrite. Prefer small targeted updates over full replacement.
|
||||
- Before destructive changes (`memory_forget`, `learning_forget_facet`, `learning_reset_cache`, `workspace_reset_persona`), ask for explicit confirmation and name exactly what will be removed.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
mod doctor;
|
||||
mod flavour;
|
||||
mod forget;
|
||||
mod recall;
|
||||
mod store;
|
||||
|
||||
pub use crate::openhuman::memory::query::*;
|
||||
pub use doctor::MemoryDoctorTool;
|
||||
pub use flavour::MemoryFlavourTool;
|
||||
pub use forget::MemoryForgetTool;
|
||||
pub use recall::MemoryRecallTool;
|
||||
pub use store::MemoryStoreTool;
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Agent tool: read a compiled persona flavour profile (issue #5172).
|
||||
//!
|
||||
//! Persona ingestion (`src/openhuman/tinycortex/persona.rs`) distills a
|
||||
//! person's coding-agent history into seven [`PersonaFacet`] flavoured trees
|
||||
//! (communication, coding style, stack, workflow, environment, directives,
|
||||
//! anti-preferences), each compiled into a small prompt-ready markdown
|
||||
//! profile via [`compile_flavoured_root`]. Until this tool, nothing surfaced
|
||||
//! those compiled profiles to the agent loop — the ingested data sat unread.
|
||||
//! `memory_flavour` lets an agent pull one facet's profile on demand.
|
||||
//!
|
||||
//! Strictly read-only: it never ingests, seals, or otherwise creates persona
|
||||
//! evidence. The only disk write it can trigger is `compile_flavoured_root`
|
||||
//! re-staging the fixed-path compiled artifact — a pure, idempotent
|
||||
//! projection of the tree's existing root node (see
|
||||
//! `vendor/tinycortex/src/memory/tree/flavoured.rs`), not new memory content.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tinycortex::memory::persona::PersonaFacet;
|
||||
use tinycortex::memory::tree::store::{get_tree_by_scope, TreeKind};
|
||||
use tinycortex::memory::tree::{compile_flavoured_root, flavoured_root_abs_path};
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::tinycortex::memory_config_from;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
/// The seven valid `flavour` slugs, for error messages.
|
||||
const VALID_FLAVOURS: &str =
|
||||
"communication, coding_style, stack, workflow, environment, directives, anti_preferences";
|
||||
|
||||
/// Let the agent read the compiled persona profile for one facet.
|
||||
pub struct MemoryFlavourTool {
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl MemoryFlavourTool {
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the YAML front matter written by [`compile_flavoured_root`]
|
||||
/// (`---\n...\n---\n<body>`) and return just the body. Front-matter field
|
||||
/// values are single-line (`yaml_quote` collapses interior newlines), so the
|
||||
/// first `\n---\n` after the opening delimiter is always the closing one.
|
||||
fn body_after_front_matter(content: &str) -> &str {
|
||||
match content.strip_prefix("---\n") {
|
||||
Some(rest) => match rest.find("\n---\n") {
|
||||
Some(pos) => &rest[pos + "\n---\n".len()..],
|
||||
// Malformed front matter (opener present but no closer): fall
|
||||
// back to everything after the opening delimiter rather than
|
||||
// the raw content, so the opener itself is never leaked.
|
||||
None => rest,
|
||||
},
|
||||
None => content,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryFlavourTool {
|
||||
fn name(&self) -> &str {
|
||||
"memory_flavour"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the compiled persona profile for one distillation facet, built from this \
|
||||
person's coding-agent history. Valid `flavour` values: communication (tone, \
|
||||
verbosity, feedback style), coding_style (naming, structure, testing habits), \
|
||||
stack (languages, frameworks, architecture), workflow (branching, PR habits, \
|
||||
parallelism), environment (editors, harnesses, CLIs, OS), directives (explicit \
|
||||
standing rules), anti_preferences (things to never do). Returns markdown prose, or \
|
||||
a clear message if no profile has been built yet. Read-only."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flavour": {
|
||||
"type": "string",
|
||||
"description": "Which persona facet to read: communication, coding_style, \
|
||||
stack, workflow, environment, directives, or anti_preferences."
|
||||
}
|
||||
},
|
||||
"required": ["flavour"]
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
// Genuinely read-only: this tool never ingests, seals, or writes
|
||||
// memory content. Overridden explicitly (not relying on the trait
|
||||
// default) so a future default change can't silently loosen this.
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
fn permission_level_with_args(&self, _args: &serde_json::Value) -> PermissionLevel {
|
||||
// No arg combination for this tool escalates past ReadOnly.
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let flavour_raw = args
|
||||
.get("flavour")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'flavour' parameter"))?
|
||||
.trim();
|
||||
if flavour_raw.is_empty() {
|
||||
return Err(anyhow::anyhow!("'flavour' cannot be empty"));
|
||||
}
|
||||
|
||||
let facet = PersonaFacet::parse_loose(flavour_raw).ok_or_else(|| {
|
||||
anyhow::anyhow!("Unknown flavour '{flavour_raw}'. Valid flavours: {VALID_FLAVOURS}")
|
||||
})?;
|
||||
|
||||
let mc = memory_config_from(&self.config, self.config.workspace_dir.clone());
|
||||
let scope = facet.tree_scope();
|
||||
let heading = facet.heading();
|
||||
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
facet = ?facet,
|
||||
"[memory_flavour] entry"
|
||||
);
|
||||
|
||||
// Fast path: the compiled artifact already exists on disk with a
|
||||
// non-empty body — read it directly without touching the tree store.
|
||||
let abs_path = flavoured_root_abs_path(&mc, &scope);
|
||||
if abs_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&abs_path) {
|
||||
let body = body_after_front_matter(&content);
|
||||
if !body.trim().is_empty() {
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
body_len = body.len(),
|
||||
"[memory_flavour] fast path hit: returning stripped body from disk"
|
||||
);
|
||||
return Ok(ToolResult::success(body.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
"[memory_flavour] fast path missed or empty, falling to tree lookup"
|
||||
);
|
||||
|
||||
// Slow path: look up the flavoured tree and (re)compile its root.
|
||||
match get_tree_by_scope(&mc, TreeKind::Flavoured, &scope) {
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
"[memory_flavour] no flavoured tree exists yet"
|
||||
);
|
||||
Ok(ToolResult::success(format!(
|
||||
"No profile built yet for {heading}. Run persona ingestion first, then try \
|
||||
again."
|
||||
)))
|
||||
}
|
||||
Ok(Some(tree)) => {
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
tree_id = %tree.id,
|
||||
"[memory_flavour] tree found, compiling root"
|
||||
);
|
||||
match compile_flavoured_root(&mc, &tree.id) {
|
||||
Ok(markdown) => {
|
||||
let body = body_after_front_matter(&markdown);
|
||||
if body.trim().is_empty() {
|
||||
Ok(ToolResult::success(format!(
|
||||
"No profile built yet for {heading}. Run persona ingestion \
|
||||
first, then try again."
|
||||
)))
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "memory_flavour",
|
||||
flavour = flavour_raw,
|
||||
body_len = body.len(),
|
||||
"[memory_flavour] compiled profile returned"
|
||||
);
|
||||
Ok(ToolResult::success(body.to_string()))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
%err,
|
||||
flavour = flavour_raw,
|
||||
"[memory_flavour] failed to compile flavoured profile"
|
||||
);
|
||||
Ok(ToolResult::error(format!(
|
||||
"Failed to compile the {heading} profile: {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
%err,
|
||||
flavour = flavour_raw,
|
||||
"[memory_flavour] failed to look up flavoured tree"
|
||||
);
|
||||
Ok(ToolResult::error(format!(
|
||||
"Failed to look up the {heading} profile: {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config() -> (TempDir, Arc<Config>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut cfg = Config::default();
|
||||
cfg.workspace_dir = tmp.path().to_path_buf();
|
||||
(tmp, Arc::new(cfg))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
assert_eq!(tool.name(), "memory_flavour");
|
||||
assert_eq!(tool.parameters_schema()["required"], json!(["flavour"]));
|
||||
assert!(tool.parameters_schema()["properties"]["flavour"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_level_is_read_only() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_level_with_args_is_always_read_only() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
assert_eq!(
|
||||
tool.permission_level_with_args(&json!({})),
|
||||
PermissionLevel::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
tool.permission_level_with_args(&json!({"flavour": "communication"})),
|
||||
PermissionLevel::ReadOnly
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_flavour_is_error() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
let result = tool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_flavour_is_error() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
let result = tool.execute(json!({"flavour": " "})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_flavour_is_error() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
let result = tool.execute(json!({"flavour": "astrology"})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unknown flavour"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_flavour_with_no_tree_yet_returns_no_profile_message() {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
let result = tool
|
||||
.execute(json!({"flavour": "coding_style"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.is_error);
|
||||
assert!(result.output().contains("No profile built yet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aliases_are_accepted() {
|
||||
for alias in ["comms", "coding", "env", "rules", "dislikes"] {
|
||||
let (_tmp, cfg) = test_config();
|
||||
let tool = MemoryFlavourTool::new(cfg);
|
||||
let result = tool.execute(json!({"flavour": alias})).await;
|
||||
assert!(result.is_ok(), "alias `{alias}` should be accepted");
|
||||
let result = result.unwrap();
|
||||
assert!(!result.is_error, "alias `{alias}` should not error");
|
||||
assert!(result.output().contains("No profile built yet"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,6 +431,11 @@ pub fn all_tools_with_runtime(
|
||||
// #002: read-only self-diagnosis of the memory pipeline so the agent
|
||||
// can explain an empty/stalled wiki + the fix.
|
||||
Box::new(MemoryDoctorTool::new(config.clone())),
|
||||
// #5172: read-only access to the compiled persona flavour profiles
|
||||
// (communication/coding_style/stack/workflow/environment/directives/
|
||||
// anti_preferences) that persona ingestion builds but nothing
|
||||
// previously surfaced to the agent loop.
|
||||
Box::new(MemoryFlavourTool::new(config.clone())),
|
||||
Box::new(MemoryQueryTool),
|
||||
// memory_search tools — vector search, chunk context, hybrid search,
|
||||
// and previously unregistered raw store tools.
|
||||
|
||||
@@ -14838,3 +14838,50 @@ async fn json_rpc_threads_transcript_get_projects_and_paginates() {
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
/// #5172 — `memory_flavour` is a read-only agent tool exposing the compiled
|
||||
/// persona flavour profiles (communication/coding_style/stack/workflow/
|
||||
/// environment/directives/anti_preferences) that persona ingestion builds
|
||||
/// but nothing previously surfaced to the agent loop.
|
||||
///
|
||||
/// Verifies:
|
||||
/// 1. The tool is reachable under its registered name (`memory_flavour`).
|
||||
/// 2. On a fresh workspace (no persona ingestion has run, so no flavoured
|
||||
/// tree exists yet), a valid flavour slug returns a clear non-error
|
||||
/// "no profile built yet" message rather than an error or a fabricated
|
||||
/// profile.
|
||||
/// 3. An unknown flavour slug is rejected.
|
||||
#[tokio::test]
|
||||
async fn memory_flavour_agent_tool_e2e_5172() {
|
||||
use openhuman_core::openhuman::config::Config;
|
||||
use openhuman_core::openhuman::tools::traits::Tool;
|
||||
use openhuman_core::openhuman::tools::MemoryFlavourTool;
|
||||
|
||||
let _env_lock = json_rpc_e2e_env_lock();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut cfg = Config::default();
|
||||
cfg.workspace_dir = tmp.path().to_path_buf();
|
||||
let tool = MemoryFlavourTool::new(std::sync::Arc::new(cfg));
|
||||
|
||||
assert_eq!(tool.name(), "memory_flavour");
|
||||
|
||||
let result = tool
|
||||
.execute(json!({ "flavour": "coding_style" }))
|
||||
.await
|
||||
.expect("valid flavour on a fresh workspace should not error");
|
||||
assert!(
|
||||
!result.is_error,
|
||||
"an unbuilt profile must not be reported as a tool error: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
result.output().contains("No profile built yet"),
|
||||
"expected the no-profile-yet message, got: {}",
|
||||
result.output()
|
||||
);
|
||||
|
||||
let unknown = tool
|
||||
.execute(json!({ "flavour": "astrology" }))
|
||||
.await
|
||||
.expect_err("an unrecognized flavour slug must be rejected");
|
||||
assert!(unknown.to_string().contains("Unknown flavour"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user