liaowenqi123/dsh-meeting-coordinator ↗★ 0

dsh-meeting-coordinator

DSH 多 Agent「会议室」插件:会籍(会议室)+ fork 上下文入场 + 主持人控场多轮讨论 + 会后各自压缩并回到工作区继续工作。基于 dsh-std 元协议。 适合需要实现多Agent协同讨论、控场及上下文管理等复杂任务的用户。

パッケージ
dsh-meeting-coordinator
互換性
未検証
バージョン
0.1.0
ライセンス
MIT
最終更新
2026/09/23

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:liaowenqi123/dsh-meeting-coordinator

ドキュメント

README 全文を読む ↗

dsh-meeting-coordinator

给一群长跑的 Agent 装上「会议室」与「互相唤起」。 基于 dsh-std 元协议开发,为 DeepSeek Harness 提供多 Agent 协作。

DSH Plugin CI dsh-std Node License: MIT

⚠️ 早期阶段 / Early stage. 核心机制已经跑通并有 215 个测试覆盖,但还没在真实生产场景里长期跑过。 遇到问题请开 Issue —— 非常欢迎反馈、提 bug、发 PR,见欢迎贡献。


一、精髓:开会,和互相唤起

单个 Agent 长时间工作时会得三种病:上下文污染、死循环(在自己的上下文里反复确认同一个错误结论)、 遗忘全局目标。多 Agent 并行的方案很多,但真正难的是"持续循环跑下去"。

本插件的答案不是更好的提示词,而是给 Agent 们一个组织。

1.1 一个新的概念:会议室(群)

关键区分——不是会话属于会议,而是会话加入会议室:

   会话 A ──┐                      ┌── 会话 B
            │   加入(主动)        │
            ├──▶  ┌──────────┐  ◀──┤
            │     │  会议室   │     │
   会话 C ──┘     └──────────┘     └── 会话 D(没加入)
                                                    │
                                          叫不动,也不会被误叫
  • 会议室是持久实体,不是每次开会临时建的;
  • 加入会议室 = 自动获得「唤起会议」的权利 + 「参会」的义务。 没加入的会话,既不能召集,也不会被召集;
  • 成员可跨工作区(成员列表里带 workspace,但不因此分裂成多个群);
  • 一个会话只能属于一个会议室。

这一条解决的是"不是每一个会话都是可被召集或唤起的": 会籍就是权限边界。你的私人助手会话不会因为某个 Agent 想开会就被拉进群聊。

1.2 「开会」是什么

不是"协调器把摘要广播出去"。是:

每个会话带着自己的记忆,临时进入会议室; 入场时预注入一段 prompt(说明议题、在场有谁、你可以说什么); 会议室是一个多轮群聊,由主持人控场,像真的开会一样互相回应; 会后每个会话各自对会议做压缩,只留下与自己相关的内容; 回去之后,记忆变成「原私有上下文 + 自己相关的会议纪要」。

关键在于每人拿到的纪要是不同的。如果是"一份全局会议纪要发给所有人", 那只是把广播换了个说法,别人的全部发言会原封不动挤进我的上下文——那正是污染。

  会话 A(记忆A)      会话 B(记忆B)      会话 C(记忆C)
        │                  │                  │
        └──────────┬───────┴───────┬──────────┘
                   ▼               ▼
        ┌────────────────────────────────────────────┐
        │  会议室(多轮群聊,共享 transcript)         │
        │                                            │
        │  入场:定位 prompt + 各自记忆的限长投影      │
        │  发言:主持人控场(主持人没有上下文)         │
        │  人类可随时插话(不占轮次)                  │
        └────────────────────────────────────────────┘
                   │               │
                   ▼               ▼
            A 的个性化纪要    B 的个性化纪要   C 的个性化纪要
              (内容各不相同)
                   │               │
                   ▼               ▼
            记忆A + 纪要A     记忆B + 纪要B   记忆C + 纪要C

1.3 「互相唤起」是什么

一个 agent 选择开会后,可以召集所有在工作或者不在工作的 agent (不在工作状态指的是输出完毕等待用户回复,或已完成任务), 开完会后,所有 agent 都会回到工作状态。

所以"唤起"是状态层面的动作,而且正在工作的会话不会被会议打断:

                      summon()
  working ─────▶ awaiting-entry ──(当前工作单元结束)──▶ in-meeting
  idle-waiting ────────────────────────────────────▶ in-meeting
  done ───────────────────────────────────────────▶ in-meeting

                      dismiss()  ──▶ 召集前的状态(还原,不是一刀切)
  • working(正在输出 / 正在调用工具)→ 先进 awaiting-entry。 不打断,等宿主在"本次输出结束 / 本次工具调用返回"的边界调 onWorkUnitComplete() 才入场。 这就是需求里说的"有一个等待的过程";
  • idle-waiting(等用户回复)和 done(任务已完成)没有工作单元要等,直接入场;
  • 散会时把每个人还原到被召集之前的状态:在跑的还在跑、等用户回复的继续等、 已完成的仍然完成,包括中途才入场的和始终没入场的(缺席)。 这解决了"会议开完了,那个已完成的 Agent 该干嘛"的问题。 ⚠️ 这里曾经写死成"全部推回 working",是一个会让会议室一次性报废的 bug: 从 idle-waiting 被叫来的成员散会后被谎报成"正在干活",下一次召集就永远等一个 不会到来的工作单元边界——"再点一次召集,就告诉我全员在工作中"。 详见 5.12。

1.4 主持人:有控场权,但没有上下文

设置一个主持人吧,模型使用唤起会话的模型,但是这个主持人直接没有上下文, 不会被任何人的上下文带偏。

这条把两件事彻底解耦了:

  • 控场(下一个谁说话、什么时候散会)由主持人负责;
  • 发言由各自会话产出,各自带着自己的记忆。

主持人只拿到群聊记录 + 成员名单 + 谁还没说话,拿不到任何人的私有记忆投影。 因此它不可能因为"某人的上下文更长/更详细"而偏好某人—— 这就是"不会被带偏"的机制保证,而不是一句提示词约定。

主持人看到的:  议题 / 群聊记录 / 名单 / 谁没说话 / 累计发言次数
主持人看不到:  任何人的工作记忆、代码、日志、历史会话

它用的是召集者会话的模型:谁开的会,就像谁在主持,风格一致, 且不需要为协调器单独引入一个模型依赖。

1.5 不做结构化

我觉得没必要做结构化的东西,AI 会自动讨论出合理结果的(这个主要还是针对 AI agent 的集会)

所以:

  • 入场 prompt 只做定位(你是谁、议题是什么、在场有谁、可以说什么), 不强制"进度/障碍/需要"三段式,也不强制任何字段;
  • 发言内容完全自由;
  • 唯一保留的硬约束是单次发言字数上限——它不是发言格式, 而是防上下文爆炸的工程底线(N 人 × R 轮不设限就会线性膨胀)。关键是软目标与硬上限分开:提示词只说"大致 800 字"(甚至明确写"不要去数字数、不要用工具核对字数"),硬上限放宽到 1600 字——紧贴实际长度的硬上限会逼模型去数字符甚至调工具核对,那是真实的算力黑洞。

主持人输出的控制决定是 JSON({"action":"invite","next":"..."} 或 {"action":"adjourn"})。 那不是给 AI 的模板,而是协调器与主持人之间的控制通道; 而且解析失败时绝不会卡住会议——会回退到安全轮转。

1.6 跑一遍看效果

pnpm run demo:room

真实输出(节选):

[1] 会议室是持久实体;会话主动加入才有"唤起/参会"的权利与义务
  ✓ 会议室「量化同步会」成员 3 人:
  · live-trading  工作区=quant-live  模型=deepseek-v4.1-flash
  · neural-net    工作区=quant-lab   模型=deepseek-v4-pro
  · trad-algo     工作区=quant-lab   模型=deepseek-v4-pro
  ✓ 成员来自 2 个不同工作区

[2] 不是所有会话都能被唤起:没加入的会话叫不动
  · data-infra 已登记为会话,但**没有加入**这个会议室
  ✓ 越权召集被拒绝:会话 data-infra 不是会议室 quant-sync 的成员,没有召集权。

[4] 人召集会议:working 的先进"待入场",空闲的直接入场
  · 会议已开始,入场情况:在场=human/neural-net/trad-algo  未到场=live-trading
  · live-trading 还在忙着,没有被打断 —— 它被记为"待入场"
  · → 现在 live-trading 的当前工作单元结束了(onWorkUnitComplete)
  · 会场更新:在场=human/live-trading/neural-net/trad-algo  未到场=(无)

[5] 入场引导:预注入 prompt + 自己的记忆投影,别人看不到
    【会议室入场】你是「实盘/泛化专家」(领域 live-trading)。
    本次会议议题:对齐成本约束与市场结构变化
    召集者:human;规模:大会(全体成员)。
    在场:neural-net、trad-algo、human(人类,有发言权)
    你被叫来开这个会。请自然地把别人不知道、但和你这个方向有关的情况说出来,比如:
    - 你现在做到哪了;- 你遇到了什么问题、卡在哪里;- 你需要谁的什么帮助…
    规则:
    - 这是一个多轮会议,由主持人控场决定发言顺序;你会在轮到你时被叫到。
    - 单次发言不超过 800 字(这不是格式要求,只是为了让所有人都有说话机会)。
    - 没有固定格式,怎么表达清楚就怎么来。
    你的私有记忆投影(只包含与你相关的部分;别人看不到这些):
    你最近的工作记录:
    - 逐笔滑点重算完成,taker 成本占 41% 夏普
    - 私有日志 9.8MB,未对外共享
  ✓ 引导里有定位信息与开放式提示,但**没有**强制的三段式模板

[6] 主持人控场:用召集者的模型,但自己没有任何上下文
    【会议主持】你是本次会议的主持人。
    你的信息范围(重要):
    - 你**没有任何与会者的私有上下文**:看不到他们的工作记忆、代码、日志、历史会话。
    - 你只能看到下面的群聊记录、成员名单,以及谁还没说话。
  ✓ 主持人只拿到群聊记录 + 名单 + 谁还没说话;拿不到任何人的私有记忆

[8] 每个参会者用的是自己的模型
  · 模型 deepseek-v4.1-flash ← live-trading
  · 模型 deepseek-v4-pro ← neural-net、trad-algo

[9] 会后压缩:每人各自提炼"与自己相关"的内容
  ── live-trading(65 字)
     待办:在 60 日 IC 窗口下重跑滑点估计并验证稳定性(我的责任)。会上确认换手压到
     5 倍以下后滑点约 20%,止损问题缓解。
  ── neural-net(73 字)
     待办:与 live-trading 对齐上线后的重训频率。会上确认换手降到 4.8 倍可行;
     若 60 日窗口上线,我的重训周期同步改为 60 日。
  ── trad-algo(59 字)
     待办:在 60 日窗口下重做统计显著性检验。我的判断被采纳:这是市场结构问题
     而非换手率问题;模型侧已承诺同步重训周期。

[11] 散会:所有成员回到工作状态
  · 散会后:data-infra=working,live-trading=working,neural-net=working,trad-algo=working

二、这个系统坚持的五个不变量

每一条都有对应的失败断言在测试里,不是口号。

不变量 1:会籍是权限边界

只有加入了会议室的会话才能召集、才会被召集。测试断言:

// 没加入的会话越权召集 → 被拒
await expect(orchestrator.convene({ calledBy: 'data-infra', ... })).rejects.toThrow(/没有召集权/)
// 排他会籍
expect(() => orchestrator.joinRoom('neural-net', 'other-room')).toThrow(RoomRegistryError)

会籍落盘(追加式 JSONL),进程重启后可恢复——内存态的群成员关系会在重启后静默消失, 让"唤起"变成无声失败。

不变量 2:私有上下文永不跨会话流动

进会议室时注入的是记忆的限长投影(memoryProjection(maxChars)),不是完整上下文:

expect(liveEntry.text).toContain('逐笔滑点重算完成')   // 自己的记忆
expect(liveEntry.text).not.toContain('残差宽度消融')   // 别人的私有记忆

为什么不共享上下文?因为共享上下文就是"上下文污染"本身。 Cognition 主张"别做多 Agent、上下文必须共享"——那是针对短周期、强耦合任务。 本项目面向长周期、弱耦合、方向可切分的场景,隔离才是解药。

不变量 3:长度是硬预算,超限拒绝而不是截断

expect(() => room.appendSpeech('a', '一'.repeat(801))).toThrow(/超长/)

静默截断比超长更糟:Agent 会以为自己的诉求已经传达了。

依据:调研发现"200 字"这个具体数字没有研究支持,是工程取舍。 但"硬上限 + 分字段预算"有先例(LangChain 的 max_token_limit 滚动摘要、 Caucus 把工具描述硬裁到 ≤260 字符)。所以本实现的是机制,上限是可配默认值。

不变量 4:停滞由外部计算,不依赖 Agent 自述

detectStall() 四类信号:silent-slot、stale-briefing、repeated-fingerprint(内容指纹连续重复=空转)、 no-progress。

竞品 Caucus 的 ask_operator 完全依赖 Agent 自判"我卡住了"。 但陷入死循环的 Agent 最不可能正确自判——否则它早跳出来了。 判据必须来自外部可观测行为。

而且节流故意留了后门:停滞触发(priority 1)穿透最小会议间隔—— "刚开完会就卡死"恰恰是最需要介入的时刻。

不变量 5:会后纪要必须每人不同且真的更短

expect(new Set(notes.map(n => n.text)).size).toBe(3)   // 每人不同
// validateMinutes:纪要必须短于会议记录本身,否则拒绝

validateMinutes 会拒绝"越压越长"的纪要(那说明模型在抄会议而不是在压缩)、空纪要、超长纪要。


三、安装

从源码构建

git clone https://github.com/liaowenqi123/dsh-meeting-coordinator.git
cd dsh-meeting-coordinator
pnpm install        # 只装依赖。dist/ 已随仓库提交,装完即可被 DSH 加载
pnpm run build      # 只有在改了 src/ 之后才需要

为什么 dist/ 要提交进仓库? 这是实测踩出来的,不是偷懒。

如果靠 prepare 脚本在安装时构建,那么从 GitHub 安装会直接失败:

ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED
The git-hosted package "dsh-meeting-coordinator@0.1.0" needs to execute build
scripts but is not in the "allowBuilds" allowlist.

pnpm ≥10 默认不允许依赖执行构建脚本,而插件市场安装未上 npm 的包走的正是 github spec。把构建产物一并提交,整条安装路径就完全不需要构建脚本了。 代价只有一个:改了 src/ 必须 pnpm run build 并把 dist/ 一起提交(见贡献章节)。

装进 DeepSeek Harness

把本包装进 DSH 的 profile(真实装载契约在 @deepseek-ai/dsh@0.1.6 上核实过):

# 以 web profile 为例
cd ~/.dsh/profiles/web
pnpm add                                      # 本地路径
pnpm add github:liaowenqi123/dsh-meeting-coordinator    # 或直接从 GitHub 装(无需构建)

DSH 不读 dsh-plugin.json(那是标准/市场/准入层的清单,用于「安装前可知兼容性」)。 运行时真正读的是 package.json 的 dsh.bundle.patch → cordis.patch.yml。 两者本包都提供:能被真实装载,也能被静态评估。

重启 DSH(或重载 profile)后,会看到一个会议室面板,可以建会议室、加会话、召集会议。

在插件市场里找它

本仓库带 GitHub topic dsh-plugin,这是社区插件市场 (如 dsh-plugin-marketplace、 DSH-Store)自动同步的索引源—— 装了市场插件就能直接搜到并一键安装。

验证安装

pnpm run verify     # 类型 + 清单静态校验 + 215 测试 + 构建 + 端到端演示

四、快速开始

环境:Node.js ^22.19 || >=24(实测 v24.14.0)+ pnpm。

pnpm install
pnpm run verify     # 一条命令跑完全部验证
步骤命令验证什么
1pnpm run typecheck严格 TS(含 exactOptionalPropertyTypes)
2pnpm run check:manifest不执行任何插件代码,静态判定清单兼容性
3pnpm run test215 个测试:协议 / 会籍 / 状态机 / 会议室 / 主持人 / DSH 模型调用 / 边界监听 / 会话状态驱动 / 停滞升级 / 压缩 / 适配层 / 宿主装配 / 借上下文接线 / 实时会议视图
4pnpm run build产出 dist/(真实装载入口)
5pnpm run probe:composition真实 compose() + LifecycleCoordinator 激活顺序
6pnpm run demo:room会议室机制端到端演示(11 步,全断言)
7pnpm run demo轻量路径演示:简报交换 + 停滞检测 + 唤醒
8pnpm run test:e2e真实 dsh web + Python mock LLM 的端到端(见 5.14;需要本机 Chrome 与 3081 空闲)

demo:room 用 ScriptedMeetingVoice(脚本替身)驱动,因为演示环境里没有活的 DSH 会话; 模型调用通道本身由 tests/dsh-voice.spec.ts 用假上游逐条断言(调用映射、模型透传、 失败处理、资源释放),其中还有一条 apply() 的端到端用例走完整真实代码路径 (会籍 → 激活 → 召集 → 等边界入场 → 每人用自己的模型发言 → 各自纪要 → 散会)。


五、模型调用与等待入场:怎么接到上游

4.1 让会话说一句话

上游 DSH 有两条多 Agent 通道,用途不同,本项目两条都用、各司其职:

通道能力本项目用途
ctx.agentTeams(TeamService)持久 peer 信箱;sendMessage 只回 {messageId, status},不回传发言内容唤起 / 通知成员(dsh-team-runtime.ts)
ctx.subagents(SubagentRuntime)start() 返回 SubagentRun,run.result 能读回最终 assistant 输出让会话说一句话(dsh-meeting-voice.ts)

所以"发言"走 subagents:

const run = await ctx.subagents.start('spawn', {
  label, prompt: [{ type: 'text', text }], parent, signal,
  agentOptions: { model },        // ← 按会话指定模型,兑现"每个参会用自己的模型"
})
const result = await run.result   // SubagentResult
result.output                     // ContentBlock[],最后一个非空 assistant 消息
await run.dispose()

优先 fork 那个人过来:

const run = await ctx.subagents.start('fork', { parent: 那个人, prompt, persona, agentOptions: { model } })

fork 的原生语义正是这件事(@deepseek-ai/dsh-subagent-fork-in-process):

seeds each child with the parent’s completed conversation turns: the child sees every finished turn The seed is a one-time snapshot taken at fork time

也就是:把那个人已完成的上下文一次性 seed 进这个子会话,于是进会场的那个人带着自己的完整上下文——它就是它本人,不是「读过它资料的陌生人」。这就是「掏过来,当作我的上下文注入」。

拿不到 fork(或该会话不是活的)时退回 spawn,由 prompt 里的完整上下文注入兜底。 理由有三:只有这条路能读回输出;单轮上下文恒有界(避免 N 人 × R 轮把上下文撑爆); 会话的身份与记忆由 AgentParticipant 持有,不会因上游会话生命周期漂移。

4.2 三个必须处理的失败模式

// ① result 在子级失败时不 reject,而是带 stopReason 解析 —— 忽略它最危险
if (stopReason !== 'completed') throw new VoiceUnavailable(...)
// ② 空输出 ≠ 成员没意见
if (text.length === 0) throw new VoiceUnavailable(...)
// ③ 超时只发 abort 信号不够:上游若忽略信号,await 会永远挂住 → 必须显式竞速
const result = await raceWithAbort(run.result, signal, label)

第 ① 条尤其关键:一场因为模型全挂而沉默的会议,看起来会像"大家都没意见"。 把失败当成沉默,比报错危险得多。这一条是被测试逼出来的真实 bug 修复 (超时测试最初直接超时挂住,暴露了只发信号不竞速的问题)。

4.3 等工作单元结束再入场

需求:"一个 agent 正在 working(不论是正在输出还是在调用工具 ing), 都将在调用结束后进入会议室(有一个等待的过程)。"

上游会话事件词汇表里正好有两个可用的边界:

事件含义何时用
step/end一个步骤结束(含该步里的工具调用)默认,最贴近"调用结束后"
turn/end整个回合结束希望成员把手头这轮彻底做完再进场

接线(dsh-boundary-watcher.ts):

ctx.on('session/event', (session, event) => {
  if (event.type !== 'step/end') return          // 只看配置的边界
  const memberId = resolveMemberId(session.id)   // 可配的会话→成员映射
  if (memberId) orchestrator.onWorkUnitComplete(memberId)  // 到边界才真正入场
})

两个刻意的设计:边界类型与身份映射都可配(上游事件名是内部词汇表的一部分, 未来可能变);监听器永不抛错(事件回调抛异常会污染宿主的事件分发,这里捕掉并记进诊断)。


六、两条路径:什么时候开会,什么时候只同步

        ┌─────────────────────────────────────────────────────────┐
        │  轻量路径:简报交换(MeetingCoordinator + BriefingBoard) │
        │  · 每 N 轮 / 4 小时:限长摘要落板 + 汇总广播(无模型调用)│
        │  · 持续计算停滞信号(指纹重复 / 轮次落后 / 无进展)      │
        └───────────────────────────┬─────────────────────────────┘
                                    │ 检测到停滞 / 按需召集
                                    ▼
        ┌─────────────────────────────────────────────────────────┐
        │  正式路径:会议室(MeetingOrchestrator + MeetingRoom)   │
        │  · 召集全体成员(含 idle / done)                        │
        │  · 入场 prompt → 主持人控场多轮讨论 → 每人个性化压缩     │
        │  · 散会后全部回到 working                                │
        └─────────────────────────────────────────────────────────┘

为什么两条都要:

  • 高频低成本。每次开会都要 N×R 次模型调用。日常节奏对齐用简报板就够, 它只是追加写 JSONL + 一次汇总,零模型调用;
  • 贵的事情只在需要时做。停滞信号是触发会议室的判据,而不是让 Agent 自己喊"我要开会";
  • 这也符合调研结论:便宜轮询 + 只在必要时开昂贵会议。 Anthropic 实测多 Agent 系统约 15× chat token,这个成本必须被理由支撑。

5.1 那条箭头是怎么落的:pulse()

上面那张图里 轻量路径 ──检测到停滞/按需召集──▶ 正式路径 这条箭头, 落在 host.ts 的 pulse() 上(不是 tick()):

pulse(): Promise {
  const light = await local.tick()                    // 轻量路径:简报落板 + 汇总广播
  return { light, escalation: await escalate(light.evaluation) }
}

三个刻意的决定:

  1. 触发评估只跑一次,升级复用它。evaluateTriggers 依赖协调器的 lastMeetingAt / lastMeetingRound,跑两遍会把节流状态推乱—— 于是"该不该开会"和"该不该升级"必须看同一份评估。
  2. 升级失败不抛错,只带回理由。全员在工作中、已有一场会在进行, 都是运行期的正常分支。把它当异常,定时器会把整棵插件树炸掉。
  3. 召集者用人类席位(human),不是某个成员。这是外部干预: 停滞由外部计算,不依赖 Agent 自述。借用某个成员的身份会同时污染两件事—— 主持人会拿到那个成员的模型,审计里也会记成"是它召集的"。

5.2 成员状态必须被喂,否则会议室永远开不起来

AgentParticipant 的初始状态是 working,而 convene() 要求至少一位 被召集成员能立刻入场(idle-waiting / done 直接入会;working 只能进 awaiting-entry)。

所以"接上 convene()"还不够——状态机不喂,整条正式路径就是死代码: 全场永远 working,convene() 永远抛「全部成员都在工作中,会议无法开始」。

两个来源共同驱动状态,刻意分成两条独立订阅:

关注点谁负责依据
"这个会话忙起来了 / 空闲了"dsh-session-state.ts会话事件 turn/start / turn/end
"这个会话的工作单元走到边界了"dsh-boundary-watcher.ts可配的 step/end / turn/end

不合并的理由:入场时机是可配置的策略,而活动状态是客观事实。 混在一个订阅里,改入场策略就会连带改状态语义。

5.3 会话 id 不是成员 id(最容易误判成"插件没生效"的坑)

上游 session/event 带的是 DSH 自己的会话 id(UUID),跟我们配置里的 slot id 毫无关系。映射不上时:日志不报错、插件也确实装好了, 但没人被登记为空闲、没人入场、会议室永远开不起来。

三级来源,优先级从高到低:

  1. config.memberSessions 显式映射(用户说了算);
  2. 自动探测 agentTeams.listMembers():上游的 TeamMemberView 同时带 id: SessionId 与 name,而成员是我们用 teammateName(slot) 起的名字, 按名字对齐就拿到权威会话 id;
  3. 恒等映射(会话 id 恰好就是 slot id 的场景,主要是测试)。

升级失败时,理由里会直接点名未映射的会话 id 并告诉你去配什么—— 不让这类配置错误表现成"插件没生效"。

5.4 上游要的是"活的 Agent",不是 Agent 服务(真机踩过的坑)

agentTeams / subagents 的第一个参数是授权凭据,上游的判据非常具体:

tryMembership(agent) {
  if (this.ctx.agents.get(agent.id) !== agent) return undefined   // ← 必须同一个对象引用
  …
  throw new TeamError(`agent "${agent.id}" is not a member of an active Agent Team`)
}

所以绝不能把 ctx.get('agents') 这个服务对象直接传下去。 实测的错误信息是 `agent "undefined"