@link-assistant/agent 0.26.15 → 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.
Files changed (65) hide show
  1. package/README.md +49 -7
  2. package/package.json +51 -42
  3. package/src/agent/agent.ts +12 -7
  4. package/src/auth/azure-login.ts +241 -0
  5. package/src/auth/plugins.ts +9 -0
  6. package/src/auth/provider-logins.ts +804 -0
  7. package/src/cli/argv.ts +1 -1
  8. package/src/cli/cmd/auth.ts +4 -0
  9. package/src/cli/cmd/models.ts +68 -13
  10. package/src/cli/cmd/run.ts +7 -1
  11. package/src/cli/continuous-mode.js +38 -12
  12. package/src/cli/event-handler.js +3 -1
  13. package/src/cli/model-config.js +27 -8
  14. package/src/cli/run-options.js +144 -78
  15. package/src/config/config.ts +306 -105
  16. package/src/config/defaults.ts +13 -5
  17. package/src/config/file-config.ts +16 -0
  18. package/src/file/ripgrep.ts +38 -5
  19. package/src/file/time.ts +86 -9
  20. package/src/index.js +42 -33
  21. package/src/mcp/index.ts +370 -106
  22. package/src/permission/index.ts +22 -0
  23. package/src/project/instance.ts +5 -1
  24. package/src/project/project.ts +35 -2
  25. package/src/provider/env-template.ts +115 -0
  26. package/src/provider/extra-providers.ts +347 -0
  27. package/src/provider/free-tier-error.ts +1 -1
  28. package/src/provider/kilo-deprecation.ts +30 -0
  29. package/src/provider/kilo.ts +44 -7
  30. package/src/provider/model-discovery.ts +690 -0
  31. package/src/provider/models.ts +11 -1
  32. package/src/provider/parity-loaders.ts +595 -0
  33. package/src/provider/provider.ts +309 -203
  34. package/src/provider/retry-fetch.ts +5 -0
  35. package/src/provider/transform.ts +168 -17
  36. package/src/session/compaction.ts +18 -2
  37. package/src/session/index.ts +30 -3
  38. package/src/session/message-v2.ts +318 -32
  39. package/src/session/processor.ts +274 -140
  40. package/src/session/prompt.ts +154 -128
  41. package/src/session/retry.ts +26 -13
  42. package/src/session/summary.ts +4 -1
  43. package/src/tool/bash.ts +232 -45
  44. package/src/tool/bash.txt +1 -1
  45. package/src/tool/codesearch.ts +15 -78
  46. package/src/tool/edit.ts +111 -30
  47. package/src/tool/glob.ts +2 -1
  48. package/src/tool/grep.ts +175 -57
  49. package/src/tool/ls.ts +2 -1
  50. package/src/tool/multiedit.ts +3 -1
  51. package/src/tool/read.ts +169 -41
  52. package/src/tool/task.ts +26 -18
  53. package/src/tool/tool.ts +4 -1
  54. package/src/tool/webfetch.ts +68 -56
  55. package/src/tool/webfetch.txt +4 -4
  56. package/src/tool/websearch.ts +20 -75
  57. package/src/tool/websearch.txt +2 -0
  58. package/src/tool/write.ts +23 -6
  59. package/src/util/bom.ts +32 -0
  60. package/src/util/exa-mcp.ts +82 -0
  61. package/src/util/legacy-error-names.ts +39 -0
  62. package/src/util/read-bounded.ts +32 -0
  63. package/src/util/sse-usage-extractor.ts +67 -13
  64. package/src/util/timeout.ts +3 -4
  65. package/src/util/verbose-fetch.ts +40 -1
@@ -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: 'ephemeral' },
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
- providerID !== 'anthropic' &&
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
- ...lastContent.providerOptions,
118
- ...providerOptions,
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
- ...msg.providerOptions,
126
- ...providerOptions,
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;
@@ -159,6 +260,27 @@ export namespace ProviderTransform {
159
260
  return undefined;
160
261
  }
161
262
 
263
+ /**
264
+ * Catalogue providers whose chat-completions endpoint accepts an unscoped
265
+ * `prompt_cache_key`, as audited by OpenCode
266
+ * (packages/core/src/plugin/provider/prompt-cache-key.ts).
267
+ * @see https://github.com/link-assistant/agent/issues/334
268
+ */
269
+ export const PROMPT_CACHE_KEY_PROVIDERS = new Set([
270
+ 'abliteration-ai',
271
+ 'aihubmix',
272
+ 'anyapi',
273
+ 'auriko',
274
+ 'edenai',
275
+ 'jalapeno',
276
+ 'llama',
277
+ 'morph',
278
+ 'snowflake-cortex',
279
+ 'tencent-tokenhub',
280
+ 'upstage',
281
+ 'venice',
282
+ ]);
283
+
162
284
  export function options(
163
285
  providerID: string,
164
286
  modelID: string,
@@ -167,6 +289,14 @@ export namespace ProviderTransform {
167
289
  ): Record<string, any> | undefined {
168
290
  const result: Record<string, any> = {};
169
291
 
292
+ if (
293
+ npm === '@ai-sdk/openai-compatible' &&
294
+ PROMPT_CACHE_KEY_PROVIDERS.has(providerID)
295
+ ) {
296
+ // Unknown openai-compatible options are sent verbatim in the body.
297
+ result['prompt_cache_key'] = sessionID;
298
+ }
299
+
170
300
  if (providerID === 'openai') {
171
301
  result['promptCacheKey'] = sessionID;
172
302
  }
@@ -251,11 +381,32 @@ export namespace ProviderTransform {
251
381
  return standardLimit;
252
382
  }
253
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
+
254
401
  export function schema(
255
- _providerID: string,
256
- _modelID: string,
402
+ providerID: string,
403
+ modelID: string,
257
404
  schema: JSONSchema.BaseSchema
258
405
  ) {
406
+ if (providerID === 'moonshotai' || modelID.toLowerCase().includes('kimi')) {
407
+ schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema;
408
+ }
409
+
259
410
  /*
260
411
  if (["openai", "azure"].includes(providerID)) {
261
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'),
@@ -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
- const reasoningTokens = topLevelReasoningTokens || nestedReasoning;
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: safeNum(toNumber(effectiveUsage.outputTokens, 'outputTokens')),
791
+ output: outputTokens,
765
792
  reasoning: reasoningTokens,
766
793
  cache: {
767
794
  write: cacheWriteTokens,