@joekytc/dsh-swarm 0.3.11 → 0.5.0
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/client/index.ts +8 -4
- package/client/session-bridge.ts +26 -2
- package/lib/client.js +22 -2
- package/lib/dispatcher/agent-runner.js +119 -51
- package/lib/dispatcher/chain-auditor.d.ts +3 -2
- package/lib/dispatcher/chain-auditor.js +2 -2
- package/lib/dispatcher/session-events.d.ts +21 -0
- package/lib/dispatcher/session-events.js +49 -0
- package/lib/dispatcher/v-orchestrator.d.ts +2 -0
- package/lib/dispatcher/v-orchestrator.js +11 -9
- package/lib/domain/review-evidence.js +3 -2
- package/lib/index.js +6 -3
- package/lib/roles/preset-installer.d.ts +50 -5
- package/lib/roles/preset-installer.js +154 -20
- package/lib/routes/kanban-http.js +23 -1
- package/lib/services/llm-catalog.d.ts +18 -0
- package/lib/services/llm-catalog.js +41 -0
- package/package.json +22 -16
- package/personas/kanban-d/agent.cordis.yml +3 -1
- package/personas/kanban-dt/agent.cordis.yml +6 -1
- package/personas/kanban-p/agent.cordis.yml +1 -1
- package/personas/kanban-pt/agent.cordis.yml +1 -1
- package/personas/kanban-v/agent.cordis.yml +1 -1
- package/personas/kanban-w/agent.cordis.yml +1 -1
- package/personas/persona-dt.md +1 -0
- package/personas/swarm/agent.cordis.yml +1 -1
package/client/index.ts
CHANGED
|
@@ -15,14 +15,15 @@ declare module '@deepseek-ai/cordis' {
|
|
|
15
15
|
}
|
|
16
16
|
import { ConfigSection } from './ConfigSection.js';
|
|
17
17
|
import { SWARM_CONFIG_NS } from './config-store.js';
|
|
18
|
-
import { setSessionsService } from './session-bridge.js';
|
|
18
|
+
import { setSessionsService, setSessionNavigator } from './session-bridge.js';
|
|
19
19
|
import css from './kanban.css';
|
|
20
20
|
import configCss from './config.css';
|
|
21
21
|
|
|
22
22
|
export const name = 'kanban-board';
|
|
23
23
|
|
|
24
|
-
/** 所需 client 服务(cordis fiber inject——loader 把模块导出当作对象插件传入)。
|
|
25
|
-
|
|
24
|
+
/** 所需 client 服务(cordis fiber inject——loader 把模块导出当作对象插件传入)。
|
|
25
|
+
* uiWorkspace:0.1.7 起宿主内置的视图所有者导航(会话跳转新接缝),fiber inject 会等待其就绪。 */
|
|
26
|
+
export const inject = ['slots', 'sessions', 'uiWorkspace'];
|
|
26
27
|
|
|
27
28
|
/** 浏览器半入口(roster 行 id: kanban-board):把看板挂到 conversation.view(会话中心 tab,additive)。
|
|
28
29
|
* 对齐 DSH 原生注册:对话(id=chat, order=0) → 轨迹(id=trajectory, order=10) → 看板(id=kanban, order=20)。
|
|
@@ -32,6 +33,9 @@ export function apply(ctx: Context): (() => void) | void {
|
|
|
32
33
|
// cast:@deepseek-ai/dsh-session(服务端包)也 merge 了 cordis Context.sessions: SessionStore,
|
|
33
34
|
// 类型面被其覆盖;浏览器半运行时注入的实为 dsh-api-session-controller 的 ISessions。
|
|
34
35
|
setSessionsService(ctx.sessions as unknown as ISessions);
|
|
36
|
+
// 会话跳转导航:0.2.0+ 宿主提供 uiWorkspace(视图所有者),但注册晚于 client apply——
|
|
37
|
+
// 只传惰性 getter(点击时解析+try/catch 降级),apply 期绝不触碰属性,防 cordis 未就绪 throw 炸掉入口注册。
|
|
38
|
+
setSessionNavigator(() => (ctx as { uiWorkspace?: { openSession(target: string): void } }).uiWorkspace ?? null);
|
|
35
39
|
let style: HTMLStyleElement | null = null;
|
|
36
40
|
let configStyle: HTMLStyleElement | null = null;
|
|
37
41
|
if (typeof document !== 'undefined') {
|
|
@@ -62,5 +66,5 @@ export function apply(ctx: Context): (() => void) | void {
|
|
|
62
66
|
ConfigSection as never,
|
|
63
67
|
),
|
|
64
68
|
);
|
|
65
|
-
return () => { if (style) style.remove(); if (configStyle) configStyle.remove(); setSessionsService(null); };
|
|
69
|
+
return () => { if (style) style.remove(); if (configStyle) configStyle.remove(); setSessionsService(null); setSessionNavigator(null); };
|
|
66
70
|
}
|
package/client/session-bridge.ts
CHANGED
|
@@ -32,8 +32,32 @@ export function useSessionIds(): ReadonlySet<string> {
|
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/** 新宿主导航接缝:dsh 0.2.0 起 ISessions.open 移除,会话导航归视图所有者(uiWorkspace.openSession)。
|
|
36
|
+
* 宿主以 getter 惰性供给(uiWorkspace 注册晚于 client apply,apply 期属性访问会在 cordis 上 throw);
|
|
37
|
+
* 0.1.7 底线宿主无此服务,getter 返回 null 走旧 open 转发。 */
|
|
38
|
+
let navigator: (() => { openSession(target: string): void } | null) | null = null;
|
|
39
|
+
|
|
40
|
+
export function setSessionNavigator(next: (() => { openSession(target: string): void } | null) | null): void {
|
|
41
|
+
navigator = next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 应用内跳转到指定会话(调用方须先经 useSessionIds 门控,宿主对不在列表的 id fail loud)。 */
|
|
36
45
|
export function openSession(id: string): void {
|
|
46
|
+
// 优先 0.2.0+ 视图所有者导航;解析失败视同服务缺席;旧宿主退回 ISessions.open 转发;两者皆缺则响亮报错。
|
|
47
|
+
if (navigator) {
|
|
48
|
+
let viaWorkspace: { openSession(target: string): void } | null = null;
|
|
49
|
+
try {
|
|
50
|
+
viaWorkspace = navigator();
|
|
51
|
+
} catch {
|
|
52
|
+
viaWorkspace = null;
|
|
53
|
+
}
|
|
54
|
+
if (viaWorkspace) {
|
|
55
|
+
viaWorkspace.openSession(id);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
37
59
|
if (!service) throw new Error('sessions service unavailable');
|
|
38
|
-
|
|
60
|
+
const open = (service as { open?: (target: string) => void }).open;
|
|
61
|
+
if (!open) throw new Error('host exposes neither uiWorkspace nor sessions.open; session jump unavailable');
|
|
62
|
+
open(id);
|
|
39
63
|
}
|
package/lib/client.js
CHANGED
|
@@ -560,9 +560,27 @@ window.__ModuleLoader__.load({
|
|
|
560
560
|
() => EMPTY
|
|
561
561
|
);
|
|
562
562
|
}
|
|
563
|
+
var navigator = null;
|
|
564
|
+
function setSessionNavigator(next) {
|
|
565
|
+
navigator = next;
|
|
566
|
+
}
|
|
563
567
|
function openSession(id) {
|
|
568
|
+
if (navigator) {
|
|
569
|
+
let viaWorkspace = null;
|
|
570
|
+
try {
|
|
571
|
+
viaWorkspace = navigator();
|
|
572
|
+
} catch {
|
|
573
|
+
viaWorkspace = null;
|
|
574
|
+
}
|
|
575
|
+
if (viaWorkspace) {
|
|
576
|
+
viaWorkspace.openSession(id);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
564
580
|
if (!service) throw new Error("sessions service unavailable");
|
|
565
|
-
service.open
|
|
581
|
+
const open = service.open;
|
|
582
|
+
if (!open) throw new Error("host exposes neither uiWorkspace nor sessions.open; session jump unavailable");
|
|
583
|
+
open(id);
|
|
566
584
|
}
|
|
567
585
|
|
|
568
586
|
// client/BoardCard.tsx
|
|
@@ -2897,9 +2915,10 @@ window.__ModuleLoader__.load({
|
|
|
2897
2915
|
|
|
2898
2916
|
// client/index.ts
|
|
2899
2917
|
var name = "kanban-board";
|
|
2900
|
-
var inject = ["slots", "sessions"];
|
|
2918
|
+
var inject = ["slots", "sessions", "uiWorkspace"];
|
|
2901
2919
|
function apply(ctx) {
|
|
2902
2920
|
setSessionsService(ctx.sessions);
|
|
2921
|
+
setSessionNavigator(() => ctx.uiWorkspace ?? null);
|
|
2903
2922
|
let style = null;
|
|
2904
2923
|
let configStyle = null;
|
|
2905
2924
|
if (typeof document !== "undefined") {
|
|
@@ -2936,6 +2955,7 @@ window.__ModuleLoader__.load({
|
|
|
2936
2955
|
if (style) style.remove();
|
|
2937
2956
|
if (configStyle) configStyle.remove();
|
|
2938
2957
|
setSessionsService(null);
|
|
2958
|
+
setSessionNavigator(null);
|
|
2939
2959
|
};
|
|
2940
2960
|
}
|
|
2941
2961
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
2
2
|
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { probeOcr } from '../services/ocr-cli.js';
|
|
5
6
|
import { installRoleTools, buildReadOnlyWriteGuard, buildDTWriteGuard, buildPlanWriteGuard, buildKbWriteGuard, registerDtTaskChain, unregisterDtTaskChain } from '../roles/toolsets.js';
|
|
@@ -7,7 +8,8 @@ import { ensureLocalKbRoot } from '../wiki/local-kb.js';
|
|
|
7
8
|
import { installCleanFsTools } from '../roles/clean-fs-tools.js';
|
|
8
9
|
import { mountOrRecompose } from '../roles/preset-installer.js';
|
|
9
10
|
import { buildModelCandidates, isModelUnavailableError } from './model-candidates.js';
|
|
10
|
-
import { toolName, isGuardSynthesizedReply } from './session-events.js';
|
|
11
|
+
import { toolName, isGuardSynthesizedReply, lastTurnEnd } from './session-events.js';
|
|
12
|
+
import { filterCandidatesByCatalog } from '../services/llm-catalog.js';
|
|
11
13
|
import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
|
|
12
14
|
import { isPathInside, resolveTargetRepoDir } from './target-repo.js';
|
|
13
15
|
import { injectGitCredentials, resolveGitPatFromCtx } from './git-credentials.js';
|
|
@@ -21,6 +23,18 @@ const GOAL_MODE_KEYWORDS = ['/goal', '目标模式', 'goal mode'];
|
|
|
21
23
|
function isInfraError(err) {
|
|
22
24
|
return /cannot prepare session|while it is live|timeout|ETIMEDOUT|ECONNREFUSED|ECONNRESET|socket/i.test(String(err));
|
|
23
25
|
}
|
|
26
|
+
/** turn 级错误分类(环境中止 ≠ 角色不收敛):
|
|
27
|
+
* - 'config':provider/model/effort 组合性永久错误(重派必撞同一错误)→ 终态 block(model-unavailable) 等人工修配置;
|
|
28
|
+
* - 'transient':配额/限流/网络类瞬时错误 → failTask({infra:true})(不计 attempts,靠调度 tick 重派);
|
|
29
|
+
* - 'unknown':未识别错误 → failTask 计入 attempts(有界重试,防未知错误无限重派)。
|
|
30
|
+
* 三种去向都不写 protocol_violation、不烧 PV 护栏预算——环境错误不是角色协议违规。 */
|
|
31
|
+
function classifyTurnError(code, message) {
|
|
32
|
+
if (code === 'UNKNOWN_MODEL' || code === 'UNSUPPORTED_REASONING_EFFORT' || code === 'INVALID_REQUEST')
|
|
33
|
+
return 'config';
|
|
34
|
+
if (code === 'QUOTA' || /quota|rate.?limit|\b429\b/i.test(message) || isInfraError(message))
|
|
35
|
+
return 'transient';
|
|
36
|
+
return 'unknown';
|
|
37
|
+
}
|
|
24
38
|
/** 关键事件追加落盘 storageDir/dispatcher.log(与 dispatcher.ts 同一文件、同一行格式)——
|
|
25
39
|
* agent-runner 未注入 logFile,按 dispatcher 同款方式经 configProvider 派生 storageDir;
|
|
26
40
|
* 写失败静默忽略(日志只是观测面,事件日志才是事实源)。 */
|
|
@@ -253,7 +267,7 @@ ${task.body}`);
|
|
|
253
267
|
(task.attempts > 0 || state.events.some((e) => e.taskId === taskId && e.kind === 'task/claimed'));
|
|
254
268
|
let agent;
|
|
255
269
|
let context = '';
|
|
256
|
-
const setup = async (agentCtx) => {
|
|
270
|
+
const setup = async (agentCtx, agent) => {
|
|
257
271
|
// 思考等级强制(waterfall):宿主 selection 无 create-options 覆盖层(dsh-host-apiproxy 的
|
|
258
272
|
// selectionFor 不消费 agentOptions),AgentOptions 也仅有 provider/model/maxTokens——agentOptions
|
|
259
273
|
// 里的 reasoningEffort 不被宿主消费,新建角色会话思考等级会落回宿主默认。改走 DSH agent/request
|
|
@@ -270,7 +284,8 @@ ${task.body}`);
|
|
|
270
284
|
// P/W/D = full access(跨目录读:P 读仓库/外部实证、W 读计划、D 执行);
|
|
271
285
|
// 但 P 挂 plan 写护栏、W 挂只读护栏(写边界由工具级强制,防"改动源码",不靠 prompt 软约束);
|
|
272
286
|
// PT/DT/V → workspace-write(评审/编排最小权限,PT/DT 只读护栏由 ToolGuard 独立保证)。
|
|
273
|
-
|
|
287
|
+
// approval/sandbox 种子事件经 AgentSetup 第二参的 agent 显式直写(0.2.0 起 ctx.agent 挂载已移除)。
|
|
288
|
+
const session = agent?.session;
|
|
274
289
|
session?.append?.('approval/policy', { policy: 'never', source: 'delegation' });
|
|
275
290
|
const fullAccess = isDExecute || task.assignee === 'p' || task.assignee === 'w';
|
|
276
291
|
session?.append?.('sandbox/mode', fullAccess ? { mode: 'danger-full-access', source: 'delegation' } : { mode: 'workspace-write', source: 'delegation' });
|
|
@@ -305,16 +320,13 @@ ${task.body}`);
|
|
|
305
320
|
// PT/DT 是 workspace-write(sandbox_permissions 扩权到天花板是合法功能,剥了破坏官方能力);
|
|
306
321
|
// W 虽 fullAccess 但只读护栏全拒 write,shadow 无意义(最小挂载面,V 无执行工具不涉及)。
|
|
307
322
|
if (task.assignee === 'p' || isDExecute) {
|
|
308
|
-
installCleanFsTools(agentCtx,
|
|
323
|
+
installCleanFsTools(agentCtx, agent);
|
|
309
324
|
}
|
|
310
325
|
// 组合标记——角色工具面成功组合后,把 { role, taskId } 记到进程内 WeakMap(键=Agent 实例)。
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const liveAgent = agentCtx.agent;
|
|
316
|
-
if (liveAgent)
|
|
317
|
-
markRoleComposition(liveAgent, { role: task.assignee, taskId: task.id });
|
|
326
|
+
// AgentSetup 第二参收到的 Agent 与发布后 agents.get(id) 返回的是同一实例,故此处键入的实例
|
|
327
|
+
// 即 resumeOrReuse 里 agents.get 命中的实例(live 复用走标记匹配,能力无损)。
|
|
328
|
+
if (agent)
|
|
329
|
+
markRoleComposition(agent, { role: task.assignee, taskId: task.id });
|
|
318
330
|
// 只读评审角色(PT/DT)注册 ToolGuard:拦截 tracked source 写入 / git mutation / 含写标记 bash。
|
|
319
331
|
// 以 dsh-tools 类型为准:tools.guard(execution => reason|undefined),execution.name/arguments 为实际字段。
|
|
320
332
|
if (task.assignee === 'pt' || task.assignee === 'dt') {
|
|
@@ -380,7 +392,35 @@ ${task.body}`);
|
|
|
380
392
|
context = this.buildContext(task, state, hasRunHistory);
|
|
381
393
|
// 模型候选链:primary + fallbacks,model/provider 不可用时静默切换下一候选;
|
|
382
394
|
// 全部候选不可用 → block(model-unavailable) 抛给用户;非 model 错误 → failTask(原逻辑)。
|
|
383
|
-
const
|
|
395
|
+
const builtCandidates = buildModelCandidates(this.configProvider.getEffective(), task.assignee, this.defaultModel);
|
|
396
|
+
// 派发前预校验(LLM 目录):不存在的 provider/model 组合、模型未声明的 reasoningEffort
|
|
397
|
+
// 直接剔除,不再打进网络白烧会话(UNKNOWN_MODEL / UNSUPPORTED_REASONING_EFFORT 秒退实证形态——
|
|
398
|
+
// 候选 fallback 只护 create/resume 抛错,turn 期错误不触发切换,必须前置拦截)。
|
|
399
|
+
// fail-open:llm 服务未就绪/目录探测失败 → 全放行,不阻断派发。
|
|
400
|
+
// 全部候选被拒 → block(model-unavailable) 终态(与 spawn 全候选失败同语义,等人工修配置)。
|
|
401
|
+
let candidates = builtCandidates;
|
|
402
|
+
if (builtCandidates.length > 0) {
|
|
403
|
+
// llm 服务可能尚未接线(runner 只依赖 agents+kanban 就绪)——缺失/异常一律 fail-open。
|
|
404
|
+
let llmRuntime;
|
|
405
|
+
try {
|
|
406
|
+
llmRuntime = this.ctx.get('llm');
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
llmRuntime = undefined;
|
|
410
|
+
}
|
|
411
|
+
const precheck = await filterCandidatesByCatalog(llmRuntime, builtCandidates);
|
|
412
|
+
if (precheck.rejected.length > 0) {
|
|
413
|
+
const detail = precheck.rejected.map((r) => `${r.candidate.provider}/${r.candidate.model}: ${r.reason}`).join(';');
|
|
414
|
+
console.error('[dsh-swarm][debug] model precheck rejected task=' + taskId + ': ' + detail);
|
|
415
|
+
logToDispatcherLog(this.configProvider, '[model-precheck] task=' + taskId + ' rejected: ' + detail);
|
|
416
|
+
}
|
|
417
|
+
if (precheck.ok.length === 0) {
|
|
418
|
+
await this.kanban.blockTask(taskId, 'model-unavailable: 配置的模型候选与 LLM 目录不匹配 —— '
|
|
419
|
+
+ precheck.rejected.map((r) => `${r.candidate.provider}/${r.candidate.model}: ${r.reason}`).join(';'), 'system');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
candidates = precheck.ok;
|
|
423
|
+
}
|
|
384
424
|
if (candidates.length === 0) {
|
|
385
425
|
// 无任何候选配置:不传 agentOptions(用部署默认),单次尝试
|
|
386
426
|
agent = hasRunHistory
|
|
@@ -451,7 +491,7 @@ ${task.body}`);
|
|
|
451
491
|
const attachId = task.resumeSessionId ?? `kbn-${task.id}`;
|
|
452
492
|
await attachSessionToWorkspace(this.ctx, attachId, sessionCwd, 'task ' + task.id + ' ' + task.assignee + '/' + task.mode);
|
|
453
493
|
try {
|
|
454
|
-
agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
494
|
+
agent.followup({ id: randomUUID(), content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
455
495
|
console.error('[dsh-swarm][debug] runner followup sent ' + taskId);
|
|
456
496
|
await agent.whenIdle();
|
|
457
497
|
console.error('[dsh-swarm][debug] runner whenIdle resolved ' + taskId);
|
|
@@ -469,7 +509,7 @@ ${task.body}`);
|
|
|
469
509
|
: (sessionLike.events ?? []);
|
|
470
510
|
if (turnEvents.filter((e) => (Number(e?.seq) || 0) > eventsBase).some(isGuardSynthesizedReply)) {
|
|
471
511
|
console.error('[dsh-swarm][debug] runner guard-synthesized reply detected (from-cache/from-security-guard) ' + taskId + ' — auto followup retry once');
|
|
472
|
-
agent.followup({ content: [{ type: 'text', text: '继续未完成的任务:上一条回复是网关安全护栏/语义缓存误拦的合成拒答,并非你的真实结论。请忽略它,从中断处继续执行任务,直至按协议收敛终态(kanban_complete 或 kanban_block)。' }], source: { kind: 'user' } });
|
|
512
|
+
agent.followup({ id: randomUUID(), content: [{ type: 'text', text: '继续未完成的任务:上一条回复是网关安全护栏/语义缓存误拦的合成拒答,并非你的真实结论。请忽略它,从中断处继续执行任务,直至按协议收敛终态(kanban_complete 或 kanban_block)。' }], source: { kind: 'user' } });
|
|
473
513
|
console.error('[dsh-swarm][debug] runner guard-retry followup sent ' + taskId);
|
|
474
514
|
await agent.whenIdle();
|
|
475
515
|
console.error('[dsh-swarm][debug] runner guard-retry whenIdle resolved ' + taskId);
|
|
@@ -488,43 +528,70 @@ ${task.body}`);
|
|
|
488
528
|
console.error('[dsh-swarm][debug] runner skip block ' + taskId + ' status=' + (cur ? cur.status : 'gone'));
|
|
489
529
|
}
|
|
490
530
|
else {
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
531
|
+
// 环境中止识别:读本轮最后一轮 turn/end(拒答重试会产生第二轮,取 seq 最大者)。
|
|
532
|
+
// whenIdle resolve 只说明会话空闲,不区分「正常收敛」与「模型/网关错误秒退」——
|
|
533
|
+
// 后者按原判据会误记 protocol_violation(UNKNOWN_MODEL 误判实证:claim→block 1.3s、
|
|
534
|
+
// 0 次模型调用、0 token)。turn 以 error 结束 → 按 classifyTurnError 分流
|
|
535
|
+
// (config → block(model-unavailable) 终态;transient → failTask(infra) 靠 tick 重派;
|
|
536
|
+
// unknown → failTask 计预算有界重试),一律不写 protocol_violation、不烧 PV 预算。
|
|
537
|
+
// 只有 turn 正常 completed(或无 turn 事件的测试桩/宿主变体兜底)才进入下方协议违规
|
|
538
|
+
// 判据——PV 从此只剩真实语义:模型正常收敛但未调 terminal 工具(comment-only 等)。
|
|
539
|
+
const finalTurnEvents = typeof sessionLike.snapshotEvents === 'function'
|
|
540
|
+
? sessionLike.snapshotEvents(eventsBase)
|
|
541
|
+
: (sessionLike.events ?? []);
|
|
542
|
+
const turnInfo = lastTurnEnd(finalTurnEvents, eventsBase);
|
|
543
|
+
if (turnInfo?.kind === 'error') {
|
|
544
|
+
const cls = classifyTurnError(turnInfo.code, turnInfo.message ?? '');
|
|
545
|
+
const detail = (turnInfo.code ?? 'UNKNOWN') + ': ' + (turnInfo.message ?? 'unknown turn error');
|
|
546
|
+
console.error('[dsh-swarm][debug] turn abort(' + cls + ') ' + taskId + ': ' + detail);
|
|
547
|
+
logToDispatcherLog(this.configProvider, '[turn-abort] task=' + taskId + ' ' + cls
|
|
548
|
+
+ (cls === 'config' ? ' → blocked model-unavailable: ' : ' → failed(infra=' + (cls === 'transient') + '): ') + detail);
|
|
549
|
+
if (cls === 'config') {
|
|
550
|
+
await this.kanban.blockTask(taskId, 'model-unavailable: turn aborted (' + detail + ')', 'system');
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
await this.kanban.failTask(taskId, 'turn-abort: ' + detail, 'system', { infra: cls === 'transient' });
|
|
554
|
+
}
|
|
508
555
|
}
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
556
|
+
else {
|
|
557
|
+
// 协议违规护栏:连续 protocol_violation 阻塞 ≥ maxProtocolViolations(默认 2)后,
|
|
558
|
+
// 下一次违规直接 gave_up(不再恢复,走 [blocked-final] 证据链抛给主 agent)。任意角色(含 pt/dt)统一。
|
|
559
|
+
const maxPV = this.configProvider.getEffective().dispatcher?.maxProtocolViolations ?? 2;
|
|
560
|
+
const priorViolations = fresh.events.filter((e) => e.taskId === taskId && e.kind === 'task/blocked' &&
|
|
561
|
+
String(e.payload['reason'] ?? '').startsWith('protocol_violation')).length;
|
|
562
|
+
const finalBlock = priorViolations >= maxPV;
|
|
563
|
+
const reason = finalBlock
|
|
564
|
+
? 'gave_up: protocol_violation after ' + maxPV + ' review cycles without complete/block'
|
|
565
|
+
: 'protocol_violation: idle without complete/block';
|
|
566
|
+
await this.kanban.blockTask(taskId, reason, 'system');
|
|
567
|
+
// comment-only 收尾显形——放 block 之后(保证 block 一定先发生,
|
|
568
|
+
// 告警只追加在已阻塞卡上,不影响终态判据与 [blocked-final] 证据链快照)。
|
|
569
|
+
// 本轮增量事件命中 kanban_comment(经 toolName 兼容读取落盘/live 两形态)且未成功提交
|
|
570
|
+
// (能走到这里即终态判据已确认非确定态)→ 交付内容很可能滞留 comments,显形告警。
|
|
571
|
+
const commentOnly = agent.session.snapshotEvents(eventsBase).some((e) => toolName(e) === 'kanban_comment');
|
|
572
|
+
if (commentOnly) {
|
|
573
|
+
await this.kanban.comment(taskId, '[comment-only-closeout] 本轮仅用 kanban_comment 交付(无 complete/block)。交付内容可能滞留 comments(如角色工具缺失/会话被错误复用);请人工核对 comments 与产物后解除阻塞重派。', 'system');
|
|
574
|
+
}
|
|
575
|
+
if (finalBlock) {
|
|
576
|
+
// [blocked-final] 证据链:block 时间线 + 复核/评论时间线 + 最终 reason(system 确定性写入)
|
|
577
|
+
const evs = fresh.events.filter((e) => e.taskId === taskId);
|
|
578
|
+
const blockTimeline = evs
|
|
579
|
+
.filter((e) => e.kind === 'task/blocked')
|
|
580
|
+
.map((e) => ` - seq=${e.seq} at=${e.at} author=${e.author} reason=${String(e.payload['reason'] ?? '')}`)
|
|
581
|
+
.join('\n');
|
|
582
|
+
const reviewTimeline = evs
|
|
583
|
+
.filter((e) => e.kind === 'task/commented')
|
|
584
|
+
.map((e) => ` - seq=${e.seq} at=${e.at} author=${e.author}: ${String(e.payload['body'] ?? '')}`)
|
|
585
|
+
.join('\n');
|
|
586
|
+
await this.kanban.comment(taskId, [
|
|
587
|
+
'[blocked-final] 协议违规超护栏,任务不再自动恢复(人工解除后仍按 gave_up 终态处理)。',
|
|
588
|
+
'## block 时间线',
|
|
589
|
+
blockTimeline,
|
|
590
|
+
'## 复核/评论时间线',
|
|
591
|
+
reviewTimeline || ' - (无复核评论)',
|
|
592
|
+
'最终原因: ' + reason,
|
|
593
|
+
].join('\n'), 'system');
|
|
594
|
+
}
|
|
528
595
|
}
|
|
529
596
|
}
|
|
530
597
|
}
|
|
@@ -582,7 +649,8 @@ ${task.body}`);
|
|
|
582
649
|
// GUI 默认组合 incarnation:缺 kanban_complete → 重跑 setup 幂等补挂后复用。
|
|
583
650
|
// setup 对 live ctx 的各步均可加:effort waterfall/approval+sandbox append(known 事件类型、
|
|
584
651
|
// latest-wins)/preset mount(bindings 覆盖)/角色工具(此前为默认组合,无同名冲突)/护栏。
|
|
585
|
-
|
|
652
|
+
// 双参签名(0.2.0):repair 重跑 setup 时显式传 live agent 实例(标记随修复自愈)。
|
|
653
|
+
await opts.setup(liveCtx, live);
|
|
586
654
|
if (toolsSvc.get('kanban_complete', live))
|
|
587
655
|
return live; // 修复后必须验证到位,防静默半修复
|
|
588
656
|
throw new Error('live session composition repair failed: kanban_complete still missing after re-setup (session ' + sessionId + ')');
|
|
@@ -11,8 +11,9 @@ import type { AuditEvidence, Task } from '../domain/types.js';
|
|
|
11
11
|
* a. 作用域收窄:仅扫描会话工作区(session.header.cwd)位于本链发起工作区
|
|
12
12
|
* (Chain.workspaceDir = /plan: 主 agent 所在工作空间)内的会话,
|
|
13
13
|
* 排除其他项目的主会话(如 评估 项目里调试 dsh-swarm 的会话);
|
|
14
|
-
* b. 行为判定:run_code
|
|
15
|
-
*
|
|
14
|
+
* b. 行为判定:run_code 按实际派发子调用(新旧宿主双名 tool/code-dispatch[-start] /
|
|
15
|
+
* tool/ptc-dispatch[-start],经 rootCallId 关联外层调用)判定是否真的发生写,而非把
|
|
16
|
+
* run_code 一律视为写;
|
|
16
17
|
* c. 只读排除:bash 命令 / 兜底 code 字符串仅当含写操作标记(BASH_WRITE_RE)且
|
|
17
18
|
* 含 workspacesRoot 路径时才计为写证据;纯只读排查(ls/cat/glob/read/grep)不产生证据。
|
|
18
19
|
* 2. 产物归属核对(fallback,机械可测):枚举 workspaces/<chainId>/ 下条目;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/dispatcher/chain-auditor.ts
|
|
2
2
|
import { readdirSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { eventType, toolArgs, toolName } from './session-events.js';
|
|
4
|
+
import { eventType, isRunCodeDispatchEvent, toolArgs, toolName } from './session-events.js';
|
|
5
5
|
import { isPathInside } from './target-repo.js';
|
|
6
6
|
import { headerPresetOf } from './session-preset.js';
|
|
7
7
|
/** 直接写工具(无条件视为写能力;只读工具如 read/glob/grep 不算)。 */
|
|
@@ -113,7 +113,7 @@ function sessionWriteEvidence(id, workspacesRoot, events) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
else if (
|
|
116
|
+
else if (isRunCodeDispatchEvent(ev)) {
|
|
117
117
|
const rootCallId = String(dataField(ev, 'rootCallId') ?? '');
|
|
118
118
|
const name = String(dataField(ev, 'name') ?? '');
|
|
119
119
|
if (!rootCallId || !name)
|
|
@@ -18,6 +18,13 @@ export declare function eventType(e: {
|
|
|
18
18
|
export declare function toolName(e: unknown): string | undefined;
|
|
19
19
|
/** 取工具调用参数对象(兼容 JSON 字符串 / 对象两种落盘形态)。 */
|
|
20
20
|
export declare function toolArgs(e: unknown): Record<string, unknown>;
|
|
21
|
+
/** run_code 派发子调用事件名(start 与完成两种)新旧宿主双名集合:
|
|
22
|
+
* 旧宿主会话落盘为 tool/code-dispatch-start / tool/code-dispatch;
|
|
23
|
+
* 宿主 V3+ 会话格式改名为 tool/ptc-dispatch-start / tool/ptc-dispatch。
|
|
24
|
+
* 消费方(链审计器按派发子调用判定 run_code 实际写行为)双名等价命中,勿散落硬编码。 */
|
|
25
|
+
export declare const RUN_CODE_DISPATCH_EVENT_TYPES: ReadonlySet<string>;
|
|
26
|
+
/** 该事件是否为 run_code 派发子调用事件(start 或完成,新旧事件名皆认)。 */
|
|
27
|
+
export declare function isRunCodeDispatchEvent(e: unknown): boolean;
|
|
21
28
|
/** 取 assistant 消息的网关缓存回放标记(replayState.response.responseModel,如 'from-cache')。
|
|
22
29
|
* 非 assistant/message 或无标记返回 null。2026-09-04:远程网关回复缓存整包回放时由 SSE
|
|
23
30
|
* id/model 字段透传(dsh 纯透传),插件侧据此识别缓存劫持零产出。兼容落盘(data.message)
|
|
@@ -30,3 +37,17 @@ export declare function replayModel(e: unknown): string | null;
|
|
|
30
37
|
export declare const GUARD_SYNTH_REPLAY_MODELS: ReadonlySet<string>;
|
|
31
38
|
/** 该事件是否为网关合成拒答/缓存回放的 assistant 消息(判定与标记集单一事实源)。 */
|
|
32
39
|
export declare function isGuardSynthesizedReply(e: unknown): boolean;
|
|
40
|
+
/** turn/end 结束原因(区分「环境错误秒退」与「角色正常收敛」的单一读取点):
|
|
41
|
+
* 落盘形态 {"type":"turn/end","seq":N,"data":{"turn":T,"reason":{"kind":"completed"} |
|
|
42
|
+
* {"kind":"error","error":{"code":"UNKNOWN_MODEL","message":"…"}}}};
|
|
43
|
+
* live 顶层展开形态经顶层 turn/reason 兜底兼容。非 turn/end 或 reason 不可读 → null。 */
|
|
44
|
+
export interface TurnEndInfo {
|
|
45
|
+
turn: number;
|
|
46
|
+
kind: string;
|
|
47
|
+
code: string | null;
|
|
48
|
+
message: string | null;
|
|
49
|
+
}
|
|
50
|
+
export declare function turnEndOf(e: unknown): TurnEndInfo | null;
|
|
51
|
+
/** 本轮增量(seq > fromSeq)内最后一轮 turn/end——拒答重试会产生第二轮,取 seq 最大者。
|
|
52
|
+
* 无 turn/end(测试桩/宿主变体)→ null,调用方按「无轮信息」回退原判据,不改变既有行为。 */
|
|
53
|
+
export declare function lastTurnEnd(events: ReadonlyArray<unknown>, fromSeq: number): TurnEndInfo | null;
|
|
@@ -35,6 +35,21 @@ export function toolArgs(e) {
|
|
|
35
35
|
}
|
|
36
36
|
return {};
|
|
37
37
|
}
|
|
38
|
+
/** run_code 派发子调用事件名(start 与完成两种)新旧宿主双名集合:
|
|
39
|
+
* 旧宿主会话落盘为 tool/code-dispatch-start / tool/code-dispatch;
|
|
40
|
+
* 宿主 V3+ 会话格式改名为 tool/ptc-dispatch-start / tool/ptc-dispatch。
|
|
41
|
+
* 消费方(链审计器按派发子调用判定 run_code 实际写行为)双名等价命中,勿散落硬编码。 */
|
|
42
|
+
export const RUN_CODE_DISPATCH_EVENT_TYPES = new Set([
|
|
43
|
+
'tool/code-dispatch-start',
|
|
44
|
+
'tool/code-dispatch',
|
|
45
|
+
'tool/ptc-dispatch-start',
|
|
46
|
+
'tool/ptc-dispatch',
|
|
47
|
+
]);
|
|
48
|
+
/** 该事件是否为 run_code 派发子调用事件(start 或完成,新旧事件名皆认)。 */
|
|
49
|
+
export function isRunCodeDispatchEvent(e) {
|
|
50
|
+
const t = eventType(e);
|
|
51
|
+
return t !== undefined && RUN_CODE_DISPATCH_EVENT_TYPES.has(t);
|
|
52
|
+
}
|
|
38
53
|
/** 取 assistant 消息的网关缓存回放标记(replayState.response.responseModel,如 'from-cache')。
|
|
39
54
|
* 非 assistant/message 或无标记返回 null。2026-09-04:远程网关回复缓存整包回放时由 SSE
|
|
40
55
|
* id/model 字段透传(dsh 纯透传),插件侧据此识别缓存劫持零产出。兼容落盘(data.message)
|
|
@@ -55,3 +70,37 @@ export function isGuardSynthesizedReply(e) {
|
|
|
55
70
|
const m = replayModel(e);
|
|
56
71
|
return m !== null && GUARD_SYNTH_REPLAY_MODELS.has(m);
|
|
57
72
|
}
|
|
73
|
+
export function turnEndOf(e) {
|
|
74
|
+
const rec = e;
|
|
75
|
+
const t = rec?.type ?? rec?.data?.type;
|
|
76
|
+
if (t !== 'turn/end')
|
|
77
|
+
return null;
|
|
78
|
+
const reason = rec?.data?.reason ?? rec?.reason;
|
|
79
|
+
const kind = typeof reason?.kind === 'string' ? reason.kind : null;
|
|
80
|
+
if (!kind)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
83
|
+
turn: Number(rec?.data?.turn ?? rec?.turn) || 0,
|
|
84
|
+
kind,
|
|
85
|
+
code: typeof reason?.error?.code === 'string' ? reason.error.code : null,
|
|
86
|
+
message: typeof reason?.error?.message === 'string' ? reason.error.message : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** 本轮增量(seq > fromSeq)内最后一轮 turn/end——拒答重试会产生第二轮,取 seq 最大者。
|
|
90
|
+
* 无 turn/end(测试桩/宿主变体)→ null,调用方按「无轮信息」回退原判据,不改变既有行为。 */
|
|
91
|
+
export function lastTurnEnd(events, fromSeq) {
|
|
92
|
+
let best = null;
|
|
93
|
+
let bestSeq = -1;
|
|
94
|
+
for (const e of events) {
|
|
95
|
+
const rec = e;
|
|
96
|
+
const seq = Number(rec?.seq ?? rec?.data?.seq) || 0;
|
|
97
|
+
if (seq <= fromSeq)
|
|
98
|
+
continue;
|
|
99
|
+
const info = turnEndOf(e);
|
|
100
|
+
if (info && seq >= bestSeq) {
|
|
101
|
+
best = info;
|
|
102
|
+
bestSeq = seq;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
@@ -34,7 +34,9 @@ export declare function buildPhaseInstruction(phase: VPhase, ctx: {
|
|
|
34
34
|
taskId?: string;
|
|
35
35
|
}, kbMode: 'remote' | 'local'): string;
|
|
36
36
|
interface AgentLike {
|
|
37
|
+
/** 宿主 UserMessage 要求 identified(id 必填):缺 id 的消息会被 v4 会话校验拒读。 */
|
|
37
38
|
followup(msg: {
|
|
39
|
+
id: string;
|
|
38
40
|
content: {
|
|
39
41
|
type: string;
|
|
40
42
|
text: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { installRoleTools } from '../roles/toolsets.js';
|
|
2
3
|
import { mountOrRecompose } from '../roles/preset-installer.js';
|
|
3
4
|
import { resolveTaskParents } from '../domain/task-parents.js';
|
|
@@ -64,7 +65,7 @@ export const PHASE_INSTRUCTIONS = {
|
|
|
64
65
|
].join('\n'),
|
|
65
66
|
dt: [
|
|
66
67
|
'## DT 阶段任务体要求(实现校验+评审,只读护栏)',
|
|
67
|
-
'body 写入实现校验指令:对 D 产物实证校验(test/build/typecheck/diff/git 证据 + open-code-review 评审),输出 verdict+issues 入交接 metadata.review_evidence。评审目标为 D 交接 metadata.branch 指向的 feature 分支,而非 TARGET_BRANCH。评审引擎:用 ocr_review 工具按配置引擎评审——委托 = preview→rule→git diff 自评;托管 = managed 一次出 findings;托管未配置时按工具指引静默改走委托;ocr 未安装则 kanban_block(\'review-tool-unavailable\') 并注明可在 GUI 配置面板安装。ocr_review 的 repo 参数缺省 = 会话工作目录;评审目标不在其内(D 的 worktree / 其他仓库)时须显式传仓库绝对路径(取 D 交接 metadata.worktree_dir 或规格卡 file-prefetch 附件 ref)。',
|
|
68
|
+
'body 写入实现校验指令:对 D 产物实证校验(test/build/typecheck/diff/git 证据 + open-code-review 评审),输出 verdict+issues 入交接 metadata.review_evidence。review_evidence.test 必须带两键:exit(数字 0,键名 exit 非 exit_code)与 runner(固定 \'vitest\',与 D 卡 gate 实测口径一致)——评审闸机械校验,键名/取值错一处即拒绝 complete。评审目标为 D 交接 metadata.branch 指向的 feature 分支,而非 TARGET_BRANCH。评审引擎:用 ocr_review 工具按配置引擎评审——委托 = preview→rule→git diff 自评;托管 = managed 一次出 findings;托管未配置时按工具指引静默改走委托;ocr 未安装则 kanban_block(\'review-tool-unavailable\') 并注明可在 GUI 配置面板安装。ocr_review 的 repo 参数缺省 = 会话工作目录;评审目标不在其内(D 的 worktree / 其他仓库)时须显式传仓库绝对路径(取 D 交接 metadata.worktree_dir 或规格卡 file-prefetch 附件 ref)。',
|
|
68
69
|
'铁律:DT 是只读校验+评审角色,绝不修改源码/产物;校验经 ToolGuard 硬性只读护栏;不注入 git 凭据。',
|
|
69
70
|
].join('\n'),
|
|
70
71
|
w3: [
|
|
@@ -89,7 +90,7 @@ export function buildPhaseInstruction(phase, ctx, kbMode) {
|
|
|
89
90
|
case 'd':
|
|
90
91
|
return '先读父任务交接(W2)里的 page_path(本地库 wiki/sources/ 下相对路径,库根见运行时上下文注入——Task 8 已注入 D),直接 fs 读实施计划原文;如需 KB 经验召回,经 skill 工具加载 llm-wiki query(本地库;kanban-d preset 已挂 tool-skill)——再按计划执行规格卡 solution/testing —— git worktree/branch → 改代码/README → git commit → git push(仅 feature 分支,可选)→ 自检并附产物证据(changed_files/commit_hash)。其余 TDD/TARGET_REPO/TARGET_BRANCH 等要求与 remote 版一致。';
|
|
91
92
|
case 'dt':
|
|
92
|
-
return `对 D 产物实证校验(test/build/typecheck/diff/git 证据 + open-code-review 评审),输出 verdict+issues 入交接 metadata.review_evidence。评审引擎:用 ocr_review 工具按配置引擎评审(委托 = preview→rule→git diff 自评;托管 = managed 一次出 findings;托管未配置时按工具指引静默改走委托;ocr 未安装则 kanban_block('review-tool-unavailable') 并注明可在 GUI 配置面板安装)。ocr_review 的 repo 缺省 = 会话工作目录,评审目标不在其内(D 的 worktree / 其他仓库)时显式传仓库绝对路径(D 交接 metadata.worktree_dir / 规格卡 file-prefetch ref)。评审页经 fs 写本地库 <库根>/wiki/queries/${ctx.chainId}/review/<name>.md(库根已由系统注入 DT 运行时上下文);库根外一律只读,其余铁律与 remote 版一致。`;
|
|
93
|
+
return `对 D 产物实证校验(test/build/typecheck/diff/git 证据 + open-code-review 评审),输出 verdict+issues 入交接 metadata.review_evidence。review_evidence.test 必须带两键:exit(数字 0,键名 exit 非 exit_code)与 runner(固定 'vitest')——评审闸机械校验,键名/取值错一处即拒绝 complete。评审引擎:用 ocr_review 工具按配置引擎评审(委托 = preview→rule→git diff 自评;托管 = managed 一次出 findings;托管未配置时按工具指引静默改走委托;ocr 未安装则 kanban_block('review-tool-unavailable') 并注明可在 GUI 配置面板安装)。ocr_review 的 repo 缺省 = 会话工作目录,评审目标不在其内(D 的 worktree / 其他仓库)时显式传仓库绝对路径(D 交接 metadata.worktree_dir / 规格卡 file-prefetch ref)。评审页经 fs 写本地库 <库根>/wiki/queries/${ctx.chainId}/review/<name>.md(库根已由系统注入 DT 运行时上下文);库根外一律只读,其余铁律与 remote 版一致。`;
|
|
93
94
|
default:
|
|
94
95
|
return PHASE_INSTRUCTIONS[phase] ?? '';
|
|
95
96
|
}
|
|
@@ -266,7 +267,7 @@ export class VOrchestrator {
|
|
|
266
267
|
'gave_up 任务说明链路已停止,建议查看对应 [blocked-final] 证据链(block 时间线 + 复核/评论时间线 + 最终原因),给出终态解释。',
|
|
267
268
|
'规则:只评论、不建卡、不改任务状态;已有 [blocked-review] 评论的任务不要重复评论。',
|
|
268
269
|
].join('\n\n');
|
|
269
|
-
agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
270
|
+
agent.followup({ id: randomUUID(), content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
270
271
|
await agent.whenIdle();
|
|
271
272
|
return; // 本轮 V 唯一动作是阻塞复核,不再推进阶段
|
|
272
273
|
}
|
|
@@ -411,7 +412,7 @@ export class VOrchestrator {
|
|
|
411
412
|
let turnError = null;
|
|
412
413
|
try {
|
|
413
414
|
agent = await this.getVAgent(orch);
|
|
414
|
-
agent.followup({ content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
415
|
+
agent.followup({ id: randomUUID(), content: [{ type: 'text', text: context }], source: { kind: 'user' } });
|
|
415
416
|
await agent.whenIdle();
|
|
416
417
|
}
|
|
417
418
|
catch (err) {
|
|
@@ -619,7 +620,7 @@ export class VOrchestrator {
|
|
|
619
620
|
// 与 agent-runner.ts 的 installRoleTools 用法一致。
|
|
620
621
|
// V 后台编排会话与 P/W/D 一致,显式设置 approval=never + sandbox=workspace-write,
|
|
621
622
|
// 避免在无 preset 装配时因默认审批策略在后台无应答者而挂起(卡死调度器首轮 tick)。
|
|
622
|
-
const setup = async (agentCtx) => {
|
|
623
|
+
const setup = async (agentCtx, agent) => {
|
|
623
624
|
// 思考等级强制(waterfall):与 agent-runner 同缺陷——宿主 selection 无 create-options 覆盖层,
|
|
624
625
|
// agentOptions.reasoningEffort 不被消费,V 编排会话思考等级会落回宿主默认。走 DSH agent/request
|
|
625
626
|
// waterfall 逐请求强制 'high'(宿主 installModelSelection 同机制),作用域仅本 V 会话。
|
|
@@ -629,7 +630,8 @@ export class VOrchestrator {
|
|
|
629
630
|
const resolved = await next();
|
|
630
631
|
return { ...resolved, reasoningEffort: 'high' };
|
|
631
632
|
});
|
|
632
|
-
|
|
633
|
+
// approval/sandbox 种子事件经 AgentSetup 第二参的 agent 显式直写(0.2.0 起 ctx.agent 挂载已移除)。
|
|
634
|
+
const session = agent?.session;
|
|
633
635
|
session?.append?.('approval/policy', { policy: 'never', source: 'delegation' });
|
|
634
636
|
session?.append?.('sandbox/mode', { mode: 'workspace-write', source: 'delegation' });
|
|
635
637
|
// 对齐(2026-08-17):V=butler·orchestrator 零执行能力——先挂 kanban-v 裁剪 preset
|
|
@@ -653,9 +655,9 @@ export class VOrchestrator {
|
|
|
653
655
|
}
|
|
654
656
|
await installRoleTools(agentCtx, 'v', { kanban: this.kanban, wiki: this.wiki });
|
|
655
657
|
// setup 完整成功(mount + 工具面)后写身份标记——live 复用校验依据(同 agent-runner 组合标记机制)。
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
vSessionCompositions.set(
|
|
658
|
+
// 键 = AgentSetup 第二参的 Agent 实例(与发布后 agents.get(id) 返回同一实例)。
|
|
659
|
+
if (agent)
|
|
660
|
+
vSessionCompositions.set(agent, V_SESSION_PRESET_ID);
|
|
659
661
|
};
|
|
660
662
|
// 模型候选链:V 会话 create/resume 按 primary→fallbacks 静默切换;
|
|
661
663
|
// V 无任务卡可 block——全候选不可用抛最后错误(wakeV 调用方按既有错误路径处理)。
|
|
@@ -39,8 +39,9 @@ export function validateReviewEvidence(role, handoff) {
|
|
|
39
39
|
// DT:实证校验证据(fail 评审同样必须已实证——test 字段必须存在;仅 pass 要求 exit 0)
|
|
40
40
|
if (!ev.test || typeof ev.test !== 'object')
|
|
41
41
|
missing.push('review_evidence.test');
|
|
42
|
+
// 报错文案带明确键名:agent 不读源码也能一轮自纠(exit_code 等别名一律不收)
|
|
42
43
|
else if (ev.verdict === 'pass' && ev.test['exit'] !== 0)
|
|
43
|
-
missing.push('review_evidence.test
|
|
44
|
+
missing.push('review_evidence.test.exit 必须为数字 0(键名 exit,非 exit_code;pass 门槛)');
|
|
44
45
|
const buildOk = ev.build && typeof ev.build === 'object';
|
|
45
46
|
const tcOk = ev.typecheck && typeof ev.typecheck === 'object';
|
|
46
47
|
if (!buildOk && !tcOk)
|
|
@@ -76,7 +77,7 @@ export function validateReviewEvidence(role, handoff) {
|
|
|
76
77
|
missing.push('review_evidence.tdd (test_files)');
|
|
77
78
|
}
|
|
78
79
|
if (ev.test && typeof ev.test === 'object' && ev.test['runner'] !== 'vitest') {
|
|
79
|
-
missing.push(
|
|
80
|
+
missing.push("review_evidence.test.runner 必须为 'vitest'(键名 runner,与 D 卡 gate 实测口径一致)");
|
|
80
81
|
}
|
|
81
82
|
if (ev.verdict === 'pass' && tdd.test_first !== true) {
|
|
82
83
|
missing.push('review_evidence.tdd (test_first=true)');
|
package/lib/index.js
CHANGED
|
@@ -36,10 +36,13 @@ export function apply(ctx, config) {
|
|
|
36
36
|
const configProvider = new ConfigProvider(ctx, config, storageDir);
|
|
37
37
|
// KanbanProvider 持 ConfigProvider 引用,kb_url base 经 getter 热读取(配置面板改后无需重建)。
|
|
38
38
|
const provider = new KanbanProvider(ctx, config, configProvider);
|
|
39
|
-
//
|
|
40
|
-
|
|
39
|
+
// 双路安装角色裁剪 preset:目录写 $DSH_HOME/.agent-presets/(0.1.7-rc.x 宿主发现根)+
|
|
40
|
+
// 运行时注册 agentPresets.register(0.2.0 宿主唯一生效路径)。注册等服务就绪后完成,
|
|
41
|
+
// 由 installer 如实记录「目录写 / 运行时注册」各自清单,不假阳性。
|
|
42
|
+
void installRolePresets(ctx).catch((err) => {
|
|
43
|
+
console.warn('[dsh-swarm] role preset install error: ' + String(err));
|
|
44
|
+
});
|
|
41
45
|
installLlWikiSkill(); // 尽力而为:失败仅告警不阻断
|
|
42
|
-
console.info('[dsh-swarm] role presets installed: ' + (installed.length ? installed.join(',') : 'none'));
|
|
43
46
|
// IM 投递(企微,2026-09-07 评审决议):W3 收尾/链阻塞时经 dsh-im 投群。
|
|
44
47
|
// imDelivery.enabled=false(默认)时事件处理器零开销早退;dshIm 服务缺失显式降级留痕。
|
|
45
48
|
const disposeImDelivery = wireImDelivery(ctx, provider.service, configProvider);
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
/** 包内组合目录(随包分发)。src/roles/ 与 lib/roles/ 深度一致,均经 ../../ 回到包根。 */
|
|
2
2
|
export declare function packagePresetsDir(): string;
|
|
3
|
-
/** $DSH_HOME/.agent-presets(
|
|
3
|
+
/** $DSH_HOME/.agent-presets(0.1.7-rc.x 宿主用户预设根;DSH_HOME 缺省 ~/.dsh)。 */
|
|
4
4
|
export declare function userPresetsRoot(): string;
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/** 单条 preset 组合行(官方 PresetDefinition.plugins 行的结构面:cordis entry list)。 */
|
|
6
|
+
export interface PresetPluginRow {
|
|
7
|
+
readonly id?: string;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly config?: unknown;
|
|
10
|
+
readonly disabled?: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** 注册给宿主 registry 的 preset 定义(官方 PresetDefinition 的结构面)。 */
|
|
13
|
+
export interface PresetDefinitionLike {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
readonly description?: string;
|
|
17
|
+
readonly order?: number;
|
|
18
|
+
readonly plugins: readonly PresetPluginRow[];
|
|
19
|
+
}
|
|
20
|
+
/** 宿主 agentPresets registry 的结构面(本插件只消费 register)。 */
|
|
21
|
+
export interface PresetRegistryLike {
|
|
22
|
+
register(definition: PresetDefinitionLike): Promise<() => Promise<void>>;
|
|
23
|
+
}
|
|
24
|
+
/** 安装期 ctx 的最小面(只需可选 get)。 */
|
|
25
|
+
export interface PresetCtxLike {
|
|
26
|
+
get?(name: string): unknown;
|
|
27
|
+
}
|
|
28
|
+
/** 解析包内单个 preset 的组合与元数据为宿主 registry 定义;组合缺失返回 undefined。 */
|
|
29
|
+
export declare function readPresetDefinition(pkgDir: string, id: string): PresetDefinitionLike | undefined;
|
|
30
|
+
/** 路径二:运行时注册全部包内 preset;返回注册成功/失败 id 列表与成功项的撤销句柄(组合缺失的 id 不计)。 */
|
|
31
|
+
export declare function registerRolePresets(registry: PresetRegistryLike, ids: readonly string[], pkgDir?: string): Promise<{
|
|
32
|
+
registered: string[];
|
|
33
|
+
failed: string[];
|
|
34
|
+
disposers: Array<() => Promise<void>>;
|
|
35
|
+
}>;
|
|
36
|
+
/** 安装结果(如实区分两条路径,不假阳性)。 */
|
|
37
|
+
export interface RolePresetInstallReport {
|
|
38
|
+
/** 目录写成功的 preset id(路径一)。 */
|
|
39
|
+
dirWritten: string[];
|
|
40
|
+
/** 运行时注册成功的 preset id(路径二;服务不可用时为空)。 */
|
|
41
|
+
runtimeRegistered: string[];
|
|
42
|
+
/** 运行时注册失败的 preset id。 */
|
|
43
|
+
runtimeFailed: string[];
|
|
44
|
+
/** 路径二实际形态:runtime=已注册;unavailable=宿主无 agentPresets 服务(仅目录写生效)。 */
|
|
45
|
+
runtimeMode: 'runtime' | 'unavailable';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 双路安装入口。目录写同步完成并即时留痕;运行时注册等服务就绪后完成并留痕。
|
|
49
|
+
* 返回前目录写已落盘;注册部分异步收敛(fire-and-forget 调用方须 catch)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function installRolePresets(ctx: PresetCtxLike, opts?: {
|
|
52
|
+
registerWaitMs?: number;
|
|
53
|
+
}): Promise<RolePresetInstallReport>;
|
|
9
54
|
/** 角色 preset 挂载的容器面(dsh-agent-presets roster 的最小结构类型)。 */
|
|
10
55
|
export interface PresetMountLike {
|
|
11
56
|
mount(ctx: unknown, id: string): Promise<unknown>;
|
|
@@ -1,36 +1,103 @@
|
|
|
1
1
|
// src/roles/preset-installer.ts
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { JSON_SCHEMA, load as loadYaml, Type } from 'js-yaml';
|
|
6
7
|
/**
|
|
7
|
-
* 角色裁剪 preset
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
8
|
+
* 角色裁剪 preset 的双路安装:
|
|
9
|
+
*
|
|
10
|
+
* 路径一 · 目录写(0.1.7-rc.x 宿主的底线):把包内组合复制到 `$DSH_HOME/.agent-presets/<id>/`,
|
|
11
|
+
* 老宿主的 agent-presets 发现逻辑扫该目录完成装载。0.2.0 宿主不再扫描任何目录,此路不影响新宿主。
|
|
12
|
+
*
|
|
13
|
+
* 路径二 · 运行时注册(0.2.0 起的唯一生效路径):0.2.0 的 dsh-agent-preset-registry 是纯运行时
|
|
14
|
+
* 服务,preset 定义只经 `agentPresets.register(definition)` 进入(官方 dsh-agent-preset 插件
|
|
15
|
+
* 即在 Service.init 中 yield register(config)),无任何目录发现。本插件经 ctx 取 agentPresets
|
|
16
|
+
* 服务(未就绪则短轮询等待),把与目录写同一份 yml 解析出的定义注册进去,两个路径语义同源。
|
|
17
|
+
*
|
|
18
|
+
* 幂等:目录写覆盖同名文件;运行时注册遇宿主 "Duplicate agent preset" 视为已注册成功。
|
|
19
|
+
* 尽力而为:单 preset 目录写/注册失败仅告警不阻断插件启动,后续挂载失败由 runner 降级日志兜底。
|
|
17
20
|
*/
|
|
18
21
|
const PRESET_IDS = ['kanban-v', 'kanban-p', 'kanban-w', 'kanban-d', 'kanban-pt', 'kanban-dt', 'swarm'];
|
|
22
|
+
/** 等待 agentPresets 服务就绪的超时(宿主组合启动期服务装配可能晚于插件 apply)。 */
|
|
23
|
+
const REGISTER_WAIT_MS = 60000;
|
|
19
24
|
/** 包内组合目录(随包分发)。src/roles/ 与 lib/roles/ 深度一致,均经 ../../ 回到包根。 */
|
|
20
25
|
export function packagePresetsDir() {
|
|
21
26
|
return fileURLToPath(new URL('../../personas/', import.meta.url));
|
|
22
27
|
}
|
|
23
|
-
/** $DSH_HOME/.agent-presets(
|
|
28
|
+
/** $DSH_HOME/.agent-presets(0.1.7-rc.x 宿主用户预设根;DSH_HOME 缺省 ~/.dsh)。 */
|
|
24
29
|
export function userPresetsRoot() {
|
|
25
30
|
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), '.agent-presets');
|
|
26
31
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
// 宿主 entry-list YAML 方言的镜像(@deepseek-ai/cordis-plugin-include 的 entryListSchema):
|
|
33
|
+
// `!!js` 标量解析为 { __jsExpr } 表达式节点,由宿主 Loader 在行激活时求值(组合内
|
|
34
|
+
// `disabled: !!js process.platform === 'win32'` 等行依赖此节点形态,禁在解析期求值)。
|
|
35
|
+
const JS_EXPR_TAG = new Type('tag:yaml.org,2002:js', {
|
|
36
|
+
kind: 'scalar',
|
|
37
|
+
resolve: (data) => typeof data === 'string',
|
|
38
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
39
|
+
});
|
|
40
|
+
const ENTRY_LIST_SCHEMA = JSON_SCHEMA.extend(JS_EXPR_TAG);
|
|
41
|
+
/** 镜像宿主 registry 的入口列表形状校验:顶层为带 name 的行数组(组行递归其 config)。 */
|
|
42
|
+
function entryListProblem(rows, at = '') {
|
|
43
|
+
if (!Array.isArray(rows))
|
|
44
|
+
return at === '' ? 'composition must be a top-level list of plugin rows' : `group ${at} must hold a list of plugin rows`;
|
|
45
|
+
for (const [index, row] of rows.entries()) {
|
|
46
|
+
const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`;
|
|
47
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row))
|
|
48
|
+
return `${label} is not a plugin row`;
|
|
49
|
+
const name = row.name;
|
|
50
|
+
if (typeof name !== 'string' || name === '')
|
|
51
|
+
return `${label} names no plugin (a "name" string is required)`;
|
|
52
|
+
const config = row.config;
|
|
53
|
+
if (row.group === true && config !== undefined) {
|
|
54
|
+
const nested = entryListProblem(config, label);
|
|
55
|
+
if (nested !== undefined)
|
|
56
|
+
return nested;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** 解析包内单个 preset 的组合与元数据为宿主 registry 定义;组合缺失返回 undefined。 */
|
|
62
|
+
export function readPresetDefinition(pkgDir, id) {
|
|
63
|
+
const srcFile = join(pkgDir, id, 'agent.cordis.yml');
|
|
64
|
+
if (!existsSync(srcFile))
|
|
65
|
+
return undefined;
|
|
66
|
+
let rows;
|
|
67
|
+
let meta;
|
|
68
|
+
try {
|
|
69
|
+
rows = loadYaml(readFileSync(srcFile, 'utf8'), { schema: ENTRY_LIST_SCHEMA });
|
|
70
|
+
const metaFile = join(pkgDir, id, 'preset.yml');
|
|
71
|
+
meta = existsSync(metaFile)
|
|
72
|
+
? loadYaml(readFileSync(metaFile, 'utf8'), { schema: JSON_SCHEMA })
|
|
73
|
+
: {};
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.warn('[dsh-swarm] role preset parse failed ' + id + ': ' + String(err));
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const problem = entryListProblem(rows);
|
|
80
|
+
if (problem !== undefined) {
|
|
81
|
+
console.warn('[dsh-swarm] role preset composition invalid ' + id + ': ' + problem);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const definition = {
|
|
85
|
+
id,
|
|
86
|
+
plugins: rows,
|
|
87
|
+
};
|
|
88
|
+
if (typeof meta.name === 'string')
|
|
89
|
+
definition.name = meta.name;
|
|
90
|
+
if (typeof meta.description === 'string')
|
|
91
|
+
definition.description = meta.description;
|
|
92
|
+
if (typeof meta.order === 'number')
|
|
93
|
+
definition.order = meta.order;
|
|
94
|
+
return definition;
|
|
95
|
+
}
|
|
96
|
+
/** 路径一:目录写(0.1.7-rc.x 宿主发现根)。返回成功写入的 preset id 列表。 */
|
|
97
|
+
function writeUserDirPresets() {
|
|
31
98
|
const src = packagePresetsDir();
|
|
32
99
|
const dstRoot = userPresetsRoot();
|
|
33
|
-
const
|
|
100
|
+
const written = [];
|
|
34
101
|
for (const id of PRESET_IDS) {
|
|
35
102
|
const srcFile = join(src, id, 'agent.cordis.yml');
|
|
36
103
|
if (!existsSync(srcFile))
|
|
@@ -42,14 +109,81 @@ export function installRolePresets() {
|
|
|
42
109
|
const meta = join(src, id, 'preset.yml');
|
|
43
110
|
if (existsSync(meta))
|
|
44
111
|
copyFileSync(meta, join(dstDir, 'preset.yml'));
|
|
45
|
-
|
|
112
|
+
written.push(id);
|
|
46
113
|
}
|
|
47
114
|
catch (err) {
|
|
48
115
|
// 尽力而为:写入失败(如沙箱/权限拒绝)仅告警,不阻断插件启动
|
|
49
|
-
console.warn('[dsh-swarm] role preset
|
|
116
|
+
console.warn('[dsh-swarm] role preset dir write skipped ' + id + ' -> ' + dstRoot + ': ' + String(err));
|
|
50
117
|
}
|
|
51
118
|
}
|
|
52
|
-
return
|
|
119
|
+
return written;
|
|
120
|
+
}
|
|
121
|
+
/** 路径二:运行时注册全部包内 preset;返回注册成功/失败 id 列表与成功项的撤销句柄(组合缺失的 id 不计)。 */
|
|
122
|
+
export async function registerRolePresets(registry, ids, pkgDir = packagePresetsDir()) {
|
|
123
|
+
const registered = [];
|
|
124
|
+
const failed = [];
|
|
125
|
+
const disposers = [];
|
|
126
|
+
for (const id of ids) {
|
|
127
|
+
const definition = readPresetDefinition(pkgDir, id);
|
|
128
|
+
if (!definition)
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
disposers.push(await registry.register(definition));
|
|
132
|
+
registered.push(id);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// 插件重 apply 时定义已在 registry:与首次注册语义等价,按成功计
|
|
136
|
+
if (String(err).includes('Duplicate agent preset')) {
|
|
137
|
+
registered.push(id);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
failed.push(id);
|
|
141
|
+
console.warn('[dsh-swarm] role preset runtime register failed ' + id + ': ' + String(err));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { registered, failed, disposers };
|
|
145
|
+
}
|
|
146
|
+
/** 短轮询等待宿主 agentPresets 服务就绪;超时返回 undefined(降级仅目录写)。 */
|
|
147
|
+
async function waitForPresets(ctx, timeoutMs) {
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const poll = setInterval(() => {
|
|
151
|
+
const found = ctx.get?.('agentPresets');
|
|
152
|
+
if (found) {
|
|
153
|
+
clearInterval(poll);
|
|
154
|
+
resolve(found);
|
|
155
|
+
}
|
|
156
|
+
else if (Date.now() - started >= timeoutMs) {
|
|
157
|
+
clearInterval(poll);
|
|
158
|
+
resolve(undefined);
|
|
159
|
+
}
|
|
160
|
+
}, 500);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 双路安装入口。目录写同步完成并即时留痕;运行时注册等服务就绪后完成并留痕。
|
|
165
|
+
* 返回前目录写已落盘;注册部分异步收敛(fire-and-forget 调用方须 catch)。
|
|
166
|
+
*/
|
|
167
|
+
export async function installRolePresets(ctx, opts = {}) {
|
|
168
|
+
const dirWritten = writeUserDirPresets();
|
|
169
|
+
console.info('[dsh-swarm] role preset dir write: ' + (dirWritten.length ? dirWritten.join(',') : 'none'));
|
|
170
|
+
const registry = await waitForPresets(ctx, opts.registerWaitMs ?? REGISTER_WAIT_MS);
|
|
171
|
+
if (!registry) {
|
|
172
|
+
console.warn('[dsh-swarm] role presets installed: dir-write=' + (dirWritten.join(',') || 'none')
|
|
173
|
+
+ ' runtime-register=none (agentPresets service unavailable; directory write only)');
|
|
174
|
+
return { dirWritten, runtimeRegistered: [], runtimeFailed: [], runtimeMode: 'unavailable' };
|
|
175
|
+
}
|
|
176
|
+
const { registered, failed, disposers } = await registerRolePresets(registry, PRESET_IDS);
|
|
177
|
+
console.info('[dsh-swarm] role presets installed: dir-write=' + (dirWritten.join(',') || 'none')
|
|
178
|
+
+ ' runtime-register=' + (registered.join(',') || 'none')
|
|
179
|
+
+ (failed.length ? ' failed=' + failed.join(',') : ''));
|
|
180
|
+
// 插件卸载/重载时撤销注册,避免宿主 registry 留下失效定义(官方 register 契约:声明方持有 disposer)。
|
|
181
|
+
ctx.on?.('dispose', () => {
|
|
182
|
+
for (const dispose of disposers.splice(0))
|
|
183
|
+
void dispose().catch(() => { });
|
|
184
|
+
});
|
|
185
|
+
const report = { dirWritten, runtimeRegistered: registered, runtimeFailed: failed, runtimeMode: 'runtime' };
|
|
186
|
+
return report;
|
|
53
187
|
}
|
|
54
188
|
/**
|
|
55
189
|
* preset 挂载收敛入口(2026-09-21 事故修复):宿主 dsh-scope 的 scope key 绑定在进程
|
|
@@ -2,7 +2,8 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { buildLlmCatalog } from '../services/llm-catalog.js';
|
|
5
|
+
import { buildLlmCatalog, filterCandidatesByCatalog } from '../services/llm-catalog.js';
|
|
6
|
+
import { ROLES } from '../domain/config-override.js';
|
|
6
7
|
import { INSTALL_GUIDANCE, OCR_PACKAGE, managedProviderReady, probeOcr, wireManagedProvider } from '../services/ocr-cli.js';
|
|
7
8
|
import { serveKanbanEvents } from './kanban-sse.js';
|
|
8
9
|
import { resolveReviewTarget } from '../domain/review-target.js';
|
|
@@ -323,6 +324,27 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
323
324
|
},
|
|
324
325
|
imDelivery: { fallbackBotId: raw.imDelivery?.fallbackBotId ?? cur.imDelivery?.fallbackBotId ?? '' },
|
|
325
326
|
};
|
|
327
|
+
// 模型配置保存口预检(堵源头):角色模型与 managed 评审组合 (provider, model, effort)
|
|
328
|
+
// 与 LLM 目录比对,确认不匹配 → 400 附明细。与派发前预检(agent-runner 消费同一目录)
|
|
329
|
+
// 同口径——跨层拼接的死组合(provider/model 来自不同层)在保存时即被拦下,不再流入派发。
|
|
330
|
+
// fail-open:llm 运行时缺失/目录探测失败放行(网络抖动 ≠ 配置错误),不阻断无关字段保存。
|
|
331
|
+
const toProbe = [];
|
|
332
|
+
for (const role of ROLES) {
|
|
333
|
+
const m = snapshot.roles.models[role];
|
|
334
|
+
if (m?.provider?.trim() && m?.model?.trim())
|
|
335
|
+
toProbe.push({ provider: m.provider, model: m.model, reasoningEffort: m.reasoningEffort });
|
|
336
|
+
}
|
|
337
|
+
if (snapshot.reviewEngine.mode === 'managed' && snapshot.reviewEngine.managed.provider.trim() && snapshot.reviewEngine.managed.model.trim()) {
|
|
338
|
+
toProbe.push({ provider: snapshot.reviewEngine.managed.provider, model: snapshot.reviewEngine.managed.model });
|
|
339
|
+
}
|
|
340
|
+
const catalogProbe = await filterCandidatesByCatalog(llm, toProbe);
|
|
341
|
+
if (catalogProbe.rejected.length > 0) {
|
|
342
|
+
json(res, 400, {
|
|
343
|
+
error: 'validation failed',
|
|
344
|
+
fields: catalogProbe.rejected.map((r) => `${r.candidate.provider}/${r.candidate.model}: ${r.reason}`),
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
326
348
|
const r = configProvider.applyOverride(snapshot);
|
|
327
349
|
if (!r.ok) {
|
|
328
350
|
json(res, 400, { error: 'validation failed', fields: r.errors });
|
|
@@ -33,3 +33,21 @@ export interface LlmRuntimeLike {
|
|
|
33
33
|
}>;
|
|
34
34
|
}
|
|
35
35
|
export declare function buildLlmCatalog(llm: LlmRuntimeLike): Promise<LlmCatalog>;
|
|
36
|
+
/** 候选模型(与 dispatcher AgentModelOptions 结构同形;不 import 防环)。 */
|
|
37
|
+
export interface ModelCandidateLike {
|
|
38
|
+
provider: string;
|
|
39
|
+
model: string;
|
|
40
|
+
reasoningEffort?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface CandidateReject {
|
|
43
|
+
candidate: ModelCandidateLike;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
/** 派发/保存前候选预校验:provider 已配置、model 属该 provider、reasoningEffort ∈ 模型声明集。
|
|
47
|
+
* fail-open 原则:运行时缺失、provider 枚举失败、单候选探测异常一律放行——只拦「确实查到
|
|
48
|
+
* 不匹配」的组合,目录不可达(网络抖动)≠ 配置错误,不应阻断派发/保存。
|
|
49
|
+
* effort 声明集为空(resolveModelInfo 降级)时跳过 effort 把关。 */
|
|
50
|
+
export declare function filterCandidatesByCatalog(runtime: LlmRuntimeLike | undefined, candidates: readonly ModelCandidateLike[]): Promise<{
|
|
51
|
+
ok: ModelCandidateLike[];
|
|
52
|
+
rejected: CandidateReject[];
|
|
53
|
+
}>;
|
|
@@ -17,3 +17,44 @@ export async function buildLlmCatalog(llm) {
|
|
|
17
17
|
}
|
|
18
18
|
return { providers, models };
|
|
19
19
|
}
|
|
20
|
+
/** 派发/保存前候选预校验:provider 已配置、model 属该 provider、reasoningEffort ∈ 模型声明集。
|
|
21
|
+
* fail-open 原则:运行时缺失、provider 枚举失败、单候选探测异常一律放行——只拦「确实查到
|
|
22
|
+
* 不匹配」的组合,目录不可达(网络抖动)≠ 配置错误,不应阻断派发/保存。
|
|
23
|
+
* effort 声明集为空(resolveModelInfo 降级)时跳过 effort 把关。 */
|
|
24
|
+
export async function filterCandidatesByCatalog(runtime, candidates) {
|
|
25
|
+
if (!runtime || candidates.length === 0)
|
|
26
|
+
return { ok: [...candidates], rejected: [] };
|
|
27
|
+
let providerIds;
|
|
28
|
+
try {
|
|
29
|
+
providerIds = new Set(runtime.listProviders().map((p) => p.id));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { ok: [...candidates], rejected: [] };
|
|
33
|
+
}
|
|
34
|
+
const ok = [];
|
|
35
|
+
const rejected = [];
|
|
36
|
+
for (const c of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
if (!providerIds.has(c.provider)) {
|
|
39
|
+
rejected.push({ candidate: c, reason: `provider "${c.provider}" 未配置` });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const models = await runtime.listModels(c.provider);
|
|
43
|
+
if (!models.some((m) => m.id === c.model)) {
|
|
44
|
+
rejected.push({ candidate: c, reason: `provider "${c.provider}" 无模型 "${c.model}"` });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const info = await runtime.resolveModelInfo(c.provider, c.model).catch(() => undefined);
|
|
48
|
+
const efforts = (info?.reasoning?.efforts ?? []).map((e) => e.id);
|
|
49
|
+
if (efforts.length > 0 && c.reasoningEffort && !efforts.includes(c.reasoningEffort)) {
|
|
50
|
+
rejected.push({ candidate: c, reason: `模型 "${c.model}" 不支持 reasoningEffort "${c.reasoningEffort}"(声明:${efforts.join('/')})` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
ok.push(c);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
ok.push(c); // 单候选探测异常 fail-open
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ok, rejected };
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joekytc/dsh-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A governed swarm of six specialist DSH agents (orchestrator, planner, knowledge-base bridge, developer and two reviewers) that turns a requirement into a strict phase pipeline with machine-verified delivery evidence, review-gated merges, a full audit-log event stream and a live kanban tab; design inspired by the Hermes Agent kanban",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "joekytc",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"client": {
|
|
34
34
|
"platform": "web",
|
|
35
35
|
"inject": [
|
|
36
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
37
38
|
]
|
|
38
39
|
}
|
|
39
40
|
},
|
|
@@ -58,27 +59,32 @@
|
|
|
58
59
|
"test": "vitest run",
|
|
59
60
|
"build:client": "node scripts/build-client.mjs"
|
|
60
61
|
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"js-yaml": "^4.3.2"
|
|
64
|
+
},
|
|
61
65
|
"peerDependencies": {
|
|
62
66
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
63
|
-
"@deepseek-ai/dsh-agent": "
|
|
64
|
-
"@deepseek-ai/dsh-api-session-controller": "
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-conversation": "
|
|
66
|
-
"@deepseek-ai/dsh-credentials": "
|
|
67
|
-
"@deepseek-ai/dsh-persona": "
|
|
68
|
-
"@deepseek-ai/dsh-session": "
|
|
69
|
-
"@deepseek-ai/dsh-tool-bash": "
|
|
70
|
-
"@deepseek-ai/dsh-tool-fs": "
|
|
71
|
-
"@deepseek-ai/dsh-tool-fs-search": "
|
|
72
|
-
"@deepseek-ai/dsh-tools": "
|
|
73
|
-
"@deepseek-ai/dsh-util-values": "
|
|
67
|
+
"@deepseek-ai/dsh-agent": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-api-session-controller": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-conversation": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-credentials": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-persona": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-session": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-tool-bash": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-tool-fs": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-tool-fs-search": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-tools": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-util-values": "~0.1.7-rc.2 || ^0.2.0-rc.2",
|
|
74
78
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
75
79
|
},
|
|
76
80
|
"devDependencies": {
|
|
77
|
-
"@deepseek-ai/
|
|
78
|
-
"@deepseek-ai/dsh-client-
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-
|
|
81
|
+
"@deepseek-ai/cordis-plugin-include": "^1.0.9",
|
|
82
|
+
"@deepseek-ai/dsh-client-store": "^0.2.0-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.2",
|
|
80
85
|
"@testing-library/dom": "^10.4.0",
|
|
81
86
|
"@testing-library/react": "^16.1.0",
|
|
87
|
+
"@types/js-yaml": "^4.0.9",
|
|
82
88
|
"@types/node": "^22.0.0",
|
|
83
89
|
"@types/react": "^18.3.0",
|
|
84
90
|
"@types/react-dom": "^18.3.0",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- id: persona
|
|
13
13
|
name: '@deepseek-ai/dsh-persona'
|
|
14
14
|
config:
|
|
15
|
-
|
|
15
|
+
prefix: >-
|
|
16
16
|
You are the fullstack development agent (the ONLY executor) powered by the {{model}} model.
|
|
17
17
|
Your working directory is {{cwd}}. You implement and test the aligned spec; you are
|
|
18
18
|
the sole accountable executor and may delegate implementation subtasks. Rules:
|
|
@@ -134,12 +134,14 @@
|
|
|
134
134
|
config:
|
|
135
135
|
provider: spawn
|
|
136
136
|
toolName: subagent
|
|
137
|
+
maxDepth: 3
|
|
137
138
|
|
|
138
139
|
- id: tool-subagent-fork
|
|
139
140
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
140
141
|
config:
|
|
141
142
|
provider: fork
|
|
142
143
|
toolName: subagent_fork
|
|
144
|
+
maxDepth: 3
|
|
143
145
|
|
|
144
146
|
- id: tool-subagent-control
|
|
145
147
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- id: persona
|
|
12
12
|
name: '@deepseek-ai/dsh-persona'
|
|
13
13
|
config:
|
|
14
|
-
|
|
14
|
+
prefix: >-
|
|
15
15
|
You are the implementation verifier + reviewer agent (read-only) powered by the {{model}}
|
|
16
16
|
model. Your working directory is {{cwd}}. You empirically verify D's implementation and
|
|
17
17
|
review it — you never modify source and you never execute writes. Rules:
|
|
@@ -59,6 +59,10 @@
|
|
|
59
59
|
{ verdict: 'pass'|'fail', issues: [...], test/build/typecheck/lint/diff/git/
|
|
60
60
|
openCodeReview/reviewPage }: pass = all six checks pass (system advances to W3);
|
|
61
61
|
fail = critical/high unresolved (system creates a D rework task + fresh review card).
|
|
62
|
+
test must carry two mechanically-gated keys (wrong key name or value rejects the
|
|
63
|
+
complete call): test = { exit: 0, runner: 'vitest', targeted/full } — the key is
|
|
64
|
+
exit (a number, never exit_code) and runner is fixed to 'vitest' (same runner the
|
|
65
|
+
D-side gate actually runs).
|
|
62
66
|
6. Never call kanban_create; only complete/block/comment your own bound task.
|
|
63
67
|
|
|
64
68
|
- id: agent-instructions
|
|
@@ -91,3 +95,4 @@
|
|
|
91
95
|
config:
|
|
92
96
|
provider: spawn
|
|
93
97
|
toolName: subagent
|
|
98
|
+
maxDepth: 3
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- id: persona
|
|
16
16
|
name: '@deepseek-ai/dsh-persona'
|
|
17
17
|
config:
|
|
18
|
-
|
|
18
|
+
prefix: >-
|
|
19
19
|
You are the planning agent (planner) powered by the {{model}} model. Your working
|
|
20
20
|
directory is {{cwd}}. You produce the implementation plan only — you are NOT the
|
|
21
21
|
executor. When injected repo facts are insufficient, you may do READ-ONLY self-inspection
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- id: persona
|
|
10
10
|
name: '@deepseek-ai/dsh-persona'
|
|
11
11
|
config:
|
|
12
|
-
|
|
12
|
+
prefix: >-
|
|
13
13
|
You are the plan-review agent (read-only) powered by the {{model}} model. Your working
|
|
14
14
|
directory is {{cwd}}. You review P's plan deliverable only — you never modify any artifact
|
|
15
15
|
and you never execute code. Rules:
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- id: persona
|
|
13
13
|
name: '@deepseek-ai/dsh-persona'
|
|
14
14
|
config:
|
|
15
|
-
|
|
15
|
+
prefix: >-
|
|
16
16
|
You are the orchestration agent (butler) powered by the {{model}} model. Your working
|
|
17
17
|
directory is {{cwd}}. You only decompose, dispatch and summarize — you are NEVER the executor.
|
|
18
18
|
Rules:
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- id: persona
|
|
13
13
|
name: '@deepseek-ai/dsh-persona'
|
|
14
14
|
config:
|
|
15
|
-
|
|
15
|
+
prefix: >-
|
|
16
16
|
You are the knowledge officer (Knowledge Officer) powered by the {{model}} model. Your
|
|
17
17
|
working directory is {{cwd}}. You curate the requirement knowledge base (KB) in one of two
|
|
18
18
|
modes (the active mode and the local KB root, when local, are stated in your task context);
|
package/personas/persona-dt.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
3. 评审引擎由配置面板 reviewEngine.mode 决定(默认委托):①委托 = 调 ocr_review{sub:'preview'} 获取评审范围 → ocr_review{sub:'rule'} 获取各文件评审规则 → 自行 git diff 逐文件深入评审 → 按严重级归类(Critical/High 必报、Medium 带上下文、Low 默认丢弃);②托管 = 调 ocr_review{sub:'managed', from:<TARGET_BRANCH>, to:<branch>} 一次出归一化 findings(branch 取 D 交接 metadata.branch;可带 background 传业务上下文,从规格卡/任务描述提炼一句话背景),status 非 completed 或返回托管未配置指引时静默改走委托流程。
|
|
10
10
|
4. wiki 只读 + 写仅限 `projects/<repoSlug>/<chain>/review/` 评审命名空间(repoSlug 由系统按链工作区派生;写评审结论/证据链,不替代 W 的产物同步)。
|
|
11
11
|
5. 评审结论写进 kanban_complete 的交接 metadata.review_evidence = { verdict: 'pass'|'fail', issues: [...], test/build/typecheck/lint/diff/git/openCodeReview/reviewPage }:
|
|
12
|
+
- test 必须带两键(评审闸机械校验,键名/取值错一处即拒绝 complete):test = { exit: 0, runner: 'vitest', targeted/full }——键名是 `exit`(数字,非 exit_code),`runner` 固定 'vitest'(与 D 卡 gate 实测口径一致)。
|
|
12
13
|
- pass = 六项校验全过 → 系统推进 W3;
|
|
13
14
|
- fail = critical/high 未处置 → 系统 createReworkTask 让 D 返工 + 新建复审卡。
|
|
14
15
|
6. 不得调用 kanban_create;只可 complete/block/comment 本任务(会话绑定)。
|