@link-assistant/agent 0.19.2 → 0.20.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.19.2",
3
+ "version": "0.20.2",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli/argv.ts CHANGED
@@ -4,15 +4,15 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Extract a named argument directly from process.argv.
7
+ * Extract a named argument from an arbitrary argv-like array.
8
8
  * Supports --name=value, --name value, and optional short aliases (-x=value, -x value).
9
- * @returns The argument value from CLI or null if not found
9
+ * @returns The argument value or null if not found
10
10
  */
11
- function getArgFromProcessArgv(
11
+ function extractArgFromArray(
12
+ args: string[],
12
13
  longFlag: string,
13
14
  shortFlag?: string
14
15
  ): string | null {
15
- const args = process.argv;
16
16
  const longPrefix = `--${longFlag}=`;
17
17
  const shortPrefix = shortFlag ? `-${shortFlag}=` : null;
18
18
  for (let i = 0; i < args.length; i++) {
@@ -43,6 +43,38 @@ function getArgFromProcessArgv(
43
43
  return null;
44
44
  }
45
45
 
46
+ /**
47
+ * Extract a named argument directly from process.argv, falling back to Bun.argv.
48
+ * Bun global installs and compiled binaries may have different process.argv structures
49
+ * (see oven-sh/bun#22157), so we check both sources. (#192, #239)
50
+ * @returns The argument value from CLI or null if not found
51
+ */
52
+ function getArgFromProcessArgv(
53
+ longFlag: string,
54
+ shortFlag?: string
55
+ ): string | null {
56
+ // Try process.argv first (standard Node.js / Bun behavior)
57
+ const fromProcess = extractArgFromArray(process.argv, longFlag, shortFlag);
58
+ if (fromProcess !== null) {
59
+ return fromProcess;
60
+ }
61
+
62
+ // Fallback: try Bun.argv if available — Bun global installs may have
63
+ // different process.argv structure (extra elements, shifted indices) (#239)
64
+ if (typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv) {
65
+ const fromBun = extractArgFromArray(
66
+ globalThis.Bun.argv,
67
+ longFlag,
68
+ shortFlag
69
+ );
70
+ if (fromBun !== null) {
71
+ return fromBun;
72
+ }
73
+ }
74
+
75
+ return null;
76
+ }
77
+
46
78
  /**
47
79
  * Extract model argument directly from process.argv
48
80
  * This is a safeguard against yargs caching issues (#192)
@@ -69,3 +101,14 @@ export function getCompactionModelFromProcessArgv(): string | null {
69
101
  export function getCompactionSafetyMarginFromProcessArgv(): string | null {
70
102
  return getArgFromProcessArgv('compaction-safety-margin');
71
103
  }
104
+
105
+ /**
106
+ * Extract --compaction-models argument directly from process.argv
107
+ * The value is a links notation references sequence, e.g.:
108
+ * "(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)"
109
+ * @returns The compaction models argument from CLI or null if not found
110
+ * @see https://github.com/link-assistant/agent/issues/232
111
+ */
112
+ export function getCompactionModelsFromProcessArgv(): string | null {
113
+ return getArgFromProcessArgv('compaction-models');
114
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  /** Default model used when no `--model` CLI argument is provided. */
9
- export const DEFAULT_MODEL = 'opencode/minimax-m2.5-free';
9
+ export const DEFAULT_MODEL = 'opencode/qwen3.6-plus-free';
10
10
 
11
11
  /** Default provider ID extracted from DEFAULT_MODEL. */
12
12
  export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
@@ -23,6 +23,29 @@ export const DEFAULT_MODEL_ID = DEFAULT_MODEL.split('/').slice(1).join('/');
23
23
  */
24
24
  export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
25
25
 
26
+ /**
27
+ * Default compaction models cascade, ordered from smallest/cheapest context to largest.
28
+ * During compaction, the system tries each model in order. If the used context exceeds
29
+ * a model's context limit, it skips to the next larger model. If a model's rate limit
30
+ * is reached, it also skips to the next model.
31
+ * The special value "same" means use the same model as `--model`.
32
+ *
33
+ * Parsed as links notation references sequence (single anonymous link):
34
+ * "(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)"
35
+ *
36
+ * Context limits (approximate):
37
+ * big-pickle: ~200K
38
+ * nemotron-3-super-free: ~262K
39
+ * minimax-m2.5-free: ~200K
40
+ * gpt-5-nano: ~400K
41
+ * qwen3.6-plus-free: ~1M
42
+ * same: (base model's context)
43
+ *
44
+ * @see https://github.com/link-assistant/agent/issues/232
45
+ */
46
+ export const DEFAULT_COMPACTION_MODELS =
47
+ '(big-pickle nemotron-3-super-free minimax-m2.5-free gpt-5-nano qwen3.6-plus-free same)';
48
+
26
49
  /**
27
50
  * Default compaction safety margin as a percentage of usable context window.
28
51
  * Applied only when the compaction model has a context window equal to or smaller
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getModelFromProcessArgv,
3
3
  getCompactionModelFromProcessArgv,
4
+ getCompactionModelsFromProcessArgv,
4
5
  getCompactionSafetyMarginFromProcessArgv,
5
6
  } from './argv.ts';
6
7
  import { Log } from '../util/log.ts';
@@ -8,6 +9,7 @@ import {
8
9
  DEFAULT_PROVIDER_ID,
9
10
  DEFAULT_MODEL_ID,
10
11
  DEFAULT_COMPACTION_MODEL,
12
+ DEFAULT_COMPACTION_MODELS,
11
13
  DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
12
14
  } from './defaults.ts';
13
15
 
@@ -19,12 +21,25 @@ import {
19
21
  * @returns {Promise<{providerID: string, modelID: string}>}
20
22
  */
21
23
  export async function parseModelConfig(argv, outputError, outputStatus) {
22
- // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196)
24
+ // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196, #239)
23
25
  // This is critical because yargs under Bun may fail to parse --model correctly,
24
26
  // returning the default value instead of the user's CLI argument.
25
27
  const cliModelArg = getModelFromProcessArgv();
26
28
  let modelArg = argv.model;
27
29
 
30
+ // Diagnostic logging: always log raw argv sources when debugging model resolution (#239)
31
+ // Bun global installs may have different process.argv structure (oven-sh/bun#22157)
32
+ Log.Default.info(() => ({
33
+ message: 'model resolution: argv sources',
34
+ processArgv: process.argv,
35
+ bunArgv:
36
+ typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
37
+ ? globalThis.Bun.argv
38
+ : '(not available)',
39
+ cliModelArg: cliModelArg ?? '(null - not found in argv)',
40
+ yargsModel: modelArg,
41
+ }));
42
+
28
43
  // ALWAYS prefer the CLI value over yargs when available (#196)
29
44
  // The yargs default (DEFAULT_MODEL) can silently override user's --model argument
30
45
  if (cliModelArg) {
@@ -39,6 +54,29 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
39
54
  // Always use CLI value when available, even if it matches yargs
40
55
  // This ensures we use the actual CLI argument, not a cached/default yargs value
41
56
  modelArg = cliModelArg;
57
+ } else if (modelArg === `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`) {
58
+ // cliModelArg is null AND yargs returned the default — check if process.argv
59
+ // actually contains --model to detect silent yargs/Bun mismatch (#239)
60
+ const rawArgvStr = process.argv.join(' ');
61
+ if (
62
+ rawArgvStr.includes('--model ') ||
63
+ rawArgvStr.includes('--model=') ||
64
+ rawArgvStr.includes('-m ') ||
65
+ rawArgvStr.includes('-m=')
66
+ ) {
67
+ Log.Default.error(() => ({
68
+ message:
69
+ 'CRITICAL: --model flag detected in process.argv but both getModelFromProcessArgv() and yargs returned default. ' +
70
+ 'This is likely a Bun/yargs argument parsing bug (oven-sh/bun#22157). ' +
71
+ 'The requested model will NOT be used — the default model will be used instead.',
72
+ processArgv: process.argv,
73
+ bunArgv:
74
+ typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
75
+ ? globalThis.Bun.argv
76
+ : '(not available)',
77
+ defaultModel: `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`,
78
+ }));
79
+ }
42
80
  }
43
81
 
44
82
  let providerID;
@@ -71,26 +109,41 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
71
109
  // Validate that the model exists in the provider (#196, #231)
72
110
  // If user explicitly specified provider/model and the model is not found,
73
111
  // fail immediately instead of silently falling back to a different model.
112
+ // However, if the model is the default (no --model CLI flag), warn but proceed (#239).
113
+ // The models.dev API may lag behind the provider's actual model availability.
114
+ const isDefaultModel = !cliModelArg;
74
115
  try {
75
116
  const { Provider } = await import('../provider/provider.ts');
76
117
  const s = await Provider.state();
77
118
  const provider = s.providers[providerID];
78
119
  if (provider && !provider.info.models[modelID]) {
79
- // Provider exists but model doesn't — fail with a clear error (#231)
80
- // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
81
120
  const availableModels = Object.keys(provider.info.models).slice(0, 10);
82
- Log.Default.error(() => ({
83
- message:
84
- 'model not found in provider refusing to proceed with explicit provider/model',
85
- providerID,
86
- modelID,
87
- availableModels,
88
- }));
89
- throw new Error(
90
- `Model "${modelID}" not found in provider "${providerID}". ` +
91
- `Available models include: ${availableModels.join(', ')}. ` +
92
- `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
93
- );
121
+ if (isDefaultModel) {
122
+ // Default model not in models.dev catalog — warn but proceed (#239)
123
+ // The provider may still accept it; models.dev can lag behind actual availability.
124
+ Log.Default.warn(() => ({
125
+ message:
126
+ 'default model not found in models.dev catalog — proceeding anyway',
127
+ providerID,
128
+ modelID,
129
+ availableModels,
130
+ }));
131
+ } else {
132
+ // User explicitly specified provider/model — fail with a clear error (#231)
133
+ // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
134
+ Log.Default.error(() => ({
135
+ message:
136
+ 'model not found in provider — refusing to proceed with explicit provider/model',
137
+ providerID,
138
+ modelID,
139
+ availableModels,
140
+ }));
141
+ throw new Error(
142
+ `Model "${modelID}" not found in provider "${providerID}". ` +
143
+ `Available models include: ${availableModels.join(', ')}. ` +
144
+ `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
145
+ );
146
+ }
94
147
  }
95
148
  } catch (validationError) {
96
149
  // Re-throw if this is our own validation error (not an infrastructure issue)
@@ -173,20 +226,71 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
173
226
  return { providerID, modelID, compactionModel: compactionModelResult };
174
227
  }
175
228
 
229
+ /**
230
+ * Parse a links notation references sequence string into an array of model names.
231
+ * Format: "(model1 model2 model3)" — parenthesized space-separated list.
232
+ * @param {string} notation - Links notation sequence string
233
+ * @returns {string[]} Array of model name strings
234
+ * @see https://github.com/link-assistant/agent/issues/232
235
+ */
236
+ function parseLinksNotationSequence(notation) {
237
+ const trimmed = notation.trim();
238
+ // Remove surrounding parentheses if present
239
+ const inner =
240
+ trimmed.startsWith('(') && trimmed.endsWith(')')
241
+ ? trimmed.slice(1, -1)
242
+ : trimmed;
243
+ // Split on whitespace and filter empty strings
244
+ return inner.split(/\s+/).filter((s) => s.length > 0);
245
+ }
246
+
247
+ /**
248
+ * Resolve a single compaction model entry (short name, provider/model, or "same").
249
+ * @returns {{ providerID: string, modelID: string, useSameModel: boolean }}
250
+ */
251
+ async function resolveCompactionModelEntry(
252
+ modelArg,
253
+ baseProviderID,
254
+ baseModelID
255
+ ) {
256
+ const useSameModel = modelArg.toLowerCase() === 'same';
257
+
258
+ if (useSameModel) {
259
+ return {
260
+ providerID: baseProviderID,
261
+ modelID: baseModelID,
262
+ useSameModel: true,
263
+ };
264
+ }
265
+
266
+ if (modelArg.includes('/')) {
267
+ const parts = modelArg.split('/');
268
+ return {
269
+ providerID: parts[0],
270
+ modelID: parts.slice(1).join('/'),
271
+ useSameModel: false,
272
+ };
273
+ }
274
+
275
+ // Short name resolution
276
+ const { Provider } = await import('../provider/provider.ts');
277
+ const resolved = await Provider.parseModelWithResolution(modelArg);
278
+ return {
279
+ providerID: resolved.providerID,
280
+ modelID: resolved.modelID,
281
+ useSameModel: false,
282
+ };
283
+ }
284
+
176
285
  /**
177
286
  * Parse compaction model config from argv.
178
- * Resolves --compaction-model and --compaction-safety-margin CLI arguments.
287
+ * Supports both --compaction-model (single) and --compaction-models (cascade).
288
+ * When --compaction-models is specified, it overrides --compaction-model.
179
289
  * The special value "same" means use the base model for compaction.
180
290
  * @see https://github.com/link-assistant/agent/issues/219
291
+ * @see https://github.com/link-assistant/agent/issues/232
181
292
  */
182
293
  async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
183
- // Get compaction model from CLI (safeguard against yargs caching)
184
- const cliCompactionModelArg = getCompactionModelFromProcessArgv();
185
- const compactionModelArg =
186
- cliCompactionModelArg ??
187
- argv['compaction-model'] ??
188
- DEFAULT_COMPACTION_MODEL;
189
-
190
294
  // Get safety margin from CLI
191
295
  const cliSafetyMarginArg = getCompactionSafetyMarginFromProcessArgv();
192
296
  const compactionSafetyMarginPercent = cliSafetyMarginArg
@@ -194,49 +298,97 @@ async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
194
298
  : (argv['compaction-safety-margin'] ??
195
299
  DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT);
196
300
 
197
- // Special "same" alias use the base model for compaction
198
- const useSameModel = compactionModelArg.toLowerCase() === 'same';
301
+ // Check for --compaction-models (cascade) first it overrides --compaction-model
302
+ const cliCompactionModelsArg = getCompactionModelsFromProcessArgv();
303
+ const compactionModelsArg =
304
+ cliCompactionModelsArg ??
305
+ argv['compaction-models'] ??
306
+ DEFAULT_COMPACTION_MODELS;
199
307
 
200
- let compactionProviderID;
201
- let compactionModelID;
308
+ // Parse the links notation sequence into an array of model names
309
+ const modelNames = parseLinksNotationSequence(compactionModelsArg);
310
+
311
+ if (modelNames.length > 0) {
312
+ // Resolve each model in the cascade
313
+ const compactionModels = [];
314
+ for (const name of modelNames) {
315
+ try {
316
+ const resolved = await resolveCompactionModelEntry(
317
+ name,
318
+ baseProviderID,
319
+ baseModelID
320
+ );
321
+ compactionModels.push({
322
+ providerID: resolved.providerID,
323
+ modelID: resolved.modelID,
324
+ useSameModel: resolved.useSameModel,
325
+ });
326
+ } catch (err) {
327
+ // If a model can't be resolved, log and skip it
328
+ Log.Default.warn(() => ({
329
+ message: 'skipping unresolvable compaction model in cascade',
330
+ model: name,
331
+ error: err?.message,
332
+ }));
333
+ }
334
+ }
202
335
 
203
- if (useSameModel) {
204
- compactionProviderID = baseProviderID;
205
- compactionModelID = baseModelID;
206
- Log.Default.info(() => ({
207
- message:
208
- 'compaction model set to "same" — using base model for compaction',
209
- compactionProviderID,
210
- compactionModelID,
211
- }));
212
- } else if (compactionModelArg.includes('/')) {
213
- const parts = compactionModelArg.split('/');
214
- compactionProviderID = parts[0];
215
- compactionModelID = parts.slice(1).join('/');
216
- Log.Default.info(() => ({
217
- message: 'using explicit compaction model',
218
- compactionProviderID,
219
- compactionModelID,
220
- }));
221
- } else {
222
- // Short name resolution
223
- const { Provider } = await import('../provider/provider.ts');
224
- const resolved =
225
- await Provider.parseModelWithResolution(compactionModelArg);
226
- compactionProviderID = resolved.providerID;
227
- compactionModelID = resolved.modelID;
228
336
  Log.Default.info(() => ({
229
- message: 'resolved short compaction model name',
230
- input: compactionModelArg,
231
- compactionProviderID,
232
- compactionModelID,
337
+ message: 'compaction models cascade configured',
338
+ models: compactionModels.map((m) =>
339
+ m.useSameModel ? 'same' : `${m.providerID}/${m.modelID}`
340
+ ),
341
+ source: cliCompactionModelsArg ? 'cli' : 'default',
233
342
  }));
343
+
344
+ // Use the first model as the primary compaction model (for backward compatibility)
345
+ // The full cascade is stored in compactionModels array
346
+ const primary = compactionModels[0] || {
347
+ providerID: baseProviderID,
348
+ modelID: baseModelID,
349
+ useSameModel: true,
350
+ };
351
+
352
+ return {
353
+ providerID: primary.providerID,
354
+ modelID: primary.modelID,
355
+ useSameModel: primary.useSameModel,
356
+ compactionSafetyMarginPercent,
357
+ compactionModels,
358
+ };
234
359
  }
235
360
 
361
+ // Fallback to single --compaction-model
362
+ const cliCompactionModelArg = getCompactionModelFromProcessArgv();
363
+ const compactionModelArg =
364
+ cliCompactionModelArg ??
365
+ argv['compaction-model'] ??
366
+ DEFAULT_COMPACTION_MODEL;
367
+
368
+ const resolved = await resolveCompactionModelEntry(
369
+ compactionModelArg,
370
+ baseProviderID,
371
+ baseModelID
372
+ );
373
+
374
+ Log.Default.info(() => ({
375
+ message: 'using single compaction model',
376
+ compactionProviderID: resolved.providerID,
377
+ compactionModelID: resolved.modelID,
378
+ useSameModel: resolved.useSameModel,
379
+ }));
380
+
236
381
  return {
237
- providerID: compactionProviderID,
238
- modelID: compactionModelID,
239
- useSameModel,
382
+ providerID: resolved.providerID,
383
+ modelID: resolved.modelID,
384
+ useSameModel: resolved.useSameModel,
240
385
  compactionSafetyMarginPercent,
386
+ compactionModels: [
387
+ {
388
+ providerID: resolved.providerID,
389
+ modelID: resolved.modelID,
390
+ useSameModel: resolved.useSameModel,
391
+ },
392
+ ],
241
393
  };
242
394
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEFAULT_MODEL,
3
3
  DEFAULT_COMPACTION_MODEL,
4
+ DEFAULT_COMPACTION_MODELS,
4
5
  DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
5
6
  } from './defaults.ts';
6
7
 
@@ -151,9 +152,17 @@ export function buildRunOptions(yargs) {
151
152
  .option('compaction-model', {
152
153
  type: 'string',
153
154
  description:
154
- 'Model to use for context compaction in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context).',
155
+ 'Model to use for context compaction in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context). Overridden by --compaction-models if both are specified.',
155
156
  default: DEFAULT_COMPACTION_MODEL,
156
157
  })
158
+ .option('compaction-models', {
159
+ type: 'string',
160
+ description:
161
+ 'Ordered cascade of compaction models in links notation sequence format: "(model1 model2 ... same)". ' +
162
+ "Models are tried from smallest/cheapest context to largest. If used context exceeds a model's limit or its rate limit is reached, the next model is tried. " +
163
+ 'The special value "same" uses the base model. Overrides --compaction-model when specified.',
164
+ default: DEFAULT_COMPACTION_MODELS,
165
+ })
157
166
  .option('compaction-safety-margin', {
158
167
  type: 'number',
159
168
  description:
package/src/index.js CHANGED
@@ -87,12 +87,22 @@ process.stderr.write = function (chunk, encoding, callback) {
87
87
  }
88
88
  }
89
89
 
90
- // Wrap non-JSON stderr output in JSON envelope
91
- const wrapped = `${JSON.stringify({
92
- type: 'error',
93
- errorType: 'RuntimeError',
94
- message: trimmed,
95
- })}\n`;
90
+ // Wrap non-JSON stderr output in JSON envelope.
91
+ // Verbose/debug messages should use "type": "log", not "type": "error" (#235).
92
+ const isVerboseMsg =
93
+ trimmed.startsWith('[verbose]') || trimmed.startsWith('[debug]');
94
+ const wrapped = isVerboseMsg
95
+ ? `${JSON.stringify({
96
+ type: 'log',
97
+ level: 'debug',
98
+ service: 'stderr',
99
+ message: trimmed,
100
+ })}\n`
101
+ : `${JSON.stringify({
102
+ type: 'error',
103
+ errorType: 'RuntimeError',
104
+ message: trimmed,
105
+ })}\n`;
96
106
  return originalStderrWrite(wrapped, encoding, callback);
97
107
  };
98
108
 
@@ -7,7 +7,12 @@ import { data } from './models-macro';
7
7
 
8
8
  export namespace ModelsDev {
9
9
  const log = Log.create({ service: 'models.dev' });
10
- const verboseFetch = createVerboseFetch(fetch, { caller: 'models.dev' });
10
+ const verboseFetch = createVerboseFetch(fetch, {
11
+ caller: 'models.dev',
12
+ // models.dev/api.json response can be 200KB+; logging the full body
13
+ // crashes the subprocess in CI (#239). Keep preview small.
14
+ responseBodyMaxChars: 2000,
15
+ });
11
16
  const filepath = path.join(Global.Path.cache, 'models.json');
12
17
 
13
18
  export const Model = z
@@ -1241,17 +1241,13 @@ export namespace Provider {
1241
1241
 
1242
1242
  // Log a one-time confirmation that the verbose wrapper is active for this provider.
1243
1243
  // This diagnostic breadcrumb confirms the wrapper is in the fetch chain.
1244
- // Also write to stderr as a redundant channel — stdout JSON may be filtered by wrappers.
1245
1244
  // See: https://github.com/link-assistant/agent/issues/215
1245
+ // See: https://github.com/link-assistant/agent/issues/235
1246
1246
  if (!verboseWrapperConfirmed) {
1247
1247
  verboseWrapperConfirmed = true;
1248
- log.info('verbose HTTP logging active', {
1248
+ log.debug('verbose HTTP logging active', {
1249
1249
  providerID: provider.id,
1250
1250
  });
1251
- // Redundant stderr confirmation — visible even if stdout is piped/filtered
1252
- process.stderr.write(
1253
- `[verbose] HTTP logging active for provider: ${provider.id}\n`
1254
- );
1255
1251
  }
1256
1252
 
1257
1253
  const url =
@@ -1623,30 +1619,51 @@ export namespace Provider {
1623
1619
  }
1624
1620
 
1625
1621
  if (!isSyntheticProvider && !info) {
1626
- // Model not found even after cache refresh — fail with a clear error (#231)
1627
- // Previously this created synthetic fallback info, which allowed the API call
1628
- // to proceed with the wrong model (e.g., kimi-k2.5-free routed to minimax-m2.5-free)
1622
+ // Model not found even after cache refresh.
1623
+ // Check if this is the default model — if so, create synthetic info and proceed (#239).
1624
+ // The models.dev API can lag behind the provider's actual model availability.
1625
+ // For user-specified models, fail with a clear error (#231) to prevent silent substitution.
1626
+ const { DEFAULT_PROVIDER_ID, DEFAULT_MODEL_ID } =
1627
+ await import('../cli/defaults.ts');
1628
+ const isDefaultModel =
1629
+ providerID === DEFAULT_PROVIDER_ID && modelID === DEFAULT_MODEL_ID;
1629
1630
  const availableInProvider = Object.keys(provider.info.models).slice(
1630
1631
  0,
1631
1632
  10
1632
1633
  );
1633
- log.error(() => ({
1634
- message:
1635
- 'model not found in provider catalog after refresh — refusing to proceed',
1636
- providerID,
1637
- modelID,
1638
- availableModels: availableInProvider,
1639
- totalModels: Object.keys(provider.info.models).length,
1640
- }));
1641
1634
 
1642
- throw new ModelNotFoundError({
1643
- providerID,
1644
- modelID,
1645
- suggestion:
1646
- `Model "${modelID}" not found in provider "${providerID}" (checked ${Object.keys(provider.info.models).length} models). ` +
1647
- `Available models include: ${availableInProvider.join(', ')}. ` +
1648
- `Use --model ${providerID}/<model-id> with a valid model.`,
1649
- });
1635
+ if (isDefaultModel) {
1636
+ // Default model not in models.dev catalog — create synthetic info and try anyway (#239)
1637
+ log.warn(() => ({
1638
+ message:
1639
+ 'default model not in provider catalog creating synthetic info to proceed',
1640
+ providerID,
1641
+ modelID,
1642
+ availableModels: availableInProvider,
1643
+ }));
1644
+ info = {
1645
+ id: modelID,
1646
+ name: modelID,
1647
+ } as typeof info;
1648
+ } else {
1649
+ log.error(() => ({
1650
+ message:
1651
+ 'model not found in provider catalog after refresh — refusing to proceed',
1652
+ providerID,
1653
+ modelID,
1654
+ availableModels: availableInProvider,
1655
+ totalModels: Object.keys(provider.info.models).length,
1656
+ }));
1657
+
1658
+ throw new ModelNotFoundError({
1659
+ providerID,
1660
+ modelID,
1661
+ suggestion:
1662
+ `Model "${modelID}" not found in provider "${providerID}" (checked ${Object.keys(provider.info.models).length} models). ` +
1663
+ `Available models include: ${availableInProvider.join(', ')}. ` +
1664
+ `Use --model ${providerID}/<model-id> with a valid model.`,
1665
+ });
1666
+ }
1650
1667
  }
1651
1668
 
1652
1669
  try {
@@ -1731,7 +1748,13 @@ export namespace Provider {
1731
1748
  priority = priority.filter((m) => m !== 'claude-haiku-4.5');
1732
1749
  }
1733
1750
  if (providerID === 'opencode' || providerID === 'local') {
1734
- priority = ['minimax-m2.5-free', 'gpt-5-nano', 'big-pickle'];
1751
+ priority = [
1752
+ 'qwen3.6-plus-free',
1753
+ 'minimax-m2.5-free',
1754
+ 'gpt-5-nano',
1755
+ 'nemotron-3-super-free',
1756
+ 'big-pickle',
1757
+ ];
1735
1758
  }
1736
1759
  if (providerID === 'kilo') {
1737
1760
  priority = [
@@ -1758,7 +1781,9 @@ export namespace Provider {
1758
1781
  }
1759
1782
 
1760
1783
  const priority = [
1784
+ 'qwen3.6-plus-free',
1761
1785
  'glm-5-free',
1786
+ 'nemotron-3-super-free',
1762
1787
  'minimax-m2.5-free',
1763
1788
  'gpt-5-nano',
1764
1789
  'big-pickle',
@@ -1841,7 +1866,7 @@ export namespace Provider {
1841
1866
  * 1. If model is uniquely available in one provider, use that provider
1842
1867
  * 2. If model is available in multiple providers, prioritize based on free model availability:
1843
1868
  * - kilo: glm-5-free, glm-4.5-air-free, minimax-m2.5-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
1844
- * - opencode: big-pickle, gpt-5-nano (unique to OpenCode)
1869
+ * - opencode: big-pickle, gpt-5-nano, qwen3.6-plus-free, nemotron-3-super-free (unique to OpenCode)
1845
1870
  * 3. For shared models, prefer OpenCode first, then fall back to Kilo on rate limit
1846
1871
  *
1847
1872
  * @param modelID - Short model name without provider prefix
@@ -36,15 +36,35 @@ export namespace SessionCompaction {
36
36
  */
37
37
  export const OVERFLOW_SAFETY_MARGIN = 0.85;
38
38
 
39
+ /**
40
+ * A single compaction model entry in the cascade.
41
+ * @see https://github.com/link-assistant/agent/issues/232
42
+ */
43
+ export interface CompactionModelEntry {
44
+ providerID: string;
45
+ modelID: string;
46
+ useSameModel: boolean;
47
+ }
48
+
39
49
  /**
40
50
  * Compaction model configuration passed from CLI.
51
+ * Supports both single model (backward compat) and cascade of models (#232).
41
52
  * @see https://github.com/link-assistant/agent/issues/219
53
+ * @see https://github.com/link-assistant/agent/issues/232
42
54
  */
43
55
  export interface CompactionModelConfig {
44
56
  providerID: string;
45
57
  modelID: string;
46
58
  useSameModel: boolean;
47
59
  compactionSafetyMarginPercent: number;
60
+ /**
61
+ * Ordered cascade of compaction models from smallest/cheapest to largest.
62
+ * When present, the system tries each model in order during compaction.
63
+ * If used context exceeds a model's limit or its rate limit is reached,
64
+ * the next model is tried.
65
+ * @see https://github.com/link-assistant/agent/issues/232
66
+ */
67
+ compactionModels?: CompactionModelEntry[];
48
68
  }
49
69
 
50
70
  /**
@@ -398,6 +398,15 @@ export namespace MessageV2 {
398
398
  modelID: z.string(),
399
399
  useSameModel: z.boolean(),
400
400
  compactionSafetyMarginPercent: z.number(),
401
+ compactionModels: z
402
+ .array(
403
+ z.object({
404
+ providerID: z.string(),
405
+ modelID: z.string(),
406
+ useSameModel: z.boolean(),
407
+ })
408
+ )
409
+ .optional(),
401
410
  })
402
411
  .optional(),
403
412
  system: z.string().optional(),
@@ -95,6 +95,15 @@ export namespace SessionPrompt {
95
95
  modelID: z.string(),
96
96
  useSameModel: z.boolean(),
97
97
  compactionSafetyMarginPercent: z.number(),
98
+ compactionModels: z
99
+ .array(
100
+ z.object({
101
+ providerID: z.string(),
102
+ modelID: z.string(),
103
+ useSameModel: z.boolean(),
104
+ })
105
+ )
106
+ .optional(),
98
107
  })
99
108
  .optional(),
100
109
  agent: z.string().optional(),
@@ -295,15 +304,30 @@ export namespace SessionPrompt {
295
304
  // continue the loop to execute them instead of prematurely exiting.
296
305
  // See: https://github.com/link-assistant/agent/issues/194
297
306
  if (lastAssistant.finish === 'unknown') {
307
+ // First check for tool calls BEFORE checking zero tokens (#239)
308
+ // Some providers (e.g., OpenCode Zen / OpenRouter) return zero tokens and
309
+ // unknown finish reason even when the model successfully executed tool calls.
310
+ // We must check for tool calls first to avoid prematurely terminating
311
+ // a session that is actually making progress.
312
+ const lastAssistantParts = msgs.find(
313
+ (m) => m.info.id === lastAssistant.id
314
+ )?.parts;
315
+ const hasToolCalls = lastAssistantParts?.some(
316
+ (p) =>
317
+ p.type === 'tool' &&
318
+ (p.state.status === 'completed' || p.state.status === 'running')
319
+ );
320
+
298
321
  // SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
299
322
  // When all tokens are 0 and finish reason is 'unknown', this indicates the provider
300
323
  // returned an empty/error response (e.g., rate limit, model unavailable, API failure).
301
- // Log a clear error message so the problem is visible in logs.
324
+ // But ONLY treat this as fatal if there are NO tool calls (#239).
302
325
  const tokens = lastAssistant.tokens;
303
326
  if (
304
327
  tokens.input === 0 &&
305
328
  tokens.output === 0 &&
306
- tokens.reasoning === 0
329
+ tokens.reasoning === 0 &&
330
+ !hasToolCalls
307
331
  ) {
308
332
  const errorMessage =
309
333
  `Provider returned zero tokens with unknown finish reason. ` +
@@ -331,20 +355,13 @@ export namespace SessionPrompt {
331
355
  break;
332
356
  }
333
357
 
334
- const lastAssistantParts = msgs.find(
335
- (m) => m.info.id === lastAssistant.id
336
- )?.parts;
337
- const hasToolCalls = lastAssistantParts?.some(
338
- (p) =>
339
- p.type === 'tool' &&
340
- (p.state.status === 'completed' || p.state.status === 'running')
341
- );
342
358
  if (hasToolCalls) {
343
359
  log.info(() => ({
344
360
  message:
345
361
  'continuing loop despite unknown finish reason - tool calls detected',
346
362
  sessionID,
347
363
  finishReason: lastAssistant.finish,
364
+ zeroTokens: tokens.input === 0 && tokens.output === 0,
348
365
  hint: 'Provider returned undefined finishReason but made tool calls',
349
366
  }));
350
367
  // Don't break - continue the loop to handle tool call results
@@ -542,27 +559,109 @@ export namespace SessionPrompt {
542
559
 
543
560
  // pending compaction
544
561
  if (task?.type === 'compaction') {
545
- // Use compaction model if configured, otherwise fall back to base model
562
+ // Use compaction model cascade if configured (#232)
546
563
  const compactionModelConfig = lastUser.compactionModel;
547
- const compactionProviderID =
548
- compactionModelConfig && !compactionModelConfig.useSameModel
549
- ? compactionModelConfig.providerID
550
- : model.providerID;
551
- const compactionModelID =
552
- compactionModelConfig && !compactionModelConfig.useSameModel
553
- ? compactionModelConfig.modelID
554
- : model.modelID;
555
- const result = await SessionCompaction.process({
556
- messages: msgs,
557
- parentID: lastUser.id,
558
- abort,
559
- model: {
560
- providerID: compactionProviderID,
561
- modelID: compactionModelID,
562
- },
563
- sessionID,
564
- });
565
- if (result === 'stop') break;
564
+ const cascade = compactionModelConfig?.compactionModels;
565
+
566
+ if (cascade && cascade.length > 0) {
567
+ // Cascade logic: try each model in order (smallest to largest context)
568
+ // Skip models whose context limit is smaller than current used tokens
569
+ // Skip models that hit rate limits (try next)
570
+ const currentTokens = lastFinished
571
+ ? lastFinished.tokens.input +
572
+ lastFinished.tokens.cache.read +
573
+ lastFinished.tokens.output
574
+ : 0;
575
+
576
+ let compactionResult = 'stop';
577
+ for (const entry of cascade) {
578
+ const entryProviderID = entry.useSameModel
579
+ ? model.providerID
580
+ : entry.providerID;
581
+ const entryModelID = entry.useSameModel
582
+ ? model.modelID
583
+ : entry.modelID;
584
+
585
+ // Check if this model's context is large enough for the current tokens
586
+ if (!entry.useSameModel) {
587
+ try {
588
+ const entryModel = await Provider.getModel(
589
+ entryProviderID,
590
+ entryModelID
591
+ );
592
+ const entryContextLimit = entryModel.info?.limit?.context ?? 0;
593
+ if (
594
+ entryContextLimit > 0 &&
595
+ currentTokens > entryContextLimit
596
+ ) {
597
+ log.info(() => ({
598
+ message:
599
+ 'skipping compaction model — context too small for current tokens',
600
+ modelID: entryModelID,
601
+ providerID: entryProviderID,
602
+ contextLimit: entryContextLimit,
603
+ currentTokens,
604
+ }));
605
+ continue;
606
+ }
607
+ } catch {
608
+ log.info(() => ({
609
+ message:
610
+ 'could not resolve compaction cascade model — skipping',
611
+ modelID: entryModelID,
612
+ providerID: entryProviderID,
613
+ }));
614
+ continue;
615
+ }
616
+ }
617
+
618
+ try {
619
+ compactionResult = await SessionCompaction.process({
620
+ messages: msgs,
621
+ parentID: lastUser.id,
622
+ abort,
623
+ model: {
624
+ providerID: entryProviderID,
625
+ modelID: entryModelID,
626
+ },
627
+ sessionID,
628
+ });
629
+ // If compaction succeeded, break the cascade
630
+ break;
631
+ } catch (err) {
632
+ // If rate limited or error, try next model in cascade
633
+ log.warn(() => ({
634
+ message: 'compaction model failed — trying next in cascade',
635
+ modelID: entryModelID,
636
+ providerID: entryProviderID,
637
+ error: err?.message,
638
+ }));
639
+ continue;
640
+ }
641
+ }
642
+ if (compactionResult === 'stop') break;
643
+ } else {
644
+ // Single model fallback (backward compatibility)
645
+ const compactionProviderID =
646
+ compactionModelConfig && !compactionModelConfig.useSameModel
647
+ ? compactionModelConfig.providerID
648
+ : model.providerID;
649
+ const compactionModelID =
650
+ compactionModelConfig && !compactionModelConfig.useSameModel
651
+ ? compactionModelConfig.modelID
652
+ : model.modelID;
653
+ const result = await SessionCompaction.process({
654
+ messages: msgs,
655
+ parentID: lastUser.id,
656
+ abort,
657
+ model: {
658
+ providerID: compactionProviderID,
659
+ modelID: compactionModelID,
660
+ },
661
+ sessionID,
662
+ });
663
+ if (result === 'stop') break;
664
+ }
566
665
  continue;
567
666
  }
568
667
 
@@ -22,8 +22,15 @@ export namespace Storage {
22
22
 
23
23
  const MIGRATIONS: Migration[] = [
24
24
  async (dir) => {
25
- const project = path.resolve(dir, '../project');
26
- if (!fs.exists(project)) return;
25
+ // Sanitize path: strip null bytes that may appear in Bun runtime path operations (#239)
26
+ const project = path.resolve(dir, '../project').replace(/\0/g, '');
27
+ if (
28
+ !(await fs
29
+ .stat(project)
30
+ .then((s) => s.isDirectory())
31
+ .catch(() => false))
32
+ )
33
+ return;
27
34
  for await (const projectDir of new Bun.Glob('*').scan({
28
35
  cwd: project,
29
36
  onlyFiles: false,
@@ -45,7 +52,13 @@ export namespace Storage {
45
52
  if (worktree) break;
46
53
  }
47
54
  if (!worktree) continue;
48
- if (!(await fs.exists(worktree))) continue;
55
+ if (
56
+ !(await fs
57
+ .stat(worktree)
58
+ .then((s) => s.isDirectory())
59
+ .catch(() => false))
60
+ )
61
+ continue;
49
62
  const [id] = await $`git rev-list --max-parents=0 --all`
50
63
  .quiet()
51
64
  .nothrow()
@@ -174,8 +187,8 @@ export namespace Storage {
174
187
  const state = lazy(async () => {
175
188
  const dir = path.join(Global.Path.data, 'storage');
176
189
  const migration = await Bun.file(path.join(dir, 'migration'))
177
- .json()
178
- .then((x) => parseInt(x))
190
+ .text()
191
+ .then((x) => parseInt(x.trim(), 10))
179
192
  .catch(() => 0);
180
193
  for (let index = migration; index < MIGRATIONS.length; index++) {
181
194
  log.info(() => ({ message: 'running migration', index }));
@@ -79,7 +79,7 @@ export function sanitizeHeaders(
79
79
  */
80
80
  export function bodyPreview(
81
81
  body: BodyInit | null | undefined,
82
- maxChars = 200000
82
+ maxChars = 4000
83
83
  ): string | undefined {
84
84
  if (!body) return undefined;
85
85
 
@@ -128,8 +128,8 @@ export function createVerboseFetch(
128
128
  ): typeof fetch {
129
129
  const {
130
130
  caller,
131
- responseBodyMaxChars = 200000,
132
- requestBodyMaxChars = 200000,
131
+ responseBodyMaxChars = 4000,
132
+ requestBodyMaxChars = 4000,
133
133
  } = options;
134
134
 
135
135
  return async (
@@ -330,10 +330,15 @@ export function resetHttpCallCount(): void {
330
330
  export function registerPendingStreamLogExitHandler(): void {
331
331
  process.once('exit', () => {
332
332
  if (pendingStreamLogs > 0) {
333
- // Use stderr directly since the process is exiting and log infrastructure may be unavailable
334
- process.stderr.write(
335
- `[verbose] warning: ${pendingStreamLogs} HTTP stream response log(s) were still pending at process exit — response bodies may be missing from logs\n`
336
- );
333
+ // Use stderr directly since the process is exiting and log infrastructure may be unavailable.
334
+ // Write as JSON to avoid the stderr interceptor wrapping it as "type": "error" (#235).
335
+ const warning = JSON.stringify({
336
+ type: 'log',
337
+ level: 'warn',
338
+ service: 'http',
339
+ message: `${pendingStreamLogs} HTTP stream response log(s) were still pending at process exit — response bodies may be missing from logs`,
340
+ });
341
+ process.stderr.write(warning + '\n');
337
342
  }
338
343
  });
339
344
  }