feat(memory): expose compiled flavour profiles via a read-only memory_flavour tool (#5175)

This commit is contained in:
Cyrus Gray
2026-07-24 20:31:33 +05:30
committed by GitHub
parent 55d3ab5ccc
commit 2d6064cb09
10 changed files with 387 additions and 1 deletions
@@ -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.
+2
View File
@@ -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;
+307
View File
@@ -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"));
}
}
}
+5
View File
@@ -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.
+47
View File
@@ -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"));
}