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
5 changed files with 174 additions and 1 deletions
+73
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"