Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/openhuman/agent_memory/agent/agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
4 changes: 4 additions & 0 deletions src/openhuman/agent_memory/agent/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/openhuman/agent_registry/agents/context_scout/agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/openhuman/agent_registry/agents/context_scout/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/openhuman/memory/tools.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 307 additions & 0 deletions src/openhuman/memory/tools/flavour.rs
Original file line number Diff line number Diff line change
@@ -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,
},
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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()));
}
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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}"
)))
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +153 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Lookup failure is mislabeled as a compile failure.

The Err(err) arm at Line 157 (from get_tree_by_scope, a tree-lookup failure) reuses the same "Profile for {heading} could not be compiled: {err}" message as the genuine compile failure at Line 146. An agent/user reading this will think compilation broke when the tree lookup itself failed, which muddies troubleshooting.

🐛 Proposed fix to distinguish lookup vs compile failures
             Err(err) => {
                 tracing::warn!(
                     %err,
                     flavour = flavour_raw,
                     "[memory_flavour] failed to look up flavoured tree"
                 );
                 Ok(ToolResult::success(format!(
-                    "Profile for {heading} could not be compiled: {err}"
+                    "Profile for {heading} could not be looked up: {err}"
                 )))
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match get_tree_by_scope(&mc, TreeKind::Flavoured, &scope) {
Ok(None) => Ok(ToolResult::success(format!(
"No profile built yet for {heading}. Run persona ingestion first, then try \
again."
))),
Ok(Some(tree)) => match compile_flavoured_root(&mc, &tree.id) {
Ok(markdown) => {
if body_after_front_matter(&markdown).trim().is_empty() {
Ok(ToolResult::success(format!(
"No profile built yet for {heading}. Run persona ingestion first, \
then try again."
)))
} else {
Ok(ToolResult::success(markdown))
}
}
Err(err) => {
tracing::warn!(
%err,
flavour = flavour_raw,
"[memory_flavour] failed to compile flavoured profile"
);
Ok(ToolResult::success(format!(
"Profile for {heading} could not be compiled: {err}"
)))
}
},
Err(err) => {
tracing::warn!(
%err,
flavour = flavour_raw,
"[memory_flavour] failed to look up flavoured tree"
);
Ok(ToolResult::success(format!(
"Profile for {heading} could not be compiled: {err}"
)))
}
}
match get_tree_by_scope(&mc, TreeKind::Flavoured, &scope) {
Ok(None) => Ok(ToolResult::success(format!(
"No profile built yet for {heading}. Run persona ingestion first, then try \
again."
))),
Ok(Some(tree)) => match compile_flavoured_root(&mc, &tree.id) {
Ok(markdown) => {
if body_after_front_matter(&markdown).trim().is_empty() {
Ok(ToolResult::success(format!(
"No profile built yet for {heading}. Run persona ingestion first, \
then try again."
)))
} else {
Ok(ToolResult::success(markdown))
}
}
Err(err) => {
tracing::warn!(
%err,
flavour = flavour_raw,
"[memory_flavour] failed to compile flavoured profile"
);
Ok(ToolResult::success(format!(
"Profile for {heading} could not be compiled: {err}"
)))
}
},
Err(err) => {
tracing::warn!(
%err,
flavour = flavour_raw,
"[memory_flavour] failed to look up flavoured tree"
);
Ok(ToolResult::success(format!(
"Profile for {heading} could not be looked up: {err}"
)))
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/memory/tools/flavour.rs` around lines 130 - 167, Update the
outer Err(err) arm handling get_tree_by_scope in the flavoured-profile lookup
flow to report a tree lookup failure rather than reusing the compile-failure
message. Keep the existing compile-specific message in the
compile_flavoured_root error arm unchanged, and preserve the current error
details and successful result behavior.

}
}

#[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 changes: 5 additions & 0 deletions src/openhuman/tools/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add memory_flavour to the named agent allowlists

Registering the tool here makes it part of the global registry, but normal chat sessions are built against the orchestrator definition and memory work is delegated to agent_memory/profile_memory_agent, all of which use ToolScope::Named allowlists. I checked those TOML allowlists and none include memory_flavour, so in the actual CLI/Tauri chat paths the harness filters this tool out and the model never sees or calls it; only wildcard/custom sessions would. Please add the tool to the appropriate named allowlist(s) and prompt guidance, or this feature remains unreachable for the intended agent loop.

Useful? React with 👍 / 👎.

Box::new(MemoryQueryTool),
// memory_search tools — vector search, chunk context, hybrid search,
// and previously unregistered raw store tools.
Expand Down
Loading
Loading