diff --git a/src/openhuman/agent_memory/agent/agent.toml b/src/openhuman/agent_memory/agent/agent.toml index 2e0c4d2e0..cef7ee4c5 100644 --- a/src/openhuman/agent_memory/agent/agent.toml +++ b/src/openhuman/agent_memory/agent/agent.toml @@ -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", ] diff --git a/src/openhuman/agent_memory/agent/prompt.md b/src/openhuman/agent_memory/agent/prompt.md index 7adfa8c1b..4c83691e3 100644 --- a/src/openhuman/agent_memory/agent/prompt.md +++ b/src/openhuman/agent_memory/agent/prompt.md @@ -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 diff --git a/src/openhuman/agent_registry/agents/context_scout/agent.toml b/src/openhuman/agent_registry/agents/context_scout/agent.toml index 9bc984ca7..2123d0d0e 100644 --- a/src/openhuman/agent_registry/agents/context_scout/agent.toml +++ b/src/openhuman/agent_registry/agents/context_scout/agent.toml @@ -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", diff --git a/src/openhuman/agent_registry/agents/context_scout/prompt.md b/src/openhuman/agent_registry/agents/context_scout/prompt.md index 69ee42327..365406d2f 100644 --- a/src/openhuman/agent_registry/agents/context_scout/prompt.md +++ b/src/openhuman/agent_registry/agents/context_scout/prompt.md @@ -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 diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml index f7d4dfce1..e678a5d94 100644 --- a/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml @@ -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", ] diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md index 1f38cb6ac..2902de0c6 100644 --- a/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/prompt.md @@ -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. diff --git a/src/openhuman/memory/tools.rs b/src/openhuman/memory/tools.rs index 176a5d598..6e2fd5aae 100644 --- a/src/openhuman/memory/tools.rs +++ b/src/openhuman/memory/tools.rs @@ -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; diff --git a/src/openhuman/memory/tools/flavour.rs b/src/openhuman/memory/tools/flavour.rs new file mode 100644 index 000000000..00c19c20f --- /dev/null +++ b/src/openhuman/memory/tools/flavour.rs @@ -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, +} + +impl MemoryFlavourTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +/// Strip the YAML front matter written by [`compile_flavoured_root`] +/// (`---\n...\n---\n`) 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 { + 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) { + 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")); + } + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 5de0cdfb4..dfed55971 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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. diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 85b74751e..2f5a878c1 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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")); +}