@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 +1 -1
- package/src/cli/argv.ts +47 -4
- package/src/cli/defaults.ts +24 -1
- package/src/cli/model-config.js +211 -59
- package/src/cli/run-options.js +10 -1
- package/src/index.js +16 -6
- package/src/provider/models.ts +6 -1
- package/src/provider/provider.ts +52 -27
- package/src/session/compaction.ts +20 -0
- package/src/session/message-v2.ts +9 -0
- package/src/session/prompt.ts +129 -30
- package/src/storage/storage.ts +18 -5
- package/src/util/verbose-fetch.ts +12 -7
package/package.json
CHANGED
package/src/cli/argv.ts
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Extract a named argument
|
|
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
|
|
9
|
+
* @returns The argument value or null if not found
|
|
10
10
|
*/
|
|
11
|
-
function
|
|
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
|
+
}
|
package/src/cli/defaults.ts
CHANGED
|
@@ -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/
|
|
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
|
package/src/cli/model-config.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
198
|
-
const
|
|
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
|
-
|
|
201
|
-
|
|
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: '
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
238
|
-
modelID:
|
|
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
|
}
|
package/src/cli/run-options.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
package/src/provider/models.ts
CHANGED
|
@@ -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, {
|
|
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
|
package/src/provider/provider.ts
CHANGED
|
@@ -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.
|
|
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
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
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
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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 = [
|
|
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(),
|
package/src/session/prompt.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
562
|
+
// Use compaction model cascade if configured (#232)
|
|
546
563
|
const compactionModelConfig = lastUser.compactionModel;
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
package/src/storage/storage.ts
CHANGED
|
@@ -22,8 +22,15 @@ export namespace Storage {
|
|
|
22
22
|
|
|
23
23
|
const MIGRATIONS: Migration[] = [
|
|
24
24
|
async (dir) => {
|
|
25
|
-
|
|
26
|
-
|
|
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 (
|
|
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
|
-
.
|
|
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 =
|
|
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 =
|
|
132
|
-
requestBodyMaxChars =
|
|
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
|
-
|
|
335
|
-
|
|
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
|
}
|