@joekytc/dsh-swarm 0.3.1 → 0.3.6
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/README.md +14 -7
- package/README.zh-CN.md +10 -5
- package/client/KanbanBoard.tsx +3 -1
- package/client/TaskDrawer.tsx +74 -2
- package/client/WorkflowRail.tsx +84 -1
- package/client/config-store.ts +3 -1
- package/client/kanban.css +28 -0
- package/client/timeline-model.ts +4 -0
- package/client/workflow-model.ts +20 -11
- package/lib/client.js +1100 -22
- package/lib/config.d.ts +5 -1
- package/lib/config.js +2 -1
- package/lib/dispatcher/agent-runner.js +25 -9
- package/lib/dispatcher/event-waker.js +4 -1
- package/lib/dispatcher/v-orchestrator.d.ts +2 -0
- package/lib/dispatcher/v-orchestrator.js +58 -9
- package/lib/domain/config-override.d.ts +6 -0
- package/lib/domain/config-override.js +16 -1
- package/lib/domain/im-message.d.ts +21 -2
- package/lib/domain/im-message.js +84 -4
- package/lib/domain/kanban-service.d.ts +8 -1
- package/lib/domain/kanban-service.js +67 -1
- package/lib/domain/ocr-review.d.ts +6 -2
- package/lib/domain/ocr-review.js +20 -11
- package/lib/domain/permissions.d.ts +1 -1
- package/lib/domain/permissions.js +4 -0
- package/lib/domain/projection.js +8 -3
- package/lib/domain/review-evidence.js +9 -0
- package/lib/domain/review-target.d.ts +10 -0
- package/lib/domain/review-target.js +24 -0
- package/lib/domain/state-machine.js +1 -1
- package/lib/domain/types.d.ts +16 -3
- package/lib/routes/kanban-http.js +66 -6
- package/lib/services/config-provider.js +2 -0
- package/lib/services/im-bot-probe.d.ts +36 -0
- package/lib/services/im-bot-probe.js +128 -0
- package/lib/services/im-delivery.d.ts +69 -7
- package/lib/services/im-delivery.js +220 -34
- package/lib/tools/kanban-tools.js +38 -1
- package/lib/tools/main-session-tools.js +34 -9
- package/lib/tools/ocr-review-tools.js +13 -6
- package/package.json +6 -1
- package/personas/kanban-d/agent.cordis.yml +0 -1
- package/personas/kanban-dt/agent.cordis.yml +4 -3
- package/personas/kanban-p/agent.cordis.yml +16 -0
- package/personas/kanban-pt/agent.cordis.yml +19 -1
- package/personas/kanban-w/agent.cordis.yml +15 -0
- package/personas/persona-d.md +0 -1
- package/personas/persona-dt.md +2 -2
- package/personas/persona-p.md +4 -0
- package/personas/persona-pt.md +4 -1
- package/personas/persona-w.md +1 -0
- package/personas/swarm/agent.cordis.yml +11 -0
|
@@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
2
2
|
import type { KanbanService } from '../domain/kanban-service.js';
|
|
3
3
|
import type { BoardState } from '../domain/types.js';
|
|
4
4
|
import type { ConfigProvider } from './config-provider.js';
|
|
5
|
+
import { type ProbeResult } from './im-bot-probe.js';
|
|
5
6
|
/** dsh-im 宿主服务结构接口(形状以 @xmanrui/dsh-im PROACTIVE_DELIVERY.md 为准,运行时守卫校验)。
|
|
6
7
|
* listTargets 证据(2026-09-07):PROACTIVE_DELIVERY.md:176 `const targets = await ctx.dshIm.listTargets(botId);
|
|
7
8
|
* // [{ targetId, name?, kind, route }, ...]`;宿主 lib/index.js 实现为
|
|
@@ -33,17 +34,73 @@ export interface ImDeliveryOptions {
|
|
|
33
34
|
manual?: boolean;
|
|
34
35
|
/** 投递目标类型(0.3.x 自由投递):group=群聊;user=私聊(仅且只有一个已保存目标)。缺省 group。 */
|
|
35
36
|
targetKind?: 'group' | 'user';
|
|
37
|
+
/** 手动投递会话的 preset(工具从 session header 注入):多机器人时用于匹配机器人默认模式。
|
|
38
|
+
* 缺省=无 preset(auto 路径无会话上下文),跳过模式匹配直接走默认/交互。 */
|
|
39
|
+
sessionPreset?: string;
|
|
40
|
+
/** 手动投递会话的 live agent(工具从 exec.agent 注入):userQuestions 的 GUI answerer 挂在
|
|
41
|
+
* Agent-scoped waterfall 上,不传 agent 的询问落在根 ctx 无人应答(NO_PROVIDER)。 */
|
|
42
|
+
agent?: unknown;
|
|
43
|
+
/** 测试注入:覆盖机器人决策链的探针/交互/写默认实现(生产由 ctx 与 configProvider 装配)。 */
|
|
44
|
+
botChoice?: Partial<BotChoiceDeps>;
|
|
36
45
|
}
|
|
37
46
|
/** dsh-im 未安装的可识别错误前缀(0.3.1:缺插件属环境问题不重试,直接友好提醒安装)。 */
|
|
38
47
|
export declare const DSH_IM_MISSING_PREFIX = "dsh-im-not-installed";
|
|
39
48
|
export declare const DSH_IM_MISSING_GUIDANCE = "\u672A\u68C0\u6D4B\u5230 dsh-im \u63D2\u4EF6\uFF0C\u65E0\u6CD5\u6295\u9012\u4F01\u5FAE\u6D88\u606F\u3002\u8BF7\u5148\u5B89\u88C5\u5E76\u542F\u7528 @xmanrui/dsh-im \u63D2\u4EF6\uFF08\u5B89\u88C5\u540E\u91CD\u542F dsh \u751F\u6548\uFF09\uFF0C\u518D\u91CD\u8BD5\u6295\u9012\u3002";
|
|
40
49
|
export declare function isDshImLike(svc: unknown): svc is DshImLike;
|
|
41
|
-
/**
|
|
42
|
-
|
|
50
|
+
/** 机器人来源(供调用方在 guidance 里如实转述,勿让模型猜)。 */
|
|
51
|
+
export type BotVia = 'explicit' | 'single' | 'preset' | 'fallback' | 'interactive';
|
|
52
|
+
export interface BotAskOption {
|
|
53
|
+
botId: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
export interface BotChoiceDeps {
|
|
57
|
+
/** 当前会话 preset(手动投递由工具从 session header 注入);null/空 = 无法模式匹配(auto 路径)。 */
|
|
58
|
+
sessionPreset: string | null;
|
|
59
|
+
/** 多机器人时读 bot↔preset/连接态;失败=降级(不阻断投递,退化到默认/交互)。 */
|
|
60
|
+
probe: () => Promise<ProbeResult>;
|
|
61
|
+
/** 交互选择;无询问通道返回 null,用户取消/超时也返回 null(fail-closed 不投)。 */
|
|
62
|
+
ask: ((options: BotAskOption[]) => Promise<{
|
|
63
|
+
botId: string;
|
|
64
|
+
setDefault: boolean;
|
|
65
|
+
} | null>) | null;
|
|
66
|
+
/** 写默认机器人;写失败返回 false(本次投递照常,仅默认未落库)。 */
|
|
67
|
+
setFallback: ((botId: string) => Promise<boolean>) | null;
|
|
68
|
+
log: (m: string) => void;
|
|
69
|
+
}
|
|
70
|
+
export interface BotChoice {
|
|
71
|
+
botId: string;
|
|
72
|
+
via: BotVia;
|
|
73
|
+
fallbackSaved?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
|
|
76
|
+
* 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
|
|
77
|
+
export declare function chooseBot(im: DshImLike, cfg: {
|
|
78
|
+
botId: string;
|
|
79
|
+
fallbackBotId?: string;
|
|
80
|
+
}, deps: BotChoiceDeps): Promise<({
|
|
81
|
+
ok: true;
|
|
82
|
+
} & BotChoice) | {
|
|
83
|
+
ok: false;
|
|
84
|
+
error: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** targetId 解析:配置显式指定优先;留空自动发现该机器人下唯一已保存目标(按 kind)。
|
|
87
|
+
* 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed)。
|
|
88
|
+
* 机器人非显式指定(preset/默认/交互选出)而配置又填死了 targetId 时先校验归属:
|
|
89
|
+
* targetId 是 per-bot 作用域,填错机器人只会落成 dsh-im 的 unknown-target,这里提前给可行动报错。 */
|
|
90
|
+
export declare function resolveTargetId(im: DshImLike, botId: string, botIdExplicit: boolean, cfg: {
|
|
91
|
+
targetId: string;
|
|
92
|
+
dmTargetId?: string;
|
|
93
|
+
}, kind: 'group' | 'user'): Promise<{
|
|
94
|
+
targetId: string;
|
|
95
|
+
} | {
|
|
96
|
+
error: string;
|
|
97
|
+
}>;
|
|
98
|
+
/** botId+targetId 一步解析(无交互/无探针的简化入口,保留给单机器人场景与单测)。 */
|
|
43
99
|
export declare function resolveTarget(im: DshImLike, cfg: {
|
|
44
100
|
botId: string;
|
|
45
101
|
targetId: string;
|
|
46
102
|
dmTargetId?: string;
|
|
103
|
+
fallbackBotId?: string;
|
|
47
104
|
}, kind: 'group' | 'user'): Promise<{
|
|
48
105
|
botId: string;
|
|
49
106
|
targetId: string;
|
|
@@ -57,17 +114,20 @@ export declare function sendWithRetry(im: DshImLike, botId: string, targetId: st
|
|
|
57
114
|
ok: false;
|
|
58
115
|
error: string;
|
|
59
116
|
}>;
|
|
60
|
-
|
|
61
|
-
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
62
|
-
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
63
|
-
export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<{
|
|
117
|
+
export type SendResult = {
|
|
64
118
|
ok: true;
|
|
65
119
|
botId: string;
|
|
66
120
|
targetId: string;
|
|
121
|
+
via: BotVia;
|
|
122
|
+
fallbackSaved?: boolean;
|
|
67
123
|
} | {
|
|
68
124
|
ok: false;
|
|
69
125
|
error: string;
|
|
70
|
-
}
|
|
126
|
+
};
|
|
127
|
+
/** 发送器工厂:resolveDshIm → chooseBot(fail-closed)→ resolveTargetId → sendWithRetry → 留痕。
|
|
128
|
+
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
129
|
+
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
130
|
+
export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<SendResult>;
|
|
71
131
|
export type ReportVariant = 'completion' | 'blocked';
|
|
72
132
|
/** /sms 链解析(纯函数)。空 query:completion=最近满足 W3 完成判据的链(最后完成事件 at 最大);
|
|
73
133
|
* blocked=最近阻塞的链(chain/blocked 事件 at 最大)。显式 query:全量链精确/后缀解析后再验判据。 */
|
|
@@ -99,6 +159,8 @@ export declare function sendChainReport(ctx: Context, kanban: KanbanService, con
|
|
|
99
159
|
chainId: string;
|
|
100
160
|
botId: string;
|
|
101
161
|
targetId: string;
|
|
162
|
+
via: BotVia;
|
|
163
|
+
fallbackSaved?: boolean;
|
|
102
164
|
} | {
|
|
103
165
|
ok: false;
|
|
104
166
|
error: string;
|
|
@@ -2,8 +2,13 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { buildCompletionMessage, buildBlockMessage } from '../domain/im-message.js';
|
|
5
|
+
import { createBotProbe, probeDepsFromCtx } from './im-bot-probe.js';
|
|
5
6
|
const RETRYABLE_CODES = new Set(['bot-not-connected', 'delivery-failed']);
|
|
6
7
|
const DEFAULT_RETRY_DELAYS_MS = [5_000, 10_000, 15_000];
|
|
8
|
+
/** 机器人交互选择等待上限(5 分钟):到点 abort 信号 + 竞速超时,按「未选择」fail-closed 不投。 */
|
|
9
|
+
const ASK_TIMEOUT_MS = 300_000;
|
|
10
|
+
/** 「设为默认」选项文案:提问与应答解析共用一处,改文案两处同步(字符串比对,无 id 兜底)。 */
|
|
11
|
+
const ASK_SET_DEFAULT_LABEL = '设为默认';
|
|
7
12
|
/** dsh-im 未安装的可识别错误前缀(0.3.1:缺插件属环境问题不重试,直接友好提醒安装)。 */
|
|
8
13
|
export const DSH_IM_MISSING_PREFIX = 'dsh-im-not-installed';
|
|
9
14
|
const DSH_IM_MISSING_ERROR = `${DSH_IM_MISSING_PREFIX}: 未检测到 dsh-im 插件服务,请先安装并启用 @xmanrui/dsh-im(安装后重启 dsh 生效)`;
|
|
@@ -23,35 +28,115 @@ function resolveDshIm(ctx, log) {
|
|
|
23
28
|
}
|
|
24
29
|
return svc;
|
|
25
30
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/** 交互选项展示文案:脱敏名 + id(id 是唯一可靠标识,展示名可能重名)。 */
|
|
32
|
+
function askLabel(o) {
|
|
33
|
+
return o.label ? `${o.label}(${o.botId})` : o.botId;
|
|
34
|
+
}
|
|
35
|
+
/** 多机器人且无法自动决策时的 fail-closed 报错:列候选(有探针数据则带模式与连接态)。 */
|
|
36
|
+
function botCandidatesError(ids, probed) {
|
|
37
|
+
const byId = new Map(probed.map((b) => [b.botId, b]));
|
|
38
|
+
const list = ids.map((id) => {
|
|
39
|
+
const c = byId.get(id);
|
|
40
|
+
if (!c)
|
|
41
|
+
return id;
|
|
42
|
+
return `${id}(${c.label || '未命名'}·${c.agentPreset || '未设模式'}${c.connected ? '' : '·离线'})`;
|
|
43
|
+
}).join(' / ');
|
|
44
|
+
return `企微机器人数量=${ids.length}(期望 1),无法自动确定投递对象。候选:${list}。请在插件配置 imDelivery.botId 显式指定,或在可交互的手动投递会话重试以选择`;
|
|
45
|
+
}
|
|
46
|
+
/** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
|
|
47
|
+
* 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
|
|
48
|
+
export async function chooseBot(im, cfg, deps) {
|
|
49
|
+
const explicit = cfg.botId.trim();
|
|
50
|
+
if (explicit)
|
|
51
|
+
return { ok: true, botId: explicit, via: 'explicit' };
|
|
52
|
+
const wecom = (await im.listBots()).filter((b) => b.channel === 'wecom');
|
|
53
|
+
if (wecom.length === 0) {
|
|
54
|
+
return { ok: false, error: '未检测到企微机器人接入:请先在 dsh-im 设置→IM机器人 接入机器人后重试' };
|
|
36
55
|
}
|
|
56
|
+
const ids = wecom.map((b) => b.botId);
|
|
57
|
+
if (ids.length === 1)
|
|
58
|
+
return { ok: true, botId: ids[0], via: 'single' };
|
|
59
|
+
const fallback = (cfg.fallbackBotId ?? '').trim();
|
|
60
|
+
const preset = (deps.sessionPreset ?? '').trim();
|
|
61
|
+
// 只有「能模式匹配」或「能交互」时才值得探针:纯自动路径 + 无默认 → 直接给候选报错,省一次握手。
|
|
62
|
+
const probed = (preset || deps.ask) ? await deps.probe() : { ok: false, error: 'probe-skipped' };
|
|
63
|
+
if (!probed.ok && (preset || deps.ask))
|
|
64
|
+
deps.log(`[im-delivery] bot probe unavailable: ${probed.error}`);
|
|
65
|
+
const candidates = probed.ok ? probed.bots.filter((b) => ids.includes(b.botId)) : [];
|
|
66
|
+
if (preset && candidates.length) {
|
|
67
|
+
const hit = candidates.filter((b) => b.connected && b.agentPreset === preset);
|
|
68
|
+
if (hit.length === 1)
|
|
69
|
+
return { ok: true, botId: hit[0].botId, via: 'preset' };
|
|
70
|
+
deps.log(hit.length > 1
|
|
71
|
+
? `[im-delivery] preset ${preset} 命中 ${hit.length} 个已连接机器人,不唯一,转默认/交互`
|
|
72
|
+
: `[im-delivery] preset ${preset} 未命中已连接机器人(候选模式:${candidates.map((b) => b.agentPreset || '未设').join(', ') || '无元数据'}),转默认/交互`);
|
|
73
|
+
}
|
|
74
|
+
if (fallback && ids.includes(fallback))
|
|
75
|
+
return { ok: true, botId: fallback, via: 'fallback' };
|
|
76
|
+
if (fallback)
|
|
77
|
+
deps.log(`[im-delivery] 默认机器人 ${fallback} 不在接入列表,转交互`);
|
|
78
|
+
if (!deps.ask)
|
|
79
|
+
return { ok: false, error: botCandidatesError(ids, candidates) };
|
|
80
|
+
const options = ids.map((id) => {
|
|
81
|
+
const c = candidates.find((b) => b.botId === id);
|
|
82
|
+
if (!c)
|
|
83
|
+
return { botId: id, label: '' };
|
|
84
|
+
return { botId: id, label: `${c.label || '未命名'}·${c.agentPreset || '未设模式'}${c.connected ? '' : '·离线'}` };
|
|
85
|
+
});
|
|
86
|
+
const answer = await deps.ask(options);
|
|
87
|
+
if (!answer)
|
|
88
|
+
return { ok: false, error: '未选择投递机器人(交互取消或超时),本次不投递' };
|
|
89
|
+
if (!answer.setDefault || !deps.setFallback)
|
|
90
|
+
return { ok: true, botId: answer.botId, via: 'interactive' };
|
|
91
|
+
const saved = await deps.setFallback(answer.botId);
|
|
92
|
+
if (!saved)
|
|
93
|
+
deps.log('[im-delivery] 默认机器人写入失败(配置未更新),本次仍按所选机器人投递');
|
|
94
|
+
return { ok: true, botId: answer.botId, via: 'interactive', fallbackSaved: saved };
|
|
95
|
+
}
|
|
96
|
+
/** dsh-im 目标列表(宿主同 Host 服务返回裸数组;防御兼容 Connection RPC `target.list` 信封形状)。 */
|
|
97
|
+
async function readTargets(im, botId) {
|
|
98
|
+
const raw = await im.listTargets(botId);
|
|
99
|
+
if (Array.isArray(raw))
|
|
100
|
+
return raw;
|
|
101
|
+
const enveloped = raw?.targets;
|
|
102
|
+
return Array.isArray(enveloped) ? enveloped : [];
|
|
103
|
+
}
|
|
104
|
+
/** targetId 解析:配置显式指定优先;留空自动发现该机器人下唯一已保存目标(按 kind)。
|
|
105
|
+
* 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed)。
|
|
106
|
+
* 机器人非显式指定(preset/默认/交互选出)而配置又填死了 targetId 时先校验归属:
|
|
107
|
+
* targetId 是 per-bot 作用域,填错机器人只会落成 dsh-im 的 unknown-target,这里提前给可行动报错。 */
|
|
108
|
+
export async function resolveTargetId(im, botId, botIdExplicit, cfg, kind) {
|
|
37
109
|
const explicit = (kind === 'group' ? cfg.targetId : cfg.dmTargetId ?? '').trim();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
110
|
+
const label = kind === 'group' ? '群目标' : '私聊目标';
|
|
111
|
+
const cfgHint = kind === 'group' ? 'imDelivery.targetId' : 'imDelivery.dmTargetId';
|
|
112
|
+
if (explicit) {
|
|
113
|
+
if (botIdExplicit)
|
|
114
|
+
return { targetId: explicit };
|
|
115
|
+
const targets = await readTargets(im, botId);
|
|
116
|
+
const owned = targets.some((x) => x.targetId === explicit);
|
|
117
|
+
if (owned)
|
|
118
|
+
return { targetId: explicit };
|
|
119
|
+
return { error: `${cfgHint}=${explicit} 不属于本轮选定的机器人 ${botId}(targetId 按机器人隔离)。请清空该字段改由自动发现,或显式指定 imDelivery.botId 固定机器人` };
|
|
120
|
+
}
|
|
121
|
+
const targets = await readTargets(im, botId);
|
|
122
|
+
const matched = targets.filter((x) => x.kind === kind);
|
|
123
|
+
if (matched.length !== 1) {
|
|
124
|
+
const candidates = matched.length > 1 ? ',候选:' + matched.map((x) => `${x.targetId}${x.name ? `(${x.name})` : ''}`).join(' / ') : '';
|
|
125
|
+
return { error: `已保存${label}数量=${matched.length}(期望 1,仅且只有一个)${candidates}。请到 dsh-im 设置→IM机器人 ${matched.length > 1 ? '清理多余目标' : '新建目标'}或在插件配置 ${cfgHint} 显式指定` };
|
|
53
126
|
}
|
|
54
|
-
return {
|
|
127
|
+
return { targetId: matched[0].targetId };
|
|
128
|
+
}
|
|
129
|
+
/** botId+targetId 一步解析(无交互/无探针的简化入口,保留给单机器人场景与单测)。 */
|
|
130
|
+
export async function resolveTarget(im, cfg, kind) {
|
|
131
|
+
const choice = await chooseBot(im, cfg, {
|
|
132
|
+
sessionPreset: null, probe: async () => ({ ok: false, error: 'probe-skipped' }), ask: null, setFallback: null, log: () => { },
|
|
133
|
+
});
|
|
134
|
+
if (!choice.ok)
|
|
135
|
+
return { error: choice.error };
|
|
136
|
+
const t = await resolveTargetId(im, choice.botId, cfg.botId.trim() !== '', cfg, kind);
|
|
137
|
+
if ('error' in t)
|
|
138
|
+
return t;
|
|
139
|
+
return { botId: choice.botId, targetId: t.targetId };
|
|
55
140
|
}
|
|
56
141
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
57
142
|
/** 发送 + 退避重试(仅可重试错误码;{sent:true} 即停——dsh-im 无幂等,成功后绝不重发)。 */
|
|
@@ -88,13 +173,90 @@ function makeDefaultLog(storageDir) {
|
|
|
88
173
|
catch { /* 忽略写失败 */ }
|
|
89
174
|
};
|
|
90
175
|
}
|
|
91
|
-
/**
|
|
176
|
+
/** 交互选择接线(仅手动投递路径):两问一次弹——选机器人 + 是否设为默认。
|
|
177
|
+
* agent 必传(exec.agent 的 live 实例):GUI 弹窗按 agent 路由;无询问通道返回 null(调用方 fail-closed 列候选),
|
|
178
|
+
* 取消/超时/服务端拒绝同样返回 null(不投),失败原因进日志(只带错误码,不含任何敏感载荷)。 */
|
|
179
|
+
function buildBotAsk(ctx, agent, log) {
|
|
180
|
+
const uq = ctx.get?.('userQuestions');
|
|
181
|
+
if (typeof uq?.ask !== 'function') {
|
|
182
|
+
log('[im-delivery] 机器人交互不可用:无 userQuestions 服务');
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return async (options) => {
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const timer = setTimeout(() => { controller.abort(); }, ASK_TIMEOUT_MS);
|
|
188
|
+
try {
|
|
189
|
+
const ans = await Promise.race([
|
|
190
|
+
uq.ask({
|
|
191
|
+
questions: [
|
|
192
|
+
{
|
|
193
|
+
id: 'im-bot',
|
|
194
|
+
header: '投递机器人',
|
|
195
|
+
question: '本轮企微投递用哪个机器人?(当前会话模式未匹配到任何机器人)',
|
|
196
|
+
detail: '展示名·默认模式;离线机器人不保证能发出。选择只作用于本次投递。',
|
|
197
|
+
options: options.map((o) => ({ label: askLabel(o), description: o.botId })),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'im-bot-default',
|
|
201
|
+
header: '设为默认',
|
|
202
|
+
question: '是否把所选机器人设为默认机器人?(之后模式未命中时直接使用,不再询问)',
|
|
203
|
+
detail: '写入插件配置 imDelivery.fallbackBotId(可在设置面板看到来源标记)。',
|
|
204
|
+
options: [{ label: ASK_SET_DEFAULT_LABEL }, { label: '仅本次' }],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
...(agent === undefined ? {} : { agent }),
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
}),
|
|
210
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('im-bot-ask-timeout')), ASK_TIMEOUT_MS)),
|
|
211
|
+
]);
|
|
212
|
+
const pick = ans.answers?.find((a) => a.id === 'im-bot');
|
|
213
|
+
const typed = pick?.custom?.trim() ?? '';
|
|
214
|
+
const selected = pick?.selected?.[0]?.trim() ?? '';
|
|
215
|
+
// 自由文本也接受,但必须是候选之一——防手写错 id 投到未知对象。
|
|
216
|
+
const botId = options.find((o) => askLabel(o) === selected || o.botId === selected || o.botId === typed)?.botId ?? '';
|
|
217
|
+
if (!botId) {
|
|
218
|
+
log('[im-delivery] 机器人交互已应答但未映射到候选(selected 与候选不符)');
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const setDefault = (ans.answers?.find((a) => a.id === 'im-bot-default')?.selected ?? []).includes(ASK_SET_DEFAULT_LABEL);
|
|
222
|
+
return { botId, setDefault };
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const code = err.code;
|
|
226
|
+
const name = err.name;
|
|
227
|
+
log(`[im-delivery] 机器人交互未完成: ${typeof code === 'string' && code ? code : typeof name === 'string' && name ? name : 'unknown'}`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** 写默认机器人接线:经 ConfigProvider.applyOverride 落 config-override.json + 审计留痕。 */
|
|
236
|
+
function buildSetFallback(configProvider) {
|
|
237
|
+
return async (botId) => {
|
|
238
|
+
try {
|
|
239
|
+
const snap = configProvider.snapshot().effective;
|
|
240
|
+
return configProvider.applyOverride({ ...snap, imDelivery: { fallbackBotId: botId } }).ok;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** 发送器工厂:resolveDshIm → chooseBot(fail-closed)→ resolveTargetId → sendWithRetry → 留痕。
|
|
92
248
|
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
93
249
|
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
94
250
|
export function createSender(ctx, kanban, configProvider, opts = {}) {
|
|
95
251
|
const log = opts.log ?? makeDefaultLog(deriveStorageDir(configProvider));
|
|
96
252
|
const delays = opts.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
97
253
|
const targetKind = opts.targetKind ?? 'group';
|
|
254
|
+
// 探针单飞缓存挂在发送器上(连发 /sms 不重复握手);交互与写默认只在手动路径接线(后台 auto 路径无人应答)。
|
|
255
|
+
const probe = opts.botChoice?.probe ?? createBotProbe(probeDepsFromCtx(ctx));
|
|
256
|
+
const ask = opts.botChoice?.ask !== undefined ? opts.botChoice.ask
|
|
257
|
+
: (opts.manual ? buildBotAsk(ctx, opts.agent, log) : null);
|
|
258
|
+
const setFallback = opts.botChoice?.setFallback !== undefined ? opts.botChoice.setFallback
|
|
259
|
+
: (opts.manual ? buildSetFallback(configProvider) : null);
|
|
98
260
|
return async (chainId, text) => {
|
|
99
261
|
const cfg = configProvider.getEffective().imDelivery;
|
|
100
262
|
const im = resolveDshIm(ctx, log);
|
|
@@ -111,15 +273,22 @@ export function createSender(ctx, kanban, configProvider, opts = {}) {
|
|
|
111
273
|
}
|
|
112
274
|
return { ok: false, error: DSH_IM_MISSING_ERROR };
|
|
113
275
|
}
|
|
114
|
-
const
|
|
276
|
+
const choice = await chooseBot(im, cfg, {
|
|
277
|
+
sessionPreset: opts.sessionPreset ?? null, probe, ask, setFallback, log,
|
|
278
|
+
});
|
|
279
|
+
if (!choice.ok) {
|
|
280
|
+
log(`[im-delivery] bot resolve failed chain=${chainId}: ${choice.error}`);
|
|
281
|
+
return { ok: false, error: choice.error };
|
|
282
|
+
}
|
|
283
|
+
const t = await resolveTargetId(im, choice.botId, cfg.botId.trim() !== '', cfg, targetKind);
|
|
115
284
|
if ('error' in t) {
|
|
116
285
|
log(`[im-delivery] target resolve failed chain=${chainId}: ${t.error}`);
|
|
117
286
|
return { ok: false, error: t.error };
|
|
118
287
|
}
|
|
119
|
-
const r = await sendWithRetry(im,
|
|
288
|
+
const r = await sendWithRetry(im, choice.botId, t.targetId, text, delays, log);
|
|
120
289
|
if (r.ok) {
|
|
121
|
-
log(`[im-delivery] delivered chain=${chainId} bot=${
|
|
122
|
-
return { ok: true, botId:
|
|
290
|
+
log(`[im-delivery] delivered chain=${chainId} bot=${choice.botId} target=${t.targetId} via=${choice.via}`);
|
|
291
|
+
return { ok: true, botId: choice.botId, targetId: t.targetId, via: choice.via, ...(choice.fallbackSaved === undefined ? {} : { fallbackSaved: choice.fallbackSaved }) };
|
|
123
292
|
}
|
|
124
293
|
// 超限显形(评审决议):dispatcher.log 留痕;auto 路径追加 events.jsonl 双留痕;投递失败绝不 block 链。
|
|
125
294
|
log(`[im-delivery] FAILED chain=${chainId}: ${r.error}`);
|
|
@@ -266,7 +435,7 @@ export async function sendChainReport(ctx, kanban, configProvider, opts, variant
|
|
|
266
435
|
? { ok: false, error: r.error, guidance: DSH_IM_MISSING_GUIDANCE }
|
|
267
436
|
: { ok: false, error: r.error };
|
|
268
437
|
}
|
|
269
|
-
return { ok: true, chainId, botId: r.botId, targetId: r.targetId };
|
|
438
|
+
return { ok: true, chainId, botId: r.botId, targetId: r.targetId, via: r.via, ...(r.fallbackSaved === undefined ? {} : { fallbackSaved: r.fallbackSaved }) };
|
|
270
439
|
}
|
|
271
440
|
/** 接线:订阅看板事件多播通道(不动 setOnTaskCompleted 单消费者钩子)。
|
|
272
441
|
* listener 同步返回,投递全程 fire-and-forget 自兜异常(publish 在 emit 队列内同步调用,不得拖慢落盘)。 */
|
|
@@ -279,8 +448,9 @@ export function wireImDelivery(ctx, kanban, configProvider, opts = {}) {
|
|
|
279
448
|
const cfg = configProvider.getEffective().imDelivery;
|
|
280
449
|
if (!cfg?.enabled)
|
|
281
450
|
return; // 功能关闭:零开销早退(非异常,不留痕)
|
|
282
|
-
// 仅 task/completed(带 taskId
|
|
283
|
-
|
|
451
|
+
// 仅 task/completed(带 taskId)/ chain/blocked / review/gave-up 关心事件流;其余早退,避免每个无关事件触发 snapshot 全量重放。
|
|
452
|
+
// 2026-09-15:+ review/gave-up(评审超限待裁决)——不再静默等看门狗 block,人工在 gave-up 时刻即获通知与两条出口。
|
|
453
|
+
if (!((ev.kind === 'task/completed' && ev.taskId) || ev.kind === 'chain/blocked' || ev.kind === 'review/gave-up'))
|
|
284
454
|
return;
|
|
285
455
|
const state = await kanban.snapshot();
|
|
286
456
|
if (ev.kind === 'task/completed' && ev.taskId) {
|
|
@@ -295,6 +465,22 @@ export function wireImDelivery(ctx, kanban, configProvider, opts = {}) {
|
|
|
295
465
|
const msg = buildBlockMessage(state, ev.chainId, String(ev.payload['reason'] ?? ''), storageDir);
|
|
296
466
|
await deliver(ev.chainId, msg);
|
|
297
467
|
}
|
|
468
|
+
else if (ev.kind === 'review/gave-up') {
|
|
469
|
+
const chain = state.chains.get(ev.chainId);
|
|
470
|
+
if (!chain)
|
|
471
|
+
return;
|
|
472
|
+
const reviewTaskId = String(ev.payload['reviewTaskId'] ?? '');
|
|
473
|
+
const targetTaskId = String(ev.payload['targetTaskId'] ?? '');
|
|
474
|
+
const msg = [
|
|
475
|
+
`【评审超限待裁决】${chain.title || ev.chainId} 的评审卡 ${reviewTaskId} 超限放弃(${String(ev.payload['reason'] ?? '')})。`,
|
|
476
|
+
`被评审卡:${targetTaskId}`,
|
|
477
|
+
'两条出口:',
|
|
478
|
+
'① 人工豁免评审 → 计划评审按通过继续(GUI 任务详情「豁免评审」按钮 / 主会话 kanban_waive_review);',
|
|
479
|
+
'② 人工恢复链后按需返工(GUI 链头「人工恢复」按钮 / 主会话 kanban_reopen_chain)。',
|
|
480
|
+
'若无人处置,看门狗将在链持续无进展后把链置 blocked(届时可人工恢复)。',
|
|
481
|
+
].join('\n');
|
|
482
|
+
await deliver(ev.chainId, msg);
|
|
483
|
+
}
|
|
298
484
|
})().catch((err) => log('[im-delivery] handler failed ev=' + ev.kind + ': ' + String(err)));
|
|
299
485
|
};
|
|
300
486
|
return kanban.subscribe(handle);
|
|
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
2
2
|
import {} from '@deepseek-ai/dsh-util-values';
|
|
3
3
|
import { KanbanService } from '../domain/kanban-service.js';
|
|
4
4
|
import { can } from '../domain/permissions.js';
|
|
5
|
+
import { resolveReviewTarget } from '../domain/review-target.js';
|
|
5
6
|
function guard(action, caller, task = null) {
|
|
6
7
|
if (!can(action, caller.actor, task))
|
|
7
8
|
throw new Error('permission denied: ' + action);
|
|
@@ -92,7 +93,7 @@ export function buildKanbanTools(service, getCaller) {
|
|
|
92
93
|
parameters: {
|
|
93
94
|
taskId: { type: 'string', required: true },
|
|
94
95
|
summary: { type: 'string', required: true, description: 'Human-readable completion summary' },
|
|
95
|
-
metadata: { type: 'json', description: 'Machine-readable handoff: changed_files/verification/kb_url...' },
|
|
96
|
+
metadata: { type: 'json', description: 'Machine-readable handoff: changed_files/verification/kb_url... W3/kb may add report = { requirement, status, branch, tasks: [{ text, done }], acceptance, verification, leftovers?, todos? } (delivery-report fields; tasks mirrors the OpenSpec plan checklist openspec/changes/<id>/tasks.md verbatim, done = checked)' },
|
|
96
97
|
},
|
|
97
98
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
98
99
|
async execute(args) {
|
|
@@ -166,5 +167,41 @@ export function buildKanbanTools(service, getCaller) {
|
|
|
166
167
|
return task;
|
|
167
168
|
},
|
|
168
169
|
}),
|
|
170
|
+
// 人工恢复能力(2026-09-15):仅 human(caller.actor 由主会话/角色装配注入;角色工具面不暴露这两个工具)。
|
|
171
|
+
defineTool({
|
|
172
|
+
name: 'kanban_reopen_chain',
|
|
173
|
+
description: 'Reopen a blocked chain back to executing (human only; reason required).',
|
|
174
|
+
parameters: {
|
|
175
|
+
chainId: { type: 'string', required: true, description: 'Chain id (ch_xxx)' },
|
|
176
|
+
reason: { type: 'string', required: true, description: 'Recovery reason (audited)' },
|
|
177
|
+
},
|
|
178
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
179
|
+
async execute(args) {
|
|
180
|
+
const caller = getCaller();
|
|
181
|
+
const ev = await service.reopenChain(args.chainId, args.reason, caller.actor);
|
|
182
|
+
return ev;
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
defineTool({
|
|
186
|
+
name: 'kanban_waive_review',
|
|
187
|
+
description: 'Waive a failed/gave-up review card for its target task (human only; reason required).',
|
|
188
|
+
parameters: {
|
|
189
|
+
taskId: { type: 'string', required: true, description: 'Review card id (pt/dt card)' },
|
|
190
|
+
reason: { type: 'string', required: true, description: 'Waive reason (audited)' },
|
|
191
|
+
},
|
|
192
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
193
|
+
async execute(args) {
|
|
194
|
+
const caller = getCaller();
|
|
195
|
+
const state = await service.snapshot();
|
|
196
|
+
const reviewTask = state.tasks.get(args.taskId);
|
|
197
|
+
if (!reviewTask)
|
|
198
|
+
throw new Error('unknown review task: ' + args.taskId);
|
|
199
|
+
const target = resolveReviewTarget(state, reviewTask, reviewTask.chainId);
|
|
200
|
+
if (!target)
|
|
201
|
+
throw new Error('review target not resolvable for ' + args.taskId);
|
|
202
|
+
const ev = await service.waiveReview(args.taskId, target.root.id, args.reason, caller.actor);
|
|
203
|
+
return ev;
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
169
206
|
];
|
|
170
207
|
}
|
|
@@ -131,6 +131,16 @@ function buildFreeSendGuidance(routes, query, dm) {
|
|
|
131
131
|
'投递结果只向用户确认成功与目标,不复述正文;失败时把 error/guidance 原样转告,勿编造原因。',
|
|
132
132
|
].join('\n');
|
|
133
133
|
}
|
|
134
|
+
/** 当前会话 preset(多机器人投递按它匹配机器人的默认 dsh 模式)。
|
|
135
|
+
* header.agentPreset 缺省(用默认模式创建的会话可能不落该字段)→ 回落到 agentPresets.defaultId;
|
|
136
|
+
* 两者都拿不到返回空串,调用方按「无模式匹配」处理(走默认机器人/交互,绝不猜)。 */
|
|
137
|
+
function sessionPresetOf(ctx, exec) {
|
|
138
|
+
const fromHeader = exec?.agent?.session?.header?.agentPreset;
|
|
139
|
+
if (typeof fromHeader === 'string' && fromHeader.trim())
|
|
140
|
+
return fromHeader.trim();
|
|
141
|
+
const presets = ctx.get?.('agentPresets');
|
|
142
|
+
return typeof presets?.defaultId === 'string' ? presets.defaultId : '';
|
|
143
|
+
}
|
|
134
144
|
/** v2 主会话工具面:/plan: 捕获规划上下文(零副作用)→ planning_checklist_save 回写 → /openspec: 建链。
|
|
135
145
|
* 工具面 = kanban_route + 只读 kanban 子集 + spec_card_view + planning 工具;
|
|
136
146
|
* 无 spec_card_edit/approve、无 kanban_create/complete/block(主会话越权写由工具面裁剪 + prefetch 子代理只读护栏双保险)。 */
|
|
@@ -150,8 +160,10 @@ export function registerMainSessionTools(ctx, configProvider) {
|
|
|
150
160
|
? new LocalWikiClient(ensureLocalKbRoot())
|
|
151
161
|
: (ctx.get('wiki') ?? new WikiVaultClient(() => configProvider.getEffective().wikiVault));
|
|
152
162
|
const caller = () => ({ actor: 'human' });
|
|
153
|
-
//
|
|
154
|
-
|
|
163
|
+
// 主会话 kanban 工具子集(无 create/complete/block)。2026-09-15 恢复能力:+ 人工恢复双工具
|
|
164
|
+
//(kanban_reopen_chain / kanban_waive_review)——主会话 caller 恒为 human(服务层再校验 can()),
|
|
165
|
+
// 角色 toolsets 名单不动(角色工具面零暴露)。
|
|
166
|
+
const readOnly = new Set(['kanban_show', 'kanban_chain', 'kanban_list', 'kanban_comment', 'kanban_reopen_chain', 'kanban_waive_review']);
|
|
155
167
|
for (const tool of buildKanbanTools(service, caller)) {
|
|
156
168
|
const name = tool.name;
|
|
157
169
|
if (name && readOnly.has(name))
|
|
@@ -232,15 +244,23 @@ export function registerMainSessionTools(ctx, configProvider) {
|
|
|
232
244
|
// 其余非空 rest → 自由投递(正文由主 agent 按意图生成,显式豁免;事实基准=看板实查)。
|
|
233
245
|
// -s 独立 token 由 parseSendRequest 剥离置 dm;manual 路径零重试。
|
|
234
246
|
const parsed = parseSendRequest(plan.rest);
|
|
235
|
-
const targetOpts = {
|
|
247
|
+
const targetOpts = {
|
|
248
|
+
retryDelaysMs: [], manual: true,
|
|
249
|
+
targetKind: (parsed.dm ? 'user' : 'group'),
|
|
250
|
+
sessionPreset: sessionPresetOf(ctx, exec),
|
|
251
|
+
agent: exec?.agent,
|
|
252
|
+
};
|
|
236
253
|
const deliver = async (variant, query) => {
|
|
237
254
|
const r = await sendChainReport(ctx, service, configProvider, targetOpts, variant, query);
|
|
238
255
|
if (r.ok) {
|
|
239
256
|
const noun = variant === 'blocked' ? '阻塞通知' : '完成汇报';
|
|
240
257
|
const dest = parsed.dm ? '私聊' : '群聊';
|
|
258
|
+
const via = r.via === 'interactive' ? `(本次交互选定${r.fallbackSaved ? ',已设为默认' : ''})`
|
|
259
|
+
: r.via === 'fallback' ? '(未命中会话模式,走默认机器人)'
|
|
260
|
+
: r.via === 'preset' ? '(按会话模式匹配机器人)' : '';
|
|
241
261
|
return {
|
|
242
|
-
kind: 'send', chainId: r.chainId, botId: r.botId, targetId: r.targetId,
|
|
243
|
-
guidance: `${noun}已投递${dest}(/sms 手动触发)。请仅向用户确认投递成功与目标,勿复述消息正文。`,
|
|
262
|
+
kind: 'send', chainId: r.chainId, botId: r.botId, targetId: r.targetId, via: r.via,
|
|
263
|
+
guidance: `${noun}已投递${dest}${via}(/sms 手动触发)。请仅向用户确认投递成功与目标,勿复述消息正文。`,
|
|
244
264
|
};
|
|
245
265
|
}
|
|
246
266
|
return { kind: 'send', error: r.error, guidance: r.guidance ?? '请将 error 字段原样转告用户,勿复述消息正文。' };
|
|
@@ -328,7 +348,7 @@ export function registerMainSessionTools(ctx, configProvider) {
|
|
|
328
348
|
dm: { type: 'boolean', description: 'true = private chat; omit/false = group' },
|
|
329
349
|
},
|
|
330
350
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v) }] },
|
|
331
|
-
async execute(args) {
|
|
351
|
+
async execute(args, exec) {
|
|
332
352
|
// actor 边界与 planning 工具同构:主会话工具面仅限人工触发,非 human caller 一律拒绝
|
|
333
353
|
if (caller().actor !== 'human')
|
|
334
354
|
throw new Error('permission denied: sms_send');
|
|
@@ -338,7 +358,10 @@ export function registerMainSessionTools(ctx, configProvider) {
|
|
|
338
358
|
if (text.length > 4000)
|
|
339
359
|
throw new Error(`text too long: ${text.length} > 4000 chars`);
|
|
340
360
|
const dm = args.dm === true;
|
|
341
|
-
const deliver = createSender(ctx, service, configProvider, {
|
|
361
|
+
const deliver = createSender(ctx, service, configProvider, {
|
|
362
|
+
retryDelaysMs: [], manual: true, targetKind: dm ? 'user' : 'group',
|
|
363
|
+
sessionPreset: sessionPresetOf(ctx, exec), agent: exec?.agent,
|
|
364
|
+
});
|
|
342
365
|
const r = await deliver('free-send', text);
|
|
343
366
|
if (!r.ok) {
|
|
344
367
|
return {
|
|
@@ -347,8 +370,10 @@ export function registerMainSessionTools(ctx, configProvider) {
|
|
|
347
370
|
};
|
|
348
371
|
}
|
|
349
372
|
return {
|
|
350
|
-
kind: 'send', mode: 'free', dm, botId: r.botId, targetId: r.targetId,
|
|
351
|
-
guidance: '已投递(自由投递)。请仅向用户确认投递成功与目标(群聊/私聊),勿复述正文。'
|
|
373
|
+
kind: 'send', mode: 'free', dm, botId: r.botId, targetId: r.targetId, via: r.via,
|
|
374
|
+
guidance: '已投递(自由投递)。请仅向用户确认投递成功与目标(群聊/私聊),勿复述正文。'
|
|
375
|
+
+ (r.fallbackSaved === true ? '已按你的选择把该机器人设为默认机器人(后续模式未命中时直接使用)。' : '')
|
|
376
|
+
+ (r.fallbackSaved === false ? '注意:设为默认未写入成功(配置未更新),下次仍需重新选择。' : ''),
|
|
352
377
|
};
|
|
353
378
|
},
|
|
354
379
|
}));
|
|
@@ -28,6 +28,7 @@ export function buildOcrReviewTool(deps) {
|
|
|
28
28
|
to: { type: 'string', description: '目标分支/引用(range 模式,默认 HEAD)' },
|
|
29
29
|
commit: { type: 'string', description: '单次提交审查' },
|
|
30
30
|
paths: { type: 'array', items: { type: 'string' }, description: 'rule 子命令的文件路径列表' },
|
|
31
|
+
background: { type: 'string', description: '业务上下文,托管评审时提升评审质量' },
|
|
31
32
|
},
|
|
32
33
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v) }] },
|
|
33
34
|
async execute(args) {
|
|
@@ -42,18 +43,24 @@ export function buildOcrReviewTool(deps) {
|
|
|
42
43
|
throw new Error("ocr_review: sub='rule' requires non-empty paths (file path list)");
|
|
43
44
|
if (args.sub === 'managed' && !args.commit && !args.from)
|
|
44
45
|
throw new Error("ocr_review: sub='managed' requires commit or from (base ref)");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// 托管评审走 ocr 自带 LLM(官方预算 15min×2 rounds),超时须远大于 preview/rule 的本地 git 操作
|
|
47
|
+
const timeoutMs = args.sub === 'managed' ? 2_400_000 : 600_000;
|
|
48
|
+
const res = await runOcrFn(buildOcrArgs(args.sub, { repo: args.repo, from: args.from, to: args.to, commit: args.commit, paths: args.paths, background: args.background }), { cwd: deps.cwd?.() ?? process.cwd(), timeoutMs });
|
|
49
|
+
// 失败不抛错:结果附 error 摘要(error + stderr 前 500 字符),模型拿得到部分结果与原因后自行降级/重试
|
|
50
|
+
const errSummary = res.error ? `${res.error} ${res.stderr.slice(0, 500)}`.trim() : '';
|
|
48
51
|
if (args.sub === 'preview') {
|
|
49
52
|
const preview = parsePreviewJson(res.stdout);
|
|
53
|
+
const errorPart = errSummary ? { error: errSummary } : {};
|
|
50
54
|
if (shouldSuggestManaged(preview.files.length)) {
|
|
51
|
-
return JSON.stringify({ ...preview, suggestion: `文件较多(N>${SUGGEST_MANAGED_FILES}),可在配置面板切换托管模式` });
|
|
55
|
+
return JSON.stringify({ ...preview, ...errorPart, suggestion: `文件较多(N>${SUGGEST_MANAGED_FILES}),可在配置面板切换托管模式` });
|
|
52
56
|
}
|
|
53
|
-
return JSON.stringify(preview);
|
|
57
|
+
return JSON.stringify({ ...preview, ...errorPart });
|
|
54
58
|
}
|
|
55
|
-
if (args.sub === 'rule')
|
|
59
|
+
if (args.sub === 'rule') {
|
|
60
|
+
if (errSummary)
|
|
61
|
+
return JSON.stringify({ error: errSummary });
|
|
56
62
|
return res.stdout.slice(0, 8000);
|
|
63
|
+
}
|
|
57
64
|
const report = parseManagedJson(res.stdout);
|
|
58
65
|
if (report.status !== 'completed' && res.error)
|
|
59
66
|
return JSON.stringify({ ...report, message: res.error });
|