@link-assistant/agent 0.16.18 → 0.18.0

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/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,146 +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: 'Generate AI session summaries',
737
- default: false,
738
- }),
607
+ builder: buildRunOptions,
739
608
  handler: async (argv) => {
740
609
  // Check both CLI flag and environment variable for compact JSON mode
741
610
  const compactJson =
@@ -917,7 +786,10 @@ async function main() {
917
786
  if (argv['output-response-model'] === false) {
918
787
  Flag.setOutputResponseModel(false);
919
788
  }
920
- if (argv['summarize-session'] === true) {
789
+ // summarize-session is enabled by default, only set if explicitly disabled
790
+ if (argv['summarize-session'] === false) {
791
+ Flag.setSummarizeSession(false);
792
+ } else {
921
793
  Flag.setSummarizeSession(true);
922
794
  }
923
795
  // retry-on-rate-limits is enabled by default, only set if explicitly disabled
@@ -929,6 +801,15 @@ async function main() {
929
801
  level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
930
802
  compactJson: isCompact,
931
803
  });
804
+
805
+ // Monkey-patch globalThis.fetch for raw HTTP logging in --verbose mode.
806
+ // Catches ALL HTTP calls regardless of AI SDK fetch passthrough. (#217)
807
+ if (!globalThis.__agentVerboseFetchInstalled) {
808
+ globalThis.fetch = createVerboseFetch(globalThis.fetch, {
809
+ caller: 'global',
810
+ });
811
+ globalThis.__agentVerboseFetchInstalled = true;
812
+ }
932
813
  })
933
814
  .fail((msg, err, yargs) => {
934
815
  // Handle errors from command handlers
@@ -1201,25 +1201,23 @@ export namespace Provider {
1201
1201
  sessionID: provider.id,
1202
1202
  });
1203
1203
 
1204
- // Wrap fetch with verbose HTTP logging for debugging provider issues.
1205
- // IMPORTANT: The verbose check is done at call time (not SDK creation time)
1206
- // because the SDK is cached and Flag.OPENCODE_VERBOSE may change after creation.
1207
- // When verbose is disabled, the wrapper is a no-op passthrough with negligible overhead.
1208
- // See: https://github.com/link-assistant/agent/issues/200
1209
- // See: https://github.com/link-assistant/agent/issues/206
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).
1209
+ // See: https://github.com/link-assistant/agent/issues/217
1210
1210
  // See: https://github.com/link-assistant/agent/issues/215
1211
1211
  {
1212
1212
  const innerFetch = options['fetch'];
1213
1213
  let verboseWrapperConfirmed = false;
1214
1214
  let httpCallCount = 0;
1215
1215
 
1216
- // Log at SDK creation time that the fetch wrapper is installed.
1217
- // This runs once per provider SDK creation (not per request).
1218
- // If verbose is off at creation time, the per-request check still applies.
1219
- // See: https://github.com/link-assistant/agent/issues/215
1220
- log.info('verbose HTTP fetch wrapper installed', {
1216
+ log.info('provider SDK fetch chain configured', {
1221
1217
  providerID: provider.id,
1222
1218
  pkg,
1219
+ globalVerboseFetchInstalled:
1220
+ !!globalThis.__agentVerboseFetchInstalled,
1223
1221
  verboseAtCreation: Flag.OPENCODE_VERBOSE,
1224
1222
  });
1225
1223
 
@@ -1227,8 +1225,15 @@ export namespace Provider {
1227
1225
  input: RequestInfo | URL,
1228
1226
  init?: RequestInit
1229
1227
  ): Promise<Response> => {
1230
- // Check verbose flag at call time — not at SDK creation time
1231
- if (!Flag.OPENCODE_VERBOSE) {
1228
+ // 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
+ ) {
1232
1237
  return innerFetch(input, init);
1233
1238
  }
1234
1239
 
@@ -1301,8 +1306,8 @@ export namespace Provider {
1301
1306
  : undefined;
1302
1307
  if (bodyStr && typeof bodyStr === 'string') {
1303
1308
  bodyPreview =
1304
- bodyStr.length > 2000
1305
- ? bodyStr.slice(0, 2000) +
1309
+ bodyStr.length > 200000
1310
+ ? bodyStr.slice(0, 200000) +
1306
1311
  `... [truncated, total ${bodyStr.length} chars]`
1307
1312
  : bodyStr;
1308
1313
  }
@@ -1362,7 +1367,7 @@ export namespace Provider {
1362
1367
  // still receives the full stream while we asynchronously log a preview.
1363
1368
  // For non-streaming responses, buffer the body and reconstruct the Response.
1364
1369
  // See: https://github.com/link-assistant/agent/issues/204
1365
- const responseBodyMaxChars = 4000;
1370
+ const responseBodyMaxChars = 200000;
1366
1371
  const contentType = response.headers.get('content-type') ?? '';
1367
1372
  const isStreaming =
1368
1373
  contentType.includes('event-stream') ||
@@ -28,20 +28,149 @@ export namespace SessionCompaction {
28
28
  ),
29
29
  };
30
30
 
31
+ /**
32
+ * Default safety margin ratio for compaction trigger.
33
+ * We trigger compaction at 85% of usable context to avoid hitting hard limits.
34
+ * This means we stop 15% before (context - output) tokens.
35
+ * @see https://github.com/link-assistant/agent/issues/217
36
+ */
37
+ export const OVERFLOW_SAFETY_MARGIN = 0.85;
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
+
31
95
  export function isOverflow(input: {
32
96
  tokens: MessageV2.Assistant['tokens'];
33
97
  model: ModelsDev.Model;
98
+ compactionModel?: CompactionModelConfig;
99
+ compactionModelContextLimit?: number;
34
100
  }) {
35
101
  if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false;
36
- const context = input.model.limit.context;
37
- if (context === 0) return false;
102
+ const baseModelContextLimit = input.model.limit.context;
103
+ if (baseModelContextLimit === 0) return false;
38
104
  const count =
39
105
  input.tokens.input + input.tokens.cache.read + input.tokens.output;
40
- const output =
106
+ const outputTokenLimit =
41
107
  Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) ||
42
108
  SessionPrompt.OUTPUT_TOKEN_MAX;
43
- const usable = context - output;
44
- return count > usable;
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);
116
+ const overflow = count > safeLimit;
117
+ log.info(() => ({
118
+ message: 'overflow check',
119
+ modelID: input.model.id,
120
+ contextLimit: baseModelContextLimit,
121
+ outputLimit: outputTokenLimit,
122
+ usableContextWindow,
123
+ safeLimit,
124
+ safetyMarginRatio,
125
+ compactionModelID: input.compactionModel?.modelID,
126
+ compactionModelContextLimit: input.compactionModelContextLimit,
127
+ currentTokens: count,
128
+ tokensBreakdown: {
129
+ input: input.tokens.input,
130
+ cacheRead: input.tokens.cache.read,
131
+ output: input.tokens.output,
132
+ },
133
+ overflow,
134
+ headroom: safeLimit - count,
135
+ }));
136
+ return overflow;
137
+ }
138
+
139
+ /**
140
+ * Compute context diagnostics for a given model and token usage.
141
+ * Used in step-finish parts to show context usage in JSON output.
142
+ * @see https://github.com/link-assistant/agent/issues/217
143
+ */
144
+ export function contextDiagnostics(input: {
145
+ tokens: { input: number; output: number; cache: { read: number } };
146
+ model: ModelsDev.Model;
147
+ compactionModel?: CompactionModelConfig;
148
+ compactionModelContextLimit?: number;
149
+ }): MessageV2.ContextDiagnostics | undefined {
150
+ const contextLimit = input.model.limit.context;
151
+ if (contextLimit === 0) return undefined;
152
+ const outputLimit =
153
+ Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) ||
154
+ SessionPrompt.OUTPUT_TOKEN_MAX;
155
+ const usableContext = contextLimit - outputLimit;
156
+ const safetyMarginRatio = computeSafetyMarginRatio({
157
+ baseModelContextLimit: contextLimit,
158
+ compactionModel: input.compactionModel,
159
+ compactionModelContextLimit: input.compactionModelContextLimit,
160
+ });
161
+ const safeLimit = Math.floor(usableContext * safetyMarginRatio);
162
+ const currentTokens =
163
+ input.tokens.input + input.tokens.cache.read + input.tokens.output;
164
+ return {
165
+ contextLimit,
166
+ outputLimit,
167
+ usableContext,
168
+ safeLimit,
169
+ safetyMargin: safetyMarginRatio,
170
+ currentTokens,
171
+ headroom: safeLimit - currentTokens,
172
+ overflow: currentTokens > safeLimit,
173
+ };
45
174
  }
46
175
 
47
176
  export const PRUNE_MINIMUM = 20_000;
@@ -100,10 +229,27 @@ export namespace SessionCompaction {
100
229
  };
101
230
  abort: AbortSignal;
102
231
  }) {
232
+ log.info(() => ({
233
+ message: 'compaction process starting',
234
+ providerID: input.model.providerID,
235
+ modelID: input.model.modelID,
236
+ messageCount: input.messages.length,
237
+ sessionID: input.sessionID,
238
+ }));
103
239
  const model = await Provider.getModel(
104
240
  input.model.providerID,
105
241
  input.model.modelID
106
242
  );
243
+ if (Flag.OPENCODE_VERBOSE) {
244
+ log.info(() => ({
245
+ message: 'compaction model loaded',
246
+ providerID: model.providerID,
247
+ modelID: model.modelID,
248
+ npm: model.npm,
249
+ contextLimit: model.info.limit.context,
250
+ outputLimit: model.info.limit.output,
251
+ }));
252
+ }
107
253
  const system = [...SystemPrompt.summarize(model.providerID)];
108
254
  const msg = (await Session.updateMessage({
109
255
  id: Identifier.ascending('message'),
@@ -156,6 +302,19 @@ export namespace SessionCompaction {
156
302
  );
157
303
  // Defensive check: ensure modelMessages is iterable (AI SDK 6.0.1 compatibility fix)
158
304
  const safeModelMessages = Array.isArray(modelMessages) ? modelMessages : [];
305
+
306
+ if (Flag.OPENCODE_VERBOSE) {
307
+ log.info(() => ({
308
+ message: 'compaction streamText call',
309
+ providerID: model.providerID,
310
+ modelID: model.modelID,
311
+ systemPromptCount: system.length,
312
+ modelMessageCount: safeModelMessages.length,
313
+ filteredMessageCount: input.messages.length - safeModelMessages.length,
314
+ toolCall: model.info.tool_call,
315
+ }));
316
+ }
317
+
159
318
  const result = await processor.process(() =>
160
319
  streamText({
161
320
  onError(error) {
@@ -240,6 +240,27 @@ export namespace MessageV2 {
240
240
  });
241
241
  export type ModelInfo = z.infer<typeof ModelInfo>;
242
242
 
243
+ /**
244
+ * Context diagnostic info for step-finish parts.
245
+ * Shows model context limits and current usage to help debug compaction decisions.
246
+ * @see https://github.com/link-assistant/agent/issues/217
247
+ */
248
+ export const ContextDiagnostics = z
249
+ .object({
250
+ contextLimit: z.number(),
251
+ outputLimit: z.number(),
252
+ usableContext: z.number(),
253
+ safeLimit: z.number(),
254
+ safetyMargin: z.number(),
255
+ currentTokens: z.number(),
256
+ headroom: z.number(),
257
+ overflow: z.boolean(),
258
+ })
259
+ .meta({
260
+ ref: 'ContextDiagnostics',
261
+ });
262
+ export type ContextDiagnostics = z.infer<typeof ContextDiagnostics>;
263
+
243
264
  export const StepFinishPart = PartBase.extend({
244
265
  type: z.literal('step-finish'),
245
266
  reason: z.string(),
@@ -257,6 +278,9 @@ export namespace MessageV2 {
257
278
  // Model info included when --output-response-model is enabled
258
279
  // @see https://github.com/link-assistant/agent/issues/179
259
280
  model: ModelInfo.optional(),
281
+ // Context diagnostics for debugging compaction decisions
282
+ // @see https://github.com/link-assistant/agent/issues/217
283
+ context: ContextDiagnostics.optional(),
260
284
  }).meta({
261
285
  ref: 'StepFinishPart',
262
286
  });
@@ -368,6 +392,14 @@ export namespace MessageV2 {
368
392
  providerID: z.string(),
369
393
  modelID: z.string(),
370
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(),
371
403
  system: z.string().optional(),
372
404
  appendSystem: z.string().optional(),
373
405
  tools: z.record(z.string(), z.boolean()).optional(),
@@ -17,6 +17,7 @@ import { Bus } from '../bus';
17
17
  import { SessionRetry } from './retry';
18
18
  import { SessionStatus } from './status';
19
19
  import { Flag } from '../flag/flag';
20
+ import { SessionCompaction } from './compaction';
20
21
 
21
22
  export namespace SessionProcessor {
22
23
  const DOOM_LOOP_THRESHOLD = 3;
@@ -366,6 +367,22 @@ export namespace SessionProcessor {
366
367
  }
367
368
  : undefined;
368
369
 
370
+ // Compute context diagnostics for JSON output
371
+ // @see https://github.com/link-assistant/agent/issues/217
372
+ const contextDiag = SessionCompaction.contextDiagnostics({
373
+ tokens: usage.tokens,
374
+ model: input.model,
375
+ });
376
+
377
+ if (Flag.OPENCODE_VERBOSE && contextDiag) {
378
+ log.info(() => ({
379
+ message: 'step-finish context diagnostics',
380
+ providerID: input.providerID,
381
+ modelID: input.model.id,
382
+ ...contextDiag,
383
+ }));
384
+ }
385
+
369
386
  await Session.updatePart({
370
387
  id: Identifier.ascending('part'),
371
388
  reason: finishReason,
@@ -376,6 +393,7 @@ export namespace SessionProcessor {
376
393
  tokens: usage.tokens,
377
394
  cost: usage.cost,
378
395
  model: modelInfo,
396
+ context: contextDiag,
379
397
  });
380
398
  await Session.updateMessage(input.assistantMessage);
381
399
  if (snapshot) {