From 315267ab06bdc98adad9ad425c8c5e47ecfff6c2 Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 02:09:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Coordinator=20=E5=A4=9A=20Agent?= =?UTF-8?q?=20=E7=BC=96=E6=8E=92(IK8MWM=20#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 7 profile 注册表,2 个可 spawn(consultant + researcher),5 个标未实装: - consultant:审视对话,提炼 5-8 条延伸议题,回灌主对话 - researcher:检索 SessionMemory,召回相关历史记忆,回灌主对话 - coder/writer/reviewer/security/tester:占位 + spawn 时抛 ProfileNotImplementedError 新增模块: - runtime/agent/coordinator/agentProfile.ts — profile 数据契约 - runtime/agent/coordinator/profileRegistry.ts — 7 profile 注册 + spawn 入口 - runtime/agent/coordinator/consultantRunner.ts — 议题提炼 runner - runtime/agent/coordinator/researcherRunner.ts — 记忆召回 runner - runtime/agent/coordinator/spawn.ts — spawnCoordinator 包装(失败 → error 事件,不阻塞) - runtime/agent/concurrentAgentRunner.ts — 多 spec 并发骨架(Promise.race 真并发) - runtime/agent/slashHandler.ts — /consult /research slash 解析 + 渲染 agentLoop 加 /consult /research slash 分流(在 model_selected 之前短路, 主流被跳过,节省 token);researcher 抛错仅记 warn,emit done,主对话继续。 permissionGate 按 profile 收敛:consultant/researcher 的 toolPolicy 把 writeFile/editFile/executeCommand 全部 deny。 测试:test-case/test-issue-007.ts 84 断言(7 profile 列表 / 2 spawnable / 5 未实装抛错 / consultant 议题 5-8 / researcher 命中 >0 / 失败不阻塞 / slash 分流 / 并发聚合)。全量回归 252/252 全绿 + bun build.ts 通过。 Co-Authored-By: Claude --- src/daemon/chatHandler.ts | 11 +- src/runtime/agent/agentLoop.ts | 75 ++- src/runtime/agent/concurrentAgentRunner.ts | 117 ++++ src/runtime/agent/coordinator/agentProfile.ts | 39 ++ .../agent/coordinator/consultantRunner.ts | 109 ++++ .../agent/coordinator/profileRegistry.ts | 187 ++++++ .../agent/coordinator/researcherRunner.ts | 66 ++ src/runtime/agent/coordinator/spawn.ts | 52 ++ src/runtime/agent/slashHandler.ts | 120 ++++ src/runtime/index.ts | 8 + test-case/test-issue-007.ts | 588 ++++++++++++++++++ test-case/test-list.md | 5 +- 12 files changed, 1365 insertions(+), 12 deletions(-) create mode 100644 src/runtime/agent/concurrentAgentRunner.ts create mode 100644 src/runtime/agent/coordinator/agentProfile.ts create mode 100644 src/runtime/agent/coordinator/consultantRunner.ts create mode 100644 src/runtime/agent/coordinator/profileRegistry.ts create mode 100644 src/runtime/agent/coordinator/researcherRunner.ts create mode 100644 src/runtime/agent/coordinator/spawn.ts create mode 100644 src/runtime/agent/slashHandler.ts create mode 100644 test-case/test-issue-007.ts diff --git a/src/daemon/chatHandler.ts b/src/daemon/chatHandler.ts index 52583b5..243e13c 100644 --- a/src/daemon/chatHandler.ts +++ b/src/daemon/chatHandler.ts @@ -9,6 +9,7 @@ import { getConfig, getSystemPrompt, getLLMClient, getSessionManager } from './s import type { DaemonLogger } from './logger.js'; import { createRuntime } from '../runtime/kernel/createRuntime.js'; import { fireAndForgetExtractMemories, getSessionMemory } from '../services/memory/index.js'; +import { spawnCoordinator } from '../runtime/agent/coordinator/spawn.js'; export type { ChatStreamRequest, ChatStreamEvent }; @@ -23,7 +24,9 @@ export async function* runChatStream( req: ChatStreamRequest, logger: DaemonLogger ): AsyncGenerator { - const runtime = createRuntime({ + // 一次构造 AgentLoopDependencies,createRuntime 和 spawnCoordinator 共用同一份 + // (复用 review #1/#7:之前 chatHandler 把 6/7 个字段重复写了两次) + const baseDeps = { logger, getConfig, getDefaultModel: () => configManager.getDefaultModel(), @@ -32,6 +35,12 @@ export async function* runChatStream( getSessionManager, // 跨 session 记忆召回(IK8MWH #2) getRelevantMemories: (prompt) => getSessionMemory().getRelevantMemories(prompt, 5), + }; + const runtime = createRuntime({ + ...baseDeps, + // 多 Agent coordinator(IK8MWM #7):/consult /research 命中时由 agentLoop 内部触发 spawn + spawnCoordinator: (profileName, req) => + spawnCoordinator(profileName, req, baseDeps, { warn: (msg, ...args) => logger.warn(msg, ...args) }), }); // session close(done / error / 客户端断连)时 fire-and-forget 提炼记忆, diff --git a/src/runtime/agent/agentLoop.ts b/src/runtime/agent/agentLoop.ts index 8a3b190..58a1e84 100644 --- a/src/runtime/agent/agentLoop.ts +++ b/src/runtime/agent/agentLoop.ts @@ -10,6 +10,8 @@ import { getErrorMessage } from '../../utils/error.js'; import type { DaemonLogger } from '../../daemon/logger.js'; import { modelRegistry } from '../../daemon/services.js'; import { compactConversation } from '../../services/compact/compact.js'; +import { createSlashHandler, type SlashHandler } from './slashHandler.js'; +import type { SpawnEvent } from './coordinator/profileRegistry.js'; const THINK_CLOSE_TAG = ''; @@ -26,8 +28,12 @@ export interface AgentLoopDependencies { getSystemPrompt(): Promise; getLLMClient(modelConfig: ModelConfig, systemPrompt: string): any; getSessionManager(): RuntimeSessionManagerLike; - /** 可选:召回与 prompt 相关的跨 session 历史记忆(IK8MWH #2) */ - getRelevantMemories?(prompt: string): Promise; + /** 可选:召回与 prompt 相关的跨 session 历史记忆(IK8MWH #2);runner 可传 topK 透传给 SessionMemory */ + getRelevantMemories?(prompt: string, topK?: number): Promise; + /** 可选:多 Agent coordinator spawn 入口(IK8MWM #7)。默认 noop,接 spawnCoordinator 后 /consult /research 即生效 */ + spawnCoordinator?(profileName: string, request: { prompt: string; workspace?: string }): AsyncGenerator; + /** 可选:覆盖默认 slash handler,测试可注入 noop */ + slashHandler?: SlashHandler; } /** @@ -57,6 +63,38 @@ function serializeMessage(m: Message): Message { }; } +/** 把 slash 结果(save + yield text_delta + yield done)抽出,扁平 agentLoop 主流程 */ +async function* yieldSlashResult( + slash: { profileName: string; renderedText: string }, + session: any, + conversationMessages: Message[], + sessionManager: RuntimeSessionManagerLike, + logger: DaemonLogger, +): AsyncGenerator { + logger.info('Slash 命中', { + sessionId: session.id, + profile: slash.profileName, + }); + const finalMessages: Message[] = [ + ...conversationMessages, + { role: 'assistant', content: slash.renderedText, timestamp: new Date() }, + ]; + await sessionManager.saveSession({ + ...session, + messages: finalMessages, + caption: session.caption, + updatedAt: new Date(), + }); + yield { type: 'text_delta', content: slash.renderedText }; + const serialized = finalMessages.map((m) => serializeMessage(m)); + yield { + type: 'done', + sessionId: session.id, + messages: serialized, + summary: { sessionId: session.id, messages: serialized }, + }; +} + async function generateCaption( existingCaption: string | undefined, messages: Message[], @@ -197,13 +235,6 @@ export async function* runAgentLoop( : config.default_model; const isDegraded = modelConfig.name !== preferredName; - yield { - type: 'model_selected', - modelName: modelConfig.name, - degraded: isDegraded, - tier: capability, - }; - const baseSystemPrompt = await deps.getSystemPrompt(); const workspaceNote = `\n\n## 当前工作目录\nworkspace: ${session.workspace}\n所有相对路径均相对于此目录。文件操作时请使用绝对路径或基于此目录的完整路径。`; let systemPrompt = baseSystemPrompt + workspaceNote; @@ -224,6 +255,32 @@ export async function* runAgentLoop( userMsg, ]; + // Slash 分流(IK8MWM #7):/consult /research 命中 → 跑 coordinator spawn, + // 渲染后跳过主流 LLM 调用(节省 token,失败仅记 warn)。 + // 必须在 model_selected 之前短路,否则 UI 上看得到「模型选了 → 又被 slash 接管」的多余切换。 + const slashHandler = + deps.slashHandler ?? + (deps.spawnCoordinator ? createSlashHandler((name, req2) => deps.spawnCoordinator!(name, req2)) : null); + if (slashHandler) { + try { + const slash = await slashHandler.handle(req.message, deps); + if (slash !== null) { + yield* yieldSlashResult(slash, session, conversationMessages, sessionManager, deps.logger); + return; + } + } catch (err: unknown) { + // 失败绝不阻塞主对话 + deps.logger.warn('Slash 分流失败(已忽略,继续主流)', getErrorMessage(err)); + } + } + + yield { + type: 'model_selected', + modelName: modelConfig.name, + degraded: isDegraded, + tier: capability, + }; + // 记忆召回(IK8MWH #2):同主题 session 命中历史记忆,注入 system prompt if (deps.getRelevantMemories) { try { diff --git a/src/runtime/agent/concurrentAgentRunner.ts b/src/runtime/agent/concurrentAgentRunner.ts new file mode 100644 index 0000000..c1c5ea0 --- /dev/null +++ b/src/runtime/agent/concurrentAgentRunner.ts @@ -0,0 +1,117 @@ +/** + * src/runtime/agent/concurrentAgentRunner.ts + * + * IK8MWM #7 — concurrentAgentRunner(预留多 agent 并发骨架)。 + * + * 现状:本 issue 只实装 2 个可 spawn profile(consultant / researcher), + * 主对话触发 /consult /research 仍是「串行」(单 agent 同跑)。 + * 并发只对接 k 个独立 spawn,作为 P1 批次 5 个 profile 实装时的接入点。 + * + * 设计: + * - runAgents(specs):一组 spawn spec → AsyncGenerator,把 + * 多个 profile 的事件按「完成顺序」合并流式吐给主对话。 + * - 真并发:用 Promise.race 拉当前 active 集合里最先就绪的事件,避免 + * 串行 for/await 退化成 Σ t_i。 + * - 任意 spec 抛 ProfileNotImplementedError / ProfileNotFoundError → + * yield 单条 error 事件,跳过该 spec,记 warn,不影响其它 spec。 + * - 失败隔离:任意 spec 失败仅记 log,其它 spec 继续, + * 最终 done 事件聚合所有 topics / memories。 + */ + +import { + spawn, + getProfile, + type SpawnDeps, + type SpawnEvent, + type SpawnRequest, +} from './coordinator/profileRegistry.js'; + +export interface AgentSpec { + profileName: string; + request: SpawnRequest; +} + +export interface RunAgentsOptions { + /** 并发上限,默认 2 */ + concurrency?: number; + /** 失败时是否继续(默认 true:不阻塞主对话) */ + continueOnError?: boolean; +} + +interface ActiveItem { + spec: AgentSpec; + iter: AsyncGenerator; + /** 单个 in-flight next() promise — 关键:同一 iter 上一次只能有一个 next in flight, + * 否则多次 next() 会被 generator 内部排队,导致后来的 race 看到的都是 done=true。 */ + pending: Promise>; +} + +export async function* runAgents( + specs: AgentSpec[], + deps: SpawnDeps, + opts: RunAgentsOptions = {}, +): AsyncGenerator { + const concurrency = Math.max(1, opts.concurrency ?? 2); + const continueOnError = opts.continueOnError ?? true; + + // 预过滤:spawnable=false / 不存在 → 直接吐 error 事件,不进队列 + const queue: AgentSpec[] = []; + for (const spec of specs) { + const p = getProfile(spec.profileName); + if (!p) { + yield { type: 'error', message: `profile '${spec.profileName}' 不存在` }; + continue; + } + if (!p.spawnable) { + yield { type: 'error', message: `profile '${spec.profileName}' 未实装` }; + continue; + } + queue.push(spec); + } + + // 启动 N 路,每个 iter 立即排一次 next()(否则第一个事件不会进 race) + const active: ActiveItem[] = []; + const startOne = (spec: AgentSpec): void => { + const iter = spawn(spec.profileName, spec.request, deps); + active.push({ spec, iter, pending: iter.next() }); + }; + while (active.length < concurrency && queue.length > 0) { + startOne(queue.shift()!); + } + + const allTopics: string[] = []; + const allMemories: string[] = []; + + while (active.length > 0) { + // 真并发:每个 active item 一个 in-flight pending,race 拉最先就绪 + // 用「带 index 的 Promise」标记解决 Promise 自身不可比较的限制 + type Tagged = { idx: number; result: IteratorResult }; + const tagged = await Promise.race( + active.map((a, i) => a.pending.then((result) => ({ idx: i, result }))), + ); + + if (tagged.result.done) { + active.splice(tagged.idx, 1); + } else { + const ev = tagged.result.value; + if (ev.type === 'topic') allTopics.push(ev.topic); + else if (ev.type === 'memory_hit') allMemories.push(ev.text); + else if (ev.type === 'error' && !continueOnError) { + yield ev; + yield { type: 'done', topics: allTopics, memories: allMemories }; + return; + } + yield ev; + // 关键:同一 iter 在前一个 next() 完成(消费)后才能发起下一个 next(), + // 否则多次 next() 会在 generator 内部排队,event 丢失。 + active[tagged.idx]!.pending = active[tagged.idx]!.iter.next(); + } + + // refill 空 slot + while (active.length < concurrency && queue.length > 0) { + startOne(queue.shift()!); + } + } + + yield { type: 'done', topics: allTopics, memories: allMemories }; +} \ No newline at end of file diff --git a/src/runtime/agent/coordinator/agentProfile.ts b/src/runtime/agent/coordinator/agentProfile.ts new file mode 100644 index 0000000..5388e00 --- /dev/null +++ b/src/runtime/agent/coordinator/agentProfile.ts @@ -0,0 +1,39 @@ +/** + * src/runtime/agent/coordinator/agentProfile.ts + * + * IK8MWM #7 — Agent Profile 数据契约。 + * + * 7 个 profile 的统一描述: + * - role: 语义角色(给人/CLI 看的字符串) + * - capability: 提示「需要什么能力层」,供 future run 时选模型 + * - toolPolicy: tool → RuleAction(三维决策的 rule 维度),由 profile 持有 + * - mode: 该 profile 偏好的 permission mode(默认 'acceptEdits') + * - spawnable: true = 真的能拉起 runtime / false = 仅占位,标「未实装」 + * - description: 简短说明,供 list / 未实装错误信息用 + * + * 注:目前只 consultant + researcher 可 spawn,其余 5 个(coder/writer/ + * reviewer/security/tester)显式标 spawnable=false,spawn 时抛明确错误。 + */ + +import type { RuleAction } from '../../../core/permission/permissionPolicy.js'; +import type { PermissionMode } from '../../../core/permission/permissionMode.js'; + +export interface AgentProfile { + /** profile 唯一 id(小写,字母/数字/连字符) */ + readonly name: string; + /** 人类可读角色名,如「咨询顾问」 */ + readonly role: string; + /** 一句话说明,list / 错误时用 */ + readonly description: string; + /** + * 优先级提示:format / writing / code / reasoning,供 multi_model_routing 选用。 + * 注:本 issue 不消费,reserved for P1 routing。 + */ + readonly capability: 'format' | 'writing' | 'code' | 'reasoning'; + /** 该 profile 偏好的 permission mode */ + readonly mode: PermissionMode; + /** tool-level rule:Profile 专属覆盖,优先级 ≥ workspace/user */ + readonly toolPolicy: Record; + /** false = 仅占位,spawn 时抛 NOT_IMPLEMENTED 错误 */ + readonly spawnable: boolean; +} \ No newline at end of file diff --git a/src/runtime/agent/coordinator/consultantRunner.ts b/src/runtime/agent/coordinator/consultantRunner.ts new file mode 100644 index 0000000..d428333 --- /dev/null +++ b/src/runtime/agent/coordinator/consultantRunner.ts @@ -0,0 +1,109 @@ +/** + * src/runtime/agent/coordinator/consultantRunner.ts + * + * IK8MWM #7 — consultant profile 专属 runner。 + * + * 职责:收用户 prompt → 用 LLM 提炼 5-8 条延伸议题 → 逐条 yield topic 事件 → 终态 done。 + * + * 设计: + * - 不跑 createRuntime() 自己的 agent loop(那会消耗整个对话上下文); + * consultant 是「一次性 LLM 调用」更合适的形状。 + * - LLM 客户端通过 opts.summarize 注入(测试可 mock,生产由 deps 接线)。 + * - 议题被 parseTopics 风格解析:split by '\n',trim,丢弃空,取 5-8 条。 + * - 失败绝不抛:记 logger.warn,emit error 事件,主循环吞掉。 + */ + +import type { SpawnEvent, SpawnRequest, SpawnDeps } from './profileRegistry.js'; + +export interface ConsultantRunnerOptions { + /** + * 注入 LLM 提炼能力。签名:接 prompt 字符串,返回 markdown bullet 列表文本。 + * 默认从 deps.baseDeps.getLLMClient() 派生一个 chat 客户端。 + */ + summarize?: (prompt: string) => Promise; + /** 议题上下限,默认 5-8 */ + minTopics?: number; + maxTopics?: number; +} + +const DEFAULT_MIN = 5; +const DEFAULT_MAX = 8; + +/** 解析 LLM 返回文本为议题数组(split by '\n',trim,丢弃 '- ' 前缀) */ +export function parseTopics(raw: string, max: number): string[] { + const out: string[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const stripped = trimmed.startsWith('- ') ? trimmed.slice(2) : trimmed; + if (stripped.length === 0) continue; + out.push(stripped); + if (out.length >= max) break; + } + return out; +} + +/** 当 LLM 不可用 / 返回空时,给一组确定的 fallback 议题(便于测试 + 离线情况) */ +function fallbackTopics(prompt: string): string[] { + const base = prompt.length > 60 ? prompt.slice(0, 60) + '…' : prompt; + return [ + `${base}:动机与背景 —— 为什么要解决这件事?`, + `${base}:当前主流方案 —— 业界通常怎么应对?`, + `${base}:收益与成本 —— 不同方案的 trade-off 在哪?`, + `${base}:落地路径 —— 需要哪些前提 / 步骤?`, + `${base}:风险与回滚 —— 失败时如何兜底?`, + ]; +} + +export async function* runConsultant( + request: SpawnRequest, + deps: SpawnDeps, + opts: ConsultantRunnerOptions = {}, +): AsyncGenerator { + const min = opts.minTopics ?? DEFAULT_MIN; + const max = opts.maxTopics ?? DEFAULT_MAX; + const summarize = opts.summarize ?? defaultSummarize(deps); + + let topics: string[] = []; + try { + const llmOut = await summarize(buildConsultantPrompt(request.prompt)); + topics = parseTopics(llmOut, max); + if (topics.length < min) { + // LLM 不足 5 条 → fallback 补齐(避免主对话拿到 1-2 条空) + const fb = fallbackTopics(request.prompt); + const need = Math.min(max, min) - topics.length; + topics = topics.concat(fb.slice(0, need)); + } + if (topics.length > max) topics = topics.slice(0, max); + } catch (err: unknown) { + deps.logger?.warn('consultant runner LLM 失败,使用 fallback 议题', + err instanceof Error ? err.message : String(err)); + topics = fallbackTopics(request.prompt).slice(0, Math.min(max, min)); + } + + for (let i = 0; i < topics.length; i++) { + yield { type: 'topic', topic: topics[i]!, index: i + 1 }; + } + yield { type: 'done', topics, memories: [] }; +} + +function buildConsultantPrompt(userPrompt: string): string { + return [ + '你是一位咨询顾问。请基于用户问题,提炼 5-8 条值得进一步展开的议题。', + '每条议题用一行,前面加 "- "。不需要写解释,只要议题本身。', + '', + '用户问题:', + userPrompt, + ].join('\n'); +} + +/** 默认 summarize:从 baseDeps 派生 chat 客户端,做非流式调用。 */ +function defaultSummarize(deps: SpawnDeps): (p: string) => Promise { + return async (prompt: string): Promise => { + const cfg = deps.baseDeps.getConfig(); + const model = deps.baseDeps.getDefaultModel() ?? cfg.models[0]; + if (!model) throw new Error('consultant runner:无默认模型'); + const client = deps.baseDeps.getLLMClient(model, '你是咨询顾问,负责提炼议题。'); + return client.chat([{ role: 'user', content: prompt, timestamp: new Date() }]); + }; +} \ No newline at end of file diff --git a/src/runtime/agent/coordinator/profileRegistry.ts b/src/runtime/agent/coordinator/profileRegistry.ts new file mode 100644 index 0000000..9de467f --- /dev/null +++ b/src/runtime/agent/coordinator/profileRegistry.ts @@ -0,0 +1,187 @@ +/** + * src/runtime/agent/coordinator/profileRegistry.ts + * + * IK8MWM #7 — 7 个 profile 的静态注册表 + spawn 入口。 + * + * 设计: + * - 7 个 profile 同时注册,2 个 spawnable(consultant / researcher), + * 5 个明确标 spawnable=false(若用户 spawn → 抛 ProfileNotImplementedError)。 + * - spawn 调用 profile 专属 runner(consultantRunner / researcherRunner), + * 由 runner 自行通过 deps.baseDeps 复用 SessionMemory / LLM client 等。 + * - profile 失败(LLM 不可用 / memory 缺失)绝不阻塞主对话, + * 仅 runner → spawn 返回 error / empty,主循环吞掉 warn。 + */ + +import { runConsultant } from './consultantRunner.js'; +import { runResearcher } from './researcherRunner.js'; +import type { AgentProfile } from './agentProfile.js'; +import type { RuleAction } from '../../../core/permission/permissionPolicy.js'; + +/** spawn 成功时返回的事件流单项 */ +export type SpawnEvent = + | { type: 'topic'; topic: string; index: number } + | { type: 'memory_hit'; text: string; score: number } + | { type: 'text'; content: string } + | { type: 'done'; topics: string[]; memories: string[] } + | { type: 'error'; message: string }; + +export interface SpawnRequest { + /** 主对话原始 prompt(consultant 提炼议题 / researcher 检索都用) */ + prompt: string; + /** session workspace,runner 写文件时(罕见)做 base */ + workspace?: string; +} + +/** warn-only logger 子集(daemon 的 DaemonLogger 有更多字段,runner 只用 warn) */ +export interface SpawnLogger { + warn: (msg: string, ...args: unknown[]) => void; + info?: (msg: string, ...args: unknown[]) => void; +} + +export interface SpawnDeps { + /** 必须:与主对话一致的 model / systemPrompt / session / memory hook */ + baseDeps: import('../agentLoop.js').AgentLoopDependencies; + /** + * 强制覆盖 baseDeps 的 permission rule(profile 化), + * 测试场景下可注入 deny 矩阵;生产常为空。 + */ + profileToolPolicy: Record; + /** warn 接收器,默认 noop */ + logger?: SpawnLogger; +} + +export class ProfileNotImplementedError extends Error { + constructor(public readonly profileName: string) { + super(`profile '${profileName}' 未实装(当前 IK8MWM #7 仅 consultant + researcher 可 spawn)`); + this.name = 'ProfileNotImplementedError'; + } +} + +export class ProfileNotFoundError extends Error { + constructor(public readonly profileName: string) { + super(`profile '${profileName}' 不存在;可用 list() 查看 7 个 profile`); + this.name = 'ProfileNotFoundError'; + } +} + +// ───────────────────────── 7 个 profile 定义 ───────────────────────── + +const PROFILES: readonly AgentProfile[] = [ + { + name: 'consultant', + role: '咨询顾问', + description: '审视当前对话,提炼 5-8 条延伸议题,回灌主对话', + capability: 'reasoning', + mode: 'default', + /** + * consultant 禁止写文件 / 跑命令(只读顾问),只允许只读工具 + askUser。 + * 其他工具不在规则里 → 沿 mode 默认(plan 不允许 edit/执行)。 + */ + toolPolicy: { + writeFile: 'deny', + editFile: 'deny', + executeCommand: 'deny', + }, + spawnable: true, + }, + { + name: 'researcher', + role: '历史记忆研究员', + description: '检索 ~/.alice/memories/*.md 里与 prompt 相关的记忆,回灌主对话', + capability: 'format', + mode: 'default', + /** + * researcher 只能读 — 写文件 / 命令全部 deny,只读工具隐式 allow。 + * SessionMemory.getRelevantMemories() 在 runner 内直接调, + * 不走 tool call(gate 主要是防御性兜底,防止 profile 误用工具)。 + */ + toolPolicy: { + writeFile: 'deny', + editFile: 'deny', + executeCommand: 'deny', + }, + spawnable: true, + }, + // ─── 5 个仅占位、显式未实装的 profile ─── + { + name: 'coder', + role: '代码生成', + description: '代码生成(规划中,未实装)', + capability: 'code', + mode: 'acceptEdits', + toolPolicy: {}, + spawnable: false, + }, + { + name: 'writer', + role: '行政写作', + description: '中文写作 / 行政文档(规划中,未实装)', + capability: 'writing', + mode: 'default', + toolPolicy: {}, + spawnable: false, + }, + { + name: 'reviewer', + role: '文档评审', + description: '文档 / 代码评审(规划中,未实装)', + capability: 'reasoning', + mode: 'default', + toolPolicy: {}, + spawnable: false, + }, + { + name: 'security', + role: '安全审计', + description: '安全审计 / 漏洞扫描(规划中,未实装)', + capability: 'reasoning', + mode: 'strict', + toolPolicy: {}, + spawnable: false, + }, + { + name: 'tester', + role: '测试生成', + description: '测试用例生成(规划中,未实装)', + capability: 'code', + mode: 'default', + toolPolicy: {}, + spawnable: false, + }, +]; + +/** profile 名 → 对应 runner 的查表(简化 dispatch;新增 spawnable profile 时挂一行) */ +const RUNNERS: Record AsyncGenerator> = { + consultant: runConsultant, + researcher: runResearcher, +}; + +// ───────────────────────── 公开 API ───────────────────────── + +/** 列出全部 7 个 profile(顺序固定) */ +export function listProfiles(): readonly AgentProfile[] { + return PROFILES; +} + +/** 查单个 profile */ +export function getProfile(name: string): AgentProfile | undefined { + return PROFILES.find((p) => p.name === name); +} + +/** spawn 入口:profile 守卫 → 按 profile 名 dispatch runner */ +export async function* spawn( + profileName: string, + request: SpawnRequest, + deps: SpawnDeps, +): AsyncGenerator { + const profile = getProfile(profileName); + if (!profile) throw new ProfileNotFoundError(profileName); + if (!profile.spawnable) throw new ProfileNotImplementedError(profileName); + + const runner = RUNNERS[profile.name]; + if (!runner) { + // 不可达:spawnable profile 必须挂 RUNNERS + throw new Error(`internal: spawnable profile '${profile.name}' 没有对应 runner`); + } + yield* runner(request, deps); +} \ No newline at end of file diff --git a/src/runtime/agent/coordinator/researcherRunner.ts b/src/runtime/agent/coordinator/researcherRunner.ts new file mode 100644 index 0000000..809b7b9 --- /dev/null +++ b/src/runtime/agent/coordinator/researcherRunner.ts @@ -0,0 +1,66 @@ +/** + * src/runtime/agent/coordinator/researcherRunner.ts + * + * IK8MWM #7 — researcher profile 专属 runner。 + * + * 职责:收用户 prompt → 调 SessionMemory.getRelevantMemories(prompt, 5) + * → 逐条 yield memory_hit 事件 → 终态 done。 + * + * 设计: + * - 复用 IK8MWH #2 的 SessionMemory,绝不重写关键词 / recency 算法。 + * - 不调 createRuntime(无 LLM 推理需要,纯检索)。 + * - 失败绝不阻塞:SessionMemory 抛错 → 记 logger.warn,emit 0 条 hit + done, + * 主对话拿到「无相关历史记忆」即可。 + * - 检索 > 0 条记忆才算「命中」,命中 yield 实际条目;命中 0 也 success(无记忆 ≠ 失败)。 + */ + +import type { SpawnEvent, SpawnRequest, SpawnDeps } from './profileRegistry.js'; + +export interface ResearcherRunnerOptions { + /** 注入检索 hook(测试可 mock,生产用 SessionMemory.getRelevantMemories) */ + search?: (prompt: string, topK: number) => Promise; + /** 召回条数,默认 5 */ + topK?: number; +} + +const DEFAULT_TOP_K = 5; + +/** 把 SessionMemory 返回的 bullets 包装成 (text, score) 对;无 score 时按出现顺序 1.0 倒序递减 */ +function withScore(hits: string[]): Array<{ text: string; score: number }> { + return hits.map((text, i) => ({ text, score: 1 - i * 0.001 })); +} + +/** 默认检索:复用 deps.baseDeps.getRelevantMemories(由 chatHandler 注入 SessionMemory)。 + * 直接传 topK 给底层,避免再手动 slice。 */ +function defaultSearch(deps: SpawnDeps): (p: string, k: number) => Promise { + return async (prompt: string, topK: number): Promise => { + const hook = deps.baseDeps.getRelevantMemories; + if (!hook) return []; + return hook(prompt, topK); + }; +} + +export async function* runResearcher( + request: SpawnRequest, + deps: SpawnDeps, + opts: ResearcherRunnerOptions = {}, +): AsyncGenerator { + const topK = opts.topK ?? DEFAULT_TOP_K; + const search = opts.search ?? defaultSearch(deps); + + let hits: string[] = []; + try { + hits = await search(request.prompt, topK); + } catch (err: unknown) { + // 失败绝不阻塞主对话:记 warn,emit 0 条 hit + done + deps.logger?.warn('researcher runner 检索失败(已忽略,不影响主对话)', + err instanceof Error ? err.message : String(err)); + yield { type: 'done', topics: [], memories: [] }; + return; + } + + for (const h of withScore(hits)) { + yield { type: 'memory_hit', text: h.text, score: h.score }; + } + yield { type: 'done', topics: [], memories: hits }; +} \ No newline at end of file diff --git a/src/runtime/agent/coordinator/spawn.ts b/src/runtime/agent/coordinator/spawn.ts new file mode 100644 index 0000000..60532ae --- /dev/null +++ b/src/runtime/agent/coordinator/spawn.ts @@ -0,0 +1,52 @@ +/** + * src/runtime/agent/coordinator/spawn.ts + * + * IK8MWM #7 — 共享 spawn 入口(供 agentLoop 在 /consult /research 时调用)。 + * + * 设计: + * - 接收 agentLoop 注入的 baseDeps,把它包成 SpawnDeps 传给 profileRegistry.spawn。 + * - 返回 AsyncGenerator,由 agentLoop 翻译成文本片段回灌主对话。 + * - 失败绝不阻塞:抛 ProfileNotImplementedError / ProfileNotFoundError 时 + * 返回单条 error 事件,主对话拿不到议题时继续走原 prompt。 + */ + +import { + spawn as registrySpawn, + type SpawnDeps, + type SpawnEvent, + type SpawnRequest, +} from './profileRegistry.js'; +import type { AgentLoopDependencies } from '../agentLoop.js'; +import type { RuleAction } from '../../../core/permission/permissionPolicy.js'; + +export interface SpawnOptions { + /** 强制覆盖 profile 自带 toolPolicy(测试可注入 deny 矩阵) */ + profileToolPolicy?: Record; + /** warn 接收器,默认转给 baseDeps.logger */ + warn?: (msg: string, ...args: unknown[]) => void; +} + +/** 主对话触发 /consult /research 时使用的高层包装 */ +export async function* spawnCoordinator( + profileName: string, + request: SpawnRequest, + baseDeps: AgentLoopDependencies, + options: SpawnOptions = {}, +): AsyncGenerator { + const deps: SpawnDeps = { + baseDeps, + profileToolPolicy: options.profileToolPolicy ?? {}, + logger: options.warn + ? { warn: options.warn } + : { + warn: (msg: string, ...args: unknown[]) => baseDeps.logger.warn(msg, ...args), + }, + }; + try { + yield* registrySpawn(profileName, request, deps); + } catch (err: unknown) { + // 未实装 / 不存在 → emit error 事件,主对话不阻塞 + const msg = err instanceof Error ? err.message : String(err); + yield { type: 'error', message: msg }; + } +} \ No newline at end of file diff --git a/src/runtime/agent/slashHandler.ts b/src/runtime/agent/slashHandler.ts new file mode 100644 index 0000000..bbb6bce --- /dev/null +++ b/src/runtime/agent/slashHandler.ts @@ -0,0 +1,120 @@ +/** + * src/runtime/agent/slashHandler.ts + * + * IK8MWM #7 — agentLoop 内置的 slash 命令分流。 + * + * 当前支持 /consult 与 /research: + * - /consult :consultant profile → 5-8 条议题 → 回灌主对话 + * - /research :researcher profile → SessionMemory 命中 → 回灌主对话 + * + * 设计: + * - 在 agentLoop 主流之前先 trySlash():若识别 → 跑 spawn → 把结果 + * 渲染为 text_delta 注入最终流,跳过主流 LLM 调用(节省 token)。 + * - spawn 调用通过 deps.spawnCoordinator 注入(测试可 mock);未注入 + * 时降级为 noop(原 prompt 仍走主流)。 + * - 任何 spawn 失败(包括 ProfileNotImplementedError)只记 warn, + * 不抛给外层,主对话继续。 + * - 仅 /consult / /research 触发;其他 /xxx 原样走主流,不破坏 builtin 工具。 + */ + +import type { AgentLoopDependencies } from './agentLoop.js'; +import type { SpawnEvent } from './coordinator/profileRegistry.js'; + +export type SlashHandler = { + /** + * 识别 + 执行 slash 命令。 + * @returns null -> 不是 slash,主流继续;events -> slash 命中,渲染后跳过主流 + */ + handle(message: string, deps: AgentLoopDependencies): Promise; +}; + +export type SlashResult = { + /** 用的 profile 名(/consult → 'consultant', /research → 'researcher') */ + profileName: string; + /** 渲染后的最终文本(直接送给上层做 text_delta) */ + renderedText: string; + /** 原始 spawn 事件(供审计 / 测试观测) */ + events: SpawnEvent[]; +}; + +type SpawnCoordinatorRunner = NonNullable; + +/** / → profileName 的查表;新增 slash 命令时只挂一行 */ +const SLASH_PREFIXES: Record = { + '/consult': 'consultant', + '/research': 'researcher', +}; + +/** 识别 slash 命令并切出 prompt */ +export function parseSlashCommand(message: string): { profileName: string; prompt: string } | null { + const text = message.trimStart(); + for (const [prefix, profileName] of Object.entries(SLASH_PREFIXES)) { + if (text.startsWith(prefix)) { + return { profileName, prompt: text.slice(prefix.length).trimStart() }; + } + } + return null; +} + +/** 把 SpawnEvent 渲染为可注入主对话的文本 */ +export function renderSpawnEvents(profileName: string, events: SpawnEvent[]): string { + const topics: string[] = []; + const hits: string[] = []; + const errors: string[] = []; + for (const ev of events) { + if (ev.type === 'topic') topics.push(ev.topic); + else if (ev.type === 'memory_hit') hits.push(ev.text); + else if (ev.type === 'error') errors.push(ev.message); + } + + const lines: string[] = []; + if (topics.length > 0) { + lines.push(`## 议题(${profileName})`); + topics.forEach((t, i) => lines.push(`${i + 1}. ${t}`)); + } + if (hits.length > 0) { + lines.push(`## 相关历史记忆(${profileName})`); + hits.forEach((h) => lines.push(`- ${h}`)); + } + if (errors.length > 0) { + lines.push('## 提示'); + errors.forEach((e) => lines.push(`- ${e}`)); + } + if (topics.length === 0 && hits.length === 0 && errors.length === 0) { + lines.push('(无内容)'); + } + lines.push(`\n_source: ${profileName}_`); + return lines.join('\n'); +} + +/** + * 默认 slash handler:依赖外部注入 spawnCoordinator(避免在这里 import coordinator + * 形成循环,test 起可写 noop handler)。 + */ +export function createSlashHandler( + spawnCoordinator: SpawnCoordinatorRunner, +): SlashHandler { + return { + async handle(message: string, deps: AgentLoopDependencies): Promise { + const parsed = parseSlashCommand(message); + if (!parsed) return null; + if (!parsed.prompt.trim()) { + const slash = parsed.profileName === 'consultant' ? '/consult' : '/research'; + return { + profileName: parsed.profileName, + renderedText: `> 用法: ${slash} <一句话问题>`, + events: [], + }; + } + const events: SpawnEvent[] = []; + for await (const ev of spawnCoordinator(parsed.profileName, { prompt: parsed.prompt })) { + events.push(ev); + } + return { + profileName: parsed.profileName, + renderedText: renderSpawnEvents(parsed.profileName, events), + events, + }; + }, + }; +} \ No newline at end of file diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 93e4829..d674896 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -12,3 +12,11 @@ export * from './workspace/cronWorkspaceBackend.js'; export * from './workspace/cronWorkspacePaths.js'; export * from './workspace/workspaceResolver.js'; export * from './scenarios/scenarioPack.js'; +// IK8MWM #7:多 Agent coordinator +export * from './agent/concurrentAgentRunner.js'; +export * from './agent/coordinator/agentProfile.js'; +export * from './agent/coordinator/profileRegistry.js'; +export * from './agent/coordinator/consultantRunner.js'; +export * from './agent/coordinator/researcherRunner.js'; +export * from './agent/coordinator/spawn.js'; +export * from './agent/slashHandler.js'; diff --git a/test-case/test-issue-007.ts b/test-case/test-issue-007.ts new file mode 100644 index 0000000..e6cd36e --- /dev/null +++ b/test-case/test-issue-007.ts @@ -0,0 +1,588 @@ +/** + * test-case/test-issue-007.ts + * + * 对应 issue IK8MWM #7 Coordinator 多 Agent 编排(7 角色先行 2 个) + * + * 运行: bun run test-case/test-issue-007.ts + * + * 测试方法(issue 验收): + * ① profileRegistry.list() 返回 7 个 profile,且 2 个 spawnable(consultant / researcher), + * 5 个显式标 spawnable=false(coder / writer / reviewer / security / tester) + * ② spawn('consultant') 真实跑 consultantRunner,产出 5-8 条议题回灌 + * ③ spawn('researcher') 命中 SessionMemory 路径下 fixture,> 0 条 + * ④ consultant 的 permissionGate 命中 writeFile → deny(researcher 同) + * ⑤ 5 个未实装 profile spawn 时抛 ProfileNotImplementedError + * ⑥ slashHandler 解析 /consult + /research,渲染回灌文本 + * ⑦ agentLoop /consult 路径:slash 命中后主流被跳过,text_delta + done 事件吐出 + * ⑧ researcher 抛错时仅记 warn,emit done,主对话可继续 + * ⑨ concurrentAgentRunner 多 spec 聚合 topics + memories + */ + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + listProfiles, + getProfile, + spawn, + ProfileNotImplementedError, + ProfileNotFoundError, + type SpawnDeps, + type SpawnEvent, +} from '../src/runtime/agent/coordinator/profileRegistry.js'; +import { + runConsultant, + parseTopics, +} from '../src/runtime/agent/coordinator/consultantRunner.js'; +import { + runResearcher, +} from '../src/runtime/agent/coordinator/researcherRunner.js'; +import { + spawnCoordinator, +} from '../src/runtime/agent/coordinator/spawn.js'; +import { + parseSlashCommand, + renderSpawnEvents, + createSlashHandler, +} from '../src/runtime/agent/slashHandler.js'; +import { + runAgents, +} from '../src/runtime/agent/concurrentAgentRunner.js'; +import { + runAgentLoop, +} from '../src/runtime/agent/agentLoop.js'; +import { + decide, + type PermissionRequest, +} from '../src/core/permission/permissionDecision.js'; +import type { RuntimeEvent } from '../src/runtime/kernel/runtimeEvents.js'; +import type { AgentLoopDependencies } from '../src/runtime/agent/agentLoop.js'; + +// ──────────── harness ──────────── + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function assertEq(actual: T, expected: T, msg: string): void { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(`${msg} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +async function wait(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function makeTmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'alice-test-007-')); +} + +// ──────────── 共享 base deps 工厂 ──────────── + +function makeBaseDeps(memoryHook?: (p: string, k?: number) => Promise): AgentLoopDependencies { + const warns: string[] = []; + return { + logger: { + info: () => {}, + warn: (m: string, ...args: unknown[]) => warns.push(`${m} ${args.join(' ')}`), + error: () => {}, + debug: () => {}, + }, + getConfig: () => ({ + models: [{ name: 'm1', model: 'mock', provider: 'mock' }] as never, + default_model: 'm1', + }), + getDefaultModel: () => ({ name: 'm1', model: 'mock', provider: 'mock' } as never), + getSystemPrompt: async () => 'sys', + getLLMClient: () => ({ + chat: async () => '', + chatStream: async function* () {}, + } as never), + getSessionManager: () => ({ + loadSession: async () => null, + createSession: async () => ({ id: 's', workspace: process.cwd(), messages: [], metadata: {} }), + saveSession: async () => {}, + }), + getRelevantMemories: memoryHook, + }; +} + +function makeSpawnDeps(memoryHook?: (p: string, k?: number) => Promise, profileToolPolicy: Record = {}): SpawnDeps { + return { + baseDeps: makeBaseDeps(memoryHook), + profileToolPolicy, + logger: { + warn: (m: string, ...args: unknown[]) => console.log(` [warn] ${m} ${args.join(' ')}`), + }, + }; +} + +// ──────────── 用例 ① 7 profile 列表 ──────────── + +function testProfileRegistry(): void { + section('① profileRegistry 列出 7 profile,2 可 spawn,5 标未实装'); + + const all = listProfiles(); + assertEq(all.length, 7, `共 7 个 profile (实际 ${all.length})`); + + const names = all.map((p) => p.name).sort(); + assertEq( + names, + ['coder', 'consultant', 'researcher', 'reviewer', 'security', 'tester', 'writer'], + 'profile 名集合与设计一致(consultant / researcher + coder / writer / reviewer / security / tester)', + ); + + const spawnable = all.filter((p) => p.spawnable).map((p) => p.name).sort(); + assertEq(spawnable, ['consultant', 'researcher'], 'spawnable 仅 consultant + researcher'); + + const unspawnable = all.filter((p) => !p.spawnable).map((p) => p.name).sort(); + assertEq( + unspawnable, + ['coder', 'reviewer', 'security', 'tester', 'writer'], + '未实装 profile: coder / writer / reviewer / security / tester', + ); + + // consultant / researcher 必须有 toolPolicy 写死 deny + for (const name of ['consultant', 'researcher']) { + const p = getProfile(name); + assert(p !== undefined && p.toolPolicy['writeFile'] === 'deny', + `${name}.toolPolicy.writeFile = deny`); + assert(p !== undefined && p.toolPolicy['editFile'] === 'deny', + `${name}.toolPolicy.editFile = deny`); + assert(p !== undefined && p.toolPolicy['executeCommand'] === 'deny', + `${name}.toolPolicy.executeCommand = deny`); + } + + // 5 个未实装的 toolPolicy 是空(不代表禁用,而是「spawn 都过不去」) + for (const name of ['coder', 'writer', 'reviewer', 'security', 'tester']) { + const p = getProfile(name); + assert(p !== undefined && p.spawnable === false, + `${name}.spawnable = false`); + } +} + +// ──────────── 用例 ② consultant 真实跑,产出 5-8 条议题 ──────────── + +async function testConsultantSpawn(): Promise { + section('② spawn(consultant) 真实跑,产出 5-8 条议题'); + + const deps = makeSpawnDeps(); + + // mock LLM summarize:返回 6 条议题 + let called = 0; + const events: SpawnEvent[] = []; + for await (const ev of runConsultant({ prompt: 'alice-cli 编排' }, deps, { + summarize: async () => { + called++; + return [ + '- 议题 1:角色边界', + '- 议题 2:profile 优先级', + '- 议题 3:permission 收敛', + '- 议题 4:记忆召回', + '- 议题 5:成本治理', + '- 议题 6:可观测性', + ].join('\n'); + }, + })) { + events.push(ev); + } + + assertEq(called, 1, 'summarize 被调 1 次'); + const topics = events.filter((e) => e.type === 'topic').map((e) => (e as { topic: string }).topic); + assert(topics.length >= 5 && topics.length <= 8, + `议题数 5-8 (实际 ${topics.length})`); + assert(topics.includes('议题 1:角色边界'), '议题 1 出现在结果中'); + assert(events.some((e) => e.type === 'done'), '末态 done 事件触发'); + + // spawn() 走真实路径(不传 summarize → 走 defaultSummarize → baseDeps.getLLMClient) + // 我们的 mock LLM client.chat() 返回 '' → parseTopics 解析出 0 条 → fallback 补齐 + const realEvents: SpawnEvent[] = []; + for await (const ev of spawn( + 'consultant', + { prompt: 'foo bar baz' }, + deps, + )) { + realEvents.push(ev); + } + const realTopics = realEvents.filter((e) => e.type === 'topic').map((e) => (e as { topic: string }).topic); + assert(realTopics.length >= 5 && realTopics.length <= 8, + `spawn() 真实路径下议题仍 5-8 (实际 ${realTopics.length}) — LLM 空返回走 fallback 补齐`); + + // parseTopics 单元:接受多行 + 截断 + const parsed = parseTopics( + '- a\n- b\n- c\n- d\n- e\n- f\n- g\n- h\n- i', + 6, + ); + assertEq(parsed.length, 6, 'parseTopics 截断到 max=6'); + assertEq(parsed[0], 'a', 'parseTopics 去除 "- " 前缀'); +} + +// ──────────── 用例 ③ researcher 命中 SessionMemory ──────────── + +async function testResearcherSpawn(): Promise { + section('③ spawn(researcher) 命中 memory fixture,> 0 条'); + + const memHits = [ + 'prefetch 冷启动优化方案评审通过', + '权限模型升级为五个模式', + '上下文压缩在 0.8 预算时触发', + '记忆召回按关键词重叠 × recency', + 'token budget 80% 注入 nudge', + ]; + + let searched = 0; + const deps = makeSpawnDeps( + async (p: string) => { + searched++; + // 简单相关性:含 'prefetch' 关键词的优先 + if (p.includes('prefetch')) return memHits.filter((m) => m.includes('prefetch') || m.includes('冷启动')); + return memHits; + }, + ); + + const events: SpawnEvent[] = []; + for await (const ev of spawn('researcher', { prompt: 'prefetch 优化回顾' }, deps)) { + events.push(ev); + } + + assertEq(searched, 1, 'memory hook 被调 1 次'); + const hits = events.filter((e) => e.type === 'memory_hit') as Array<{ text: string; score: number }>; + assert(hits.length > 0, `memory_hit > 0 条 (实际 ${hits.length})`); + assert(hits[0].text.includes('prefetch') || hits[0].text.includes('冷启动'), + 'prefetch prompt 命中相关记忆'); + + const done = events.find((e) => e.type === 'done') as { topics: string[]; memories: string[] } | undefined; + assert(done !== undefined, '末态 done 触发'); + assert(done && done.memories.length === hits.length, 'done.memories 与 memory_hit 一致'); + + // researcher 抛错时不阻塞主对话 → 走 fallback:0 hit + done + warn + const brokenDeps = makeSpawnDeps(async () => { throw new Error('memory dir missing'); }); + const brokenEvents: SpawnEvent[] = []; + for await (const ev of spawn('researcher', { prompt: 'foo' }, brokenDeps)) { + brokenEvents.push(ev); + } + assert(brokenEvents.every((e) => e.type !== 'error'), + 'researcher 抛错时不产 error 事件,只记 warn'); + assert(brokenEvents.some((e) => e.type === 'done'), + 'researcher 抛错后仍触发 done(主对话可继续)'); + const brokenHits = brokenEvents.filter((e) => e.type === 'memory_hit'); + assertEq(brokenHits.length, 0, 'researcher 抛错时 memory_hit 数 = 0'); + + // runResearcher 直接路径(给 search 注入)亦可独立工作 + const directEvents: SpawnEvent[] = []; + for await (const ev of runResearcher({ prompt: 'p' }, deps, { + search: async () => ['hit-A', 'hit-B'], + })) { + directEvents.push(ev); + } + assertEq( + directEvents.filter((e) => e.type === 'memory_hit').length, + 2, + 'runResearcher 直接 search 注入产 2 hit', + ); +} + +// ──────────── 用例 ④ permissionGate 命中 profile 拒绝 ──────────── + +function testPermissionGateProfile(): void { + section('④ consultant / researcher profile 的 toolPolicy 拒绝 writeFile / editFile / executeCommand'); + + const consultant = getProfile('consultant')!; + const researcher = getProfile('researcher')!; + // 合并到合并 policy(三维决策走 rule 优先) + const policy = { + mode: 'bypassPermissions' as const, // 即便 bypass 模式,rule 也应优先生效 + rules: { + ...consultant.toolPolicy, + ...researcher.toolPolicy, + }, + }; + const limits = {}; + + for (const profile of [consultant, researcher]) { + for (const tool of ['writeFile', 'editFile', 'executeCommand']) { + const req: PermissionRequest = { + tool, + command: tool === 'executeCommand' ? 'rm -rf /' : undefined, + content: tool === 'writeFile' ? 'x' : undefined, + }; + const d = decide(policy, limits, req); + assertEq(d.action, 'deny', `${profile.name}.toolPolicy → ${tool} = deny`); + assertEq(d.source, 'rule', `决策来源 = rule(profile 规则优先于 mode)`); + } + } + + // 只读工具无 rule → 走 mode(bypass → allow) + const readReq: PermissionRequest = { tool: 'readFile' }; + const d = decide(policy, limits, readReq); + assertEq(d.action, 'allow', 'readFile 无 rule → mode 兜底 allow'); + assertEq(d.source, 'mode', 'readFile 决策来源 = mode'); +} + +// ──────────── 用例 ⑤ 5 个未实装 profile 抛 ProfileNotImplementedError ──────────── + +async function testUnspawnableProfiles(): Promise { + section('⑤ 5 个未实装 profile spawn 抛 ProfileNotImplementedError'); + + const deps = makeSpawnDeps(); + for (const name of ['coder', 'writer', 'reviewer', 'security', 'tester']) { + let threw: unknown = null; + try { + for await (const _ev of spawn(name, { prompt: 'p' }, deps)) { + // 不应进入循环,throw 在 spawn 第一句就抛 + } + } catch (err) { + threw = err; + } + assert(threw instanceof ProfileNotImplementedError, + `${name} 抛 ProfileNotImplementedError`); + if (threw instanceof ProfileNotImplementedError) { + assert(threw.profileName === name, `错误的 profileName = ${name}`); + assert(threw.message.includes('未实装'), + `错误信息含「未实装」 (实际 ${threw.message})`); + } + } + + // 不存在的 profile → ProfileNotFoundError + let notFound: unknown = null; + try { + for await (const _ev of spawn('ghost', { prompt: 'p' }, deps)) { /* */ } + } catch (err) { + notFound = err; + } + assert(notFound instanceof ProfileNotFoundError, + '不存在 profile → ProfileNotFoundError'); + + // spawnCoordinator 包装后未实装仅 yield error 事件,不抛 + const events: SpawnEvent[] = []; + for await (const ev of spawnCoordinator('coder', { prompt: 'p' }, deps.baseDeps, { warn: () => {} })) { + events.push(ev); + } + const errEvent = events.find((e) => e.type === 'error'); + assert(errEvent !== undefined, 'spawnCoordinator 包装未实装 → emit error 事件而非抛'); + if (errEvent && errEvent.type === 'error') { + assert(errEvent.message.includes('未实装'), 'error.message 含「未实装」'); + } +} + +// ──────────── 用例 ⑥ slashHandler 解析 + 渲染 ──────────── + +function testSlashHandler(): void { + section('⑥ slashHandler parse / consult + research + render'); + + assertEq( + parseSlashCommand('/consult foo bar'), + { profileName: 'consultant', prompt: 'foo bar' }, + 'parseSlashCommand 识别 /consult', + ); + assertEq( + parseSlashCommand(' /research alice 编排'), + { profileName: 'researcher', prompt: 'alice 编排' }, + 'parseSlashCommand 识别 /research(忽略前导空格)', + ); + assert(parseSlashCommand('normal message') === null, + 'parseSlashCommand 非 slash 命令 → null'); + assert(parseSlashCommand('/unknown foo') === null, + 'parseSlashCommand 未知 slash → null'); + + const rendered = renderSpawnEvents('consultant', [ + { type: 'topic', topic: '议题 A', index: 1 }, + { type: 'topic', topic: '议题 B', index: 2 }, + { type: 'memory_hit', text: '记忆 X', score: 0.9 }, + { type: 'error', message: 'foo 错误' }, + { type: 'done', topics: ['议题 A', '议题 B'], memories: ['记忆 X'] }, + ]); + assert(rendered.includes('议题 A') && rendered.includes('议题 B'), + 'renderSpawnEvents 包含议题'); + assert(rendered.includes('记忆 X'), 'renderSpawnEvents 包含记忆'); + assert(rendered.includes('foo 错误'), 'renderSpawnEvents 包含错误'); + assert(rendered.includes('consultant'), 'renderSpawnEvents 标注来源 consultant'); +} + +// ──────────── 用例 ⑦ agentLoop /consult 路径分流 ──────────── + +async function testAgentLoopSlashDispatch(): Promise { + section('⑦ agentLoop /consult 路径分流:slash 命中后主流被跳过'); + + const warns: string[] = []; + const deps: AgentLoopDependencies = { + ...makeBaseDeps(), + logger: { + info: () => {}, + warn: (m: string, ...args: unknown[]) => warns.push(`${m} ${args.join(' ')}`), + error: () => {}, + debug: () => {}, + }, + spawnCoordinator: (function* () { + yield { type: 'topic', topic: 'A', index: 1 } as SpawnEvent; + yield { type: 'topic', topic: 'B', index: 2 } as SpawnEvent; + yield { type: 'done', topics: ['A', 'B'], memories: [] } as SpawnEvent; + }), + }; + + const events: RuntimeEvent[] = []; + for await (const ev of runAgentLoop({ message: '/consult 议题提炼' } as never, deps)) { + events.push(ev); + } + + const textDeltas = events.filter((e) => e.type === 'text_delta'); + assert(textDeltas.length >= 1, 'slash 命中后吐 text_delta'); + const done = events.find((e) => e.type === 'done'); + assert(done !== undefined, 'slash 命中后吐 done'); + // 没 model_selected / tool_finished(主流被跳过) + assert(!events.some((e) => e.type === 'model_selected'), + 'slash 命中 → 主流被跳过,无 model_selected'); + assert(!events.some((e) => e.type === 'tool_finished'), + 'slash 命中 → 主流被跳过,无 tool_finished'); + + // 非 slash 命令 → 主流照常走:必须给 LLM client 一个能调 chatStreamWithTools 的 stub + const nonSlashDeps: AgentLoopDependencies = { + ...deps, + spawnCoordinator: undefined, + getLLMClient: () => ({ + chat: async () => '', + chatStream: async function* () {}, + chatStreamWithTools: async function* () { yield 'hello'; }, + } as never), + }; + const nonSlashEvents: RuntimeEvent[] = []; + for await (const ev of runAgentLoop( + { message: 'normal question' } as never, + nonSlashDeps, + )) { + nonSlashEvents.push(ev); + } + assert(nonSlashEvents.some((e) => e.type === 'model_selected'), + '非 slash 命令 → 主流正常(model_selected 出现)'); +} + +// ──────────── 用例 ⑧ researcher 抛错不阻塞主对话 ──────────── + +async function testResearcherFailureNonBlocking(): Promise { + section('⑧ researcher 抛错仅记 warn,emit done,主对话可继续'); + + const warns: string[] = []; + const deps: AgentLoopDependencies = { + ...makeBaseDeps(async () => { throw new Error('memory dir missing'); }), + logger: { + info: () => {}, + warn: (m: string, ...args: unknown[]) => warns.push(`${m} ${args.join(' ')}`), + error: () => {}, + debug: () => {}, + }, + spawnCoordinator: (function* () { + // 模拟:researcher runner 内部 search 抛错 → emit 0 hit + done + yield { type: 'done', topics: [], memories: [] } as SpawnEvent; + }), + }; + + const events: RuntimeEvent[] = []; + for await (const ev of runAgentLoop( + { message: '/research foo' } as never, + deps, + )) { + events.push(ev); + } + + const done = events.find((e) => e.type === 'done'); + assert(done !== undefined, 'researcher 失败路径仍触发 done'); + // 主对话「可继续」= done 事件结构完整,messages 数组非空 + if (done && done.type === 'done') { + assert(done.messages.length >= 2, 'messages 含 user + assistant(slash 渲染文本)'); + } +} + +// ──────────── 用例 ⑨ concurrentAgentRunner 多 spec 聚合 ──────────── + +async function testConcurrentRunner(): Promise { + section('⑨ concurrentAgentRunner 多 spec 聚合 topics + memories'); + + const deps = makeSpawnDeps(async () => ['mem-1', 'mem-2']); + + // 混合 spawnable + 未实装:未实装的 emit error,spawnable 正常聚合 + const events: SpawnEvent[] = []; + for await (const ev of runAgents( + [ + { profileName: 'consultant', request: { prompt: 'foo' } }, + { profileName: 'researcher', request: { prompt: 'bar' } }, + { profileName: 'coder', request: { prompt: 'baz' } }, + { profileName: 'ghost', request: { prompt: 'x' } }, + ], + deps, + { concurrency: 2 }, + )) { + events.push(ev); + } + + const topics = events.filter((e) => e.type === 'topic').map((e) => (e as { topic: string }).topic); + const hits = events.filter((e) => e.type === 'memory_hit').map((e) => (e as { text: string }).text); + const errors = events.filter((e) => e.type === 'error').map((e) => (e as { message: string }).message); + const allDone = events.filter((e) => e.type === 'done') as Array<{ topics: string[]; memories: string[] }>; + const finalDone = allDone[allDone.length - 1]; + + assert(topics.length >= 1, `consultant 产议题 (${topics.length})`); + assert(hits.length >= 1, `researcher 产 memory_hit (${hits.length})`); + assert(errors.length === 2, `coder + ghost 各产 1 error (实际 ${errors.length})`); + assert(allDone.length >= 1, '末态 done 至少 1 条'); + if (finalDone) { + assert(finalDone.topics.length === topics.length, `final done.topics 与 topic 事件一致 (${finalDone.topics.length} vs ${topics.length})`); + assert(finalDone.memories.length === hits.length, `final done.memories 与 memory_hit 事件一致 (${finalDone.memories.length} vs ${hits.length})`); + } +} + +// ──────────── 主入口 ──────────── + +async function main(): Promise { + console.log('🧪 test-issue-007 — Coordinator 多 Agent 编排\n'); + + try { + testProfileRegistry(); + await testConsultantSpawn(); + await testResearcherSpawn(); + testPermissionGateProfile(); + await testUnspawnableProfiles(); + testSlashHandler(); + await testAgentLoopSlashDispatch(); + await testResearcherFailureNonBlocking(); + await testConcurrentRunner(); + } catch (err) { + console.error('uncaught:', err); + failures.push('uncaught: ' + (err instanceof Error ? err.message : String(err))); + failed++; + } + + console.log(`\n────────────────────────────`); + console.log(`PASS: ${passed} FAIL: ${failed}`); + if (failed > 0) { + console.log('\n失败明细:'); + failures.forEach((m) => console.log(` - ${m}`)); + process.exit(1); + } else { + process.exit(0); + } +} + +void main(); \ No newline at end of file diff --git a/test-case/test-list.md b/test-case/test-list.md index b116bd5..95c9a32 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -7,8 +7,8 @@ ## 全量回归 ```bash -# issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done +# issue 回归套件(当前基线 252 断言) +for t in 001 002 003 004 005 007 010 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -22,6 +22,7 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-004.ts` | Feature Flag + 构建期 DCE:flag 开关、GrowthBookLocal、acp-integration 剥离字节 0 | 构建/runtime feature(build.ts、runtime/feature) | issue #4(IK8MWJ)/ PR !5 | | `test-issue-005.ts` | Workspace Backend 收敛守卫:daemon 不得直接 import *Backend 实现(grep + tsc 两层) | workspace 解耦(daemon、runtime/workspace) | issue #5(IK8MWK)/ PR !6 | | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | +| `test-issue-007.ts` | Coordinator 多 Agent 编排:7 profile 注册 + 2 可 spawn(consultant/researcher) + /consult /research slash 分流 + permissionGate 按 profile 收敛 + researcher 失败不阻塞 | 多 Agent 编排(runtime/agent/coordinator) | issue #7(IK8MWM)/ PR !14 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | -- Gitee