@link-assistant/agent 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli/argv.ts CHANGED
@@ -4,30 +4,68 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Extract model argument directly from process.argv
8
- * This is a safeguard against yargs caching issues (#192)
9
- * @returns The model argument from CLI or null if not found
7
+ * Extract a named argument directly from process.argv.
8
+ * Supports --name=value, --name value, and optional short aliases (-x=value, -x value).
9
+ * @returns The argument value from CLI or null if not found
10
10
  */
11
- export function getModelFromProcessArgv(): string | null {
11
+ function getArgFromProcessArgv(
12
+ longFlag: string,
13
+ shortFlag?: string
14
+ ): string | null {
12
15
  const args = process.argv;
16
+ const longPrefix = `--${longFlag}=`;
17
+ const shortPrefix = shortFlag ? `-${shortFlag}=` : null;
13
18
  for (let i = 0; i < args.length; i++) {
14
19
  const arg = args[i];
15
- // Handle --model=value format
16
- if (arg.startsWith('--model=')) {
17
- return arg.substring('--model='.length);
20
+ // Handle --flag=value format
21
+ if (arg.startsWith(longPrefix)) {
22
+ return arg.substring(longPrefix.length);
18
23
  }
19
- // Handle --model value format
20
- if (arg === '--model' && i + 1 < args.length) {
24
+ // Handle --flag value format
25
+ if (arg === `--${longFlag}` && i + 1 < args.length) {
21
26
  return args[i + 1];
22
27
  }
23
- // Handle -m=value format
24
- if (arg.startsWith('-m=')) {
25
- return arg.substring('-m='.length);
26
- }
27
- // Handle -m value format (but not if it looks like another flag)
28
- if (arg === '-m' && i + 1 < args.length && !args[i + 1].startsWith('-')) {
29
- return args[i + 1];
28
+ if (shortPrefix) {
29
+ // Handle -x=value format
30
+ if (arg.startsWith(shortPrefix)) {
31
+ return arg.substring(shortPrefix.length);
32
+ }
33
+ // Handle -x value format (but not if it looks like another flag)
34
+ if (
35
+ arg === `-${shortFlag}` &&
36
+ i + 1 < args.length &&
37
+ !args[i + 1].startsWith('-')
38
+ ) {
39
+ return args[i + 1];
40
+ }
30
41
  }
31
42
  }
32
43
  return null;
33
44
  }
45
+
46
+ /**
47
+ * Extract model argument directly from process.argv
48
+ * This is a safeguard against yargs caching issues (#192)
49
+ * @returns The model argument from CLI or null if not found
50
+ */
51
+ export function getModelFromProcessArgv(): string | null {
52
+ return getArgFromProcessArgv('model', 'm');
53
+ }
54
+
55
+ /**
56
+ * Extract --compaction-model argument directly from process.argv
57
+ * @returns The compaction model argument from CLI or null if not found
58
+ * @see https://github.com/link-assistant/agent/issues/219
59
+ */
60
+ export function getCompactionModelFromProcessArgv(): string | null {
61
+ return getArgFromProcessArgv('compaction-model');
62
+ }
63
+
64
+ /**
65
+ * Extract --compaction-safety-margin argument directly from process.argv
66
+ * @returns The compaction safety margin (%) from CLI or null if not found
67
+ * @see https://github.com/link-assistant/agent/issues/219
68
+ */
69
+ export function getCompactionSafetyMarginFromProcessArgv(): string | null {
70
+ return getArgFromProcessArgv('compaction-safety-margin');
71
+ }
@@ -193,7 +193,8 @@ export async function runContinuousServerMode(
193
193
  modelID,
194
194
  systemMessage,
195
195
  appendSystemMessage,
196
- jsonStandard
196
+ jsonStandard,
197
+ compactionModel
197
198
  ) {
198
199
  // Check both CLI flag and environment variable for compact JSON mode
199
200
  const compactJson = argv['compact-json'] === true || Flag.COMPACT_JSON();
@@ -286,6 +287,7 @@ export async function runContinuousServerMode(
286
287
  body: JSON.stringify({
287
288
  parts,
288
289
  model: { providerID, modelID },
290
+ compactionModel,
289
291
  system: systemMessage,
290
292
  appendSystem: appendSystemMessage,
291
293
  }),
@@ -443,7 +445,8 @@ export async function runContinuousDirectMode(
443
445
  modelID,
444
446
  systemMessage,
445
447
  appendSystemMessage,
446
- jsonStandard
448
+ jsonStandard,
449
+ compactionModel
447
450
  ) {
448
451
  // Check both CLI flag and environment variable for compact JSON mode
449
452
  const compactJson = argv['compact-json'] === true || Flag.COMPACT_JSON();
@@ -517,6 +520,7 @@ export async function runContinuousDirectMode(
517
520
  sessionID,
518
521
  parts,
519
522
  model: { providerID, modelID },
523
+ compactionModel,
520
524
  system: systemMessage,
521
525
  appendSystem: appendSystemMessage,
522
526
  }).catch((error) => {
@@ -13,3 +13,21 @@ export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
13
13
 
14
14
  /** Default model ID extracted from DEFAULT_MODEL. */
15
15
  export const DEFAULT_MODEL_ID = DEFAULT_MODEL.split('/').slice(1).join('/');
16
+
17
+ /**
18
+ * Default compaction model used when no `--compaction-model` CLI argument is provided.
19
+ * gpt-5-nano has a 400K context window, larger than most free base models (~200K),
20
+ * which allows compacting 100% of the base model's context without a safety margin.
21
+ * The special value "same" means use the same model as `--model`.
22
+ * @see https://github.com/link-assistant/agent/issues/219
23
+ */
24
+ export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
25
+
26
+ /**
27
+ * Default compaction safety margin as a percentage of usable context window.
28
+ * Applied only when the compaction model has a context window equal to or smaller
29
+ * than the base model. When the compaction model has a larger context, the margin
30
+ * is automatically set to 0 (allowing 100% context usage).
31
+ * @see https://github.com/link-assistant/agent/issues/219
32
+ */
33
+ export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT = 15;
@@ -1,6 +1,15 @@
1
- import { getModelFromProcessArgv } from './argv.ts';
1
+ import {
2
+ getModelFromProcessArgv,
3
+ getCompactionModelFromProcessArgv,
4
+ getCompactionSafetyMarginFromProcessArgv,
5
+ } from './argv.ts';
2
6
  import { Log } from '../util/log.ts';
3
- import { DEFAULT_PROVIDER_ID, DEFAULT_MODEL_ID } from './defaults.ts';
7
+ import {
8
+ DEFAULT_PROVIDER_ID,
9
+ DEFAULT_MODEL_ID,
10
+ DEFAULT_COMPACTION_MODEL,
11
+ DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
12
+ } from './defaults.ts';
4
13
 
5
14
  /**
6
15
  * Parse model config from argv. Supports "provider/model" or short "model" format.
@@ -101,6 +110,13 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
101
110
  }));
102
111
  }
103
112
 
113
+ // Parse compaction model (#219)
114
+ const compactionModelResult = await parseCompactionModelConfig(
115
+ argv,
116
+ providerID,
117
+ modelID
118
+ );
119
+
104
120
  // Handle --use-existing-claude-oauth option
105
121
  // This reads OAuth credentials from ~/.claude/.credentials.json (Claude Code CLI)
106
122
  // For new authentication, use: agent auth login (select Anthropic > Claude Pro/Max)
@@ -144,5 +160,73 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
144
160
  }
145
161
  }
146
162
 
147
- return { providerID, modelID };
163
+ return { providerID, modelID, compactionModel: compactionModelResult };
164
+ }
165
+
166
+ /**
167
+ * Parse compaction model config from argv.
168
+ * Resolves --compaction-model and --compaction-safety-margin CLI arguments.
169
+ * The special value "same" means use the base model for compaction.
170
+ * @see https://github.com/link-assistant/agent/issues/219
171
+ */
172
+ async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
173
+ // Get compaction model from CLI (safeguard against yargs caching)
174
+ const cliCompactionModelArg = getCompactionModelFromProcessArgv();
175
+ const compactionModelArg =
176
+ cliCompactionModelArg ??
177
+ argv['compaction-model'] ??
178
+ DEFAULT_COMPACTION_MODEL;
179
+
180
+ // Get safety margin from CLI
181
+ const cliSafetyMarginArg = getCompactionSafetyMarginFromProcessArgv();
182
+ const compactionSafetyMarginPercent = cliSafetyMarginArg
183
+ ? parseInt(cliSafetyMarginArg, 10)
184
+ : (argv['compaction-safety-margin'] ??
185
+ DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT);
186
+
187
+ // Special "same" alias — use the base model for compaction
188
+ const useSameModel = compactionModelArg.toLowerCase() === 'same';
189
+
190
+ let compactionProviderID;
191
+ let compactionModelID;
192
+
193
+ if (useSameModel) {
194
+ compactionProviderID = baseProviderID;
195
+ compactionModelID = baseModelID;
196
+ Log.Default.info(() => ({
197
+ message:
198
+ 'compaction model set to "same" — using base model for compaction',
199
+ compactionProviderID,
200
+ compactionModelID,
201
+ }));
202
+ } else if (compactionModelArg.includes('/')) {
203
+ const parts = compactionModelArg.split('/');
204
+ compactionProviderID = parts[0];
205
+ compactionModelID = parts.slice(1).join('/');
206
+ Log.Default.info(() => ({
207
+ message: 'using explicit compaction model',
208
+ compactionProviderID,
209
+ compactionModelID,
210
+ }));
211
+ } else {
212
+ // Short name resolution
213
+ const { Provider } = await import('../provider/provider.ts');
214
+ const resolved =
215
+ await Provider.parseModelWithResolution(compactionModelArg);
216
+ compactionProviderID = resolved.providerID;
217
+ compactionModelID = resolved.modelID;
218
+ Log.Default.info(() => ({
219
+ message: 'resolved short compaction model name',
220
+ input: compactionModelArg,
221
+ compactionProviderID,
222
+ compactionModelID,
223
+ }));
224
+ }
225
+
226
+ return {
227
+ providerID: compactionProviderID,
228
+ modelID: compactionModelID,
229
+ useSameModel,
230
+ compactionSafetyMarginPercent,
231
+ };
148
232
  }
@@ -0,0 +1,163 @@
1
+ import {
2
+ DEFAULT_MODEL,
3
+ DEFAULT_COMPACTION_MODEL,
4
+ DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
5
+ } from './defaults.ts';
6
+
7
+ /**
8
+ * Yargs builder for the default `run` command options.
9
+ * Extracted from index.js to keep file size under 1000 lines.
10
+ */
11
+ export function buildRunOptions(yargs) {
12
+ return yargs
13
+ .option('model', {
14
+ type: 'string',
15
+ description: 'Model to use in format providerID/modelID',
16
+ default: DEFAULT_MODEL,
17
+ })
18
+ .option('json-standard', {
19
+ type: 'string',
20
+ description:
21
+ 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
22
+ default: 'opencode',
23
+ choices: ['opencode', 'claude'],
24
+ })
25
+ .option('system-message', {
26
+ type: 'string',
27
+ description: 'Full override of the system message',
28
+ })
29
+ .option('system-message-file', {
30
+ type: 'string',
31
+ description: 'Full override of the system message from file',
32
+ })
33
+ .option('append-system-message', {
34
+ type: 'string',
35
+ description: 'Append to the default system message',
36
+ })
37
+ .option('append-system-message-file', {
38
+ type: 'string',
39
+ description: 'Append to the default system message from file',
40
+ })
41
+ .option('server', {
42
+ type: 'boolean',
43
+ description: 'Run in server mode (default)',
44
+ default: true,
45
+ })
46
+ .option('verbose', {
47
+ type: 'boolean',
48
+ description:
49
+ 'Enable verbose mode to debug API requests (shows system prompt, token counts, etc.)',
50
+ default: false,
51
+ })
52
+ .option('dry-run', {
53
+ type: 'boolean',
54
+ description:
55
+ 'Simulate operations without making actual API calls or package installations (useful for testing)',
56
+ default: false,
57
+ })
58
+ .option('use-existing-claude-oauth', {
59
+ type: 'boolean',
60
+ description:
61
+ 'Use existing Claude OAuth credentials from ~/.claude/.credentials.json (from Claude Code CLI)',
62
+ default: false,
63
+ })
64
+ .option('prompt', {
65
+ alias: 'p',
66
+ type: 'string',
67
+ description: 'Prompt message to send directly (bypasses stdin reading)',
68
+ })
69
+ .option('disable-stdin', {
70
+ type: 'boolean',
71
+ description:
72
+ 'Disable stdin streaming mode (requires --prompt or shows help)',
73
+ default: false,
74
+ })
75
+ .option('stdin-stream-timeout', {
76
+ type: 'number',
77
+ description:
78
+ 'Optional timeout in milliseconds for stdin reading (default: no timeout)',
79
+ })
80
+ .option('auto-merge-queued-messages', {
81
+ type: 'boolean',
82
+ description:
83
+ 'Enable auto-merging of rapidly arriving input lines into single messages (default: true)',
84
+ default: true,
85
+ })
86
+ .option('interactive', {
87
+ type: 'boolean',
88
+ description:
89
+ 'Enable interactive mode to accept manual input as plain text strings (default: true). Use --no-interactive to only accept JSON input.',
90
+ default: true,
91
+ })
92
+ .option('always-accept-stdin', {
93
+ type: 'boolean',
94
+ description:
95
+ 'Keep accepting stdin input even after the agent finishes work (default: true). Use --no-always-accept-stdin for single-message mode.',
96
+ default: true,
97
+ })
98
+ .option('compact-json', {
99
+ type: 'boolean',
100
+ description:
101
+ 'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
102
+ default: false,
103
+ })
104
+ .option('resume', {
105
+ alias: 'r',
106
+ type: 'string',
107
+ description:
108
+ 'Resume a specific session by ID. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
109
+ })
110
+ .option('continue', {
111
+ alias: 'c',
112
+ type: 'boolean',
113
+ description:
114
+ 'Continue the most recent session. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
115
+ default: false,
116
+ })
117
+ .option('no-fork', {
118
+ type: 'boolean',
119
+ description:
120
+ 'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
121
+ default: false,
122
+ })
123
+ .option('generate-title', {
124
+ type: 'boolean',
125
+ description:
126
+ 'Generate session titles using AI (default: false). Disabling saves tokens and prevents rate limit issues.',
127
+ default: false,
128
+ })
129
+ .option('retry-timeout', {
130
+ type: 'number',
131
+ description:
132
+ 'Maximum total retry time in seconds for rate limit errors (default: 604800 = 7 days)',
133
+ })
134
+ .option('retry-on-rate-limits', {
135
+ type: 'boolean',
136
+ description:
137
+ 'Retry AI completions API requests when rate limited (HTTP 429). Use --no-retry-on-rate-limits in integration tests to fail fast instead of waiting.',
138
+ default: true,
139
+ })
140
+ .option('output-response-model', {
141
+ type: 'boolean',
142
+ description: 'Include model info in step_finish output',
143
+ default: true,
144
+ })
145
+ .option('summarize-session', {
146
+ type: 'boolean',
147
+ description:
148
+ 'Generate AI session summaries (default: true). Use --no-summarize-session to disable.',
149
+ default: true,
150
+ })
151
+ .option('compaction-model', {
152
+ type: 'string',
153
+ description:
154
+ 'Model to use for context compaction in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context).',
155
+ default: DEFAULT_COMPACTION_MODEL,
156
+ })
157
+ .option('compaction-safety-margin', {
158
+ type: 'number',
159
+ description:
160
+ 'Safety margin (%) of usable context window before triggering compaction. Only applies when the compaction model has equal or smaller context than the base model. Default: 15.',
161
+ default: DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
162
+ });
163
+ }
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { Server } from './server/server.ts';
6
6
  import { Instance } from './project/instance.ts';
7
7
  import { Log } from './util/log.ts';
8
8
  import { parseModelConfig } from './cli/model-config.js';
9
- import { DEFAULT_MODEL } from './cli/defaults.ts';
9
+ import { buildRunOptions } from './cli/run-options.js';
10
10
  // Bus is used via createBusEventSubscription in event-handler.js
11
11
  import { Session } from './session/index.ts';
12
12
  import { SessionPrompt } from './session/prompt.ts';
@@ -278,7 +278,7 @@ async function runAgentMode(argv, request) {
278
278
  fn: async () => {
279
279
  // Parse model config inside Instance.provide context
280
280
  // This allows parseModelWithResolution to access the provider state
281
- const { providerID, modelID } = await parseModelConfig(
281
+ const { providerID, modelID, compactionModel } = await parseModelConfig(
282
282
  argv,
283
283
  outputError,
284
284
  outputStatus
@@ -293,7 +293,8 @@ async function runAgentMode(argv, request) {
293
293
  modelID,
294
294
  systemMessage,
295
295
  appendSystemMessage,
296
- jsonStandard
296
+ jsonStandard,
297
+ compactionModel
297
298
  );
298
299
  } else {
299
300
  // DIRECT MODE: Run everything in single process
@@ -304,7 +305,8 @@ async function runAgentMode(argv, request) {
304
305
  modelID,
305
306
  systemMessage,
306
307
  appendSystemMessage,
307
- jsonStandard
308
+ jsonStandard,
309
+ compactionModel
308
310
  );
309
311
  }
310
312
  },
@@ -363,7 +365,7 @@ async function runContinuousAgentMode(argv) {
363
365
  fn: async () => {
364
366
  // Parse model config inside Instance.provide context
365
367
  // This allows parseModelWithResolution to access the provider state
366
- const { providerID, modelID } = await parseModelConfig(
368
+ const { providerID, modelID, compactionModel } = await parseModelConfig(
367
369
  argv,
368
370
  outputError,
369
371
  outputStatus
@@ -377,7 +379,8 @@ async function runContinuousAgentMode(argv) {
377
379
  modelID,
378
380
  systemMessage,
379
381
  appendSystemMessage,
380
- jsonStandard
382
+ jsonStandard,
383
+ compactionModel
381
384
  );
382
385
  } else {
383
386
  // DIRECT MODE: Run everything in single process
@@ -387,7 +390,8 @@ async function runContinuousAgentMode(argv) {
387
390
  modelID,
388
391
  systemMessage,
389
392
  appendSystemMessage,
390
- jsonStandard
393
+ jsonStandard,
394
+ compactionModel
391
395
  );
392
396
  }
393
397
  },
@@ -409,7 +413,8 @@ async function runServerMode(
409
413
  modelID,
410
414
  systemMessage,
411
415
  appendSystemMessage,
412
- jsonStandard
416
+ jsonStandard,
417
+ compactionModel
413
418
  ) {
414
419
  const compactJson = argv['compact-json'] === true;
415
420
 
@@ -475,6 +480,7 @@ async function runServerMode(
475
480
  providerID,
476
481
  modelID,
477
482
  },
483
+ compactionModel,
478
484
  system: systemMessage,
479
485
  appendSystem: appendSystemMessage,
480
486
  }),
@@ -508,7 +514,8 @@ async function runDirectMode(
508
514
  modelID,
509
515
  systemMessage,
510
516
  appendSystemMessage,
511
- jsonStandard
517
+ jsonStandard,
518
+ compactionModel
512
519
  ) {
513
520
  const compactJson = argv['compact-json'] === true;
514
521
 
@@ -558,6 +565,7 @@ async function runDirectMode(
558
565
  providerID,
559
566
  modelID,
560
567
  },
568
+ compactionModel,
561
569
  system: systemMessage,
562
570
  appendSystem: appendSystemMessage,
563
571
  }).catch((error) => {
@@ -596,147 +604,7 @@ async function main() {
596
604
  .command({
597
605
  command: '$0',
598
606
  describe: 'Run agent in interactive or stdin mode (default)',
599
- builder: (yargs) =>
600
- yargs
601
- .option('model', {
602
- type: 'string',
603
- description: 'Model to use in format providerID/modelID',
604
- default: DEFAULT_MODEL,
605
- })
606
- .option('json-standard', {
607
- type: 'string',
608
- description:
609
- 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
610
- default: 'opencode',
611
- choices: ['opencode', 'claude'],
612
- })
613
- .option('system-message', {
614
- type: 'string',
615
- description: 'Full override of the system message',
616
- })
617
- .option('system-message-file', {
618
- type: 'string',
619
- description: 'Full override of the system message from file',
620
- })
621
- .option('append-system-message', {
622
- type: 'string',
623
- description: 'Append to the default system message',
624
- })
625
- .option('append-system-message-file', {
626
- type: 'string',
627
- description: 'Append to the default system message from file',
628
- })
629
- .option('server', {
630
- type: 'boolean',
631
- description: 'Run in server mode (default)',
632
- default: true,
633
- })
634
- .option('verbose', {
635
- type: 'boolean',
636
- description:
637
- 'Enable verbose mode to debug API requests (shows system prompt, token counts, etc.)',
638
- default: false,
639
- })
640
- .option('dry-run', {
641
- type: 'boolean',
642
- description:
643
- 'Simulate operations without making actual API calls or package installations (useful for testing)',
644
- default: false,
645
- })
646
- .option('use-existing-claude-oauth', {
647
- type: 'boolean',
648
- description:
649
- 'Use existing Claude OAuth credentials from ~/.claude/.credentials.json (from Claude Code CLI)',
650
- default: false,
651
- })
652
- .option('prompt', {
653
- alias: 'p',
654
- type: 'string',
655
- description:
656
- 'Prompt message to send directly (bypasses stdin reading)',
657
- })
658
- .option('disable-stdin', {
659
- type: 'boolean',
660
- description:
661
- 'Disable stdin streaming mode (requires --prompt or shows help)',
662
- default: false,
663
- })
664
- .option('stdin-stream-timeout', {
665
- type: 'number',
666
- description:
667
- 'Optional timeout in milliseconds for stdin reading (default: no timeout)',
668
- })
669
- .option('auto-merge-queued-messages', {
670
- type: 'boolean',
671
- description:
672
- 'Enable auto-merging of rapidly arriving input lines into single messages (default: true)',
673
- default: true,
674
- })
675
- .option('interactive', {
676
- type: 'boolean',
677
- description:
678
- 'Enable interactive mode to accept manual input as plain text strings (default: true). Use --no-interactive to only accept JSON input.',
679
- default: true,
680
- })
681
- .option('always-accept-stdin', {
682
- type: 'boolean',
683
- description:
684
- 'Keep accepting stdin input even after the agent finishes work (default: true). Use --no-always-accept-stdin for single-message mode.',
685
- default: true,
686
- })
687
- .option('compact-json', {
688
- type: 'boolean',
689
- description:
690
- 'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
691
- default: false,
692
- })
693
- .option('resume', {
694
- alias: 'r',
695
- type: 'string',
696
- description:
697
- 'Resume a specific session by ID. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
698
- })
699
- .option('continue', {
700
- alias: 'c',
701
- type: 'boolean',
702
- description:
703
- 'Continue the most recent session. By default, forks the session with a new UUID. Use --no-fork to continue in the same session.',
704
- default: false,
705
- })
706
- .option('no-fork', {
707
- type: 'boolean',
708
- description:
709
- 'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
710
- default: false,
711
- })
712
- .option('generate-title', {
713
- type: 'boolean',
714
- description:
715
- 'Generate session titles using AI (default: false). Disabling saves tokens and prevents rate limit issues.',
716
- default: false,
717
- })
718
- .option('retry-timeout', {
719
- type: 'number',
720
- description:
721
- 'Maximum total retry time in seconds for rate limit errors (default: 604800 = 7 days)',
722
- })
723
- .option('retry-on-rate-limits', {
724
- type: 'boolean',
725
- description:
726
- 'Retry AI completions API requests when rate limited (HTTP 429). Use --no-retry-on-rate-limits in integration tests to fail fast instead of waiting.',
727
- default: true,
728
- })
729
- .option('output-response-model', {
730
- type: 'boolean',
731
- description: 'Include model info in step_finish output',
732
- default: true,
733
- })
734
- .option('summarize-session', {
735
- type: 'boolean',
736
- description:
737
- 'Generate AI session summaries (default: true). Use --no-summarize-session to disable.',
738
- default: true,
739
- }),
607
+ builder: buildRunOptions,
740
608
  handler: async (argv) => {
741
609
  // Check both CLI flag and environment variable for compact JSON mode
742
610
  const compactJson =
@@ -934,8 +802,14 @@ async function main() {
934
802
  compactJson: isCompact,
935
803
  });
936
804
 
937
- // Monkey-patch globalThis.fetch for raw HTTP logging in --verbose mode.
938
- // Catches ALL HTTP calls regardless of AI SDK fetch passthrough. (#217)
805
+ // Global fetch monkey-patch for verbose HTTP logging (#221).
806
+ // This catches any HTTP calls that go through globalThis.fetch directly,
807
+ // including non-provider calls (auth, config, tools) that may not have
808
+ // their own createVerboseFetch wrapper. The provider-level wrapper in
809
+ // provider.ts getSDK() also logs independently — both mechanisms are
810
+ // kept active to maximize HTTP observability in --verbose mode.
811
+ // See: https://github.com/link-assistant/agent/issues/221
812
+ // See: https://github.com/link-assistant/agent/issues/217
939
813
  if (!globalThis.__agentVerboseFetchInstalled) {
940
814
  globalThis.fetch = createVerboseFetch(globalThis.fetch, {
941
815
  caller: 'global',
@@ -1201,11 +1201,13 @@ export namespace Provider {
1201
1201
  sessionID: provider.id,
1202
1202
  });
1203
1203
 
1204
- // Verbose HTTP logging is handled by the global fetch monkey-patch
1205
- // (installed in CLI middleware in index.js). The global patch catches ALL
1206
- // HTTP calls reliably, regardless of how the AI SDK passes fetch internally.
1207
- // This provider-level wrapper is kept as a fallback for environments where
1208
- // the global patch may not be installed (e.g., programmatic use).
1204
+ // Verbose HTTP logging for provider API calls.
1205
+ // This provider-level wrapper logs HTTP requests/responses independently
1206
+ // of the global fetch monkey-patch. Both mechanisms are kept active to
1207
+ // maximize HTTP observability the global patch may miss calls if the
1208
+ // AI SDK captures/resolves fetch references before it is installed,
1209
+ // while this wrapper is injected directly into the SDK's fetch option.
1210
+ // See: https://github.com/link-assistant/agent/issues/221
1209
1211
  // See: https://github.com/link-assistant/agent/issues/217
1210
1212
  // See: https://github.com/link-assistant/agent/issues/215
1211
1213
  {
@@ -1226,14 +1228,9 @@ export namespace Provider {
1226
1228
  init?: RequestInit
1227
1229
  ): Promise<Response> => {
1228
1230
  // Check verbose flag at call time — not at SDK creation time.
1229
- // When the global fetch monkey-patch is installed, it handles verbose
1230
- // logging for all calls. The provider wrapper is a fallback for
1231
- // environments without the global patch.
1232
- // See: https://github.com/link-assistant/agent/issues/217
1233
- if (
1234
- !Flag.OPENCODE_VERBOSE ||
1235
- globalThis.__agentVerboseFetchInstalled
1236
- ) {
1231
+ // This ensures --verbose works even when the flag is set after SDK creation.
1232
+ // See: https://github.com/link-assistant/agent/issues/206
1233
+ if (!Flag.OPENCODE_VERBOSE) {
1237
1234
  return innerFetch(input, init);
1238
1235
  }
1239
1236
 
@@ -29,36 +29,101 @@ export namespace SessionCompaction {
29
29
  };
30
30
 
31
31
  /**
32
- * Safety margin ratio for compaction trigger.
32
+ * Default safety margin ratio for compaction trigger.
33
33
  * We trigger compaction at 85% of usable context to avoid hitting hard limits.
34
34
  * This means we stop 15% before (context - output) tokens.
35
35
  * @see https://github.com/link-assistant/agent/issues/217
36
36
  */
37
37
  export const OVERFLOW_SAFETY_MARGIN = 0.85;
38
38
 
39
+ /**
40
+ * Compaction model configuration passed from CLI.
41
+ * @see https://github.com/link-assistant/agent/issues/219
42
+ */
43
+ export interface CompactionModelConfig {
44
+ providerID: string;
45
+ modelID: string;
46
+ useSameModel: boolean;
47
+ compactionSafetyMarginPercent: number;
48
+ }
49
+
50
+ /**
51
+ * Compute the effective safety margin ratio.
52
+ *
53
+ * When the compaction model has a larger context window than the base model,
54
+ * the entire base model context can be used (ratio = 1.0, i.e. 0% margin),
55
+ * because the compaction model can ingest all of it.
56
+ *
57
+ * When the compaction model has equal or smaller context, the configured
58
+ * safety margin applies (default 15% → ratio 0.85).
59
+ *
60
+ * @see https://github.com/link-assistant/agent/issues/219
61
+ */
62
+ export function computeSafetyMarginRatio(input: {
63
+ baseModelContextLimit: number;
64
+ compactionModel?: CompactionModelConfig;
65
+ compactionModelContextLimit?: number;
66
+ }): number {
67
+ const compactionModelConfig = input.compactionModel;
68
+ if (!compactionModelConfig) return OVERFLOW_SAFETY_MARGIN;
69
+
70
+ const compactionSafetyMarginPercent =
71
+ compactionModelConfig.compactionSafetyMarginPercent;
72
+ const configuredRatio = 1 - compactionSafetyMarginPercent / 100;
73
+
74
+ // When using the same model, always apply the configured safety margin
75
+ if (compactionModelConfig.useSameModel) return configuredRatio;
76
+
77
+ // When compaction model has a larger context, no safety margin needed
78
+ const compactionContextLimit = input.compactionModelContextLimit ?? 0;
79
+ if (
80
+ compactionContextLimit > 0 &&
81
+ compactionContextLimit > input.baseModelContextLimit
82
+ ) {
83
+ log.info(() => ({
84
+ message:
85
+ 'compaction model has larger context — using full base model context',
86
+ baseModelContextLimit: input.baseModelContextLimit,
87
+ compactionModelContextLimit: compactionContextLimit,
88
+ }));
89
+ return 1.0;
90
+ }
91
+
92
+ return configuredRatio;
93
+ }
94
+
39
95
  export function isOverflow(input: {
40
96
  tokens: MessageV2.Assistant['tokens'];
41
97
  model: ModelsDev.Model;
98
+ compactionModel?: CompactionModelConfig;
99
+ compactionModelContextLimit?: number;
42
100
  }) {
43
101
  if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false;
44
- const context = input.model.limit.context;
45
- if (context === 0) return false;
102
+ const baseModelContextLimit = input.model.limit.context;
103
+ if (baseModelContextLimit === 0) return false;
46
104
  const count =
47
105
  input.tokens.input + input.tokens.cache.read + input.tokens.output;
48
- const output =
106
+ const outputTokenLimit =
49
107
  Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) ||
50
108
  SessionPrompt.OUTPUT_TOKEN_MAX;
51
- const usable = context - output;
52
- const safeLimit = Math.floor(usable * OVERFLOW_SAFETY_MARGIN);
109
+ const usableContextWindow = baseModelContextLimit - outputTokenLimit;
110
+ const safetyMarginRatio = computeSafetyMarginRatio({
111
+ baseModelContextLimit,
112
+ compactionModel: input.compactionModel,
113
+ compactionModelContextLimit: input.compactionModelContextLimit,
114
+ });
115
+ const safeLimit = Math.floor(usableContextWindow * safetyMarginRatio);
53
116
  const overflow = count > safeLimit;
54
117
  log.info(() => ({
55
118
  message: 'overflow check',
56
119
  modelID: input.model.id,
57
- contextLimit: context,
58
- outputLimit: output,
59
- usableContext: usable,
120
+ contextLimit: baseModelContextLimit,
121
+ outputLimit: outputTokenLimit,
122
+ usableContextWindow,
60
123
  safeLimit,
61
- safetyMargin: OVERFLOW_SAFETY_MARGIN,
124
+ safetyMarginRatio,
125
+ compactionModelID: input.compactionModel?.modelID,
126
+ compactionModelContextLimit: input.compactionModelContextLimit,
62
127
  currentTokens: count,
63
128
  tokensBreakdown: {
64
129
  input: input.tokens.input,
@@ -79,6 +144,8 @@ export namespace SessionCompaction {
79
144
  export function contextDiagnostics(input: {
80
145
  tokens: { input: number; output: number; cache: { read: number } };
81
146
  model: ModelsDev.Model;
147
+ compactionModel?: CompactionModelConfig;
148
+ compactionModelContextLimit?: number;
82
149
  }): MessageV2.ContextDiagnostics | undefined {
83
150
  const contextLimit = input.model.limit.context;
84
151
  if (contextLimit === 0) return undefined;
@@ -86,7 +153,12 @@ export namespace SessionCompaction {
86
153
  Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) ||
87
154
  SessionPrompt.OUTPUT_TOKEN_MAX;
88
155
  const usableContext = contextLimit - outputLimit;
89
- const safeLimit = Math.floor(usableContext * OVERFLOW_SAFETY_MARGIN);
156
+ const safetyMarginRatio = computeSafetyMarginRatio({
157
+ baseModelContextLimit: contextLimit,
158
+ compactionModel: input.compactionModel,
159
+ compactionModelContextLimit: input.compactionModelContextLimit,
160
+ });
161
+ const safeLimit = Math.floor(usableContext * safetyMarginRatio);
90
162
  const currentTokens =
91
163
  input.tokens.input + input.tokens.cache.read + input.tokens.output;
92
164
  return {
@@ -94,7 +166,7 @@ export namespace SessionCompaction {
94
166
  outputLimit,
95
167
  usableContext,
96
168
  safeLimit,
97
- safetyMargin: OVERFLOW_SAFETY_MARGIN,
169
+ safetyMargin: safetyMarginRatio,
98
170
  currentTokens,
99
171
  headroom: safeLimit - currentTokens,
100
172
  overflow: currentTokens > safeLimit,
@@ -392,6 +392,14 @@ export namespace MessageV2 {
392
392
  providerID: z.string(),
393
393
  modelID: z.string(),
394
394
  }),
395
+ compactionModel: z
396
+ .object({
397
+ providerID: z.string(),
398
+ modelID: z.string(),
399
+ useSameModel: z.boolean(),
400
+ compactionSafetyMarginPercent: z.number(),
401
+ })
402
+ .optional(),
395
403
  system: z.string().optional(),
396
404
  appendSystem: z.string().optional(),
397
405
  tools: z.record(z.string(), z.boolean()).optional(),
@@ -89,6 +89,14 @@ export namespace SessionPrompt {
89
89
  modelID: z.string(),
90
90
  })
91
91
  .optional(),
92
+ compactionModel: z
93
+ .object({
94
+ providerID: z.string(),
95
+ modelID: z.string(),
96
+ useSameModel: z.boolean(),
97
+ compactionSafetyMarginPercent: z.number(),
98
+ })
99
+ .optional(),
92
100
  agent: z.string().optional(),
93
101
  noReply: z.boolean().optional(),
94
102
  system: z.string().optional(),
@@ -396,6 +404,28 @@ export namespace SessionPrompt {
396
404
  // Re-throw the error so it can be handled by the caller
397
405
  throw error;
398
406
  }
407
+ // Resolve compaction model context limit for overflow detection (#219)
408
+ let compactionModelContextLimit: number | undefined;
409
+ const compactionModelConfig = lastUser.compactionModel;
410
+ if (compactionModelConfig && !compactionModelConfig.useSameModel) {
411
+ try {
412
+ const compactionModelResolved = await Provider.getModel(
413
+ compactionModelConfig.providerID,
414
+ compactionModelConfig.modelID
415
+ );
416
+ compactionModelContextLimit =
417
+ compactionModelResolved.info?.limit?.context;
418
+ } catch {
419
+ // If compaction model can't be resolved, fall back to default safety margin
420
+ log.info(() => ({
421
+ message:
422
+ 'could not resolve compaction model for context limit — using default safety margin',
423
+ compactionProviderID: compactionModelConfig.providerID,
424
+ compactionModelID: compactionModelConfig.modelID,
425
+ }));
426
+ }
427
+ }
428
+
399
429
  const task = tasks.pop();
400
430
 
401
431
  // pending subtask
@@ -512,13 +542,23 @@ export namespace SessionPrompt {
512
542
 
513
543
  // pending compaction
514
544
  if (task?.type === 'compaction') {
545
+ // Use compaction model if configured, otherwise fall back to base model
546
+ const compactionModelConfig = lastUser.compactionModel;
547
+ const compactionProviderID =
548
+ compactionModelConfig && !compactionModelConfig.useSameModel
549
+ ? compactionModelConfig.providerID
550
+ : model.providerID;
551
+ const compactionModelID =
552
+ compactionModelConfig && !compactionModelConfig.useSameModel
553
+ ? compactionModelConfig.modelID
554
+ : model.modelID;
515
555
  const result = await SessionCompaction.process({
516
556
  messages: msgs,
517
557
  parentID: lastUser.id,
518
558
  abort,
519
559
  model: {
520
- providerID: model.providerID,
521
- modelID: model.modelID,
560
+ providerID: compactionProviderID,
561
+ modelID: compactionModelID,
522
562
  },
523
563
  sessionID,
524
564
  });
@@ -533,6 +573,8 @@ export namespace SessionPrompt {
533
573
  SessionCompaction.isOverflow({
534
574
  tokens: lastFinished.tokens,
535
575
  model: model.info ?? { id: model.modelID },
576
+ compactionModel: lastUser.compactionModel,
577
+ compactionModelContextLimit,
536
578
  })
537
579
  ) {
538
580
  await SessionCompaction.create({
@@ -1053,6 +1095,7 @@ export namespace SessionPrompt {
1053
1095
  model: input.model,
1054
1096
  agent,
1055
1097
  }),
1098
+ compactionModel: input.compactionModel,
1056
1099
  };
1057
1100
 
1058
1101
  const parts = await Promise.all(