From cb3e41e2fd8253456b4a2958567b539a9a8ca322 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:36:48 -0700 Subject: [PATCH] feat(onboarding): opt-in structured profile-build path on first contact (#41114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(onboarding): opt-in structured profile-build path on first contact On a user's very first gateway message, Hermes now optionally offers to build a short profile of them — then, only with consent, gathers durable facts and persists them to the user-profile memory store (memory tool, target="user") so future sessions start already knowing who they are. Inspired by Poke's zero-input onboarding, but consent-first by design: - The agent OFFERS, never assumes. Declining stops it immediately. - Before ANY external lookup it states what it will look up and asks. - It never reads connected accounts (email/calendar) silently — the exact privacy concern that made naive implementations feel invasive. Wiring reuses existing infrastructure end-to-end: - gateway/run.py first-message hook (was a plain self-intro) now swaps in the profile-build directive when enabled and not yet offered. - agent/onboarding.py gains profile_build_mode()/profile_build_directive() + PROFILE_BUILD_FLAG, latched once via the existing onboarding.seen mechanism so the offer fires at most once per install. - config default onboarding.profile_build: "ask" (set "off" to disable). Added to an existing section, so no _config_version bump needed. No new storage layer, no new injection path, no prompt-cache impact. * fix(dashboard): fold onboarding into agent tab to avoid 1-field category onboarding.profile_build is the only schema-surfaced onboarding field (onboarding.seen is an internal latch dict), so the dashboard CONFIG_SCHEMA single-field-category invariant rejected it. Merge onboarding -> agent like the other small categories. --- agent/onboarding.py | 60 ++++++++++++++++++++++++++++ gateway/run.py | 32 ++++++++++++++- hermes_cli/config.py | 6 +++ hermes_cli/web_server.py | 4 ++ tests/agent/test_onboarding.py | 73 ++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) diff --git a/agent/onboarding.py b/agent/onboarding.py index 220b1c60520..cf7e20593e2 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -26,6 +26,7 @@ logger = logging.getLogger(__name__) BUSY_INPUT_FLAG = "busy_input_prompt" TOOL_PROGRESS_FLAG = "tool_progress_prompt" OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup" +PROFILE_BUILD_FLAG = "profile_build_offered" # ------------------------------------------------------------------------- @@ -126,6 +127,62 @@ def detect_openclaw_residue(home: Optional[Path] = None) -> bool: return False +# ------------------------------------------------------------------------- +# Onboarding profile-build path (opt-in, consent-gated) +# ------------------------------------------------------------------------- + +def profile_build_mode(config: Mapping[str, Any]) -> str: + """Resolve the onboarding profile-build mode from config. + + Returns one of: + ``"ask"`` — on first contact, OFFER to build a profile (default). + ``"off"`` — never offer; the first-message note stays a plain intro. + + Read from ``config.onboarding.profile_build``. Unknown / missing values + fall back to ``"ask"`` so the default experience offers the flow. Any + network/account lookups inside the flow are separately consented to in + conversation — this setting only governs whether the offer is made. + """ + if not isinstance(config, Mapping): + return "ask" + onboarding = config.get("onboarding") + if not isinstance(onboarding, Mapping): + return "ask" + mode = onboarding.get("profile_build") + if isinstance(mode, str) and mode.strip().lower() == "off": + return "off" + return "ask" + + +def profile_build_directive() -> str: + """System-note directive appended to the very first message ever. + + Instructs the agent to run a short, opt-in, consent-gated profile-build + flow and persist confirmed facts to the user-profile memory store + (``memory`` tool, ``target="user"``). Phrased so the agent ASKS before any + lookup and never silently reads connected accounts — directly addressing + the privacy concern that reading email/accounts unprompted feels invasive. + """ + return ( + "\n\n[System note: This is the user's very first message ever. " + "After a one-sentence introduction (mention /help shows commands), " + "OFFER — do not assume — to build a short profile of them so you can " + "be more useful, and explain they can decline or do it later. If and " + "ONLY IF they accept:\n" + " 1. Ask for whatever they're comfortable sharing (name, what they " + "do, how they like you to work). Volunteered facts come first.\n" + " 2. Before ANY external lookup, say what you intend to look up and " + "get explicit consent for that step. Never read their connected " + "accounts (email, calendar, etc.) silently — ask each time.\n" + " 3. With consent, you may use web_search to confirm public details " + "(e.g. employer, public profiles) from the data points they gave.\n" + " 4. Save each confirmed, durable fact with the memory tool using " + "target=\"user\" — keep entries compact and high-signal.\n" + "If they decline at any point, stop immediately and continue normally. " + "Keep the whole exchange light and conversational, not an interrogation.]" + ) + + # ------------------------------------------------------------------------- # State read / write # ------------------------------------------------------------------------- @@ -182,12 +239,15 @@ __all__ = [ "BUSY_INPUT_FLAG", "TOOL_PROGRESS_FLAG", "OPENCLAW_RESIDUE_FLAG", + "PROFILE_BUILD_FLAG", "busy_input_hint_gateway", "busy_input_hint_cli", "tool_progress_hint_gateway", "tool_progress_hint_cli", "openclaw_residue_hint_cli", "detect_openclaw_residue", + "profile_build_mode", + "profile_build_directive", "is_seen", "mark_seen", ] diff --git a/gateway/run.py b/gateway/run.py index ecbe1a86605..14dc362a4da 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9445,11 +9445,41 @@ class GatewayRunner: # First-message onboarding -- only on the very first interaction ever if not history and not self.session_store.has_any_sessions(): - context_prompt += ( + # Default first-contact note: a brief self-introduction. + _intro_note = ( "\n\n[System note: This is the user's very first message ever. " "Briefly introduce yourself and mention that /help shows available commands. " "Keep the introduction concise -- one or two sentences max.]" ) + # Opt-in structured profile-build path. When enabled (default + # "ask") and not yet offered on this install, swap the plain intro + # for a consent-gated directive that offers to build a user + # profile and persists confirmed facts via memory(target="user"). + # The offer fires at most once (onboarding.seen flag); set + # onboarding.profile_build: off in config.yaml to disable. + try: + from agent.onboarding import ( + PROFILE_BUILD_FLAG, + is_seen, + mark_seen, + profile_build_directive, + profile_build_mode, + ) + _onb_cfg = _load_gateway_config() + if ( + profile_build_mode(_onb_cfg) == "ask" + and not is_seen(_onb_cfg, PROFILE_BUILD_FLAG) + ): + context_prompt += profile_build_directive() + mark_seen(_hermes_home / "config.yaml", PROFILE_BUILD_FLAG) + else: + context_prompt += _intro_note + except Exception as _pb_err: + logger.debug( + "Profile-build onboarding directive failed, using plain intro: %s", + _pb_err, + ) + context_prompt += _intro_note # One-time prompt if no home channel is set for this platform # Skip for webhooks - they deliver directly to configured targets (github_comment, etc.) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8dc3b291f4c..fc98998c200 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2268,6 +2268,12 @@ DEFAULT_CONFIG = { # never fires again. Users can wipe the section to re-see all hints. "onboarding": { "seen": {}, + # Structured profile-build path offered on the very first gateway + # message ever. "ask" (default) -> offer to build a user profile + # (opt-in, consent-gated; the agent asks before any lookup and never + # reads connected accounts silently). "off" -> plain intro only. + # The offer fires at most once (latched under onboarding.seen). + "profile_build": "ask", }, # ``hermes update`` behaviour. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 40a36c36418..bebdfe1b27b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -510,6 +510,10 @@ _CATEGORY_MERGE: Dict[str, str] = { "prompt_caching": "agent", "goals": "agent", "updates": "general", + # `onboarding.profile_build` is the only schema-surfaced onboarding field + # (`onboarding.seen` is an internal latch dict, not a user setting), so fold + # it into the agent tab rather than spawning a one-field orphan category. + "onboarding": "agent", # Only `telegram.reactions` currently lives under telegram — fold it in # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index 0ae03db3aa8..09799608818 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -236,3 +236,76 @@ class TestOpenclawResidueSeenFlag: assert mark_seen(cfg_path, OPENCLAW_RESIDUE_FLAG) is True loaded = yaml.safe_load(cfg_path.read_text()) assert is_seen(loaded, OPENCLAW_RESIDUE_FLAG) is True + + +class TestProfileBuildMode: + def test_default_is_ask(self): + from agent.onboarding import profile_build_mode + + assert profile_build_mode({}) == "ask" + assert profile_build_mode({"onboarding": {}}) == "ask" + assert profile_build_mode({"onboarding": {"profile_build": "ask"}}) == "ask" + + def test_off_disables(self): + from agent.onboarding import profile_build_mode + + assert profile_build_mode({"onboarding": {"profile_build": "off"}}) == "off" + assert profile_build_mode({"onboarding": {"profile_build": "OFF"}}) == "off" + + def test_unknown_value_falls_back_to_ask(self): + from agent.onboarding import profile_build_mode + + assert profile_build_mode({"onboarding": {"profile_build": "banana"}}) == "ask" + + def test_non_mapping_config_safe(self): + from agent.onboarding import profile_build_mode + + assert profile_build_mode("not a dict") == "ask" # type: ignore[arg-type] + assert profile_build_mode({"onboarding": "nope"}) == "ask" + + +class TestProfileBuildDirective: + def test_directive_is_opt_in_and_consent_gated(self): + from agent.onboarding import profile_build_directive + + d = profile_build_directive() + # Must OFFER, not assume. + assert "OFFER" in d + # Must require consent before external lookups. + assert "consent" in d.lower() + # Must forbid silently reading connected accounts. + assert "silently" in d.lower() + # Must persist via the user-profile memory store. + assert 'target="user"' in d + # Must allow declining. + assert "decline" in d.lower() + + def test_directive_mentions_first_message(self): + from agent.onboarding import profile_build_directive + + assert "first message ever" in profile_build_directive() + + +class TestProfileBuildSeenFlag: + def test_flag_round_trips(self, tmp_path): + from agent.onboarding import PROFILE_BUILD_FLAG + + cfg_path = tmp_path / "config.yaml" + assert mark_seen(cfg_path, PROFILE_BUILD_FLAG) is True + loaded = yaml.safe_load(cfg_path.read_text()) + assert is_seen(loaded, PROFILE_BUILD_FLAG) is True + + def test_flag_independent_of_busy_input(self, tmp_path): + from agent.onboarding import PROFILE_BUILD_FLAG + + cfg_path = tmp_path / "config.yaml" + mark_seen(cfg_path, BUSY_INPUT_FLAG) + loaded = yaml.safe_load(cfg_path.read_text()) + assert is_seen(loaded, PROFILE_BUILD_FLAG) is False + + +class TestProfileBuildConfigDefault: + def test_default_config_carries_ask(self): + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["onboarding"]["profile_build"] == "ask"