diff --git a/plugins/hermes/miloco-plugin/context_injection.py b/plugins/hermes/miloco-plugin/context_injection.py index 6e8594882..cee0e4e9b 100644 --- a/plugins/hermes/miloco-plugin/context_injection.py +++ b/plugins/hermes/miloco-plugin/context_injection.py @@ -8,6 +8,7 @@ ``prependSystemContext`` / ``appendSystemContext`` 两段,这里合并成单个 context 块:先指令块(identity/capabilities/perception/memory/notify/language),再数据块 (home-profile / pending-suggestions / device-catalog),用分隔线隔开。 +其中 notify 块只注入 miloco 后台会话(见 :func:`is_miloco_background_session`)。 profile 判定(与 TS 端 ``resolveProfile`` 对齐): - ``platform == "cron"`` 或 session_id 含 ``":cron:"`` / ``"miloco:cron:"`` → minimal @@ -63,6 +64,41 @@ def resolve_profile( return "full" +# cron 消息头:openclaw 把 cron turn 的消息改写成 ``[cron: ] …``, +# backend schedule runner 则写成 ``[cron:] …``。取方括号内整段做归属判断。 +_CRON_HEADER_RE = re.compile(r"^\[cron:([^\]]*)\]") + +# 受管 job 都叫 ``miloco-``,要求 ``miloco-`` 出现在词首(方括号起首、或 jobId 后的 +# 空格 / 冒号之后)。不做裸 substring 匹配:否则用户自建的「巡检 miloco 日志」这类 job 名 +# 会被认领成后台会话。严格程度与 session_id 段判定对齐。 +_MILOCO_JOB_RE = re.compile(r"(?:^|[\s:])miloco-") + + +def is_miloco_background_session( + session_id: Optional[str], + user_message: Optional[str] = None, +) -> bool: + """与 TS 端 ``isMilocoBackgroundSession(sessionKey, {prompt})`` 等价。 + + 判断本轮是不是「miloco 后台会话」——由感知引擎 / miloco 定时任务 / 规则与任务事件 + 拉起、turn 跑在后台、**回复对用户不可见**,只能按 miloco-notify skill 主动推送才算 + 送达,故需注入 :data:`B_NOTIFY`;其余会话(用户 IM、常规 cron、CLI 主会话)不注入。 + + 两条线索任一命中即算: + + - session_id 有 miloco 段:backend dispatcher ``_ROUTE`` 写死 + ``agent:main:miloco{,-rule,-suggest}``、schedule runner 写死 + ``miloco-schedule:``,hermes 侧形如 ``miloco:cron:…`` / ``miloco-rule-``。 + - cron 头带 miloco:isolated cron 的 session_id 是 ``agent::cron::run:``, + jobId 随机、看不出归属,只能从消息头里的 job 名认领(miloco 自管的 job 都叫 ``miloco-*``)。 + """ + key = session_id or "" + if any(seg == "miloco" or seg.startswith("miloco-") for seg in key.split(":")): + return True + m = _CRON_HEADER_RE.match((user_message or "").lstrip()) + return bool(m) and bool(_MILOCO_JOB_RE.search(m.group(1))) + + # --------------------------------------------------------------------------- # 静态指令块(抄自 prompt.ts,文本保持 1:1) # --------------------------------------------------------------------------- @@ -187,11 +223,16 @@ def _build_perception(profile: Profile) -> str: B_RULE_EXEC = "" B_CONSTRAINTS = "" +# 只注入 miloco 后台会话(见 is_miloco_background_session)。既然作用域已由注入侧收敛, +# 正文就不再写「当面回答用户提问除外」这类例外——那句在语音 lane 反而是错的:语音提问的 +# 答复同样得经 TTS 推回去。作用域交给 gate,正文只讲这类会话里该怎么做。 +# 同理不提「用户要配置通知渠道」:配渠道必然发生在用户自己说话的会话里,而那种会话已经 +# 不注入本块,写在这儿只会让模型以为后台也会有人来配。该入口交给 miloco-notify 的 skill +# description 兜(那是普通对话里唯一的加载触发器)。 B_NOTIFY = """## 通知用户 -**要主动找人时——而不是当面回答用户此刻的提问——动手前必须先读 `miloco-notify` skill。** 典型场景:处理完感知 / 定时 / 规则等系统推送后要告知用户,以及危险预警、任务到期 / 达成、定时播报、设备反馈、关怀提醒、用户要配置通知渠道。 -为什么是硬性前置、不能跳过: -- **处理系统推送时你的回话对用户不可见**——光把结论写进回复,没有任何人收到,等于没通知。必须经本 skill 决策并交付渠道才算送达。 -- 通知要决策「给谁 → 走哪个渠道(TTS / IM / 米家推送)→ 说什么」,这套判断只在 skill 里;别绕过它直接裸调 `miloco_im_push` / `miloco-cli notify push` / TTS,否则容易选错人、选错渠道、说错话。""" +本轮由 miloco 后台触发(感知引擎 / 定时任务 / 规则或任务事件),会话不在用户面前——**你写进回复里的话没有任何人看得到**。 +- 本轮**只要有信息要传达给家庭成员**(回应语音提问、危险预警、任务到期 / 达成、定时播报、设备异常、关怀提醒),**动手前必须先读 `miloco-notify` skill**:通知要决策「给谁 → 走哪个渠道(TTS / IM / 米家推送)→ 说什么」,这套判断只在 skill 里;别绕过它直接裸调 `miloco_im_push` / `miloco-cli notify push` / TTS,否则容易选错人、选错渠道、说错话。 +- 本轮**不需要告知任何人**(只是归档、巡检、写记忆、改设备状态)→ 不必读本 skill,做完即止,别为了"有个交代"硬发一条。""" B_LANGUAGE = "## 输出语言\n用用户使用的语言回复用户(设备名、人名、专有名词保持原样)。" @@ -270,7 +311,11 @@ def build_pending_suggestion_block() -> str: # 装配 # --------------------------------------------------------------------------- -def _build_prepend(profile: Profile) -> str: +def _build_prepend( + profile: Profile, + session_id: Optional[str] = None, + user_message: Optional[str] = None, +) -> str: """指令块,按 prompt.ts §3 序。""" parts: List[str] = [B_IDENTITY, _build_timezone_block()] if profile == "full": @@ -283,7 +328,11 @@ def _build_prepend(profile: Profile) -> str: parts.append(B_MEMORY) if B_CONSTRAINTS: parts.append(B_CONSTRAINTS) - parts.append(B_NOTIFY) + # B_NOTIFY 只给 miloco 后台会话:那里回复对用户不可见,不主动推就等于没通知。 + # 用户 IM / 常规 cron 也注入的话,会把"回答用户此刻的提问""汇报刚做完的设备操作" + # 一并误判成通知场景——白绕一次 skill,常规 cron 里还曾把一次汇报拖到 120s 超时。 + if is_miloco_background_session(session_id, user_message): + parts.append(B_NOTIFY) parts.append(B_LANGUAGE) return "\n\n".join(parts) @@ -330,7 +379,7 @@ def inject_context( """ try: profile = resolve_profile(session_id, platform, user_message) - prepend = _build_prepend(profile) + prepend = _build_prepend(profile, session_id, user_message) append = _build_append(profile) sections = [prepend] if prepend else [] diff --git a/plugins/hermes/miloco-plugin/hermes_adapter/adapter.py b/plugins/hermes/miloco-plugin/hermes_adapter/adapter.py index 5339cf84b..bebfa7774 100644 --- a/plugins/hermes/miloco-plugin/hermes_adapter/adapter.py +++ b/plugins/hermes/miloco-plugin/hermes_adapter/adapter.py @@ -214,20 +214,38 @@ def __init__( # ---- build_system -------------------------------------------------- - def build_system(self, profile: str, extra: dict[str, Any]) -> str: + def build_system( + self, + profile: str, + extra: dict[str, Any], + session_key: Optional[str] = None, + user_message: Optional[str] = None, + ) -> str: """组装 OpenAI ```` 消息文本。 对齐 doc §五 #2: 硬约束 + 工具索引 + 感知格式 + 数据源 + (按 profile)档案/目录。 实现要点: 从 plugin 侧 ``context_injection`` 模块复用 ``_build_prepend`` / ``_build_append``(都是 module-private,这里直接 import 或 inline)。 + + ``session_key`` / ``user_message`` 供 ``_build_prepend`` 判 B_NOTIFY 的注入范围 + (见 ``context_injection.is_miloco_background_session``)。 + + 注意传的是 **miloco 侧的 session_key**(``agent:main:miloco`` 这种),不是 hermes + 的 session_id——后者由 ``_map_session`` 统一加了 ``miloco:`` 前缀,按段一切必然 + 命中,gate 会恒为真等于没判。调用方(``send_turn``)负责在 owner-channel 投递时 + 传 None,见那里的注释。 + + 两个参数留默认值只为兼容 duck-typed 契约里 ``build_system(profile, extra)`` + 的老签名——但**生产路径必须显式传**,否则 gate 恒判 False、后台 lane 的通知块 + 会被整片摘掉。 """ from .context_injection import ( _build_append, _build_prepend, ) - prepend = _build_prepend(profile) + prepend = _build_prepend(profile, session_key, user_message) append = _build_append(profile) sections = [prepend] if prepend else [] if append: @@ -280,12 +298,27 @@ async def send_turn(self, ctx: Any) -> Any: ) timeout_s = max(wait_timeout_ms / 1000.0, 1.0) + _HTTP_BUFFER_S + # B_NOTIFY 的注入范围判据(对齐 openclaw webhooks/agent.ts 的 effectiveSessionKey + # 语义):看**本轮回复用户能不能看见**。 + # - 常规后台 lane(interaction / bind / rule / suggestion):deliver 为假,回复没有 + # 任何人接收 → 按 miloco 侧 session_key 判,命中 agent:main:miloco{,-rule,-suggest} + # → 注入「## 通知用户」,让 agent 知道要主动推。 + # - owner-channel 投递(onboarding):turn 虽跑在新会话,但整轮回复会经 hermes send + # 推到车主 IM,用户看得见 → 传 None 让 gate 判 False,与 openclaw 侧把 sessionKey + # 改写成车主 IM 会话后不注入的行为一致。 + # 别拿 hermes 的 session_id 当判据:_map_session 给每个 id 都加了 `miloco:` 前缀, + # 按段一切必然命中 miloco,gate 会恒为真、等于没判。 + notify_session_key = None if delivery.get("deliver") else session_key + # 组装 messages: (可选) + - # build_system 内部可能走 subprocess(catalog CLI),丢线程池避免阻塞事件循环 + # build_system 内部可能走 subprocess(catalog CLI),丢线程池避免阻塞事件循环。 + # 后两个参数必须透传:漏传会让「## 通知用户」整片消失、后台告警静默丢失。 if profile != "minimal": import asyncio as _asyncio loop = _asyncio.get_running_loop() - system_text = await loop.run_in_executor(None, self.build_system, profile, extra) + system_text = await loop.run_in_executor( + None, self.build_system, profile, extra, notify_session_key, text + ) else: system_text = "" messages: list[dict[str, str]] = [] diff --git a/plugins/hermes/tests/conftest.py b/plugins/hermes/tests/conftest.py index 23749189d..b7cb1e03a 100644 --- a/plugins/hermes/tests/conftest.py +++ b/plugins/hermes/tests/conftest.py @@ -51,3 +51,27 @@ def _load_single(alias: str, file: Path) -> None: # 插件:作为包 miloco_plugin_pkg 装载(context_injection/tools_* 间有相对导入) _load_pkg("miloco_plugin_pkg", _PLUGIN_DIR) + + +def _alias_flat_adapter_deps() -> None: + """复刻 install-hermes.sh 的摊平部署布局,让 adapter 的相对导入能解析。 + + 生产里 install-hermes.sh(见 :615-680)把 ``hermes_adapter/{__init__,adapter}.py`` + 和 ``{context_injection,catalog,paths,tools_habit}.py`` 一起拷进 + ``$MILOCO_HOME/agent_platform/hermes/``——adapter.py 与这几个模块同级,所以它写的是 + ``from .context_injection import ...``。仓库布局里 hermes_adapter/ 只有 adapter.py, + 相对导入解析不到,``build_system`` 这条**生效路径**就没法在单测里跑。 + + 这里按生产布局补上 sys.modules 别名(同一个模块对象,monkeypatch 两边同时生效)。 + """ + import importlib + + parent = "miloco_plugin_pkg.hermes_adapter" + importlib.import_module(parent) + for name in ("context_injection", "catalog", "paths", "tools_habit"): + alias = f"{parent}.{name}" + if alias not in sys.modules: + sys.modules[alias] = importlib.import_module(f"miloco_plugin_pkg.{name}") + + +_alias_flat_adapter_deps() diff --git a/plugins/hermes/tests/test_context_injection.py b/plugins/hermes/tests/test_context_injection.py index 9974c2e14..99460a978 100644 --- a/plugins/hermes/tests/test_context_injection.py +++ b/plugins/hermes/tests/test_context_injection.py @@ -32,6 +32,61 @@ def test_profile_full(tmp_miloco_home): assert ci.resolve_profile("anything-else") == "full" +# ---------- is_miloco_background_session ---------- + +@pytest.mark.parametrize( + "session_id,expected", + [ + # miloco 后台 lane / 定时任务(backend dispatcher `_ROUTE` + schedule runner) + ("agent:main:miloco", True), + ("agent:main:miloco-rule", True), + ("agent:main:miloco-suggest", True), + ("miloco-schedule:abc123", True), + # hermes 侧 session_id 形态 + ("miloco:cron:digest", True), + ("miloco-rule-abc", True), + # 非 miloco:常规 cron / 用户 IM / CLI 主会话 + ("agent:main:cron:[t1]:run:abc", False), + ("agent:main:telegram:dm:123", False), + ("wechat:s1", False), + ("agent:main", False), + (None, False), + ], +) +def test_is_miloco_background_session(tmp_miloco_home, session_id, expected): + assert ci.is_miloco_background_session(session_id) is expected + + +def test_is_miloco_background_session_cron_header(tmp_miloco_home): + """isolated cron 的 session_id 看不出归属,只能认消息头里的 job 名。""" + key = "agent:main:cron:[t1]:run:abc" + assert ci.is_miloco_background_session(key, "[cron:job1 miloco-home-patrol] 执行巡检。") + assert not ci.is_miloco_background_session(key, "[cron:job2 PTM 汇报] 汇总进展。") + # 正文提到 miloco 不算数——只认方括号内的 cron 头 + assert not ci.is_miloco_background_session( + "agent:main:telegram:dm:1", "帮我看看 miloco 是怎么工作的" + ) + + +@pytest.mark.parametrize( + "prompt,expected", + [ + # 受管 job 都叫 miloco-,词首出现才算 + ("[cron:job1 miloco-home-patrol] 巡检", True), + ("[cron:miloco-habit-suggest] 跑建议", True), + # 用户自建 job 名里顺口提到 miloco 不该被认领 + ("[cron:job7 巡检 miloco 日志] 看看日志", False), + ("[cron:job8 milocoish-report] 汇报", False), + ("[cron:job9 同步 miloco] 同步", False), + ], +) +def test_is_miloco_background_session_cron_header_word_boundary( + tmp_miloco_home, prompt, expected +): + key = "agent:main:cron:[t1]:run:abc" + assert ci.is_miloco_background_session(key, prompt) is expected + + # ---------- inject_context ---------- def test_full_includes_catalog_and_capabilities(tmp_miloco_home, monkeypatch): @@ -46,17 +101,35 @@ def test_full_includes_catalog_and_capabilities(tmp_miloco_home, monkeypatch): def test_minimal_includes_identity_notify_timezone(tmp_miloco_home, monkeypatch): - """minimal profile 注入 identity + timezone + notify + language(对齐 OpenClaw)。""" + """miloco 定时任务(minimal)注入 identity + timezone + notify + language(对齐 OpenClaw)。""" monkeypatch.setattr(ci, "get_catalog", lambda: "# devices catalog\nx") out = ci.inject_context(session_id="miloco:cron:digest", platform="cron") assert out is not None ctx = out["context"] assert "Miloco" in ctx # B_IDENTITY assert "时区" in ctx # B_TIMEZONE - assert "通知用户" in ctx # B_NOTIFY + assert "通知用户" in ctx # B_NOTIFY —— miloco 后台会话,回复不可见,必须主动推 assert "输出语言" in ctx # B_LANGUAGE +def test_non_miloco_sessions_omit_notify(tmp_miloco_home, monkeypatch): + """常规 cron 与用户 IM 会话不注入 B_NOTIFY:回复本身就能到人。""" + monkeypatch.setattr(ci, "get_catalog", lambda: "") + cron = ci.inject_context( + session_id="agent:main:cron:[t1]:run:abc", + user_message="[cron:job2 PTM 汇报] 汇总今天的进展。", + platform="cron", + ) + assert cron is not None + assert "通知用户" not in cron["context"] + assert "miloco-notify" not in cron["context"] + + im = ci.inject_context(session_id="agent:main:telegram:dm:123", user_message="把客厅灯打开") + assert im is not None + assert "通知用户" not in im["context"] + assert "## 能力概览" in im["context"] # full profile 的其余块不受影响 + + def test_empty_catalog_omitted(tmp_miloco_home, monkeypatch): """catalog 空但 full profile → prepend 仍有能力概览,context 不为 None。""" monkeypatch.setattr(ci, "get_catalog", lambda: "") diff --git a/plugins/hermes/tests/test_hermes_adapter.py b/plugins/hermes/tests/test_hermes_adapter.py index 5904dd164..0f941eb4f 100644 --- a/plugins/hermes/tests/test_hermes_adapter.py +++ b/plugins/hermes/tests/test_hermes_adapter.py @@ -6,6 +6,7 @@ - _looks_like_overflow best-effort 关键词检测 - _extract_error_text 兼容 OpenAI-style envelope - build_system(profile) 通过 _build_prepend/_build_append 拼装 system msg +- B_NOTIFY 注入范围门控(send_turn → build_system 是 hermes 侧唯一生效路径) - resolve_notify_target 3 级 fallback(state.json → scan ~/.hermes → needsBind) - _map_session session_key → hermes session_id 映射 @@ -487,4 +488,117 @@ def _fake_ctx(text: str, trace_id: str = "tr-test", lane: str = "miloco-interact ctx.wait_timeout_ms = 1000 ctx.profile = "minimal" ctx.extra = {} - return ctx \ No newline at end of file + return ctx + + +# --------------------------------------------------------------------------- +# B_NOTIFY 注入范围(生效路径回归) +# +# hermes 侧的 pre_llm_call 钩子是 noop(miloco-plugin/__init__.py:70), +# context_injection.inject_context 在 hermes 上根本不会被调用—— +# send_turn → build_system → _build_prepend 才是唯一的生效路径。 +# 只测 inject_context 兜不住这里:门控接错线时那边全绿、生产上通知块整片消失。 +# --------------------------------------------------------------------------- + + +@pytest.fixture +def notify_env(tmp_path, monkeypatch): + """临时 MILOCO_HOME + 屏蔽 catalog 子进程(build_system 会起 miloco-cli)。""" + from miloco_plugin_pkg import context_injection as ci + + monkeypatch.setenv("MILOCO_HOME", str(tmp_path)) + monkeypatch.setattr(ci, "get_catalog", lambda: "") + return tmp_path + + +@pytest.mark.parametrize( + "session_key,expected", + [ + # dispatcher `_ROUTE` 的后台 lane:回复没人接收,得主动推 + ("agent:main:miloco", True), + ("agent:main:miloco-rule", True), + ("agent:main:miloco-suggest", True), + # 用户 IM / 无归属:回复本身就到人,不注入 + ("agent:main:telegram:dm:123", False), + ("wechat:s1", False), + (None, False), + ], +) +def test_build_system_scopes_notify_block(notify_env, session_key, expected): + from miloco_plugin_pkg.hermes_adapter.adapter import Adapter + + out = Adapter().build_system("full", {}, session_key, "客厅有人跌倒") + assert ("## 通知用户" in out) is expected + # 其余块不受门控影响 + assert "## 能力概览" in out + + +def test_build_system_rejects_hermes_session_id(notify_env): + """传 hermes 的 session_id 会让门控恒真——用它当判据等于没判。 + + ``_map_session`` 给每个 id 都加了 ``miloco:`` 前缀,按段切必然命中; + 本测试把这一点钉死,防止有人「顺手」把 send_turn 改回传 session_id。 + """ + from miloco_plugin_pkg import context_injection as ci + from miloco_plugin_pkg.hermes_adapter.adapter import _map_session + + mapped = _map_session("agent:main:telegram:dm:123", "miloco-interactive") + assert mapped.startswith("miloco:") + assert ci.is_miloco_background_session(mapped) is True # 恒真 → 不可作判据 + assert ci.is_miloco_background_session("agent:main:telegram:dm:123") is False + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "session_key,delivery,expected", + [ + # 后台 lane(interaction / rule / suggestion):deliver 为假 → 注入 + ("agent:main:miloco", {}, True), + ("agent:main:miloco-rule", {}, True), + # owner-channel 投递(onboarding):整轮回复经 hermes send 推到车主 IM, + # 用户看得见 → 不注入,与 openclaw 改写 sessionKey 后的行为一致 + ( + "agent:main:miloco", + {"resolve_target": "owner-channel", "deliver": True}, + False, + ), + ], +) +async def test_send_turn_passes_notify_scope_to_build_system( + notify_env, monkeypatch, anyio_backend, session_key, delivery, expected +): + """回归:send_turn 必须把 session_key / 本轮文本透给 build_system。 + + 漏传 → 门控恒判 False → 后台 lane 的「## 通知用户」整片消失, + 「客厅有人跌倒」这类告警会被写进一条没人收得到的回复里。 + """ + import httpx + from miloco_plugin_pkg.hermes_adapter import adapter as ad + + monkeypatch.setattr( + ad, "_resolve_owner_session", lambda: ("agent:main:telegram:dm:9", "telegram") + ) + + captured: dict = {} + + async def fake_post(self, url, **kw): + captured["body"] = kw.get("json") + resp = mock.MagicMock() + resp.status_code = 200 + return resp + + async def fake_aclose(self): + return None + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) + monkeypatch.setattr(httpx.AsyncClient, "aclose", fake_aclose) + + ctx = _fake_ctx("[感知引擎] 客厅有人跌倒,15 秒未起身") + ctx.session_key = session_key + ctx.profile = "full" + ctx.extra = {"delivery": delivery} + await ad.Adapter().send_turn(ctx) + + messages = captured["body"]["messages"] + assert messages[0]["role"] == "system" + assert ("## 通知用户" in messages[0]["content"]) is expected diff --git a/plugins/openclaw/src/hooks/prompt.ts b/plugins/openclaw/src/hooks/prompt.ts index fe852d850..77fb10c6e 100644 --- a/plugins/openclaw/src/hooks/prompt.ts +++ b/plugins/openclaw/src/hooks/prompt.ts @@ -37,6 +37,43 @@ export function resolveProfile( return "full"; } +// cron 消息头:openclaw 把 cron turn 的消息改写成 `[cron: ] …`, +// backend schedule runner 则写成 `[cron:] …`。取方括号内整段做归属判断。 +const CRON_HEADER_RE = /^\[cron:([^\]]*)\]/; + +// 受管 job 都叫 `miloco-`,要求 `miloco-` 出现在词首(方括号起首、或 jobId 后的 +// 空格 / 冒号之后)。不做裸 substring 匹配:否则用户自建的「巡检 miloco 日志」这类 job +// 名会被认领成后台会话,又吃回本次要摘掉的那条硬前置。严格程度与上面 sessionKey 段判 +// 定(`=== "miloco"` / `startsWith("miloco-")`)对齐。 +const MILOCO_JOB_RE = /(?:^|[\s:])miloco-/; + +// B_NOTIFY 的注入范围判定:本轮是不是「miloco 后台会话」。 +// 这类会话由感知引擎 / miloco 定时任务 / 规则与任务事件拉起,turn 跑在后台(deliver=false), +// **回复对用户不可见**,只能按 miloco-notify skill 主动推送才算送达,故必须注入 B_NOTIFY; +// 其余会话(用户 IM、常规 cron、CLI 主会话)不注入——见组装处注释。 +// +// 两条线索任一命中即算: +// - sessionKey 有 miloco 段:backend dispatcher `_ROUTE` 写死 `agent:main:miloco{,-rule,-suggest}`, +// schedule runner 写死 `miloco-schedule:`;hermes 侧形如 `miloco:cron:…` / +// `miloco-rule-`。按 `:` 切段后判「等于 miloco 或以 miloco- 开头」,两端形态都能覆盖。 +// - cron 头带 miloco:isolated cron 的 sessionKey 是 `agent::cron::run:`, +// jobId 由宿主随机生成、看不出归属,只能从消息头里的 job 名认领;miloco 自管的 4 个 job +// 都叫 `miloco-*`(见 home-profile/scheduler.ts kCronTasks)。 +// +// 已知代价:用户若把自己的 agent 命名为 miloco(`agent:miloco:telegram:…`),会误判成后台 +// 会话而多注入一段——等同改动前的行为,无功能损失,不为此再加区分。 +export function isMilocoBackgroundSession( + sessionKey: string | undefined, + opts?: { prompt?: string }, +): boolean { + const hasMilocoSegment = (sessionKey ?? "") + .split(":") + .some((seg) => seg === "miloco" || seg.startsWith("miloco-")); + if (hasMilocoSegment) return true; + const cronHeader = CRON_HEADER_RE.exec(opts?.prompt?.trimStart() ?? "")?.[1]; + return MILOCO_JOB_RE.test(cronHeader ?? ""); +} + // ===== prepend 指令块(静态) ===== const B_IDENTITY = `你是经验丰富的家庭智能管家 Miloco。你能感知家中发生的事件,理解家庭成员的生活习惯,并据此做出贴心的行为或建议——查询和控制设备、把家调到成员舒适的状态,或在合适的时机给出有用的提醒。 @@ -115,11 +152,16 @@ const B_RULE_EXEC = ""; // 预留占位:将来若要重新常驻硬约束,在此填入并决定作用域。 const B_CONSTRAINTS = ""; +// 只注入 miloco 后台会话(见 isMilocoBackgroundSession)。既然作用域已由注入侧收敛, +// 正文就不再写「当面回答用户提问除外」这类例外——那句在语音 lane 反而是错的:语音提问的 +// 答复同样得经 TTS 推回去。作用域交给 gate,正文只讲这类会话里该怎么做。 +// 同理不提「用户要配置通知渠道」:配渠道必然发生在用户自己说话的会话里,而那种会话已经 +// 不注入本块,写在这儿只会让模型以为后台也会有人来配、白绕一次 channel-config。该入口交 +// 给 miloco-notify 的 skill description 兜(那是普通对话里唯一的加载触发器)。 const B_NOTIFY = `## 通知用户 -**要主动找人时——而不是当面回答用户此刻的提问——动手前必须先读 \`miloco-notify\` skill。** 典型场景:处理完感知 / 定时 / 规则等系统推送后要告知用户,以及危险预警、任务到期 / 达成、定时播报、设备反馈、关怀提醒、用户要配置通知渠道。 -为什么是硬性前置、不能跳过: -- **处理系统推送时你的回话对用户不可见**——光把结论写进回复,没有任何人收到,等于没通知。必须经本 skill 决策并交付渠道才算送达。 -- 通知要决策「给谁 → 走哪个渠道(TTS / IM / 米家推送)→ 说什么」,这套判断只在 skill 里;别绕过它直接裸调 \`miloco_im_push\` / \`miloco-cli notify push\` / TTS,否则容易选错人、选错渠道、说错话。`; +本轮由 miloco 后台触发(感知引擎 / 定时任务 / 规则或任务事件),会话不在用户面前——**你写进回复里的话没有任何人看得到**。 +- 本轮**只要有信息要传达给家庭成员**(回应语音提问、危险预警、任务到期 / 达成、定时播报、设备异常、关怀提醒),**动手前必须先读 \`miloco-notify\` skill**:通知要决策「给谁 → 走哪个渠道(TTS / IM / 米家推送)→ 说什么」,这套判断只在 skill 里;别绕过它直接裸调 \`miloco_im_push\` / \`miloco-cli notify push\` / TTS,否则容易选错人、选错渠道、说错话。 +- 本轮**不需要告知任何人**(只是归档、巡检、写记忆、改设备状态)→ 不必读本 skill,做完即止,别为了"有个交代"硬发一条。`; function buildOnboardingSessionBlock( sessionKey: string | undefined, @@ -307,7 +349,15 @@ export const registerBeforePromptBuildHook: HookRegister = (api) => { if (profile === "rule" && B_RULE_EXEC) prepend.push(B_RULE_EXEC); if (profile !== "minimal") prepend.push(B_MEMORY); if (B_CONSTRAINTS) prepend.push(B_CONSTRAINTS); - prepend.push(B_NOTIFY, B_LANGUAGE); + // B_NOTIFY 只给 miloco 后台会话:那里回复对用户不可见,不主动推就等于没通知。 + // 用户 IM / 常规 cron 也注入的话,会把"回答用户此刻的提问""汇报刚做完的设备操作" + // 一并误判成通知场景——白绕一次 skill,常规 cron 里还曾把一次汇报拖到 120s 超时。 + // 这些会话自身的回复就能到人(或本就不该找人),需要主动触达时 miloco-notify 仍可 + // 按 skill 描述自行发现加载,只是不再作为常驻硬前置。 + if (isMilocoBackgroundSession(ctx?.sessionKey, { prompt: event?.prompt })) { + prepend.push(B_NOTIFY); + } + prepend.push(B_LANGUAGE); // ---- append:数据块(今日感知日志 → 待回应 → 目录),minimal 不带 ---- const append: string[] = []; diff --git a/plugins/openclaw/tests/prompt.test.ts b/plugins/openclaw/tests/prompt.test.ts index b25340a26..5051448a3 100644 --- a/plugins/openclaw/tests/prompt.test.ts +++ b/plugins/openclaw/tests/prompt.test.ts @@ -6,7 +6,11 @@ import { readOnboardingState, writeOnboardingInviteState, } from "../src/home-profile/onboarding_state.js"; -import { registerBeforePromptBuildHook, resolveProfile } from "../src/hooks/prompt.js"; +import { + isMilocoBackgroundSession, + registerBeforePromptBuildHook, + resolveProfile, +} from "../src/hooks/prompt.js"; import { toLocalParts } from "../src/utils/time.js"; // 感知日志文件名日期取部署时区;测试固定 tz 后按同一逻辑算出某个偏移日的文件名。 @@ -87,6 +91,69 @@ describe("resolveProfile", () => { }); }); +describe("isMilocoBackgroundSession", () => { + it.each([ + // miloco 后台 lane(backend dispatcher `_ROUTE`) + ["agent:main:miloco", true], + ["agent:main:miloco-rule", true], + ["agent:main:miloco-suggest", true], + // backend schedule runner:`miloco-schedule:` + ["miloco-schedule:abc123", true], + // hermes 侧 session_id 形态 + ["miloco:cron:digest", true], + ["miloco-rule-abc", true], + // 非 miloco:常规 cron / 用户 IM / CLI 主会话 + ["agent:main:cron:[t1]:run:abc", false], + ["agent:main:telegram:dm:123", false], + ["wechat:s1", false], + ["agent:main:main", false], + ["agent:main", false], + [undefined, false], + ])("%s → %s", (key, expected) => { + expect(isMilocoBackgroundSession(key as string | undefined)).toBe(expected); + }); + + // isolated cron 的 sessionKey 看不出归属,只能从 `[cron: ]` 里的 job 名认领。 + it("miloco 自管 cron(job 名带 miloco)→ true", () => { + expect( + isMilocoBackgroundSession("agent:main:cron:[t1]:run:abc", { + prompt: "[cron:job1 miloco-home-patrol] 执行家庭巡检。", + }), + ).toBe(true); + }); + + it("用户自建 cron(job 名不含 miloco)→ false", () => { + expect( + isMilocoBackgroundSession("agent:main:cron:[t1]:run:abc", { + prompt: "[cron:job2 PTM 汇报] 汇总今天的 PTM 进展。", + }), + ).toBe(false); + }); + + // 消息正文里出现 miloco 不算数——只认方括号内的 cron 头。 + it("正文提到 miloco 但无 cron 头 → false", () => { + expect( + isMilocoBackgroundSession("agent:main:telegram:dm:1", { + prompt: "帮我看看 miloco 是怎么工作的", + }), + ).toBe(false); + }); + + // cron 头只认词首的 `miloco-`:受管 job 都叫 miloco-,用户 job 名里顺口提到 + // miloco 不该被认领——否则又给它套回本次要摘掉的那条硬前置。 + it.each([ + ["[cron:job1 miloco-home-patrol] 巡检", true], + ["[cron:miloco-habit-suggest] 跑建议", true], + ["[cron:job7 巡检 miloco 日志] 看看日志", false], + ["[cron:job8 milocoish-report] 汇报", false], + ["[cron:job9 同步 miloco] 同步", false], + ])("cron 头 %s → %s", (prompt, expected) => { + expect(isMilocoBackgroundSession("agent:main:cron:[t1]:run:abc", { prompt })).toBe( + expected, + ); + }); +}); + describe("before_prompt_build 组装", () => { let tmpHome: string; let tmpWorkspace: string; @@ -212,19 +279,70 @@ describe("before_prompt_build 组装", () => { expect(r.prependSystemContext).not.toContain("语音指令"); }); - it("minimal(cron):仅身份+通知+语言,无感知/能力/记忆,append 为空", async () => { + it("minimal(常规 cron):仅身份+语言,无通知/感知/能力/记忆,append 为空", async () => { const { api, run } = makeApi(); registerBeforePromptBuildHook(api, {} as any); const r = await run("agent:main:cron:[t1]:run:abc"); expect(r.prependSystemContext).toContain("Miloco"); - expect(r.prependSystemContext).toContain("miloco-notify"); expect(r.prependSystemContext).toContain("## 输出语言"); + // 非 miloco 会话不注入通知块——回复本身就能到人,注入反而诱导它绕 skill。 + expect(r.prependSystemContext).not.toContain("## 通知用户"); + expect(r.prependSystemContext).not.toContain("miloco-notify"); expect(r.prependSystemContext).not.toContain("## 感知"); expect(r.prependSystemContext).not.toContain("## 能力概览"); expect(r.prependSystemContext).not.toContain("## 家庭记忆"); expect(r.appendSystemContext).toBeUndefined(); }); + it("用户 IM 会话(full)不注入通知块,但保留能力/记忆", async () => { + const { api, run } = makeApi(); + registerBeforePromptBuildHook(api, {} as any); + const r = await run("agent:main:telegram:dm:123", { + prompt: "帮我把客厅灯打开", + workspaceDir: tmpWorkspace, + }); + expect(r.prependSystemContext).not.toContain("## 通知用户"); + expect(r.prependSystemContext).not.toContain("miloco-notify"); + expect(r.prependSystemContext).toContain("## 能力概览"); + expect(r.prependSystemContext).toContain("## 家庭记忆"); + }); + + it("miloco 后台 lane(rule / suggest)仍注入通知块", async () => { + const { api, run } = makeApi(); + registerBeforePromptBuildHook(api, {} as any); + for (const key of ["agent:main:miloco-rule", "agent:main:miloco-suggest"]) { + const r = await run(key); + expect(r.prependSystemContext, key).toContain("## 通知用户"); + expect(r.prependSystemContext, key).toContain("miloco-notify"); + } + }); + + // backend schedule runner 的 session key 是 `miloco-schedule:`、消息带 `[cron:` 头, + // 因此 profile 落在 minimal——但它正是"到点主动播报"的场景,通知块不能跟着 minimal 一起砍掉。 + it("miloco 定时任务(minimal)仍注入通知块", async () => { + const { api, run } = makeApi(); + registerBeforePromptBuildHook(api, {} as any); + const r = await run("miloco-schedule:c1", { prompt: "[cron:早安播报] 播报今天的天气。" }); + expect(r.prependSystemContext).toContain("## 通知用户"); + expect(r.prependSystemContext).not.toContain("## 能力概览"); + }); + + // isolated cron 的 sessionKey 不带 miloco,靠消息头里的 job 名认领。 + it("miloco 自管 cron(job 名带 miloco)仍注入通知块,用户自建 cron 不注入", async () => { + const { api, run } = makeApi(); + registerBeforePromptBuildHook(api, {} as any); + + const mine = await run("agent:main:cron:[t1]:run:abc", { + prompt: "[cron:job1 miloco-habit-suggest] 执行每日习惯洞察。", + }); + expect(mine.prependSystemContext).toContain("## 通知用户"); + + const theirs = await run("agent:main:cron:[t2]:run:xyz", { + prompt: "[cron:job2 PTM 汇报] 汇总今天的 PTM 进展。", + }); + expect(theirs.prependSystemContext).not.toContain("## 通知用户"); + }); + it("isolated cron(sessionKey 像交互式,但消息带 [cron: 前缀)→ minimal", async () => { const { api, run } = makeApi(); registerBeforePromptBuildHook(api, {} as any); diff --git a/plugins/skills/miloco-notify/SKILL.md b/plugins/skills/miloco-notify/SKILL.md index 62506916b..84551ae1e 100644 --- a/plugins/skills/miloco-notify/SKILL.md +++ b/plugins/skills/miloco-notify/SKILL.md @@ -1,10 +1,10 @@ --- name: miloco-notify -description: 需要主动触达家庭成员时使用——感知告警、危险预警、任务到期、定时播报、设备反馈、关怀提醒等一切「主动告知 / 通知 / 触达」的场景。负责决策:通知给谁 → 走哪个渠道(音箱 TTS / IM / 米家推送)→ 写什么文案 → 交付执行。只要不是被动回答用户当前的提问、而是系统或 agent 要主动找人,就加载本 skill。用户想配置 / 绑定 / 解绑通知接收渠道时也走本 skill(见 references/channel-config.md)。 +description: 在 miloco 后台会话(感知引擎 / 定时任务 / 规则或任务事件触发、回复对用户不可见)里需要触达家庭成员时使用——感知告警、危险预警、任务到期、定时播报、设备异常、关怀提醒等场景。负责决策:通知给谁 → 走哪个渠道(音箱 TTS / IM / 米家推送)→ 写什么文案 → 交付执行。用户正在和你对话、你的回复本身就能到他眼前时**不要**加载本 skill(包括汇报他刚让你做的设备操作结果),直接回复即可。**唯一例外**:用户要配置 / 绑定 / 解绑通知接收渠道时,即使正在对话中也必须加载本 skill(见 references/channel-config.md)。 metadata: author: miloco - version: "3.1" - date: "2026-06-16" + version: "3.2" + date: "2026-07-31" openclaw: requires: bins: ["miloco-cli"] @@ -12,15 +12,17 @@ metadata: # miloco-notify -凡是 agent **主动找人**(而非回答当前对话)的场景,都经由本 skill 决策「**给谁 → 走哪 → 说什么**」,再交对应渠道执行。 +凡是在**回复到不了用户**的会话里要向家庭成员传达信息,都经由本 skill 决策「**给谁 → 走哪 → 说什么**」,再交对应渠道执行。 ## 何时激活 -- 要主动向家庭成员传递信息 → 激活(感知告警、危险预警、任务到期、定时播报、设备反馈、关怀提醒) -- 用户正在对话、你在直接回答他的提问 → **不激活**(那是普通回复) -- 用户要配置 / 绑定 / 解绑通知接收渠道 → 激活,转 [references/channel-config.md](references/channel-config.md) +默认看**本轮由谁触发**(渠道配置类请求除外,见第 3 条): -典型触发源:感知引擎 suggestion、任务 fire、cron 定时、巡检关怀、含「异常 / 危险 / 提醒 / 通知」语义的系统事件。 +- **miloco 后台会话**——感知引擎推送、miloco 定时任务、规则 / 任务事件拉起,turn 跑在后台、你的回复没有任何人看得到 → **激活**(感知告警、危险预警、任务到期、定时播报、设备异常、关怀提醒;语音提问的答复也算,得经 TTS 送回去) +- **用户正在跟你说话**——IM 对话、常规 cron 等你的回复本身就到得了人的会话 → **不激活**,直接回复即可。哪怕内容听起来像"通知"(汇报他刚让你做的设备操作结果、回答他问的设备状态)也一样,那是普通回复 +- 用户要配置 / 绑定 / 解绑通知接收渠道 → **不看触发源,一律激活**(这类请求只会发生在用户正在对话的会话里,是上一条的唯一例外),转 [references/channel-config.md](references/channel-config.md) + +典型触发源:感知引擎 suggestion、任务 fire、miloco 定时任务、巡检关怀、含「异常 / 危险 / 提醒 / 通知」语义的系统事件。 ## 重要原则