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.
Files changed (64) hide show
  1. package/.opencode/plugins/home-logo-animation.tsx +5 -1
  2. package/.opencode/plugins/mesh-commands.tsx +4 -3
  3. package/.opencode/plugins/routing-mode-display.tsx +6 -2
  4. package/.opencode/tui.json +2 -1
  5. package/bin/robopark.js +2 -0
  6. package/dist/cli.js +46 -1
  7. package/dist/commands/maintain.d.ts +29 -0
  8. package/dist/commands/maintain.js +186 -0
  9. package/dist/commands/mcp.js +1 -1
  10. package/dist/commands/mesh-install.d.ts +10 -0
  11. package/dist/commands/mesh-install.js +160 -0
  12. package/dist/commands/robopark.d.ts +6 -0
  13. package/dist/commands/robopark.js +450 -0
  14. package/dist/commands/run.js +82 -22
  15. package/dist/commands/serve.d.ts +1 -0
  16. package/dist/commands/serve.js +152 -11
  17. package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
  18. package/dist/kernel/agents/backends/cli-backend.js +140 -0
  19. package/dist/kernel/agents/backends/detect.d.ts +17 -0
  20. package/dist/kernel/agents/backends/detect.js +140 -0
  21. package/dist/kernel/agents/backends/index.d.ts +11 -0
  22. package/dist/kernel/agents/backends/index.js +3 -0
  23. package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
  24. package/dist/kernel/agents/backends/parse-utils.js +72 -0
  25. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  26. package/dist/kernel/agents/backends/profiles.js +48 -0
  27. package/dist/kernel/agents/backends/registry.d.ts +31 -0
  28. package/dist/kernel/agents/backends/registry.js +174 -0
  29. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  30. package/dist/kernel/agents/backends/rotation.js +107 -0
  31. package/dist/kernel/agents/backends/types.d.ts +67 -0
  32. package/dist/kernel/agents/backends/types.js +1 -0
  33. package/dist/kernel/agents/index.d.ts +8 -0
  34. package/dist/kernel/agents/index.js +8 -0
  35. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  36. package/dist/kernel/federation/dashboard-html.js +371 -0
  37. package/dist/kernel/federation/federation.d.ts +112 -2
  38. package/dist/kernel/federation/federation.js +270 -10
  39. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  40. package/dist/kernel/federation/telemetry.d.ts +1 -1
  41. package/dist/kernel/federation/telemetry.js +2 -1
  42. package/dist/kernel/federation/transport-http.d.ts +17 -1
  43. package/dist/kernel/federation/transport-http.js +65 -2
  44. package/dist/kernel/federation/types.d.ts +46 -1
  45. package/dist/kernel/free-providers.js +83 -0
  46. package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
  47. package/dist/kernel/gateway/openai-gateway.js +428 -49
  48. package/dist/kernel/index.d.ts +2 -1
  49. package/dist/kernel/index.js +3 -1
  50. package/dist/kernel/logger.d.ts +16 -3
  51. package/dist/kernel/logger.js +38 -0
  52. package/dist/kernel/mcp/mcp-server.js +65 -10
  53. package/dist/kernel/orchestrator.d.ts +4 -0
  54. package/dist/kernel/orchestrator.js +34 -0
  55. package/dist/kernel/provider-manager.d.ts +13 -0
  56. package/dist/kernel/provider-manager.js +58 -5
  57. package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
  58. package/dist/kernel/providers/openai-compatible-provider.js +22 -3
  59. package/dist/kernel/recovery-manager.js +55 -15
  60. package/dist/kernel/router.js +15 -2
  61. package/dist/kernel/types.d.ts +12 -2
  62. package/dist/robopark-cli.d.ts +2 -0
  63. package/dist/robopark-cli.js +23 -0
  64. 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
- const MAX_ATTEMPTS = 4;
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
- /** Start listening. Scans ports [port .. port+10] to avoid collisions. */
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 + 10; candidate++) {
90
+ for (let candidate = first; candidate <= first + 20; candidate++) {
66
91
  try {
67
92
  await this.listen(host, candidate);
68
- this.boundPort = candidate;
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
- if (err.code !== 'EADDRINUSE')
75
- throw err;
98
+ const code = err.code;
99
+ if (code !== 'EADDRINUSE' && code !== 'EACCES')
100
+ break; // unexpected — stop scanning, try ephemeral
76
101
  }
77
102
  }
78
- throw lastErr ?? new Error('gateway: no free port');
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 → escalate-on-error → relay ────────────────────────
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 = { ...body, model: target.model, stream };
153
- try {
154
- const resp = await this.executor.execute(target, upstream, controller.signal);
155
- if (resp.ok) {
156
- this.opts.kernel.providerManager.recordSuccess(target.providerId);
157
- await this.relay(resp, res, stream);
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
- lastStatus = resp.status;
161
- lastErr = await resp.text().catch(() => `${resp.status}`);
162
- this.recordError(target.providerId, resp.status, lastErr);
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
- catch (err) {
165
- lastStatus = 502;
166
- lastErr = err instanceof Error ? err.message : String(err);
167
- if (controller.signal.aborted)
168
- return; // client hung up
169
- this.recordError(target.providerId, 0, lastErr);
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
- /** Stream bytes through untouched, or relay the JSON body for non-streaming. */
184
- async relay(resp, res, stream) {
185
- if (stream && resp.body) {
186
- res.writeHead(200, {
187
- 'content-type': resp.headers.get('content-type') ?? 'text/event-stream',
188
- 'cache-control': 'no-cache',
189
- connection: 'keep-alive',
190
- });
191
- const reader = resp.body.getReader();
192
- for (;;) {
193
- const { done, value } = await reader.read();
194
- if (done)
195
- break;
196
- res.write(Buffer.from(value));
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
- const text = await resp.text();
202
- res.writeHead(resp.status, { 'content-type': resp.headers.get('content-type') ?? 'application/json' });
203
- res.end(text);
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
- if (kind === '429' || kind === 'provider-down') {
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
- return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
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
- return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
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
- if (status === 429)
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) {
@@ -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';
@@ -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)
@@ -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
+ }