cs-bots 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +5 -5
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +3 -3
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +3 -3
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +2 -2
- package/package.json +10 -2
- package/src/api.js +263 -6
- package/src/board.js +155 -9
- package/src/client.js +194 -14
- package/src/dispatcher.js +70 -11
- package/src/index.js +8 -1
- package/src/kernel.js +15 -0
- package/src/room.js +243 -18
- package/src/roster.js +184 -24
- package/src/teams.js +353 -4
- package/src/tools.js +123 -4
package/src/teams.js
CHANGED
|
@@ -64,7 +64,10 @@ export const DEFAULT_TEAM_ADMIN_POLICY = 'open'
|
|
|
64
64
|
const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
|
|
65
65
|
|
|
66
66
|
/** 合法动作(含 setPolicy:策略变更本身也是要留痕的事件)。 */
|
|
67
|
-
const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom'])
|
|
67
|
+
const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom', 'route'])
|
|
68
|
+
|
|
69
|
+
/** 跨团队路由的最大 hop 数(>3 拒绝)。 */
|
|
70
|
+
const ROUTE_MAX_HOP = 3
|
|
68
71
|
|
|
69
72
|
/** 结构化错误。 */
|
|
70
73
|
export class TeamError extends Error {
|
|
@@ -192,6 +195,12 @@ export function createTeams(ctx, config = {}) {
|
|
|
192
195
|
if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
|
|
193
196
|
let skipped = 0
|
|
194
197
|
for (const ev of rows) {
|
|
198
|
+
// route 事件是**审计记录**,不是团队状态变更 —— 没有 id/rev,仍要进 events
|
|
199
|
+
// 供 listRoutes 查询;只有团队状态变更事件才需要 id + rev 去重。
|
|
200
|
+
if (ev?.action === 'route') {
|
|
201
|
+
events.push(clone(ev))
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
195
204
|
if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
|
|
196
205
|
const current = entries.get(ev.id)
|
|
197
206
|
if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
|
|
@@ -282,7 +291,7 @@ export function createTeams(ctx, config = {}) {
|
|
|
282
291
|
*/
|
|
283
292
|
function create(input = {}) {
|
|
284
293
|
assertAlive('create')
|
|
285
|
-
const { name, type, purpose, memberIds, leadId, adminPolicy, by, now } = input ?? {}
|
|
294
|
+
const { name, type, purpose, memberIds, leadId, adminPolicy, by, now, kind, nativeTeamId } = input ?? {}
|
|
286
295
|
const nameNorm = requireString(name, 'name', 'create')
|
|
287
296
|
if (nameNorm.length > 120) throw new TeamError(`teams.create:name ≤120 字符(收到 ${nameNorm.length})`, 'TEAM_BAD_FIELD', { field: 'name' })
|
|
288
297
|
const teamType = type ?? 'big'
|
|
@@ -301,18 +310,40 @@ export function createTeams(ctx, config = {}) {
|
|
|
301
310
|
throw new TeamError(`teams.create:leadId ${lead} 不能同时出现在 memberIds 中(会形成自引用)`, 'TEAM_BAD_FIELD', { field: 'memberIds' })
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
// ── kind:人类/智能体(服务层强制推导,防止伪造)─────────────────────────
|
|
314
|
+
// 由执行者身份(author)硬推导:HUMAN_IDS ⇒ 'human',其他 ⇒ 'agent'。
|
|
315
|
+
// 显式传 kind 但与推导矛盾 ⇒ 抛 TEAM_KIND_MISMATCH(不许伪造 Boss 建的团队)。
|
|
316
|
+
const derivedKind = HUMAN_IDS.includes(author) ? 'human' : 'agent'
|
|
317
|
+
if (kind !== undefined && kind !== null && kind !== derivedKind) {
|
|
318
|
+
throw new TeamError(
|
|
319
|
+
`teams.create:kind=${JSON.stringify(kind)} 与执行者身份矛盾(执行者 ${author} ⇒ '${derivedKind}')——不许伪造`,
|
|
320
|
+
'TEAM_KIND_MISMATCH',
|
|
321
|
+
{ author, derivedKind, requested: kind },
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
const teamKind = derivedKind
|
|
325
|
+
|
|
304
326
|
idCounter += 1
|
|
305
327
|
const id = `team-${idCounter}`
|
|
306
328
|
/** @type {Record<string, unknown>} */
|
|
307
329
|
const rec = {
|
|
308
|
-
id, rev: 1, name: nameNorm, type: teamType, purpose: purpose ?? '',
|
|
330
|
+
id, rev: 1, name: nameNorm, type: teamType, kind: teamKind, purpose: purpose ?? '',
|
|
309
331
|
leadId: lead, members,
|
|
332
|
+
// ── t-39 第2批 C:绑定 DSH 原生 team ──
|
|
333
|
+
// cs-bots 团队(teams.jsonl)与 DSH 原生 team(agentTeams)是**两套人模型**,
|
|
334
|
+
// 没有任何自动交集(原生 TeamId = root session id,跟我们的 team-N 毫无关系)
|
|
335
|
+
// ⇒ 必须显式绑定。这个字段是「Boss 在这个 cs-bots 房间发言时,该同步给哪个
|
|
336
|
+
// 原生团队」的唯一依据。
|
|
337
|
+
//
|
|
338
|
+
// 为什么从 cs-bots 侧配:原生侧**没有**「列出所有原生 team」的接口,
|
|
339
|
+
// 只有 `tryMembership(agent)`(从某个成员反查它所属团队)⇒ 反向枚举不可行。
|
|
340
|
+
nativeTeamId: typeof nativeTeamId === 'string' && nativeTeamId.length > 0 ? nativeTeamId : null,
|
|
310
341
|
adminPolicy: policy,
|
|
311
342
|
status: 'open',
|
|
312
343
|
createdAt: at, createdBy: author, updatedAt: at, updatedBy: author,
|
|
313
344
|
}
|
|
314
345
|
commit('create', at, author, null, rec)
|
|
315
|
-
info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType})创建 by ${author}`)
|
|
346
|
+
info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType},${teamKind})创建 by ${author}`)
|
|
316
347
|
return clone(rec)
|
|
317
348
|
}
|
|
318
349
|
|
|
@@ -502,6 +533,209 @@ export function createTeams(ctx, config = {}) {
|
|
|
502
533
|
return clone(next)
|
|
503
534
|
}
|
|
504
535
|
|
|
536
|
+
// ── A. 跨团队路由(t-39 第2批 · bot_team_route 的服务层本体)────────────────
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 解析并校验一条跨团队路由目标 —— **只做决定,不做投递**。
|
|
540
|
+
*
|
|
541
|
+
* 为什么拆开:投递要 dispatcher(wakes.jsonl / dedup / 背压),而 dispatcher 挂在
|
|
542
|
+
* `src/tools.js` 的组装层(`s.dispatcher`)。若把投递塞进 TeamsService,本服务就得
|
|
543
|
+
* 反向依赖 dispatcher —— 两个域服务互指会让装配顺序变成硬约束。所以这里只回答
|
|
544
|
+
* 「路由到谁」,投递由工具层完成,语义边界清晰。
|
|
545
|
+
*
|
|
546
|
+
* 失败一律**返回可读的 refused**,不抛裸错 —— 契约要求 `{ok:false, decision:'refused',
|
|
547
|
+
* reason}` 而不是异常(异常在工具层会被包成 `tool-crashed`,理由就丢了)。
|
|
548
|
+
*
|
|
549
|
+
* @param {{ fromTeam?: string, toTeam?: string, toBot?: string,
|
|
550
|
+
* taskId: string, text?: string, hop?: number, by: string }} input
|
|
551
|
+
* @returns {{ ok: true, decision: 'accepted', to: { kind: 'team'|'bot', id: string,
|
|
552
|
+
* leadId: string, name: string }, hop: number, dedupKey: string,
|
|
553
|
+
* fromTeam: string }
|
|
554
|
+
* | { ok: false, decision: 'refused', reason: string, to?: object, hop?: number }}
|
|
555
|
+
*/
|
|
556
|
+
function resolveRoute(input = {}) {
|
|
557
|
+
assertAlive('resolveRoute')
|
|
558
|
+
const { fromTeam, toTeam, toBot, taskId, hop, by } = input ?? {}
|
|
559
|
+
const author = requireString(by, 'by', 'resolveRoute')
|
|
560
|
+
|
|
561
|
+
// ── 参数校验:全部**可读拒绝** ──
|
|
562
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
563
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TASK_ID_REQUIRED' }
|
|
564
|
+
}
|
|
565
|
+
if (!toTeam && !toBot) {
|
|
566
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_REQUIRED' }
|
|
567
|
+
}
|
|
568
|
+
if (toTeam && toBot) {
|
|
569
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_AMBIGUOUS' }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── hop 计数:>3 拒绝(环路防护第一道闸)──
|
|
573
|
+
const hopCount = Number.isFinite(Number(hop)) ? Math.max(0, Math.trunc(Number(hop))) : 0
|
|
574
|
+
if (hopCount > ROUTE_MAX_HOP) {
|
|
575
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_HOP_EXCEEDED', hop: hopCount }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── 解析目标团队 / 目标 lead ──
|
|
579
|
+
let targetTeamId = null
|
|
580
|
+
let targetLeadId = null
|
|
581
|
+
let targetName = null
|
|
582
|
+
|
|
583
|
+
if (toTeam) {
|
|
584
|
+
const team = entries.get(toTeam)
|
|
585
|
+
if (!team) return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
|
|
586
|
+
targetTeamId = team.id
|
|
587
|
+
targetLeadId = team.leadId ?? null
|
|
588
|
+
targetName = team.name
|
|
589
|
+
} else {
|
|
590
|
+
// toBot:必须**属于**某个 cs-bots 团队(leadId 或成员),否则无法确定唤醒谁
|
|
591
|
+
for (const t of entries.values()) {
|
|
592
|
+
if (t.leadId === toBot || (Array.isArray(t.members) && t.members.includes(toBot))) {
|
|
593
|
+
targetTeamId = t.id
|
|
594
|
+
targetLeadId = t.leadId ?? null
|
|
595
|
+
targetName = t.name
|
|
596
|
+
break
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!targetTeamId) {
|
|
600
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 目标团队必须有人可唤 —— 契约:**只唤 leadId**,不 wake 全部成员
|
|
605
|
+
if (!targetLeadId) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
decision: 'refused',
|
|
609
|
+
reason: 'ROUTE_NO_LEAD',
|
|
610
|
+
to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, name: targetName },
|
|
611
|
+
hop: hopCount,
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const fromKey = fromTeam ?? author
|
|
616
|
+
|
|
617
|
+
// 去重键:交给 dispatcher 的 dedupKey 机制(不自己造一套)。
|
|
618
|
+
// **不含 hop** —— 这样同一 (from→to, taskId) 的重复路由会命中 dispatcher 的
|
|
619
|
+
// IdempotencySet 而被判 skipped,正是「A→B→A→B 第 3 跳被拒」的实现路径。
|
|
620
|
+
const dedupKey = `route:${fromKey}:${targetTeamId}:${taskId}`
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
ok: true,
|
|
624
|
+
decision: 'accepted',
|
|
625
|
+
to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, leadId: targetLeadId, name: targetName },
|
|
626
|
+
hop: hopCount,
|
|
627
|
+
dedupKey,
|
|
628
|
+
fromTeam: fromKey,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* **一条龙路由**:决定 → 投递 → 留痕。工具层(`bot_team_route`)与 HTTP 层
|
|
634
|
+
* (`POST /teams/route`)**共用这一个方法**。
|
|
635
|
+
*
|
|
636
|
+
* 为什么必须共用:两条路各写一遍,行为就会漂 —— 工具层去重了、HTTP 层没去重,
|
|
637
|
+
* 或者一边先投后记、一边先记后投。契约要求「跨团队路由」只有一套语义,
|
|
638
|
+
* 所以序列编排只能有一处。
|
|
639
|
+
*
|
|
640
|
+
* 依赖注入:`dispatcher` 由调用方传入(TeamsService 不反向依赖 dispatcher,
|
|
641
|
+
* 否则两个域服务互指,装配顺序变成硬约束)。
|
|
642
|
+
*
|
|
643
|
+
* @param {{ fromTeam?: string, toTeam?: string, toBot?: string, taskId?: string,
|
|
644
|
+
* text?: string, hop?: number, by: string, now?: number,
|
|
645
|
+
* dispatcher: object }} input
|
|
646
|
+
* @returns {object} `{ok, decision, to, hop, wake, reason?, taskId}` —— DecisionKind 语义
|
|
647
|
+
*/
|
|
648
|
+
function deliverRoute(input = {}) {
|
|
649
|
+
assertAlive('deliverRoute')
|
|
650
|
+
const { dispatcher, taskId: rawTaskId, by, now, text, hop } = input ?? {}
|
|
651
|
+
const author = requireString(by, 'by', 'deliverRoute')
|
|
652
|
+
|
|
653
|
+
// taskId 缺省生成:页面上的「发协作请求」不一定挂任务(Boss 只是想跨团队喊一句)。
|
|
654
|
+
// 每轮生成独立 id ⇒ 不会被 dispatcher 误判重(同一轮重发才判重,由调用方复用同一 id)。
|
|
655
|
+
const taskId = typeof rawTaskId === 'string' && rawTaskId.trim() !== ''
|
|
656
|
+
? rawTaskId.trim()
|
|
657
|
+
: `route-${normalizeNow(now, 'deliverRoute')}`
|
|
658
|
+
|
|
659
|
+
const decision = resolveRoute({ ...input, taskId, by: author })
|
|
660
|
+
if (!decision.ok) return { ...decision, taskId }
|
|
661
|
+
|
|
662
|
+
if (!dispatcher || typeof dispatcher.notify !== 'function') {
|
|
663
|
+
return { ok: false, decision: 'refused', reason: 'ROUTE_NO_DISPATCHER', to: decision.to, hop: decision.hop, taskId }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 投递:只唤对方 leadId;dedupKey 交给 dispatcher(不自己造去重);
|
|
667
|
+
// teamId 让 byTeam() 能按团队聚合唤醒成本。
|
|
668
|
+
const wakeText = text ?? `[cs-bots 跨团队路由] ${decision.fromTeam} → ${decision.to.id}`
|
|
669
|
+
+ `(lead=${decision.to.leadId}, hop=${decision.hop}, task=${taskId})`
|
|
670
|
+
const wake = dispatcher.notify({
|
|
671
|
+
target: decision.to.leadId,
|
|
672
|
+
reason: 'room-message',
|
|
673
|
+
dedupKey: decision.dedupKey,
|
|
674
|
+
text: wakeText,
|
|
675
|
+
teamId: decision.to.id,
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// 留痕在投递之后(投递已有 dedupKey 记在 wakes.jsonl;这里记的是「路由决策」本身)。
|
|
679
|
+
// 落盘失败**必须显式报错**,不能吞 —— 「投了没记」会让环路统计漏跳。
|
|
680
|
+
try {
|
|
681
|
+
recordRoute({ to: decision.to, fromTeam: decision.fromTeam, taskId, text, hop: decision.hop, by: author, now, wake })
|
|
682
|
+
} catch (recordError) {
|
|
683
|
+
return {
|
|
684
|
+
ok: false, decision: 'refused', reason: 'ROUTE_RECORD_FAILED',
|
|
685
|
+
message: recordError?.message ?? String(recordError),
|
|
686
|
+
to: decision.to, hop: decision.hop, wake, taskId,
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return { ok: true, decision: 'delivered', to: decision.to, hop: decision.hop, wake, taskId }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 把已经做出的路由决定**落盘留痕**(intent-first:先记后投)。
|
|
695
|
+
*
|
|
696
|
+
* 顺序不可颠倒:`记了没投` 是可见可重试的;`投了没记` 则绕过限额/审计,
|
|
697
|
+
* 让环路防护统计不到这一跳 —— 那正是要防的。
|
|
698
|
+
* @param {{ to: object, fromTeam: string, taskId: string, text?: string, hop: number,
|
|
699
|
+
* by: string, now?: number, wake?: object }} input
|
|
700
|
+
*/
|
|
701
|
+
function recordRoute(input = {}) {
|
|
702
|
+
assertAlive('recordRoute')
|
|
703
|
+
const { to, fromTeam, taskId, text, hop, by, now, wake } = input ?? {}
|
|
704
|
+
const author = requireString(by, 'by', 'recordRoute')
|
|
705
|
+
const at = normalizeNow(now, 'recordRoute')
|
|
706
|
+
const rec = {
|
|
707
|
+
toKind: to?.kind ?? 'team',
|
|
708
|
+
toId: to?.id ?? null,
|
|
709
|
+
toLeadId: to?.leadId ?? null,
|
|
710
|
+
toName: to?.name ?? null,
|
|
711
|
+
fromTeam: fromTeam ?? null,
|
|
712
|
+
taskId,
|
|
713
|
+
text: text ?? '',
|
|
714
|
+
hop: Number(hop ?? 0),
|
|
715
|
+
wake: wake ?? null,
|
|
716
|
+
at,
|
|
717
|
+
}
|
|
718
|
+
if (!appendJsonl(jsonlPath, { action: 'route', at, by: author, before: null, after: rec })) {
|
|
719
|
+
throw new TeamError(`teams.recordRoute:事件追加失败(${jsonlPath})—— 拒绝「投了没记」`, 'TEAM_APPEND_FAILED', { action: 'route' })
|
|
720
|
+
}
|
|
721
|
+
events.push({ action: 'route', at, by: author, before: null, after: clone(rec) })
|
|
722
|
+
info(`[cs-bots/teams] route ${rec.fromTeam} → ${rec.toId}(lead=${rec.toLeadId}, hop=${rec.hop}, task=${taskId})by ${author}`)
|
|
723
|
+
return clone(rec)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 查 route 审计记录(按 toId / taskId / fromTeam 过滤)。
|
|
728
|
+
* @param {{ toId?: string, fromTeam?: string, taskId?: string }} [query]
|
|
729
|
+
*/
|
|
730
|
+
function listRoutes(query = {}) {
|
|
731
|
+
assertAlive('listRoutes')
|
|
732
|
+
let rows = events.filter((e) => e.action === 'route').map((e) => ({ ...e.after, by: e.by, at: e.at }))
|
|
733
|
+
if (query?.toId !== undefined && query?.toId !== null) rows = rows.filter((r) => r.toId === query.toId)
|
|
734
|
+
if (query?.fromTeam !== undefined && query?.fromTeam !== null) rows = rows.filter((r) => r.fromTeam === query.fromTeam)
|
|
735
|
+
if (query?.taskId !== undefined && query?.taskId !== null) rows = rows.filter((r) => r.taskId === query.taskId)
|
|
736
|
+
return clone(rows)
|
|
737
|
+
}
|
|
738
|
+
|
|
505
739
|
// ── API:读 ────────────────────────────────────────────────────────────────
|
|
506
740
|
|
|
507
741
|
function list(query = {}) {
|
|
@@ -513,6 +747,113 @@ export function createTeams(ctx, config = {}) {
|
|
|
513
747
|
return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
|
|
514
748
|
}
|
|
515
749
|
|
|
750
|
+
/**
|
|
751
|
+
* 按房间 id 反查绑定它的团队(t-39 第2批 C)。
|
|
752
|
+
*
|
|
753
|
+
* 用途:Boss 在 cs-bots 团队的议事厅发言 ⇒ 知道该同步给哪个原生 team。
|
|
754
|
+
* 一个房间只可能被一个团队绑定(`attachRoom` 是 CAS 单值覆盖)。
|
|
755
|
+
*
|
|
756
|
+
* @param {string} roomId
|
|
757
|
+
* @returns {object|null} 团队记录(含 `nativeTeamId`),没绑定 ⇒ null
|
|
758
|
+
*/
|
|
759
|
+
function findByRoom(roomId) {
|
|
760
|
+
assertAlive('findByRoom')
|
|
761
|
+
if (typeof roomId !== 'string' || roomId.length === 0) return null
|
|
762
|
+
for (const t of entries.values()) {
|
|
763
|
+
if (t.roomId === roomId) return get(t.id)
|
|
764
|
+
}
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* **发言同步给 DSH 原生 team 的 Lead**(t-39 第2批 C · Boss 体验 item 5)。
|
|
770
|
+
*
|
|
771
|
+
* ### 为什么要这个
|
|
772
|
+
* Boss 在 cs-bots 团队的议事厅发言,原生 team 的 Lead 感知不到 —— 因为两套
|
|
773
|
+
* 团队模型是**分离的**(cs-bots `teams.jsonl` vs DSH `agentTeam`),消息不会自动
|
|
774
|
+
* 跨过去。这个方法负责把这条链补上。
|
|
775
|
+
*
|
|
776
|
+
* ### 链路(lead 批准的实现方向,2026-11-11)
|
|
777
|
+
* Boss 在 cs-bots 房间发言 → `findByRoom(roomId)` 反查绑定团队
|
|
778
|
+
* → 取该团队的 `nativeTeamId` → 投递给原生 team 的 Lead。
|
|
779
|
+
*
|
|
780
|
+
* **为什么投递 target = nativeTeamId 就是投递给 Lead**:
|
|
781
|
+
* DSH 原生 `TeamId` 就是 root session id(`TeamId(id: SessionId|string)`,
|
|
782
|
+
* 且 `tryMembership(agent).root.id === membership.id`)。所以原生 team 的 Lead
|
|
783
|
+
* 会话 id 就等于这个 TeamId。投递 root = 投递 Lead,走的是 root 路径,**合法**。
|
|
784
|
+
*
|
|
785
|
+
* ### 为什么用 dispatcher 而不是 agentTeams.sendMessage
|
|
786
|
+
* 官方 `sendMessage(caller, ...)` 需要 Lead 身份的 `Agent` 作为凭证,且校验
|
|
787
|
+
* Lead→child 边 —— 队友不能给 Lead 发消息。cs-bots 是 root session,用
|
|
788
|
+
* `dispatcher.followup(root)` 投递是 root 路径,不冒充任何人。
|
|
789
|
+
*
|
|
790
|
+
* ### 诚实原则(硬要求)
|
|
791
|
+
* 同步失败**绝不**返回 `ok:false`,也**绝不**假装成功:
|
|
792
|
+
* `{ ok: true, synced: false, reason }` —— 房间消息本身已经成功落盘,
|
|
793
|
+
* 同步是附加动作,失败必须可见但不影响消息。
|
|
794
|
+
*
|
|
795
|
+
* reason 取值(给前端 A 的确定形状):
|
|
796
|
+
* - `NATIVE_TEAM_NOT_FOUND` 房间没绑任何 cs-bots 团队
|
|
797
|
+
* - `NATIVE_TEAM_NOT_BOUND` 团队存在但 `nativeTeamId` 为 null(要先配置绑定)
|
|
798
|
+
* - `SYNC_DISPATCHER_UNAVAILABLE` 装配层没注入 dispatcher
|
|
799
|
+
* - `SYNC_DEDUP_HIT` 这条消息之前已同步过(幂等,不算新同步也不算失败)
|
|
800
|
+
* - `SYNC_DELIVERY_FAILED` 投递失败(dispatcher 返回 failed)
|
|
801
|
+
* - `SYNCED` 同步成功
|
|
802
|
+
*
|
|
803
|
+
* @param {{ roomId: string, messageId?: string, text?: string, by: string,
|
|
804
|
+
* dispatcher: object }} input
|
|
805
|
+
* @returns {{ ok: true, synced: boolean, reason: string,
|
|
806
|
+
* teamId?: string, nativeTeamId?: string, leadId?: string, wake?: object }}
|
|
807
|
+
*/
|
|
808
|
+
function syncToNativeLead(input = {}) {
|
|
809
|
+
assertAlive('syncToNativeLead')
|
|
810
|
+
const { roomId, messageId, text, by, dispatcher } = input ?? {}
|
|
811
|
+
requireString(by, 'by', 'syncToNativeLead')
|
|
812
|
+
|
|
813
|
+
// ① 反查房间属于哪个 cs-bots 团队(一个房间只可能被一个团队绑定)
|
|
814
|
+
const team = findByRoom(roomId)
|
|
815
|
+
if (!team) {
|
|
816
|
+
return { ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_FOUND', detail: '房间未绑定任何 cs-bots 团队' }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ② 团队有没有绑定 DSH 原生 team —— 这是同步的唯一依据
|
|
820
|
+
if (!team.nativeTeamId) {
|
|
821
|
+
return {
|
|
822
|
+
ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_BOUND',
|
|
823
|
+
teamId: team.id, detail: '团队未绑定 DSH 原生 team,请先在团队详情里配置 nativeTeamId',
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ③ dispatcher 在不在(缺失 ⇒ 无法投递,但不是消息本身的错)
|
|
828
|
+
if (!dispatcher || typeof dispatcher.notify !== 'function') {
|
|
829
|
+
return {
|
|
830
|
+
ok: true, synced: false, reason: 'SYNC_DISPATCHER_UNAVAILABLE',
|
|
831
|
+
teamId: team.id, nativeTeamId: team.nativeTeamId,
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ④ 投递给原生 team 的 Lead(root session id == nativeTeamId)
|
|
836
|
+
// dedupKey 用消息 id —— 同一消息重复 post 不会重复同步(幂等)。
|
|
837
|
+
const dedupKey = `lead-sync:${messageId ?? roomId}:${team.id}`
|
|
838
|
+
const wake = dispatcher.notify({
|
|
839
|
+
target: team.nativeTeamId,
|
|
840
|
+
reason: 'room-message',
|
|
841
|
+
dedupKey,
|
|
842
|
+
text: `[cs-bots 团队发言同步] 「${team.name}」房间新消息:${String(text ?? '').slice(0, 200)}`,
|
|
843
|
+
teamId: team.id,
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
// ⑤ 诚实汇报:只有真的发出去才算 synced:true
|
|
847
|
+
const leadId = team.nativeTeamId
|
|
848
|
+
if (wake?.ok === true && wake?.result === 'sent') {
|
|
849
|
+
return { ok: true, synced: true, reason: 'SYNCED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
850
|
+
}
|
|
851
|
+
if (wake?.ok === true && wake?.result === 'skipped') {
|
|
852
|
+
return { ok: true, synced: false, reason: 'SYNC_DEDUP_HIT', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
853
|
+
}
|
|
854
|
+
return { ok: true, synced: false, reason: 'SYNC_DELIVERY_FAILED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
|
|
855
|
+
}
|
|
856
|
+
|
|
516
857
|
function get(id) {
|
|
517
858
|
assertAlive('get')
|
|
518
859
|
const t = entries.get(id)
|
|
@@ -548,8 +889,16 @@ export function createTeams(ctx, config = {}) {
|
|
|
548
889
|
// 契约 §3:把「议事厅」绑到团队上。api.js 的 ensureTeamRoom 会调它;
|
|
549
890
|
// 不导出 ⇒ 房间建好了但团队记不住 roomId,重启后自动配厅就永远重配一遍。
|
|
550
891
|
attachRoom,
|
|
892
|
+
// 契约 t-39:跨团队路由(只决定 + 留痕,投递由工具层用 dispatcher 完成)
|
|
893
|
+
resolveRoute,
|
|
894
|
+
recordRoute,
|
|
895
|
+
listRoutes,
|
|
896
|
+
// 一条龙:工具层与 HTTP 层共用的唯一序列编排
|
|
897
|
+
deliverRoute,
|
|
551
898
|
list,
|
|
552
899
|
get,
|
|
900
|
+
findByRoom,
|
|
901
|
+
syncToNativeLead,
|
|
553
902
|
stats,
|
|
554
903
|
replay,
|
|
555
904
|
diagnostics: () => clone(diagnostics),
|
package/src/tools.js
CHANGED
|
@@ -94,6 +94,18 @@ const DEFS = [
|
|
|
94
94
|
priority: { type: 'number', description: '优先级(数值越大越先)' },
|
|
95
95
|
dueAt: { type: 'number', description: '截止时间(epoch ms)' },
|
|
96
96
|
sourceSeq: { type: 'string', description: '幂等键:同键重复创建只产生一条' },
|
|
97
|
+
source: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
enum: ['human', 'ai'],
|
|
100
|
+
description: '谁下的令:human=人(Boss)/ ai=智能体。不传则按调用者身份自动推断。',
|
|
101
|
+
},
|
|
102
|
+
channel: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
enum: ['user', 'session', 'external'],
|
|
105
|
+
description:
|
|
106
|
+
'从哪个入口下的令(t-40 需求①):user=用户在页面手工建 / session=会话里产出 / '
|
|
107
|
+
+ 'external=外部渠道接入(预留)。不传则按 source 推断(人→user,智能体→session)。',
|
|
108
|
+
},
|
|
97
109
|
expectedComplexity: {
|
|
98
110
|
type: 'string',
|
|
99
111
|
enum: ['T0', 'T1', 'T2', 'T3'],
|
|
@@ -144,7 +156,9 @@ const DEFS = [
|
|
|
144
156
|
},
|
|
145
157
|
{
|
|
146
158
|
name: 'bot_task_list',
|
|
147
|
-
description:
|
|
159
|
+
description:
|
|
160
|
+
'列出 cs-bots 任务板上的任务。默认不含已删除(tombstone);传 status 可只看某状态;'
|
|
161
|
+
+ '传 source / channel 可按「谁下的令 / 从哪个入口下的令」筛选(t-40 需求①)。',
|
|
148
162
|
parameters: {
|
|
149
163
|
type: 'object',
|
|
150
164
|
properties: {
|
|
@@ -153,12 +167,46 @@ const DEFS = [
|
|
|
153
167
|
enum: ['pending', 'in_progress', 'completed', 'blocked', 'deleted'],
|
|
154
168
|
description: '只看该状态',
|
|
155
169
|
},
|
|
170
|
+
source: { type: 'string', enum: ['human', 'ai'], description: '只看该来源(人 / 智能体)' },
|
|
171
|
+
channel: {
|
|
172
|
+
type: 'string',
|
|
173
|
+
enum: ['user', 'session', 'external'],
|
|
174
|
+
description: '只看该渠道(用户手工 / 会话产出 / 外部接入)',
|
|
175
|
+
},
|
|
156
176
|
},
|
|
157
177
|
additionalProperties: false,
|
|
158
178
|
},
|
|
159
179
|
run: (s, a) => {
|
|
160
180
|
if (!s.board) return failure('board-unavailable')
|
|
161
|
-
|
|
181
|
+
// 只把**给了的**过滤条件传下去:给 undefined 会命中 `!== null` 分支造成误筛
|
|
182
|
+
const opts = {}
|
|
183
|
+
if (a?.status) opts.status = a.status
|
|
184
|
+
if (a?.source) opts.source = a.source
|
|
185
|
+
if (a?.channel) opts.channel = a.channel
|
|
186
|
+
return success({ tasks: s.board.list(opts) })
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'bot_task_room',
|
|
191
|
+
description:
|
|
192
|
+
'给任务开一个议事厅(群聊房间)。**长任务在建任务时就自动有了**,本工具用于:' +
|
|
193
|
+
'① 短任务按需开(Boss:「短任务默认不建房间,防止房间爆炸,需要时再建」);' +
|
|
194
|
+
'② 长任务的议事厅丢了/想补一个。幂等:已有就复用,不会重复建。' +
|
|
195
|
+
'需要任务有负责人(没负责人就没有团队可挂)。',
|
|
196
|
+
parameters: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: { id: { type: 'string', description: '任务 id' } },
|
|
199
|
+
required: ['id'],
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
},
|
|
202
|
+
run: async (s, a, exec) => {
|
|
203
|
+
if (typeof s.openTaskRoom !== 'function') return failure('task-room-unavailable', { hint: '后端未注入 openTaskRoom' })
|
|
204
|
+
try {
|
|
205
|
+
const r = await s.openTaskRoom(String(a.id ?? ''), callerOf(exec))
|
|
206
|
+
return r.ok ? success(r) : failure('task-room-failed', { message: r.reason })
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return failure(error?.code ?? 'task-room-failed', { message: String(error?.message ?? error) })
|
|
209
|
+
}
|
|
162
210
|
},
|
|
163
211
|
},
|
|
164
212
|
{
|
|
@@ -428,6 +476,31 @@ const DEFS = [
|
|
|
428
476
|
return success({ bots: [...live, ...retired] })
|
|
429
477
|
},
|
|
430
478
|
},
|
|
479
|
+
{
|
|
480
|
+
name: 'bot_roster_purge',
|
|
481
|
+
description:
|
|
482
|
+
'把**已退役**的 Bot 从注册表里真正清掉(retire 只是标记,记录会一直留着)。' +
|
|
483
|
+
'**只能清已退役的**:在役 Bot 一律拒绝。**不删工作目录、不杀会话** —— ' +
|
|
484
|
+
'只移除注册表记录,cwd 里的产物原样保留。清理动作在审计流里留档,可追溯。' +
|
|
485
|
+
'allRetired:true 一次清干净;或用 ids 指定。(**官方 roster 没有这个能力**)',
|
|
486
|
+
parameters: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: {
|
|
489
|
+
allRetired: { type: 'boolean', description: '清全部已退役的 Bot(与 ids 二选一)' },
|
|
490
|
+
ids: { type: 'array', items: { type: 'string' }, description: '指定要清的 Bot id 列表' },
|
|
491
|
+
reason: { type: 'string', description: '清理原因(写进审计流)' },
|
|
492
|
+
},
|
|
493
|
+
additionalProperties: false,
|
|
494
|
+
},
|
|
495
|
+
run: (s, a, exec) => {
|
|
496
|
+
if (!s.roster) return failure('roster-unavailable')
|
|
497
|
+
try {
|
|
498
|
+
return success(s.roster.purge({ ids: a.ids, allRetired: a.allRetired, reason: a.reason, byId: callerOf(exec) }))
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return failure(error?.code ?? 'ROSTER_PURGE_FAILED', { message: String(error?.message ?? error) })
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
},
|
|
431
504
|
{
|
|
432
505
|
name: 'bot_roster_manage',
|
|
433
506
|
description:
|
|
@@ -1132,7 +1205,7 @@ const DEFS = [
|
|
|
1132
1205
|
},
|
|
1133
1206
|
{
|
|
1134
1207
|
name: 'bot_teams_add',
|
|
1135
|
-
description: '向团队添加成员(需带 expectedRev 做 CAS
|
|
1208
|
+
description: '向团队添加成员(需带 expectedRev 做 CAS)。若该团队已绑定房间(roomId),自动把成员拉进房间。',
|
|
1136
1209
|
parameters: {
|
|
1137
1210
|
type: 'object',
|
|
1138
1211
|
properties: {
|
|
@@ -1146,7 +1219,18 @@ const DEFS = [
|
|
|
1146
1219
|
run: (s, a, exec) => {
|
|
1147
1220
|
if (!s.teams) return failure('teams-unavailable')
|
|
1148
1221
|
try {
|
|
1149
|
-
|
|
1222
|
+
const team = s.teams.addMember({ ...a, by: callerOf(exec) })
|
|
1223
|
+
// ── ③ bot_teams_add 补 attachRoom:若团队有 roomId,自动拉人进房间(失败不阻断主流程)──
|
|
1224
|
+
// 参照 api.js:1555 附近的逻辑:只回报 roomError,不让它把「加入团队成功」变成失败。
|
|
1225
|
+
let roomError
|
|
1226
|
+
if (team.roomId && s.room) {
|
|
1227
|
+
try {
|
|
1228
|
+
s.room.join({ roomId: team.roomId, sessionId: a.memberId, byId: callerOf(exec) })
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
roomError = `room.join 失败(${e?.code ?? 'room-join-failed'}):${e?.message ?? String(e)}`
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return success({ team, roomError })
|
|
1150
1234
|
} catch (error) {
|
|
1151
1235
|
return failure(error?.code ?? 'teams-add-failed', { message: error?.message ?? String(error) })
|
|
1152
1236
|
}
|
|
@@ -1223,6 +1307,41 @@ const DEFS = [
|
|
|
1223
1307
|
}
|
|
1224
1308
|
},
|
|
1225
1309
|
},
|
|
1310
|
+
// ── A. bot_team_route(t-39 第2批 · 跨团队定向路由)─────────────────────────
|
|
1311
|
+
{
|
|
1312
|
+
name: 'bot_team_route',
|
|
1313
|
+
description:
|
|
1314
|
+
'跨团队定向路由:把任务/消息路由给另一个团队或某个 Bot 所在团队的 lead。' +
|
|
1315
|
+
'只唤醒对方 leadId,不唤醒全部成员。先落 route 事件再投递(intent-first)。' +
|
|
1316
|
+
'失败一律返回可读的 {ok:false, decision:"refused", reason},绝不静默。',
|
|
1317
|
+
parameters: {
|
|
1318
|
+
type: 'object',
|
|
1319
|
+
properties: {
|
|
1320
|
+
fromTeam: { type: 'string', description: '发送方团队 id(可选;缺省按执行者会话 id 推导)' },
|
|
1321
|
+
toTeam: { type: 'string', description: '目标团队 id' },
|
|
1322
|
+
toBot: { type: 'string', description: '目标 Bot 的会话 id(toTeam 与 toBot 二选一)' },
|
|
1323
|
+
taskId: {
|
|
1324
|
+
type: 'string',
|
|
1325
|
+
description: '关联任务 id(可选;缺省自动生成 route-<时间戳>——页面「发协作请求」不一定挂任务)。'
|
|
1326
|
+
+ '同一轮重发请复用同一 taskId,否则会被当成新路由(去重键含 taskId)',
|
|
1327
|
+
},
|
|
1328
|
+
text: { type: 'string', description: '要投递的正文内容' },
|
|
1329
|
+
hop: { type: 'number', description: '当前跳数(>3 拒绝;缺省 0)' },
|
|
1330
|
+
},
|
|
1331
|
+
additionalProperties: false,
|
|
1332
|
+
},
|
|
1333
|
+
run: (s, a, exec) => {
|
|
1334
|
+
if (!s.teams) return failure('teams-unavailable')
|
|
1335
|
+
// 唯一的序列编排在 `teams.deliverRoute()`(决定 → 投递 → 留痕)。
|
|
1336
|
+
// 工具层与 `POST /teams/route` 共用它 —— 两路各写一遍行为就会漂
|
|
1337
|
+
// (一边去重了、一边没去重;一边先投后记、一边先记后投)。
|
|
1338
|
+
return s.teams.deliverRoute({
|
|
1339
|
+
...a,
|
|
1340
|
+
by: callerOf(exec),
|
|
1341
|
+
dispatcher: s.dispatcher,
|
|
1342
|
+
})
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1226
1345
|
]
|
|
1227
1346
|
|
|
1228
1347
|
|