infinicode 2.3.5 → 2.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/.opencode/plugins/home-logo-animation.tsx +5 -1
- package/.opencode/plugins/mesh-commands.tsx +4 -3
- package/.opencode/plugins/routing-mode-display.tsx +6 -2
- package/.opencode/tui.json +2 -1
- package/bin/robopark.js +2 -0
- package/dist/cli.js +46 -1
- package/dist/commands/maintain.d.ts +29 -0
- package/dist/commands/maintain.js +186 -0
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mesh-install.d.ts +10 -0
- package/dist/commands/mesh-install.js +160 -0
- package/dist/commands/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/run.js +82 -22
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +152 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
- package/dist/kernel/agents/backends/cli-backend.js +140 -0
- package/dist/kernel/agents/backends/detect.d.ts +17 -0
- package/dist/kernel/agents/backends/detect.js +140 -0
- package/dist/kernel/agents/backends/index.d.ts +11 -0
- package/dist/kernel/agents/backends/index.js +3 -0
- package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
- package/dist/kernel/agents/backends/parse-utils.js +72 -0
- package/dist/kernel/agents/backends/profiles.d.ts +67 -0
- package/dist/kernel/agents/backends/profiles.js +48 -0
- package/dist/kernel/agents/backends/registry.d.ts +31 -0
- package/dist/kernel/agents/backends/registry.js +174 -0
- package/dist/kernel/agents/backends/rotation.d.ts +51 -0
- package/dist/kernel/agents/backends/rotation.js +107 -0
- package/dist/kernel/agents/backends/types.d.ts +67 -0
- package/dist/kernel/agents/backends/types.js +1 -0
- package/dist/kernel/agents/index.d.ts +8 -0
- package/dist/kernel/agents/index.js +8 -0
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +371 -0
- package/dist/kernel/federation/federation.d.ts +112 -2
- package/dist/kernel/federation/federation.js +270 -10
- package/dist/kernel/federation/moltfed-adapter.js +1 -0
- package/dist/kernel/federation/telemetry.d.ts +1 -1
- package/dist/kernel/federation/telemetry.js +2 -1
- package/dist/kernel/federation/transport-http.d.ts +17 -1
- package/dist/kernel/federation/transport-http.js +65 -2
- package/dist/kernel/federation/types.d.ts +46 -1
- package/dist/kernel/free-providers.js +83 -0
- package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
- package/dist/kernel/gateway/openai-gateway.js +428 -49
- package/dist/kernel/index.d.ts +2 -1
- package/dist/kernel/index.js +3 -1
- package/dist/kernel/logger.d.ts +16 -3
- package/dist/kernel/logger.js +38 -0
- package/dist/kernel/mcp/mcp-server.js +65 -10
- package/dist/kernel/orchestrator.d.ts +4 -0
- package/dist/kernel/orchestrator.js +34 -0
- package/dist/kernel/provider-manager.d.ts +13 -0
- package/dist/kernel/provider-manager.js +58 -5
- package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
- package/dist/kernel/providers/openai-compatible-provider.js +22 -3
- package/dist/kernel/recovery-manager.js +55 -15
- package/dist/kernel/router.js +15 -2
- package/dist/kernel/types.d.ts +12 -2
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +6 -3
|
@@ -36,8 +36,28 @@ export class FetchExecutor {
|
|
|
36
36
|
return fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
// Keep swapping models until one succeeds (or the pool is exhausted). A weak
|
|
40
|
+
// model hallucinating a tool call must never stop the flow — it just triggers
|
|
41
|
+
// the next escalation. Bounded only to cap worst-case latency on huge pools;
|
|
42
|
+
// the loop also exits early the moment nextTarget() runs out of untried models.
|
|
43
|
+
const MAX_ATTEMPTS = 8;
|
|
40
44
|
const MODELS_TTL_MS = 30_000;
|
|
45
|
+
// Stall guards — a provider that accepts the request then goes silent must not
|
|
46
|
+
// hang the TUI forever. Time out and swap instead.
|
|
47
|
+
const HEADERS_TIMEOUT_MS = 60_000; // to receive response headers
|
|
48
|
+
const FIRST_CHUNK_TIMEOUT_MS = 120_000; // to first streamed byte (reasoning models are slow to start)
|
|
49
|
+
const IDLE_TIMEOUT_MS = 60_000; // max silence BETWEEN chunks before we call it stalled
|
|
50
|
+
// Never buffer more than this many SSE frames while looking for a commit point —
|
|
51
|
+
// past it, start streaming regardless so the user always sees output flowing
|
|
52
|
+
// (prevents a whole long generation from being withheld until the very end).
|
|
53
|
+
const FLUSH_AFTER_FRAMES = 6;
|
|
54
|
+
/** Extract the concatenated `data:` payload from one SSE frame (or null). */
|
|
55
|
+
function sseData(frame) {
|
|
56
|
+
const lines = frame.split('\n').filter(l => l.startsWith('data:'));
|
|
57
|
+
if (lines.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return lines.map(l => l.slice(5).trimStart()).join('');
|
|
60
|
+
}
|
|
41
61
|
export class KernelGateway {
|
|
42
62
|
opts;
|
|
43
63
|
server;
|
|
@@ -57,25 +77,38 @@ export class KernelGateway {
|
|
|
57
77
|
get url() {
|
|
58
78
|
return `http://${this.opts.host ?? '127.0.0.1'}:${this.boundPort}/v1`;
|
|
59
79
|
}
|
|
60
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Start listening. Scans ports [port .. port+20] then falls back to an OS-chosen
|
|
82
|
+
* ephemeral port. Retries past BOTH EADDRINUSE (a lingering session holds it) and
|
|
83
|
+
* EACCES (a Windows excluded/reserved range) — never dies on a single bad port,
|
|
84
|
+
* since the whole point is that the gateway must come up so the TUI gets routing.
|
|
85
|
+
*/
|
|
61
86
|
async start() {
|
|
62
87
|
const host = this.opts.host ?? '127.0.0.1';
|
|
63
88
|
const first = this.opts.port ?? 47915;
|
|
64
89
|
let lastErr;
|
|
65
|
-
for (let candidate = first; candidate <= first +
|
|
90
|
+
for (let candidate = first; candidate <= first + 20; candidate++) {
|
|
66
91
|
try {
|
|
67
92
|
await this.listen(host, candidate);
|
|
68
|
-
this.boundPort
|
|
69
|
-
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${candidate} (${this.table.size} providers)`);
|
|
93
|
+
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${this.boundPort} (${this.table.size} providers)`);
|
|
70
94
|
return;
|
|
71
95
|
}
|
|
72
96
|
catch (err) {
|
|
73
97
|
lastErr = err;
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
const code = err.code;
|
|
99
|
+
if (code !== 'EADDRINUSE' && code !== 'EACCES')
|
|
100
|
+
break; // unexpected — stop scanning, try ephemeral
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
|
-
|
|
103
|
+
// Last resort: let the OS pick any free port (bulletproof against reserved ranges).
|
|
104
|
+
try {
|
|
105
|
+
await this.listen(host, 0);
|
|
106
|
+
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${this.boundPort} (ephemeral, ${this.table.size} providers)`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw lastErr ?? err;
|
|
111
|
+
}
|
|
79
112
|
}
|
|
80
113
|
listen(host, port) {
|
|
81
114
|
return new Promise((resolve, reject) => {
|
|
@@ -84,6 +117,8 @@ export class KernelGateway {
|
|
|
84
117
|
server.listen(port, host, () => {
|
|
85
118
|
server.off('error', reject);
|
|
86
119
|
this.server = server;
|
|
120
|
+
const addr = server.address();
|
|
121
|
+
this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
87
122
|
resolve();
|
|
88
123
|
});
|
|
89
124
|
});
|
|
@@ -131,12 +166,13 @@ export class KernelGateway {
|
|
|
131
166
|
}
|
|
132
167
|
return { object: 'list', data };
|
|
133
168
|
}
|
|
134
|
-
// ── Chat: route → execute →
|
|
169
|
+
// ── Chat: route → execute → validate → swap-on-failure → relay ──────────────
|
|
135
170
|
async handleChat(body, res) {
|
|
136
171
|
const stream = body['stream'] === true;
|
|
137
172
|
const messages = Array.isArray(body['messages']) ? body['messages'] : [];
|
|
138
173
|
const estTokens = Math.ceil(JSON.stringify(messages).length / 4);
|
|
139
174
|
const requested = typeof body['model'] === 'string' ? body['model'] : '';
|
|
175
|
+
const toolNames = this.allowedTools(body);
|
|
140
176
|
const tried = new Set();
|
|
141
177
|
let target = await this.resolveTarget(requested, estTokens, tried);
|
|
142
178
|
if (!target) {
|
|
@@ -147,27 +183,51 @@ export class KernelGateway {
|
|
|
147
183
|
res.on('close', () => controller.abort());
|
|
148
184
|
let lastStatus = 502;
|
|
149
185
|
let lastErr = 'no attempt made';
|
|
186
|
+
// `currentBody` may become a continuation request (original messages + the
|
|
187
|
+
// partial answer as an assistant prefix). `carried` is the whole answer
|
|
188
|
+
// streamed so far, accumulated across however many providers died mid-task.
|
|
189
|
+
let currentBody = body;
|
|
190
|
+
let carried = '';
|
|
150
191
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS && target; attempt++) {
|
|
151
192
|
tried.add(`${target.providerId}/${target.model}`);
|
|
152
|
-
const upstream = { ...
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
193
|
+
const upstream = { ...currentBody, model: target.model, stream };
|
|
194
|
+
const outcome = await this.attempt(target, upstream, res, stream, toolNames, controller);
|
|
195
|
+
// Clean completion (streamed finish, or any non-stream response).
|
|
196
|
+
if (outcome.forwarded && outcome.complete !== false) {
|
|
197
|
+
this.opts.kernel.providerManager.recordSuccess(target.providerId);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (outcome.aborted)
|
|
201
|
+
return; // client hung up — nothing to do
|
|
202
|
+
// Mid-stream truncation: the provider streamed part of the answer then died
|
|
203
|
+
// (finish_reason:length, dropped connection, or went idle). We can't swap
|
|
204
|
+
// cleanly — the client already has the partial — so CONTINUE from it with a
|
|
205
|
+
// fresh model. This is the "long task never just stops" guarantee.
|
|
206
|
+
if (outcome.truncated && outcome.committed) {
|
|
207
|
+
carried += outcome.assembled ?? '';
|
|
208
|
+
this.opts.logger.warn(`[gateway] truncated on ${target.providerId}/${target.model} (${outcome.finishReason ?? '?'}) — continuing from ${carried.length} chars`);
|
|
209
|
+
this.opts.kernel.providerManager.penalize?.(target.providerId, 'provider-down');
|
|
210
|
+
const next = await this.nextTarget(target, `truncated ${outcome.finishReason ?? ''}`, estTokens, tried);
|
|
211
|
+
if (!next) {
|
|
212
|
+
this.endStream(res);
|
|
158
213
|
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
214
|
+
} // pool spent — close cleanly, don't hang
|
|
215
|
+
currentBody = this.buildContinuation(body, carried);
|
|
216
|
+
this.emitSwitch(target, next, 'continue after truncation');
|
|
217
|
+
target = next;
|
|
218
|
+
continue;
|
|
163
219
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
220
|
+
// Committed but failed in a way we can't continue from — end the open stream
|
|
221
|
+
// gracefully instead of trying to send an error over it.
|
|
222
|
+
if (outcome.committed) {
|
|
223
|
+
this.opts.logger.warn(`[gateway] committed stream failed on ${target.providerId}/${target.model}: ${outcome.error}`);
|
|
224
|
+
this.endStream(res);
|
|
225
|
+
return;
|
|
170
226
|
}
|
|
227
|
+
// Pre-commit failure — nothing sent yet, swap cleanly to the next model.
|
|
228
|
+
lastStatus = outcome.status;
|
|
229
|
+
lastErr = outcome.error;
|
|
230
|
+
this.recordError(target.providerId, outcome.status, outcome.error);
|
|
171
231
|
const next = await this.nextTarget(target, `${lastStatus} ${lastErr}`, estTokens, tried);
|
|
172
232
|
if (!next)
|
|
173
233
|
break;
|
|
@@ -179,28 +239,318 @@ export class KernelGateway {
|
|
|
179
239
|
error: { message: `all routes failed: ${lastErr}`, type: 'gateway_error' },
|
|
180
240
|
});
|
|
181
241
|
}
|
|
242
|
+
else {
|
|
243
|
+
this.endStream(res); // exhausted attempts mid-stream — close so the TUI doesn't hang
|
|
244
|
+
}
|
|
182
245
|
}
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
246
|
+
/**
|
|
247
|
+
* Build a continuation request: the original messages plus the partial answer as
|
|
248
|
+
* an assistant prefix so a fresh model resumes it instead of restarting. `prefix:
|
|
249
|
+
* true` is the continue-this-message convention (DeepSeek/Mistral/Zhipu/Qwen);
|
|
250
|
+
* providers that ignore it at worst restart — still better than a dead task.
|
|
251
|
+
*/
|
|
252
|
+
buildContinuation(body, assembled) {
|
|
253
|
+
const base = Array.isArray(body['messages']) ? body['messages'] : [];
|
|
254
|
+
const messages = assembled.length > 0
|
|
255
|
+
? [...base, { role: 'assistant', content: assembled, prefix: true }]
|
|
256
|
+
: [...base];
|
|
257
|
+
return { ...body, messages };
|
|
258
|
+
}
|
|
259
|
+
/** Close an already-open SSE stream with a synthetic clean finish so the client
|
|
260
|
+
* gets a proper end-of-turn instead of hanging on a half-open connection. */
|
|
261
|
+
endStream(res) {
|
|
262
|
+
try {
|
|
263
|
+
if (!res.headersSent) {
|
|
264
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
265
|
+
}
|
|
266
|
+
if (!res.writableEnded) {
|
|
267
|
+
res.write('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n');
|
|
268
|
+
res.write('data: [DONE]\n\n');
|
|
269
|
+
res.end();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { /* ignore */ }
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Run one model. Returns forwarded:true only after the response is validated
|
|
276
|
+
* and sent to the client; otherwise nothing is written to `res` and the caller
|
|
277
|
+
* swaps to the next model. Catches BOTH transport failures (non-2xx) AND bad
|
|
278
|
+
* tool calls the model streams back on a 200 (a weak model naming a tool that
|
|
279
|
+
* isn't in request.tools — e.g. `glob={...}` — which downstream would reject).
|
|
280
|
+
*/
|
|
281
|
+
async attempt(target, upstream, res, stream, toolNames, controller) {
|
|
282
|
+
// Per-attempt controller: aborts on client close OR our stall timeouts, so a
|
|
283
|
+
// silent provider can't hang the request. Linked to the parent controller.
|
|
284
|
+
const ctrl = new AbortController();
|
|
285
|
+
const onParentAbort = () => ctrl.abort();
|
|
286
|
+
if (controller.signal.aborted)
|
|
287
|
+
ctrl.abort();
|
|
288
|
+
else
|
|
289
|
+
controller.signal.addEventListener('abort', onParentAbort, { once: true });
|
|
290
|
+
const cleanup = () => controller.signal.removeEventListener('abort', onParentAbort);
|
|
291
|
+
let resp;
|
|
292
|
+
const headersTimer = setTimeout(() => ctrl.abort(), this.opts.headersTimeoutMs ?? HEADERS_TIMEOUT_MS);
|
|
293
|
+
try {
|
|
294
|
+
resp = await this.executor.execute(target, upstream, ctrl.signal);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
clearTimeout(headersTimer);
|
|
298
|
+
cleanup();
|
|
299
|
+
if (controller.signal.aborted)
|
|
300
|
+
return { forwarded: false, status: 0, error: 'aborted', aborted: true };
|
|
301
|
+
return { forwarded: false, status: 504, error: `connect/headers timeout: ${err instanceof Error ? err.message : String(err)}` };
|
|
302
|
+
}
|
|
303
|
+
clearTimeout(headersTimer);
|
|
304
|
+
if (!resp.ok) {
|
|
305
|
+
cleanup();
|
|
306
|
+
const text = await resp.text().catch(() => `${resp.status}`);
|
|
307
|
+
return { forwarded: false, status: resp.status, error: text };
|
|
308
|
+
}
|
|
309
|
+
const ctype = resp.headers.get('content-type') ?? '';
|
|
310
|
+
if (stream && ctype.includes('text/event-stream') && resp.body) {
|
|
311
|
+
const out = await this.streamValidated(resp, res, toolNames, controller, ctrl);
|
|
312
|
+
cleanup();
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
// Non-streaming (or a provider that returned plain JSON despite stream=true).
|
|
316
|
+
try {
|
|
317
|
+
const text = await resp.text();
|
|
318
|
+
let parsed = null;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(text);
|
|
321
|
+
}
|
|
322
|
+
catch { /* leave null */ }
|
|
323
|
+
const bad = this.validateResponse(parsed, toolNames);
|
|
324
|
+
if (bad)
|
|
325
|
+
return { forwarded: false, status: 200, error: bad };
|
|
326
|
+
res.writeHead(resp.status, { 'content-type': ctype || 'application/json' });
|
|
327
|
+
res.end(text);
|
|
328
|
+
return { forwarded: true, status: resp.status, error: '' };
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
cleanup();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Relay an SSE stream, but with a small look-ahead: buffer frames until the
|
|
336
|
+
* first tool-call name or content delta. If that first tool call names a tool
|
|
337
|
+
* not in request.tools (or an error frame arrives), fail WITHOUT writing to the
|
|
338
|
+
* client so the caller can swap models. Once a valid start is seen, commit and
|
|
339
|
+
* stream the rest live (so large generations still stream token-by-token).
|
|
340
|
+
*/
|
|
341
|
+
async streamValidated(resp, res, toolNames, parentCtrl, ctrl) {
|
|
342
|
+
const reader = resp.body.getReader();
|
|
343
|
+
const decoder = new TextDecoder();
|
|
344
|
+
let raw = '';
|
|
345
|
+
let committed = res.headersSent; // a prior (continuation) attempt already opened the stream
|
|
346
|
+
let started = false;
|
|
347
|
+
// Terminal frames (a finish_reason marker or `[DONE]`) are HELD, not written
|
|
348
|
+
// live: if the stream turns out truncated we must NOT send a finish/[DONE] to
|
|
349
|
+
// the client (that would end the turn) — we swap and continue instead. On a
|
|
350
|
+
// clean finish we flush them so the client sees a proper end.
|
|
351
|
+
const held = [];
|
|
352
|
+
let assembled = ''; // visible answer text so far (for continuation prefix)
|
|
353
|
+
let finishReason = null;
|
|
354
|
+
let sawDone = false;
|
|
355
|
+
let sawToolCall = false;
|
|
356
|
+
const pending = [];
|
|
357
|
+
const commit = () => {
|
|
358
|
+
if (!res.headersSent) {
|
|
359
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
360
|
+
}
|
|
361
|
+
for (const p of pending)
|
|
362
|
+
res.write(p);
|
|
363
|
+
pending.length = 0;
|
|
364
|
+
committed = true;
|
|
365
|
+
};
|
|
366
|
+
// What did this stream actually do? Distinguishes "done for real" from
|
|
367
|
+
// "cut off mid-answer", which is the whole point of continuation.
|
|
368
|
+
const classify = (endReason) => {
|
|
369
|
+
const clean = sawToolCall
|
|
370
|
+
|| finishReason === 'stop'
|
|
371
|
+
|| finishReason === 'tool_calls'
|
|
372
|
+
|| (sawDone && finishReason !== 'length');
|
|
373
|
+
if (clean) {
|
|
374
|
+
// Real completion — flush the held terminal frames + trailing partial.
|
|
375
|
+
if (!committed)
|
|
376
|
+
commit();
|
|
377
|
+
for (const h of held)
|
|
378
|
+
res.write(h);
|
|
379
|
+
if (raw)
|
|
380
|
+
res.write(raw);
|
|
381
|
+
if (!res.writableEnded)
|
|
382
|
+
res.end();
|
|
383
|
+
return { forwarded: true, status: 200, error: '', committed: true, complete: true, assembled };
|
|
384
|
+
}
|
|
385
|
+
// Never committed anything and the stream is already over → clean swap
|
|
386
|
+
// (nothing was sent to the client, so the caller can retry from scratch).
|
|
387
|
+
if (!committed && assembled.length === 0) {
|
|
388
|
+
return { forwarded: false, status: endReason === 'done' ? 502 : 504, error: `truncated before any output (${endReason}${finishReason ? ' ' + finishReason : ''})` };
|
|
389
|
+
}
|
|
390
|
+
// Committed + truncated (finish_reason:length, dropped connection, or idle
|
|
391
|
+
// stall). We've already streamed part of the answer, so we CANNOT swap
|
|
392
|
+
// cleanly — signal the caller to CONTINUE from `assembled` with a fresh model.
|
|
393
|
+
if (!committed)
|
|
394
|
+
commit(); // flush what we buffered so the client sees it
|
|
395
|
+
return {
|
|
396
|
+
forwarded: false, status: 0, error: `stream truncated (${endReason}${finishReason ? ' ' + finishReason : ''})`,
|
|
397
|
+
committed: true, complete: false, assembled, truncated: true, finishReason: finishReason ?? endReason,
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
for (;;) {
|
|
401
|
+
// Race each read against a stall timeout (longer for the first token). On a
|
|
402
|
+
// stall we abort the upstream and — if nothing has been sent yet — swap.
|
|
403
|
+
const limit = started
|
|
404
|
+
? (this.opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS)
|
|
405
|
+
: (this.opts.firstChunkTimeoutMs ?? FIRST_CHUNK_TIMEOUT_MS);
|
|
406
|
+
let timer;
|
|
407
|
+
const readP = reader.read().then(r => ({ r })).catch(e => ({ err: e }));
|
|
408
|
+
const outcome = await Promise.race([
|
|
409
|
+
readP,
|
|
410
|
+
new Promise(resolve => { timer = setTimeout(() => resolve({ idle: true }), limit); }),
|
|
411
|
+
]);
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
if ('idle' in outcome) {
|
|
414
|
+
ctrl.abort();
|
|
415
|
+
try {
|
|
416
|
+
await reader.cancel();
|
|
417
|
+
}
|
|
418
|
+
catch { /* ignore */ }
|
|
419
|
+
if (!committed) {
|
|
420
|
+
return { forwarded: false, status: 504, error: `upstream stalled (${started ? 'idle' : 'no first token'} >${limit / 1000}s)` };
|
|
421
|
+
}
|
|
422
|
+
return classify('idle'); // streaming already started — continue from partial
|
|
423
|
+
}
|
|
424
|
+
if ('err' in outcome) {
|
|
425
|
+
if (parentCtrl.signal.aborted)
|
|
426
|
+
return { forwarded: false, status: 0, error: 'aborted', aborted: true };
|
|
427
|
+
if (!committed)
|
|
428
|
+
return { forwarded: false, status: 502, error: outcome.err instanceof Error ? outcome.err.message : String(outcome.err) };
|
|
429
|
+
return classify('err');
|
|
430
|
+
}
|
|
431
|
+
const { done, value } = outcome.r;
|
|
432
|
+
if (done)
|
|
433
|
+
return classify('done');
|
|
434
|
+
started = true;
|
|
435
|
+
raw += decoder.decode(value, { stream: true });
|
|
436
|
+
const parts = raw.split('\n\n');
|
|
437
|
+
raw = parts.pop() ?? '';
|
|
438
|
+
for (const frame of parts) {
|
|
439
|
+
const payload = frame + '\n\n';
|
|
440
|
+
const verdict = this.inspectFrame(frame, toolNames);
|
|
441
|
+
// Track completion signals + accumulate the visible answer for continuation.
|
|
442
|
+
if (verdict.done)
|
|
443
|
+
sawDone = true;
|
|
444
|
+
if (verdict.toolCall)
|
|
445
|
+
sawToolCall = true;
|
|
446
|
+
if (verdict.finishReason)
|
|
447
|
+
finishReason = verdict.finishReason;
|
|
448
|
+
if (verdict.content)
|
|
449
|
+
assembled += verdict.content;
|
|
450
|
+
if (!committed) {
|
|
451
|
+
if (verdict.error) {
|
|
452
|
+
try {
|
|
453
|
+
await reader.cancel();
|
|
454
|
+
}
|
|
455
|
+
catch { /* ignore */ }
|
|
456
|
+
return { forwarded: false, status: 200, error: verdict.error };
|
|
457
|
+
}
|
|
458
|
+
if (verdict.commit || pending.length >= FLUSH_AFTER_FRAMES)
|
|
459
|
+
commit();
|
|
460
|
+
}
|
|
461
|
+
// Hold terminal frames (finish/`[DONE]`) so a truncation can suppress them;
|
|
462
|
+
// stream everything else live.
|
|
463
|
+
if (verdict.terminal) {
|
|
464
|
+
held.push(payload);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (committed)
|
|
468
|
+
res.write(payload);
|
|
469
|
+
else
|
|
470
|
+
pending.push(payload);
|
|
197
471
|
}
|
|
198
|
-
res.end();
|
|
199
|
-
return;
|
|
200
472
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Classify one SSE frame: does it error, is it a safe commit point, is it a
|
|
476
|
+
* TERMINAL frame (finish_reason / `[DONE]` — held back so a truncation can be
|
|
477
|
+
* continued), and what visible content does it carry (accumulated so a fresh
|
|
478
|
+
* model can continue the answer from where a dead provider left off)?
|
|
479
|
+
*/
|
|
480
|
+
inspectFrame(frame, toolNames) {
|
|
481
|
+
const data = sseData(frame);
|
|
482
|
+
if (!data)
|
|
483
|
+
return { commit: false };
|
|
484
|
+
if (data === '[DONE]')
|
|
485
|
+
return { commit: true, terminal: true, done: true }; // end of stream
|
|
486
|
+
let obj = null;
|
|
487
|
+
try {
|
|
488
|
+
obj = JSON.parse(data);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return { commit: false };
|
|
492
|
+
}
|
|
493
|
+
if (obj && obj['error']) {
|
|
494
|
+
const e = obj['error'];
|
|
495
|
+
return { commit: false, error: typeof e === 'string' ? e : JSON.stringify(e) };
|
|
496
|
+
}
|
|
497
|
+
const choice = obj?.choices?.[0];
|
|
498
|
+
const delta = choice?.delta;
|
|
499
|
+
const finish = choice?.finish_reason;
|
|
500
|
+
const finishReason = typeof finish === 'string' && finish.length > 0 ? finish : undefined;
|
|
501
|
+
const tcName = delta?.tool_calls?.[0]?.function?.name;
|
|
502
|
+
if (typeof tcName === 'string' && tcName.length > 0) {
|
|
503
|
+
if (toolNames.size > 0 && !toolNames.has(tcName)) {
|
|
504
|
+
return { commit: false, error: `tool call validation failed: '${tcName}' was not in request.tools` };
|
|
505
|
+
}
|
|
506
|
+
return { commit: true, toolCall: true, finishReason };
|
|
507
|
+
}
|
|
508
|
+
// Only real answer text (delta.content) feeds the continuation prefix; reasoning
|
|
509
|
+
// tokens are internal and must not be replayed as the assistant's answer.
|
|
510
|
+
const visible = typeof delta?.content === 'string' ? delta.content : '';
|
|
511
|
+
const anyText = visible || (typeof delta?.reasoning_content === 'string' ? delta.reasoning_content : '')
|
|
512
|
+
|| (typeof delta?.reasoning === 'string' ? delta.reasoning : '');
|
|
513
|
+
if (finishReason)
|
|
514
|
+
return { commit: true, terminal: true, finishReason, content: visible || undefined };
|
|
515
|
+
if (anyText.length > 0)
|
|
516
|
+
return { commit: true, content: visible || undefined };
|
|
517
|
+
return { commit: false };
|
|
518
|
+
}
|
|
519
|
+
/** Validate a non-streamed response body for unknown tool calls / errors. */
|
|
520
|
+
validateResponse(parsed, toolNames) {
|
|
521
|
+
if (!parsed || typeof parsed !== 'object')
|
|
522
|
+
return null;
|
|
523
|
+
const obj = parsed;
|
|
524
|
+
if (obj['error']) {
|
|
525
|
+
const e = obj['error'];
|
|
526
|
+
return typeof e === 'string' ? e : JSON.stringify(e);
|
|
527
|
+
}
|
|
528
|
+
if (toolNames.size === 0)
|
|
529
|
+
return null;
|
|
530
|
+
const calls = obj
|
|
531
|
+
?.choices?.[0]?.message?.tool_calls;
|
|
532
|
+
if (Array.isArray(calls)) {
|
|
533
|
+
for (const c of calls) {
|
|
534
|
+
const n = c?.function?.name;
|
|
535
|
+
if (typeof n === 'string' && !toolNames.has(n)) {
|
|
536
|
+
return `tool call validation failed: '${n}' was not in request.tools`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
/** The set of tool names the request declared (OpenAI tools[].function.name). */
|
|
543
|
+
allowedTools(body) {
|
|
544
|
+
const set = new Set();
|
|
545
|
+
const tools = body['tools'];
|
|
546
|
+
if (Array.isArray(tools)) {
|
|
547
|
+
for (const t of tools) {
|
|
548
|
+
const n = t?.function?.name ?? t?.name;
|
|
549
|
+
if (typeof n === 'string')
|
|
550
|
+
set.add(n);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return set;
|
|
204
554
|
}
|
|
205
555
|
// ── Routing helpers ──────────────────────────────────────────────────────────
|
|
206
556
|
/** Resolve the initial target: honor an explicit provider/model, else route. */
|
|
@@ -250,18 +600,41 @@ export class KernelGateway {
|
|
|
250
600
|
const kind = this.opts.kernel.recovery.classify(errText);
|
|
251
601
|
const ranked = await this.rank(estTokens);
|
|
252
602
|
const usable = ranked.filter(d => this.table.has(d.providerId) && !tried.has(`${d.providerId}/${d.modelId}`));
|
|
253
|
-
|
|
603
|
+
// Provider-level failures (rate limit, credits, bad key, outage, filter) →
|
|
604
|
+
// move to a DIFFERENT provider; escalating to a bigger model on the same dead
|
|
605
|
+
// provider would just fail again.
|
|
606
|
+
const rotateKinds = new Set(['429', 'provider-down', 'quota-exhausted', 'auth-failure', 'content-filter']);
|
|
607
|
+
if (rotateKinds.has(kind)) {
|
|
254
608
|
const diffProvider = usable.find(d => d.providerId !== current.providerId);
|
|
255
609
|
if (diffProvider)
|
|
256
610
|
return this.toTarget(this.table.get(diffProvider.providerId), diffProvider.modelId);
|
|
257
|
-
|
|
611
|
+
if (usable[0])
|
|
612
|
+
return this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId);
|
|
613
|
+
// Ranking gave nothing usable — still rotate to ANY untried provider/model.
|
|
614
|
+
return this.firstUntried(tried, current.providerId) ?? this.firstUntried(tried);
|
|
258
615
|
}
|
|
259
616
|
// escalate: strictly stronger model than the current one
|
|
260
617
|
const esc = this.opts.kernel.router.escalate(await this.routingRequest(estTokens), current.providerId, current.model);
|
|
261
618
|
if (esc && this.table.has(esc.providerId) && !tried.has(`${esc.providerId}/${esc.modelId}`)) {
|
|
262
619
|
return this.toTarget(this.table.get(esc.providerId), esc.modelId);
|
|
263
620
|
}
|
|
264
|
-
|
|
621
|
+
if (usable[0])
|
|
622
|
+
return this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId);
|
|
623
|
+
// Last resort: ANY configured model not yet tried, so a swap/continuation never
|
|
624
|
+
// dead-ends just because the router couldn't rank (e.g. providers not yet
|
|
625
|
+
// health-verified at session start). Prefer a different provider.
|
|
626
|
+
return this.firstUntried(tried, current.providerId) ?? this.firstUntried(tried);
|
|
627
|
+
}
|
|
628
|
+
/** First configured provider/model not in `tried` (optionally skipping a provider). */
|
|
629
|
+
firstUntried(tried, exceptProvider) {
|
|
630
|
+
for (const gp of this.table.values()) {
|
|
631
|
+
if (exceptProvider && gp.id === exceptProvider)
|
|
632
|
+
continue;
|
|
633
|
+
for (const m of gp.models)
|
|
634
|
+
if (!tried.has(`${gp.id}/${m.id}`))
|
|
635
|
+
return this.toTarget(gp, m.id);
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
265
638
|
}
|
|
266
639
|
toTarget(gp, model) {
|
|
267
640
|
return { providerId: gp.id, baseURL: gp.baseURL, apiKey: gp.apiKey, headers: gp.headers, model };
|
|
@@ -288,18 +661,24 @@ export class KernelGateway {
|
|
|
288
661
|
availableProviders: kernel.providerManager.listInfos(),
|
|
289
662
|
availableModels: this.modelsCache.models,
|
|
290
663
|
estimatedRequestTokens: estTokens,
|
|
664
|
+
minBenchmark: this.opts.minBenchmark,
|
|
291
665
|
};
|
|
292
666
|
}
|
|
293
667
|
// ── Telemetry / health ───────────────────────────────────────────────────────
|
|
294
668
|
recordError(providerId, status, error) {
|
|
295
|
-
|
|
669
|
+
const kind = this.opts.kernel.recovery.classify(`${status} ${error}`);
|
|
670
|
+
if (status === 429 || kind === '429')
|
|
296
671
|
this.opts.kernel.providerManager.record429(providerId);
|
|
297
672
|
else
|
|
298
673
|
this.opts.kernel.providerManager.recordFailure(providerId);
|
|
674
|
+
// Cooldown credits/auth/outage providers so the NEXT turns skip them too
|
|
675
|
+
// (not just this request's retry loop). tool/context failures cool down 0ms.
|
|
676
|
+
this.opts.kernel.providerManager.penalize(providerId, kind, `${kind}: ${error.slice(0, 120)}`);
|
|
677
|
+
this.modelsCache = undefined; // reflect the cooldown in the next routing pass
|
|
299
678
|
this.opts.kernel.eventBus.emit({
|
|
300
679
|
type: 'RECOVERY',
|
|
301
680
|
timestamp: Date.now(),
|
|
302
|
-
data: { source: 'gateway', providerId, status, error: error.slice(0, 300) },
|
|
681
|
+
data: { source: 'gateway', providerId, status, kind, error: error.slice(0, 300) },
|
|
303
682
|
});
|
|
304
683
|
}
|
|
305
684
|
emitSwitch(from, to, reason) {
|
package/dist/kernel/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { VerificationEngine } from './verification-engine.js';
|
|
|
22
22
|
export type { VerificationEngineDeps } from './verification-engine.js';
|
|
23
23
|
export { RecoveryManager } from './recovery-manager.js';
|
|
24
24
|
export type { RecoveryManagerDeps, RecoveryContext } from './recovery-manager.js';
|
|
25
|
-
export { ConsoleLogger, SilentLogger } from './logger.js';
|
|
25
|
+
export { ConsoleLogger, SilentLogger, FileLogger } from './logger.js';
|
|
26
26
|
export { OllamaProvider } from './providers/ollama-provider.js';
|
|
27
27
|
export type { OllamaProviderOptions } from './providers/ollama-provider.js';
|
|
28
28
|
export { OpenAICompatibleProvider } from './providers/openai-compatible-provider.js';
|
|
@@ -37,6 +37,7 @@ export type { MissionPlannerDeps, PlanOptions } from './planner.js';
|
|
|
37
37
|
export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.js';
|
|
38
38
|
export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
|
|
39
39
|
export * from './federation/index.js';
|
|
40
|
+
export * from './agents/index.js';
|
|
40
41
|
export * from './gateway/index.js';
|
|
41
42
|
export { createMcpServer, serveMcpStdio } from './mcp/index.js';
|
|
42
43
|
export type { McpServerDeps } from './mcp/index.js';
|
package/dist/kernel/index.js
CHANGED
|
@@ -18,7 +18,7 @@ export { PluginManager } from './plugin-manager.js';
|
|
|
18
18
|
export { PolicyEngine, PolicyPresets, POLICY_FACTORIES } from './policies.js';
|
|
19
19
|
export { VerificationEngine } from './verification-engine.js';
|
|
20
20
|
export { RecoveryManager } from './recovery-manager.js';
|
|
21
|
-
export { ConsoleLogger, SilentLogger } from './logger.js';
|
|
21
|
+
export { ConsoleLogger, SilentLogger, FileLogger } from './logger.js';
|
|
22
22
|
export { OllamaProvider } from './providers/ollama-provider.js';
|
|
23
23
|
export { OpenAICompatibleProvider } from './providers/openai-compatible-provider.js';
|
|
24
24
|
export { GeminiProvider } from './providers/gemini-provider.js';
|
|
@@ -29,6 +29,8 @@ export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.
|
|
|
29
29
|
export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
|
|
30
30
|
// Federation — neutral device mesh (peers, telemetry, config sync, MOLTFED interop)
|
|
31
31
|
export * from './federation/index.js';
|
|
32
|
+
// Agents — pluggable executors (native kernel + third-party coding CLIs)
|
|
33
|
+
export * from './agents/index.js';
|
|
32
34
|
// Gateway — local OpenAI-compatible endpoint backed by kernel routing/recovery
|
|
33
35
|
export * from './gateway/index.js';
|
|
34
36
|
// MCP — north-facing control surface (spawn/dispatch/role/voice/map over the mesh)
|
package/dist/kernel/logger.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenKernel — Logger
|
|
3
|
-
*/
|
|
4
1
|
import type { Logger } from './types.js';
|
|
5
2
|
export declare class ConsoleLogger implements Logger {
|
|
6
3
|
private prefix;
|
|
@@ -16,3 +13,19 @@ export declare class SilentLogger implements Logger {
|
|
|
16
13
|
warn(): void;
|
|
17
14
|
error(): void;
|
|
18
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Appends structured lines to a log file (best-effort — never throws into the
|
|
18
|
+
* caller). Used to give the kernel gateway a durable trace of routing/swap/
|
|
19
|
+
* truncation decisions during a TUI session, since its console is owned by the
|
|
20
|
+
* TUI. Read the file to see exactly why a turn died mid-task.
|
|
21
|
+
*/
|
|
22
|
+
export declare class FileLogger implements Logger {
|
|
23
|
+
private file;
|
|
24
|
+
private prefix;
|
|
25
|
+
constructor(file: string, prefix?: string);
|
|
26
|
+
private write;
|
|
27
|
+
debug(message: string, ...args: unknown[]): void;
|
|
28
|
+
info(message: string, ...args: unknown[]): void;
|
|
29
|
+
warn(message: string, ...args: unknown[]): void;
|
|
30
|
+
error(message: string, ...args: unknown[]): void;
|
|
31
|
+
}
|