@link-assistant/agent 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/argv.ts +37 -5
- package/src/cli/defaults.ts +6 -5
- package/src/cli/model-config.js +66 -15
- package/src/index.js +16 -6
- package/src/provider/models.ts +6 -1
- package/src/provider/provider.ts +45 -30
- package/src/session/prompt.ts +18 -10
- 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)
|
|
@@ -73,7 +105,7 @@ export function getCompactionSafetyMarginFromProcessArgv(): string | null {
|
|
|
73
105
|
/**
|
|
74
106
|
* Extract --compaction-models argument directly from process.argv
|
|
75
107
|
* The value is a links notation references sequence, e.g.:
|
|
76
|
-
* "(big-pickle nemotron-3-super-free
|
|
108
|
+
* "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
|
|
77
109
|
* @returns The compaction models argument from CLI or null if not found
|
|
78
110
|
* @see https://github.com/link-assistant/agent/issues/232
|
|
79
111
|
*/
|
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/nemotron-3-super-free';
|
|
10
10
|
|
|
11
11
|
/** Default provider ID extracted from DEFAULT_MODEL. */
|
|
12
12
|
export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
|
|
@@ -31,20 +31,21 @@ export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
|
|
|
31
31
|
* The special value "same" means use the same model as `--model`.
|
|
32
32
|
*
|
|
33
33
|
* Parsed as links notation references sequence (single anonymous link):
|
|
34
|
-
* "(big-pickle nemotron-3-super-free
|
|
34
|
+
* "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
|
|
35
35
|
*
|
|
36
36
|
* Context limits (approximate):
|
|
37
37
|
* big-pickle: ~200K
|
|
38
|
-
* nemotron-3-super-free: ~262K
|
|
39
38
|
* minimax-m2.5-free: ~200K
|
|
39
|
+
* nemotron-3-super-free: ~262K (default model)
|
|
40
40
|
* gpt-5-nano: ~400K
|
|
41
|
-
* qwen3.6-plus-free: ~1M
|
|
42
41
|
* same: (base model's context)
|
|
43
42
|
*
|
|
43
|
+
* Note: qwen3.6-plus-free was removed — free promotion ended April 2026.
|
|
44
|
+
* @see https://github.com/link-assistant/agent/issues/242
|
|
44
45
|
* @see https://github.com/link-assistant/agent/issues/232
|
|
45
46
|
*/
|
|
46
47
|
export const DEFAULT_COMPACTION_MODELS =
|
|
47
|
-
'(big-pickle nemotron-3-super-free
|
|
48
|
+
'(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Default compaction safety margin as a percentage of usable context window.
|
package/src/cli/model-config.js
CHANGED
|
@@ -21,12 +21,25 @@ import {
|
|
|
21
21
|
* @returns {Promise<{providerID: string, modelID: string}>}
|
|
22
22
|
*/
|
|
23
23
|
export async function parseModelConfig(argv, outputError, outputStatus) {
|
|
24
|
-
// 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)
|
|
25
25
|
// This is critical because yargs under Bun may fail to parse --model correctly,
|
|
26
26
|
// returning the default value instead of the user's CLI argument.
|
|
27
27
|
const cliModelArg = getModelFromProcessArgv();
|
|
28
28
|
let modelArg = argv.model;
|
|
29
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
|
+
|
|
30
43
|
// ALWAYS prefer the CLI value over yargs when available (#196)
|
|
31
44
|
// The yargs default (DEFAULT_MODEL) can silently override user's --model argument
|
|
32
45
|
if (cliModelArg) {
|
|
@@ -41,6 +54,29 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
|
|
|
41
54
|
// Always use CLI value when available, even if it matches yargs
|
|
42
55
|
// This ensures we use the actual CLI argument, not a cached/default yargs value
|
|
43
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
|
+
}
|
|
44
80
|
}
|
|
45
81
|
|
|
46
82
|
let providerID;
|
|
@@ -73,26 +109,41 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
|
|
|
73
109
|
// Validate that the model exists in the provider (#196, #231)
|
|
74
110
|
// If user explicitly specified provider/model and the model is not found,
|
|
75
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;
|
|
76
115
|
try {
|
|
77
116
|
const { Provider } = await import('../provider/provider.ts');
|
|
78
117
|
const s = await Provider.state();
|
|
79
118
|
const provider = s.providers[providerID];
|
|
80
119
|
if (provider && !provider.info.models[modelID]) {
|
|
81
|
-
// Provider exists but model doesn't — fail with a clear error (#231)
|
|
82
|
-
// Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
|
|
83
120
|
const availableModels = Object.keys(provider.info.models).slice(0, 10);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
147
|
}
|
|
97
148
|
} catch (validationError) {
|
|
98
149
|
// Re-throw if this is our own validation error (not an infrastructure issue)
|
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 {
|
|
@@ -1732,10 +1749,9 @@ export namespace Provider {
|
|
|
1732
1749
|
}
|
|
1733
1750
|
if (providerID === 'opencode' || providerID === 'local') {
|
|
1734
1751
|
priority = [
|
|
1735
|
-
'
|
|
1752
|
+
'nemotron-3-super-free',
|
|
1736
1753
|
'minimax-m2.5-free',
|
|
1737
1754
|
'gpt-5-nano',
|
|
1738
|
-
'nemotron-3-super-free',
|
|
1739
1755
|
'big-pickle',
|
|
1740
1756
|
];
|
|
1741
1757
|
}
|
|
@@ -1764,9 +1780,8 @@ export namespace Provider {
|
|
|
1764
1780
|
}
|
|
1765
1781
|
|
|
1766
1782
|
const priority = [
|
|
1767
|
-
'qwen3.6-plus-free',
|
|
1768
|
-
'glm-5-free',
|
|
1769
1783
|
'nemotron-3-super-free',
|
|
1784
|
+
'glm-5-free',
|
|
1770
1785
|
'minimax-m2.5-free',
|
|
1771
1786
|
'gpt-5-nano',
|
|
1772
1787
|
'big-pickle',
|
|
@@ -1849,7 +1864,7 @@ export namespace Provider {
|
|
|
1849
1864
|
* 1. If model is uniquely available in one provider, use that provider
|
|
1850
1865
|
* 2. If model is available in multiple providers, prioritize based on free model availability:
|
|
1851
1866
|
* - kilo: glm-5-free, glm-4.5-air-free, minimax-m2.5-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
|
|
1852
|
-
* - opencode: big-pickle, gpt-5-nano,
|
|
1867
|
+
* - opencode: big-pickle, gpt-5-nano, nemotron-3-super-free (unique to OpenCode)
|
|
1853
1868
|
* 3. For shared models, prefer OpenCode first, then fall back to Kilo on rate limit
|
|
1854
1869
|
*
|
|
1855
1870
|
* @param modelID - Short model name without provider prefix
|
package/src/session/prompt.ts
CHANGED
|
@@ -304,15 +304,30 @@ export namespace SessionPrompt {
|
|
|
304
304
|
// continue the loop to execute them instead of prematurely exiting.
|
|
305
305
|
// See: https://github.com/link-assistant/agent/issues/194
|
|
306
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
|
+
|
|
307
321
|
// SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
|
|
308
322
|
// When all tokens are 0 and finish reason is 'unknown', this indicates the provider
|
|
309
323
|
// returned an empty/error response (e.g., rate limit, model unavailable, API failure).
|
|
310
|
-
//
|
|
324
|
+
// But ONLY treat this as fatal if there are NO tool calls (#239).
|
|
311
325
|
const tokens = lastAssistant.tokens;
|
|
312
326
|
if (
|
|
313
327
|
tokens.input === 0 &&
|
|
314
328
|
tokens.output === 0 &&
|
|
315
|
-
tokens.reasoning === 0
|
|
329
|
+
tokens.reasoning === 0 &&
|
|
330
|
+
!hasToolCalls
|
|
316
331
|
) {
|
|
317
332
|
const errorMessage =
|
|
318
333
|
`Provider returned zero tokens with unknown finish reason. ` +
|
|
@@ -340,20 +355,13 @@ export namespace SessionPrompt {
|
|
|
340
355
|
break;
|
|
341
356
|
}
|
|
342
357
|
|
|
343
|
-
const lastAssistantParts = msgs.find(
|
|
344
|
-
(m) => m.info.id === lastAssistant.id
|
|
345
|
-
)?.parts;
|
|
346
|
-
const hasToolCalls = lastAssistantParts?.some(
|
|
347
|
-
(p) =>
|
|
348
|
-
p.type === 'tool' &&
|
|
349
|
-
(p.state.status === 'completed' || p.state.status === 'running')
|
|
350
|
-
);
|
|
351
358
|
if (hasToolCalls) {
|
|
352
359
|
log.info(() => ({
|
|
353
360
|
message:
|
|
354
361
|
'continuing loop despite unknown finish reason - tool calls detected',
|
|
355
362
|
sessionID,
|
|
356
363
|
finishReason: lastAssistant.finish,
|
|
364
|
+
zeroTokens: tokens.input === 0 && tokens.output === 0,
|
|
357
365
|
hint: 'Provider returned undefined finishReason but made tool calls',
|
|
358
366
|
}));
|
|
359
367
|
// Don't break - continue the loop to handle tool call results
|
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
|
}
|