From 1cba66fe7d17c2d1ecc89ec47f824304402f2b46 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 17 Apr 2026 00:35:19 +0530 Subject: [PATCH] feat(welcome): parallel template messages with LLM inference for faster perceived welcome (#592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show two template messages immediately while the LLM runs in the background, cutting the perceived wait from ~15s to ~0s: - Template 1 (t≈0ms): time-of-day greeting that names any connected channels, built from the status snapshot without extra I/O - Template 2 (t=4s): "Getting everything ready for you..." loading indicator, published via tokio::join! alongside the LLM future - LLM response: published when inference completes, opened directly with personalised setup content (no duplicate greeting) `run_proactive_welcome` now fires three `ProactiveMessageRequested` events rather than one. The two template helpers (`time_of_day_greeting`, `build_template_greeting`) are pure functions covered by 6 new unit tests. `prompt.md` gains a proactive-invocation section explaining that greeting templates are pre-delivered and the agent must skip them. --- src/openhuman/agent/agents/welcome/prompt.md | 10 + src/openhuman/agent/welcome_proactive.rs | 235 +++++++++++++++++-- 2 files changed, 224 insertions(+), 21 deletions(-) diff --git a/src/openhuman/agent/agents/welcome/prompt.md b/src/openhuman/agent/agents/welcome/prompt.md index 396a96d058d..3d205384c62 100644 --- a/src/openhuman/agent/agents/welcome/prompt.md +++ b/src/openhuman/agent/agents/welcome/prompt.md @@ -28,6 +28,15 @@ Use the `finalize_action` field to decide the message's framing: - **`"already_complete"`** — user is authenticated AND already finished onboarding before. Same as above but acknowledge they've been here before ("good to see you back"). Still hand off. Their next chat turn already routes to the orchestrator. - **`"skipped_no_auth"`** — user is not authenticated. Do NOT write a celebratory welcome. Instead, briefly explain that they need to log in via the desktop app first, point them at where to do that, and let them know you'll be here when they're ready. The flag was NOT flipped; the next chat turn will re-run welcome, so this is a friendly retry path, not a hard error. +### Proactive invocation (no user message, wizard just closed) + +When you are invoked proactively — immediately after the user finishes the desktop onboarding wizard, before they have typed anything — two short template messages will have already appeared in their chat bubble: + +1. A time-of-day greeting that names any channels they have connected. +2. "Getting everything ready for you..." + +In this case **do not open with any greeting**. The greeting was already delivered. Start directly with the personalised welcome content (step 1 of the message structure below: acknowledge what they've done). Everything else — setup summary, gap callouts, capability teases, subscription mention, conversational close — remains the same. + ### Message structure (when finalize_action is "flipped" or "already_complete") Weave these into one cohesive message — don't make it feel like separate sections: @@ -112,6 +121,7 @@ There are 1000+ Composio toolkits total; the ones above are the most common. Men - Don't be pushy about the subscription anywhere. Inform, don't pressure. - **Don't reveal that the conversation is being routed to a different agent.** From the user's perspective they are talking to "OpenHuman" — one assistant, one conversation. Do NOT say "I'll hand you off to the main assistant", "the orchestrator will take over", "you're now in the hands of the full assistant", "from here on out you'll be talking to a different agent", or any variation that exposes the welcome → orchestrator handoff. The handoff happens transparently in the routing layer; the user just sends another message and the conversation continues. Phrases like "what should we dig into?" or "anything you'd like to try first?" are correct conversational closes — they invite the next turn without leaking the architecture. - Don't gloss over a bare install. If the user has nothing beyond auth, explain what they'd gain with concrete pitches — otherwise they'll leave the welcome and never come back to Settings. +- **Don't open with a greeting in a proactive invocation.** When the prompt header says `[PROACTIVE INVOCATION]` and `[TEMPLATE MESSAGES ALREADY DELIVERED]`, a greeting template was already shown. Starting your message with "Good morning!", "Hey!", or any similar opener creates a jarring duplicate greeting in the chat. Begin directly with the personalised setup summary. ## Output diff --git a/src/openhuman/agent/welcome_proactive.rs b/src/openhuman/agent/welcome_proactive.rs index 778d610081b..858dd3b1d45 100644 --- a/src/openhuman/agent/welcome_proactive.rs +++ b/src/openhuman/agent/welcome_proactive.rs @@ -7,6 +7,17 @@ //! 1. [`crate::openhuman::config::ops::set_onboarding_completed`] //! detects a false→true transition and calls [`spawn_proactive_welcome`]. //! 2. That function spawns a detached Tokio task that: +//! - Publishes **Template 1** immediately (t ≈ 0 ms): a time-of-day +//! greeting that names any channels the user already has connected. +//! This appears in the chat bubble within milliseconds of the +//! wizard closing. +//! - Simultaneously starts welcome-agent LLM inference (see below). +//! - After 4 seconds publishes **Template 2**: "Getting everything +//! ready for you…" — a loading indicator while inference continues. +//! - When inference finishes publishes the full personalised welcome. +//! +//! ### Welcome agent inference (parallel path) +//! //! - Builds the same JSON status snapshot //! `tools::implementations::agent::complete_onboarding`'s //! `check_status` would have returned, with `finalize_action = @@ -17,12 +28,9 @@ //! the agent runs with its own `prompt.md`, tool allowlist, and //! model hint. //! - Calls [`crate::openhuman::agent::Agent::run_single`] with a -//! prompt that embeds the snapshot and instructs the agent to -//! skip iteration 1 (the tool call) and go straight to iteration -//! 2 (writing the welcome message). Because we already flipped -//! `chat_onboarding_completed`, the `complete_onboarding` tool -//! would be a no-op anyway — but avoiding the extra round-trip -//! keeps latency down. +//! prompt that embeds the snapshot, skips the tool-call iteration, +//! and instructs the agent not to repeat the greeting (templates +//! already delivered that). //! - On success, publishes //! [`DomainEvent::ProactiveMessageRequested`] so the existing //! [`crate::openhuman::channels::proactive::ProactiveMessageSubscriber`] @@ -69,12 +77,79 @@ pub fn spawn_proactive_welcome(config: Config) { }); } -/// Internal: build the snapshot, run the welcome agent, publish the -/// result. Split out from the spawn so it can be unit-tested with -/// an injected Config + mocked provider. +// --------------------------------------------------------------------------- +// Template helpers +// --------------------------------------------------------------------------- + +/// Returns a time-of-day greeting string based on the current UTC hour. +/// +/// Uses UTC to avoid a timezone-library dependency; the slight offset +/// from the user's local time is acceptable for an informal greeting. +/// Defaults to "Good afternoon" if the system clock is unavailable. +fn time_of_day_greeting() -> &'static str { + let hour = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| ((d.as_secs() / 3600) % 24) as u8) + .unwrap_or(14); // default to afternoon on clock error + match hour { + 5..=11 => "Good morning", + 12..=16 => "Good afternoon", + 17..=20 => "Good evening", + _ => "Hey there", + } +} + +/// Build Template 1 — an immediate personalised greeting that names any +/// channels the user already has connected. +/// +/// Shown instantly (t ≈ 0 ms) while LLM inference runs in parallel. +/// Receives the JSON status snapshot so it can reference real setup data +/// without a second config read. +fn build_template_greeting(snapshot: &serde_json::Value) -> String { + let greeting = time_of_day_greeting(); + + let channels: Vec<&str> = snapshot + .get("channels_connected") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + if channels.is_empty() { + return format!("{greeting}! Getting your workspace ready."); + } + + let channel_list = if channels.len() == 1 { + channels[0].to_string() + } else if channels.len() == 2 { + format!("{} and {}", channels[0], channels[1]) + } else { + // 3+ channels: "a, b, and c" + let (last, rest) = channels + .split_last() + .expect("len >= 3 guaranteed by else branch"); + format!("{}, and {last}", rest.join(", ")) + }; + + format!( + "{greeting}! I can see you've got {channel_list} connected \ + — pulling your full setup together now." + ) +} + +// --------------------------------------------------------------------------- +// Core proactive flow +// --------------------------------------------------------------------------- + +/// Internal: build the snapshot, fire templates, run the welcome agent in +/// parallel with the template delays, and publish all three messages in +/// order. +/// +/// Split out from the spawn so it can be unit-tested with an injected +/// Config. async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> { tracing::info!( - "[welcome::proactive] starting proactive welcome (chat_onboarding_completed={}, ui_onboarding_completed={})", + "[welcome::proactive] starting proactive welcome \ + (chat_onboarding_completed={}, ui_onboarding_completed={})", config.chat_onboarding_completed, config.onboarding_completed ); @@ -91,6 +166,16 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> { "[welcome::proactive] built status snapshot" ); + // --- Template 1: immediate greeting (t ≈ 0 ms) -------------------- + let template_greeting = build_template_greeting(&snapshot); + tracing::debug!("[welcome::proactive] publishing template 1 (immediate greeting)"); + publish_global(DomainEvent::ProactiveMessageRequested { + source: PROACTIVE_WELCOME_SOURCE.to_string(), + message: template_greeting, + job_name: Some(PROACTIVE_WELCOME_JOB_NAME.to_string()), + }); + + // --- Build agent and prompt ---------------------------------------- let mut agent = Agent::from_config_for_agent(&config, "welcome").map_err(|e| { anyhow::anyhow!("build welcome agent: {e} — ensure AgentDefinitionRegistry is initialised") })?; @@ -107,9 +192,19 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> { // `finalize_action: "already_complete"` (we've pre-flipped the // flag) so the message still lands — just with a slightly // different framing. + // + // We also tell the agent that greeting templates have already been + // shown so it does not open with a redundant "Good morning / Hey + // there" — the personalised setup summary should start immediately. let prompt = format!( "[PROACTIVE INVOCATION — the user just finished the desktop onboarding wizard; \ this is not a reply to anything they typed, it is your opening message.]\n\n\ + [TEMPLATE MESSAGES ALREADY DELIVERED — two short placeholder messages have \ + already appeared in the chat before your response arrives:\n\ + 1. A time-of-day greeting naming the user's connected channels (shown immediately).\n\ + 2. \"Getting everything ready for you...\" (shown ~4 seconds later).\n\ + Do NOT open with any greeting (\"Good morning\", \"Hey there\", \"Hi!\", etc.). \ + Jump straight into the personalised welcome content about their specific setup.]\n\n\ Skip iteration 1. Do NOT call `complete_onboarding` or any other tool. The \ status snapshot that `complete_onboarding(check_status)` would have returned \ is already provided below. Jump straight to iteration 2 and write the \ @@ -120,13 +215,30 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> { ); tracing::debug!( prompt_chars = prompt.len(), - "[welcome::proactive] invoking welcome agent run_single" + "[welcome::proactive] invoking welcome agent run_single (parallel with template delay)" ); - let response = agent - .run_single(&prompt) - .await - .map_err(|e| anyhow::anyhow!("welcome agent run_single failed: {e}"))?; + // --- Run LLM and 4 s template delay concurrently ------------------ + // + // Template 2 fires after 4 seconds regardless of LLM speed, giving + // the user visible feedback during inference. The LLM response is + // published only after inference completes (which is typically + // 10–30 s for a 200-350 word welcome). + // + // `tokio::join!` drives both futures on the current task, so no + // additional Send bounds are needed on `Agent`. + let (llm_result, ()) = tokio::join!(agent.run_single(&prompt), async { + tokio::time::sleep(tokio::time::Duration::from_secs(4)).await; + tracing::debug!("[welcome::proactive] publishing template 2 (loading indicator)"); + publish_global(DomainEvent::ProactiveMessageRequested { + source: PROACTIVE_WELCOME_SOURCE.to_string(), + message: "Getting everything ready for you...".to_string(), + job_name: Some(PROACTIVE_WELCOME_JOB_NAME.to_string()), + }); + }); + + let response = + llm_result.map_err(|e| anyhow::anyhow!("welcome agent run_single failed: {e}"))?; let trimmed = response.trim(); if trimmed.is_empty() { @@ -138,22 +250,22 @@ async fn run_proactive_welcome(config: Config) -> anyhow::Result<()> { "[welcome::proactive] welcome agent produced message — publishing ProactiveMessageRequested" ); + // --- Publish LLM response (message 3) ----------------------------- publish_global(DomainEvent::ProactiveMessageRequested { source: PROACTIVE_WELCOME_SOURCE.to_string(), message: trimmed.to_string(), job_name: Some(PROACTIVE_WELCOME_JOB_NAME.to_string()), }); - // Post-publish confirmation. `publish_global` is a best-effort - // broadcast send that swallows lag / no-subscriber errors, so - // without this line the caller can't distinguish "reached the - // end successfully" from "silently bailed somewhere above" by - // reading the log alone. + // Post-publish confirmation so the log clearly marks end-to-end + // success (publish_global is fire-and-forget; without this line, + // "reached the end cleanly" is ambiguous from the log alone). tracing::debug!( source = PROACTIVE_WELCOME_SOURCE, job_name = PROACTIVE_WELCOME_JOB_NAME, response_chars = trimmed.chars().count(), - "[welcome::proactive] proactive welcome flow complete" + "[welcome::proactive] proactive welcome flow complete \ + (3 messages published: greeting, loading, llm-response)" ); Ok(()) @@ -170,4 +282,85 @@ mod tests { assert_eq!(PROACTIVE_WELCOME_SOURCE, "onboarding_completed"); assert_eq!(PROACTIVE_WELCOME_JOB_NAME, "welcome"); } + + #[test] + fn template_greeting_no_channels() { + let snapshot = serde_json::json!({ "channels_connected": [] }); + let msg = build_template_greeting(&snapshot); + assert!( + msg.contains("workspace"), + "expected workspace mention: {msg}" + ); + // No channel list when there are none + assert!( + !msg.contains("connected —"), + "unexpected channel suffix: {msg}" + ); + } + + #[test] + fn template_greeting_one_channel() { + let snapshot = serde_json::json!({ "channels_connected": ["telegram"] }); + let msg = build_template_greeting(&snapshot); + assert!(msg.contains("telegram"), "expected channel name: {msg}"); + assert!(msg.contains("connected"), "expected 'connected': {msg}"); + } + + #[test] + fn template_greeting_two_channels() { + let snapshot = serde_json::json!({ "channels_connected": ["telegram", "discord"] }); + let msg = build_template_greeting(&snapshot); + assert!(msg.contains("telegram"), "expected telegram: {msg}"); + assert!(msg.contains("discord"), "expected discord: {msg}"); + assert!(msg.contains(" and "), "expected 'and' join: {msg}"); + // Should not have serial comma for exactly two items + assert!( + !msg.contains(", and"), + "unexpected serial comma for two items: {msg}" + ); + } + + #[test] + fn template_greeting_three_channels() { + let snapshot = serde_json::json!({ + "channels_connected": ["telegram", "discord", "slack"] + }); + let msg = build_template_greeting(&snapshot); + assert!(msg.contains("telegram"), "{msg}"); + assert!(msg.contains("discord"), "{msg}"); + assert!(msg.contains("slack"), "{msg}"); + // Oxford comma for 3+ items + assert!( + msg.contains(", and "), + "expected Oxford comma for 3 items: {msg}" + ); + } + + #[test] + fn template_greeting_missing_channels_key() { + // Snapshot without the key should not panic and should return a + // non-empty fallback greeting. + let snapshot = serde_json::json!({}); + let msg = build_template_greeting(&snapshot); + assert!(!msg.is_empty(), "expected non-empty fallback message"); + assert!( + msg.contains("workspace"), + "expected workspace fallback: {msg}" + ); + } + + #[test] + fn time_of_day_greeting_returns_known_string() { + let greeting = time_of_day_greeting(); + let valid = [ + "Good morning", + "Good afternoon", + "Good evening", + "Hey there", + ]; + assert!( + valid.contains(&greeting), + "unexpected greeting string: {greeting}" + ); + } }