feishu-new-topic
A Hermes Agent plugin for Feishu/Lark. #new opens a new topic — a new Hermes session — while the
session you were in keeps running.
English
you: #new check this quote for me
└─ opens topic omt_… → this message lands in a fresh session
the session you were in keeps running, untouched
Why not /new?
/new is a reset: it interrupts the turn running in the session, drops its queued follow-ups and
in-flight delegations, and clears the conversation. If a long task is running in the main chat,
/new kills it.
#new is a second lane. The message is routed to a different session (a Feishu topic), so the
work in progress keeps running exactly as it was — same model, same tool calls, same partial output.
Nothing is interrupted, nothing is cleared.
| You want | Use |
|---|---|
| Start something unrelated while the current task keeps running | #new … |
| Throw away the current conversation and everything running in it | /new |
Rules
Two triggers. No escape character, no exceptions.
| You send | Result |
|---|---|
| #new <body> | Opens a topic on that message; <body> is the first turn of the new session |
| #new (alone) | Opens a topic; the body becomes a Chinese placeholder telling the agent a topic was just opened |
| #NEW … / #new … | Same — case and full-width forms are folded |
| #new … inside a topic | No nested topics in Feishu: the prefix is stripped, a note is appended, the message stays in the current session |
| anything else | Untouched, including # heading, # comment, # new title, ## h2, #!/bin/sh, #news |
The trigger must be the first characters of the message. Two consequences worth knowing: in a
group, if you @-mention someone else alongside the bot, Feishu's adapter prepends a
[Mentioned: …] hint and the trigger no longer starts the text, so it will not fire; and #new
followed by a slash command (#new /reset) opens the topic and then runs that command in the new
session, because the rewritten text goes through normal command parsing.
Why #new and not # or %: a bare # opens a Markdown heading, a root shell prompt and a
Python/shell comment; %, $, > collide with LaTeX, prompts and mail quotes. Pasting
documentation or code would then require an escape rule. #new is a single token that Markdown
never reads as a heading (# must be followed by a space) and that code only writes as # new .
It does not collide with Markdown, code or shell syntax, so no escape rule is needed.
Voice
Voice notes are matched against these phrasings. Arbitrary spacing inside a phrase — as ASR may
insert — is tolerated (e.g. 开启 一个 新话题). Trailing punctuation is allowed; whatever follows is
the body.
开启一个新话题 · 开启一个新的话题 · 开启一个新对话 · 开启一个新的对话
开始一个新话题 · 开始一个新对话 · 打开一个新话题 · 新开一个话题 · 新开一个对话
Phrase alone → topic opened, waiting for instruction.
How it works
Hermes derives the session key from platform + chat_id + thread_id, so a new topic already means a
new session. The missing piece: Feishu gives a bot no "new conversation" button.
The plugin registers pre_gateway_dispatch, which Hermes runs at ingress before auth, command
parsing and build_session_key(), and whose event.source is mutable:
POST /openapi/im/v1/messages/{message_id}/replywithreply_in_thread=trueon the user's own
message — Feishu returns thethread_id.- That placeholder reply (
·) is deleted. The topic survives; its root is the user's message, so
nothing from the bot lingers in the new topic. event.source.thread_idis set to the new id; the gateway re-readsevent.sourceand
build_session_key()then yields a fresh session key.- The trigger is stripped and the message is dispatched as a normal rewrite.
No core changes, no config.yaml changes.
Install
hermes plugins install kaigregliu/hermes-feishu-new-topic
hermes plugins enable feishu-new-topic
hermes gateway restart
Manual: clone into any directory under ~/.hermes/plugins/ — the directory name is only a
convention, the manifest's name is the key — then
hermes plugins enable feishu-new-topic && hermes gateway restart.
Verify:
hermes plugins validate ~/.hermes/plugins/feishu-new-topic # expect: Validation passed
hermes plugins doctor feishu-new-topic # expect: OK … 1 hook(s)
Requirements: Hermes Agent with the Feishu adapter configured and online, running plugins
in-process (plugins.isolation: in_process, the default). The manifest declares
requires_hermes: ">=0.22"; the plugin was built and verified against Hermes 0.22.5. No Python
dependencies — lark_oapi ships with Hermes and is imported lazily, only when a topic is actually
created.
Behaviour when things fail
| Situation | What happens |
|---|---|
| No live Feishu adapter | Message dispatches normally in the current session, with a note saying why |
| Reply API error / no thread_id | Same; the Feishu error code is included in the note. Up to 3 attempts with backoff, retrying only on 2200 Internal Error and network errors |
| Placeholder delete fails | The topic still works; a · reply may stay in it (logged as a warning) |
| Duplicate delivery of the same message | The already-created thread_id is reused for 15 min (last 40 messages kept in the plugin state file) |
| Unauthorised sender | Not rewritten at all — the message follows Hermes's normal authorisation path |
Limits
- Feishu only. Not a generic multi-platform plugin; it is a no-op on every other platform.
- Groups: the bot must actually receive the message. That is Feishu's
require_mentionbehaviour
(defaulttrue, configurable in the adapter'sextra), not something this plugin changes. - One level only: a topic cannot contain a topic.
- In-process only: with
plugins.isolation: host, hooks receive a placeholder gateway instead of the
live runner, so the plugin cannot resolve the receiving bot or the sender's authorisation. It then
does nothing and the message stays as written in the current session. - If Feishu processed the reply but every response is lost on the way back, the plugin never learns
thethread_id: the message is dispatched normally in the current session, and the topic that was
created server-side (with its placeholder) may be left orphaned. Retries reuse one request UUID,
which makes a duplicate topic unlikely, but no client can resolve that ambiguity blindly. - No topic-title API is involved. Feishu derives the label shown in the message list from the first
message in the topic; the plugin deletes its placeholder, so the agent's first reply is what
shows. Feishu's API has no topic-title field to set. - Depends on gateway/adapter internals (
_adapters_for_profile, the adapter's_clientand
_run_blocking). These exist in Hermes 0.22 and are used by Hermes's own bundled plugins, but a
future Feishu-adapter refactor can break this plugin — it degrades to "topic could not be opened",
never to a dropped message.
Disclosure
- Network: normally two Feishu OpenAPI calls —
messages/{id}/replywithreply_in_thread=true
(creates the topic, returnsthread_id) andmessages/{id}/delete(removes the one-character
placeholder reply). The reply is retried up to three times on2200 Internal Erroror network
errors, so a failing creation can cost three attempts; cached duplicates, in-topic messages and
unauthorised senders make no calls at all. Nothing else is contacted; no telemetry. - Credentials: none handled. Calls go through the Feishu client of the bot that received the
message (_delivery_adapter_for), which Hermes already authenticated. - State: a dedupe map (15 min TTL, ≤40 entries) in the plugin context, persisted by Hermes to
~/.hermes/plugin-data/<plugin>/state.json. Each entry is keyed by the Feishumessage_idit came
from and holds{thread_id, timestamp}— message ids and topic ids, no message content. - Hermes surfaces: the
pre_gateway_dispatchhook, plus read-only access to the receiving bot's
adapter (_delivery_adapter_for) and its Feishu client. No rebinding of core objects, and no
guessing an adapter from the profile or the default bot. - Authorisation: a message is only rewritten after the gateway's own authorisation probe
(_is_user_authorized_for_source) says yes. With no such probe, the plugin does nothing — fail
closed, never "open the topic anyway". - Locale: the notes the plugin appends (the empty-body placeholder, the in-topic note, the
failure notes) are Chinese-only, whatever language the user writes in.
Test
pip install lark-oapi # the recall path builds a real DeleteMessageRequest
python tests/test_plugin.py # expect: 20/20 passed
No Hermes installation needed — the suite drives the hook with fake adapters, events and context.
Covers: trigger and prefix stripping, full-width and case forms, plain text untouched, #-forms
untouched, #news boundary, bare #new, in-topic degradation, creation failure, duplicate reuse
(sequential and concurrent), the receiving bot winning over a profile's own bot, unauthorised sender,
a gateway without the authorisation probe or the delivery resolver (fail closed), voice phrasings
(including ASR spacing and phrase-inside-body), long body.
License
MIT — see LICENSE. Authors: kai & 雪碧.
Built for Hermes Agent.
中文
你: #new 帮我核一下这张报价单
└─ 开一条话题 omt_… → 这句话进一个全新的 session
你原来那个会话照跑不误
为什么不直接用 /new
/new 是重置:它会打断该会话里正在跑的 turn,丢掉排队的跟进和在飞的后台任务,然后清空对话。
主会话跑着长任务时,/new 会直接把它终止。
#new 是另开一条车道:这条消息被路由到另一个 session(飞书话题),正在跑的活原样继续 ——
同一个模型、同一批工具调用、已经产出的部分都还在。不打断,也不清空。
| 你想要 | 用哪个 |
|---|---|
| 当前任务继续跑,同时开一件不相干的事 | #new … |
| 丢掉当前对话和里面在跑的一切 | /new |
规则
两条触发,没有转义符,没有例外。
| 你发 | 结果 |
|---|---|
| #new 正文 | 在这条消息上开一条话题,正文就是新会话的第一轮 |
| #new(单独一条) | 也开话题,正文变成一句「刚开了一条新话题,等你下一步指令」 |
| #NEW … / #new … | 一样 —— 大小写、全角都认 |
| 在话题内发 #new … | 飞书没有嵌套话题:去掉前缀、末尾附一句提示,消息留在当前会话 |
| 其它一切 | 原样不动,包括 # 标题、# 注释、# new 标题、## 二级标题、#!/bin/sh、#news |
触发词必须是消息的头几个字符。两个要知道的后果:群里同时 @ 了别人时,飞书适配器会在正文前插一句
[Mentioned: …],#new 就不在开头了,不会触发;#new 后面跟斜杠命令(#new /reset)会先开话题、
再在新会话里执行那条命令 —— 因为改写后的正文走的是正常命令解析。
为什么用 #new 而不是单个 # 或 %:单个 # 是 Markdown 标题、终端 root 提示符、Python/shell
注释的开头;%、$、> 又分别是 LaTeX 注释、终端提示符、邮件引用 —— 粘贴文档或代码就会撞车,
撞了就得发明转义规则。#new 是个连写 token:Markdown 标题要求 # 后跟空格,代码注释只会写成
# new … 带空格,所以它几乎不会被粘贴内容命中,不需要转义。
语音
语音按下面这些说法命中(ASR 在字之间插空格也认:开启 一个 新话题)。短语后面可以跟标点,
剩下的就是正文。
开启一个新话题 · 开启一个新的话题 · 开启一个新对话 · 开启一个新的对话
开始一个新话题 · 开始一个新对话 · 打开一个新话题 · 新开一个话题 · 新开一个对话
只发短语不带正文 → 开话题,等你下一步指令。
原理
Hermes 的会话键来自 platform + chat_id + thread_id,所以「新话题 = 新 session」本来就是内核行为;
缺的是飞书这一层没有「给机器人新建一个空对话」的入口。
插件注册 pre_gateway_dispatch:它在 auth、命令解析、build_session_key() 之前触发,
event.source 可变:
- 对用户那条消息调
POST /openapi/im/v1/messages/{message_id}/reply并带
reply_in_thread=true—— 飞书返回thread_id; - 立刻撤回那条单字符占位回复(
·)。话题留下,根消息是用户自己那条,话题里不残留机器人噪声; - 把
event.source.thread_id改成新话题 id;网关会重读event.source,于是
build_session_key()算出新的会话键; - 去掉触发词,消息按正常 rewrite 分发。
不改内核、不改 config.yaml。
安装
hermes plugins install kaigregliu/hermes-feishu-new-topic
hermes plugins enable feishu-new-topic
hermes gateway restart
手动装:克隆到 ~/.hermes/plugins/ 下任意目录 —— 目录名只是约定,真正的键是 manifest 里的 name
—— 再 hermes plugins enable feishu-new-topic && hermes gateway restart。
确认:
hermes plugins validate ~/.hermes/plugins/feishu-new-topic # 期望:Validation passed
hermes plugins doctor feishu-new-topic # 期望:OK … 1 hook(s)
前提: Hermes Agent 的飞书适配器已配好并在线,且插件在进程内运行(plugins.isolation: in_process,这也是默认值)。manifest 声明 requires_hermes: ">=0.22",插件在 Hermes 0.22.5 上
开发并验证。不需要任何 Python 依赖 —— lark_oapi 随 Hermes 一起装,且只在真正建话题时才懒加载。
失败时怎么办
| 情况 | 结果 |
|---|---|
| 飞书适配器不在线 | 消息照常在当前会话处理,末尾附一句原因 |
| 接口报错 / 没返回 thread_id | 同上,提示里带飞书错误码。最多 3 次退避重试,只在 2200 Internal Error 与网络错误时重试 |
| 撤回占位消息失败 | 话题照样可用,只是可能留一条 ·(日志里是 warning) |
| 同一条消息重复投递 | 15 分钟内复用已建的话题(保留最近 40 条,存在插件状态文件里) |
| 未授权的发送者 | 完全不改写,消息走 Hermes 原本的授权流程 |
边界
- 只支持飞书,不是通用的多平台插件;在其它平台上它什么都不做。
- 群聊里机器人必须真收到这条消息 —— 那是飞书的
require_mention行为(默认true,可在适配器
extra里配),本插件不改变它。 - 只有一层:话题里不能再开话题。
- 只在进程内生效:若
plugins.isolation: host,钩子拿到的是占位网关而不是活的 runner,插件无法
解析「收到消息的 bot」和发送者授权 —— 此时它什么都不做,消息按原文留在当前会话。 - 如果飞书已经处理了 reply、但所有响应都在回程丢失,插件拿不到
thread_id:消息降级回主会话,
而服务端已建出的那条话题(含占位回复)可能成为孤儿。重试复用同一个请求 UUID 能大幅降低重复建
话题的概率,但这种「结果未知」任何客户端都无法盲解。 - 不涉及任何话题标题 API。飞书消息列表里那个标题来自话题里的第一条消息;插件把占位消息撤掉了,
所以显示的是 agent 的第一句回复。飞书的 API 没有可设置的话题标题字段。 - 依赖网关/适配器内部接口(
_delivery_adapter_for、适配器的_client与_run_blocking)。
这些在 Hermes 0.22 里存在,Hermes 自带的插件也在用,但将来飞书适配器重构可能让它失效 ——
失效时降级成「开不了话题」,不会丢消息。
披露
- 网络: 正常两次飞书 OpenAPI 调用 ——
messages/{id}/reply(带reply_in_thread=true,建话题并
返回thread_id)和messages/{id}/delete(撤掉单字符占位)。reply 在2200 Internal Error或
网络错误时最多重试 3 次,所以建话题失败一次最多会打 3 次;命中缓存、在话题内、未授权这三种情况
一次调用都不发。除此之外不联系任何服务,没有埋点。 - 凭据: 不碰。调用走「收到这条消息的那个 bot」的飞书客户端(
_delivery_adapter_for),
那是 Hermes 已经登录好的。 - 状态: 插件上下文里存一份去重表(15 分钟 TTL,最多 40 条),Hermes 会把它落到
~/.hermes/plugin-data/<plugin>/state.json。每条以来源的飞书message_id为键,值只有
{thread_id, 时间戳}—— 消息 id 与话题 id,没有消息正文。 - 用到的 Hermes 接口:
pre_gateway_dispatch钩子,以及对「接收消息的 bot」适配器
(_delivery_adapter_for)与其飞书客户端的只读访问。不挂接(rebind)任何内核对象,
也不按 profile 或默认 bot 去猜适配器。 - 授权: 只有当网关自己的授权探针(
_is_user_authorized_for_source)放行时才会改写消息;
探针不存在就什么都不做 —— fail closed,绝不「先开了再说」。 - 语言: 插件附加的提示(空正文占位语、话题内提示、失败提示)目前只有中文,不随用户语言切换。
自测
pip install lark-oapi # 撤回路径会构造真实的 DeleteMessageRequest
python tests/test_plugin.py # 期望:20/20 passed
不需要装 Hermes —— 用假的适配器、事件和上下文直接驱动钩子。
覆盖:触发与去前缀、全角与大小写、普通消息不动、单个 # 系列不动、#news 边界、只发 #new、
话题内降级、建话题失败降级、重复投递复用(顺序与并发)、多 profile 下「接收 bot 优先于运行
profile 自己的 bot」、未授权不触发、网关缺授权探针或缺 delivery 解析器时 fail closed、
语音各种说法(含 ASR 断字与句中不触发)、长正文照常开。
许可
MIT —— 见 LICENSE。作者:kai & 雪碧。
为 Hermes Agent 而写。
README from the reviewed commit d8da4b3; it updates when the author re-pins.

kaigregliu