dsh-advisor-plugin 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/cordis.patch.yml +6 -0
- package/lib/advisor-prompt.d.ts +28 -0
- package/lib/advisor-prompt.js +91 -0
- package/lib/client/AdvisorCard.d.ts +15 -0
- package/lib/client/AdvisorCard.js +60 -0
- package/lib/client/AdvisorToolRow.d.ts +40 -0
- package/lib/client/AdvisorToolRow.js +52 -0
- package/lib/client/PatrolNodeView.d.ts +18 -0
- package/lib/client/PatrolNodeView.js +57 -0
- package/lib/client/controller.d.ts +161 -0
- package/lib/client/controller.js +287 -0
- package/lib/client/index.d.ts +20 -0
- package/lib/client/index.js +841 -0
- package/lib/client/locales.d.ts +54 -0
- package/lib/client/locales.js +101 -0
- package/lib/client/patrol-chat.d.ts +78 -0
- package/lib/client/patrol-chat.js +54 -0
- package/lib/client/store.d.ts +11 -0
- package/lib/client/store.js +23 -0
- package/lib/command.d.ts +7 -0
- package/lib/command.js +52 -0
- package/lib/config.d.ts +37 -0
- package/lib/config.js +83 -0
- package/lib/emission-guard.d.ts +32 -0
- package/lib/emission-guard.js +74 -0
- package/lib/gating.d.ts +8 -0
- package/lib/gating.js +48 -0
- package/lib/history.d.ts +20 -0
- package/lib/history.js +95 -0
- package/lib/index.d.ts +25 -0
- package/lib/index.js +96 -0
- package/lib/llm-call.d.ts +69 -0
- package/lib/llm-call.js +483 -0
- package/lib/patrol-event.d.ts +88 -0
- package/lib/patrol-event.js +68 -0
- package/lib/patrol.d.ts +51 -0
- package/lib/patrol.js +202 -0
- package/lib/prompt-section.d.ts +7 -0
- package/lib/prompt-section.js +16 -0
- package/lib/settings.d.ts +18 -0
- package/lib/settings.js +50 -0
- package/lib/tool.d.ts +22 -0
- package/lib/tool.js +91 -0
- package/package.json +91 -0
- package/src/advisor-prompt.ts +102 -0
- package/src/client/AdvisorCard.tsx +253 -0
- package/src/client/AdvisorToolRow.tsx +83 -0
- package/src/client/PatrolNodeView.tsx +85 -0
- package/src/client/controller.ts +399 -0
- package/src/client/index.ts +100 -0
- package/src/client/locales.ts +105 -0
- package/src/client/patrol-chat.ts +109 -0
- package/src/client/store.ts +29 -0
- package/src/command.ts +61 -0
- package/src/config.ts +113 -0
- package/src/emission-guard.ts +76 -0
- package/src/gating.ts +54 -0
- package/src/history.ts +102 -0
- package/src/index.ts +106 -0
- package/src/llm-call.ts +552 -0
- package/src/patrol-event.ts +115 -0
- package/src/patrol.ts +229 -0
- package/src/prompt-section.ts +21 -0
- package/src/settings.ts +74 -0
- package/src/tool.ts +114 -0
package/src/llm-call.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llm-call —— 审查模型侧调用,走 ctx.llm.stream(复用 DSH 已注册的
|
|
3
|
+
* provider 路由与凭据——审查模型就是"DSH 已经配置好的模型"之一)。
|
|
4
|
+
*
|
|
5
|
+
* 聚合 text-delta、捕获 usage、把 finish 分块路由成类型化结果;
|
|
6
|
+
* 正常停止但无正文时恰好重试一次(aborted / error 短路不重试)。
|
|
7
|
+
*
|
|
8
|
+
* 调查工具(对照 oh-my-pi:advisor 默认授予 read/grep/glob,建议前先
|
|
9
|
+
* 亲自查证):传入 investigateRoot 时走 agentic 循环——审查模型可用
|
|
10
|
+
* 两个只读工具(工作区内正则搜索 / 读文件)核实事实后再出最终裁决。
|
|
11
|
+
* 工具在本地用 node:fs 实现(只读、锁定在会话工作区子树内、结果截断),
|
|
12
|
+
* 不经过宿主工具运行时(无审批/沙箱副作用,代价是只有这两个只读原语)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
|
+
import type {
|
|
20
|
+
ContentBlock,
|
|
21
|
+
FinishReason,
|
|
22
|
+
GenerateOptions,
|
|
23
|
+
Message,
|
|
24
|
+
ReasoningEffortId,
|
|
25
|
+
ToolCallBlock,
|
|
26
|
+
ToolSchema,
|
|
27
|
+
TokenUsage,
|
|
28
|
+
} from '@deepseek-ai/dsh-llm'
|
|
29
|
+
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
30
|
+
import type { Selection } from './config.js'
|
|
31
|
+
|
|
32
|
+
export type AdvisorOutcome =
|
|
33
|
+
| { ok: true, text: string, usage: TokenUsage | undefined, finishKind: string }
|
|
34
|
+
| { ok: false, errorMessage: string }
|
|
35
|
+
|
|
36
|
+
// 侧调用深度计数:巡逻的 llm/stream 注入据此排除审查模型自己的请求
|
|
37
|
+
// (巡逻侧调用与显式 advisor() 工具调用都不该被注入干预)
|
|
38
|
+
let reviewerCallDepth = 0
|
|
39
|
+
|
|
40
|
+
export function isReviewerCallActive(): boolean {
|
|
41
|
+
return reviewerCallDepth > 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AttemptResult {
|
|
45
|
+
finish: FinishReason
|
|
46
|
+
text: string
|
|
47
|
+
usage: TokenUsage | undefined
|
|
48
|
+
/** 本轮的完整内容块(block-end 顺序)——调查循环用它重建 assistant 消息 */
|
|
49
|
+
blocks: ContentBlock[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runAttempt(
|
|
53
|
+
ctx: Context,
|
|
54
|
+
selection: Selection,
|
|
55
|
+
systemPrompt: string,
|
|
56
|
+
messages: Message[],
|
|
57
|
+
signal: AbortSignal | undefined,
|
|
58
|
+
tools?: readonly ToolSchema[],
|
|
59
|
+
sessionId?: SessionId,
|
|
60
|
+
): Promise<AttemptResult> {
|
|
61
|
+
let text = ''
|
|
62
|
+
let usage: TokenUsage | undefined
|
|
63
|
+
let finish: FinishReason | undefined
|
|
64
|
+
const blocks: ContentBlock[] = []
|
|
65
|
+
const options: GenerateOptions = {
|
|
66
|
+
provider: selection.provider,
|
|
67
|
+
model: selection.model,
|
|
68
|
+
messages,
|
|
69
|
+
system: systemPrompt,
|
|
70
|
+
...(signal === undefined ? {} : { signal }),
|
|
71
|
+
...(tools === undefined ? {} : { tools: [...tools] }),
|
|
72
|
+
// 侧调用也要带上主会话 id:部分适配器(example-provider 等)依赖它做请求路由
|
|
73
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
74
|
+
}
|
|
75
|
+
if (selection.effort !== undefined) options.reasoningEffort = selection.effort as ReasoningEffortId
|
|
76
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
77
|
+
if (chunk.type === 'text-delta') {
|
|
78
|
+
text += chunk.text
|
|
79
|
+
} else if (chunk.type === 'usage') {
|
|
80
|
+
usage = chunk.usage
|
|
81
|
+
} else if (chunk.type === 'block-end') {
|
|
82
|
+
blocks.push(chunk.block)
|
|
83
|
+
} else if (chunk.type === 'finish') {
|
|
84
|
+
finish = chunk.reason
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (finish === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
finish: { kind: 'error', failure: { message: 'stream ended without a finish chunk', code: 'ADVISOR_NO_FINISH' } },
|
|
90
|
+
text,
|
|
91
|
+
usage,
|
|
92
|
+
blocks,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { finish, text, usage, blocks }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function terminal(finish: FinishReason): Extract<AdvisorOutcome, { ok: false }> | undefined {
|
|
99
|
+
if (finish.kind === 'aborted') {
|
|
100
|
+
return { ok: false, errorMessage: `Advisor 调用在完成前被取消(${finish.failure.message})` }
|
|
101
|
+
}
|
|
102
|
+
if (finish.kind === 'error') {
|
|
103
|
+
return { ok: false, errorMessage: `Advisor 调用失败:${finish.failure.code}: ${finish.failure.message}` }
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 剥离审查模型对执行模型"最后一句话"的回显。实测部分审查模型(经由
|
|
110
|
+
* 代理网关)会在正式建议前先复读执行模型调用 advisor 前的可见文本
|
|
111
|
+
* (如"好的,马上调用 advisor 咨询!")——对执行模型毫无信息量。
|
|
112
|
+
* 仅当 guidance 的首个非空行与转发消息里最后一条 assistant 文本完全
|
|
113
|
+
* 一致时剥掉该行,误伤面最小。
|
|
114
|
+
*/
|
|
115
|
+
export function stripExecutorEcho(guidance: string, messages: readonly Message[]): string {
|
|
116
|
+
let lastAssistantText: string | undefined
|
|
117
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
118
|
+
const msg = messages[i]
|
|
119
|
+
if (msg?.role !== 'assistant') continue
|
|
120
|
+
for (let j = msg.content.length - 1; j >= 0; j -= 1) {
|
|
121
|
+
const block = msg.content[j]
|
|
122
|
+
if (block?.type === 'text' && block.text.trim() !== '') {
|
|
123
|
+
lastAssistantText = block.text.trim()
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
if (lastAssistantText === undefined) return guidance
|
|
130
|
+
const trimmed = guidance.trimStart()
|
|
131
|
+
if (!trimmed.startsWith(lastAssistantText)) return guidance
|
|
132
|
+
const rest = trimmed.slice(lastAssistantText.length)
|
|
133
|
+
return rest.replace(/^\s*\n+/, '').trimStart() === '' ? guidance.trim() : rest.replace(/^\s*\n+/, '').trimStart()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 内部一轮调用的结果:公开的 AdvisorOutcome 字段 + 本轮块与工具调用 */
|
|
137
|
+
interface InnerOutcome {
|
|
138
|
+
ok: true
|
|
139
|
+
text: string
|
|
140
|
+
usage: TokenUsage | undefined
|
|
141
|
+
finishKind: string
|
|
142
|
+
blocks: ContentBlock[]
|
|
143
|
+
toolCalls: ToolCallBlock[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 审查侧调用入口。
|
|
148
|
+
* @param investigateRoot 提供时启用调查循环:审查模型可用只读工具在工作区
|
|
149
|
+
* 内核实后再出最终裁决(对照 oh-my-pi 的 advisor 调查授权)
|
|
150
|
+
*/
|
|
151
|
+
export async function callReviewer(
|
|
152
|
+
ctx: Context,
|
|
153
|
+
selection: Selection,
|
|
154
|
+
systemPrompt: string,
|
|
155
|
+
messages: Message[],
|
|
156
|
+
signal: AbortSignal | undefined,
|
|
157
|
+
investigateRoot?: string,
|
|
158
|
+
sessionId?: SessionId,
|
|
159
|
+
): Promise<AdvisorOutcome> {
|
|
160
|
+
reviewerCallDepth += 1
|
|
161
|
+
try {
|
|
162
|
+
const system = investigateRoot === undefined ? systemPrompt : systemPrompt + INVESTIGATE_NOTE
|
|
163
|
+
if (investigateRoot === undefined) {
|
|
164
|
+
const outcome = await attemptWithDegradation(ctx, selection, system, messages, signal, undefined, sessionId)
|
|
165
|
+
if (outcome.ok === false) return outcome
|
|
166
|
+
return { ok: true, text: outcome.text, usage: outcome.usage, finishKind: outcome.finishKind }
|
|
167
|
+
}
|
|
168
|
+
return await investigateLoop(ctx, selection, system, messages, signal, investigateRoot, sessionId)
|
|
169
|
+
} finally {
|
|
170
|
+
reviewerCallDepth -= 1
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 单轮调用 + 双降级(档位不支持 → 默认档;上下文溢出 → 配对安全截断) */
|
|
175
|
+
async function attemptWithDegradation(
|
|
176
|
+
ctx: Context,
|
|
177
|
+
selection: Selection,
|
|
178
|
+
systemPrompt: string,
|
|
179
|
+
messages: Message[],
|
|
180
|
+
signal: AbortSignal | undefined,
|
|
181
|
+
tools: readonly ToolSchema[] | undefined,
|
|
182
|
+
sessionId?: SessionId,
|
|
183
|
+
): Promise<InnerOutcome | { ok: false, errorMessage: string }> {
|
|
184
|
+
let outcome = await callReviewerInner(ctx, selection, systemPrompt, messages, signal, tools, sessionId)
|
|
185
|
+
// 思考强度降级:配置的 effort 档位不被该模型支持时(实测 example-model+max 全军
|
|
186
|
+
// 覆没的场景),剥掉 reasoningEffort 重试一次——宁可走模型默认档,不可静默失败
|
|
187
|
+
if (outcome.ok === false && selection.effort !== undefined && isUnsupportedEffortError(outcome.errorMessage)) {
|
|
188
|
+
console.warn(`[dsh-advisor] 配置的推理档位 "${selection.effort}" 不被 ${selection.provider}/${selection.model} 支持,已自动降级为模型默认档(可在设置页调整档位)`)
|
|
189
|
+
outcome = await callReviewerInner(
|
|
190
|
+
ctx,
|
|
191
|
+
{ provider: selection.provider, model: selection.model },
|
|
192
|
+
systemPrompt,
|
|
193
|
+
messages,
|
|
194
|
+
signal,
|
|
195
|
+
tools,
|
|
196
|
+
sessionId,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
// 上下文截断降级:执行模型窗口(如 1M)远大于审查模型(如 190k)时,长会话
|
|
200
|
+
// 整段转发会溢出(实测 reviewer-model 报 CONTEXT_WINDOW_EXCEEDED)。截到尾部
|
|
201
|
+
// 近况重试一次——巡逻裁决主要依赖近期行为,截断后仍足以判断方向。
|
|
202
|
+
if (outcome.ok === false && isContextOverflowError(outcome.errorMessage) && messages.length > 8) {
|
|
203
|
+
const truncated = truncateForReviewer(messages)
|
|
204
|
+
console.warn(`[dsh-advisor] 会话超出审查模型窗口,已截断转发(${messages.length} → ${truncated.length} 条消息),仅保留工具清单与近期活动`)
|
|
205
|
+
outcome = await callReviewerInner(ctx, selection, systemPrompt, truncated, signal, tools, sessionId)
|
|
206
|
+
}
|
|
207
|
+
return outcome
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 调查循环轮数上限(对照 OMP"每次建议 2-3 次调用,blocker 前可更多") */
|
|
211
|
+
const MAX_INVESTIGATE_ROUNDS = 4
|
|
212
|
+
|
|
213
|
+
async function investigateLoop(
|
|
214
|
+
ctx: Context,
|
|
215
|
+
selection: Selection,
|
|
216
|
+
systemPrompt: string,
|
|
217
|
+
messages: Message[],
|
|
218
|
+
signal: AbortSignal | undefined,
|
|
219
|
+
root: string,
|
|
220
|
+
sessionId?: SessionId,
|
|
221
|
+
): Promise<AdvisorOutcome> {
|
|
222
|
+
const convo: Message[] = [...messages]
|
|
223
|
+
const execute = createInvestigateExecutor(root)
|
|
224
|
+
let totalUsage: TokenUsage | undefined
|
|
225
|
+
for (let round = 0; ; round += 1) {
|
|
226
|
+
const isFinal = round >= MAX_INVESTIGATE_ROUNDS
|
|
227
|
+
const inner = await attemptWithDegradation(ctx, selection, systemPrompt, convo, signal, isFinal ? undefined : INVESTIGATE_TOOL_SCHEMAS, sessionId)
|
|
228
|
+
if (inner.ok === false) return inner
|
|
229
|
+
if (totalUsage === undefined) totalUsage = inner.usage
|
|
230
|
+
else if (inner.usage !== undefined) totalUsage = sumUsage(totalUsage, inner.usage)
|
|
231
|
+
if (inner.toolCalls.length === 0 || isFinal) {
|
|
232
|
+
return { ok: true, text: inner.text, usage: totalUsage, finishKind: inner.finishKind }
|
|
233
|
+
}
|
|
234
|
+
// 重建本轮 assistant 消息(含工具调用块),执行调查工具并回灌结果
|
|
235
|
+
convo.push(createAssistantMessage({
|
|
236
|
+
content: inner.blocks.filter(b => b.type === 'text' || b.type === 'tool-call'),
|
|
237
|
+
source: { provider: selection.provider, model: selection.model },
|
|
238
|
+
}))
|
|
239
|
+
for (const call of inner.toolCalls) {
|
|
240
|
+
const started = Date.now()
|
|
241
|
+
const result = await execute(call)
|
|
242
|
+
console.log(`[dsh-advisor] 审查者调查 ${call.name}(${Date.now() - started}ms,${result.isError ? '失败' : `${result.content.length} 字符`})`)
|
|
243
|
+
convo.push(createToolResultMessage({
|
|
244
|
+
callId: call.id,
|
|
245
|
+
content: [{ type: 'text', text: result.content }],
|
|
246
|
+
isError: result.isError,
|
|
247
|
+
}))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sumUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
253
|
+
return {
|
|
254
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
255
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
256
|
+
...(a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined
|
|
257
|
+
? { cacheReadTokens: (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0) }
|
|
258
|
+
: {}),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 是否"模型不支持该推理档位"类错误(纯函数,可单测) */
|
|
263
|
+
export function isUnsupportedEffortError(errorMessage: string | undefined): boolean {
|
|
264
|
+
if (errorMessage === undefined) return false
|
|
265
|
+
const upper = errorMessage.toUpperCase()
|
|
266
|
+
return upper.includes('UNSUPPORTED_REASONING_EFFORT') || upper.includes('DOES NOT SUPPORT REASONING EFFORT')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 是否"上下文窗口溢出"类错误(纯函数,可单测) */
|
|
270
|
+
export function isContextOverflowError(errorMessage: string | undefined): boolean {
|
|
271
|
+
if (errorMessage === undefined) return false
|
|
272
|
+
const upper = errorMessage.toUpperCase()
|
|
273
|
+
return upper.includes('CONTEXT_WINDOW_EXCEEDED') || upper.includes('CONTEXT LENGTH EXCEEDED') || upper.includes('CONTEXTWINDOW')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 截断系数:保留尾部消息条数占比(溢出通常差数倍,砍到 1/4 最稳) */
|
|
277
|
+
const TRUNCATE_KEEP_RATIO = 0.25
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 是否 user 角色的工具结果消息(截断配对安全的判定单元,纯函数可单测)。
|
|
281
|
+
*/
|
|
282
|
+
function isToolResultMessage(message: Message | undefined): boolean {
|
|
283
|
+
return message !== undefined
|
|
284
|
+
&& message.role === 'user'
|
|
285
|
+
&& message.content.some(block => block.type === 'tool-result')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 溢出时的截断转发(纯函数,可单测):保留首条(工具清单合成消息)+
|
|
290
|
+
* 尾部约 1/4 的近期消息,并在衔接处插入一条截断说明。巡逻/咨询的裁决
|
|
291
|
+
* 主要依赖近期行为,截断后仍足以判断方向。
|
|
292
|
+
*
|
|
293
|
+
* 配对安全:消息边界截断可能把 tool_use 切在丢弃区、把配对的
|
|
294
|
+
* tool_result 留在保留区头部——孤儿 tool_result 会被 Claude/OpenAI 类
|
|
295
|
+
* provider 整包拒绝(实测 `unexpected tool_use_id ... must have a
|
|
296
|
+
* corresponding tool_use block`)。从保留区头部起剥掉含 tool-result 块
|
|
297
|
+
* 的 user 消息,直到首条不再引用被切断的调用。
|
|
298
|
+
*/
|
|
299
|
+
export function truncateForReviewer(messages: readonly Message[]): Message[] {
|
|
300
|
+
if (messages.length <= 8) return [...messages]
|
|
301
|
+
const head = messages.slice(0, 1)
|
|
302
|
+
const keepCount = Math.max(4, Math.floor(messages.length * TRUNCATE_KEEP_RATIO))
|
|
303
|
+
let tail = messages.slice(-keepCount)
|
|
304
|
+
while (tail.length > 0 && isToolResultMessage(tail[0])) {
|
|
305
|
+
tail = tail.slice(1)
|
|
306
|
+
}
|
|
307
|
+
const notice = createUserMessage({
|
|
308
|
+
content: [{ type: 'text', text: '[advisor] Earlier conversation was truncated to fit the reviewer\'s context window; judge from the recent activity below.' }],
|
|
309
|
+
source: { kind: 'user' },
|
|
310
|
+
})
|
|
311
|
+
return [...head, notice, ...tail]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function callReviewerInner(
|
|
315
|
+
ctx: Context,
|
|
316
|
+
selection: Selection,
|
|
317
|
+
systemPrompt: string,
|
|
318
|
+
messages: Message[],
|
|
319
|
+
signal: AbortSignal | undefined,
|
|
320
|
+
tools?: readonly ToolSchema[],
|
|
321
|
+
sessionId?: SessionId,
|
|
322
|
+
): Promise<InnerOutcome | { ok: false, errorMessage: string }> {
|
|
323
|
+
let attempt = await runAttempt(ctx, selection, systemPrompt, messages, signal, tools, sessionId)
|
|
324
|
+
const toolCallsOf = (a: typeof attempt): ToolCallBlock[] => a.blocks.filter((b): b is ToolCallBlock => b.type === 'tool-call')
|
|
325
|
+
|
|
326
|
+
const firstTerminal = terminal(attempt.finish)
|
|
327
|
+
if (firstTerminal !== undefined) return firstTerminal
|
|
328
|
+
|
|
329
|
+
// 有界重试:持久返回空正文且未请求工具的 provider 不会热循环
|
|
330
|
+
// (调查轮里"只有工具调用没有正文"是正常形态,不触发重试)
|
|
331
|
+
if (attempt.text.trim() === '' && toolCallsOf(attempt).length === 0) {
|
|
332
|
+
attempt = await runAttempt(ctx, selection, systemPrompt, messages, signal, tools, sessionId)
|
|
333
|
+
const retryTerminal = terminal(attempt.finish)
|
|
334
|
+
if (retryTerminal !== undefined) return retryTerminal
|
|
335
|
+
if (attempt.text.trim() === '' && toolCallsOf(attempt).length === 0) {
|
|
336
|
+
return { ok: false, errorMessage: 'Advisor 返回了空正文(已重试一次)。' }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
text: attempt.text.trim(),
|
|
343
|
+
usage: attempt.usage,
|
|
344
|
+
finishKind: attempt.finish.kind,
|
|
345
|
+
blocks: attempt.blocks,
|
|
346
|
+
toolCalls: toolCallsOf(attempt),
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─────────────────────────── 审查者调查工具 ───────────────────────────
|
|
351
|
+
// 对照 oh-my-pi:advisor 默认被授予 read/grep/glob,建议前先亲自查证。
|
|
352
|
+
// 这里用 node:fs 实现等价的两个只读原语,锁定在会话工作区子树内。
|
|
353
|
+
|
|
354
|
+
/** 启用调查时追加到系统提示的说明(最终裁决格式契约不变) */
|
|
355
|
+
const INVESTIGATE_NOTE = '\n\nYou may call the provided read-only tools (search_files / read_file) to verify facts in the workspace before giving your verdict. Investigate sparingly (a few calls), then answer in the exact format requested above.'
|
|
356
|
+
|
|
357
|
+
const INVESTIGATE_TOOL_SCHEMAS: readonly ToolSchema[] = [
|
|
358
|
+
{
|
|
359
|
+
name: 'search_files',
|
|
360
|
+
description: 'Recursively search file contents inside the session workspace (case-sensitive regex). Returns file:line: text matches (max 50). Use to verify code/config facts before ruling.',
|
|
361
|
+
parameters: {
|
|
362
|
+
type: 'object',
|
|
363
|
+
properties: {
|
|
364
|
+
pattern: { type: 'string', description: 'Regex, e.g. poiRegion|districtCode' },
|
|
365
|
+
glob: { type: 'string', description: 'Optional filename filter, e.g. *.java or **/*.ts' },
|
|
366
|
+
},
|
|
367
|
+
required: ['pattern'],
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'read_file',
|
|
372
|
+
description: 'Read one file inside the session workspace with line numbers (max 200 lines per call).',
|
|
373
|
+
parameters: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
properties: {
|
|
376
|
+
path: { type: 'string', description: 'Path relative to the workspace root' },
|
|
377
|
+
startLine: { type: 'number', description: 'First line to read (default 1)' },
|
|
378
|
+
endLine: { type: 'number', description: 'Last line to read (default startLine+199)' },
|
|
379
|
+
},
|
|
380
|
+
required: ['path'],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
/** 搜索时跳过的目录与超大文件 */
|
|
386
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'target', '.venv', '__pycache__', '.idea', '.next', 'out'])
|
|
387
|
+
const MAX_FILE_BYTES = 1_500_000
|
|
388
|
+
const MAX_READ_LINES = 200
|
|
389
|
+
const MAX_OUTPUT_CHARS = 12_000
|
|
390
|
+
const MAX_MATCHES = 50
|
|
391
|
+
|
|
392
|
+
/** 简易 glob → RegExp(* 不跨目录、** 跨目录、双星号加斜杠匹配零个或多个目录、? 单字符) */
|
|
393
|
+
export function globToRegExp(glob: string): RegExp {
|
|
394
|
+
let re = ''
|
|
395
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
396
|
+
const c = glob[i]
|
|
397
|
+
if (c === '*') {
|
|
398
|
+
if (glob[i + 1] === '*') {
|
|
399
|
+
if (glob[i + 2] === '/') {
|
|
400
|
+
// **/ 允许零层目录:src/**/*.ts 也要匹配 src/a.ts
|
|
401
|
+
re += '(?:.*/)?'
|
|
402
|
+
i += 2
|
|
403
|
+
} else {
|
|
404
|
+
re += '.*'
|
|
405
|
+
i += 1
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
re += '[^/]*'
|
|
409
|
+
}
|
|
410
|
+
} else if (c === '?') {
|
|
411
|
+
re += '[^/]'
|
|
412
|
+
} else {
|
|
413
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return new RegExp(`(^|/)${re}$`)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 把目标路径解析进工作区子树(纯函数,可单测):越界(../ 或绝对路径指向
|
|
421
|
+
* 区外)返回 undefined——审查者的读取面被硬限制在会话工作区内。
|
|
422
|
+
*/
|
|
423
|
+
export function resolveWithinRoot(root: string, target: string): string | undefined {
|
|
424
|
+
const base = path.resolve(root)
|
|
425
|
+
const resolved = path.resolve(base, target)
|
|
426
|
+
const rel = path.relative(base, resolved)
|
|
427
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return undefined
|
|
428
|
+
return resolved
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 截断到字符上限并标注(结果给模型看的部分保持紧凑) */
|
|
432
|
+
function clampOutput(text: string): string {
|
|
433
|
+
if (text.length <= MAX_OUTPUT_CHARS) return text
|
|
434
|
+
return `${text.slice(0, MAX_OUTPUT_CHARS)}\n… [truncated at ${MAX_OUTPUT_CHARS} chars]`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function searchFiles(root: string, args: Record<string, unknown>): Promise<{ content: string, isError: boolean }> {
|
|
438
|
+
const pattern = typeof args.pattern === 'string' ? args.pattern : ''
|
|
439
|
+
if (pattern === '') return { content: 'missing "pattern"', isError: true }
|
|
440
|
+
let regex: RegExp
|
|
441
|
+
try {
|
|
442
|
+
regex = new RegExp(pattern)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
return { content: `invalid regex: ${String(error)}`, isError: true }
|
|
445
|
+
}
|
|
446
|
+
const glob = typeof args.glob === 'string' && args.glob !== '' ? globToRegExp(args.glob) : undefined
|
|
447
|
+
const base = path.resolve(root)
|
|
448
|
+
const out: string[] = []
|
|
449
|
+
const stack: string[] = [base]
|
|
450
|
+
let scanned = 0
|
|
451
|
+
while (stack.length > 0 && out.length < MAX_MATCHES && scanned < 4_000) {
|
|
452
|
+
const dir = stack.pop()
|
|
453
|
+
if (dir === undefined) break
|
|
454
|
+
let entries
|
|
455
|
+
try {
|
|
456
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
457
|
+
} catch {
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
if (out.length >= MAX_MATCHES) break
|
|
462
|
+
const full = path.join(dir, entry.name)
|
|
463
|
+
if (entry.isDirectory()) {
|
|
464
|
+
if (!SKIP_DIRS.has(entry.name)) stack.push(full)
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
if (!entry.isFile()) continue
|
|
468
|
+
// glob 同时匹配相对路径与文件名:支持 src/**/*.ts 这类路径模式,
|
|
469
|
+
// 也保留 *.java 这类仅文件名模式(旧行为不回归)
|
|
470
|
+
const relativePath = path.relative(base, full)
|
|
471
|
+
if (glob !== undefined && !glob.test(relativePath) && !glob.test(entry.name)) continue
|
|
472
|
+
scanned += 1
|
|
473
|
+
try {
|
|
474
|
+
const info = await stat(full)
|
|
475
|
+
if (info.size > MAX_FILE_BYTES) continue
|
|
476
|
+
const text = await readFile(full, 'utf8')
|
|
477
|
+
const lines = text.split('\n')
|
|
478
|
+
for (let i = 0; i < lines.length && out.length < MAX_MATCHES; i += 1) {
|
|
479
|
+
if (regex.test(lines[i] ?? '')) {
|
|
480
|
+
out.push(`${path.relative(base, full)}:${i + 1}: ${(lines[i] ?? '').trim().slice(0, 200)}`)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
// 不可读/非文本文件直接跳过
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (out.length === 0) return { content: `no matches for /${pattern}/`, isError: false }
|
|
489
|
+
return { content: clampOutput(out.join('\n')), isError: false }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function readFileSlice(root: string, args: Record<string, unknown>): Promise<{ content: string, isError: boolean }> {
|
|
493
|
+
const target = typeof args.path === 'string' ? args.path : ''
|
|
494
|
+
if (target === '') return { content: 'missing "path"', isError: true }
|
|
495
|
+
const resolved = resolveWithinRoot(root, target)
|
|
496
|
+
if (resolved === undefined) return { content: `path escapes the workspace: ${target}`, isError: true }
|
|
497
|
+
// 词法钳制挡不住符号链接:按真实路径再确认一次,防止工作区内的
|
|
498
|
+
// symlink 把审查者读到区外(如 ~/.ssh、/etc)
|
|
499
|
+
let safePath: string
|
|
500
|
+
try {
|
|
501
|
+
const realRoot = await realpath(path.resolve(root))
|
|
502
|
+
const realTarget = await realpath(resolved)
|
|
503
|
+
const rel = path.relative(realRoot, realTarget)
|
|
504
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
505
|
+
return { content: `path escapes the workspace through a symlink: ${target}`, isError: true }
|
|
506
|
+
}
|
|
507
|
+
safePath = realTarget
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { content: `cannot read ${target}: ${String(error)}`, isError: true }
|
|
510
|
+
}
|
|
511
|
+
const rawStart = typeof args.startLine === 'number' && Number.isFinite(args.startLine) ? args.startLine : 1
|
|
512
|
+
const startLine = Math.max(1, Math.floor(rawStart))
|
|
513
|
+
// endLine 是 schema 公开参数:尊重调用方请求,但仍保留单次 200 行硬上限
|
|
514
|
+
const rawEnd = typeof args.endLine === 'number' && Number.isFinite(args.endLine)
|
|
515
|
+
? args.endLine
|
|
516
|
+
: startLine + MAX_READ_LINES - 1
|
|
517
|
+
const endLine = Math.min(Math.floor(rawEnd), startLine + MAX_READ_LINES - 1)
|
|
518
|
+
let text: string
|
|
519
|
+
try {
|
|
520
|
+
const info = await stat(safePath)
|
|
521
|
+
if (info.size > MAX_FILE_BYTES * 10) return { content: `file too large (${info.size} bytes)`, isError: true }
|
|
522
|
+
text = await readFile(safePath, 'utf8')
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return { content: `cannot read ${target}: ${String(error)}`, isError: true }
|
|
525
|
+
}
|
|
526
|
+
const lines = text.split('\n')
|
|
527
|
+
const picked: string[] = []
|
|
528
|
+
for (let i = startLine - 1; i < Math.min(endLine, lines.length); i += 1) {
|
|
529
|
+
picked.push(`${i + 1}\t${lines[i] ?? ''}`)
|
|
530
|
+
}
|
|
531
|
+
if (picked.length === 0) return { content: `empty range ${startLine}-${endLine} (file has ${lines.length} lines)`, isError: true }
|
|
532
|
+
return { content: clampOutput(picked.join('\n')), isError: false }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 调查工具执行器(只读、限工作区、结果截断) */
|
|
536
|
+
export function createInvestigateExecutor(root: string): (call: ToolCallBlock) => Promise<{ content: string, isError: boolean }> {
|
|
537
|
+
return async (call) => {
|
|
538
|
+
let args: Record<string, unknown>
|
|
539
|
+
try {
|
|
540
|
+
args = JSON.parse(call.arguments) as Record<string, unknown>
|
|
541
|
+
} catch {
|
|
542
|
+
return { content: `arguments is not valid JSON: ${call.arguments.slice(0, 120)}`, isError: true }
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
if (call.name === 'search_files') return await searchFiles(root, args)
|
|
546
|
+
if (call.name === 'read_file') return await readFileSlice(root, args)
|
|
547
|
+
return { content: `unknown tool: ${call.name}`, isError: true }
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return { content: `tool failed: ${String(error)}`, isError: true }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patrol-event —— 巡逻裁决的会话事件契约(host 写入端与 client 认领端共享)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么借用 `hook/invoked` 类型:巡逻裁决要"写进会话日志"才能在消息流
|
|
5
|
+
* 出卡片且刷新/重载后仍在,但持久化读取路径会拒绝 SessionEventMap 之外
|
|
6
|
+
* 的未知事件类型(整个会话档无法重载——宁可不出卡片也不能 brick 会话),
|
|
7
|
+
* 而 Session.append 又不提供 ignorable 标记的写入面。`hook/invoked` 在
|
|
8
|
+
* 0.1.1 已列入持久化已知词汇表,但本版本全运行时零生产者/零消费者
|
|
9
|
+
* (hooks 子系统此版本不存在),是唯一"已知且无人认领"的惰性类型。
|
|
10
|
+
* 载荷用 plugin + point 双字段判别:客户端 definition 只认领自己的事件,
|
|
11
|
+
* 将来真正的 hooks 子系统事件原样落回 stock 兜底路径,互不干扰。
|
|
12
|
+
*
|
|
13
|
+
* 该事件是 log-only(非 surface):执行模型看不到它——模型面的干预注入
|
|
14
|
+
* 仍走 systemPrompt 动态段(patrol.ts),事件只服务人类可观测性。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
18
|
+
|
|
19
|
+
/** 巡逻裁决事件的事件类型(借用的惰性已知类型) */
|
|
20
|
+
export const PATROL_EVENT_TYPE = 'hook/invoked'
|
|
21
|
+
/** 载荷判别:插件名 */
|
|
22
|
+
export const PATROL_EVENT_PLUGIN = 'dsh-advisor'
|
|
23
|
+
/** 载荷判别:触发点 */
|
|
24
|
+
export const PATROL_EVENT_POINT = 'advisor-patrol'
|
|
25
|
+
|
|
26
|
+
/** 巡逻裁决事件载荷(append 校验要求全部为可无损 JSON 序列化的值) */
|
|
27
|
+
export interface PatrolEventData {
|
|
28
|
+
readonly plugin: typeof PATROL_EVENT_PLUGIN
|
|
29
|
+
readonly point: typeof PATROL_EVENT_POINT
|
|
30
|
+
/** on-track 也写事件(用户拍板:每次巡逻出低调灰卡,视野完整);unclear 不写 */
|
|
31
|
+
readonly verdict: 'on-track' | 'correction' | 'stop'
|
|
32
|
+
readonly detail: string
|
|
33
|
+
/** 触发本次巡逻时的步数计数 */
|
|
34
|
+
readonly step: number
|
|
35
|
+
/** 审查模型路由(provider/model) */
|
|
36
|
+
readonly reviewer: string
|
|
37
|
+
/** 本次裁决耗时(毫秒) */
|
|
38
|
+
readonly patrolMs: number
|
|
39
|
+
/** 审查调用的 token 用量(网关未回报时缺省) */
|
|
40
|
+
readonly usage?: { readonly input: number, readonly output: number, readonly cacheRead?: number }
|
|
41
|
+
/** 冷却期内降级:只出卡片不注入(concern 在 immuneTurns 窗口内) */
|
|
42
|
+
readonly downgraded?: true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 声明合并进会话事件表——官方设计的插件扩展点(dsh-tool-workflow 等包
|
|
46
|
+
// 同款姿势,目标是 /types 子模块)。0.1.1 的 map 尚未声明该类型;若将来
|
|
47
|
+
// 链接的 dsh 版本官方声明了 hooks 载荷,这里会编译冲突——那是升级时
|
|
48
|
+
// 需要换事件类型的明确信号,而不是静默漂移。
|
|
49
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
50
|
+
interface SessionEventMap {
|
|
51
|
+
'hook/invoked': PatrolEventData
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 组装巡逻裁决事件载荷(纯函数,可单测) */
|
|
56
|
+
export function buildPatrolEventData(input: {
|
|
57
|
+
verdict: 'on-track' | 'correction' | 'stop'
|
|
58
|
+
detail: string
|
|
59
|
+
step: number
|
|
60
|
+
reviewer: string
|
|
61
|
+
patrolMs: number
|
|
62
|
+
usage?: { inputTokens: number, outputTokens: number, cacheReadTokens?: number }
|
|
63
|
+
downgraded?: true
|
|
64
|
+
}): PatrolEventData {
|
|
65
|
+
return {
|
|
66
|
+
plugin: PATROL_EVENT_PLUGIN,
|
|
67
|
+
point: PATROL_EVENT_POINT,
|
|
68
|
+
verdict: input.verdict,
|
|
69
|
+
detail: input.detail,
|
|
70
|
+
step: input.step,
|
|
71
|
+
reviewer: input.reviewer,
|
|
72
|
+
patrolMs: input.patrolMs,
|
|
73
|
+
...input.usage === undefined ? {} : {
|
|
74
|
+
usage: {
|
|
75
|
+
input: input.usage.inputTokens,
|
|
76
|
+
output: input.usage.outputTokens,
|
|
77
|
+
...input.usage.cacheReadTokens === undefined ? {} : { cacheRead: input.usage.cacheReadTokens },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 运行时判别一条会话事件是否为本插件的巡逻裁决(客户端 definition 的
|
|
85
|
+
* match 与测试共用)。结构收窄读取,不信任载荷形状。
|
|
86
|
+
*/
|
|
87
|
+
export function isPatrolVerdictEvent(event: { type?: unknown, data?: unknown }): boolean {
|
|
88
|
+
if (event.type !== PATROL_EVENT_TYPE) return false
|
|
89
|
+
const data = event.data
|
|
90
|
+
if (data === null || typeof data !== 'object') return false
|
|
91
|
+
const record = data as Record<string, unknown>
|
|
92
|
+
return record.plugin === PATROL_EVENT_PLUGIN && record.point === PATROL_EVENT_POINT
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 把巡逻裁决写入会话日志(host 侧)。任何失败只记日志不抛出——可观测性
|
|
97
|
+
* 通道绝不能破坏巡逻/执行流程。
|
|
98
|
+
* @returns 是否写入成功
|
|
99
|
+
*/
|
|
100
|
+
export function appendPatrolVerdict(session: Session, input: {
|
|
101
|
+
verdict: 'on-track' | 'correction' | 'stop'
|
|
102
|
+
detail: string
|
|
103
|
+
step: number
|
|
104
|
+
reviewer: string
|
|
105
|
+
patrolMs: number
|
|
106
|
+
usage?: { inputTokens: number, outputTokens: number, cacheReadTokens?: number }
|
|
107
|
+
}): boolean {
|
|
108
|
+
try {
|
|
109
|
+
session.append(PATROL_EVENT_TYPE, buildPatrolEventData(input))
|
|
110
|
+
return true
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.warn('[dsh-advisor][patrol] 裁决事件写入会话日志失败(卡片将不显示):', error)
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
}
|