Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions plugins/hermes/miloco-plugin/context_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +64,41 @@ def resolve_profile(
return "full"


# cron 消息头:openclaw 把 cron turn 的消息改写成 ``[cron:<jobId> <jobName>] …``,
# backend schedule runner 则写成 ``[cron:<name>] …``。取方括号内整段做归属判断。
_CRON_HEADER_RE = re.compile(r"^\[cron:([^\]]*)\]")

# 受管 job 都叫 ``miloco-<name>``,要求 ``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:<cron_id>``,hermes 侧形如 ``miloco:cron:…`` / ``miloco-rule-<id>``。
- cron 头带 miloco:isolated cron 的 session_id 是 ``agent:<id>:cron:<jobId>:run:<runId>``,
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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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用用户使用的语言回复用户(设备名、人名、专有名词保持原样)。"

Expand Down Expand Up @@ -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":
Expand All @@ -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)

Expand Down Expand Up @@ -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 []
Expand Down
41 changes: 37 additions & 4 deletions plugins/hermes/miloco-plugin/hermes_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<system>`` 消息文本。

对齐 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:
Expand Down Expand Up @@ -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: <system>(可选) + <user>
# 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]] = []
Expand Down
24 changes: 24 additions & 0 deletions plugins/hermes/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
77 changes: 75 additions & 2 deletions plugins/hermes/tests/test_context_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>,词首出现才算
("[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):
Expand All @@ -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: "")
Expand Down
Loading
Loading