agentworkshop 0.5.5 → 0.5.7

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.
Files changed (50) hide show
  1. package/.AgentWorkShop/prompts/compaction-hints.md +9 -0
  2. package/.AgentWorkShop/prompts/host-tools.json +695 -630
  3. package/.AgentWorkShop/prompts/peer-reply-cross-channel.md +10 -0
  4. package/.AgentWorkShop/prompts/system-manual.md +6 -1
  5. package/.output/.AgentWorkShop/prompts/compaction-hints.md +9 -0
  6. package/.output/.AgentWorkShop/prompts/host-tools.json +695 -630
  7. package/.output/.AgentWorkShop/prompts/peer-reply-cross-channel.md +10 -0
  8. package/.output/.AgentWorkShop/prompts/system-manual.md +6 -1
  9. package/.output/nitro.json +1 -1
  10. package/.output/public/_nuxt/builds/latest.json +1 -1
  11. package/.output/public/_nuxt/builds/meta/02278199-eeea-4f96-882a-94c5408c6cb6.json +1 -0
  12. package/.output/server/chunks/_/nitro.mjs +2188 -1324
  13. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  14. package/.output/server/chunks/routes/api/workshop/agent-tools/invoke.post.mjs +5 -2
  15. package/.output/server/chunks/routes/api/workshop/agent-tools/invoke.post.mjs.map +1 -1
  16. package/.output/server/chunks/routes/api/workshop/channels/_id/index3.post.mjs +25 -3
  17. package/.output/server/chunks/routes/api/workshop/channels/_id/index3.post.mjs.map +1 -1
  18. package/.output/server/package.json +1 -1
  19. package/package.json +1 -1
  20. package/scripts/_dbg-cross-channel-e2e.mjs +157 -0
  21. package/scripts/_dbg-cross-team-visibility-e2e.mjs +129 -0
  22. package/scripts/test-context-governor.ts +176 -0
  23. package/scripts/test-memory-brief-chronicle.ts +180 -0
  24. package/scripts/test-memory-dynamic.ts +3 -1
  25. package/scripts/test-memory-rrf-mmr.ts +114 -0
  26. package/server/api/workshop/agent-tools/invoke.post.ts +8 -1
  27. package/server/api/workshop/channels/[id]/messages/index.post.ts +26 -1
  28. package/server/api/workshop/ws.ts +1 -0
  29. package/server/data/daqs.json +35 -13
  30. package/server/data/dcw-lines.json +14 -0
  31. package/server/data/dcw-products.json +14 -0
  32. package/server/data/dcw-recipes.json +28 -0
  33. package/server/data/dcw-rollback.json +122 -1
  34. package/server/data/dcw-runs.json +54 -36
  35. package/server/data/dcw-writes.json +60 -0
  36. package/server/data/dcws.json +42 -0
  37. package/server/data/line-runs.json +32 -22
  38. package/server/services/workshop/agents/adapters/omp-rpc-client.ts +65 -0
  39. package/server/services/workshop/agents/agent-interface.ts +44 -1
  40. package/server/services/workshop/agents/mock-agent.ts +12 -0
  41. package/server/services/workshop/agents/omp-agent.ts +304 -8
  42. package/server/services/workshop/db/database.ts +16 -1
  43. package/server/services/workshop/db/memory.repo.ts +40 -2
  44. package/server/services/workshop/prompts/loader.ts +6 -5
  45. package/server/services/workshop/runtime/agent-runtime.ts +52 -6
  46. package/server/services/workshop/runtime/channel-runtime.ts +14 -6
  47. package/server/services/workshop/runtime/manager.ts +314 -3
  48. package/server/services/workshop/runtime/memory.ts +313 -78
  49. package/server/services/workshop/types/task.ts +14 -0
  50. package/.output/public/_nuxt/builds/meta/f2f649c5-fd3b-4507-a3a1-8b4a194de72d.json +0 -1
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 上下文治理环测试(假 RPC 客户端,零真实子进程):
3
+ * 70% 门控 / get_state 复查 / compact→compaction_end 等待 / legacy 降级 /
4
+ * 压缩事件分支 harvest / 被动 usage 透出 / 总开关与最小间隔。
5
+ * 运行: npx tsx --tsconfig .nuxt/tsconfig.server.json scripts/test-context-governor.ts
6
+ */
7
+ import { OmpRpcAgentImpl } from '../server/services/workshop/agents/omp-agent'
8
+
9
+ let failures = 0
10
+ function check(name: string, ok: boolean, detail = ''): void {
11
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
12
+ if (!ok) failures += 1
13
+ }
14
+
15
+ type Frame = Record<string, unknown>
16
+
17
+ /** 假 omp RPC 客户端:协议面仿真(命令应答/事件订阅/被动 usage) */
18
+ class FakeOmpClient {
19
+ alive = true
20
+ streaming = false
21
+ statsOk = true
22
+ compactOk = true
23
+ usageTokens = 0
24
+ contextWindow = 1000
25
+ autoCompact: boolean | null = null
26
+ sent: Array<Record<string, unknown>> = []
27
+ private listeners = new Set<(e: Frame) => void>()
28
+
29
+ onEvent(fn: (e: Frame) => void): () => void {
30
+ this.listeners.add(fn)
31
+ return () => this.listeners.delete(fn)
32
+ }
33
+
34
+ emit(e: Frame): void {
35
+ for (const fn of this.listeners) fn(e)
36
+ }
37
+
38
+ async send(cmd: Record<string, unknown>): Promise<{ success: boolean, data?: unknown }> {
39
+ this.sent.push(cmd)
40
+ const type = cmd.type as string
41
+ if (type === 'get_session_stats') {
42
+ if (!this.statsOk) throw new Error('omp RPC 命令失败: get_session_stats 未知命令')
43
+ return { success: true, data: { contextUsage: { tokens: this.usageTokens, contextWindow: this.contextWindow, percent: this.usageTokens / this.contextWindow } } }
44
+ }
45
+ if (type === 'get_state') {
46
+ return { success: true, data: { isStreaming: this.streaming, isCompacting: false, sessionId: 'sess-1', model: { contextWindow: this.contextWindow } } }
47
+ }
48
+ if (type === 'compact') {
49
+ if (!this.compactOk) throw new Error('omp RPC 命令失败: compact 未知命令')
50
+ const summary = '压缩摘要:完成任务A;未竟:验证B'
51
+ setTimeout(() => {
52
+ this.emit({ type: 'compaction_end', result: { summary, tokensBefore: this.usageTokens, tokensAfter: 30000 }, reason: 'manual' })
53
+ }, 10)
54
+ return { success: true, data: {} }
55
+ }
56
+ if (type === 'set_auto_compaction') {
57
+ this.autoCompact = cmd.enabled as boolean
58
+ return { success: true }
59
+ }
60
+ return { success: true }
61
+ }
62
+
63
+ getContextUsage(): { tokens: number, contextWindow: number, percent: number } {
64
+ return { tokens: this.usageTokens, contextWindow: this.contextWindow, percent: this.usageTokens / this.contextWindow }
65
+ }
66
+
67
+ setContextWindow(n: number): void {
68
+ this.contextWindow = n
69
+ }
70
+
71
+ onHostToolCall(_fn: unknown): void {}
72
+ }
73
+
74
+ /** 装配:真实 OmpRpcAgentImpl + 注入假客户端/假 workspace */
75
+ function assemble(): { impl: OmpRpcAgentImpl, client: FakeOmpClient, harvested: Array<Record<string, unknown>> } {
76
+ const impl = new OmpRpcAgentImpl({ agentId: 'a1', name: 'A', role: 'worker', channelId: 'ch1' })
77
+ const client = new FakeOmpClient()
78
+ const harvested: Array<Record<string, unknown>> = []
79
+ const box = impl as unknown as Record<string, unknown>
80
+ box.client = client
81
+ box.workspace = {
82
+ recordSessionMemory: async (i: Record<string, unknown>) => {
83
+ harvested.push(i)
84
+ },
85
+ }
86
+ return { impl, client, harvested }
87
+ }
88
+
89
+ const gate = (impl: OmpRpcAgentImpl, reason = 'test'): Promise<void> =>
90
+ (impl as unknown as Record<string, unknown>).contextGate(reason) as Promise<void>
91
+
92
+ process.env.AW_OMP_COMPACT_THRESHOLD = '0.7'
93
+ process.env.AW_OMP_COMPACT_MIN_INTERVAL_MS = '300000'
94
+ process.env.AW_OMP_COMPACT_WAIT_MS = '5000'
95
+
96
+ console.log('\n--- 门控基础路径 ---')
97
+ {
98
+ process.env.AW_OMP_COMPACT_ENABLED = '1'
99
+ const { impl, client, harvested } = assemble()
100
+ client.usageTokens = 500 // 50% < 70%
101
+ await gate(impl)
102
+ check('低于阈值不触发 compact', !client.sent.some(c => c.type === 'compact'), `sent=${client.sent.map(c => c.type)}`)
103
+
104
+ client.usageTokens = 850 // 85% ≥ 70%
105
+ await gate(impl)
106
+ check('达阈值发起 compact', client.sent.some(c => c.type === 'compact'))
107
+ check('compact 前先复查 get_state', client.sent.indexOf('get_state') !== -1 || client.sent.some(c => c.type === 'get_state'))
108
+ await new Promise(r => setTimeout(r, 50))
109
+ check('compaction_end 摘要 harvest 入库', harvested.length === 1 && harvested[0]!.summary === '压缩摘要:完成任务A;未竟:验证B', JSON.stringify(harvested))
110
+ check('harvest 携带 token 元信息', harvested[0]!.tokensBefore === 850 && harvested[0]!.tokensAfter === 30000)
111
+
112
+ // 最小间隔:压缩后立即再 gate 不应二次 compact
113
+ client.usageTokens = 950
114
+ await gate(impl)
115
+ check('最小间隔防振荡(不再 compact)', client.sent.filter(c => c.type === 'compact').length === 1)
116
+ }
117
+
118
+ console.log('\n--- 安全防线 ---')
119
+ {
120
+ // get_state 复查:流式中绝不压缩
121
+ const { impl, client } = assemble()
122
+ client.usageTokens = 900
123
+ client.streaming = true
124
+ await gate(impl)
125
+ check('isStreaming=true 时不发起 compact', !client.sent.some(c => c.type === 'compact'))
126
+
127
+ // 总开关
128
+ process.env.AW_OMP_COMPACT_ENABLED = '0'
129
+ const g2 = assemble()
130
+ g2.client.usageTokens = 900
131
+ await gate(g2.impl)
132
+ check('总开关关闭不压缩', !g2.client.sent.some(c => c.type === 'compact'))
133
+ process.env.AW_OMP_COMPACT_ENABLED = '1'
134
+
135
+ // legacy 降级:compact 未知命令 → 标记后不再尝试
136
+ const g3 = assemble()
137
+ g3.client.usageTokens = 900
138
+ g3.client.compactOk = false
139
+ await gate(g3.impl)
140
+ const box3 = g3.impl as unknown as Record<string, unknown>
141
+ check('未知命令 → legacy 标记', box3.compactLegacy === true)
142
+ g3.client.usageTokens = 980
143
+ await gate(g3.impl)
144
+ check('legacy 后续回合不再发 compact', g3.client.sent.filter(c => c.type === 'compact').length === 1)
145
+ }
146
+
147
+ console.log('\n--- 事件分支与状态透出 ---')
148
+ {
149
+ const { impl, client, harvested } = assemble()
150
+ // 回合中途原生压缩(compaction_start/end 事件):不污染事件流 + harvest
151
+ const map = (impl as unknown as Record<string, (e: Frame) => Frame[]>).mapOmpEvent.bind(impl)
152
+ const startOut = map({ type: 'compaction_start' }, undefined)
153
+ const endOut = map({ type: 'compaction_end', result: { summary: '原生阈值压缩摘要', tokensBefore: 900 }, reason: 'threshold' }, undefined)
154
+ const settledOut = map({ type: 'agent_settled' }, undefined)
155
+ check('压缩事件映射为空(不污染 AgentEvent 流)', startOut.length === 0 && endOut.length === 0 && settledOut.length === 0)
156
+ await new Promise(r => setTimeout(r, 20))
157
+ check('原生压缩摘要同样 harvest', harvested.some(h => h.summary === '原生阈值压缩摘要'))
158
+
159
+ // 被动 usage 透出 getContextStats
160
+ client.usageTokens = 420
161
+ const stats = (impl as unknown as Record<string, () => unknown>).getContextStats()
162
+ check('getContextStats 透出用量与占比', JSON.stringify(stats) === JSON.stringify({ usedTokens: 420, contextWindow: 1000, percent: 0.42, compacting: false }), JSON.stringify(stats))
163
+
164
+ // 双路去重:同摘要 60s 内只入库一次
165
+ map({ type: 'compaction_end', result: { summary: '原生阈值压缩摘要' }, reason: 'threshold' }, undefined)
166
+ await new Promise(r => setTimeout(r, 20))
167
+ check('同摘要双达去重(60s 窗口)', harvested.filter(h => h.summary === '原生阈值压缩摘要').length === 1)
168
+
169
+ // onTurnSettled 复用 gate(自守卫不抛错)
170
+ client.usageTokens = 100
171
+ await impl.onTurnSettled()
172
+ check('onTurnSettled 低用量安全返回', true)
173
+ }
174
+
175
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
176
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 记忆持久化面测试:L0 会话简报 / 压缩摘要入库 / 团队编年史 / 维护豁免与淘汰 /
3
+ * manager 任务终态事件 → 团队域沉淀总线接线。
4
+ * 运行: npx tsx --tsconfig .nuxt/tsconfig.server.json scripts/test-memory-brief-chronicle.ts
5
+ */
6
+ import type { DatabaseSync } from 'node:sqlite'
7
+ import { openWorkshopDb } from '../server/services/workshop/db/database'
8
+ import { createMemoryRepo, TEAM_AGENT_ID } from '../server/services/workshop/db/memory.repo'
9
+ import { AgentMemory, runMemoryMaintenance, unsegmentCJK } from '../server/services/workshop/runtime/memory'
10
+ import type { AgentInterface } from '../server/services/workshop/agents/agent-interface'
11
+ import { createChannelRepo } from '../server/services/workshop/db/channel.repo'
12
+ import { createAgentRepo } from '../server/services/workshop/db/agent.repo'
13
+ import { createTeamRepo } from '../server/services/workshop/db/team.repo'
14
+ import { createTeamMemberRepo } from '../server/services/workshop/db/team-member.repo'
15
+ import { createChannelAgentRepo } from '../server/services/workshop/db/channel-agent.repo'
16
+ import { createMessageRepo } from '../server/services/workshop/db/message.repo'
17
+ import { createTaskRepo } from '../server/services/workshop/db/task.repo'
18
+ import { createSubscriptionRepo } from '../server/services/workshop/db/subscription.repo'
19
+ import { createAgentChannelManager } from '../server/services/workshop/runtime/manager'
20
+
21
+ let failures = 0
22
+ function check(name: string, ok: boolean, detail = ''): void {
23
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
24
+ if (!ok) failures += 1
25
+ }
26
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
27
+
28
+ const db: DatabaseSync = openWorkshopDb(':memory:')
29
+ const repo = createMemoryRepo(db)
30
+ const seg = (s: string): string => s.replace(/([\u4e00-\u9fff])/g, ' $1 ')
31
+
32
+ // ===== L0 会话简报:recordTaskOutcome / save(shared) 自动刷新,幂等单行 =====
33
+ console.log('\n--- L0 会话简报(brief)---')
34
+ {
35
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'w1' })
36
+ check('无完成记录时不产简报', repo.findByAgentDedup('ch1', 'w1', 'brief:w1') === null)
37
+ await mem.recordTaskOutcome({
38
+ id: 'tk1', channelId: 'ch1', assigneeId: 'w1', creatorId: 'lead', title: '网关配置任务',
39
+ state: 'COMPLETED', progress: 100, retryCount: 0,
40
+ artifacts: [{ artifactId: 'a1', name: 'deliverable', parts: [{ text: '网关路由表已生效' }] }],
41
+ history: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
42
+ } as Parameters<AgentMemory['recordTaskOutcome']>[0])
43
+ let brief = repo.findByAgentDedup('ch1', 'w1', 'brief:w1')
44
+ check('任务终态后简报生成', brief !== null)
45
+ check('简报含最近完成条目', brief !== null && unsegmentCJK(repo.getById(brief.id)!.content).includes('网关配置任务'))
46
+ check('简报为单行幂等(brief kind)', repo.listByAgentChannel('ch1', 'w1', 100).filter(r => r.kind === 'brief').length === 1)
47
+
48
+ await mem.save({ title: '限流约定', content: '全队统一令牌桶', scope: 'shared', dedupKey: 'conv:rl' })
49
+ brief = repo.findByAgentDedup('ch1', 'w1', 'brief:w1')
50
+ check('共享沉淀后简报含团队约定', brief !== null && unsegmentCJK(repo.getById(brief.id)!.content).includes('限流约定'))
51
+
52
+ // recall L0 置顶
53
+ const block = await mem.recall('任意查询')
54
+ check('recall 注入块含会话简报', block !== null && block.includes('会话简报'))
55
+ }
56
+
57
+ // ===== 压缩摘要入库:episodic-session,序号 dedupKey,可检索 =====
58
+ console.log('\n--- 压缩摘要(episodic-session)---')
59
+ {
60
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'w1' })
61
+ const s1 = await mem.recordSessionCompaction({ summary: '完成网关配置;待办:验证路由', tokensBefore: 90000, tokensAfter: 30000, reason: 'threshold' })
62
+ const s2 = await mem.recordSessionCompaction({ summary: '第二次压缩摘要', tokensBefore: 88000 })
63
+ check('dedupKey 序号递增不互踩', s1.dedupKey !== s2.dedupKey && repo.findByAgentDedup('ch1', 'w1', s1.dedupKey) !== null && repo.findByAgentDedup('ch1', 'w1', s2.dedupKey) !== null)
64
+ const found = await mem.recallRows('网关配置 验证路由')
65
+ check('压缩摘要可被 search_memory 检索', found.some(s => s.kind === 'episodic-session'), JSON.stringify(found.map(s => s.kind)))
66
+ const row = repo.listByAgentChannel('ch1', 'w1', 100).find(r => r.kind === 'episodic-session')
67
+ check('importance=0.75 元信息入库', row !== null && row.importance === 0.75)
68
+ }
69
+
70
+ // ===== 团队编年史:滚动重写幂等单行 =====
71
+ console.log('\n--- 团队编年史(chronicle)---')
72
+ {
73
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: TEAM_AGENT_ID })
74
+ mem.upsertChronicle(['09-02 10:00 [完成] 任务A — w1(交付 x)', '09-02 11:00 [失败] 任务B — w2'].join('\n'))
75
+ mem.upsertChronicle(['09-02 12:00 [完成] 任务C — w3'].join('\n'))
76
+ const rows = repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).filter(r => r.kind === 'chronicle')
77
+ check('编年史幂等单行(重写不新增)', rows.length === 1)
78
+ const chron = rows[0] !== undefined ? unsegmentCJK(rows[0]!.content) : ''
79
+ check('编年史内容为最新滚动', chron.includes('w3') && !chron.includes('任务 A'), chron)
80
+ }
81
+
82
+ // ===== 维护:session 14d 过期 / 策展层豁免 / team-task 淘汰 =====
83
+ console.log('\n--- 维护规则 v2 ---')
84
+ {
85
+ const old = new Date(Date.now() - 20 * 86_400_000).toISOString()
86
+ repo.upsert({ channelId: 'ch1', agentId: 'w1', kind: 'episodic-session', title: '旧压缩摘要', titleFts: seg('旧压缩摘要'), content: seg('过期内容'), importance: 0.75, taskId: null, dedupKey: 'session:w1:c99' })
87
+ db.prepare('UPDATE agent_memories SET created_at = ? WHERE dedup_key = ?').run(old, 'session:w1:c99')
88
+
89
+ const res = runMemoryMaintenance(repo, { expireDays: 180, cap: 500 })
90
+ check('episodic-session 14d 过期删除', res.deletedExpired >= 1 && repo.findByAgentDedup('ch1', 'w1', 'session:w1:c99') === null, JSON.stringify(res))
91
+ check('brief 豁免保留', repo.findByAgentDedup('ch1', 'w1', 'brief:w1') !== null)
92
+ check('chronicle 豁免保留', repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).some(r => r.kind === 'chronicle'))
93
+
94
+ // team-task 容量淘汰(cap=2 时仅保留 2 行)
95
+ for (let i = 0; i < 4; i++) {
96
+ repo.upsert({ channelId: 'ch1', agentId: TEAM_AGENT_ID, kind: 'episodic-team-task', title: `团队任务${i}`, titleFts: seg(`团队任务${i}`), content: seg('成果'), importance: i < 2 ? 0.9 : 0.5, taskId: `tt-${i}`, dedupKey: `team-task:tt-${i}` })
97
+ }
98
+ runMemoryMaintenance(repo, { expireDays: 180, cap: 2 })
99
+ const remaining = repo.listByAgentChannel('ch1', TEAM_AGENT_ID, 100).filter(r => r.kind === 'episodic-team-task')
100
+ check('team episodic 纳入容量淘汰(高分保留)', remaining.length === 2 && remaining.every(r => r.importance === 0.9), `left=${remaining.length}`)
101
+ }
102
+
103
+ // ===== manager 总线接线:任务终态 → team-task 行 + 编年史(单点不双写)=====
104
+ console.log('\n--- manager 任务终态团队沉淀 ---')
105
+ {
106
+ const tmRepos = {
107
+ channels: createChannelRepo(db),
108
+ agents: createAgentRepo(db),
109
+ teams: createTeamRepo(db),
110
+ teamMembers: createTeamMemberRepo(db),
111
+ channelAgents: createChannelAgentRepo(db),
112
+ messages: createMessageRepo(db),
113
+ subscriptions: createSubscriptionRepo(db),
114
+ tasks: createTaskRepo(db),
115
+ memories: repo,
116
+ }
117
+ // 空转 impl:run 即返回(lead 自派任务的回合立即落定,不产错误)
118
+ const stubImpl = (): AgentInterface => ({ async* run() {} })
119
+ const manager = createAgentChannelManager({ repos: tmRepos, implFactory: stubImpl, db })
120
+ const ch = tmRepos.channels.create({ name: 'ch-harvest' })
121
+ const lead = tmRepos.channelAgents.create({ channelId: ch.id, templateId: null, name: 'lead-h', harness: 'mock', role: 'lead' })
122
+ // 激活 channel runtime(创建 bus + 注册团队沉淀监听)
123
+ manager.subscribeTaskEvents(ch.id, () => {})
124
+
125
+ const task = await manager.dispatchTask(ch.id, lead.id, { assigneeId: lead.id, title: '数据迁移执行', description: '迁移并验证' })
126
+ await sleep(30)
127
+ await manager.completeTask(ch.id, lead.id, {
128
+ taskId: task.id,
129
+ artifacts: [{ artifactId: 'd1', name: 'deliverable', parts: [{ text: '迁移完成,校验和一致' }] }],
130
+ })
131
+ await sleep(30)
132
+
133
+ const teamRow = repo.findByAgentDedup(ch.id, TEAM_AGENT_ID, `team-task:${task.id}`)
134
+ check('任务完成 → team-task 共享行入库', teamRow !== null)
135
+ const teamRows = repo.listByAgentChannel(ch.id, TEAM_AGENT_ID, 100).filter(r => r.kind === 'episodic-team-task')
136
+ check('团队域单点写入(无双写)', teamRows.length === 1, `rows=${teamRows.length}`)
137
+ check('交付物进入共享行内容', teamRow !== null && unsegmentCJK(repo.getById(teamRow.id)!.content).includes('校验和一致'))
138
+ const chronicle = repo.listByAgentChannel(ch.id, TEAM_AGENT_ID, 100).find(r => r.kind === 'chronicle')
139
+ check('编年史随任务终态滚动更新', chronicle !== undefined && unsegmentCJK(repo.getById(chronicle.id)!.content).includes('数据迁移执行'))
140
+ await manager.shutdown()
141
+ }
142
+
143
+ // ===== 空闲反思:当月 episodic-task ≥ 阈值 → reflection 行聚合(月度幂等)=====
144
+ console.log('\n--- 空闲反思(reflection)---')
145
+ {
146
+ const month = new Date().toISOString().slice(0, 7)
147
+ for (let i = 0; i < 8; i++) {
148
+ repo.upsert({ channelId: 'ch1', agentId: 'rx', kind: 'episodic-task', title: `反思任务${i}`, titleFts: seg(`反思任务${i}`), content: seg('结论性内容'), importance: 0.8, taskId: `rx-${i}`, dedupKey: `task:rx-${i}` })
149
+ }
150
+ const stubImpl = (): AgentInterface => ({ async* run() {} })
151
+ const m2 = createAgentChannelManager({
152
+ repos: {
153
+ channels: createChannelRepo(db),
154
+ agents: createAgentRepo(db),
155
+ teams: createTeamRepo(db),
156
+ teamMembers: createTeamMemberRepo(db),
157
+ channelAgents: createChannelAgentRepo(db),
158
+ messages: createMessageRepo(db),
159
+ subscriptions: createSubscriptionRepo(db),
160
+ tasks: createTaskRepo(db),
161
+ memories: repo,
162
+ },
163
+ implFactory: stubImpl,
164
+ db,
165
+ })
166
+ ;(m2 as unknown as Record<string, () => void>).reflectIdleMemories()
167
+ const reflRef = repo.findByAgentDedup('ch1', 'rx', `reflection:rx:${month}`)
168
+ const refl = reflRef ? repo.getById(reflRef.id) : null
169
+ check('反思行入库(月度 dedupKey)', refl !== null)
170
+ check('反思行聚合任务结论', refl !== null && unsegmentCJK(refl.content).includes('反思任务'))
171
+
172
+ // 再跑一轮:无新增任务 → 不重复聚合(内容不变,幂等)
173
+ ;(m2 as unknown as Record<string, () => void>).reflectIdleMemories()
174
+ const reflCount = repo.listByAgentChannel('ch1', 'rx', 100).filter(r => r.kind === 'reflection').length
175
+ check('反思月度幂等(无增量不再重写)', reflCount === 1)
176
+ await m2.shutdown()
177
+ }
178
+
179
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
180
+ process.exit(failures === 0 ? 0 : 1)
@@ -89,7 +89,9 @@ console.log('\n--- 记忆引子 primer(小预算注入)---')
89
89
  const block = await memA1.recall('限流 令牌桶 部署', { touch: false })
90
90
  check('primer 返回记忆块', block !== null)
91
91
  check('primer 含按需抓取工具提示', block !== null && block.includes('search_memory'), block?.slice(-80))
92
- check('primer 小预算(默认300 tokens,行数受限)', block !== null && block.split('\n').length < 10, `lines=${block?.split('\n').length}`)
92
+ // v2 三层注入:L0 简报(save(shared) 后自动存在)+ L1 引子 + 提示,同在 300 tok 引子预算 + 500 tok 总预算内
93
+ check('primer 含 L0 会话简报', block !== null && block.includes('会话简报'))
94
+ check('primer 小预算(行数受限)', block !== null && block.split('\n').length < 16, `lines=${block?.split('\n').length}`)
93
95
 
94
96
  const memTiny = new AgentMemory(repo, { channelId: 'ch1', agentId: 'a1', budgetTokens: 5 })
95
97
  check('极小预算 primer 无行则 null', (await memTiny.recall('限流')) === null)
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 记忆检索 v2 算法测试:RRF 融合 / MMR 多样性 / kind 感知半衰期 / 任务关联加权 / 策展层排除。
3
+ * 运行: npx tsx --tsconfig .nuxt/tsconfig.server.json scripts/test-memory-rrf-mmr.ts
4
+ */
5
+ import type { DatabaseSync } from 'node:sqlite'
6
+ import { openWorkshopDb } from '../server/services/workshop/db/database'
7
+ import { createMemoryRepo, TEAM_AGENT_ID } from '../server/services/workshop/db/memory.repo'
8
+ import { AgentMemory, buildMatchQuery, estimateTokens } from '../server/services/workshop/runtime/memory'
9
+
10
+ let failures = 0
11
+ function check(name: string, ok: boolean, detail = ''): void {
12
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
13
+ if (!ok) failures += 1
14
+ }
15
+
16
+ const db: DatabaseSync = openWorkshopDb(':memory:')
17
+ const repo = createMemoryRepo(db)
18
+ const seg = (s: string): string => s.replace(/([\u4e00-\u9fff])/g, ' $1 ')
19
+
20
+ // ===== buildMatchQuery:CJK 词项扩容 + 去重 =====
21
+ console.log('\n--- buildMatchQuery(CJK 词项扩容)---')
22
+ {
23
+ const q = buildMatchQuery('网关限流方案讨论,部署脚本与令牌桶阈值约定')
24
+ const termCount = q ? q.split(' OR ').length : 0
25
+ check('CJK 单字词项上限提升(>12)', termCount > 12, `terms=${termCount}`)
26
+ const deduped = buildMatchQuery('限流 限流 限流')
27
+ check('词项去重', deduped !== null && deduped.split(' OR ').length === 2, `terms=${deduped}`) // 限/流
28
+ check('token 估算为正', estimateTokens('abc 中文') > 0)
29
+ }
30
+
31
+ // ===== RRF 融合:FTS+向量双榜融合(词袋 embedder)=====
32
+ console.log('\n--- RRF 融合(混合检索)---')
33
+ {
34
+ // hash 词袋 provider:同词集 → 相近向量(零网络)
35
+ const { createHashEmbeddingProvider } = await import('../server/services/workshop/runtime/embedding-provider')
36
+ const embedder = createHashEmbeddingProvider(64)
37
+
38
+ const rows = [
39
+ { dedup: 'task:a', title: 'Redis 缓存穿透方案', content: '布隆过滤器前置拦截,空值缓存五分钟' },
40
+ { dedup: 'task:b', title: 'Redis 雪崩预案', content: '过期时间加随机抖动,多级缓存兜底' },
41
+ { dedup: 'task:c', title: '部署脚本清单', content: 'docker compose 拉镜像,健康检查后切流量' },
42
+ ]
43
+ for (const r of rows) {
44
+ repo.upsert({ channelId: 'ch1', agentId: 'a1', kind: 'episodic-task', title: r.title, titleFts: seg(r.title), content: seg(r.content), importance: 0.8, taskId: r.dedup, dedupKey: `task:${r.dedup}` })
45
+ }
46
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'a1', embedder })
47
+ // 向量可命中(词袋相似)但 FTS 无词面匹配的查询:RRF 仍能通过向量榜给出结果
48
+ const hits = await mem.recallRows('cache 穿透 拦截')
49
+ check('向量榜独有命中可达(RRF 不丢向量结果)', hits.some(s => s.title === 'Redis 缓存穿透方案'), JSON.stringify(hits.map(s => s.title)))
50
+
51
+ // 双榜同时命中:融合排序应把两榜都命中的排前
52
+ const both = await mem.recallRows('Redis 雪崩')
53
+ check('双榜命中排序靠前', both.length > 0 && both[0]!.title === 'Redis 雪崩预案', JSON.stringify(both.map(s => `${s.title}:${s.score}`)))
54
+ }
55
+
56
+ // ===== kind 感知半衰期:semantic 不衰减,episodic-task 14d =====
57
+ console.log('\n--- kind 感知半衰期 ---')
58
+ {
59
+ const old = new Date(Date.now() - 60 * 86_400_000).toISOString()
60
+ repo.upsert({ channelId: 'ch1', agentId: 'old1', kind: 'semantic', title: '六十天前的架构决策', titleFts: seg('六十天前的架构决策'), content: seg('内核结论:单写多读'), importance: 0.9, taskId: null, dedupKey: 'manual:old-sem' })
61
+ repo.upsert({ channelId: 'ch1', agentId: 'old1', kind: 'episodic-task', title: '六十天前的任务', titleFts: seg('六十天前的任务'), content: seg('过程性记录,内容已过时'), importance: 0.9, taskId: 't-old', dedupKey: 'task:t-old' })
62
+ // 直接改 created_at 模拟老化(upsert ON CONFLICT 会刷新,故用参数化 UPDATE)
63
+ db.prepare('UPDATE agent_memories SET created_at = ? WHERE agent_id = ?').run(old, 'old1')
64
+
65
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'old1' })
66
+ const hits = await mem.recallRows('六十天前')
67
+ const sem = hits.find(s => s.title === '六十天前的架构决策')
68
+ const epi = hits.find(s => s.title === '六十天前的任务')
69
+ check('semantic 知识不随时近衰减(分高于 episodic)', sem !== undefined && epi !== undefined && sem.score > epi.score, `sem=${sem?.score} epi=${epi?.score}`)
70
+ }
71
+
72
+ // ===== 任务关联加权:兄弟/父任务记忆置顶 =====
73
+ console.log('\n--- 任务关联加权(related-task boost)---')
74
+ {
75
+ repo.upsert({ channelId: 'ch1', agentId: 'rel', kind: 'episodic-task', title: '无关旧任务', titleFts: seg('无关旧任务'), content: seg('数据库迁移完成,版本升到 v14'), importance: 0.8, taskId: 't-other', dedupKey: 'task:t-other' })
76
+ repo.upsert({ channelId: 'ch1', agentId: 'rel', kind: 'episodic-task', title: '兄弟任务结论', titleFts: seg('兄弟任务结论'), content: seg('API 网关路由表已生成'), importance: 0.55, taskId: 't-sib1', dedupKey: 'task:t-sib1' })
77
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'rel' })
78
+ // 查询词面同时弱命中两行;兄弟任务 id 加权后必须反超高重要度旧行
79
+ const hits = await mem.recallRows('任务 结论 数据库 网关', { relatedTaskIds: ['t-sib1', 't-parent'] })
80
+ check('兄弟任务记忆加权反超(置顶)', hits[0]?.title === '兄弟任务结论', JSON.stringify(hits.map(s => `${s.title}:${s.score}`)))
81
+ const hitsNoRel = await mem.recallRows('任务 结论 数据库 网关', { touch: false })
82
+ check('无关联集时高重要度旧行居前(对照)', hitsNoRel[0]?.title === '无关旧任务', JSON.stringify(hitsNoRel.map(s => `${s.title}:${s.score}`)))
83
+ }
84
+
85
+ // ===== 策展层:brief 走 L0 置顶,L1 引子不重复;chronicle 不进 L1 但 L2 可检索 =====
86
+ console.log('\n--- 策展层(L0/L1/L2 分层)---')
87
+ {
88
+ const mem = new AgentMemory(repo, { channelId: 'ch1', agentId: 'cur' })
89
+ await mem.save({ title: '普通结论', content: '普通检索应该能命中', scope: 'private', dedupKey: 'note:normal' })
90
+ mem.repo.upsert({ channelId: 'ch1', agentId: 'cur', kind: 'brief', title: '会话简报', titleFts: seg('会话简报'), content: seg('最近完成:普通结论'), importance: 0.6, taskId: null, dedupKey: 'brief:cur' })
91
+ mem.repo.upsert({ channelId: 'ch1', agentId: 'cur', kind: 'chronicle', title: '团队编年史', titleFts: seg('团队编年史'), content: seg('完成 普通结论'), importance: 0.9, taskId: null, dedupKey: 'chronicle:ch1' })
92
+
93
+ // L1(recall):brief 经 L0 路径恰好注入一次(去重靠 excludeCurated);chronicle 不进 L1
94
+ const primer = await mem.recall('普通')
95
+ const briefHits = (primer?.match(/会话简报/g) ?? []).length
96
+ check('brief 恰好注入一次(L0 路径)', briefHits === 1, `hits=${briefHits}`)
97
+ check('L1 引子不含 chronicle(防双份注入)', primer !== null && !primer.includes('编年史'))
98
+
99
+ // L2(search_memory/recallRows):策展层可被检索(特性)
100
+ const found = await mem.recallRows('编年史')
101
+ check('L2 search_memory 可命中 chronicle', found.some(s => s.title === '团队编年史'), JSON.stringify(found.map(s => s.title)))
102
+ }
103
+
104
+ // ===== team 域可见性:episodic-team-task 对全员 shared 可检索 =====
105
+ console.log('\n--- team-task 共享域 ---')
106
+ {
107
+ repo.upsert({ channelId: 'ch1', agentId: TEAM_AGENT_ID, kind: 'episodic-team-task', title: '团队任务成果', titleFts: seg('团队任务成果'), content: seg('数据迁移已全部完成并验证'), importance: 0.8, taskId: 't-team', dedupKey: 'team-task:t-team' })
108
+ const memB = new AgentMemory(repo, { channelId: 'ch1', agentId: 'member-b' })
109
+ const seen = await memB.recallRows('数据迁移', { scope: 'shared' })
110
+ check('其他成员 shared 域可命中 team-task 行', seen.some(s => s.title === '团队任务成果'), JSON.stringify(seen.map(s => s.title)))
111
+ }
112
+
113
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
114
+ process.exit(failures === 0 ? 0 : 1)
@@ -6,6 +6,7 @@
6
6
  import { readBody } from 'h3'
7
7
  import { resolveUser } from '@/server/api/workshop/caller'
8
8
  import { defineApiHandler } from '@/server/utils/response'
9
+ import { getWorkshopManager } from '@/server/plugins/workshop'
9
10
  import {
10
11
  toolDaqQuery,
11
12
  toolDcwControl,
@@ -15,6 +16,9 @@ import {
15
16
  toolMyIndustrialNodes,
16
17
  } from '@/server/services/workshop/agents/industrial-tools'
17
18
 
19
+ /** 协作工具族:经 manager 工具桥落到 agent workspace(跨团队可见性/跨 Channel 通信) */
20
+ const COLLAB_TOOLS = new Set(['list_other_teams', 'search_other_teams_memory', 'send_cross_channel_message'])
21
+
18
22
  export default defineApiHandler(async (event) => {
19
23
  resolveUser(event)
20
24
  const body = await readBody<{ agentId?: string, tool?: string, args?: Record<string, unknown> }>(event) ?? {}
@@ -23,7 +27,10 @@ export default defineApiHandler(async (event) => {
23
27
  const args = body.args ?? {}
24
28
  if (!agentId) throw createError({ statusCode: 400, statusMessage: 'agentId required' })
25
29
  let result: { text: string, isError?: boolean }
26
- if (tool === 'my_industrial_nodes') result = await toolMyIndustrialNodes(agentId)
30
+ if (COLLAB_TOOLS.has(tool)) {
31
+ result = getWorkshopManager().invokeAgentWorkspaceTool(agentId, tool, args)
32
+ }
33
+ else if (tool === 'my_industrial_nodes') result = await toolMyIndustrialNodes(agentId)
27
34
  else if (tool === 'dcw_control') result = await toolDcwControl(agentId, args as { node_id?: string, value?: number, hypothesis?: string, task_id?: string })
28
35
  else if (tool === 'daq_query') result = await toolDaqQuery(agentId, args as Parameters<typeof toolDaqQuery>[1])
29
36
  else if (tool === 'dcw_judge') result = await toolDcwJudge(agentId, args as { record_id?: string, verdict?: string, reason?: string })
@@ -15,7 +15,7 @@ import { defineApiHandler } from '../../../../../utils/response'
15
15
  import { getWorkshopManager } from '../../../../../plugins/workshop'
16
16
 
17
17
  const sendMessageSchema = z.object({
18
- toAgentId: z.string().min(1, 'toAgentId 必填'),
18
+ toAgentId: z.string().min(1, 'toAgentId 必填').optional(),
19
19
  text: z.string().min(1, 'text 必填'),
20
20
  fromAgentId: z.string().optional(),
21
21
  /** 人类发送者显示名(时间线 a2a.message 的发送者归属 → x-aw-from-label) */
@@ -23,6 +23,11 @@ const sendMessageSchema = z.object({
23
23
  priority: z.enum(['immediate', 'task']).default('task'),
24
24
  /** 触发器:要求接收方回执(执行结果 + 所需内容,in_reply_to 关联) */
25
25
  requireReply: z.boolean().optional(),
26
+ /**
27
+ * 跨 Channel 通信(仅 lead):指定目标 channel(id 或名字)→ 消息直投其 lead mailbox。
28
+ * 与 toAgentId 二选一;fromAgentId 必填且必须是本 channel 的 lead(worker 拒绝)。
29
+ */
30
+ toChannelId: z.string().optional(),
26
31
  })
27
32
 
28
33
  export default defineApiHandler(async (event) => {
@@ -33,6 +38,26 @@ export default defineApiHandler(async (event) => {
33
38
  const channel = manager.getChannelForUser(channelId, user.id)
34
39
  manager.requireOwned(channel.ownerUserId, user.id, 'channel')
35
40
 
41
+ // 跨 Channel 分支:lead 专属;目标解析(id/名字)与投递在 manager 内完成
42
+ if (body.toChannelId) {
43
+ if (body.toAgentId) {
44
+ throw new AppError(400, 'BAD_REQUEST', 'toChannelId 与 toAgentId 二选一,不可同时指定')
45
+ }
46
+ if (!body.fromAgentId) {
47
+ throw new AppError(403, 'ROLE_FORBIDDEN', '跨 Channel 通信必须以本 channel Leader 身份发送(fromAgentId 必填)')
48
+ }
49
+ const fromAgentId = manager.resolveChannelMember(channelId, body.fromAgentId).id
50
+ return manager.sendCrossChannelMessage(channelId, fromAgentId, {
51
+ toChannelId: body.toChannelId,
52
+ parts: [{ text: body.text }],
53
+ requireReply: body.requireReply,
54
+ })
55
+ }
56
+
57
+ if (!body.toAgentId) {
58
+ throw new AppError(400, 'BAD_REQUEST', 'toAgentId 必填(跨 Channel 请改用 toChannelId)')
59
+ }
60
+
36
61
  // 目标寻址(容错:精确实例 id / 模板 id / 名字 / 唯一名字前缀)
37
62
  const target = manager.resolveChannelMember(channelId, body.toAgentId)
38
63
  // 发送方身份校验:显式 fromAgentId 必须可解析到本 channel 成员 ——
@@ -379,6 +379,7 @@ function bindStreamSubscriptions(manager: AgentChannelManager, stream: ChannelSt
379
379
  currentTaskProgress: e.currentTaskProgress ?? null,
380
380
  queued: e.queuedCount ?? 0,
381
381
  completed: e.completedCount ?? 0,
382
+ context: e.context ?? null,
382
383
  }, { agentId: e.agentId })))
383
384
  // harness 事件流(message/artifact/status.message/error)
384
385
  stream.unsubs.push(manager.subscribeChannelEvents(channelId, (event, source) => mapAgentEvent(manager, stream, event, source)))