@twinsearth/dsh-master 0.0.0-stage → 3.2.8
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 +661 -0
- package/README.md +89 -2
- package/client.js +263 -0
- package/cordis.patch.yml +71 -0
- package/dist-host/contract/client.js +82 -0
- package/dist-host/contract/constants.js +70 -0
- package/dist-host/contract/envelope.js +84 -0
- package/dist-host/contract/index.js +17 -0
- package/dist-host/contract/negotiate.js +87 -0
- package/dist-host/contract/server.js +71 -0
- package/dist-host/contract/transports.js +360 -0
- package/dist-host/index.js +260 -0
- package/dist-host/kernel.js +186 -0
- package/dist-host/plugins/cost-optimizer.js +147 -0
- package/dist-host/plugins/dual-manager.js +251 -0
- package/dist-host/plugins/superagent-router.js +499 -0
- package/dist-host/plugins/superagent-worker.js +73 -0
- package/dist-host/superagent/bridge-tokens.js +236 -0
- package/dist-host/superagent/dispatch.js +198 -0
- package/dist-host/superagent/feishu.js +115 -0
- package/dist-host/superagent/github.js +119 -0
- package/dist-host/superagent/pr-delivery.js +351 -0
- package/dist-host/superagent/router.js +219 -0
- package/dist-host/superagent/stream-adapter.js +238 -0
- package/dist-host/superagent/stream-tokens.js +141 -0
- package/dist-host/superagent/ticket-store.js +108 -0
- package/dist-host/superagent/trust.js +145 -0
- package/dist-host/superagent/types.js +144 -0
- package/dist-host/superagent/worker.js +95 -0
- package/dist-host/superagent/writeback.js +162 -0
- package/dist-worker/agent-loop.js +493 -0
- package/dist-worker/errors.js +41 -0
- package/dist-worker/github-pr.js +44 -0
- package/dist-worker/index.js +323 -0
- package/dist-worker/llm-http.js +351 -0
- package/dist-worker/main.js +45 -0
- package/dist-worker/prompt.js +35 -0
- package/dist-worker/sink-http.js +135 -0
- package/dist-worker/tools.js +542 -0
- package/dist-worker/types.js +14 -0
- package/docs/ARCHITECTURE.md +114 -0
- package/docs/CHANGELOG.md +377 -0
- package/docs/INTEROP.md +60 -0
- package/docs/REVIEW-v3.2.7.md +121 -0
- package/docs/ROADMAP.md +106 -0
- package/docs/SECURITY.md +153 -0
- package/docs/SMOKE-3.2.5.md +77 -0
- package/docs/SMOKE-3.2.7.md +73 -0
- package/docs/SUPERAGENT.md +141 -0
- package/docs/WORKER-AGENT-LOOP.md +139 -0
- package/icon.svg +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/src/contract/client.js +86 -0
- package/src/contract/constants.js +79 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +19 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.ts +265 -0
- package/src/kernel.ts +190 -0
- package/src/plugins/cost-optimizer.ts +155 -0
- package/src/plugins/dual-manager.ts +252 -0
- package/src/plugins/superagent-router.ts +465 -0
- package/src/plugins/superagent-worker.ts +85 -0
- package/src/superagent/bridge-tokens.ts +236 -0
- package/src/superagent/dispatch.ts +197 -0
- package/src/superagent/feishu.ts +104 -0
- package/src/superagent/github.ts +111 -0
- package/src/superagent/pr-delivery.ts +308 -0
- package/src/superagent/router.ts +201 -0
- package/src/superagent/stream-adapter.ts +184 -0
- package/src/superagent/stream-tokens.ts +142 -0
- package/src/superagent/ticket-store.ts +104 -0
- package/src/superagent/trust.ts +139 -0
- package/src/superagent/types.ts +135 -0
- package/src/superagent/worker.ts +100 -0
- package/src/superagent/writeback.ts +154 -0
- package/tsconfig.host.build.json +12 -0
- package/tsconfig.host.json +21 -0
- package/tsconfig.worker.build.json +13 -0
- package/tsconfig.worker.json +17 -0
- package/types/superagent.d.ts +99 -0
- package/worker/agent-loop.ts +524 -0
- package/worker/errors.ts +47 -0
- package/worker/github-pr.ts +65 -0
- package/worker/index.ts +334 -0
- package/worker/llm-http.ts +359 -0
- package/worker/main.ts +45 -0
- package/worker/prompt.ts +39 -0
- package/worker/sink-http.ts +159 -0
- package/worker/tools.ts +531 -0
- package/worker/types.ts +247 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
import { DEFAULT_LIMITS } from "./types.js";
|
|
4
|
+
import { WorkerError, WorkerLimitError, WorkerTicketError, toFailure } from "./errors.js";
|
|
5
|
+
import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
|
|
6
|
+
import { createToolRegistry } from "./tools.js";
|
|
7
|
+
/**
|
|
8
|
+
* 周期检测(v3.2.7 审计收口):识别「重复序列」而不是只看连续相同。
|
|
9
|
+
*
|
|
10
|
+
* 对最近窗口内**末尾**的序列做判定:若长度 p(1..maxPeriod)的块连续重复 ≥minRepeats 次,
|
|
11
|
+
* 即判定为同一循环。这样 A/B/A/B、A/B/C/A/B/C 这类交替都能在第 minRepeats 个周期就被抓住,
|
|
12
|
+
* 不依赖"连续相同"或宽松阈值。
|
|
13
|
+
*
|
|
14
|
+
* @param {string[]} signatures 最近调用签名(按时间顺序)
|
|
15
|
+
* @param {{minRepeats?:number, maxPeriod?:number}} [opts]
|
|
16
|
+
* @returns {{period:number, repeats:number, sample:string} | null}
|
|
17
|
+
*/
|
|
18
|
+
export function detectRepeatingCycle(signatures, opts = {}) {
|
|
19
|
+
const maxPeriod = Math.max(1, Number(opts.maxPeriod ?? 8) || 8);
|
|
20
|
+
const list = Array.isArray(signatures) ? signatures : [];
|
|
21
|
+
for (let period = 1; period <= maxPeriod; period += 1) {
|
|
22
|
+
// 周期 1(纯连续相同)必须遵守规格:> maxIdenticalToolRepeats 才算死循环;
|
|
23
|
+
// 周期 >= 2 用 3 个完整周期(minRepeats),用于抓 A/B/A/B 之类的交替。
|
|
24
|
+
const minRepeats = period === 1
|
|
25
|
+
? Math.max(2, Number(opts.maxIdenticalRepeats ?? 3) + 1)
|
|
26
|
+
: Math.max(2, Number(opts.minRepeats ?? (Number(opts.maxIdenticalRepeats ?? 3) + 1)) || 3);
|
|
27
|
+
const need = period * minRepeats;
|
|
28
|
+
if (list.length < need)
|
|
29
|
+
continue;
|
|
30
|
+
const tail = list.slice(-need);
|
|
31
|
+
const block = tail.slice(0, period);
|
|
32
|
+
let same = true;
|
|
33
|
+
for (let i = period; i < need; i += 1) {
|
|
34
|
+
if (tail[i] !== block[i % period]) {
|
|
35
|
+
same = false;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (same)
|
|
40
|
+
return { period, repeats: minRepeats, sample: block.join(' | ') };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const ABSOLUTE_LIMIT_CEILING = {
|
|
45
|
+
maxSteps: 200,
|
|
46
|
+
maxTokens: 10_000_000,
|
|
47
|
+
maxWallClockMs: 24 * 60 * 60_000,
|
|
48
|
+
llmTimeoutMs: 10 * 60_000,
|
|
49
|
+
toolTimeoutMs: 10 * 60_000,
|
|
50
|
+
maxToolCallsPerStep: 32,
|
|
51
|
+
maxIdenticalToolRepeats: 100,
|
|
52
|
+
maxToolResultChars: 100_000,
|
|
53
|
+
maxContextChars: 5_000_000,
|
|
54
|
+
};
|
|
55
|
+
function mergeLimits(partial) {
|
|
56
|
+
const out = { ...DEFAULT_LIMITS };
|
|
57
|
+
if (partial && typeof partial === 'object') {
|
|
58
|
+
for (const key of Object.keys(DEFAULT_LIMITS)) {
|
|
59
|
+
const value = partial[key];
|
|
60
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
61
|
+
out[key] = Math.min(Math.floor(value), ABSOLUTE_LIMIT_CEILING[key]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** 通用的 secret 脱敏(步骤/日志/最终报告);具体 key 值由 redactValues 兜底。 */
|
|
68
|
+
export function redactSecrets(text, extraValues = []) {
|
|
69
|
+
let out = String(text ?? '');
|
|
70
|
+
for (const value of extraValues) {
|
|
71
|
+
const secret = String(value ?? '');
|
|
72
|
+
if (secret.length >= 8)
|
|
73
|
+
out = out.split(secret).join('[REDACTED]');
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]')
|
|
77
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]{10,}\b/g, '[REDACTED]')
|
|
78
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{10,}\b/g, '[REDACTED]')
|
|
79
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
|
|
80
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer [REDACTED]')
|
|
81
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 兼容 v3.2.1 Router Ticket 与 v3.2.2 TicketPayload。
|
|
85
|
+
* Router 的 mandate.scope 是 threadId(用于派发/授权快照),Worker 的
|
|
86
|
+
* mandate.scope 需要是 workspace 内路径;这里若发现 scope 非路径则保留
|
|
87
|
+
* Router 原 scope 并让工具层退回 workspace(见 tools.resolveMandateRoot)。
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeTicket(input) {
|
|
90
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
91
|
+
const mandateRaw = raw.mandate && typeof raw.mandate === 'object' ? raw.mandate : {};
|
|
92
|
+
const ticketId = String(raw.ticketId ?? raw.id ?? '').trim();
|
|
93
|
+
const source = String(raw.source ?? raw.sourceId ?? '').trim();
|
|
94
|
+
const taskDescription = String(raw.taskDescription ?? raw.payload ?? '').trim();
|
|
95
|
+
const scope = String(mandateRaw.scope ?? '').trim();
|
|
96
|
+
if (!ticketId)
|
|
97
|
+
throw new WorkerTicketError('ticketId is required');
|
|
98
|
+
if (!source)
|
|
99
|
+
throw new WorkerTicketError('source is required');
|
|
100
|
+
if (!taskDescription)
|
|
101
|
+
throw new WorkerTicketError('taskDescription is required');
|
|
102
|
+
if (!scope)
|
|
103
|
+
throw new WorkerTicketError('mandate.scope is required');
|
|
104
|
+
return {
|
|
105
|
+
ticketId,
|
|
106
|
+
source,
|
|
107
|
+
taskDescription,
|
|
108
|
+
mandate: {
|
|
109
|
+
scope,
|
|
110
|
+
allowShell: mandateRaw.allowShell === true,
|
|
111
|
+
policyVersion: mandateRaw.policyVersion === undefined ? 'v1.0' : String(mandateRaw.policyVersion),
|
|
112
|
+
strength: mandateRaw.strength === 'explicit' || mandateRaw.strength === 'inferred' ? mandateRaw.strength : undefined,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** 校验 LLM 返回的 tool_calls 结构;malformed 的调用仍回灌错误文本给模型。 */
|
|
117
|
+
function validateToolCalls(response) {
|
|
118
|
+
const message = response?.message ?? { role: 'assistant', content: '' };
|
|
119
|
+
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
120
|
+
return calls.map((call, index) => {
|
|
121
|
+
const id = String(call?.id ?? `call_${index + 1}`);
|
|
122
|
+
const fn = call?.function && typeof call.function === 'object' ? call.function : undefined;
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
name: String(fn?.name ?? ''),
|
|
126
|
+
args: String(fn?.arguments ?? '{}'),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export class SuperAgentWorker {
|
|
131
|
+
llm;
|
|
132
|
+
tools;
|
|
133
|
+
workspace;
|
|
134
|
+
baseMandate;
|
|
135
|
+
logger;
|
|
136
|
+
sink;
|
|
137
|
+
limits;
|
|
138
|
+
clock;
|
|
139
|
+
model;
|
|
140
|
+
redactValues;
|
|
141
|
+
effectiveMandate;
|
|
142
|
+
steps = [];
|
|
143
|
+
tokenUsage = 0;
|
|
144
|
+
phase = 'initializing';
|
|
145
|
+
startedAt = 0;
|
|
146
|
+
finalAnswer = '';
|
|
147
|
+
history = [];
|
|
148
|
+
prDelivery;
|
|
149
|
+
/** 本步是否已收到 provider 的真实流式 delta(true 时不再分块模拟)。 */
|
|
150
|
+
streamedThisStep = false;
|
|
151
|
+
registry;
|
|
152
|
+
constructor(deps) {
|
|
153
|
+
if (!deps || !deps.llm || typeof deps.llm.chat !== 'function')
|
|
154
|
+
throw new Error('SuperAgentWorker requires deps.llm.chat');
|
|
155
|
+
if (!deps.workspace)
|
|
156
|
+
throw new Error('SuperAgentWorker requires deps.workspace');
|
|
157
|
+
this.llm = deps.llm;
|
|
158
|
+
this.tools = deps.tools ?? [];
|
|
159
|
+
this.workspace = deps.workspace;
|
|
160
|
+
this.baseMandate = deps.mandate;
|
|
161
|
+
this.effectiveMandate = deps.mandate;
|
|
162
|
+
this.logger = deps.logger;
|
|
163
|
+
this.sink = deps.sink;
|
|
164
|
+
this.limits = mergeLimits(deps.limits);
|
|
165
|
+
this.clock = typeof deps.clock === 'function' ? deps.clock : () => Date.now();
|
|
166
|
+
this.model = deps.model;
|
|
167
|
+
this.redactValues = Array.isArray(deps.redactValues) ? deps.redactValues.filter((value) => typeof value === 'string') : [];
|
|
168
|
+
this.registry = createToolRegistry(this.tools);
|
|
169
|
+
}
|
|
170
|
+
redact(text) {
|
|
171
|
+
return redactSecrets(text, this.redactValues);
|
|
172
|
+
}
|
|
173
|
+
/** 主入口:永远返回 WorkerResult(不把内部异常抛给宿主/Router)。 */
|
|
174
|
+
async execute(input) {
|
|
175
|
+
this.startedAt = this.clock();
|
|
176
|
+
this.steps = [];
|
|
177
|
+
this.tokenUsage = 0;
|
|
178
|
+
this.finalAnswer = '';
|
|
179
|
+
this.prDelivery = undefined;
|
|
180
|
+
let ticket;
|
|
181
|
+
try {
|
|
182
|
+
ticket = normalizeTicket(input);
|
|
183
|
+
this.prDelivery = input.pr;
|
|
184
|
+
this.ticketId = ticket.ticketId;
|
|
185
|
+
// v3.2.3:Ticket 的 Mandate 必须与构造 Worker 时的授权一致;不一致直接拒绝。
|
|
186
|
+
if (String(ticket.mandate.scope) !== String(this.baseMandate.scope)) {
|
|
187
|
+
throw new WorkerError('mandate-mismatch', 'ticket mandate.scope does not match the worker mandate');
|
|
188
|
+
}
|
|
189
|
+
// 权限取交集:Ticket 可以进一步收窄,但不能扩大构造时的授权。
|
|
190
|
+
this.effectiveMandate = {
|
|
191
|
+
...ticket.mandate,
|
|
192
|
+
allowShell: this.baseMandate.allowShell === true && ticket.mandate.allowShell === true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
const failure = toFailure(error);
|
|
197
|
+
this.setPhase('failed');
|
|
198
|
+
this.record({ type: 'error', content: `${failure.code}: ${failure.message}` });
|
|
199
|
+
return this.result(ticketIdOf(input), false, failure);
|
|
200
|
+
}
|
|
201
|
+
this.history = [
|
|
202
|
+
{ role: 'system', content: buildSystemPrompt(ticket) },
|
|
203
|
+
{ role: 'user', content: buildUserPrompt(ticket) },
|
|
204
|
+
];
|
|
205
|
+
try {
|
|
206
|
+
this.setPhase('initializing');
|
|
207
|
+
this.record({ type: 'phase', content: 'initializing' });
|
|
208
|
+
let stepIndex = 0;
|
|
209
|
+
// 规格判定:连续相同工具调用 > maxIdenticalToolRepeats → dead-loop。
|
|
210
|
+
let lastSignature = '';
|
|
211
|
+
let signatureRepeats = 0;
|
|
212
|
+
// 补充判定(P2 审计):滑动窗口内的同签名次数,用于抓 A/B/A/B 交替;
|
|
213
|
+
// 阈值取 max(6, 2×连续阈值),比规格更宽松,避免误杀“合法重复几次再换”的行为。
|
|
214
|
+
const signatureWindow = [];
|
|
215
|
+
const WINDOW_SIZE = 40;
|
|
216
|
+
const WINDOW_THRESHOLD = Math.max(6, Number(this.limits.maxIdenticalToolRepeats) * 2);
|
|
217
|
+
let totalToolCalls = 0;
|
|
218
|
+
const maxTotalToolCalls = Math.min(this.limits.maxSteps * this.limits.maxToolCallsPerStep, 1000);
|
|
219
|
+
// eslint-disable-next-line no-constant-condition
|
|
220
|
+
while (true) {
|
|
221
|
+
this.assertWallClock();
|
|
222
|
+
if (stepIndex >= this.limits.maxSteps) {
|
|
223
|
+
throw new WorkerLimitError('max-steps', `Worker 超出最大循环次数 (${this.limits.maxSteps}),判定为死循环,强制终止。`);
|
|
224
|
+
}
|
|
225
|
+
stepIndex += 1;
|
|
226
|
+
this.setPhase('thinking');
|
|
227
|
+
this.log(`[Worker] === 循环 Step ${stepIndex}/${this.limits.maxSteps} ===`);
|
|
228
|
+
this.assertContextBudget();
|
|
229
|
+
const response = await this.callLLM();
|
|
230
|
+
const rawContent = String(response.message?.content ?? '');
|
|
231
|
+
if (rawContent.length > this.limits.maxContextChars) {
|
|
232
|
+
throw new WorkerLimitError('context-limit-exceeded', `单条模型响应长度 ${rawContent.length} 超过上限 ${this.limits.maxContextChars}`);
|
|
233
|
+
}
|
|
234
|
+
const usage = response.usage;
|
|
235
|
+
let used = Number(usage?.totalTokens ?? ((usage?.promptTokens ?? 0) + (usage?.completionTokens ?? 0))) || 0;
|
|
236
|
+
if (used <= 0) {
|
|
237
|
+
// provider 不返回 usage 时的兜底估算,防止 Token 预算被绕过。
|
|
238
|
+
used = Math.ceil((JSON.stringify(this.history).length + rawContent.length) / 4);
|
|
239
|
+
}
|
|
240
|
+
this.tokenUsage += used;
|
|
241
|
+
if (this.tokenUsage > this.limits.maxTokens) {
|
|
242
|
+
throw new WorkerLimitError('token-budget-exceeded', `超出最大 Token 预算 (${this.limits.maxTokens}),强制终止。`);
|
|
243
|
+
}
|
|
244
|
+
const assistantMessage = {
|
|
245
|
+
role: 'assistant',
|
|
246
|
+
content: this.redact(rawContent),
|
|
247
|
+
tool_calls: Array.isArray(response.message?.tool_calls) ? response.message.tool_calls : undefined,
|
|
248
|
+
};
|
|
249
|
+
this.history.push(assistantMessage);
|
|
250
|
+
if (assistantMessage.content) {
|
|
251
|
+
this.stream(assistantMessage.content);
|
|
252
|
+
this.record({ type: 'thought', content: assistantMessage.content });
|
|
253
|
+
}
|
|
254
|
+
const toolCalls = validateToolCalls(response);
|
|
255
|
+
if (toolCalls.length === 0) {
|
|
256
|
+
this.finalAnswer = String(assistantMessage.content ?? '').trim() || '任务执行完成,无返回内容。';
|
|
257
|
+
this.setPhase('reporting');
|
|
258
|
+
this.record({ type: 'final_answer', content: this.finalAnswer });
|
|
259
|
+
this.setPhase('done');
|
|
260
|
+
return this.result(ticket.ticketId, true);
|
|
261
|
+
}
|
|
262
|
+
if (toolCalls.length > this.limits.maxToolCallsPerStep) {
|
|
263
|
+
throw new WorkerLimitError('too-many-tool-calls', `单轮工具调用数 ${toolCalls.length} 超过上限 ${this.limits.maxToolCallsPerStep}`);
|
|
264
|
+
}
|
|
265
|
+
for (const call of toolCalls) {
|
|
266
|
+
const signature = `${call.name}:${call.args}`;
|
|
267
|
+
totalToolCalls += 1;
|
|
268
|
+
if (totalToolCalls > maxTotalToolCalls) {
|
|
269
|
+
throw new WorkerLimitError('tool-call-budget-exceeded', `工具调用总数 ${totalToolCalls} 超过上限 ${maxTotalToolCalls},判定为失控循环。`);
|
|
270
|
+
}
|
|
271
|
+
// ① 规格判定:连续相同工具调用(同名+同参)超过阈值
|
|
272
|
+
if (signature === lastSignature) {
|
|
273
|
+
signatureRepeats += 1;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
lastSignature = signature;
|
|
277
|
+
signatureRepeats = 1;
|
|
278
|
+
}
|
|
279
|
+
if (signatureRepeats > this.limits.maxIdenticalToolRepeats) {
|
|
280
|
+
throw new WorkerLimitError('dead-loop-detected', `连续重复调用同一工具 ${signatureRepeats} 次(${call.name}),判定为死循环。`);
|
|
281
|
+
}
|
|
282
|
+
// ② 补充判定:滑动窗口内同签名次数(不要求连续,A/B/A/B 交替也会累计)
|
|
283
|
+
signatureWindow.push(signature);
|
|
284
|
+
if (signatureWindow.length > WINDOW_SIZE)
|
|
285
|
+
signatureWindow.shift();
|
|
286
|
+
const sameInWindow = signatureWindow.filter((item) => item === signature).length;
|
|
287
|
+
if (sameInWindow > WINDOW_THRESHOLD) {
|
|
288
|
+
throw new WorkerLimitError('dead-loop-detected', `最近 ${signatureWindow.length} 次调用中同一工具/参数出现 ${sameInWindow} 次(${call.name}),判定为死循环。`);
|
|
289
|
+
}
|
|
290
|
+
// ③ 周期判定(cycle-dead-loop):A/B/A/B、A/B/C/A/B/C 交替在第 3 个周期即判死循环
|
|
291
|
+
const cycle = detectRepeatingCycle(signatureWindow, { maxPeriod: 8, maxIdenticalRepeats: this.limits.maxIdenticalToolRepeats });
|
|
292
|
+
if (cycle) {
|
|
293
|
+
throw new WorkerLimitError('dead-loop-detected', `检测到周期为 ${cycle.period} 的重复调用序列(重复 ${cycle.repeats} 次:[${cycle.sample}]),判定为死循环(cycle-dead-loop)。`);
|
|
294
|
+
}
|
|
295
|
+
this.record({ type: 'tool_call', content: call.args, toolName: call.name, toolArgs: safeParseArgs(call.args) });
|
|
296
|
+
this.setPhase('executing_tool');
|
|
297
|
+
this.stream(undefined, call.name);
|
|
298
|
+
const result = await this.executeTool(call);
|
|
299
|
+
const safeResult = this.redact(result);
|
|
300
|
+
const truncated = safeResult.length > this.limits.maxToolResultChars
|
|
301
|
+
? safeResult.slice(0, this.limits.maxToolResultChars) + `\n…[工具结果截断,原长 ${safeResult.length} 字符]`
|
|
302
|
+
: safeResult;
|
|
303
|
+
this.record({ type: 'tool_result', content: truncated, toolName: call.name });
|
|
304
|
+
this.history.push({
|
|
305
|
+
role: 'tool',
|
|
306
|
+
tool_call_id: call.id,
|
|
307
|
+
name: call.name,
|
|
308
|
+
content: truncated,
|
|
309
|
+
});
|
|
310
|
+
this.setPhase('thinking');
|
|
311
|
+
}
|
|
312
|
+
// 循环继续:带着 tool 结果再次询问模型。
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
const failure = toFailure(error);
|
|
317
|
+
this.setPhase('failed');
|
|
318
|
+
this.log(`[Worker] 循环终止: ${failure.code} ${failure.message}`);
|
|
319
|
+
this.record({ type: 'error', content: `${failure.code}: ${failure.message}` });
|
|
320
|
+
return this.result(ticket.ticketId, false, failure);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** 最近一步(供宿主轮询推送)。 */
|
|
324
|
+
getLatestStep() {
|
|
325
|
+
return this.steps.length > 0 ? this.steps[this.steps.length - 1] : null;
|
|
326
|
+
}
|
|
327
|
+
/** 崩溃恢复现场:把已走步骤/预算一起交给 Router/会话仓。 */
|
|
328
|
+
snapshot() {
|
|
329
|
+
return {
|
|
330
|
+
ticketId: this.ticketId,
|
|
331
|
+
phase: this.phase,
|
|
332
|
+
steps: this.steps.map((step) => ({ ...step })),
|
|
333
|
+
tokenUsage: this.tokenUsage,
|
|
334
|
+
elapsedMs: Math.max(0, this.clock() - this.startedAt),
|
|
335
|
+
finalAnswer: this.finalAnswer,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
ticketId = '';
|
|
339
|
+
result(ticketId, ok, error) {
|
|
340
|
+
return {
|
|
341
|
+
ok,
|
|
342
|
+
ticketId,
|
|
343
|
+
phase: this.phase,
|
|
344
|
+
finalAnswer: this.finalAnswer,
|
|
345
|
+
steps: this.steps.map((step) => ({ ...step })),
|
|
346
|
+
tokenUsage: this.tokenUsage,
|
|
347
|
+
elapsedMs: Math.max(0, this.clock() - this.startedAt),
|
|
348
|
+
...(this.prDelivery ? { pr: this.prDelivery } : {}),
|
|
349
|
+
error,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
assertWallClock() {
|
|
353
|
+
const elapsed = this.clock() - this.startedAt;
|
|
354
|
+
if (elapsed > this.limits.maxWallClockMs) {
|
|
355
|
+
throw new WorkerLimitError('wall-clock-exceeded', `Worker 超出最大墙钟时间 (${this.limits.maxWallClockMs}ms)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
assertContextBudget() {
|
|
359
|
+
const chars = JSON.stringify(this.history).length;
|
|
360
|
+
if (chars > this.limits.maxContextChars) {
|
|
361
|
+
throw new WorkerLimitError('context-limit-exceeded', `上下文长度 ${chars} 超过上限 ${this.limits.maxContextChars},停止以避免 OOM/超长请求。`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async callLLM() {
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
let timer;
|
|
367
|
+
this.streamedThisStep = false;
|
|
368
|
+
const timeout = new Promise((_, reject) => {
|
|
369
|
+
timer = setTimeout(() => {
|
|
370
|
+
controller.abort();
|
|
371
|
+
reject(new WorkerError('llm-timeout', `LLM 调用超时(>${this.limits.llmTimeoutMs}ms)`));
|
|
372
|
+
}, this.limits.llmTimeoutMs);
|
|
373
|
+
});
|
|
374
|
+
const request = {
|
|
375
|
+
model: this.model,
|
|
376
|
+
messages: this.history,
|
|
377
|
+
tools: this.registry.schemas(),
|
|
378
|
+
signal: controller.signal,
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
// 真实 streaming:provider 支持时逐 token 回调宿主;否则退回一次性调用。
|
|
382
|
+
const pending = typeof this.llm.chatStream === 'function'
|
|
383
|
+
? this.llm.chatStream(request, (delta) => {
|
|
384
|
+
if (delta?.content)
|
|
385
|
+
this.streamedThisStep = true;
|
|
386
|
+
try {
|
|
387
|
+
this.sink?.stream?.(delta);
|
|
388
|
+
}
|
|
389
|
+
catch { /* sink 不得打断流 */ }
|
|
390
|
+
})
|
|
391
|
+
: this.llm.chat(request);
|
|
392
|
+
const response = await Promise.race([pending, timeout]);
|
|
393
|
+
if (!response || typeof response !== 'object' || !response.message) {
|
|
394
|
+
throw new WorkerError('llm-invalid-response', 'LLM 返回空响应或缺少 message');
|
|
395
|
+
}
|
|
396
|
+
return response;
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error instanceof WorkerError)
|
|
400
|
+
throw error;
|
|
401
|
+
throw new WorkerError('llm-call-failed', error instanceof Error ? error.message : String(error));
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
if (timer)
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async executeTool(call) {
|
|
409
|
+
const controller = new AbortController();
|
|
410
|
+
try {
|
|
411
|
+
return await this.registry.execute({ name: call.name, arguments: call.args }, {
|
|
412
|
+
workspace: this.workspace,
|
|
413
|
+
signal: controller.signal,
|
|
414
|
+
timeoutMs: this.limits.toolTimeoutMs,
|
|
415
|
+
mandate: this.effectiveMandate,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// 理论上 registry 已把工具异常转成文本;这里兜底确保不打断循环。
|
|
420
|
+
return `执行报错: ${error instanceof Error ? error.message : String(error)}`;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
setPhase(phase) {
|
|
424
|
+
if (this.phase === phase)
|
|
425
|
+
return;
|
|
426
|
+
this.phase = phase;
|
|
427
|
+
try {
|
|
428
|
+
this.sink?.phase?.(phase);
|
|
429
|
+
}
|
|
430
|
+
catch { /* sink 不得影响循环 */ }
|
|
431
|
+
try {
|
|
432
|
+
this.logger?.info?.(`[Worker] phase=${phase}`);
|
|
433
|
+
}
|
|
434
|
+
catch { /* ignore */ }
|
|
435
|
+
}
|
|
436
|
+
record(step) {
|
|
437
|
+
const full = {
|
|
438
|
+
stepIndex: step.stepIndex ?? this.steps.length + 1,
|
|
439
|
+
type: step.type,
|
|
440
|
+
// v3.2.5 P1-3:所有步骤内容统一脱敏(含 error/thought/final_answer),
|
|
441
|
+
// 避免异常消息里的密钥绕过 tool_result 处的单点脱敏。
|
|
442
|
+
content: this.redact(String(step.content ?? '')),
|
|
443
|
+
toolName: step.toolName,
|
|
444
|
+
toolArgs: step.toolArgs,
|
|
445
|
+
timestamp: this.clock(),
|
|
446
|
+
tokenUsage: this.tokenUsage,
|
|
447
|
+
};
|
|
448
|
+
this.steps.push(full);
|
|
449
|
+
try {
|
|
450
|
+
this.sink?.step?.(full);
|
|
451
|
+
}
|
|
452
|
+
catch { /* ignore */ }
|
|
453
|
+
}
|
|
454
|
+
stream(content, toolName) {
|
|
455
|
+
try {
|
|
456
|
+
if (content) {
|
|
457
|
+
// 本步由 provider 真实流式输出过 → 不重复模拟;否则按块模拟兜底。
|
|
458
|
+
if (this.streamedThisStep)
|
|
459
|
+
return;
|
|
460
|
+
for (let index = 0; index < content.length; index += 240) {
|
|
461
|
+
this.sink?.stream?.({ content: content.slice(index, index + 240) });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else if (toolName) {
|
|
465
|
+
this.sink?.stream?.({ toolName });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch { /* ignore */ }
|
|
469
|
+
}
|
|
470
|
+
log(message) {
|
|
471
|
+
try {
|
|
472
|
+
this.sink?.log?.(message);
|
|
473
|
+
}
|
|
474
|
+
catch { /* ignore */ }
|
|
475
|
+
try {
|
|
476
|
+
this.logger?.info?.(message);
|
|
477
|
+
}
|
|
478
|
+
catch { /* ignore */ }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function ticketIdOf(input) {
|
|
482
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
483
|
+
return String(raw.ticketId ?? raw.id ?? '');
|
|
484
|
+
}
|
|
485
|
+
function safeParseArgs(args) {
|
|
486
|
+
try {
|
|
487
|
+
const parsed = JSON.parse(String(args ?? '{}'));
|
|
488
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
/** Worker 可分类错误:Router/宿主据此决定是否重试或报警。 */
|
|
4
|
+
export class WorkerError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
fatal;
|
|
7
|
+
constructor(code, message, fatal = true) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'WorkerError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.fatal = fatal;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class WorkerLimitError extends WorkerError {
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(code, message, true);
|
|
17
|
+
this.name = 'WorkerLimitError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class WorkerTicketError extends WorkerError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super('ticket-invalid', message, true);
|
|
23
|
+
this.name = 'WorkerTicketError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class MandateViolationError extends WorkerError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super('mandate-violation', message, false);
|
|
29
|
+
this.name = 'MandateViolationError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** 把 unknown 转成可序列化的失败对象。 */
|
|
33
|
+
export function toFailure(error) {
|
|
34
|
+
if (error instanceof WorkerError) {
|
|
35
|
+
return { code: error.code, message: error.message, fatal: error.fatal };
|
|
36
|
+
}
|
|
37
|
+
if (error instanceof Error) {
|
|
38
|
+
return { code: 'worker-failed', message: error.message, fatal: true };
|
|
39
|
+
}
|
|
40
|
+
return { code: 'worker-failed', message: String(error), fatal: true };
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
export function createGithubPrClient(options) {
|
|
4
|
+
const token = String(options.token ?? '').trim();
|
|
5
|
+
const repo = String(options.repo ?? '').trim();
|
|
6
|
+
if (!token)
|
|
7
|
+
throw new Error('github pr client requires token');
|
|
8
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo))
|
|
9
|
+
throw new Error('github pr client requires repo owner/name');
|
|
10
|
+
const apiBase = String(options.apiBase ?? 'https://api.github.com').replace(/\/+$/, '');
|
|
11
|
+
const defaultBase = String(options.baseBranch ?? 'main');
|
|
12
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
13
|
+
return {
|
|
14
|
+
async createPullRequest(input) {
|
|
15
|
+
const branch = String(input?.branch ?? '').trim();
|
|
16
|
+
if (!branch)
|
|
17
|
+
throw new Error('create_pr requires a branch (push it first)');
|
|
18
|
+
const response = await fetchImpl(`${apiBase}/repos/${repo}/pulls`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
accept: 'application/vnd.github+json',
|
|
23
|
+
authorization: `Bearer ${token}`,
|
|
24
|
+
'user-agent': 'dsh-master-worker',
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
title: String(input?.title ?? '').slice(0, 200),
|
|
28
|
+
body: String(input?.body ?? '').slice(0, 20_000),
|
|
29
|
+
head: branch,
|
|
30
|
+
base: String(input?.base ?? defaultBase),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const text = await response.text();
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new Error(`github pr create failed: HTTP ${response.status} ${text.slice(0, 200)}`);
|
|
36
|
+
let parsed = {};
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
return String(parsed.html_url ?? `PR #${parsed.number ?? '?'}`);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|