Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/openhuman/agent/agents/welcome/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
235 changes: 214 additions & 21 deletions src/openhuman/agent/welcome_proactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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`]
Expand Down Expand Up @@ -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
);
Expand All @@ -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")
})?;
Expand All @@ -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 \
Expand All @@ -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() {
Expand All @@ -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(())
Expand All @@ -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}"
);
}
}
Loading