@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.
Files changed (54) hide show
  1. package/README.md +47 -5
  2. package/package.json +41 -42
  3. package/src/cli/argv.ts +1 -1
  4. package/src/cli/cmd/run.ts +7 -1
  5. package/src/cli/continuous-mode.js +38 -12
  6. package/src/cli/event-handler.js +3 -1
  7. package/src/cli/model-config.js +20 -4
  8. package/src/cli/run-options.js +144 -78
  9. package/src/config/config.ts +306 -105
  10. package/src/config/defaults.ts +13 -5
  11. package/src/config/file-config.ts +16 -0
  12. package/src/file/ripgrep.ts +38 -5
  13. package/src/file/time.ts +86 -9
  14. package/src/index.js +39 -33
  15. package/src/mcp/index.ts +370 -106
  16. package/src/permission/index.ts +22 -0
  17. package/src/project/instance.ts +5 -1
  18. package/src/project/project.ts +35 -2
  19. package/src/provider/free-tier-error.ts +1 -1
  20. package/src/provider/kilo-deprecation.ts +30 -0
  21. package/src/provider/kilo.ts +25 -7
  22. package/src/provider/provider.ts +53 -22
  23. package/src/provider/retry-fetch.ts +5 -0
  24. package/src/provider/transform.ts +139 -17
  25. package/src/session/compaction.ts +18 -2
  26. package/src/session/index.ts +30 -3
  27. package/src/session/message-v2.ts +318 -32
  28. package/src/session/processor.ts +274 -140
  29. package/src/session/prompt.ts +154 -128
  30. package/src/session/retry.ts +26 -13
  31. package/src/session/summary.ts +4 -1
  32. package/src/tool/bash.ts +232 -45
  33. package/src/tool/bash.txt +1 -1
  34. package/src/tool/codesearch.ts +15 -78
  35. package/src/tool/edit.ts +111 -30
  36. package/src/tool/glob.ts +2 -1
  37. package/src/tool/grep.ts +175 -57
  38. package/src/tool/ls.ts +2 -1
  39. package/src/tool/multiedit.ts +3 -1
  40. package/src/tool/read.ts +169 -41
  41. package/src/tool/task.ts +26 -18
  42. package/src/tool/tool.ts +4 -1
  43. package/src/tool/webfetch.ts +68 -56
  44. package/src/tool/webfetch.txt +4 -4
  45. package/src/tool/websearch.ts +20 -75
  46. package/src/tool/websearch.txt +2 -0
  47. package/src/tool/write.ts +23 -6
  48. package/src/util/bom.ts +32 -0
  49. package/src/util/exa-mcp.ts +82 -0
  50. package/src/util/legacy-error-names.ts +39 -0
  51. package/src/util/read-bounded.ts +32 -0
  52. package/src/util/sse-usage-extractor.ts +67 -13
  53. package/src/util/timeout.ts +3 -4
  54. package/src/util/verbose-fetch.ts +40 -1
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { Permission } from '../permission';
2
3
  import os from 'os';
3
4
  import fs from 'fs/promises';
4
5
  import z from 'zod';
@@ -17,6 +18,7 @@ import {
17
18
  wrapLanguageModel,
18
19
  isStepCount,
19
20
  jsonSchema,
21
+ asSchema,
20
22
  } from 'ai';
21
23
  import { SessionCompaction } from './compaction';
22
24
  import { Instance } from '../project/instance';
@@ -33,7 +35,6 @@ import { mergeDeep, pipe } from 'remeda';
33
35
  import { ToolRegistry } from '../tool/registry';
34
36
  import { Wildcard } from '../util/wildcard';
35
37
  import { MCP } from '../mcp';
36
- import { withTimeout } from '../util/timeout';
37
38
  import { ReadTool } from '../tool/read';
38
39
  import { ListTool } from '../tool/ls';
39
40
  import { FileTime } from '../file/time';
@@ -102,7 +103,7 @@ export namespace SessionPrompt {
102
103
  abort: AbortController;
103
104
  callbacks: {
104
105
  resolve(input: MessageV2.WithParts): void;
105
- reject(): void;
106
+ reject(reason?: unknown): void;
106
107
  }[];
107
108
  }
108
109
  > = {};
@@ -274,14 +275,47 @@ export namespace SessionPrompt {
274
275
  return controller.signal;
275
276
  }
276
277
 
278
+ // Tool calls whose results must go back to the model. Provider-executed
279
+ // tools already have their result in the response, and tools interrupted by
280
+ // an abort or retry are not pending work (upstream #14707, #16776).
281
+ export function hasToolCalls(parts: MessageV2.Part[]) {
282
+ return parts.some(
283
+ (part) =>
284
+ part.type === 'tool' &&
285
+ !part.metadata?.providerExecuted &&
286
+ !(
287
+ part.state.status === 'error' &&
288
+ part.state.metadata?.interrupted === true
289
+ )
290
+ );
291
+ }
292
+
293
+ // Whether a finished step still has tool results to send back.
294
+ // --legacy-loop-exit / LINK_ASSISTANT_AGENT_LEGACY_LOOP_EXIT: only an
295
+ // 'unknown' finish was checked for tool calls before (issue #194).
296
+ export function toolCallsPending(
297
+ finish: string | undefined,
298
+ parts: MessageV2.Part[],
299
+ legacy: boolean = config.legacyLoopExit
300
+ ) {
301
+ if (legacy && finish !== 'unknown') return false;
302
+ return hasToolCalls(parts);
303
+ }
304
+
277
305
  export function cancel(sessionID: string) {
278
306
  log.info(() => ({ message: 'cancel', sessionID }));
279
307
  const s = state();
280
308
  const match = s[sessionID];
281
- if (!match) return;
309
+ if (!match) {
310
+ SessionStatus.set(sessionID, { type: 'idle' });
311
+ return;
312
+ }
282
313
  match.abort.abort();
314
+ Permission.clearSession(sessionID);
315
+ // Queued callers get a real AbortError instead of `undefined`
316
+ // (upstream #16776).
283
317
  for (const item of match.callbacks) {
284
- item.reject();
318
+ item.reject(new DOMException('Aborted', 'AbortError'));
285
319
  }
286
320
  delete s[sessionID];
287
321
  SessionStatus.set(sessionID, { type: 'idle' });
@@ -333,87 +367,67 @@ export namespace SessionPrompt {
333
367
 
334
368
  // Check if we should exit the agentic loop
335
369
  // We exit when: the assistant has a finish reason, it's not 'tool-calls',
336
- // and the assistant message is newer than the last user message
337
- if (
338
- lastAssistant?.finish &&
370
+ // it made no tool calls whose results still have to be sent back, and
371
+ // the assistant message is newer than the last user message. Providers
372
+ // report finish 'stop' (or nothing) for turns with tool calls, so the
373
+ // finish reason alone is not enough (upstream #14707, #194).
374
+ const lastAssistantParts = lastAssistant
375
+ ? msgs.find((m) => m.info.id === lastAssistant!.id)?.parts
376
+ : undefined;
377
+ const finishedTurn =
378
+ !!lastAssistant?.finish &&
339
379
  lastAssistant.finish !== 'tool-calls' &&
340
- lastUser.id < lastAssistant.id
341
- ) {
342
- // SAFETY CHECK for issue #194: If finish reason is 'unknown', check for tool calls
343
- // Some providers (e.g., Kimi K2.5 via OpenCode) return undefined finishReason
344
- // which gets converted to 'unknown'. If there were tool calls made, we should
345
- // continue the loop to execute them instead of prematurely exiting.
346
- // See: https://github.com/link-assistant/agent/issues/194
347
- if (lastAssistant.finish === 'unknown') {
348
- // First check for tool calls BEFORE checking zero tokens (#239)
349
- // Some providers (e.g., OpenCode Zen / OpenRouter) return zero tokens and
350
- // unknown finish reason even when the model successfully executed tool calls.
351
- // We must check for tool calls first to avoid prematurely terminating
352
- // a session that is actually making progress.
353
- const lastAssistantParts = msgs.find(
354
- (m) => m.info.id === lastAssistant.id
355
- )?.parts;
356
- const hasToolCalls = lastAssistantParts?.some(
357
- (p) =>
358
- p.type === 'tool' &&
359
- (p.state.status === 'completed' || p.state.status === 'running')
360
- );
361
-
362
- // SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
363
- // When all tokens are 0 and finish reason is 'unknown', this indicates the provider
364
- // returned an empty/error response (e.g., rate limit, model unavailable, API failure).
365
- // But ONLY treat this as fatal if there are NO tool calls (#239).
366
- const tokens = lastAssistant.tokens;
367
- if (
368
- tokens.input === 0 &&
369
- tokens.output === 0 &&
370
- tokens.reasoning === 0 &&
371
- !hasToolCalls
372
- ) {
373
- const errorMessage =
374
- `Provider returned zero tokens with unknown finish reason. ` +
375
- `Requested model: ${lastAssistant.model?.requestedModelID ?? 'unknown'} ` +
376
- `(provider: ${lastAssistant.model?.providerID ?? 'unknown'}). ` +
377
- `Responded model: ${lastAssistant.model?.respondedModelID ?? 'unknown'}. ` +
378
- `This usually indicates the provider failed to process the request. ` +
379
- `Check provider status, model availability, and API keys.`;
380
- log.error(() => ({
380
+ lastUser.id < lastAssistant.id;
381
+ const toolCallsMade = toolCallsPending(
382
+ lastAssistant?.finish,
383
+ lastAssistantParts ?? []
384
+ );
385
+ if (finishedTurn && toolCallsMade) {
386
+ log.info(() => ({
387
+ message: 'continuing loop - tool calls detected',
388
+ sessionID,
389
+ finishReason: lastAssistant!.finish,
390
+ }));
391
+ }
392
+ if (lastAssistant && finishedTurn && !toolCallsMade) {
393
+ // SAFETY CHECK for issue #196: Detect zero-token responses as provider failures
394
+ // When all tokens are 0 and finish reason is 'unknown', this indicates the provider
395
+ // returned an empty/error response (e.g., rate limit, model unavailable, API failure).
396
+ // Turns with tool calls never get here (#239).
397
+ const tokens = lastAssistant.tokens;
398
+ if (
399
+ lastAssistant.finish === 'unknown' &&
400
+ tokens.input === 0 &&
401
+ tokens.output === 0 &&
402
+ tokens.reasoning === 0
403
+ ) {
404
+ const errorMessage =
405
+ `Provider returned zero tokens with unknown finish reason. ` +
406
+ `Requested model: ${lastAssistant.model?.requestedModelID ?? 'unknown'} ` +
407
+ `(provider: ${lastAssistant.model?.providerID ?? 'unknown'}). ` +
408
+ `Responded model: ${lastAssistant.model?.respondedModelID ?? 'unknown'}. ` +
409
+ `This usually indicates the provider failed to process the request. ` +
410
+ `Check provider status, model availability, and API keys.`;
411
+ log.error(() => ({
412
+ message: errorMessage,
413
+ sessionID,
414
+ finishReason: lastAssistant.finish,
415
+ tokens,
416
+ cost: lastAssistant.cost,
417
+ model: lastAssistant.model,
418
+ issue: 'https://github.com/link-assistant/agent/issues/198',
419
+ }));
420
+ // Publish error event so JSON standard output includes it (#198)
421
+ Bus.publish(Session.Event.Error, {
422
+ sessionID,
423
+ error: new NamedError.Unknown({
381
424
  message: errorMessage,
382
- sessionID,
383
- finishReason: lastAssistant.finish,
384
- tokens,
385
- cost: lastAssistant.cost,
386
- model: lastAssistant.model,
387
- issue: 'https://github.com/link-assistant/agent/issues/198',
388
- }));
389
- // Publish error event so JSON standard output includes it (#198)
390
- Bus.publish(Session.Event.Error, {
391
- sessionID,
392
- error: new NamedError.Unknown({
393
- message: errorMessage,
394
- }).toObject(),
395
- });
396
- break;
397
- }
398
-
399
- if (hasToolCalls) {
400
- log.info(() => ({
401
- message:
402
- 'continuing loop despite unknown finish reason - tool calls detected',
403
- sessionID,
404
- finishReason: lastAssistant.finish,
405
- zeroTokens: tokens.input === 0 && tokens.output === 0,
406
- hint: 'Provider returned undefined finishReason but made tool calls',
407
- }));
408
- // Don't break - continue the loop to handle tool call results
409
- } else {
410
- log.info(() => ({ message: 'exiting loop', sessionID }));
411
- break;
412
- }
413
- } else {
414
- log.info(() => ({ message: 'exiting loop', sessionID }));
425
+ }).toObject(),
426
+ });
415
427
  break;
416
428
  }
429
+ log.info(() => ({ message: 'exiting loop', sessionID }));
430
+ break;
417
431
  }
418
432
 
419
433
  step++;
@@ -867,7 +881,8 @@ export namespace SessionPrompt {
867
881
  }
868
882
 
869
883
  return false;
870
- })
884
+ }),
885
+ { providerID: model.providerID, modelID: model.modelID }
871
886
  );
872
887
  // Defensive check: ensure modelMessages is iterable (AI SDK 6.0.1 compatibility fix)
873
888
  const safeModelMessages = Array.isArray(modelMessages)
@@ -998,8 +1013,10 @@ export namespace SessionPrompt {
998
1013
  maxRetries: 0,
999
1014
  activeTools: Object.keys(tools).filter((x) => x !== 'invalid'),
1000
1015
  maxOutputTokens: capOutputTokensToContext({
1016
+ // The first argument is the npm package: passing the provider ID
1017
+ // skipped the Anthropic thinking-budget adjustment (#335).
1001
1018
  baseMaxOutput: ProviderTransform.maxOutputTokens(
1002
- model.providerID,
1019
+ model.npm ?? '',
1003
1020
  params.options,
1004
1021
  model.info?.limit?.output ?? 100000,
1005
1022
  OUTPUT_TOKEN_MAX
@@ -1029,7 +1046,8 @@ export namespace SessionPrompt {
1029
1046
  args.params.prompt = ProviderTransform.message(
1030
1047
  args.params.prompt,
1031
1048
  model.providerID,
1032
- model.modelID
1049
+ model.modelID,
1050
+ model.npm
1033
1051
  );
1034
1052
  }
1035
1053
  return args.params;
@@ -1040,6 +1058,27 @@ export namespace SessionPrompt {
1040
1058
  })
1041
1059
  );
1042
1060
  if (result === 'stop') break;
1061
+ if (result === 'compact') {
1062
+ // Overflowing again straight after a compaction means the summary
1063
+ // plus the latest message still do not fit; compacting once more
1064
+ // would loop forever (upstream #14707).
1065
+ if (lastFinished?.summary) {
1066
+ processor.message.error = new MessageV2.ContextOverflowError({
1067
+ message:
1068
+ 'Context still exceeds the model limit after compaction - start a new session or use a model with a larger context window',
1069
+ }).toObject();
1070
+ await Session.updateMessage(processor.message);
1071
+ Bus.publish(Session.Event.Error, {
1072
+ sessionID,
1073
+ error: processor.message.error,
1074
+ });
1075
+ break;
1076
+ }
1077
+ await SessionCompaction.create({
1078
+ sessionID,
1079
+ model: lastUser.model,
1080
+ });
1081
+ }
1043
1082
  continue;
1044
1083
  }
1045
1084
  SessionCompaction.prune({ sessionID });
@@ -1208,56 +1247,43 @@ export namespace SessionPrompt {
1208
1247
  if (Wildcard.all(key, enabledTools) === false) continue;
1209
1248
  const execute = item.execute;
1210
1249
  if (!execute) continue;
1250
+ // MCP schemas go through the same provider fixes as built-in tools
1251
+ // (e.g. Moonshot/Kimi rejects `$ref` siblings and tuple `items`).
1252
+ // See: upstream 528fb1d404 (#24730) and b7b734f51f (#11984).
1253
+ const inputSchema = item.inputSchema;
1254
+ item.inputSchema = jsonSchema(async () =>
1255
+ ProviderTransform.schema(
1256
+ input.model.providerID,
1257
+ input.model.modelID,
1258
+ (await asSchema(inputSchema).jsonSchema) as any
1259
+ )
1260
+ );
1211
1261
  item.execute = async (args, opts) => {
1212
- // Get timeout for this specific tool
1213
1262
  const timeout = await MCP.getToolTimeout(key);
1214
-
1215
- // Wrap the execute call with timeout to prevent indefinite hangs
1216
- let result;
1217
- try {
1218
- result = await withTimeout(execute(args, opts), timeout);
1219
- } catch (error) {
1220
- // Check if it's a timeout error
1221
- if (
1222
- error instanceof Error &&
1223
- error.message.includes('timed out after')
1224
- ) {
1225
- const timeoutSec = Math.round(timeout / 1000);
1226
- throw new Error(
1227
- `MCP tool "${key}" timed out after ${timeoutSec} seconds. ` +
1228
- `The operation did not complete within the configured timeout. ` +
1229
- `You can increase the timeout in the MCP server configuration using tool_call_timeout or tool_timeouts.`
1230
- );
1231
- }
1232
- throw error;
1233
- }
1234
-
1235
- const textParts: string[] = [];
1236
- const attachments: MessageV2.FilePart[] = [];
1237
-
1238
- for (const item of result.content) {
1239
- if (item.type === 'text') {
1240
- textParts.push(item.text);
1241
- } else if (item.type === 'image') {
1242
- attachments.push({
1243
- id: Identifier.ascending('part'),
1244
- sessionID: input.sessionID,
1245
- messageID: input.processor.message.id,
1246
- type: 'file',
1247
- mime: item.mimeType,
1248
- url: `data:${item.mimeType};base64,${item.data}`,
1249
- });
1250
- }
1251
- // Add support for other types if needed
1252
- }
1263
+ const result = await MCP.callWithTimeout(
1264
+ key,
1265
+ (abortSignal) => execute(args, { ...opts, abortSignal }),
1266
+ timeout,
1267
+ opts?.abortSignal
1268
+ );
1269
+ const normalized = MCP.normalizeResult(result);
1270
+ const attachments: MessageV2.FilePart[] = normalized.attachments.map(
1271
+ (attachment) => ({
1272
+ id: Identifier.ascending('part'),
1273
+ sessionID: input.sessionID,
1274
+ messageID: input.processor.message.id,
1275
+ type: 'file',
1276
+ ...attachment,
1277
+ })
1278
+ );
1253
1279
 
1254
1280
  return {
1255
1281
  title: '',
1256
- metadata: result.metadata ?? {},
1257
- output: textParts.join('\n\n'),
1282
+ metadata: normalized.metadata,
1283
+ output: normalized.output,
1258
1284
  attachments,
1259
- content: result.content, // directly return content to preserve ordering when outputting to model
1260
- isError: result.isError,
1285
+ content: normalized.content, // directly return content to preserve ordering when outputting to model
1286
+ isError: normalized.isError,
1261
1287
  };
1262
1288
  };
1263
1289
  item.toModelOutput = Tool.toModelOutput;
@@ -8,6 +8,13 @@ export namespace SessionRetry {
8
8
  export const RETRY_INITIAL_DELAY = 2000;
9
9
  export const RETRY_BACKOFF_FACTOR = 2;
10
10
  export const RETRY_MAX_DELAY_NO_HEADERS = 30_000; // 30 seconds
11
+ // setTimeout fires immediately for anything above 2^31 - 1 ms, which turns a
12
+ // long retry-after into a tight retry loop (upstream #19224).
13
+ export const RETRY_MAX_DELAY = 2_147_483_647;
14
+
15
+ function cap(delay: number): number {
16
+ return Math.min(delay, RETRY_MAX_DELAY);
17
+ }
11
18
 
12
19
  // Maximum delay for a single retry attempt when NO retry-after header (default: 20 minutes)
13
20
  // This caps exponential backoff when headers are not available
@@ -122,17 +129,23 @@ export namespace SessionRetry {
122
129
 
123
130
  export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
124
131
  return new Promise((resolve, reject) => {
125
- const timeout = setTimeout(resolve, ms);
132
+ if (signal.aborted) {
133
+ reject(new DOMException('Aborted', 'AbortError'));
134
+ return;
135
+ }
136
+ const onAbort = () => {
137
+ clearTimeout(timeout);
138
+ reject(new DOMException('Aborted', 'AbortError'));
139
+ };
140
+ const timeout = setTimeout(() => {
141
+ // Drop the listener once the delay elapses so retries do not pile
142
+ // listeners onto the turn's abort signal (issue #335).
143
+ signal.removeEventListener('abort', onAbort);
144
+ resolve();
145
+ }, cap(ms));
126
146
  // Prevent sleep timer from keeping event loop alive (#213)
127
147
  if (timeout.unref) timeout.unref();
128
- signal.addEventListener(
129
- 'abort',
130
- () => {
131
- clearTimeout(timeout);
132
- reject(new DOMException('Aborted', 'AbortError'));
133
- },
134
- { once: true }
135
- );
148
+ signal.addEventListener('abort', onAbort, { once: true });
136
149
  });
137
150
  }
138
151
 
@@ -142,7 +155,7 @@ export namespace SessionRetry {
142
155
  */
143
156
  function addJitter(delay: number): number {
144
157
  const jitter = Math.random() * 0.1 * delay;
145
- return Math.round(delay + jitter);
158
+ return cap(Math.round(delay + jitter));
146
159
  }
147
160
 
148
161
  /**
@@ -273,9 +286,9 @@ export namespace SessionRetry {
273
286
  * Uses exponential backoff: 1s, 2s, 4s, etc.
274
287
  */
275
288
  export function socketErrorDelay(attempt: number): number {
276
- return (
289
+ return cap(
277
290
  SOCKET_ERROR_INITIAL_DELAY *
278
- Math.pow(SOCKET_ERROR_BACKOFF_FACTOR, attempt - 1)
291
+ Math.pow(SOCKET_ERROR_BACKOFF_FACTOR, attempt - 1)
279
292
  );
280
293
  }
281
294
 
@@ -286,6 +299,6 @@ export namespace SessionRetry {
286
299
  */
287
300
  export function timeoutDelay(attempt: number): number {
288
301
  const index = Math.min(attempt - 1, TIMEOUT_DELAYS.length - 1);
289
- return TIMEOUT_DELAYS[index];
302
+ return cap(TIMEOUT_DELAYS[index]);
290
303
  }
291
304
  }
@@ -281,7 +281,10 @@ export namespace SessionSummary {
281
281
  ?.parts.findLast((p) => p.type === 'text')?.text;
282
282
  if (!summary || diffs.length > 0) {
283
283
  // Pre-convert messages to ModelMessage format (async in AI SDK 6.0+)
284
- const modelMessages = await MessageV2.toModelMessage(messages);
284
+ const modelMessages = await MessageV2.toModelMessage(messages, {
285
+ providerID: model.providerID,
286
+ modelID: model.modelID,
287
+ });
285
288
  const conversationContent = JSON.stringify(modelMessages);
286
289
 
287
290
  if (isVerbose()) {