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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user