@link-assistant/agent 0.27.0 → 0.27.1
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/README.md +47 -5
- package/package.json +41 -42
- package/src/cli/argv.ts +1 -1
- package/src/cli/cmd/run.ts +7 -1
- package/src/cli/continuous-mode.js +38 -12
- package/src/cli/event-handler.js +3 -1
- package/src/cli/model-config.js +20 -4
- package/src/cli/run-options.js +144 -78
- package/src/config/config.ts +306 -105
- package/src/config/defaults.ts +13 -5
- package/src/config/file-config.ts +16 -0
- package/src/file/ripgrep.ts +38 -5
- package/src/file/time.ts +86 -9
- package/src/index.js +39 -33
- package/src/mcp/index.ts +370 -106
- package/src/permission/index.ts +22 -0
- package/src/project/instance.ts +5 -1
- package/src/project/project.ts +35 -2
- package/src/provider/free-tier-error.ts +1 -1
- package/src/provider/kilo-deprecation.ts +30 -0
- package/src/provider/kilo.ts +25 -7
- package/src/provider/provider.ts +53 -22
- package/src/provider/retry-fetch.ts +5 -0
- package/src/provider/transform.ts +139 -17
- package/src/session/compaction.ts +18 -2
- package/src/session/index.ts +30 -3
- package/src/session/message-v2.ts +318 -32
- package/src/session/processor.ts +274 -140
- package/src/session/prompt.ts +154 -128
- package/src/session/retry.ts +26 -13
- package/src/session/summary.ts +4 -1
- package/src/tool/bash.ts +232 -45
- package/src/tool/bash.txt +1 -1
- package/src/tool/codesearch.ts +15 -78
- package/src/tool/edit.ts +111 -30
- package/src/tool/glob.ts +2 -1
- package/src/tool/grep.ts +175 -57
- package/src/tool/ls.ts +2 -1
- package/src/tool/multiedit.ts +3 -1
- package/src/tool/read.ts +169 -41
- package/src/tool/task.ts +26 -18
- package/src/tool/tool.ts +4 -1
- package/src/tool/webfetch.ts +68 -56
- package/src/tool/webfetch.txt +4 -4
- package/src/tool/websearch.ts +20 -75
- package/src/tool/websearch.txt +2 -0
- package/src/tool/write.ts +23 -6
- package/src/util/bom.ts +32 -0
- package/src/util/exa-mcp.ts +82 -0
- package/src/util/legacy-error-names.ts +39 -0
- package/src/util/read-bounded.ts +32 -0
- package/src/util/sse-usage-extractor.ts +67 -13
- package/src/util/timeout.ts +3 -4
- package/src/util/verbose-fetch.ts +40 -1
package/src/project/project.ts
CHANGED
|
@@ -45,7 +45,12 @@ export namespace Project {
|
|
|
45
45
|
}
|
|
46
46
|
let worktree = path.dirname(git);
|
|
47
47
|
const timer = log.time('git.rev-parse');
|
|
48
|
-
|
|
48
|
+
// In a git worktree or submodule `.git` is a file, so the id cache lives in
|
|
49
|
+
// the common git directory instead (as upstream does). Writing to
|
|
50
|
+
// `.git/opencode` there used to reject with ENOTDIR; the rejection was not
|
|
51
|
+
// handled and the CLI exited with an UnhandledRejection (issue #335).
|
|
52
|
+
const cacheDir = await gitCacheDir(git, worktree);
|
|
53
|
+
let id = await Bun.file(path.join(cacheDir, 'opencode'))
|
|
49
54
|
.text()
|
|
50
55
|
.then((x) => x.trim())
|
|
51
56
|
.catch(() => {});
|
|
@@ -63,7 +68,16 @@ export namespace Project {
|
|
|
63
68
|
.toSorted()
|
|
64
69
|
);
|
|
65
70
|
id = roots[0];
|
|
66
|
-
|
|
71
|
+
// The cache is best effort: a read-only or unusual `.git` must not fail
|
|
72
|
+
// project detection.
|
|
73
|
+
if (id)
|
|
74
|
+
await Bun.write(path.join(cacheDir, 'opencode'), id).catch((error) =>
|
|
75
|
+
log.info(() => ({
|
|
76
|
+
message: 'project id cache not written',
|
|
77
|
+
path: path.join(cacheDir, 'opencode'),
|
|
78
|
+
error: String(error),
|
|
79
|
+
}))
|
|
80
|
+
);
|
|
67
81
|
}
|
|
68
82
|
timer.stop();
|
|
69
83
|
if (!id) {
|
|
@@ -95,6 +109,25 @@ export namespace Project {
|
|
|
95
109
|
return project;
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Directory that holds the project id cache: `.git` itself when it is a
|
|
114
|
+
* directory, otherwise the common git directory reported by git (worktrees
|
|
115
|
+
* and submodules have a `.git` file pointing elsewhere).
|
|
116
|
+
*/
|
|
117
|
+
async function gitCacheDir(git: string, worktree: string): Promise<string> {
|
|
118
|
+
const stat = await fs.stat(git).catch(() => undefined);
|
|
119
|
+
if (stat?.isDirectory()) return git;
|
|
120
|
+
const common =
|
|
121
|
+
await $`git rev-parse --path-format=absolute --git-common-dir`
|
|
122
|
+
.quiet()
|
|
123
|
+
.nothrow()
|
|
124
|
+
.cwd(worktree)
|
|
125
|
+
.text()
|
|
126
|
+
.then((x) => x.trim())
|
|
127
|
+
.catch(() => '');
|
|
128
|
+
return common || git;
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
export async function setInitialized(projectID: string) {
|
|
99
132
|
await Storage.update<Info>(['project', projectID], (draft) => {
|
|
100
133
|
draft.time.initialized = Date.now();
|
|
@@ -13,4 +13,4 @@ export function isFreeTierError(
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const FREE_TIER_ERROR_MESSAGE =
|
|
16
|
-
"OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-
|
|
16
|
+
"OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-ultra-free (authenticate with agent auth login or KILO_API_KEY), or select a paid model.";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Log } from '../util/log';
|
|
2
|
+
import { Kilo } from './kilo';
|
|
3
|
+
|
|
4
|
+
const log = Log.create({ service: 'kilo' });
|
|
5
|
+
|
|
6
|
+
// Aliases already warned about. A stored session or config that names an
|
|
7
|
+
// alias reaches getModel()/parseModel() many times in one process, so warn
|
|
8
|
+
// once per alias; the set is bounded by `Kilo.DEPRECATED_ALIASES`.
|
|
9
|
+
const warned = new Set<string>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a deprecated Kilo alias (`Kilo.DEPRECATED_ALIASES`) to its replacement
|
|
13
|
+
* and warn about it (once per alias and process); any other model ID is
|
|
14
|
+
* returned unchanged (#335).
|
|
15
|
+
*
|
|
16
|
+
* Kept out of `kilo.ts` so that module has no runtime dependencies.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveKiloDeprecatedAlias(modelID: string): string {
|
|
19
|
+
const replacement = Kilo.deprecatedAliasTarget(modelID);
|
|
20
|
+
if (!replacement) return modelID;
|
|
21
|
+
if (warned.has(modelID)) return replacement;
|
|
22
|
+
warned.add(modelID);
|
|
23
|
+
log.warn(() => ({
|
|
24
|
+
message: `kilo/${modelID} is deprecated and no longer served by Kilo; using kilo/${replacement} instead`,
|
|
25
|
+
deprecatedModel: `kilo/${modelID}`,
|
|
26
|
+
replacement: `kilo/${replacement}`,
|
|
27
|
+
hint: `Pass --model kilo/${replacement} to silence this warning`,
|
|
28
|
+
}));
|
|
29
|
+
return replacement;
|
|
30
|
+
}
|
package/src/provider/kilo.ts
CHANGED
|
@@ -25,14 +25,7 @@ export namespace Kilo {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// Super's output cap is deliberately conservative for existing integrations.
|
|
29
28
|
export const FREE_MODELS: Record<string, ModelsDev.Model> = {
|
|
30
|
-
'nemotron-3-super-free': freeModel(
|
|
31
|
-
'nvidia/nemotron-3-super-120b-a12b:free',
|
|
32
|
-
'Nemotron 3 Super (Free)',
|
|
33
|
-
262144,
|
|
34
|
-
32768
|
|
35
|
-
),
|
|
36
29
|
'nemotron-3-ultra-free': freeModel(
|
|
37
30
|
'nvidia/nemotron-3-ultra-550b-a55b:free',
|
|
38
31
|
'Nemotron 3 Ultra (Free)',
|
|
@@ -47,6 +40,31 @@ export namespace Kilo {
|
|
|
47
40
|
),
|
|
48
41
|
};
|
|
49
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Withdrawn built-in aliases that keep working temporarily. Each one maps to
|
|
45
|
+
* the served alias in `FREE_MODELS` that replaces it, and every resolution
|
|
46
|
+
* logs a warning naming the replacement. These are not catalog entries, so
|
|
47
|
+
* the drift check (`scripts/check-model-drift.mjs`) does not look them up.
|
|
48
|
+
*
|
|
49
|
+
* Kilo stopped serving `nvidia/nemotron-3-super-120b-a12b:free`; only the
|
|
50
|
+
* paid Super model remains on the gateway (#335).
|
|
51
|
+
*/
|
|
52
|
+
export const DEPRECATED_ALIASES: Readonly<Record<string, string>> = {
|
|
53
|
+
'nemotron-3-super-free': 'nemotron-3-ultra-free',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Replacement for a deprecated alias, or `undefined`. Logs nothing; use
|
|
58
|
+
* `resolveKiloDeprecatedAlias` from `./kilo-deprecation` to also warn.
|
|
59
|
+
* This module stays free of runtime dependencies because
|
|
60
|
+
* `scripts/check-default-models.mjs` imports it without installed packages.
|
|
61
|
+
*/
|
|
62
|
+
export function deprecatedAliasTarget(modelID: string): string | undefined {
|
|
63
|
+
return Object.hasOwn(DEPRECATED_ALIASES, modelID)
|
|
64
|
+
? DEPRECATED_ALIASES[modelID]
|
|
65
|
+
: undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Text models models.dev lists for Kilo. Their IDs are the same on the
|
|
52
70
|
* OpenRouter-compatible route this provider uses, so the curated aliases
|
package/src/provider/provider.ts
CHANGED
|
@@ -20,8 +20,10 @@ import { createEchoModel } from './echo';
|
|
|
20
20
|
import { createCacheModel } from './cache';
|
|
21
21
|
import { RetryFetch } from './retry-fetch';
|
|
22
22
|
import { SSEUsageExtractor } from '../util/sse-usage-extractor';
|
|
23
|
+
import { sanitizeUrl } from '../util/verbose-fetch';
|
|
23
24
|
import { OpenCodeZen } from './opencode-zen';
|
|
24
25
|
import { Kilo } from './kilo';
|
|
26
|
+
import { resolveKiloDeprecatedAlias } from './kilo-deprecation';
|
|
25
27
|
import { EnvTemplate } from './env-template';
|
|
26
28
|
import { ExtraProviders } from './extra-providers';
|
|
27
29
|
import { ModelDiscovery } from './model-discovery';
|
|
@@ -1199,19 +1201,23 @@ export namespace Provider {
|
|
|
1199
1201
|
const [sdkStream, usageStream] = response.body.tee();
|
|
1200
1202
|
const sseReqId = SSEUsageExtractor.nextRequestId();
|
|
1201
1203
|
(async () => {
|
|
1204
|
+
const reader = usageStream.getReader();
|
|
1202
1205
|
try {
|
|
1203
|
-
|
|
1206
|
+
// Read the whole branch: a tee branch that stops being read
|
|
1207
|
+
// keeps buffering the rest of the response (issue #335).
|
|
1204
1208
|
const decoder = new TextDecoder();
|
|
1205
|
-
|
|
1209
|
+
const tracker = SSEUsageExtractor.createTracker();
|
|
1206
1210
|
while (true) {
|
|
1207
1211
|
const { done, value } = await reader.read();
|
|
1208
1212
|
if (done) break;
|
|
1209
|
-
|
|
1210
|
-
if (body.length > 50000) break;
|
|
1213
|
+
tracker.push(decoder.decode(value, { stream: true }));
|
|
1211
1214
|
}
|
|
1212
|
-
|
|
1215
|
+
tracker.push(decoder.decode());
|
|
1216
|
+
const usage = tracker.finish();
|
|
1217
|
+
if (usage) SSEUsageExtractor.storeUsage(sseReqId, usage);
|
|
1213
1218
|
} catch {
|
|
1214
|
-
// Never break the SDK stream
|
|
1219
|
+
// Never break the SDK stream; release the branch instead.
|
|
1220
|
+
reader.cancel().catch(() => {});
|
|
1215
1221
|
}
|
|
1216
1222
|
})();
|
|
1217
1223
|
return new Response(sdkStream, {
|
|
@@ -1237,12 +1243,14 @@ export namespace Provider {
|
|
|
1237
1243
|
});
|
|
1238
1244
|
}
|
|
1239
1245
|
|
|
1240
|
-
|
|
1246
|
+
// Logged only; query-string credentials are masked (issue #335).
|
|
1247
|
+
const url = sanitizeUrl(
|
|
1241
1248
|
typeof input === 'string'
|
|
1242
1249
|
? input
|
|
1243
1250
|
: input instanceof URL
|
|
1244
1251
|
? input.toString()
|
|
1245
|
-
: input.url
|
|
1252
|
+
: input.url
|
|
1253
|
+
);
|
|
1246
1254
|
const method = init?.method ?? 'GET';
|
|
1247
1255
|
|
|
1248
1256
|
// Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
|
|
@@ -1369,13 +1377,15 @@ export namespace Provider {
|
|
|
1369
1377
|
try {
|
|
1370
1378
|
const reader = logStream.getReader();
|
|
1371
1379
|
const decoder = new TextDecoder();
|
|
1380
|
+
const tracker = SSEUsageExtractor.createTracker();
|
|
1372
1381
|
let bodyPreview = '';
|
|
1373
1382
|
let truncated = false;
|
|
1374
1383
|
while (true) {
|
|
1375
1384
|
const { done, value } = await reader.read();
|
|
1376
1385
|
if (done) break;
|
|
1386
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1387
|
+
tracker.push(chunk);
|
|
1377
1388
|
if (!truncated) {
|
|
1378
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1379
1389
|
bodyPreview += chunk;
|
|
1380
1390
|
if (bodyPreview.length > responseBodyMaxChars) {
|
|
1381
1391
|
bodyPreview = bodyPreview.slice(
|
|
@@ -1386,11 +1396,12 @@ export namespace Provider {
|
|
|
1386
1396
|
}
|
|
1387
1397
|
}
|
|
1388
1398
|
}
|
|
1389
|
-
// Extract usage from raw SSE stream
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
)
|
|
1399
|
+
// Extract usage from the whole raw SSE stream (not just
|
|
1400
|
+
// the logged preview) as AI SDK fallback.
|
|
1401
|
+
tracker.push(decoder.decode());
|
|
1402
|
+
const usage = tracker.finish();
|
|
1403
|
+
if (usage)
|
|
1404
|
+
SSEUsageExtractor.storeUsage(sseRequestId, usage);
|
|
1394
1405
|
// Use direct (non-lazy) logging for stream body
|
|
1395
1406
|
// See: https://github.com/link-assistant/agent/issues/211
|
|
1396
1407
|
log.info('HTTP response body (stream)', {
|
|
@@ -1554,6 +1565,12 @@ export namespace Provider {
|
|
|
1554
1565
|
const provider = s.providers[ExtraProviders.canonicalID(providerID)];
|
|
1555
1566
|
if (!provider) return false;
|
|
1556
1567
|
if (provider.info.models[modelID]) return true;
|
|
1568
|
+
// Deprecated Kilo aliases still resolve to their replacement (#335).
|
|
1569
|
+
const replacement =
|
|
1570
|
+
provider.info.id === 'kilo'
|
|
1571
|
+
? Kilo.deprecatedAliasTarget(modelID)
|
|
1572
|
+
: undefined;
|
|
1573
|
+
if (replacement && provider.info.models[replacement]) return true;
|
|
1557
1574
|
return ExtraProviders.allowsUnknownModels(provider.info.id);
|
|
1558
1575
|
}
|
|
1559
1576
|
|
|
@@ -1642,6 +1659,8 @@ export namespace Provider {
|
|
|
1642
1659
|
|
|
1643
1660
|
export async function getModel(providerID: string, modelID: string) {
|
|
1644
1661
|
providerID = ExtraProviders.canonicalID(providerID);
|
|
1662
|
+
// Sessions and configs may still name a withdrawn Kilo alias (#335).
|
|
1663
|
+
if (providerID === 'kilo') modelID = resolveKiloDeprecatedAlias(modelID);
|
|
1645
1664
|
const key = `${providerID}/${modelID}`;
|
|
1646
1665
|
const s = await state();
|
|
1647
1666
|
if (s.models.has(key)) return s.models.get(key)!;
|
|
@@ -1910,7 +1929,6 @@ export namespace Provider {
|
|
|
1910
1929
|
'gpt-5-nano',
|
|
1911
1930
|
'nemotron-3.5-lightning-free',
|
|
1912
1931
|
'nemotron-3-ultra-free',
|
|
1913
|
-
'nemotron-3-super-free',
|
|
1914
1932
|
'gpt-5',
|
|
1915
1933
|
'claude-sonnet-4',
|
|
1916
1934
|
'gemini-3-pro',
|
|
@@ -1984,10 +2002,14 @@ export namespace Provider {
|
|
|
1984
2002
|
}
|
|
1985
2003
|
|
|
1986
2004
|
export function parseModel(model: string) {
|
|
1987
|
-
const [
|
|
2005
|
+
const [rawProviderID, ...rest] = model.split('/');
|
|
2006
|
+
const providerID = ExtraProviders.canonicalID(rawProviderID);
|
|
2007
|
+
const modelID = rest.join('/');
|
|
1988
2008
|
return {
|
|
1989
|
-
providerID
|
|
1990
|
-
|
|
2009
|
+
providerID,
|
|
2010
|
+
// Withdrawn Kilo aliases keep working with a warning (#335).
|
|
2011
|
+
modelID:
|
|
2012
|
+
providerID === 'kilo' ? resolveKiloDeprecatedAlias(modelID) : modelID,
|
|
1991
2013
|
};
|
|
1992
2014
|
}
|
|
1993
2015
|
|
|
@@ -2007,6 +2029,14 @@ export namespace Provider {
|
|
|
2007
2029
|
): Promise<{ providerID: string; modelID: string } | undefined> {
|
|
2008
2030
|
const s = await state();
|
|
2009
2031
|
|
|
2032
|
+
// A withdrawn built-in alias resolves to its Kilo replacement (#335).
|
|
2033
|
+
if (Kilo.deprecatedAliasTarget(modelID)) {
|
|
2034
|
+
const replacement = resolveKiloDeprecatedAlias(modelID);
|
|
2035
|
+
if (s.providers.kilo?.info.models[replacement]) {
|
|
2036
|
+
return { providerID: 'kilo', modelID: replacement };
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2010
2040
|
if (Object.hasOwn(Kilo.FREE_MODELS, modelID)) {
|
|
2011
2041
|
const kiloProvider = s.providers.kilo;
|
|
2012
2042
|
if (kiloProvider?.info.models[modelID]) {
|
|
@@ -2080,8 +2110,9 @@ export namespace Provider {
|
|
|
2080
2110
|
* or a clear error explaining why it's not available.
|
|
2081
2111
|
*
|
|
2082
2112
|
* Examples:
|
|
2083
|
-
* - "kilo/nemotron-3-
|
|
2084
|
-
* - "nemotron-3-
|
|
2113
|
+
* - "kilo/nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" }
|
|
2114
|
+
* - "nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (resolved)
|
|
2115
|
+
* - "nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (deprecated alias, warns)
|
|
2085
2116
|
* - "big-pickle" -> { providerID: "opencode", modelID: "big-pickle" } (resolved)
|
|
2086
2117
|
* - "nonexistent-model" -> throws ModelNotFoundError
|
|
2087
2118
|
*
|
|
@@ -2142,7 +2173,7 @@ export namespace Provider {
|
|
|
2142
2173
|
* When one provider hits rate limits, the system can try an alternative.
|
|
2143
2174
|
*
|
|
2144
2175
|
* Note: This is only used for models without explicit provider specification.
|
|
2145
|
-
* If user specifies "kilo/nemotron-3-
|
|
2176
|
+
* If user specifies "kilo/nemotron-3-ultra-free", no fallback will occur.
|
|
2146
2177
|
*/
|
|
2147
2178
|
const SHARED_FREE_MODELS: Record<string, string[]> = {
|
|
2148
2179
|
'nemotron-3.5-lightning-free': ['kilo', 'opencode'],
|
|
@@ -2153,7 +2184,7 @@ export namespace Provider {
|
|
|
2153
2184
|
* This function returns a list of alternative providers that offer the same model.
|
|
2154
2185
|
*
|
|
2155
2186
|
* Note: This only returns alternatives for models without explicit provider specification.
|
|
2156
|
-
* If the original request had an explicit provider (like "kilo/nemotron-3-
|
|
2187
|
+
* If the original request had an explicit provider (like "kilo/nemotron-3-ultra-free"), this returns empty array.
|
|
2157
2188
|
*
|
|
2158
2189
|
* @param modelID - The model ID to find alternatives for
|
|
2159
2190
|
* @param failedProviderID - The provider that failed
|
|
@@ -461,6 +461,9 @@ export namespace RetryFetch {
|
|
|
461
461
|
}));
|
|
462
462
|
|
|
463
463
|
await sleep(delay, init?.signal ?? undefined);
|
|
464
|
+
// The failed response is dropped: release its body and connection
|
|
465
|
+
// now instead of waiting for GC (issue #335). Not awaited.
|
|
466
|
+
response.body?.cancel().catch(() => {});
|
|
464
467
|
continue;
|
|
465
468
|
}
|
|
466
469
|
|
|
@@ -554,6 +557,8 @@ export namespace RetryFetch {
|
|
|
554
557
|
|
|
555
558
|
try {
|
|
556
559
|
await sleep(delay, isolatedSignal);
|
|
560
|
+
// Retrying: release the dropped 429 body (issue #335).
|
|
561
|
+
response.body?.cancel().catch(() => {});
|
|
557
562
|
} catch (sleepError) {
|
|
558
563
|
// Check if the original request was aborted (user cancellation)
|
|
559
564
|
// In that case, we should stop retrying
|
|
@@ -1,8 +1,97 @@
|
|
|
1
|
-
import type { ModelMessage } from 'ai';
|
|
2
|
-
import { unique } from 'remeda';
|
|
1
|
+
import type { ModelMessage, ToolResultPart } from 'ai';
|
|
2
|
+
import { mergeDeep, unique } from 'remeda';
|
|
3
3
|
import type { JSONSchema } from 'zod/v4/core';
|
|
4
4
|
|
|
5
5
|
export namespace ProviderTransform {
|
|
6
|
+
/**
|
|
7
|
+
* Replace lone UTF-16 surrogates, which strict JSON parsers reject with a
|
|
8
|
+
* 400 on every later turn (upstream #25934).
|
|
9
|
+
*/
|
|
10
|
+
export function sanitizeSurrogates(content: string) {
|
|
11
|
+
return content.replace(
|
|
12
|
+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
|
13
|
+
'�'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeToolResult(part: ToolResultPart) {
|
|
18
|
+
const output = part.output;
|
|
19
|
+
if (output.type === 'text' || output.type === 'error-text') {
|
|
20
|
+
return {
|
|
21
|
+
...part,
|
|
22
|
+
output: { ...output, value: sanitizeSurrogates(output.value) },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (output.type === 'content') {
|
|
26
|
+
return {
|
|
27
|
+
...part,
|
|
28
|
+
output: {
|
|
29
|
+
...output,
|
|
30
|
+
value: output.value.map((item) =>
|
|
31
|
+
item.type === 'text'
|
|
32
|
+
? { ...item, text: sanitizeSurrogates(item.text) }
|
|
33
|
+
: item
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return part;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeMessages(msgs: ModelMessage[]): ModelMessage[] {
|
|
42
|
+
return msgs.map((msg) => {
|
|
43
|
+
if (typeof msg.content === 'string') {
|
|
44
|
+
return {
|
|
45
|
+
...msg,
|
|
46
|
+
content: sanitizeSurrogates(msg.content),
|
|
47
|
+
} as typeof msg;
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
50
|
+
return {
|
|
51
|
+
...msg,
|
|
52
|
+
content: msg.content.map((part: any) => {
|
|
53
|
+
if (part.type === 'text' || part.type === 'reasoning') {
|
|
54
|
+
return { ...part, text: sanitizeSurrogates(part.text) };
|
|
55
|
+
}
|
|
56
|
+
if (part.type === 'tool-result') return sanitizeToolResult(part);
|
|
57
|
+
return part;
|
|
58
|
+
}),
|
|
59
|
+
} as typeof msg;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Anthropic and Bedrock reject empty text blocks; keep reasoning that is
|
|
65
|
+
* signed or redacted (upstream #14586, #26276).
|
|
66
|
+
*/
|
|
67
|
+
function dropEmptyParts(msgs: ModelMessage[], npm?: string) {
|
|
68
|
+
if (npm !== '@ai-sdk/anthropic' && npm !== '@ai-sdk/amazon-bedrock') {
|
|
69
|
+
return msgs;
|
|
70
|
+
}
|
|
71
|
+
const ns = npm === '@ai-sdk/anthropic' ? 'anthropic' : 'bedrock';
|
|
72
|
+
return msgs
|
|
73
|
+
.map((msg) => {
|
|
74
|
+
if (typeof msg.content === 'string') {
|
|
75
|
+
return msg.content === '' ? undefined : msg;
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(msg.content)) return msg;
|
|
78
|
+
const filtered = (msg.content as any[]).filter((part) => {
|
|
79
|
+
if (part.type === 'text') return part.text !== '';
|
|
80
|
+
if (part.type === 'reasoning') {
|
|
81
|
+
return (
|
|
82
|
+
part.text.trim().length > 0 ||
|
|
83
|
+
part.providerOptions?.[ns]?.signature != null ||
|
|
84
|
+
part.providerOptions?.[ns]?.redactedData != null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
if (filtered.length === 0) return undefined;
|
|
90
|
+
return { ...msg, content: filtered } as typeof msg;
|
|
91
|
+
})
|
|
92
|
+
.filter((msg): msg is ModelMessage => msg !== undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
6
95
|
function normalizeMessages(
|
|
7
96
|
msgs: ModelMessage[],
|
|
8
97
|
providerID: string,
|
|
@@ -84,7 +173,8 @@ export namespace ProviderTransform {
|
|
|
84
173
|
|
|
85
174
|
function applyCaching(
|
|
86
175
|
msgs: ModelMessage[],
|
|
87
|
-
providerID: string
|
|
176
|
+
providerID: string,
|
|
177
|
+
npm?: string
|
|
88
178
|
): ModelMessage[] {
|
|
89
179
|
const system = msgs.filter((msg) => msg.role === 'system').slice(0, 2);
|
|
90
180
|
const final = msgs.filter((msg) => msg.role !== 'system').slice(-2);
|
|
@@ -96,35 +186,43 @@ export namespace ProviderTransform {
|
|
|
96
186
|
openrouter: {
|
|
97
187
|
cache_control: { type: 'ephemeral' },
|
|
98
188
|
},
|
|
189
|
+
// Bedrock only accepts the 'default' cache point (upstream #11664).
|
|
99
190
|
bedrock: {
|
|
100
|
-
cachePoint: { type: '
|
|
191
|
+
cachePoint: { type: 'default' },
|
|
101
192
|
},
|
|
102
193
|
openaiCompatible: {
|
|
103
194
|
cache_control: { type: 'ephemeral' },
|
|
104
195
|
},
|
|
105
196
|
};
|
|
106
197
|
|
|
198
|
+
// Bedrock reads the cache point from the message (upstream #18959).
|
|
199
|
+
const useMessageLevelOptions =
|
|
200
|
+
providerID === 'anthropic' ||
|
|
201
|
+
providerID.includes('bedrock') ||
|
|
202
|
+
npm === '@ai-sdk/amazon-bedrock';
|
|
203
|
+
|
|
107
204
|
for (const msg of unique([...system, ...final])) {
|
|
108
205
|
const shouldUseContentOptions =
|
|
109
|
-
|
|
206
|
+
!useMessageLevelOptions &&
|
|
110
207
|
Array.isArray(msg.content) &&
|
|
111
208
|
msg.content.length > 0;
|
|
112
209
|
|
|
210
|
+
// Deep-merge so signatures already in providerOptions survive (upstream #10323).
|
|
113
211
|
if (shouldUseContentOptions) {
|
|
114
212
|
const lastContent = msg.content[msg.content.length - 1];
|
|
115
213
|
if (lastContent && typeof lastContent === 'object') {
|
|
116
|
-
lastContent.providerOptions =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
214
|
+
lastContent.providerOptions = mergeDeep(
|
|
215
|
+
lastContent.providerOptions ?? {},
|
|
216
|
+
providerOptions
|
|
217
|
+
) as typeof lastContent.providerOptions;
|
|
120
218
|
continue;
|
|
121
219
|
}
|
|
122
220
|
}
|
|
123
221
|
|
|
124
|
-
msg.providerOptions =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
222
|
+
msg.providerOptions = mergeDeep(
|
|
223
|
+
msg.providerOptions ?? {},
|
|
224
|
+
providerOptions
|
|
225
|
+
) as typeof msg.providerOptions;
|
|
128
226
|
}
|
|
129
227
|
|
|
130
228
|
return msgs;
|
|
@@ -133,15 +231,18 @@ export namespace ProviderTransform {
|
|
|
133
231
|
export function message(
|
|
134
232
|
msgs: ModelMessage[],
|
|
135
233
|
providerID: string,
|
|
136
|
-
modelID: string
|
|
234
|
+
modelID: string,
|
|
235
|
+
npm?: string
|
|
137
236
|
) {
|
|
237
|
+
msgs = sanitizeMessages(msgs);
|
|
238
|
+
msgs = dropEmptyParts(msgs, npm);
|
|
138
239
|
msgs = normalizeMessages(msgs, providerID, modelID);
|
|
139
240
|
if (
|
|
140
241
|
providerID === 'anthropic' ||
|
|
141
242
|
modelID.includes('anthropic') ||
|
|
142
243
|
modelID.includes('claude')
|
|
143
244
|
) {
|
|
144
|
-
msgs = applyCaching(msgs, providerID);
|
|
245
|
+
msgs = applyCaching(msgs, providerID, npm);
|
|
145
246
|
}
|
|
146
247
|
|
|
147
248
|
return msgs;
|
|
@@ -280,11 +381,32 @@ export namespace ProviderTransform {
|
|
|
280
381
|
return standardLimit;
|
|
281
382
|
}
|
|
282
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Moonshot expands $ref before validating and rejects sibling keywords, and
|
|
386
|
+
* it does not accept tuple-style `items` arrays (upstream #24730).
|
|
387
|
+
*/
|
|
388
|
+
function sanitizeMoonshot(node: unknown): unknown {
|
|
389
|
+
if (node === null || typeof node !== 'object') return node;
|
|
390
|
+
if (Array.isArray(node)) return node.map(sanitizeMoonshot);
|
|
391
|
+
if ('$ref' in node && typeof node.$ref === 'string') {
|
|
392
|
+
return { $ref: node.$ref };
|
|
393
|
+
}
|
|
394
|
+
const result: Record<string, unknown> = Object.fromEntries(
|
|
395
|
+
Object.entries(node).map(([key, value]) => [key, sanitizeMoonshot(value)])
|
|
396
|
+
);
|
|
397
|
+
if (Array.isArray(result.items)) result.items = result.items[0] ?? {};
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
|
|
283
401
|
export function schema(
|
|
284
|
-
|
|
285
|
-
|
|
402
|
+
providerID: string,
|
|
403
|
+
modelID: string,
|
|
286
404
|
schema: JSONSchema.BaseSchema
|
|
287
405
|
) {
|
|
406
|
+
if (providerID === 'moonshotai' || modelID.toLowerCase().includes('kimi')) {
|
|
407
|
+
schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema;
|
|
408
|
+
}
|
|
409
|
+
|
|
288
410
|
/*
|
|
289
411
|
if (["openai", "azure"].includes(providerID)) {
|
|
290
412
|
if (schema.type === "object" && schema.properties) {
|
|
@@ -347,7 +347,8 @@ export namespace SessionCompaction {
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
return false;
|
|
350
|
-
})
|
|
350
|
+
}),
|
|
351
|
+
{ providerID: model.providerID, modelID: model.modelID }
|
|
351
352
|
);
|
|
352
353
|
// Defensive check: ensure modelMessages is iterable (AI SDK 6.0.1 compatibility fix)
|
|
353
354
|
const safeModelMessages = Array.isArray(modelMessages) ? modelMessages : [];
|
|
@@ -410,7 +411,8 @@ export namespace SessionCompaction {
|
|
|
410
411
|
args.params.prompt = ProviderTransform.message(
|
|
411
412
|
args.params.prompt,
|
|
412
413
|
model.providerID,
|
|
413
|
-
model.modelID
|
|
414
|
+
model.modelID,
|
|
415
|
+
model.npm
|
|
414
416
|
);
|
|
415
417
|
}
|
|
416
418
|
return args.params;
|
|
@@ -420,6 +422,20 @@ export namespace SessionCompaction {
|
|
|
420
422
|
}),
|
|
421
423
|
})
|
|
422
424
|
);
|
|
425
|
+
// The summary request itself overflowed: compacting again would send the
|
|
426
|
+
// same history, so stop with a clear error (upstream #14707).
|
|
427
|
+
if (result === 'compact') {
|
|
428
|
+
processor.message.error = new MessageV2.ContextOverflowError({
|
|
429
|
+
message:
|
|
430
|
+
'Conversation history too large to compact - exceeds model context limit',
|
|
431
|
+
}).toObject();
|
|
432
|
+
await Session.updateMessage(processor.message);
|
|
433
|
+
Bus.publish(Session.Event.Error, {
|
|
434
|
+
sessionID: input.sessionID,
|
|
435
|
+
error: processor.message.error,
|
|
436
|
+
});
|
|
437
|
+
return 'stop';
|
|
438
|
+
}
|
|
423
439
|
if (result === 'continue') {
|
|
424
440
|
const continueMsg = await Session.updateMessage({
|
|
425
441
|
id: Identifier.ascending('message'),
|
package/src/session/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Decimal } from 'decimal.js';
|
|
2
|
+
import { FileTime } from '../file/time';
|
|
2
3
|
import z from 'zod';
|
|
3
4
|
import { type LanguageModelUsage, type ProviderMetadata } from 'ai';
|
|
4
5
|
import { Bus } from '../bus';
|
|
5
6
|
import { Config } from '../config/file-config';
|
|
6
|
-
import { isVerbose } from '../config/config';
|
|
7
|
+
import { config, isVerbose } from '../config/config';
|
|
7
8
|
import { Identifier } from '../id/id';
|
|
8
9
|
import type { ModelsDev } from '../provider/models';
|
|
9
10
|
import { Storage } from '../storage/storage';
|
|
@@ -328,6 +329,7 @@ export namespace Session {
|
|
|
328
329
|
await Storage.remove(msg);
|
|
329
330
|
}
|
|
330
331
|
await Storage.remove(['session', project.id, sessionID]);
|
|
332
|
+
FileTime.clear(sessionID);
|
|
331
333
|
Bus.publish(Event.Deleted, {
|
|
332
334
|
info: session,
|
|
333
335
|
});
|
|
@@ -757,11 +759,36 @@ export namespace Session {
|
|
|
757
759
|
)
|
|
758
760
|
)
|
|
759
761
|
: 0;
|
|
760
|
-
|
|
762
|
+
// AI SDK 7 reports reasoning under outputTokenDetails.
|
|
763
|
+
const detailReasoning = safeNum(
|
|
764
|
+
toNumber(
|
|
765
|
+
(
|
|
766
|
+
effectiveUsage as {
|
|
767
|
+
outputTokenDetails?: { reasoningTokens?: unknown };
|
|
768
|
+
}
|
|
769
|
+
).outputTokenDetails?.reasoningTokens,
|
|
770
|
+
'outputTokenDetails.reasoningTokens'
|
|
771
|
+
)
|
|
772
|
+
);
|
|
773
|
+
const reasoningTokens =
|
|
774
|
+
topLevelReasoningTokens || nestedReasoning || detailReasoning;
|
|
775
|
+
|
|
776
|
+
// outputTokens already includes reasoning, which is billed separately
|
|
777
|
+
// below, so take it out to avoid charging it twice (upstream #21047).
|
|
778
|
+
// Providers that report more reasoning than output count it apart.
|
|
779
|
+
const rawOutputTokens = safeNum(
|
|
780
|
+
toNumber(effectiveUsage.outputTokens, 'outputTokens')
|
|
781
|
+
);
|
|
782
|
+
// --legacy-token-usage / LINK_ASSISTANT_AGENT_LEGACY_TOKEN_USAGE keeps
|
|
783
|
+
// the raw figure (reasoning counted in output, as before).
|
|
784
|
+
const outputTokens =
|
|
785
|
+
!config.legacyTokenUsage && reasoningTokens <= rawOutputTokens
|
|
786
|
+
? rawOutputTokens - reasoningTokens
|
|
787
|
+
: rawOutputTokens;
|
|
761
788
|
|
|
762
789
|
const tokens = {
|
|
763
790
|
input: Math.max(0, adjustedInputTokens), // Ensure non-negative
|
|
764
|
-
output:
|
|
791
|
+
output: outputTokens,
|
|
765
792
|
reasoning: reasoningTokens,
|
|
766
793
|
cache: {
|
|
767
794
|
write: cacheWriteTokens,
|