feat(onboarding): opt-in structured profile-build path on first contact (#41114)

* 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.
This commit is contained in:
Teknium 2026-06-07 08:36:48 -07:00 committed by GitHub
parent d87f293972
commit cb3e41e2fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 174 additions and 1 deletions

View file

@ -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",
]

View file

@ -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.)

View file

@ -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.

View file

@ -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.

View file

@ -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"