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:
obchain
2026-04-29 11:37:57 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 74074cd8a7
commit 800fafb080
28 changed files with 427 additions and 18 deletions
Generated
+1
View File
@@ -4574,6 +4574,7 @@ dependencies = [
"hostname",
"hound",
"html2md",
"iana-time-zone",
"image",
"landlock",
"lettre",
+1
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -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 {
+97 -2
View File
@@ -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,
}
}
+130
View File
@@ -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"));
+31
View File
@@ -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>,
}
// ─────────────────────────────────────────────────────────────────────────────
+1
View File
@@ -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),
+42
View File
@@ -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,
}
}