From 800fafb080438ef1587d037b5b7e26d83c7b20cd Mon Sep 17 00:00:00 2001 From: obchain <167975049+obchain@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:07:57 +0530 Subject: [PATCH] feat(morning_briefing): inject ambient runtime + user + datetime into system prompt (#959) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + .../agent/agents/archivist/prompt.rs | 1 + .../agent/agents/code_executor/prompt.rs | 1 + src/openhuman/agent/agents/critic/prompt.rs | 1 + src/openhuman/agent/agents/help/prompt.rs | 1 + .../agent/agents/integrations_agent/prompt.rs | 1 + src/openhuman/agent/agents/loader.rs | 1 + .../agent/agents/morning_briefing/prompt.md | 2 +- .../agent/agents/morning_briefing/prompt.rs | 119 ++++++++++++++-- .../agent/agents/orchestrator/prompt.rs | 1 + src/openhuman/agent/agents/planner/prompt.rs | 1 + .../agent/agents/researcher/prompt.rs | 1 + .../agent/agents/summarizer/prompt.rs | 1 + .../agent/agents/tool_maker/prompt.rs | 1 + .../agent/agents/tools_agent/prompt.rs | 1 + .../agent/agents/trigger_reactor/prompt.rs | 1 + .../agent/agents/trigger_triage/prompt.rs | 1 + src/openhuman/agent/agents/welcome/prompt.rs | 1 + src/openhuman/agent/debug/mod.rs | 1 + src/openhuman/agent/harness/session/turn.rs | 1 + .../agent/harness/subagent_runner/ops.rs | 1 + src/openhuman/agent/prompts/mod.rs | 99 ++++++++++++- src/openhuman/agent/prompts/mod_tests.rs | 130 ++++++++++++++++++ src/openhuman/agent/prompts/types.rs | 31 +++++ src/openhuman/agent/triage/evaluator.rs | 1 + src/openhuman/app_state/ops.rs | 42 ++++++ src/openhuman/learning/prompt_sections.rs | 1 + 28 files changed, 427 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d48d5a324..275d7eef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4574,6 +4574,7 @@ dependencies = [ "hostname", "hound", "html2md", + "iana-time-zone", "image", "landlock", "lettre", diff --git a/Cargo.toml b/Cargo.toml index 2f7f976bb..a6c771898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures = "0.3" rusqlite = { version = "0.37", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } +iana-time-zone = "0.1" cron = "0.12" futures-util = "0.3" directories = "6" diff --git a/src/openhuman/agent/agents/archivist/prompt.rs b/src/openhuman/agent/agents/archivist/prompt.rs index 3e1bdbaaa..c1128b312 100644 --- a/src/openhuman/agent/agents/archivist/prompt.rs +++ b/src/openhuman/agent/agents/archivist/prompt.rs @@ -61,6 +61,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/code_executor/prompt.rs b/src/openhuman/agent/agents/code_executor/prompt.rs index 724399356..0327753df 100644 --- a/src/openhuman/agent/agents/code_executor/prompt.rs +++ b/src/openhuman/agent/agents/code_executor/prompt.rs @@ -65,6 +65,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/critic/prompt.rs b/src/openhuman/agent/agents/critic/prompt.rs index 227e45582..257446aee 100644 --- a/src/openhuman/agent/agents/critic/prompt.rs +++ b/src/openhuman/agent/agents/critic/prompt.rs @@ -60,6 +60,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/help/prompt.rs b/src/openhuman/agent/agents/help/prompt.rs index 8d738de80..4faf15bf0 100644 --- a/src/openhuman/agent/agents/help/prompt.rs +++ b/src/openhuman/agent/agents/help/prompt.rs @@ -60,6 +60,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.rs b/src/openhuman/agent/agents/integrations_agent/prompt.rs index 6588386bd..8dcc31f19 100644 --- a/src/openhuman/agent/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent/agents/integrations_agent/prompt.rs @@ -168,6 +168,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, } } diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index d5eb6d240..0b7ad18d1 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -266,6 +266,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx) .unwrap_or_else(|e| panic!("{} prompt build failed: {e}", def.id)); diff --git a/src/openhuman/agent/agents/morning_briefing/prompt.md b/src/openhuman/agent/agents/morning_briefing/prompt.md index f8ee62b8c..c73492798 100644 --- a/src/openhuman/agent/agents/morning_briefing/prompt.md +++ b/src/openhuman/agent/agents/morning_briefing/prompt.md @@ -31,6 +31,6 @@ Prepare a morning briefing that helps the user start their day with clarity. Pul ## Rules - **Never fabricate events, emails, or tasks.** Only include data you actually retrieved from tools or memory. -- **Respect time zones.** Use the user's local time if known from memory; otherwise default to UTC and note it. +- **Respect time zones.** The system prompt below carries the user's local date/time and IANA timezone — read it from there. Do **not** ask the user to repeat their timezone; only fall back to UTC and note it if the system context is genuinely missing the field. - **No stale data.** If a tool call fails or returns empty, say so — don't fall back to yesterday's data. - **Privacy first.** Don't include full email bodies or message contents. Summarize senders and subjects. diff --git a/src/openhuman/agent/agents/morning_briefing/prompt.rs b/src/openhuman/agent/agents/morning_briefing/prompt.rs index f90cd434e..fe1ae5985 100644 --- a/src/openhuman/agent/agents/morning_briefing/prompt.rs +++ b/src/openhuman/agent/agents/morning_briefing/prompt.rs @@ -6,7 +6,7 @@ //! post-processing in the runner. use crate::openhuman::context::prompt::{ - render_datetime, render_tools, render_user_files, render_workspace, PromptContext, + render_ambient_environment, render_tools, render_user_files, render_workspace, PromptContext, }; use anyhow::Result; @@ -29,15 +29,21 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { out.push_str("\n\n"); } - let datetime = render_datetime(ctx)?; - if !datetime.trim().is_empty() { - out.push_str(datetime.trim_end()); - out.push_str("\n\n"); - } - let workspace = render_workspace(ctx)?; if !workspace.trim().is_empty() { out.push_str(workspace.trim_end()); + out.push_str("\n\n"); + } + + // Ambient runtime + user identity + current date/time so the + // briefing agent stops asking the user "what timezone are you in?" + // when the desktop app already knows — issue #926. Block sits at + // the prompt tail because the embedded `Local::now()` makes it + // time-volatile, matching the KV cache convention from + // `SystemPromptBuilder::with_defaults`. + let ambient = render_ambient_environment(ctx)?; + if !ambient.trim().is_empty() { + out.push_str(ambient.trim_end()); out.push('\n'); } @@ -47,13 +53,16 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat, UserIdentity}; use std::collections::HashSet; - #[test] - fn build_returns_nonempty_body() { - let visible: HashSet = HashSet::new(); - let ctx = PromptContext { + fn ctx_with_identity(identity: Option) -> PromptContext<'static> { + // SAFETY note: the empty visible-set is leaked once via a + // `Box::leak` so it can satisfy the `'static` lifetime on the + // returned context — these tests are short-lived and the + // singleton allocation costs nothing on the hot path. + let visible: &'static HashSet = Box::leak(Box::new(HashSet::new())); + PromptContext { workspace_dir: std::path::Path::new("."), model_name: "test", agent_id: "morning_briefing", @@ -61,14 +70,94 @@ mod tests { skills: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), - visible_tool_names: &visible, + visible_tool_names: visible, tool_call_format: ToolCallFormat::PFormat, connected_integrations: &[], connected_identities_md: String::new(), include_profile: false, include_memory_md: false, - }; - let body = build(&ctx).unwrap(); + user_identity: identity, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&ctx_with_identity(None)).unwrap(); assert!(!body.is_empty()); } + + #[test] + fn build_includes_runtime_and_datetime_sections() { + // Issue #926: the morning briefing must carry the host's + // current date/time + IANA timezone + runtime in its system + // prompt so the agent never asks the user "what timezone are + // you in?". This test pins the wiring at the parse layer so a + // future edit that drops `render_ambient_environment` from + // the builder fails loudly here. + let body = build(&ctx_with_identity(None)).unwrap(); + assert!( + body.contains("## Runtime"), + "morning_briefing prompt must carry `## Runtime` (host + OS) so the model \ + knows which device it's on; got:\n{body}" + ); + assert!( + body.contains("## Current Date & Time"), + "morning_briefing prompt must carry `## Current Date & Time` (#926); got:\n{body}" + ); + // IANA zone — either a slashed zone (`America/Los_Angeles`) + // or the `UTC` fallback for hosts where `iana-time-zone` + // can't resolve one. Keying the assertion on this catches + // any regression that switches `DateTimeSection` back to a + // bare `%Z` abbreviation. + let dt = body + .split("## Current Date & Time") + .nth(1) + .expect("datetime section must follow its heading"); + // `" UTC "` (space-bounded) — not bare `"UTC"` — because the + // format string always emits a `UTC{offset}` literal in the + // suffix (`UTC-07:00`), so a substring check on `"UTC"` alone + // is trivially satisfied even by a bare-`%Z` regression. + // Either a slashed IANA zone (`America/Los_Angeles`) or the + // explicit space-bounded `" UTC "` fallback must appear before + // the offset. + assert!( + dt.contains('/') || dt.contains(" UTC "), + "datetime section must include IANA zone or `UTC` fallback (a bare \ + `UTC-07:00` offset isn't enough — that's the locale-independent \ + offset, not the IANA zone); got:\n{dt}" + ); + } + + #[test] + fn build_includes_user_identity_when_present() { + // When the auth cache has populated `user_identity`, the + // briefing prompt must surface those fields so the agent can + // greet the user by name and address mail without asking. + let identity = UserIdentity { + id: Some("u_42".to_string()), + name: Some("Ada Lovelace".to_string()), + email: Some("ada@example.com".to_string()), + }; + let body = build(&ctx_with_identity(Some(identity))).unwrap(); + assert!(body.contains("## User")); + assert!(body.contains("- name: Ada Lovelace")); + assert!(body.contains("- email: ada@example.com")); + // The `## User` block must NEVER carry token / refresh fields — + // only id / name / email by construction. Sanity-check here so + // a future field addition forces a deliberate test update. + assert!( + !body.to_lowercase().contains("token"), + "user identity block must never embed token fields; got:\n{body}" + ); + } + + #[test] + fn build_omits_user_section_when_identity_unset() { + let body = build(&ctx_with_identity(None)).unwrap(); + assert!( + !body.contains("## User\n"), + "user section must be empty when no auth cache is populated (CLI flows, \ + signed-out sessions); got:\n{body}" + ); + } } diff --git a/src/openhuman/agent/agents/orchestrator/prompt.rs b/src/openhuman/agent/agents/orchestrator/prompt.rs index b77772f14..207a51491 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent/agents/orchestrator/prompt.rs @@ -117,6 +117,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, } } diff --git a/src/openhuman/agent/agents/planner/prompt.rs b/src/openhuman/agent/agents/planner/prompt.rs index 0f233568c..e0c0636ac 100644 --- a/src/openhuman/agent/agents/planner/prompt.rs +++ b/src/openhuman/agent/agents/planner/prompt.rs @@ -67,6 +67,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/researcher/prompt.rs b/src/openhuman/agent/agents/researcher/prompt.rs index b033f83b3..029227e49 100644 --- a/src/openhuman/agent/agents/researcher/prompt.rs +++ b/src/openhuman/agent/agents/researcher/prompt.rs @@ -61,6 +61,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/summarizer/prompt.rs b/src/openhuman/agent/agents/summarizer/prompt.rs index 373ea8fcc..a7b06dd5d 100644 --- a/src/openhuman/agent/agents/summarizer/prompt.rs +++ b/src/openhuman/agent/agents/summarizer/prompt.rs @@ -61,6 +61,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/tool_maker/prompt.rs b/src/openhuman/agent/agents/tool_maker/prompt.rs index 511bfe63b..0e1fc479e 100644 --- a/src/openhuman/agent/agents/tool_maker/prompt.rs +++ b/src/openhuman/agent/agents/tool_maker/prompt.rs @@ -65,6 +65,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/tools_agent/prompt.rs b/src/openhuman/agent/agents/tools_agent/prompt.rs index d301106c7..2d766ac3a 100644 --- a/src/openhuman/agent/agents/tools_agent/prompt.rs +++ b/src/openhuman/agent/agents/tools_agent/prompt.rs @@ -62,6 +62,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/trigger_reactor/prompt.rs b/src/openhuman/agent/agents/trigger_reactor/prompt.rs index fd68b4749..ce0843ee7 100644 --- a/src/openhuman/agent/agents/trigger_reactor/prompt.rs +++ b/src/openhuman/agent/agents/trigger_reactor/prompt.rs @@ -61,6 +61,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/trigger_triage/prompt.rs b/src/openhuman/agent/agents/trigger_triage/prompt.rs index b303e9951..59b27d379 100644 --- a/src/openhuman/agent/agents/trigger_triage/prompt.rs +++ b/src/openhuman/agent/agents/trigger_triage/prompt.rs @@ -61,6 +61,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let body = build(&ctx).unwrap(); assert!(!body.is_empty()); diff --git a/src/openhuman/agent/agents/welcome/prompt.rs b/src/openhuman/agent/agents/welcome/prompt.rs index 85c6f8f91..67f795b49 100644 --- a/src/openhuman/agent/agents/welcome/prompt.rs +++ b/src/openhuman/agent/agents/welcome/prompt.rs @@ -123,6 +123,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, } } diff --git a/src/openhuman/agent/debug/mod.rs b/src/openhuman/agent/debug/mod.rs index 328295e54..feed317dd 100644 --- a/src/openhuman/agent/debug/mod.rs +++ b/src/openhuman/agent/debug/mod.rs @@ -392,6 +392,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result) -> Result { + // IANA zone first because it's the unambiguous machine-readable + // form (`America/Los_Angeles`) — agents that need to reason about + // timezone rules should grep this, not the locale-dependent + // `%Z` abbreviation. Falls back to "UTC" when the host can't + // resolve a zone (CI, stripped containers). + let iana = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string()); let now = Local::now(); Ok(format!( - "## Current Date & Time\n\n{} ({})", + "## Current Date & Time\n\n{} {} ({}, UTC{})", now.format("%Y-%m-%d %H:%M:%S"), - now.format("%Z") + iana, + now.format("%Z"), + now.format("%:z"), )) } } +impl PromptSection for UserIdentitySection { + fn name(&self) -> &str { + "user_identity" + } + + fn build(&self, ctx: &PromptContext<'_>) -> Result { + let identity = match ctx.user_identity.as_ref() { + Some(id) if !id.is_empty() => id, + _ => return Ok(String::new()), + }; + + // Render the field list FIRST, then decide whether to ship the + // heading. `UserIdentity::is_empty()` only checks `None`-ness — + // a struct whose fields are all `Some("")` / whitespace would + // otherwise leave the prompt with a `## User` heading + intro + // pointing at zero fields, which is exactly the empty-prompt + // failure mode we're trying to suppress (#926). + let mut fields = String::new(); + if let Some(name) = identity.name.as_deref().filter(|s| !s.trim().is_empty()) { + let _ = writeln!(fields, "- name: {name}"); + } + if let Some(email) = identity.email.as_deref().filter(|s| !s.trim().is_empty()) { + let _ = writeln!(fields, "- email: {email}"); + } + if let Some(id) = identity.id.as_deref().filter(|s| !s.trim().is_empty()) { + let _ = writeln!(fields, "- id: {id}"); + } + if fields.trim().is_empty() { + return Ok(String::new()); + } + + let mut out = String::from("## User\n\n"); + out.push_str( + "The signed-in user is identified below. Use these fields directly in tool \ + calls and do not ask the user to repeat them.\n\n", + ); + out.push_str(&fields); + Ok(out.trim_end().to_string()) + } +} + // ───────────────────────────────────────────────────────────────────────────── // Section helpers for function-driven prompts // ───────────────────────────────────────────────────────────────────────────── @@ -569,6 +626,43 @@ pub fn render_datetime(ctx: &PromptContext<'_>) -> Result { DateTimeSection.build(ctx) } +/// Render the `## User` identity block. Empty when +/// [`PromptContext::user_identity`] is unset or has no populated +/// fields. See issue #926. +pub fn render_user_identity(ctx: &PromptContext<'_>) -> Result { + UserIdentitySection.build(ctx) +} + +/// Compose the full ambient-environment block — runtime + user +/// identity + current date/time, in that order. +/// +/// Per-agent `prompt.rs` builders call this once near the end of their +/// assembly so every agent reports the same machine-readable view of +/// "where am I, who is the user, what time is it" (issue #926). +/// Datetime is appended last so the time-volatile section sits at the +/// tail of the prompt and the rest of the prefix stays cache-stable +/// across turns within the same minute, matching the convention used +/// by [`SystemPromptBuilder::with_defaults`]. +pub fn render_ambient_environment(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(512); + let runtime = render_runtime(ctx)?; + if !runtime.trim().is_empty() { + out.push_str(runtime.trim_end()); + out.push_str("\n\n"); + } + let user = render_user_identity(ctx)?; + if !user.trim().is_empty() { + out.push_str(user.trim_end()); + out.push_str("\n\n"); + } + let datetime = render_datetime(ctx)?; + if !datetime.trim().is_empty() { + out.push_str(datetime.trim_end()); + out.push('\n'); + } + Ok(out) +} + /// Build a throwaway `PromptContext` for sections whose `build` only /// uses static/immutable inputs (currently just `SafetySection`). Keeps /// the `render_safety()` free function from forcing callers to @@ -596,6 +690,7 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, } } diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index cc28428b1..6fd8f6770 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -48,6 +48,7 @@ fn prompt_builder_assembles_sections() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); assert!(rendered.contains("## Tools")); @@ -77,6 +78,7 @@ fn identity_section_creates_missing_workspace_files() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let section = IdentitySection; @@ -115,6 +117,7 @@ fn datetime_section_includes_timestamp_and_timezone() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = DateTimeSection.build(&ctx).unwrap(); @@ -124,6 +127,126 @@ fn datetime_section_includes_timestamp_and_timezone() { assert!(payload.chars().any(|c| c.is_ascii_digit())); assert!(payload.contains(" (")); assert!(payload.ends_with(')')); + // IANA zone is included so agents can reason about the host's + // timezone without parsing a locale-dependent abbreviation. Either + // a slashed zone (`America/Los_Angeles`) or the `UTC` fallback for + // hosts where `iana-time-zone` can't resolve one. + assert!( + payload.contains('/') || payload.contains(" UTC "), + "rendered payload missing IANA timezone: {payload}" + ); + assert!(payload.contains("UTC"), "missing UTC offset: {payload}"); +} + +fn ctx_with_identity(identity: Option) -> PromptContext<'static> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + let visible = EMPTY_VISIBLE.get_or_init(HashSet::new); + static EMPTY_TOOLS: &[PromptTool<'static>] = &[]; + static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[]; + PromptContext { + workspace_dir: Path::new("/tmp"), + model_name: "test-model", + agent_id: "", + tools: EMPTY_TOOLS, + skills: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: visible, + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: EMPTY_INTEGRATIONS, + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + user_identity: identity, + } +} + +#[test] +fn user_identity_section_empty_when_unset() { + let ctx = ctx_with_identity(None); + let rendered = UserIdentitySection.build(&ctx).unwrap(); + assert!(rendered.is_empty()); +} + +#[test] +fn user_identity_section_renders_populated_fields_only() { + let identity = UserIdentity { + id: Some("u_42".to_string()), + name: Some("Ada Lovelace".to_string()), + email: None, + }; + let ctx = ctx_with_identity(Some(identity)); + let rendered = UserIdentitySection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## User\n\n")); + assert!(rendered.contains("- name: Ada Lovelace")); + assert!(rendered.contains("- id: u_42")); + assert!( + !rendered.contains("email:"), + "empty email field must be skipped — leaking placeholders \ + confuses agents into asking the user to confirm them" + ); +} + +#[test] +fn user_identity_section_skips_when_every_field_is_blank() { + // Backend payloads that arrive with every field set to an empty + // or whitespace string would otherwise pass the `is_empty()` + // guard (None-only) and leave the prompt with an orphan + // `## User` heading + intro paragraph pointing at zero fields — + // exactly the failure mode the section is meant to suppress. + let identity = UserIdentity { + id: Some(String::new()), + name: Some(" ".to_string()), + email: Some("\t".to_string()), + }; + let ctx = ctx_with_identity(Some(identity)); + let rendered = UserIdentitySection.build(&ctx).unwrap(); + assert!( + rendered.is_empty(), + "all-blank identity must produce no output, got:\n{rendered}" + ); +} + +#[test] +fn user_identity_section_skips_blank_strings() { + // Backend payloads sometimes carry empty-string fields rather than + // null. Treat both the same so the prompt never renders + // `- email: ` (which would invite the agent to "confirm" the + // missing value with the user). + let identity = UserIdentity { + id: Some(" ".to_string()), + name: Some(String::new()), + email: Some("ada@example.com".to_string()), + }; + let ctx = ctx_with_identity(Some(identity)); + let rendered = UserIdentitySection.build(&ctx).unwrap(); + assert!(rendered.starts_with("## User\n\n")); + assert!(rendered.contains("- email: ada@example.com")); + assert!(!rendered.contains("- name:")); + assert!(!rendered.contains("- id:")); +} + +#[test] +fn ambient_environment_orders_runtime_user_datetime() { + let identity = UserIdentity { + id: None, + name: Some("Ada".to_string()), + email: None, + }; + let ctx = ctx_with_identity(Some(identity)); + let rendered = render_ambient_environment(&ctx).unwrap(); + let runtime_pos = rendered.find("## Runtime").expect("runtime missing"); + let user_pos = rendered.find("## User").expect("user missing"); + let dt_pos = rendered + .find("## Current Date & Time") + .expect("datetime missing"); + assert!( + runtime_pos < user_pos && user_pos < dt_pos, + "ambient block must order runtime → user → datetime so the \ + time-volatile section sits at the prompt tail (KV cache \ + convention from `with_defaults`); got:\n{rendered}" + ); } #[test] @@ -173,6 +296,7 @@ fn tools_section_pformat_renders_signature_not_schema() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = ToolsSection.build(&ctx).unwrap(); @@ -217,6 +341,7 @@ fn tools_section_uses_pformat_signature_for_every_dispatcher() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = ToolsSection.build(&ctx).unwrap(); assert!( @@ -257,6 +382,7 @@ fn user_memory_section_renders_namespaces_with_headings() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.starts_with("## User Memory\n\n")); @@ -285,6 +411,7 @@ fn user_memory_section_returns_empty_when_no_summaries() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.is_empty()); @@ -932,6 +1059,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { connected_identities_md: String::new(), include_profile: true, include_memory_md: true, + user_identity: None, }; // Mirror the welcome agent runtime path: @@ -973,6 +1101,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let narrow = builder.build(&ctx_narrow).unwrap(); assert!( @@ -1051,6 +1180,7 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; let rendered = UserMemorySection.build(&ctx).unwrap(); assert!(rendered.contains("### user")); diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index 64653d18e..a74085cdc 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -153,6 +153,32 @@ pub enum ToolCallFormat { Native, } +// ───────────────────────────────────────────────────────────────────────────── +// Authenticated user identity +// ───────────────────────────────────────────────────────────────────────────── + +/// Non-secret user identity fields surfaced to the prompt layer so +/// agents stop asking the user for information the app already has — +/// see issue #926. +/// +/// Only **identifying** fields land here; tokens, refresh tokens, and +/// any opaque credential material are forbidden. The struct is +/// constructed from the cached `auth_get_me` response in +/// `app_state::ops::peek_cached_current_user_identity`, which strips +/// everything but `id` / `email` / `name` before returning. +#[derive(Debug, Clone, Default)] +pub struct UserIdentity { + pub id: Option, + pub name: Option, + pub email: Option, +} + +impl UserIdentity { + pub fn is_empty(&self) -> bool { + self.id.is_none() && self.name.is_none() && self.email.is_none() + } +} + // ───────────────────────────────────────────────────────────────────────────── // Prompt context (everything a section needs) // ───────────────────────────────────────────────────────────────────────────── @@ -182,6 +208,11 @@ pub struct PromptContext<'a> { /// When `true`, inject `MEMORY.md` (archivist-curated long-term /// memory). Capped at [`USER_FILE_MAX_CHARS`] and frozen per session. pub include_memory_md: bool, + /// Authenticated user identity (id/name/email) when available — see + /// [`UserIdentity`]. `None` for unauthenticated paths (CLI without a + /// session, tests). Pre-fetched by the caller from the + /// `auth_get_me` cache so prompt builders never reach the network. + pub user_identity: Option, } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index 130a6dae2..35527b2b9 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -343,6 +343,7 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, }; match build(&ctx) { Ok(body) if !body.is_empty() => Some(body), diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index 4ac2b04b1..efc9b8c2a 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -357,6 +357,48 @@ async fn fetch_current_user_cached(config: &Config, token: &str) -> Result Option +{ + let cache = CURRENT_USER_CACHE.lock(); + let entry = cache.as_ref()?; + let user = entry.user.as_object()?; + + let pluck = |key: &str| -> Option { + user.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + + let id = pluck("id") + .or_else(|| pluck("user_id")) + .or_else(|| pluck("userId")); + let name = pluck("name") + .or_else(|| pluck("displayName")) + .or_else(|| pluck("display_name")) + .or_else(|| pluck("full_name")) + .or_else(|| pluck("fullName")); + let email = pluck("email"); + + let identity = crate::openhuman::agent::prompts::UserIdentity { id, name, email }; + if identity.is_empty() { + None + } else { + Some(identity) + } +} + async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot { let screen_intelligence = { let _ = crate::openhuman::screen_intelligence::global_engine() diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index 8789a4c5e..1a108bb97 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -168,6 +168,7 @@ mod tests { connected_identities_md: String::new(), include_profile: false, include_memory_md: false, + user_identity: None, } }