lv24-opencode-consilium 0.0.0-stage → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +499 -3
- package/README.ru.md +500 -0
- package/README.zh.md +188 -0
- package/bin/cli.js +19 -0
- package/bin/mcp.js +24 -0
- package/consilium.config.example.json +84 -0
- package/dist/agents.d.ts +79 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +1051 -0
- package/dist/agents.js.map +1 -0
- package/dist/artifacts.d.ts +75 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +464 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1392 -0
- package/dist/cli.js.map +1 -0
- package/dist/compat.d.ts +160 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +419 -0
- package/dist/compat.js.map +1 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +728 -0
- package/dist/config.js.map +1 -0
- package/dist/consensus.d.ts +73 -0
- package/dist/consensus.d.ts.map +1 -0
- package/dist/consensus.js +273 -0
- package/dist/consensus.js.map +1 -0
- package/dist/defaults.d.ts +34 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +102 -0
- package/dist/defaults.js.map +1 -0
- package/dist/doctor.d.ts +27 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +798 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fallback.d.ts +50 -0
- package/dist/fallback.d.ts.map +1 -0
- package/dist/fallback.js +134 -0
- package/dist/fallback.js.map +1 -0
- package/dist/git.d.ts +28 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +71 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +653 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +20 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +1215 -0
- package/dist/mcp.js.map +1 -0
- package/dist/models.d.ts +97 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +873 -0
- package/dist/models.js.map +1 -0
- package/dist/plugin.d.ts +59 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +223 -0
- package/dist/plugin.js.map +1 -0
- package/dist/presets.d.ts +27 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +86 -0
- package/dist/presets.js.map +1 -0
- package/dist/replay.d.ts +37 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +103 -0
- package/dist/replay.js.map +1 -0
- package/dist/report.d.ts +28 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +300 -0
- package/dist/report.js.map +1 -0
- package/dist/smart-experts.d.ts +44 -0
- package/dist/smart-experts.d.ts.map +1 -0
- package/dist/smart-experts.js +242 -0
- package/dist/smart-experts.js.map +1 -0
- package/dist/stream.d.ts +50 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +219 -0
- package/dist/stream.js.map +1 -0
- package/dist/tool-run.d.ts +49 -0
- package/dist/tool-run.d.ts.map +1 -0
- package/dist/tool-run.js +143 -0
- package/dist/tool-run.js.map +1 -0
- package/dist/types.d.ts +352 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/variants.d.ts +30 -0
- package/dist/variants.d.ts.map +1 -0
- package/dist/variants.js +111 -0
- package/dist/variants.js.map +1 -0
- package/dist/verify.d.ts +33 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +80 -0
- package/dist/verify.js.map +1 -0
- package/dist/watch.d.ts +73 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +350 -0
- package/dist/watch.js.map +1 -0
- package/docs/session-and-plugin.md +642 -0
- package/package.json +90 -6
- package/schema/config.schema.json +100 -0
package/dist/agents.js
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Управление агентами OpenCode
|
|
3
|
+
* @module lv24-opencode-consilium
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { StringDecoder } from 'string_decoder';
|
|
7
|
+
import { DEFAULT_EXPERT_PROMPTS } from './defaults.js';
|
|
8
|
+
import { createEventDecoder } from './stream.js';
|
|
9
|
+
import { resolveOpenCodeAgent } from './config.js';
|
|
10
|
+
import { analyzeConsensus, buildConsensusPromptSection } from './consensus.js';
|
|
11
|
+
import { buildFallbackChain, shouldFallback } from './fallback.js';
|
|
12
|
+
export { parseJsonOutput } from './stream.js';
|
|
13
|
+
/** Ограничение захвата stdout/stderr (1 МиБ) */
|
|
14
|
+
const MAX_CAPTURE_LENGTH = 1024 * 1024;
|
|
15
|
+
const DEFAULT_TIMEOUT = 300000;
|
|
16
|
+
const DEFAULT_CHAIR_TIMEOUT = 600000;
|
|
17
|
+
const DEFAULT_RETRY_DELAY = 1000;
|
|
18
|
+
const DEFAULT_MAX_CONCURRENCY = 3;
|
|
19
|
+
const DEFAULT_MAX_EXPERT_TEXT = 3000;
|
|
20
|
+
const AVAILABILITY_TIMEOUT = 10000;
|
|
21
|
+
const AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/-]*$/;
|
|
22
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/#+-]*$/;
|
|
23
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@\/_-]*$/;
|
|
24
|
+
const MAX_RETRIES = 10;
|
|
25
|
+
const MAX_CONCURRENCY = 8;
|
|
26
|
+
const controllerStates = new WeakMap();
|
|
27
|
+
const killTimers = new WeakMap();
|
|
28
|
+
/** Создаёт независимый контроллер запуска */
|
|
29
|
+
export function createRunController() {
|
|
30
|
+
const state = {
|
|
31
|
+
cancelled: false,
|
|
32
|
+
children: new Set()
|
|
33
|
+
};
|
|
34
|
+
const controller = {
|
|
35
|
+
cancel() {
|
|
36
|
+
state.cancelled = true;
|
|
37
|
+
for (const child of [...state.children]) {
|
|
38
|
+
killProcessTree(child);
|
|
39
|
+
}
|
|
40
|
+
state.children.clear();
|
|
41
|
+
},
|
|
42
|
+
reset() {
|
|
43
|
+
state.cancelled = false;
|
|
44
|
+
},
|
|
45
|
+
isCancelled() {
|
|
46
|
+
return state.cancelled;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
controllerStates.set(controller, state);
|
|
50
|
+
return controller;
|
|
51
|
+
}
|
|
52
|
+
function getControllerState(controller) {
|
|
53
|
+
let state = controllerStates.get(controller);
|
|
54
|
+
if (!state) {
|
|
55
|
+
state = { cancelled: false, children: new Set() };
|
|
56
|
+
controllerStates.set(controller, state);
|
|
57
|
+
}
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
const defaultController = createRunController();
|
|
61
|
+
/** Отменяет запуск по умолчанию и убивает его процессы */
|
|
62
|
+
export function cancel() {
|
|
63
|
+
defaultController.cancel();
|
|
64
|
+
}
|
|
65
|
+
/** Сбрасывает флаг отмены запуска по умолчанию */
|
|
66
|
+
export function resetCancel() {
|
|
67
|
+
defaultController.reset();
|
|
68
|
+
}
|
|
69
|
+
/** Проверяет, отменён ли запуск по умолчанию */
|
|
70
|
+
export function isCancelled() {
|
|
71
|
+
return defaultController.isCancelled();
|
|
72
|
+
}
|
|
73
|
+
/** Убивает процесс и его дерево */
|
|
74
|
+
function killProcessTree(child) {
|
|
75
|
+
const pid = child.pid;
|
|
76
|
+
if (process.platform === 'win32') {
|
|
77
|
+
if (pid === undefined) {
|
|
78
|
+
try {
|
|
79
|
+
child.kill();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Игнорируем
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
88
|
+
stdio: 'ignore',
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
detached: true
|
|
91
|
+
});
|
|
92
|
+
killer.on('error', () => {
|
|
93
|
+
// taskkill недоступен — пробуем убить хотя бы сам процесс
|
|
94
|
+
try {
|
|
95
|
+
child.kill();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Игнорируем
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
killer.on('close', code => {
|
|
102
|
+
if (code !== 0) {
|
|
103
|
+
try {
|
|
104
|
+
child.kill();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Игнорируем
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
killer.unref();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
try {
|
|
115
|
+
child.kill();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Игнорируем
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const signalGroup = (signal) => {
|
|
124
|
+
try {
|
|
125
|
+
if (pid !== undefined) {
|
|
126
|
+
process.kill(-pid, signal);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Группа недоступна — убиваем сам процесс
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
child.kill(signal);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Игнорируем
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
signalGroup('SIGTERM');
|
|
141
|
+
if (killTimers.has(child))
|
|
142
|
+
return;
|
|
143
|
+
const killerTimer = setTimeout(() => {
|
|
144
|
+
killTimers.delete(child);
|
|
145
|
+
signalGroup('SIGKILL');
|
|
146
|
+
}, 2000);
|
|
147
|
+
killerTimer.unref();
|
|
148
|
+
killTimers.set(child, killerTimer);
|
|
149
|
+
}
|
|
150
|
+
function toFiniteNumber(value) {
|
|
151
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
function positiveInteger(value) {
|
|
154
|
+
const numeric = toFiniteNumber(value);
|
|
155
|
+
if (numeric === undefined || numeric <= 0 || !Number.isInteger(numeric)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return numeric;
|
|
159
|
+
}
|
|
160
|
+
function nonNegativeInteger(value) {
|
|
161
|
+
const numeric = toFiniteNumber(value);
|
|
162
|
+
if (numeric === undefined || numeric < 0 || !Number.isInteger(numeric)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
return numeric;
|
|
166
|
+
}
|
|
167
|
+
/** Нормализует таймаут: конечное положительное число (мс) либо fallback */
|
|
168
|
+
export function sanitizeTimeout(value, fallback) {
|
|
169
|
+
const toTimeout = (candidate) => {
|
|
170
|
+
const numeric = toFiniteNumber(candidate);
|
|
171
|
+
if (numeric === undefined || numeric <= 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
return Math.max(1, Math.round(numeric));
|
|
174
|
+
};
|
|
175
|
+
return toTimeout(value) ?? toTimeout(fallback) ?? DEFAULT_TIMEOUT;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Строит идентификатор сессии OpenCode для конкретного агента.
|
|
179
|
+
* Эксперты не должны делить одну сессию: к контексту добавляется имя агента.
|
|
180
|
+
*/
|
|
181
|
+
export function buildAgentSession(base, agentName) {
|
|
182
|
+
const trimmed = base?.trim();
|
|
183
|
+
if (trimmed === undefined || trimmed.length === 0)
|
|
184
|
+
return undefined;
|
|
185
|
+
// OpenCode требует, чтобы session id начинался с "ses".
|
|
186
|
+
const prefixed = trimmed.startsWith('ses') ? trimmed : `ses_${trimmed}`;
|
|
187
|
+
const suffix = `-${agentName.trim().length > 0 ? agentName.trim() : 'default'}`;
|
|
188
|
+
const cleaned = `${prefixed}${suffix}`
|
|
189
|
+
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
190
|
+
.replace(/-+/g, '-')
|
|
191
|
+
.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
192
|
+
// Обрезаем базу, а не суффикс: иначе разные агенты получат одинаковый id.
|
|
193
|
+
const maxBase = Math.max(8, 128 - suffix.length);
|
|
194
|
+
return cleaned.length <= 128
|
|
195
|
+
? cleaned
|
|
196
|
+
: `${cleaned.slice(0, Math.min(maxBase, cleaned.length - suffix.length))}${suffix}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Сессия председателя: базовый id используется как есть, чтобы продолжать
|
|
200
|
+
* существующую сессию OpenCode, а не создавать новую.
|
|
201
|
+
*/
|
|
202
|
+
export function buildChairSession(base) {
|
|
203
|
+
const trimmed = base?.trim();
|
|
204
|
+
if (trimmed === undefined || trimmed.length === 0)
|
|
205
|
+
return undefined;
|
|
206
|
+
const prefixed = trimmed.startsWith('ses') ? trimmed : `ses_${trimmed}`;
|
|
207
|
+
const cleaned = prefixed
|
|
208
|
+
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
209
|
+
.replace(/-+/g, '-')
|
|
210
|
+
.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
211
|
+
return cleaned.length > 0 ? cleaned.slice(0, 128) : undefined;
|
|
212
|
+
}
|
|
213
|
+
function sanitizeRetries(value, fallback) {
|
|
214
|
+
const retries = nonNegativeInteger(value) ?? nonNegativeInteger(fallback) ?? 0;
|
|
215
|
+
return Math.min(MAX_RETRIES, retries);
|
|
216
|
+
}
|
|
217
|
+
function calculateRetryDelay(attempt, baseDelay, exponentialBackoff, jitter) {
|
|
218
|
+
const base = exponentialBackoff ? baseDelay * Math.pow(2, attempt) : baseDelay;
|
|
219
|
+
if (!jitter)
|
|
220
|
+
return base;
|
|
221
|
+
// Полный джиттер 50–100%: параллельные эксперты не упираются в лимит одновременно.
|
|
222
|
+
return Math.max(0, Math.round(base * (0.5 + Math.random() * 0.5)));
|
|
223
|
+
}
|
|
224
|
+
/** Максимальная задержка rate limit, которую имеет смысл ждать внутри запуска */
|
|
225
|
+
const RATE_LIMIT_RETRY_WINDOW_MS = 30_000;
|
|
226
|
+
/** Проверяет, имеет ли смысл повторять попытку */
|
|
227
|
+
function isRetryable(code, retryAfterMs) {
|
|
228
|
+
if (code === undefined)
|
|
229
|
+
return true;
|
|
230
|
+
if (code === 'RATE_LIMITED') {
|
|
231
|
+
// Короткий лимит (секунды) пережидаем; длинный — сразу отдаём ошибку.
|
|
232
|
+
return (retryAfterMs !== undefined &&
|
|
233
|
+
retryAfterMs > 0 &&
|
|
234
|
+
retryAfterMs <= RATE_LIMIT_RETRY_WINDOW_MS);
|
|
235
|
+
}
|
|
236
|
+
return (code !== 'CANCELLED' &&
|
|
237
|
+
code !== 'MODEL_UNAVAILABLE' &&
|
|
238
|
+
code !== 'PARSE_ERROR' &&
|
|
239
|
+
code !== 'AGENT_NOT_FOUND' &&
|
|
240
|
+
code !== 'COST_LIMIT');
|
|
241
|
+
}
|
|
242
|
+
function errorToMessage(error) {
|
|
243
|
+
return error instanceof Error ? error.message : String(error);
|
|
244
|
+
}
|
|
245
|
+
function logWarn(config, message) {
|
|
246
|
+
try {
|
|
247
|
+
config.hooks?.onLog?.('warn', message);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Хук логов не должен ломать запуск
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function invokeHook(config, hook, name) {
|
|
254
|
+
try {
|
|
255
|
+
await hook();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
logWarn(config, `Ошибка хука ${name}: ${errorToMessage(error)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function invokeErrorHook(config, isChair, result) {
|
|
262
|
+
const hook = isChair ? config.hooks?.onChairError : config.hooks?.onExpertError;
|
|
263
|
+
if (!hook)
|
|
264
|
+
return;
|
|
265
|
+
try {
|
|
266
|
+
await hook(result);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
logWarn(config, `Ошибка хука ${isChair ? 'onChairError' : 'onExpertError'}: ${errorToMessage(error)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Формирует аргументы `opencode run`.
|
|
274
|
+
* Невалидные agent/model отбрасываются.
|
|
275
|
+
*/
|
|
276
|
+
export function buildRunArgs(opts) {
|
|
277
|
+
const args = ['run'];
|
|
278
|
+
if (opts.config?.standalone !== false) {
|
|
279
|
+
args.push('--standalone');
|
|
280
|
+
}
|
|
281
|
+
args.push('--format', 'json');
|
|
282
|
+
if (opts.config?.autoApprove) {
|
|
283
|
+
args.push('--auto');
|
|
284
|
+
}
|
|
285
|
+
const agent = opts.agent?.trim();
|
|
286
|
+
if (agent !== undefined && agent.length > 0 && AGENT_NAME_PATTERN.test(agent)) {
|
|
287
|
+
args.push(`--agent=${agent}`);
|
|
288
|
+
}
|
|
289
|
+
const model = opts.model?.trim();
|
|
290
|
+
if (model !== undefined && model.length > 0 && MODEL_PATTERN.test(model)) {
|
|
291
|
+
args.push(`--model=${model}`);
|
|
292
|
+
}
|
|
293
|
+
const session = opts.session?.trim();
|
|
294
|
+
if (session !== undefined && session.length > 0 && SESSION_PATTERN.test(session)) {
|
|
295
|
+
args.push(`--session=${session}`);
|
|
296
|
+
}
|
|
297
|
+
return args;
|
|
298
|
+
}
|
|
299
|
+
function buildExpertPrompt(expert, task) {
|
|
300
|
+
if (typeof expert.prompt === 'function') {
|
|
301
|
+
return expert.prompt(task);
|
|
302
|
+
}
|
|
303
|
+
if (typeof expert.prompt === 'string' && expert.prompt.trim().length > 0) {
|
|
304
|
+
return expert.prompt.replace(/\$\{task\}/g, task);
|
|
305
|
+
}
|
|
306
|
+
const fallback = Object.prototype.hasOwnProperty.call(DEFAULT_EXPERT_PROMPTS, expert.name)
|
|
307
|
+
? DEFAULT_EXPERT_PROMPTS[expert.name]
|
|
308
|
+
: DEFAULT_EXPERT_PROMPTS.default;
|
|
309
|
+
if (typeof fallback === 'function') {
|
|
310
|
+
return fallback(task);
|
|
311
|
+
}
|
|
312
|
+
if (typeof fallback === 'string') {
|
|
313
|
+
return fallback.replace(/\$\{task\}/g, task);
|
|
314
|
+
}
|
|
315
|
+
return task;
|
|
316
|
+
}
|
|
317
|
+
function buildChairPrompt(task, expertResults, maxExpertTextLength, consensus = false) {
|
|
318
|
+
const successful = expertResults.filter(result => result.success);
|
|
319
|
+
let prompt = `Синтезируй мнения экспертов и составь итоговый план. Не вызывай инструменты — ответь только текстом.\n\n`;
|
|
320
|
+
prompt += `## Задача\n${task}\n\n`;
|
|
321
|
+
prompt += `## Мнения экспертов\n`;
|
|
322
|
+
for (const result of successful) {
|
|
323
|
+
const cleanText = result.text.substring(0, maxExpertTextLength);
|
|
324
|
+
prompt += `### ${result.agent.toUpperCase()}\n${cleanText}\n\n`;
|
|
325
|
+
}
|
|
326
|
+
const failedCount = expertResults.length - successful.length;
|
|
327
|
+
if (failedCount > 0) {
|
|
328
|
+
prompt += `<!-- Не ответили экспертов: ${failedCount} -->\n\n`;
|
|
329
|
+
}
|
|
330
|
+
prompt += `## Требования к ответу\n`;
|
|
331
|
+
prompt += `1. **Резюме** (1-2 предложения)\n`;
|
|
332
|
+
prompt += `2. **Топ-3 приоритетных действия** с обоснованием\n`;
|
|
333
|
+
prompt += `3. **Главный риск** и митигация\n`;
|
|
334
|
+
prompt += `4. **Оценка трудозатрат** (если применимо)`;
|
|
335
|
+
if (consensus === true) {
|
|
336
|
+
const report = analyzeConsensus(expertResults.map(result => ({
|
|
337
|
+
agent: result.agent,
|
|
338
|
+
text: result.text,
|
|
339
|
+
model: result.model,
|
|
340
|
+
success: result.success
|
|
341
|
+
})));
|
|
342
|
+
prompt += `\n\n${buildConsensusPromptSection(report)}`;
|
|
343
|
+
}
|
|
344
|
+
return prompt;
|
|
345
|
+
}
|
|
346
|
+
function quoteCmdArg(arg) {
|
|
347
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
348
|
+
}
|
|
349
|
+
/** Готовит команду запуска (на Windows — через cmd.exe для .cmd-шимов) */
|
|
350
|
+
function buildSpawnSpec(opencodePath, args) {
|
|
351
|
+
if (process.platform !== 'win32') {
|
|
352
|
+
return { command: opencodePath, args };
|
|
353
|
+
}
|
|
354
|
+
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
355
|
+
const safePath = opencodePath.replace(/"/g, '');
|
|
356
|
+
const commandLine = `"${safePath}" ` + args.map(quoteCmdArg).join(' ');
|
|
357
|
+
return {
|
|
358
|
+
command: comspec,
|
|
359
|
+
args: ['/d', '/s', '/c', `"${commandLine}"`],
|
|
360
|
+
windowsVerbatimArguments: true
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function resolveOpenCodePath(config) {
|
|
364
|
+
const configured = config?.opencodePath?.trim();
|
|
365
|
+
return configured !== undefined && configured.length > 0 ? configured : 'opencode';
|
|
366
|
+
}
|
|
367
|
+
function resolveCwd(config) {
|
|
368
|
+
const configured = config?.cwd?.trim();
|
|
369
|
+
return configured !== undefined && configured.length > 0 ? configured : process.cwd();
|
|
370
|
+
}
|
|
371
|
+
function appendCapped(current, chunk) {
|
|
372
|
+
if (current.length >= MAX_CAPTURE_LENGTH)
|
|
373
|
+
return current;
|
|
374
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
375
|
+
return (current + text).slice(0, MAX_CAPTURE_LENGTH);
|
|
376
|
+
}
|
|
377
|
+
function tailText(value, maxLength) {
|
|
378
|
+
const trimmed = value.trim();
|
|
379
|
+
if (trimmed.length <= maxLength)
|
|
380
|
+
return trimmed;
|
|
381
|
+
return trimmed.slice(trimmed.length - maxLength);
|
|
382
|
+
}
|
|
383
|
+
function formatExitCode(code, signal) {
|
|
384
|
+
if (code !== null)
|
|
385
|
+
return `exit code ${code}`;
|
|
386
|
+
if (signal)
|
|
387
|
+
return `процесс завершён сигналом ${signal}`;
|
|
388
|
+
return 'exit code неизвестен';
|
|
389
|
+
}
|
|
390
|
+
function classifyError(message) {
|
|
391
|
+
if (/Agent not found|agent[-_. ]not[-_. ]found/i.test(message))
|
|
392
|
+
return 'AGENT_NOT_FOUND';
|
|
393
|
+
if (isModelUnavailableMessage(message))
|
|
394
|
+
return 'MODEL_UNAVAILABLE';
|
|
395
|
+
return 'EXIT_ERROR';
|
|
396
|
+
}
|
|
397
|
+
/** Типы ошибок OpenCode, означающие исчерпание квоты или rate limit */
|
|
398
|
+
const RATE_LIMIT_PATTERN = /(free[-_.]?limit|rate[-_.]?limit|quota|too[-_.]?many[-_.]?requests|resource[-_.]?exhausted)/i;
|
|
399
|
+
/** Проверяет, что ошибка потока — rate limit или исчерпание квоты */
|
|
400
|
+
function isRateLimitError(detail) {
|
|
401
|
+
if (detail.status === 429)
|
|
402
|
+
return true;
|
|
403
|
+
return RATE_LIMIT_PATTERN.test(`${detail.type ?? ''} ${detail.code ?? ''}`);
|
|
404
|
+
}
|
|
405
|
+
/** Проверяет текст ошибки на недоступную модель */
|
|
406
|
+
function isModelUnavailableMessage(message) {
|
|
407
|
+
return /Model unavailable|no-route|model[-_. ]not[-_. ]found|not found in catalog/i.test(message);
|
|
408
|
+
}
|
|
409
|
+
/** Определяет код ошибки по структурированным событиям потока */
|
|
410
|
+
function classifyStreamErrors(parsed) {
|
|
411
|
+
for (const detail of parsed.errorDetails) {
|
|
412
|
+
if (isRateLimitError(detail))
|
|
413
|
+
return 'RATE_LIMITED';
|
|
414
|
+
if (isModelUnavailableMessage(detail.message))
|
|
415
|
+
return 'MODEL_UNAVAILABLE';
|
|
416
|
+
}
|
|
417
|
+
return 'EXIT_ERROR';
|
|
418
|
+
}
|
|
419
|
+
/** Форматирует длительность ожидания для сообщения пользователю */
|
|
420
|
+
function formatRetryAfter(ms) {
|
|
421
|
+
if (ms >= 3_600_000)
|
|
422
|
+
return `${Math.round(ms / 3_600_000)} ч`;
|
|
423
|
+
if (ms >= 60_000)
|
|
424
|
+
return `${Math.round(ms / 60_000)} мин`;
|
|
425
|
+
return `${Math.max(1, Math.round(ms / 1000))} с`;
|
|
426
|
+
}
|
|
427
|
+
/** Добавляет к сообщению подсказку о времени повтора */
|
|
428
|
+
function withRetryHint(message, detail) {
|
|
429
|
+
if (detail?.retryAfterMs === undefined || detail.retryAfterMs <= 0)
|
|
430
|
+
return message;
|
|
431
|
+
return `${message} (повтор возможен через ~${formatRetryAfter(detail.retryAfterMs)})`;
|
|
432
|
+
}
|
|
433
|
+
/** Запускает opencode, пишет промпт в stdin и собирает вывод */
|
|
434
|
+
function executeOpenCode(options) {
|
|
435
|
+
return new Promise(resolve => {
|
|
436
|
+
if (options.controller?.isCancelled()) {
|
|
437
|
+
resolve({
|
|
438
|
+
code: null,
|
|
439
|
+
signal: null,
|
|
440
|
+
stdout: '',
|
|
441
|
+
stderr: '',
|
|
442
|
+
timedOut: false,
|
|
443
|
+
cancelled: true
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
let child;
|
|
448
|
+
try {
|
|
449
|
+
const spec = buildSpawnSpec(options.opencodePath, options.args);
|
|
450
|
+
child = spawn(spec.command, spec.args, {
|
|
451
|
+
cwd: options.cwd,
|
|
452
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
453
|
+
windowsHide: true,
|
|
454
|
+
detached: process.platform !== 'win32',
|
|
455
|
+
windowsVerbatimArguments: spec.windowsVerbatimArguments
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
resolve({
|
|
460
|
+
code: null,
|
|
461
|
+
signal: null,
|
|
462
|
+
stdout: '',
|
|
463
|
+
stderr: '',
|
|
464
|
+
spawnError: error instanceof Error ? error : new Error(String(error)),
|
|
465
|
+
timedOut: false,
|
|
466
|
+
cancelled: false
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!child) {
|
|
471
|
+
resolve({
|
|
472
|
+
code: null,
|
|
473
|
+
signal: null,
|
|
474
|
+
stdout: '',
|
|
475
|
+
stderr: '',
|
|
476
|
+
spawnError: new Error('Не удалось запустить opencode'),
|
|
477
|
+
timedOut: false,
|
|
478
|
+
cancelled: false
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const proc = child;
|
|
483
|
+
const state = options.controller ? getControllerState(options.controller) : undefined;
|
|
484
|
+
state?.children.add(proc);
|
|
485
|
+
let stdout = '';
|
|
486
|
+
let stderr = '';
|
|
487
|
+
let settled = false;
|
|
488
|
+
let timedOut = false;
|
|
489
|
+
let timer;
|
|
490
|
+
const cleanup = () => {
|
|
491
|
+
if (state)
|
|
492
|
+
state.children.delete(proc);
|
|
493
|
+
if (timer !== undefined) {
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
timer = undefined;
|
|
496
|
+
}
|
|
497
|
+
const killTimer = killTimers.get(proc);
|
|
498
|
+
if (killTimer !== undefined) {
|
|
499
|
+
clearTimeout(killTimer);
|
|
500
|
+
killTimers.delete(proc);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const settle = (outcome) => {
|
|
504
|
+
if (settled)
|
|
505
|
+
return;
|
|
506
|
+
settled = true;
|
|
507
|
+
cleanup();
|
|
508
|
+
resolve(outcome);
|
|
509
|
+
};
|
|
510
|
+
if (options.timeoutMs > 0) {
|
|
511
|
+
timer = setTimeout(() => {
|
|
512
|
+
timedOut = true;
|
|
513
|
+
killProcessTree(proc);
|
|
514
|
+
settle({
|
|
515
|
+
code: null,
|
|
516
|
+
signal: null,
|
|
517
|
+
stdout,
|
|
518
|
+
stderr,
|
|
519
|
+
timedOut: true,
|
|
520
|
+
cancelled: false
|
|
521
|
+
});
|
|
522
|
+
}, options.timeoutMs);
|
|
523
|
+
}
|
|
524
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
525
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
526
|
+
proc.stdout?.on('data', (chunk) => {
|
|
527
|
+
const text = typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk);
|
|
528
|
+
stdout = appendCapped(stdout, text);
|
|
529
|
+
});
|
|
530
|
+
proc.stderr?.on('data', (chunk) => {
|
|
531
|
+
const text = typeof chunk === 'string' ? chunk : stderrDecoder.write(chunk);
|
|
532
|
+
stderr = appendCapped(stderr, text);
|
|
533
|
+
});
|
|
534
|
+
const stdin = proc.stdin;
|
|
535
|
+
if (stdin) {
|
|
536
|
+
stdin.on('error', () => {
|
|
537
|
+
// Обрыв stdin (EPIPE): дочерний процесс уже не получит промпт
|
|
538
|
+
if (!settled) {
|
|
539
|
+
killProcessTree(proc);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
try {
|
|
543
|
+
if (options.input !== undefined) {
|
|
544
|
+
stdin.write(options.input, 'utf8');
|
|
545
|
+
}
|
|
546
|
+
stdin.end();
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
killProcessTree(proc);
|
|
550
|
+
try {
|
|
551
|
+
stdin.end();
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
// stdin уже закрыт
|
|
555
|
+
}
|
|
556
|
+
settle({
|
|
557
|
+
code: null,
|
|
558
|
+
signal: null,
|
|
559
|
+
stdout,
|
|
560
|
+
stderr,
|
|
561
|
+
spawnError: error instanceof Error ? error : new Error(String(error)),
|
|
562
|
+
timedOut: false,
|
|
563
|
+
cancelled: false
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
proc.on('error', (error) => {
|
|
569
|
+
settle({
|
|
570
|
+
code: null,
|
|
571
|
+
signal: null,
|
|
572
|
+
stdout,
|
|
573
|
+
stderr,
|
|
574
|
+
spawnError: error,
|
|
575
|
+
timedOut: false,
|
|
576
|
+
cancelled: false
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
proc.on('close', (code, signal) => {
|
|
580
|
+
stdout = appendCapped(stdout, stdoutDecoder.end());
|
|
581
|
+
stderr = appendCapped(stderr, stderrDecoder.end());
|
|
582
|
+
settle({
|
|
583
|
+
code,
|
|
584
|
+
signal,
|
|
585
|
+
stdout,
|
|
586
|
+
stderr,
|
|
587
|
+
timedOut,
|
|
588
|
+
cancelled: false
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/** Один запуск процесса opencode для агента */
|
|
594
|
+
async function runAgentProcess(options) {
|
|
595
|
+
const extraArgs = options.config.opencodeArgs ?? [];
|
|
596
|
+
const args = buildRunArgs({
|
|
597
|
+
agent: options.agentArg,
|
|
598
|
+
model: options.model,
|
|
599
|
+
session: options.session,
|
|
600
|
+
config: options.config
|
|
601
|
+
}).concat(extraArgs);
|
|
602
|
+
const outcome = await executeOpenCode({
|
|
603
|
+
opencodePath: resolveOpenCodePath(options.config),
|
|
604
|
+
args,
|
|
605
|
+
cwd: resolveCwd(options.config),
|
|
606
|
+
timeoutMs: options.timeoutMs,
|
|
607
|
+
input: options.prompt,
|
|
608
|
+
controller: options.controller
|
|
609
|
+
});
|
|
610
|
+
const decoder = createEventDecoder();
|
|
611
|
+
decoder.push(outcome.stdout);
|
|
612
|
+
const parsed = decoder.end();
|
|
613
|
+
const text = parsed.text;
|
|
614
|
+
const stderrTail = tailText(outcome.stderr, 500);
|
|
615
|
+
const streamCode = classifyStreamErrors(parsed);
|
|
616
|
+
const rateLimit = parsed.errorDetails.find(isRateLimitError);
|
|
617
|
+
const base = {
|
|
618
|
+
agent: options.agentName,
|
|
619
|
+
text,
|
|
620
|
+
success: false,
|
|
621
|
+
attempt: options.attempt,
|
|
622
|
+
exitCode: outcome.code,
|
|
623
|
+
usage: parsed.usage
|
|
624
|
+
};
|
|
625
|
+
if (options.model !== undefined) {
|
|
626
|
+
base.model = options.model;
|
|
627
|
+
}
|
|
628
|
+
if (rateLimit?.retryAfterMs !== undefined) {
|
|
629
|
+
base.retryAfterMs = rateLimit.retryAfterMs;
|
|
630
|
+
}
|
|
631
|
+
if (parsed.sessionID !== undefined) {
|
|
632
|
+
base.sessionId = parsed.sessionID;
|
|
633
|
+
}
|
|
634
|
+
if (outcome.cancelled) {
|
|
635
|
+
return {
|
|
636
|
+
...base,
|
|
637
|
+
errorCode: 'CANCELLED',
|
|
638
|
+
errorMessage: 'Запуск отменён',
|
|
639
|
+
exitCode: null
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (outcome.spawnError) {
|
|
643
|
+
return {
|
|
644
|
+
...base,
|
|
645
|
+
errorCode: 'SPAWN_ERROR',
|
|
646
|
+
errorMessage: outcome.spawnError.message,
|
|
647
|
+
exitCode: null
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (outcome.timedOut) {
|
|
651
|
+
return {
|
|
652
|
+
...base,
|
|
653
|
+
errorCode: 'TIMEOUT',
|
|
654
|
+
errorMessage: `Таймаут ${options.timeoutMs} мс`,
|
|
655
|
+
exitCode: null
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (outcome.code === 0 && text.trim().length > 0) {
|
|
659
|
+
return { ...base, success: true };
|
|
660
|
+
}
|
|
661
|
+
if (options.controller.isCancelled()) {
|
|
662
|
+
return {
|
|
663
|
+
...base,
|
|
664
|
+
errorCode: 'CANCELLED',
|
|
665
|
+
errorMessage: 'Запуск отменён'
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (outcome.code !== 0) {
|
|
669
|
+
const message = parsed.errors.join('; ') ||
|
|
670
|
+
stderrTail ||
|
|
671
|
+
formatExitCode(outcome.code, outcome.signal);
|
|
672
|
+
return {
|
|
673
|
+
...base,
|
|
674
|
+
errorCode: streamCode !== 'EXIT_ERROR' ? streamCode : classifyError(message),
|
|
675
|
+
errorMessage: withRetryHint(message, rateLimit)
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const message = parsed.errors.join('; ') ||
|
|
679
|
+
stderrTail ||
|
|
680
|
+
`Пустой ответ агента${parsed.eventCount > 0 ? ` (событий: ${parsed.eventCount})` : ''}`;
|
|
681
|
+
return {
|
|
682
|
+
...base,
|
|
683
|
+
errorCode: streamCode !== 'EXIT_ERROR' ? streamCode : 'EMPTY_RESPONSE',
|
|
684
|
+
errorMessage: withRetryHint(message, rateLimit)
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/** Суммирует usage двух вызовов (токены и стоимость) */
|
|
688
|
+
function sumUsagePair(a, b) {
|
|
689
|
+
if (a === undefined && b === undefined)
|
|
690
|
+
return undefined;
|
|
691
|
+
const usage = {};
|
|
692
|
+
const fields = [
|
|
693
|
+
'inputTokens',
|
|
694
|
+
'outputTokens',
|
|
695
|
+
'reasoningTokens',
|
|
696
|
+
'cacheReadTokens',
|
|
697
|
+
'cacheWriteTokens'
|
|
698
|
+
];
|
|
699
|
+
for (const field of fields) {
|
|
700
|
+
const total = (a?.[field] ?? 0) + (b?.[field] ?? 0);
|
|
701
|
+
if (total > 0)
|
|
702
|
+
usage[field] = total;
|
|
703
|
+
}
|
|
704
|
+
usage.cost = (a?.cost ?? 0) + (b?.cost ?? 0);
|
|
705
|
+
return usage;
|
|
706
|
+
}
|
|
707
|
+
/** Суммирует usage двух попыток (токены и стоимость) */
|
|
708
|
+
function mergeAttemptUsage(previous, next) {
|
|
709
|
+
const usage = sumUsagePair(previous.usage, next.usage);
|
|
710
|
+
return usage === undefined ? next : { ...next, usage };
|
|
711
|
+
}
|
|
712
|
+
/** Одна попытка вызова агента: fallback по моделям и при отсутствии агента */
|
|
713
|
+
async function callAgentOnce(options) {
|
|
714
|
+
const agentName = options.agentName.trim();
|
|
715
|
+
if (agentName.length > 0 && !AGENT_NAME_PATTERN.test(agentName)) {
|
|
716
|
+
return {
|
|
717
|
+
agent: options.agentName,
|
|
718
|
+
text: '',
|
|
719
|
+
success: false,
|
|
720
|
+
errorCode: 'PARSE_ERROR',
|
|
721
|
+
errorMessage: `Недопустимое имя агента: "${options.agentName}"`,
|
|
722
|
+
attempt: options.attempt,
|
|
723
|
+
exitCode: null
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const requestedModel = options.model?.trim();
|
|
727
|
+
let model = requestedModel !== undefined && requestedModel.length > 0
|
|
728
|
+
? requestedModel
|
|
729
|
+
: undefined;
|
|
730
|
+
// OpenCode V2 не применяет модель агента из конфигурации при `run --agent`.
|
|
731
|
+
// Заодно проверяем существование агента: для неизвестного имени сразу идём
|
|
732
|
+
// default-агентом, иначе OpenCode создаст сессию с несуществующим агентом.
|
|
733
|
+
let agentExists = agentName.length === 0;
|
|
734
|
+
if (agentName.length > 0) {
|
|
735
|
+
try {
|
|
736
|
+
const resolved = resolveOpenCodeAgent(agentName, {
|
|
737
|
+
opencodeConfigPath: options.config.opencodeConfigPath,
|
|
738
|
+
cwd: options.config.cwd
|
|
739
|
+
});
|
|
740
|
+
agentExists = resolved.exists;
|
|
741
|
+
if (model === undefined &&
|
|
742
|
+
resolved.model !== undefined &&
|
|
743
|
+
MODEL_PATTERN.test(resolved.model)) {
|
|
744
|
+
model = resolved.model;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// Конфигурация OpenCode недоступна: пусть модель и агента выбирает сам OpenCode
|
|
749
|
+
agentExists = true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const agentArg = agentExists && agentName.length > 0 ? agentName : undefined;
|
|
753
|
+
const sessionSuffix = agentName.length === 0 ? 'default' : agentExists ? agentName : `default-${agentName}`;
|
|
754
|
+
const session = options.isChair === true
|
|
755
|
+
? buildChairSession(options.sessionBase)
|
|
756
|
+
: buildAgentSession(options.sessionBase, sessionSuffix);
|
|
757
|
+
if (model !== undefined && !MODEL_PATTERN.test(model)) {
|
|
758
|
+
return {
|
|
759
|
+
agent: options.agentName,
|
|
760
|
+
text: '',
|
|
761
|
+
success: false,
|
|
762
|
+
errorCode: 'PARSE_ERROR',
|
|
763
|
+
errorMessage: `Недопустимая модель: "${model}"`,
|
|
764
|
+
attempt: options.attempt,
|
|
765
|
+
exitCode: null
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
let effectiveAgentArg = agentArg;
|
|
769
|
+
let result = await runAgentProcess({
|
|
770
|
+
agentName: options.agentName,
|
|
771
|
+
agentArg: effectiveAgentArg,
|
|
772
|
+
model,
|
|
773
|
+
session,
|
|
774
|
+
prompt: options.prompt,
|
|
775
|
+
timeoutMs: options.timeoutMs,
|
|
776
|
+
attempt: options.attempt,
|
|
777
|
+
controller: options.controller,
|
|
778
|
+
config: options.config
|
|
779
|
+
});
|
|
780
|
+
if (result.errorCode === 'AGENT_NOT_FOUND') {
|
|
781
|
+
// Агент не найден: повторяем без --agent и с default-сессией.
|
|
782
|
+
// Старую сессию не переиспользуем — OpenCode запомнил в ней несуществующего агента.
|
|
783
|
+
const fallback = await runAgentProcess({
|
|
784
|
+
agentName: options.agentName,
|
|
785
|
+
agentArg: undefined,
|
|
786
|
+
model,
|
|
787
|
+
session: buildAgentSession(options.sessionBase, agentName.length > 0 ? `default-${agentName}` : 'default'),
|
|
788
|
+
prompt: options.prompt,
|
|
789
|
+
timeoutMs: options.timeoutMs,
|
|
790
|
+
attempt: options.attempt,
|
|
791
|
+
controller: options.controller,
|
|
792
|
+
config: options.config
|
|
793
|
+
});
|
|
794
|
+
if (model !== undefined)
|
|
795
|
+
fallback.model = model;
|
|
796
|
+
result = mergeAttemptUsage(result, fallback);
|
|
797
|
+
effectiveAgentArg = undefined;
|
|
798
|
+
}
|
|
799
|
+
// Запасные модели: включаются только для RATE_LIMITED/MODEL_UNAVAILABLE/EMPTY_RESPONSE.
|
|
800
|
+
const chain = buildFallbackChain(model, options.fallbackModels ?? [], { max: 2 });
|
|
801
|
+
for (const candidate of chain) {
|
|
802
|
+
if (result.success || !shouldFallback(result.errorCode))
|
|
803
|
+
break;
|
|
804
|
+
if (options.controller.isCancelled())
|
|
805
|
+
break;
|
|
806
|
+
const attempt = await runAgentProcess({
|
|
807
|
+
agentName: options.agentName,
|
|
808
|
+
agentArg: effectiveAgentArg,
|
|
809
|
+
model: candidate,
|
|
810
|
+
session,
|
|
811
|
+
prompt: options.prompt,
|
|
812
|
+
timeoutMs: options.timeoutMs,
|
|
813
|
+
attempt: options.attempt,
|
|
814
|
+
controller: options.controller,
|
|
815
|
+
config: options.config
|
|
816
|
+
});
|
|
817
|
+
attempt.model = candidate;
|
|
818
|
+
result = mergeAttemptUsage(result, attempt);
|
|
819
|
+
if (attempt.success)
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
function finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage) {
|
|
825
|
+
const usage = { ...(mergedUsage ?? result.usage ?? {}) };
|
|
826
|
+
if (hasCost) {
|
|
827
|
+
usage.cost = totalCost;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
...result,
|
|
831
|
+
duration: Date.now() - startedAt,
|
|
832
|
+
usage
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function cancelledResult(agentName, attempt, model) {
|
|
836
|
+
const result = {
|
|
837
|
+
agent: agentName,
|
|
838
|
+
text: '',
|
|
839
|
+
success: false,
|
|
840
|
+
errorCode: 'CANCELLED',
|
|
841
|
+
errorMessage: 'Запуск отменён',
|
|
842
|
+
attempt,
|
|
843
|
+
exitCode: null
|
|
844
|
+
};
|
|
845
|
+
if (model !== undefined) {
|
|
846
|
+
result.model = model;
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
/** Ожидание с проверкой отмены каждые 100 мс; false — отменено */
|
|
851
|
+
async function delayWithCancel(delayMs, controller) {
|
|
852
|
+
let remaining = delayMs;
|
|
853
|
+
while (remaining > 0) {
|
|
854
|
+
if (controller.isCancelled())
|
|
855
|
+
return false;
|
|
856
|
+
const chunk = Math.min(100, remaining);
|
|
857
|
+
await new Promise(resolve => setTimeout(resolve, chunk));
|
|
858
|
+
remaining -= chunk;
|
|
859
|
+
}
|
|
860
|
+
return !controller.isCancelled();
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Вызывает одного агента с повторами и контролем отмены
|
|
864
|
+
*/
|
|
865
|
+
export async function callAgent(agentName, prompt, config, options = {}) {
|
|
866
|
+
const controller = options.controller ?? defaultController;
|
|
867
|
+
const isChair = options.isChair === true;
|
|
868
|
+
const sessionBase = options.session ?? config.session;
|
|
869
|
+
const fallbackModels = options.fallbackModels ?? config.fallbackModels ?? [];
|
|
870
|
+
const defaultTimeout = isChair
|
|
871
|
+
? sanitizeTimeout(config.timeouts?.chair, DEFAULT_CHAIR_TIMEOUT)
|
|
872
|
+
: sanitizeTimeout(config.timeouts?.expert, DEFAULT_TIMEOUT);
|
|
873
|
+
const timeoutMs = sanitizeTimeout(options.timeout, defaultTimeout);
|
|
874
|
+
const defaultRetries = isChair
|
|
875
|
+
? nonNegativeInteger(config.retry?.chairRetries) ?? 1
|
|
876
|
+
: nonNegativeInteger(config.retry?.expertRetries) ?? 1;
|
|
877
|
+
const retries = sanitizeRetries(options.retries, defaultRetries);
|
|
878
|
+
const delayMs = nonNegativeInteger(config.retry?.delay) ?? DEFAULT_RETRY_DELAY;
|
|
879
|
+
const exponential = config.retry?.exponentialBackoff === true;
|
|
880
|
+
const maxAttempts = retries + 1;
|
|
881
|
+
const startedAt = Date.now();
|
|
882
|
+
let totalCost = 0;
|
|
883
|
+
let hasCost = false;
|
|
884
|
+
let lastResult;
|
|
885
|
+
let mergedUsage;
|
|
886
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
887
|
+
if (controller.isCancelled()) {
|
|
888
|
+
return finalizeResult(cancelledResult(agentName, attempt, options.model), startedAt, totalCost, hasCost, mergedUsage);
|
|
889
|
+
}
|
|
890
|
+
if (attempt > 0) {
|
|
891
|
+
const rateLimitDelay = lastResult?.errorCode === 'RATE_LIMITED' ? lastResult.retryAfterMs : undefined;
|
|
892
|
+
const delay = rateLimitDelay !== undefined
|
|
893
|
+
? Math.min(rateLimitDelay, RATE_LIMIT_RETRY_WINDOW_MS)
|
|
894
|
+
: calculateRetryDelay(attempt - 1, delayMs, exponential, config.retry?.jitter === true);
|
|
895
|
+
const ready = await delayWithCancel(delay, controller);
|
|
896
|
+
if (!ready) {
|
|
897
|
+
return finalizeResult(cancelledResult(agentName, attempt, options.model), startedAt, totalCost, hasCost, mergedUsage);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const result = await callAgentOnce({
|
|
901
|
+
agentName,
|
|
902
|
+
prompt,
|
|
903
|
+
model: options.model,
|
|
904
|
+
sessionBase,
|
|
905
|
+
isChair,
|
|
906
|
+
fallbackModels,
|
|
907
|
+
timeoutMs,
|
|
908
|
+
attempt,
|
|
909
|
+
controller,
|
|
910
|
+
config
|
|
911
|
+
});
|
|
912
|
+
const cost = result.usage?.cost;
|
|
913
|
+
if (typeof cost === 'number' && Number.isFinite(cost)) {
|
|
914
|
+
totalCost += cost;
|
|
915
|
+
hasCost = true;
|
|
916
|
+
}
|
|
917
|
+
lastResult = result;
|
|
918
|
+
mergedUsage = sumUsagePair(mergedUsage, result.usage);
|
|
919
|
+
if (result.success) {
|
|
920
|
+
return finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage);
|
|
921
|
+
}
|
|
922
|
+
await invokeErrorHook(config, isChair, result);
|
|
923
|
+
if (!isRetryable(result.errorCode, result.retryAfterMs)) {
|
|
924
|
+
return finalizeResult(result, startedAt, totalCost, hasCost, mergedUsage);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return finalizeResult(lastResult ?? {
|
|
928
|
+
agent: agentName,
|
|
929
|
+
text: '',
|
|
930
|
+
success: false,
|
|
931
|
+
errorCode: 'EXIT_ERROR',
|
|
932
|
+
errorMessage: 'Все попытки исчерпаны'
|
|
933
|
+
}, startedAt, totalCost, hasCost, mergedUsage);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Параллельно вызывает экспертов с ограничением конкурентности
|
|
937
|
+
*/
|
|
938
|
+
export async function callExpertsParallel(experts, task, config, options = {}) {
|
|
939
|
+
const enabled = experts.filter(expert => expert.enabled !== false);
|
|
940
|
+
const results = new Array(enabled.length);
|
|
941
|
+
const concurrency = Math.min(MAX_CONCURRENCY, positiveInteger(config.retry?.maxConcurrency) ?? DEFAULT_MAX_CONCURRENCY);
|
|
942
|
+
const controller = options.controller;
|
|
943
|
+
const runExpert = async (expert) => {
|
|
944
|
+
try {
|
|
945
|
+
const prompt = buildExpertPrompt(expert, task);
|
|
946
|
+
await invokeHook(config, () => config.hooks?.onExpertStart?.(expert.name, task), 'onExpertStart');
|
|
947
|
+
const result = await callAgent(expert.name, prompt, config, {
|
|
948
|
+
timeout: expert.timeout,
|
|
949
|
+
retries: expert.retries,
|
|
950
|
+
isChair: false,
|
|
951
|
+
model: expert.model,
|
|
952
|
+
controller
|
|
953
|
+
});
|
|
954
|
+
await invokeHook(config, () => config.hooks?.onExpertEnd?.(result), 'onExpertEnd');
|
|
955
|
+
return result;
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
const result = {
|
|
959
|
+
agent: expert.name,
|
|
960
|
+
text: '',
|
|
961
|
+
success: false,
|
|
962
|
+
errorCode: 'SPAWN_ERROR',
|
|
963
|
+
errorMessage: errorToMessage(error)
|
|
964
|
+
};
|
|
965
|
+
await invokeHook(config, () => config.hooks?.onExpertError?.(result), 'onExpertError');
|
|
966
|
+
return result;
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
let nextIndex = 0;
|
|
970
|
+
const worker = async () => {
|
|
971
|
+
for (;;) {
|
|
972
|
+
const index = nextIndex;
|
|
973
|
+
nextIndex += 1;
|
|
974
|
+
if (index >= enabled.length)
|
|
975
|
+
return;
|
|
976
|
+
results[index] = await runExpert(enabled[index]);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
const workerCount = Math.min(concurrency, enabled.length);
|
|
980
|
+
const workers = [];
|
|
981
|
+
for (let i = 0; i < workerCount; i++) {
|
|
982
|
+
workers.push(worker());
|
|
983
|
+
}
|
|
984
|
+
await Promise.all(workers);
|
|
985
|
+
return results;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Вызывает председателя по результатам экспертов
|
|
989
|
+
*/
|
|
990
|
+
export async function callChair(expertResults, task, config, options = {}) {
|
|
991
|
+
const successful = expertResults.filter(result => result.success);
|
|
992
|
+
if (successful.length === 0) {
|
|
993
|
+
throw new Error('Ни один эксперт не дал ответа — председатель не может синтезировать результат');
|
|
994
|
+
}
|
|
995
|
+
const maxExpertTextLength = positiveInteger(config.chair?.maxExpertTextLength) ?? DEFAULT_MAX_EXPERT_TEXT;
|
|
996
|
+
let prompt;
|
|
997
|
+
const template = config.chair?.promptTemplate;
|
|
998
|
+
if (template) {
|
|
999
|
+
prompt = template(task, expertResults);
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
prompt = buildChairPrompt(task, expertResults, maxExpertTextLength, config.consensus === true);
|
|
1003
|
+
}
|
|
1004
|
+
await invokeHook(config, () => config.hooks?.onChairStart?.(task, expertResults), 'onChairStart');
|
|
1005
|
+
const result = await callAgent(config.chair.agent, prompt, config, {
|
|
1006
|
+
timeout: config.chair?.timeout,
|
|
1007
|
+
retries: config.retry?.chairRetries,
|
|
1008
|
+
isChair: true,
|
|
1009
|
+
model: config.chair?.model,
|
|
1010
|
+
controller: options.controller
|
|
1011
|
+
});
|
|
1012
|
+
await invokeHook(config, () => config.hooks?.onChairEnd?.(result), 'onChairEnd');
|
|
1013
|
+
return result;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Проверяет доступность OpenCode CLI (таймаут 10 с)
|
|
1017
|
+
*/
|
|
1018
|
+
export async function checkOpenCodeAvailable(config) {
|
|
1019
|
+
const outcome = await executeOpenCode({
|
|
1020
|
+
opencodePath: resolveOpenCodePath(config),
|
|
1021
|
+
args: ['--version'],
|
|
1022
|
+
cwd: resolveCwd(config),
|
|
1023
|
+
timeoutMs: AVAILABILITY_TIMEOUT
|
|
1024
|
+
});
|
|
1025
|
+
return outcome.spawnError === undefined && !outcome.timedOut && outcome.code === 0;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Возвращает версию OpenCode CLI или null
|
|
1029
|
+
*/
|
|
1030
|
+
export async function getOpenCodeVersion(config) {
|
|
1031
|
+
const outcome = await executeOpenCode({
|
|
1032
|
+
opencodePath: resolveOpenCodePath(config),
|
|
1033
|
+
args: ['--version'],
|
|
1034
|
+
cwd: resolveCwd(config),
|
|
1035
|
+
timeoutMs: AVAILABILITY_TIMEOUT
|
|
1036
|
+
});
|
|
1037
|
+
if (outcome.spawnError !== undefined || outcome.timedOut || outcome.code !== 0) {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
const version = outcome.stdout.trim() || outcome.stderr.trim();
|
|
1041
|
+
return version.length > 0 ? version : null;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Получает список доступных агентов из конфигурации (обратная совместимость)
|
|
1045
|
+
*/
|
|
1046
|
+
export async function getAvailableAgents() {
|
|
1047
|
+
const { loadConfig } = await import('./config.js');
|
|
1048
|
+
const { config } = loadConfig();
|
|
1049
|
+
return config.experts.map(expert => expert.name);
|
|
1050
|
+
}
|
|
1051
|
+
//# sourceMappingURL=agents.js.map
|