Multica 评论 @ 提及与委派机制深度解析:从 `mention://` 语法到触发器与权限门禁
导读
在 Multica 中,一条 issue 评论里的 @某个Agent 不仅是把人"圈"出来,更会触发后端为该 Agent 排入一个真实运行任务。本文基于仓库内内置技能文档 multica-mentioning(SKILL.md)及其源码映射(mentioning-source-map.md),完整讲解 mention 链接的语法契约、四种类型各自"入队了什么"、@all 广播语义、预览与抑制参数、以及"什么不会发生"的守卫与 outcome 编码,并下沉到 server/internal/util/mention.go 与 server/internal/handler/comment.go 的源码实现加以印证。读完你不仅能正确书写可用的 mention 链接,也能读懂 trigger_outcomes 里每一个状态码背后的判定顺序,避免把"折叠(coalesced)"误当成"提及失败"。
本文只讲后端层面一条 mention 链接意味着什么。是否应该提及(避免循环、对确认消息保持沉默)属于运行时指令(runtime brief)中 Mentions 章节的职责,不在本文范围内。
一、mention 链接由真实 UUID 构成
Multica 后端只通过唯一一种 Markdown 形态识别一条 mention:
@Label
其背后唯一的解析器是 mention.go 中导出的正则 util.MentionRe(第 16 行):
\[@?(.+?)\]\(mention://(member|agent|squad|issue|all)/([0-9a-fA-F-]+|all)\)
从中可以读出三条硬性约束:
<type>只接受四个值加一个哨兵:member、agent、squad、issue,以及字面量all;<id>只接受十六进制字符与连字符([0-9a-fA-F-]+),或者字面量all。这意味着典型的"人名"(含非十六进制字母)永远无法通过解析——链接目标必须是实体 UUID,绝不可能是显示名;- 方括号里的
Label是自由文本,采用非贪婪的.+?而不是排除]的字符组,因此像David[TF]、Bot[v2][beta]这类含方括号的标签也能被正确匹配(mention_test.go 的第 19-27 行用例专门钉死了这一行为)。@前缀是可选的,这是为了支持 issue 引用形如MUL-123的写法。
ParseMentions 会提取并去重 {Type, ID} 组合(同一 type:id 只保留一个),HasMentionAll 则报告是否存在 all 提及。上述四种类型 + @all 的完整形态全部由 Go 行为测试钉死——TestMentioningSkillTeachesTheParserContract(builtin_skills_test.go)会把它们逐一喂给 util.ParseMentions:纯文本 @name 解析为空、真实 UUID 解析成功、@all 解析为 {all, all}、而用真实 UUID 搭配错误的 type 依然能解析成功——这正是"type 必须与 id 来源匹配"的原因。
唯一的例外:project 是纯渲染链接
Label 被刻意排除在解析器之外——project 不在上述 type 组中,因此后端永远不会解析它、也就永远无法入队任何任务。它的设计意图恰恰如此:项目引用永远不应触发一次运行。每个客户端都会把这种链接渲染成可点击对象:Web/桌面端渲染为 chip(RichLink,见 rich-content.tsx),移动端则是点击后打开项目的普通富链接。因此可以用它自由指向项目(相关用法见 multica-projects-and-resources 技能),而本文其余内容只讨论解析器真正认识的那四种类型(外加 all)。
二、Step 1:用 --output json 先查出 UUID
名字不是 UUID。写入链接之前必须先查表,从对应的 list 命令取 UUID:
| 目标 | 查询命令 | 取哪个字段 |
|---|---|---|
| 人(member) | multica workspace member list --output json |
user_id |
| Agent | multica agent list --output json |
id |
| Squad | multica squad list --output json |
id |
关键陷阱:人的 mention id 必须是 user_id,而不是成员关系行(membership row)的 id。后端自己的 roster 格式化函数在生成 mention 时就使用 user_id:见 squad_briefing.go 中"Mention syntax for humans uses the user_id (matches the rest of …)"的注释,以及 formatMention(user.Name, "member", userID) 的调用,formatMention 产出的正是 @<name> 形态(第 216-218 行附近)。CLI 侧的命令在 cmd_workspace.go、cmd_agent.go、cmd_squad.go 中注册,且 cmd_compat_test.go 显式回归验证了 workspace member list 命令仍然可用。
按显示名匹配。如果名字有歧义或查不到,不要猜——直接在评论里说明情况,而不是发出一个坏链接。
三、Step 2:四种类型各自入队了什么
格式:@Name。type 与 id 来源必须匹配,否则链接会解析到错误的实体(或什么都解析不到):
| 目的 | type | UUID 来源 | 后端行为 |
|---|---|---|---|
| 触发一个 Agent | agent |
agent.id | 为该 Agent 入队一次运行(EnqueueTaskForMention) |
| 把活交给一个 Squad | squad |
squad.id | 解析该 squad 的 leader_id,为 LEADER Agent 入队一次运行 |
| 链接一个人 | member |
member.user_id | 只渲染链接;不入队任何东西——不会启动 Agent 运行 |
| 引用一个 issue | issue |
issue.id | 只渲染链接;不入队任何东西——永远安全 |
源码印证:触发集如何计算
触发集由 computeMentionedAgentCommentTriggers(在 comment.go 的 resolveMentionedAgentCommentTriggers 中体现,该函数名/职责与技能文档的描述一致)构建,评论路径把它折叠进 computeCommentAgentTriggers(comment.go),再由 enqueueCommentAgentTriggers(comment.go)入队。从源码可以清晰看到它只对两种类型采取行动:
squad分支(comment.go):在工作区内解析该 squad → 读取LeaderID→ 把 leader 加入触发集 → 入队时走 leader 触发路径(对应EnqueueTaskForSquadLeader一类的 helper,源码中的注释写明 comment trigger source 为MentionSquadLeader);- 之后
if m.Type != "agent" { continue }(comment.go)会跳过所有非agent的提及; agent分支(comment.go):按工作区加载该 agent 并加入触发集。
因此一条 member 或 issue 提及两个分支都到不了,永远不会入队任务。补充一点去重细节:seen 映射按"解析后的执行 agent id"去重,多个提及(如 @Agent A 与 leader 同为 A 的 @Squad S)解析到同一执行 agent 时只入队一次任务;而 targets 按"用户点名的 type:id"逐条记录 outcome,保证每条显式提及即使运行被折叠也不会被静默吞掉。
需要特别说明的边界:本技能不声称 member 提及会通过 Go 评论 handler 投递通知——computeMentionedAgentCommentTriggers 只按 squad/agent 分支,对 comment.go 中 notif 的检索只能找到一个与"log spam"相关的无关注释。经验证的契约是:只有 agent 与 squad 提及会入队工作。
四、发布前预览与按评论抑制
较新客户端可以在创建或编辑评论之前调用 POST /api/issues/{id}/comments/trigger-preview(路由在 router.go 中注册)。预览端点与创建、编辑重触发共用同一个 computeCommentAgentTriggers,因此界面上展示的 agent chips 来自后端规则,而不是客户端另起炉灶的复刻。
- 编辑预览传
editing_comment_id:服务端校验该评论属于同一工作区与同一 issue,校验或推导本次编辑的父评论上下文,并且只排除trigger_comment_id等于该评论本身的 pending 任务(走 agent.sql 中"exclude"变体查询,对应 helper 见 comment.go 的hasPendingTaskForIssueAndAgent)。同 issue 上来自其他评论的 pending 任务依然会对预览去重。原因在于:保存时先取消旧任务再重算触发集,所以被编辑的评论自己的历史任务不应让预览变空。它是"按评论作用域"的例外,不是绕开整个 agent 的旁路。 - 创建/编辑时可选传
suppress_agent_ids:服务端仍然先计算完整触发集,然后把它作为后置过滤器移除这些 agent id。字段缺失或为空保持旧行为;传一个合法但不在触发集中的 UUID 是 no-op;格式非法的 UUID 会在请求边界被直接拒绝(comment.go 用parseUUIDSliceOrBadRequest解析)。
对应的回归测试集中在 comment_trigger_preview_test.go:TestPreviewCommentTriggers_EditExcludesSameCommentPendingTask(编辑预览排除同评论 pending 任务)、TestPreviewCommentTriggers_EditExclusionDoesNotIgnoreOtherCommentPendingTask(其他评论的 pending 任务仍去重预览)、TestPreviewCommentTriggers_ReturnsMentionedAgentsAndSuppressFiltersCreate(suppress 过滤)等。前端在 client.ts 中发送 editing_comment_id 与 suppress_agent_ids,编辑 UI(comment-card.tsx)会在预览时渲染触发 chips、记录被抑制的 agent,并在保存时一并提交。
五、@all 是广播类型
@all 使用字面量 all,从不使用 UUID:
@all
它面向 issue 上的所有人。它不会让任何特定 Agent 运行。它的特殊之处体现在触发时刻:一条携带 @all 的评论会被视为广播,从而抑制 issue 负责人的自动"评论即触发"(以及其他隐式路由兜底——thread parent / conversation owner)。用 @all 来公告,不要用 @all 向负责人要活。
但 @all 只抑制那些隐式路由。同一条评论中显式的 @agent / @squad 依然正常触发(MUL-5411):一条形如
@all heads up — @Preflight please take this
的评论只会入队 Preflight、不会入队其他人。显式提及优先于广播。源码依据在 comment.go:computeCommentAgentTriggers 里 hasAgentOrSquadMention(mentions) 分支在 util.HasMentionAll(mentions) 短路之前被求值;且由于 all 既不是 agent 也不是 squad,它在 resolveMentionedAgentCommentTriggers 里同样被 if m.Type != "agent" { continue } 跳过,永远无法独自入队。测试 TestPreviewCommentTriggers_AllPlusExplicitAgentMentionStillTriggers、AllPlusExplicitSquadMentionStillTriggers、AllPlusMemberMentionStaysSuppressed(均在 comment_trigger_preview_test.go 中)分别验证了 @all 单独→0 个 agent、@all+@agent→仅该 agent、@all+@squad→leader、@all+@member→0 个 agent。
六、"什么不会发生":no-op、blocked、coalesced 与 deferred
下面这些情况都不会启动新运行,也都不会产生 error 响应——但它们是三种不同的事,且响应会告诉你属于哪一种。从未解析成功的提及是真正的静默 no-op;解析成功但被拒绝的会以 status: "blocked" + reason_code 的形式出现在 trigger_outcomes 里;目标正忙的会返回 coalesced 或 deferred。发帖后务必阅读这个数组——它是这些情况唯一会现身的地方。
1. 该放 UUID 的地方放了名字
mention://member/Alice 是死的。id 组只接受十六进制与连字符(或 all),普通名字里必然出现的非十六进制字母会让整个模式匹配失败,解析器返回空。无任何报错,也无人被触达。
2. 长得像 UUID 但是错的 UUID
一个格式良好却没有任何实体拥有的 UUID 能解析成功,然后在查找阶段 no-op:工作区作用域的查询找不到对应 agent,提及以 invocation_not_allowed 被标记为 blocked(见 comment.go,其中注释直言 "Do not reveal whether the id exists")。
这个错误码是刻意保持模糊的——打错的 UUID 与真正的权限拒绝看起来一模一样,因为你敲下的 id 可能指向另一个工作区的私有 agent,而 reason 绝不能证实它的存在。所以:看到 invocation_not_allowed 时,先对照线上名册(roster)核对 UUID,再去动任何可见性或调用设置(MUL-5548)。multica squad member list <squad-id> --output json 返回的 member_id 正是构造 mention 所用的字段。
反过来,一个匹配了模式却根本不是合法 UUID 的 id(如 mention://agent/-)会被带错误返回的 util.ParseUUID 拒绝(不是会 panic 的 Must 变体——评论文本不可信,后者会 panic 请求),并以 target_unavailable 被 block(comment.go)——非 UUID 在任何工作区都指不出实体,因此无需隐藏什么。两种情况都不会是 error 响应。测试 TestPreviewCommentTriggers_MalformedMentionIDDoesNotPanic 钉死了这一点。
3. 已存在 pending 任务
即使 @agent/@squad 完全正确,当目标在该 issue 上已有 pending 任务(hasPendingTaskForIssueAndAgent,comment.go)时也不会启动第二次运行。这是"折叠"而不是"丢弃":评论合并进那个任务,outcome 为 coalesced(同一个已评审 head)或 deferred(不同 head)——不要把它当成"提及没生效"而重发一遍。编辑预览是唯一例外:editing_comment_id 会忽略被编辑评论自身的 pending 任务(见本文第四节)。
4. 已归档的 agent / 未绑定 runtime 的 agent(或 leader 同样如此的 squad)
分别以 target_unavailable 与 runtime_offline 被 block。两者都只在 invoke 门禁之后才检查,因此一个无权调用目标的调用者永远无法得知它的状态。
5. 无法调用的私有 agent
被 block。mention 路径对 @agent 与 @squad 都门禁在 canInvokeAgent 上(comment.go 与第 3183 行)。这是**"运行"门禁,不是"看见"门禁**:自 MUL-3963 起,一个能在 UI 中打开私有 agent 的工作区管理员也不一定能触发它——能查看目标不等于能提及它。(顺带区分:canEnqueueSquadLeader 包装是 squad 指派/提升路径,不是本条 mention 路径;子任务完成的唤醒则不设门禁——见 multica-squads 技能。作为对照,canAccessPrivateAgent——"看见"门禁——定义在 agent_access.go,被刻意排除在 mention 路径之外。)
守卫顺序速查
| 守卫 | Outcome |
|---|---|
| 目标已归档 / 无 runtime 绑定 | blocked target_unavailable / runtime_offline(invoke 门禁之后求值) |
| 无法 invoke 的私有 agent / 私有 squad leader | blocked invocation_not_allowed |
| 格式良好但工作区内查无此 agent | blocked invocation_not_allowed(与私有 agent 同码,防枚举) |
非 UUID 的 id(mention://agent/-) |
blocked target_unavailable(agent 与 squad 分支皆然) |
| 目标已有该 issue 的 pending 任务 | 非丢弃:折叠进该任务 → coalesced / deferred(合并失败则 blocked) |
七、跨 issue 的链路与自动化委托的特殊规则
跨 issue 链路保留发起人(MUL-6490)
A2A(agent-to-agent)门禁判定的是你链路顶端的那个人,而那个人会跟着你写的评论走:评论记录下创作它的那次运行(source_task_id),因此它唤醒的运行会继承你的 originator。这在你在不同于当前运行所在的 issue 上评论时依然成立——也就是常见的"先创建 issue Y,再到那边协调"流程——所以在自己 issue 上奏效的委托,在你新建的 issue 上继续奏效。反向则不成立:没有任何机制会替换成另一个不同的人(你的 agent 的 owner,或目标 issue 的 originator)。因此如果你的链路顶端没有人,member 作用域的 allow-list 无论你移到哪个 issue 都会保持关闭。
无归属 autopilot 运行的委托(MUL-4857)
当一个无归属(unattributed)的 autopilot 运行(定时调度/webhook 分发没有人形 originator,A2A 门禁无人可依)在其创建的 issue 上通过 @mention 委托时,invoke 门禁回退到 autopilot creator 作为有效调用用户——即最初放行该分发的同一主体。于是运行中途的 @agent/@squad 委托,恰好在 autopilot creator 本可以调用该目标时触发(owner / public_to 匹配),否则保持跳过。注意这只是授权——被入队运行的 originator/归属不变。
这一回退绑定在可验证的任务血缘上(见 comment.go 关于 effectiveInvoker 的注释,以及 agent_access.go 中 autopilotDelegationAuthority 一族 helper):它只在"正在委托的运行自身就是处理该 autopilot issue 的那个任务"(author == task agent、task.issue_id == 本 issue)时适用,因此在别处工作的运行永远无法通过在该 issue 上评论来借用另一个 autopilot creator 的权限。同一权威也承载在普通的"指派 squad leader 唤醒"上(worker 在 autopilot issue 上的结果评论仍可唤醒 leader),并且在目标繁忙时依然存活:若被提及的 agent 正在运行,委托会以同一权威在该运行完成时重放,因此永远不会丢失。
编辑被视为全新的动作——它会从编辑动作重新推导评论血缘。只有 agent 作者编辑自己的评论才把血缘重新盖到当前编辑任务上;任何其他编辑者——包括以 owner/admin 身份编辑 agent 评论的工作区所有者/管理员——都会清除它。因此,编辑一条来自无关 issue 的旧 autopilot 评论,或管理员编辑 agent 的评论(拥有的是 manage 权限而非 invoke 权限),会在延后完成对账(deferred completion-reconcile)时以"关闭即失败"结束,而不是复用原运行权威。
八、错误写法 → 正确写法
错误:@alice please review
→ 纯文本,没有链接,解析为空,无人被触达。
错误:@Alice please review
→ "Alice"不是 UUID;id 组拒绝非十六进制字母,模式不匹配,链接静默死亡。
正确:
multica workspace member list --output json→ 得到 Alice 的user_id=7f3a…@Alice please review→ 真实的user_id通过解析;链接渲染并解析到 Alice。
@all 广播:@all heads up——面向所有人、不运行任何特定 agent、并抑制 assignee 自动触发。
这些精确形态由 TestMentioningSkillTeachesTheParserContract(builtin_skills_test.go)通过 util.ParseMentions 逐一钉死:名字形态解析为空、真实 UUID 形态可解析、@all 解析为 {all, all}、用真实 UUID 搭配错误 type 依然能解析(这正是 type 必须与 id 来源匹配的原因)。
参考资料与源码路径
- 技能正文:SKILL.md
- 文件行级证据源:mentioning-source-map.md
- mention 正则与解析:mention.go;解析器单元测试:mention_test.go
- 评论触发计算与入队:comment.go(
computeCommentAgentTriggers、resolveMentionedAgentCommentTriggers、hasPendingTaskForIssueAndAgent、enqueueCommentAgentTriggers) - 预览与抑制的回归测试:comment_trigger_preview_test.go
- 技能契约评估测试:builtin_skills_test.go
- 可见性与调用门禁对照:agent_access.go
- roster mention 生成(member 用
user_id):squad_briefing.go - 去重查询(含 exclude 变体):agent.sql
- 相邻主题技能:multica-squads(Squad 语义)与 multica-projects-and-resources(项目引用)分别位于 multica-squads/SKILL.md 与 multica-projects-and-resources/SKILL.md
提示:
mentioning-source-map.md中给出的行号是针对某一时刻树的指针,源码持续演进可能导致行号漂移(例如resolveMentionedAgentCommentTriggers已移动到 comment.go 更靠后的位置)。行为契约才是稳定的——如果 SKILL.md 所述行为与现状不一致,请回到 source map 重新核对。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00