@link-assistant/agent 0.26.2 → 0.26.3

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.26.2",
3
+ "version": "0.26.3",
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",
@@ -12,7 +12,9 @@ import {
12
12
  getDefaultModelParts,
13
13
  getDefaultModelSource,
14
14
  getDefaultCompactionModel,
15
+ getDefaultCompactionModelSource,
15
16
  getDefaultCompactionModels,
17
+ getDefaultCompactionModelsSource,
16
18
  getDefaultCompactionSafetyMarginPercent,
17
19
  } from './defaults.ts';
18
20
  import { outputModelResolved } from './model-resolution.ts';
@@ -356,6 +358,64 @@ async function resolveCompactionModelEntry(
356
358
  };
357
359
  }
358
360
 
361
+ /**
362
+ * Narrow a built-in compaction default to the provider the run was pointed at.
363
+ *
364
+ * The shipped cascade names OpenCode models, because the shipped base model is
365
+ * an OpenCode one. A run launched with `--model formalai/formal-ai` inherited
366
+ * that cascade unchanged, so compaction — and the session summary, which reuses
367
+ * the compaction model — called a provider the operator may hold no credentials
368
+ * for. Every one of those calls fails, and the failures read as the run having
369
+ * failed.
370
+ *
371
+ * Only the *built-in* defaults are narrowed, and only for a run pointed away
372
+ * from the built-in default provider. A cascade the operator named
373
+ * (`--compaction-model`, `--compaction-models`, or the
374
+ * `LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODEL*` env vars) is an explicit
375
+ * choice and is left alone, cross-provider or not; and a run on the shipped
376
+ * provider keeps the shipped cascade, whose extra entries are the rate-limit
377
+ * fallbacks it was written for.
378
+ *
379
+ * @param {{providerID: string, modelID: string, useSameModel: boolean}[]} entries
380
+ * @param {string} baseProviderID Provider of the model that runs the turns
381
+ * @param {string} baseModelID Model that runs the turns
382
+ * @returns {{providerID: string, modelID: string, useSameModel: boolean}[]}
383
+ * @see https://github.com/link-assistant/agent/issues/307
384
+ */
385
+ function inheritBaseProvider(entries, baseProviderID, baseModelID) {
386
+ const kept = entries.filter(
387
+ (entry) => entry.useSameModel || entry.providerID === baseProviderID
388
+ );
389
+ const dropped = entries.filter((entry) => !kept.includes(entry));
390
+ if (dropped.length === 0) {
391
+ return entries;
392
+ }
393
+
394
+ const inherited =
395
+ kept.length > 0
396
+ ? kept
397
+ : [
398
+ {
399
+ providerID: baseProviderID,
400
+ modelID: baseModelID,
401
+ useSameModel: true,
402
+ },
403
+ ];
404
+
405
+ Log.Default.info(() => ({
406
+ message: 'default compaction models narrowed to the configured provider',
407
+ hint: 'Secondary calls (compaction, session summary) inherit --model unless a compaction model is named explicitly',
408
+ baseProviderID,
409
+ baseModelID,
410
+ dropped: dropped.map((entry) => `${entry.providerID}/${entry.modelID}`),
411
+ kept: inherited.map((entry) =>
412
+ entry.useSameModel ? 'same' : `${entry.providerID}/${entry.modelID}`
413
+ ),
414
+ }));
415
+
416
+ return inherited;
417
+ }
418
+
359
419
  /**
360
420
  * Parse compaction model config from argv.
361
421
  * Supports both --compaction-model (single) and --compaction-models (cascade).
@@ -363,6 +423,7 @@ async function resolveCompactionModelEntry(
363
423
  * The special value "same" means use the base model for compaction.
364
424
  * @see https://github.com/link-assistant/agent/issues/219
365
425
  * @see https://github.com/link-assistant/agent/issues/232
426
+ * @see https://github.com/link-assistant/agent/issues/307
366
427
  */
367
428
  async function parseCompactionModelConfig(
368
429
  argv,
@@ -425,9 +486,18 @@ async function parseCompactionModelConfig(
425
486
  }
426
487
  }
427
488
 
489
+ // A cascade nobody asked for must not reach across providers (#307).
490
+ const usingBuiltInCascade =
491
+ compactionModelsSource === 'default' &&
492
+ getDefaultCompactionModelsSource(defaultOptions) === 'default' &&
493
+ baseProviderID !== DEFAULT_PROVIDER_ID;
494
+ const cascade = usingBuiltInCascade
495
+ ? inheritBaseProvider(compactionModels, baseProviderID, baseModelID)
496
+ : compactionModels;
497
+
428
498
  Log.Default.info(() => ({
429
499
  message: 'compaction models cascade configured',
430
- models: compactionModels.map((m) =>
500
+ models: cascade.map((m) =>
431
501
  m.useSameModel ? 'same' : `${m.providerID}/${m.modelID}`
432
502
  ),
433
503
  source: compactionModelsSource,
@@ -435,7 +505,7 @@ async function parseCompactionModelConfig(
435
505
 
436
506
  // Use the first model as the primary compaction model (for backward compatibility)
437
507
  // The full cascade is stored in compactionModels array
438
- const primary = compactionModels[0] || {
508
+ const primary = cascade[0] || {
439
509
  providerID: baseProviderID,
440
510
  modelID: baseModelID,
441
511
  useSameModel: true,
@@ -446,16 +516,21 @@ async function parseCompactionModelConfig(
446
516
  modelID: primary.modelID,
447
517
  useSameModel: primary.useSameModel,
448
518
  compactionSafetyMarginPercent,
449
- compactionModels,
519
+ compactionModels: cascade,
450
520
  };
451
521
  }
452
522
 
453
523
  // Fallback to single --compaction-model
454
524
  const cliCompactionModelArg = getCompactionModelFromProcessArgv();
525
+ const defaultCompactionModel = getDefaultCompactionModel(defaultOptions);
526
+ const compactionModelSource =
527
+ cliCompactionModelArg ||
528
+ (argv['compaction-model'] &&
529
+ argv['compaction-model'] !== defaultCompactionModel)
530
+ ? 'cli'
531
+ : 'default';
455
532
  const compactionModelArg =
456
- cliCompactionModelArg ??
457
- argv['compaction-model'] ??
458
- getDefaultCompactionModel(defaultOptions);
533
+ cliCompactionModelArg ?? argv['compaction-model'] ?? defaultCompactionModel;
459
534
 
460
535
  const resolved = await resolveCompactionModelEntry(
461
536
  compactionModelArg,
@@ -463,23 +538,33 @@ async function parseCompactionModelConfig(
463
538
  baseModelID
464
539
  );
465
540
 
541
+ // Same rule as the cascade: an unrequested default follows --model (#307).
542
+ const usingBuiltInCompactionModel =
543
+ compactionModelSource === 'default' &&
544
+ getDefaultCompactionModelSource(defaultOptions) === 'default' &&
545
+ baseProviderID !== DEFAULT_PROVIDER_ID;
546
+ const [entry] = usingBuiltInCompactionModel
547
+ ? inheritBaseProvider([resolved], baseProviderID, baseModelID)
548
+ : [resolved];
549
+
466
550
  Log.Default.info(() => ({
467
551
  message: 'using single compaction model',
468
- compactionProviderID: resolved.providerID,
469
- compactionModelID: resolved.modelID,
470
- useSameModel: resolved.useSameModel,
552
+ compactionProviderID: entry.providerID,
553
+ compactionModelID: entry.modelID,
554
+ useSameModel: entry.useSameModel,
555
+ source: compactionModelSource,
471
556
  }));
472
557
 
473
558
  return {
474
- providerID: resolved.providerID,
475
- modelID: resolved.modelID,
476
- useSameModel: resolved.useSameModel,
559
+ providerID: entry.providerID,
560
+ modelID: entry.modelID,
561
+ useSameModel: entry.useSameModel,
477
562
  compactionSafetyMarginPercent,
478
563
  compactionModels: [
479
564
  {
480
- providerID: resolved.providerID,
481
- modelID: resolved.modelID,
482
- useSameModel: resolved.useSameModel,
565
+ providerID: entry.providerID,
566
+ modelID: entry.modelID,
567
+ useSameModel: entry.useSameModel,
483
568
  },
484
569
  ],
485
570
  };
@@ -184,13 +184,13 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
184
184
  .option('summarize-session', {
185
185
  type: 'boolean',
186
186
  description:
187
- 'Generate AI session summaries (default: true). Use --no-summarize-session to disable.',
187
+ 'Generate AI session summaries (default: true). Use --no-summarize-session to disable. Automatically off for a model declared as having no context limit (`unlimited: true` or `limit.context: null` in the provider config).',
188
188
  default: true,
189
189
  })
190
190
  .option('compaction-model', {
191
191
  type: 'string',
192
192
  description:
193
- '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). Overridden by --compaction-models if both are specified.',
193
+ 'Model to use for context compaction and session summaries, in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context), which falls back to the --model model when --model points at another provider. Overridden by --compaction-models if both are specified.',
194
194
  default: defaultCompactionModel,
195
195
  })
196
196
  .option('compaction-models', {
@@ -198,7 +198,8 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
198
198
  description:
199
199
  'Ordered cascade of compaction models in links notation sequence format: "(model1 model2 ... same)". ' +
200
200
  "Models are tried from smallest/cheapest context to largest. If used context exceeds a model's limit or its rate limit is reached, the next model is tried. " +
201
- 'The special value "same" uses the base model. Overrides --compaction-model when specified.',
201
+ 'The special value "same" uses the base model. Overrides --compaction-model when specified. ' +
202
+ 'The default cascade is OpenCode-specific: when --model points at another provider, its entries from other providers are dropped so secondary calls inherit --model. A cascade given explicitly is used as written.',
202
203
  default: defaultCompactionModels,
203
204
  })
204
205
  .option('compaction-safety-margin', {
@@ -142,6 +142,24 @@ export function getDefaultCompactionModel(
142
142
  );
143
143
  }
144
144
 
145
+ /**
146
+ * Report where the effective default compaction model came from.
147
+ *
148
+ * Same contract as `getDefaultModelSource`: `'config'` when an operator set
149
+ * `defaultOptions.defaultCompactionModel` or
150
+ * `LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODEL`, `'default'` when the
151
+ * built-in `DEFAULT_COMPACTION_MODEL` applies. Only a built-in default may be
152
+ * narrowed to the configured model's provider (#307).
153
+ */
154
+ export function getDefaultCompactionModelSource(
155
+ options: DefaultConfigOptions = {}
156
+ ): 'config' | 'default' {
157
+ const env = options.env ?? process.env;
158
+ if (optionString(options.defaultCompactionModel)) return 'config';
159
+ if (envString(env, DEFAULT_COMPACTION_MODEL_ENV)) return 'config';
160
+ return 'default';
161
+ }
162
+
145
163
  export function getDefaultCompactionModels(
146
164
  options: DefaultConfigOptions = {}
147
165
  ): string {
@@ -153,6 +171,20 @@ export function getDefaultCompactionModels(
153
171
  );
154
172
  }
155
173
 
174
+ /**
175
+ * Report where the effective default compaction cascade came from.
176
+ *
177
+ * @see getDefaultCompactionModelSource
178
+ */
179
+ export function getDefaultCompactionModelsSource(
180
+ options: DefaultConfigOptions = {}
181
+ ): 'config' | 'default' {
182
+ const env = options.env ?? process.env;
183
+ if (optionString(options.defaultCompactionModels)) return 'config';
184
+ if (envString(env, DEFAULT_COMPACTION_MODELS_ENV)) return 'config';
185
+ return 'default';
186
+ }
187
+
156
188
  export function getDefaultCompactionSafetyMarginPercent(
157
189
  options: DefaultConfigOptions = {}
158
190
  ): number {
@@ -834,7 +834,22 @@ export namespace Config {
834
834
  ModelsDev.Provider.partial()
835
835
  .extend({
836
836
  models: z
837
- .record(z.string(), ModelsDev.Model.partial())
837
+ .record(
838
+ z.string(),
839
+ ModelsDev.Model.partial().extend({
840
+ // `context: null` states that the model has no context
841
+ // window to run out of, which turns off compaction and
842
+ // session summarization for it (#307). `output` is
843
+ // optional here so `{ "context": null }` alone is a valid
844
+ // override.
845
+ limit: z
846
+ .object({
847
+ context: z.number().nullable().optional(),
848
+ output: z.number().optional(),
849
+ })
850
+ .optional(),
851
+ })
852
+ )
838
853
  .optional(),
839
854
  options: z
840
855
  .object({
@@ -42,6 +42,12 @@ export namespace ModelsDev {
42
42
  context: z.number(),
43
43
  output: z.number(),
44
44
  }),
45
+ unlimited: z
46
+ .boolean()
47
+ .optional()
48
+ .describe(
49
+ 'Model has no context limit. Compaction and session summarization are skipped for it. Also set by writing `limit.context: null` in a provider model override.'
50
+ ),
45
51
  modalities: z
46
52
  .object({
47
53
  input: z.array(z.enum(['text', 'audio', 'image', 'video', 'pdf'])),
@@ -59,6 +65,26 @@ export namespace ModelsDev {
59
65
  });
60
66
  export type Model = z.infer<typeof Model>;
61
67
 
68
+ /**
69
+ * Whether a model declares that it has no context window to run out of.
70
+ *
71
+ * An operator states this per model in the config, either as
72
+ * `"unlimited": true` or as `"limit": { "context": null }` — the two are
73
+ * normalized to the same flag when the config is merged into the model
74
+ * database.
75
+ *
76
+ * Compaction and session summarization both exist to keep a conversation
77
+ * inside a context window. For a model without one they have no work to do,
78
+ * so they are skipped instead of spending a secondary API call per turn.
79
+ *
80
+ * @see https://github.com/link-assistant/agent/issues/307
81
+ */
82
+ export function hasUnlimitedContext(
83
+ model: { unlimited?: boolean } | undefined | null
84
+ ): boolean {
85
+ return model?.unlimited === true;
86
+ }
87
+
62
88
  export const Provider = z
63
89
  .object({
64
90
  api: z.string().optional(),
@@ -1138,11 +1138,21 @@ export namespace Provider {
1138
1138
  ...existing?.options,
1139
1139
  ...model.options,
1140
1140
  },
1141
- limit: model.limit ??
1142
- existing?.limit ?? {
1143
- context: 0,
1144
- output: 0,
1145
- },
1141
+ limit: iife(() => {
1142
+ const limit = model.limit ?? existing?.limit;
1143
+ if (!limit) return { context: 0, output: 0 };
1144
+ // `context: null` is the config spelling of "no context limit";
1145
+ // the arithmetic downstream keeps reading 0 as "no window to
1146
+ // check", and the capability itself travels in `unlimited` (#307).
1147
+ return {
1148
+ context: limit.context ?? 0,
1149
+ output: limit.output ?? existing?.limit?.output ?? 0,
1150
+ };
1151
+ }),
1152
+ unlimited:
1153
+ model.unlimited ??
1154
+ (model.limit && model.limit.context === null ? true : undefined) ??
1155
+ existing?.unlimited,
1146
1156
  modalities: model.modalities ??
1147
1157
  existing?.modalities ?? {
1148
1158
  input: ['text'],
@@ -7,7 +7,7 @@ import { MessageV2 } from './message-v2';
7
7
  import { SystemPrompt } from './system';
8
8
  import { Bus } from '../bus';
9
9
  import z from 'zod';
10
- import type { ModelsDev } from '../provider/models';
10
+ import { ModelsDev } from '../provider/models';
11
11
  import { SessionPrompt } from './prompt';
12
12
  import { config, isVerbose } from '../config/config';
13
13
  import { Token } from '../util/token';
@@ -135,6 +135,8 @@ export namespace SessionCompaction {
135
135
  estimatedInputTokens?: number;
136
136
  }) {
137
137
  if (config.disableAutocompact) return false;
138
+ // A model without a context window can never overflow it (#307).
139
+ if (ModelsDev.hasUnlimitedContext(input.model)) return false;
138
140
  const baseModelContextLimit = input.model.limit.context;
139
141
  if (baseModelContextLimit === 0) return false;
140
142
  const providerCount =
@@ -193,6 +195,7 @@ export namespace SessionCompaction {
193
195
  compactionModel?: CompactionModelConfig;
194
196
  compactionModelContextLimit?: number;
195
197
  }): MessageV2.ContextDiagnostics | undefined {
198
+ if (ModelsDev.hasUnlimitedContext(input.model)) return undefined;
196
199
  const contextLimit = input.model.limit.context;
197
200
  if (contextLimit === 0) return undefined;
198
201
  const outputLimit =
@@ -14,6 +14,7 @@ import { Instance } from '../project/instance';
14
14
  import { Storage } from '../storage/storage';
15
15
  import { Bus } from '../bus';
16
16
  import { config, isVerbose } from '../config/config';
17
+ import { ModelsDev } from '../provider/models';
17
18
  import { Token } from '../util/token';
18
19
 
19
20
  export namespace SessionSummary {
@@ -112,6 +113,25 @@ export namespace SessionSummary {
112
113
  const assistantMsg = messages.find((m) => m.info.role === 'assistant')!
113
114
  .info as MessageV2.Assistant;
114
115
 
116
+ // Summarization keeps a conversation inside a context window. A model that
117
+ // declares it has none has nothing to summarize for, so the secondary call
118
+ // is skipped rather than spent — an integrator running such a model no
119
+ // longer has to pass --no-summarize-session.
120
+ // See: https://github.com/link-assistant/agent/issues/307
121
+ const baseModel = await Provider.getModel(
122
+ assistantMsg.providerID,
123
+ assistantMsg.modelID
124
+ ).catch(() => null);
125
+ if (baseModel && ModelsDev.hasUnlimitedContext(baseModel.info)) {
126
+ log.info(() => ({
127
+ message: 'session summarization skipped for an unlimited-context model',
128
+ hint: 'The model declares `unlimited: true` (or `limit.context: null`); the diff stat is still recorded',
129
+ providerID: assistantMsg.providerID,
130
+ modelID: assistantMsg.modelID,
131
+ }));
132
+ return;
133
+ }
134
+
115
135
  // Use the compaction model (--compaction-model, e.g. gpt-5-nano) for summarization
116
136
  // to avoid doubling rate-limit pressure on the main model.
117
137
  // If the compaction model is unavailable, fall back to the main model.
@@ -145,10 +165,7 @@ export namespace SessionSummary {
145
165
  ? 'Compaction model unavailable, falling back to main model'
146
166
  : 'Using same model as --model (no compaction model configured)',
147
167
  }));
148
- model = await Provider.getModel(
149
- assistantMsg.providerID,
150
- assistantMsg.modelID
151
- ).catch(() => null);
168
+ model = baseModel;
152
169
  }
153
170
  if (!model) {
154
171
  log.info(() => ({