@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
@@ -101,6 +101,40 @@ export namespace SessionProcessor {
101
101
  let snapshot: string | undefined;
102
102
  let blocked = false;
103
103
  let attempt = 0;
104
+ let needsCompaction = false;
105
+
106
+ // Providers may send tool-call / tool-result without tool-input-start
107
+ // (provider-executed tools, some OpenAI-compatible gateways); the part
108
+ // must still exist or the call is silently dropped (upstream #24151).
109
+ async function ensureToolCall(
110
+ id: string,
111
+ toolName: string,
112
+ providerExecuted?: boolean
113
+ ) {
114
+ const existing = toolcalls[id];
115
+ if (existing) {
116
+ if (!providerExecuted || existing.metadata?.providerExecuted)
117
+ return existing;
118
+ const part = (await Session.updatePart({
119
+ ...existing,
120
+ metadata: { ...existing.metadata, providerExecuted: true },
121
+ })) as MessageV2.ToolPart;
122
+ toolcalls[id] = part;
123
+ return part;
124
+ }
125
+ const part = (await Session.updatePart({
126
+ id: Identifier.ascending('part'),
127
+ messageID: input.assistantMessage.id,
128
+ sessionID: input.assistantMessage.sessionID,
129
+ type: 'tool',
130
+ tool: toolName,
131
+ callID: id,
132
+ state: { status: 'pending', input: {}, raw: '' },
133
+ metadata: providerExecuted ? { providerExecuted: true } : undefined,
134
+ })) as MessageV2.ToolPart;
135
+ toolcalls[id] = part;
136
+ return part;
137
+ }
104
138
 
105
139
  const result = {
106
140
  get message() {
@@ -112,9 +146,11 @@ export namespace SessionProcessor {
112
146
  async process(fn: () => StreamTextResult<Record<string, AITool>, never>) {
113
147
  log.info(() => ({ message: 'process' }));
114
148
  while (true) {
149
+ // Outside the try so cleanup can finalize parts left open by an
150
+ // interrupted stream (upstream #16776).
151
+ let currentText: MessageV2.TextPart | undefined;
152
+ let reasoningMap: Record<string, MessageV2.ReasoningPart> = {};
115
153
  try {
116
- let currentText: MessageV2.TextPart | undefined;
117
- let reasoningMap: Record<string, MessageV2.ReasoningPart> = {};
118
154
  const stream = fn();
119
155
 
120
156
  for await (const value of stream.stream) {
@@ -192,19 +228,36 @@ export namespace SessionProcessor {
192
228
  break;
193
229
 
194
230
  case 'tool-call': {
195
- const match = toolcalls[value.toolCallId];
196
- if (match) {
231
+ const match = await ensureToolCall(
232
+ value.toolCallId,
233
+ value.toolName,
234
+ value.providerExecuted
235
+ );
236
+ {
237
+ // Invalid tool calls carry the raw string; the part schema
238
+ // needs a record (upstream #14862).
239
+ const toolInput =
240
+ value.input &&
241
+ typeof value.input === 'object' &&
242
+ !Array.isArray(value.input)
243
+ ? (value.input as Record<string, any>)
244
+ : { value: value.input };
197
245
  const part = await Session.updatePart({
198
246
  ...match,
199
247
  tool: value.toolName,
200
- state: {
201
- status: 'running',
202
- input: value.input,
203
- time: {
204
- start: Date.now(),
205
- },
206
- },
207
- metadata: value.providerMetadata,
248
+ state:
249
+ match.state.status === 'running'
250
+ ? { ...match.state, input: toolInput }
251
+ : {
252
+ status: 'running',
253
+ input: toolInput,
254
+ time: {
255
+ start: Date.now(),
256
+ },
257
+ },
258
+ metadata: match.metadata?.providerExecuted
259
+ ? { ...value.providerMetadata, providerExecuted: true }
260
+ : value.providerMetadata,
208
261
  });
209
262
  toolcalls[value.toolCallId] = part as MessageV2.ToolPart;
210
263
 
@@ -230,17 +283,53 @@ export namespace SessionProcessor {
230
283
  break;
231
284
  }
232
285
  case 'tool-result': {
233
- const match = toolcalls[value.toolCallId];
234
- if (match && match.state.status === 'running') {
286
+ const match = await ensureToolCall(
287
+ value.toolCallId,
288
+ value.toolName,
289
+ value.providerExecuted
290
+ );
291
+ if (
292
+ match.state.status === 'running' ||
293
+ match.state.status === 'pending'
294
+ ) {
295
+ const output = value.output as unknown;
296
+ const toolInput =
297
+ value.input &&
298
+ typeof value.input === 'object' &&
299
+ !Array.isArray(value.input)
300
+ ? (value.input as Record<string, any>)
301
+ : { value: value.input };
302
+ const time = {
303
+ start:
304
+ match.state.status === 'running'
305
+ ? match.state.time.start
306
+ : Date.now(),
307
+ end: Date.now(),
308
+ };
235
309
  await Session.updatePart({
236
310
  ...match,
237
- state: Tool.toState(value.output, {
238
- input: value.input,
239
- time: {
240
- start: match.state.time.start,
241
- end: Date.now(),
242
- },
243
- }),
311
+ // Provider-executed tools return raw values rather than
312
+ // a Tool.Result.
313
+ state:
314
+ output &&
315
+ typeof output === 'object' &&
316
+ typeof (output as { output?: unknown }).output ===
317
+ 'string'
318
+ ? Tool.toState(output as Tool.Result, {
319
+ input: toolInput,
320
+ time,
321
+ })
322
+ : {
323
+ status: 'completed',
324
+ input: toolInput,
325
+ output:
326
+ typeof output === 'string'
327
+ ? output
328
+ : (JSON.stringify(output) ?? ''),
329
+ title: '',
330
+ metadata: {},
331
+ time,
332
+ },
244
333
  });
245
334
 
246
335
  delete toolcalls[value.toolCallId];
@@ -558,7 +647,7 @@ export namespace SessionProcessor {
558
647
  if (currentText) {
559
648
  currentText.text = currentText.text.trimEnd();
560
649
  currentText.time = {
561
- start: Date.now(),
650
+ start: currentText.time?.start ?? Date.now(),
562
651
  end: Date.now(),
563
652
  };
564
653
  if (value.providerMetadata)
@@ -611,130 +700,162 @@ export namespace SessionProcessor {
611
700
  ).toObject();
612
701
  }
613
702
 
614
- // Check if error is retryable (APIError, SocketConnectionError, TimeoutError)
615
- const isRetryableAPIError =
616
- error?.name === 'APIError' && error.data.isRetryable;
617
- const isRetryableSocketError =
618
- error?.name === 'SocketConnectionError' &&
619
- error.data.isRetryable &&
620
- attempt < SessionRetry.SOCKET_ERROR_MAX_RETRIES;
621
- const isRetryableTimeoutError =
622
- error?.name === 'TimeoutError' &&
623
- error.data.isRetryable &&
624
- attempt < SessionRetry.TIMEOUT_MAX_RETRIES;
625
-
626
- // For API errors (rate limits), check if we're within the retry timeout
627
- // See: https://github.com/link-assistant/agent/issues/157
628
- const retryCheck = isRetryableAPIError
629
- ? SessionRetry.shouldRetry(
630
- input.sessionID,
631
- error.data.statusCode?.toString() ?? 'unknown'
632
- )
633
- : { shouldRetry: true, elapsedTime: 0, maxTime: 0 };
634
-
635
- if (
636
- (isRetryableAPIError && retryCheck.shouldRetry) ||
637
- isRetryableSocketError ||
638
- isRetryableTimeoutError
639
- ) {
640
- attempt++;
641
- // Use error-specific delay calculation
642
- // SessionRetry.delay may throw RetryTimeoutExceededError if retry-after exceeds timeout
643
- let delay: number;
644
- try {
645
- delay =
646
- error?.name === 'SocketConnectionError'
647
- ? SessionRetry.socketErrorDelay(attempt)
648
- : error?.name === 'TimeoutError'
649
- ? SessionRetry.timeoutDelay(attempt)
650
- : SessionRetry.delay(error, attempt);
651
- } catch (delayError) {
652
- // If retry-after exceeds LINK_ASSISTANT_AGENT_RETRY_TIMEOUT, fail immediately
653
- if (
654
- delayError instanceof SessionRetry.RetryTimeoutExceededError
655
- ) {
656
- log.error(() => ({
657
- message: 'retry-after exceeds timeout, failing immediately',
658
- retryAfterMs: delayError.retryAfterMs,
659
- maxTimeoutMs: delayError.maxTimeoutMs,
660
- }));
661
- SessionRetry.clearRetryState(input.sessionID);
662
- // Create a specific error for this case
663
- input.assistantMessage.error = {
664
- name: 'RetryTimeoutExceededError',
665
- data: {
666
- message: delayError.message,
667
- isRetryable: false,
703
+ // Overflow is handled by compacting the session and trying again,
704
+ // not by retrying the same oversized request (upstream #14707).
705
+ if (error?.name === 'ContextOverflowError') {
706
+ SessionRetry.clearRetryState(input.sessionID);
707
+ if (
708
+ config.disableAutocompact &&
709
+ !input.assistantMessage.summary
710
+ ) {
711
+ input.assistantMessage.error = error;
712
+ Bus.publish(Session.Event.Error, {
713
+ sessionID: input.assistantMessage.sessionID,
714
+ error: input.assistantMessage.error,
715
+ });
716
+ } else {
717
+ log.info(() => ({
718
+ message: 'context overflow, requesting compaction',
719
+ providerID: input.providerID,
720
+ modelID: input.model.id,
721
+ }));
722
+ needsCompaction = true;
723
+ }
724
+ } else {
725
+ // Check if error is retryable (APIError, SocketConnectionError, TimeoutError)
726
+ const isRetryableAPIError =
727
+ error?.name === 'APIError' && error.data.isRetryable;
728
+ const isRetryableSocketError =
729
+ error?.name === 'SocketConnectionError' &&
730
+ error.data.isRetryable &&
731
+ attempt < SessionRetry.SOCKET_ERROR_MAX_RETRIES;
732
+ const isRetryableTimeoutError =
733
+ error?.name === 'TimeoutError' &&
734
+ error.data.isRetryable &&
735
+ attempt < SessionRetry.TIMEOUT_MAX_RETRIES;
736
+
737
+ // For API errors (rate limits), check if we're within the retry timeout
738
+ // See: https://github.com/link-assistant/agent/issues/157
739
+ const retryCheck = isRetryableAPIError
740
+ ? SessionRetry.shouldRetry(
741
+ input.sessionID,
742
+ error.data.statusCode?.toString() ?? 'unknown'
743
+ )
744
+ : { shouldRetry: true, elapsedTime: 0, maxTime: 0 };
745
+
746
+ let delay: number | undefined;
747
+ if (
748
+ (isRetryableAPIError && retryCheck.shouldRetry) ||
749
+ isRetryableSocketError ||
750
+ isRetryableTimeoutError
751
+ ) {
752
+ attempt++;
753
+ // Use error-specific delay calculation
754
+ // SessionRetry.delay may throw RetryTimeoutExceededError if retry-after exceeds timeout
755
+ try {
756
+ delay =
757
+ error?.name === 'SocketConnectionError'
758
+ ? SessionRetry.socketErrorDelay(attempt)
759
+ : error?.name === 'TimeoutError'
760
+ ? SessionRetry.timeoutDelay(attempt)
761
+ : SessionRetry.delay(error, attempt);
762
+ } catch (delayError) {
763
+ // If retry-after exceeds LINK_ASSISTANT_AGENT_RETRY_TIMEOUT, fail immediately
764
+ if (
765
+ delayError instanceof SessionRetry.RetryTimeoutExceededError
766
+ ) {
767
+ log.error(() => ({
768
+ message:
769
+ 'retry-after exceeds timeout, failing immediately',
668
770
  retryAfterMs: delayError.retryAfterMs,
669
771
  maxTimeoutMs: delayError.maxTimeoutMs,
670
- },
671
- } as MessageV2.Error;
672
- Bus.publish(Session.Event.Error, {
673
- sessionID: input.assistantMessage.sessionID,
674
- error: input.assistantMessage.error,
675
- });
676
- break;
772
+ }));
773
+ // Report it like any other final error below. Leaving
774
+ // the loop with `break` skipped the cleanup and returned
775
+ // undefined, so the prompt loop started another step.
776
+ error = new MessageV2.RetryTimeoutExceededError(
777
+ {
778
+ message: delayError.message,
779
+ isRetryable: false,
780
+ retryAfterMs: delayError.retryAfterMs,
781
+ maxTimeoutMs: delayError.maxTimeoutMs,
782
+ },
783
+ { cause: delayError }
784
+ ).toObject();
785
+ } else throw delayError;
677
786
  }
678
- throw delayError;
679
787
  }
680
- log.info(() => ({
681
- message: 'retrying',
682
- errorType: error?.name,
683
- attempt,
684
- delay,
685
- elapsedRetryTime: retryCheck.elapsedTime,
686
- maxRetryTime: retryCheck.maxTime,
687
- }));
688
- SessionStatus.set(input.sessionID, {
689
- type: 'retry',
690
- attempt,
691
- message: error.data.message,
692
- next: Date.now() + delay,
693
- });
694
- // Update retry state to track total time
695
- SessionRetry.updateRetryState(input.sessionID, delay);
696
- await SessionRetry.sleep(delay, input.abort).catch(() => {});
697
- continue;
698
- }
699
-
700
- // Clear retry state on non-retryable error
701
- SessionRetry.clearRetryState(input.sessionID);
788
+ if (delay !== undefined) {
789
+ log.info(() => ({
790
+ message: 'retrying',
791
+ errorType: error?.name,
792
+ attempt,
793
+ delay,
794
+ elapsedRetryTime: retryCheck.elapsedTime,
795
+ maxRetryTime: retryCheck.maxTime,
796
+ }));
797
+ SessionStatus.set(input.sessionID, {
798
+ type: 'retry',
799
+ attempt,
800
+ message: error.data.message,
801
+ next: Date.now() + delay,
802
+ });
803
+ // Update retry state to track total time
804
+ SessionRetry.updateRetryState(input.sessionID, delay);
805
+ await SessionRetry.sleep(delay, input.abort).catch(() => {});
806
+ continue;
807
+ }
702
808
 
703
- // Detect model-not-supported errors from provider response body.
704
- // OpenCode and similar proxies return HTTP 401 with
705
- // {"type":"ModelError","message":"Model X not supported"}.
706
- // Without this check the error looks like an auth failure (401),
707
- // but it's actually a model-availability issue.
708
- // See: https://github.com/link-assistant/agent/issues/208
709
- if (
710
- error?.name === 'APIError' &&
711
- error.data.statusCode === 401 &&
712
- error.data.responseBody
713
- ) {
714
- const isModelError = isModelNotSupportedError(
809
+ // Clear retry state on non-retryable error
810
+ SessionRetry.clearRetryState(input.sessionID);
811
+
812
+ // Detect model-not-supported errors from provider response body.
813
+ // OpenCode and similar proxies return HTTP 401 with
814
+ // {"type":"ModelError","message":"Model X not supported"}.
815
+ // Without this check the error looks like an auth failure (401),
816
+ // but it's actually a model-availability issue.
817
+ // See: https://github.com/link-assistant/agent/issues/208
818
+ if (
819
+ error?.name === 'APIError' &&
820
+ error.data.statusCode === 401 &&
715
821
  error.data.responseBody
716
- );
717
- if (isModelError) {
718
- log.error(() => ({
719
- message:
720
- 'model not supported by provider — this is NOT an auth error',
721
- hint: 'The model was found in the local cache but the provider rejected it. The model may have been removed or is temporarily unavailable.',
722
- providerID: input.providerID,
723
- modelID: input.model.id,
724
- statusCode: error.data.statusCode,
725
- responseBody: error.data.responseBody,
726
- suggestion:
727
- 'Try a different model or check the provider status. Use --model <provider>/<model-id> to specify an alternative.',
728
- issue: 'https://github.com/link-assistant/agent/issues/208',
729
- }));
822
+ ) {
823
+ const isModelError = isModelNotSupportedError(
824
+ error.data.responseBody
825
+ );
826
+ if (isModelError) {
827
+ log.error(() => ({
828
+ message:
829
+ 'model not supported by provider — this is NOT an auth error',
830
+ hint: 'The model was found in the local cache but the provider rejected it. The model may have been removed or is temporarily unavailable.',
831
+ providerID: input.providerID,
832
+ modelID: input.model.id,
833
+ statusCode: error.data.statusCode,
834
+ responseBody: error.data.responseBody,
835
+ suggestion:
836
+ 'Try a different model or check the provider status. Use --model <provider>/<model-id> to specify an alternative.',
837
+ issue: 'https://github.com/link-assistant/agent/issues/208',
838
+ }));
839
+ }
730
840
  }
731
- }
732
841
 
733
- input.assistantMessage.error = error;
734
- Bus.publish(Session.Event.Error, {
735
- sessionID: input.assistantMessage.sessionID,
736
- error: input.assistantMessage.error,
737
- });
842
+ input.assistantMessage.error = error;
843
+ Bus.publish(Session.Event.Error, {
844
+ sessionID: input.assistantMessage.sessionID,
845
+ error: input.assistantMessage.error,
846
+ });
847
+ }
848
+ }
849
+ const end = Date.now();
850
+ if (currentText && currentText.time?.end === undefined) {
851
+ currentText.text = currentText.text.trimEnd();
852
+ currentText.time = { start: currentText.time?.start ?? end, end };
853
+ await Session.updatePart(currentText);
854
+ }
855
+ for (const part of Object.values(reasoningMap)) {
856
+ part.text = part.text.trimEnd();
857
+ part.time = { start: part.time.start ?? end, end };
858
+ await Session.updatePart(part);
738
859
  }
739
860
  const p = await MessageV2.parts(input.assistantMessage.id);
740
861
  for (const part of p) {
@@ -743,15 +864,27 @@ export namespace SessionProcessor {
743
864
  part.state.status !== 'completed' &&
744
865
  part.state.status !== 'error'
745
866
  ) {
867
+ // Keep the real start and flag the part so the loop does not
868
+ // treat an aborted call as one to continue from (upstream
869
+ // #16776).
746
870
  await Session.updatePart({
747
871
  ...part,
748
872
  state: {
749
873
  ...part.state,
750
874
  status: 'error',
751
875
  error: 'Tool execution aborted',
876
+ metadata: {
877
+ ...(part.state.status === 'running'
878
+ ? part.state.metadata
879
+ : undefined),
880
+ interrupted: true,
881
+ },
752
882
  time: {
753
- start: Date.now(),
754
- end: Date.now(),
883
+ start:
884
+ part.state.status === 'running'
885
+ ? part.state.time.start
886
+ : end,
887
+ end,
755
888
  },
756
889
  },
757
890
  });
@@ -760,6 +893,7 @@ export namespace SessionProcessor {
760
893
  input.assistantMessage.time.completed = Date.now();
761
894
  await Session.updateMessage(input.assistantMessage);
762
895
  if (blocked) return 'stop';
896
+ if (needsCompaction) return 'compact';
763
897
  if (input.assistantMessage.error) return 'stop';
764
898
  return 'continue';
765
899
  }