mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(morning_briefing): inject ambient runtime + user + datetime into system prompt (#959)
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
This commit is contained in:
co-authored by
Steven Enamakel
parent
74074cd8a7
commit
800fafb080
Generated
+1
@@ -4574,6 +4574,7 @@ dependencies = [
|
||||
"hostname",
|
||||
"hound",
|
||||
"html2md",
|
||||
"iana-time-zone",
|
||||
"image",
|
||||
"landlock",
|
||||
"lettre",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -168,6 +168,7 @@ mod tests {
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
user_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
#[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<String> = HashSet::new();
|
||||
let ctx = PromptContext {
|
||||
fn ctx_with_identity(identity: Option<UserIdentity>) -> 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<String> = 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ mod tests {
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
user_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -123,6 +123,7 @@ mod tests {
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
user_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
|
||||
connected_identities_md: crate::openhuman::agent::prompts::render_connected_identities(),
|
||||
include_profile: !definition.omit_profile,
|
||||
include_memory_md: !definition.omit_memory_md,
|
||||
user_identity: None,
|
||||
};
|
||||
|
||||
let mut text = build(&ctx)
|
||||
|
||||
@@ -1275,6 +1275,7 @@ impl Agent {
|
||||
),
|
||||
include_profile: !self.omit_profile,
|
||||
include_memory_md: !self.omit_memory_md,
|
||||
user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(),
|
||||
};
|
||||
// Route through the global context manager so every
|
||||
// prompt-building call-site — main agent, sub-agent runner,
|
||||
|
||||
@@ -539,6 +539,7 @@ async fn run_typed_mode(
|
||||
connected_identities_md: crate::openhuman::agent::prompts::render_connected_identities(),
|
||||
include_profile: !definition.omit_profile,
|
||||
include_memory_md: !definition.omit_memory_md,
|
||||
user_identity: crate::openhuman::app_state::peek_cached_current_user_identity(),
|
||||
};
|
||||
|
||||
let system_prompt = match &definition.system_prompt {
|
||||
|
||||
@@ -238,6 +238,14 @@ pub struct WorkspaceSection;
|
||||
pub struct RuntimeSection;
|
||||
pub struct DateTimeSection;
|
||||
pub struct UserMemorySection;
|
||||
/// Renders the authenticated user's non-secret identity fields
|
||||
/// (`id` / `name` / `email`) into the system prompt — see issue #926.
|
||||
///
|
||||
/// Empty when [`PromptContext::user_identity`] is `None` or the
|
||||
/// identity has no populated fields. Tokens, refresh tokens, and any
|
||||
/// opaque credential material are forbidden — only the three
|
||||
/// identifying fields ship.
|
||||
pub struct UserIdentitySection;
|
||||
|
||||
/// Injects the user-specific, session-frozen workspace files
|
||||
/// (`PROFILE.md` + `MEMORY.md`), each capped at [`USER_FILE_MAX_CHARS`].
|
||||
@@ -491,15 +499,64 @@ impl PromptSection for DateTimeSection {
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserIdentity>) -> PromptContext<'static> {
|
||||
use std::sync::OnceLock;
|
||||
static EMPTY_VISIBLE: OnceLock<HashSet<String>> = 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"));
|
||||
|
||||
@@ -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<String>,
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
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<UserIdentity>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -343,6 +343,7 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option<String> {
|
||||
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),
|
||||
|
||||
@@ -357,6 +357,48 @@ async fn fetch_current_user_cached(config: &Config, token: &str) -> Result<Optio
|
||||
Ok(fetched)
|
||||
}
|
||||
|
||||
/// Synchronous, network-free peek at the cached `auth_get_me` response,
|
||||
/// returning only the identifying fields the prompt layer is allowed to
|
||||
/// embed (`id`, `name`, `email`). Tokens stay locked behind the JWT
|
||||
/// helpers — never returned through this path. See issue #926.
|
||||
///
|
||||
/// Returns `None` when no `auth_get_me` call has populated the cache
|
||||
/// yet (CLI-only flows, fresh installs, signed-out sessions). The
|
||||
/// cache TTL is **ignored** here intentionally — for prompt rendering
|
||||
/// a slightly stale identity is fine; the freshness check only
|
||||
/// matters for the snapshot RPC that fronts the React shell.
|
||||
pub fn peek_cached_current_user_identity() -> Option<crate::openhuman::agent::prompts::UserIdentity>
|
||||
{
|
||||
let cache = CURRENT_USER_CACHE.lock();
|
||||
let entry = cache.as_ref()?;
|
||||
let user = entry.user.as_object()?;
|
||||
|
||||
let pluck = |key: &str| -> Option<String> {
|
||||
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()
|
||||
|
||||
@@ -168,6 +168,7 @@ mod tests {
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
user_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user