mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
d87f293972
commit
cb3e41e2fd
5 changed files with 174 additions and 1 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue