tinker-agent 2.2.0 → 2.4.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.
@@ -1,6 +1,6 @@
1
1
  import { ContextBuilder } from "../agent/context-builder";
2
2
  import type { ContextUsageSnapshot } from "../agent/context-meter";
3
- import type { TurnId } from "../ids/runtime-id";
3
+ import type { MessageId, TurnId } from "../ids/runtime-id";
4
4
  import type { ModelClient, PreparedModelRequest } from "../model/model-client";
5
5
  import {
6
6
  promptPrefixFingerprint,
@@ -39,7 +39,11 @@ import type {
39
39
  ToolResultRecord,
40
40
  } from "./protocol-frame";
41
41
 
42
- export type SwapPlanningTrigger = "manual" | "runtime_pressure" | "benchmark_forced";
42
+ export type SwapPlanningTrigger =
43
+ | "manual"
44
+ | "runtime_pressure"
45
+ | "benchmark_forced"
46
+ | "model_directed";
43
47
 
44
48
  export type SwapPlanningOutcome =
45
49
  | "below_trigger"
@@ -86,6 +90,7 @@ export type SwapPlanningInput = {
86
90
  readonly consumedThroughOrdinal: number;
87
91
  };
88
92
  readonly forcedTargetTokens?: number;
93
+ readonly selectedMessageIds?: readonly MessageId[];
89
94
  };
90
95
 
91
96
  export type SwapPlanningResult = {
@@ -123,14 +128,17 @@ export class SwapPlanStaleError extends Error {
123
128
 
124
129
  type ModelPreparer = Pick<ModelClient, "prepare">;
125
130
 
126
- type EligibleCandidate = {
131
+ export type EligibleSwapCandidate = {
127
132
  readonly override: SwapOverride;
128
133
  readonly rawKind: SwappableRawKind;
134
+ readonly message: Extract<CanonicalMessageRecord, { role: "tool" }>;
135
+ readonly result: ToolResultRecord;
129
136
  };
130
137
 
131
- type CandidateScan = {
132
- readonly eligible: readonly EligibleCandidate[];
138
+ export type SwapCandidateScan = {
139
+ readonly eligible: readonly EligibleSwapCandidate[];
133
140
  readonly excludedByReason: Readonly<Record<string, number>>;
141
+ readonly excludedByMessageId: ReadonlyMap<MessageId, string>;
134
142
  };
135
143
 
136
144
  type Projection = {
@@ -172,7 +180,7 @@ export class SwapPlanner {
172
180
  targetTokens,
173
181
  );
174
182
  }
175
- if (guardedTokensBefore <= targetTokens) {
183
+ if (input.trigger !== "model_directed" && guardedTokensBefore <= targetTokens) {
176
184
  return emptyResult(
177
185
  input,
178
186
  input.trigger === "runtime_pressure" ? "below_trigger" : "below_target",
@@ -189,6 +197,16 @@ export class SwapPlanner {
189
197
  input.revision.keepFromOrdinal,
190
198
  input.activeTurn,
191
199
  );
200
+ if (input.trigger === "model_directed") {
201
+ return this.planSelected({
202
+ input,
203
+ scan,
204
+ activeFingerprint,
205
+ rawTokensBefore,
206
+ guardedTokensBefore,
207
+ targetTokens,
208
+ });
209
+ }
192
210
  if (scan.eligible.length === 0) {
193
211
  return {
194
212
  ...emptyResult(
@@ -208,47 +226,7 @@ export class SwapPlanner {
208
226
  if (existing !== undefined) {
209
227
  return existing;
210
228
  }
211
- const addedOverrides = scan.eligible
212
- .slice(0, count)
213
- .map((entry) => entry.override);
214
- let prepared: PreparedModelRequest;
215
- try {
216
- const compiled = this.compiler.compileProspective({
217
- active: input.active,
218
- canonical: input.canonical,
219
- activeOverrides: input.activeOverrides,
220
- addedOverrides,
221
- activeSurface: input.surface,
222
- });
223
- const built = this.requestBuilder.build({
224
- canonical: input.canonical,
225
- revision: input.revision,
226
- surface: input.surface,
227
- activeOverrides: [...input.activeOverrides, ...addedOverrides],
228
- compiled,
229
- tools: input.tools,
230
- });
231
- prepared = this.model.prepare(built.request);
232
- } catch (error) {
233
- if (isCanonicalPlanningError(error)) {
234
- throw error;
235
- }
236
- throw new SwapPlanningDiagnosticError(
237
- "prepare",
238
- "prospective_prepare_failed",
239
- "Prospective request preparation failed.",
240
- { cause: error },
241
- );
242
- }
243
- assertProspectiveConfiguration(input.activePrepared, prepared);
244
- const breakdown = estimatePromptSegments(prepared.promptSegments);
245
- const rawTokens = breakdown.totalTokens;
246
- const projection = Object.freeze({
247
- count,
248
- prepared,
249
- rawTokens,
250
- guardedTokens: guardTokens(breakdown, input.activeUsage.correctionFactor),
251
- });
229
+ const projection = this.projectCandidates(input, scan.eligible.slice(0, count));
252
230
  projectionCache.set(count, projection);
253
231
  return projection;
254
232
  };
@@ -355,13 +333,147 @@ export class SwapPlanner {
355
333
  });
356
334
  }
357
335
 
358
- private scanCandidates(
336
+ private planSelected(input: {
337
+ input: SwapPlanningInput;
338
+ scan: SwapCandidateScan;
339
+ activeFingerprint: PromptPrefixFingerprint;
340
+ rawTokensBefore: number;
341
+ guardedTokensBefore: number;
342
+ targetTokens: number;
343
+ }): SwapPlanningResult {
344
+ const selectedMessageIds = input.input.selectedMessageIds;
345
+ if (selectedMessageIds === undefined || selectedMessageIds.length === 0) {
346
+ throw new SwapPlanningDiagnosticError(
347
+ "validate",
348
+ "missing_selected_candidates",
349
+ "Model-directed swap planning requires selected candidates.",
350
+ );
351
+ }
352
+ const eligibleById = new Map(
353
+ input.scan.eligible.map((candidate) => [candidate.message.messageId, candidate]),
354
+ );
355
+ const selected: EligibleSwapCandidate[] = [];
356
+ for (const messageId of selectedMessageIds) {
357
+ const candidate = eligibleById.get(messageId);
358
+ if (candidate === undefined) {
359
+ const reason = swapCandidateRejectionReason(
360
+ input.scan,
361
+ input.input.canonical,
362
+ messageId,
363
+ );
364
+ throw new SwapPlanningDiagnosticError(
365
+ "candidate",
366
+ `selected_candidate_${reason}`,
367
+ `A model-directed swap candidate is no longer eligible (${reason}).`,
368
+ );
369
+ }
370
+ selected.push(candidate);
371
+ }
372
+
373
+ const projection = this.projectCandidates(input.input, selected);
374
+ if (
375
+ projection.rawTokens >= input.rawTokensBefore ||
376
+ projection.guardedTokens >= input.guardedTokensBefore
377
+ ) {
378
+ throw new SwapPlanningDiagnosticError(
379
+ "validate",
380
+ "no_token_reduction",
381
+ "Prospective request did not strictly reduce raw and guarded tokens.",
382
+ );
383
+ }
384
+ const addedOverrides = Object.freeze(
385
+ selected.map((candidate) => candidate.override),
386
+ );
387
+ const plan = createPlan({
388
+ input: input.input,
389
+ activeFingerprint: input.activeFingerprint,
390
+ projectedFingerprint: promptPrefixFingerprint(projection.prepared),
391
+ addedOverrides,
392
+ targetTokens: input.targetTokens,
393
+ rawTokensBefore: input.rawTokensBefore,
394
+ rawTokensAfter: projection.rawTokens,
395
+ guardedTokensBefore: input.guardedTokensBefore,
396
+ guardedTokensAfter: projection.guardedTokens,
397
+ });
398
+ assertPlanBaseCurrent(plan, {
399
+ active: input.input.active,
400
+ revision: input.input.revision,
401
+ activeOverrides: input.input.activeOverrides,
402
+ activePrepared: input.input.activePrepared,
403
+ });
404
+ return Object.freeze({
405
+ outcome:
406
+ projection.guardedTokens <= input.targetTokens
407
+ ? "target_reached"
408
+ : "insufficient_candidates",
409
+ canonicalMessageCount: input.input.canonical.messages.length,
410
+ eligibleCandidateCount: input.scan.eligible.length,
411
+ excludedByReason: input.scan.excludedByReason,
412
+ selectedByRawKind: countRawKinds(selected),
413
+ originalObservationBytes: addedOverrides.reduce(
414
+ (total, override) => total + override.originalBytes,
415
+ 0,
416
+ ),
417
+ projectedObservationBytes: addedOverrides.reduce(
418
+ (total, override) => total + override.renderedBytes,
419
+ 0,
420
+ ),
421
+ rawTokensBefore: input.rawTokensBefore,
422
+ guardedTokensBefore: input.guardedTokensBefore,
423
+ targetTokens: input.targetTokens,
424
+ plan,
425
+ });
426
+ }
427
+
428
+ private projectCandidates(
429
+ input: SwapPlanningInput,
430
+ candidates: readonly EligibleSwapCandidate[],
431
+ ): Projection {
432
+ const addedOverrides = candidates.map((candidate) => candidate.override);
433
+ let prepared: PreparedModelRequest;
434
+ try {
435
+ const compiled = this.compiler.compileProspective({
436
+ active: input.active,
437
+ canonical: input.canonical,
438
+ activeOverrides: input.activeOverrides,
439
+ addedOverrides,
440
+ activeSurface: input.surface,
441
+ });
442
+ const built = this.requestBuilder.build({
443
+ canonical: input.canonical,
444
+ revision: input.revision,
445
+ surface: input.surface,
446
+ activeOverrides: [...input.activeOverrides, ...addedOverrides],
447
+ compiled,
448
+ tools: input.tools,
449
+ });
450
+ prepared = this.model.prepare(built.request);
451
+ } catch (error) {
452
+ if (isCanonicalPlanningError(error)) throw error;
453
+ throw new SwapPlanningDiagnosticError(
454
+ "prepare",
455
+ "prospective_prepare_failed",
456
+ "Prospective request preparation failed.",
457
+ { cause: error },
458
+ );
459
+ }
460
+ assertProspectiveConfiguration(input.activePrepared, prepared);
461
+ const breakdown = estimatePromptSegments(prepared.promptSegments);
462
+ return Object.freeze({
463
+ count: candidates.length,
464
+ prepared,
465
+ rawTokens: breakdown.totalTokens,
466
+ guardedTokens: guardTokens(breakdown, input.activeUsage.correctionFactor),
467
+ });
468
+ }
469
+
470
+ scanCandidates(
359
471
  canonical: ProtocolContextView,
360
472
  activeOverrides: readonly SwapOverride[],
361
473
  policy: SwapOnlyPolicyV1,
362
474
  keepFromOrdinal: number,
363
475
  activeTurn: SwapPlanningInput["activeTurn"],
364
- ): CandidateScan {
476
+ ): SwapCandidateScan {
365
477
  const alreadySwapped = new Set(
366
478
  activeOverrides.map((override) => override.messageId),
367
479
  );
@@ -373,19 +485,28 @@ export class SwapPlanner {
373
485
  const resultsByMessage = new Map(
374
486
  canonical.toolResults.map((result) => [result.toolMessageId, result] as const),
375
487
  );
376
- const eligible: EligibleCandidate[] = [];
488
+ const eligible: EligibleSwapCandidate[] = [];
377
489
  const exclusions = new Map<string, number>();
490
+ const excludedByMessageId = new Map<MessageId, string>();
491
+
492
+ const exclude = (
493
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>,
494
+ reason: string,
495
+ ) => {
496
+ increment(exclusions, reason);
497
+ excludedByMessageId.set(message.messageId, reason);
498
+ };
378
499
 
379
500
  for (const message of canonical.messages) {
380
501
  if (message.role !== "tool") {
381
502
  continue;
382
503
  }
383
504
  if (message.ordinal < keepFromOrdinal) {
384
- increment(exclusions, "retired_prefix");
505
+ exclude(message, "retired_prefix");
385
506
  continue;
386
507
  }
387
508
  if (alreadySwapped.has(message.messageId)) {
388
- increment(exclusions, "already_swapped");
509
+ exclude(message, "already_swapped");
389
510
  continue;
390
511
  }
391
512
  const reason = basicExclusionReason({
@@ -396,7 +517,7 @@ export class SwapPlanner {
396
517
  minimumObservationBytes: policy.minimumObservationBytes,
397
518
  });
398
519
  if (reason !== undefined) {
399
- increment(exclusions, reason);
520
+ exclude(message, reason);
400
521
  continue;
401
522
  }
402
523
  const result = resultsByMessage.get(message.messageId);
@@ -415,6 +536,8 @@ export class SwapPlanner {
415
536
  Object.freeze({
416
537
  override: this.renderer.render({ message, result }),
417
538
  rawKind: result.completion.raw.kind,
539
+ message,
540
+ result,
418
541
  }),
419
542
  );
420
543
  } catch (error) {
@@ -429,7 +552,7 @@ export class SwapPlanner {
429
552
  if (error.code === "source_hash_mismatch") {
430
553
  throw new ContextRevisionError(error.message);
431
554
  }
432
- increment(exclusions, error.code);
555
+ exclude(message, error.code);
433
556
  }
434
557
  }
435
558
 
@@ -437,10 +560,26 @@ export class SwapPlanner {
437
560
  return Object.freeze({
438
561
  eligible: Object.freeze(eligible),
439
562
  excludedByReason: frozenCountRecord(exclusions),
563
+ excludedByMessageId,
440
564
  });
441
565
  }
442
566
  }
443
567
 
568
+ export function swapCandidateRejectionReason(
569
+ scan: SwapCandidateScan,
570
+ canonical: ProtocolContextView,
571
+ messageId: MessageId,
572
+ ): string {
573
+ if (scan.eligible.some((candidate) => candidate.message.messageId === messageId)) {
574
+ throw new Error("Eligible swap candidate has no rejection reason.");
575
+ }
576
+ const recorded = scan.excludedByMessageId.get(messageId);
577
+ if (recorded !== undefined) return recorded;
578
+ return canonical.messages.some((message) => message.messageId === messageId)
579
+ ? "not_tool_message"
580
+ : "candidate_not_found";
581
+ }
582
+
444
583
  export function assertPlanBaseCurrent(
445
584
  plan: SwapRevisionPlan,
446
585
  current: {
@@ -523,6 +662,27 @@ function validatePlanningInput(input: SwapPlanningInput): void {
523
662
  "Forced swap target must be a non-negative safe integer.",
524
663
  );
525
664
  }
665
+ if (input.trigger === "model_directed") {
666
+ if (
667
+ input.activeTurn === undefined ||
668
+ input.selectedMessageIds === undefined ||
669
+ input.selectedMessageIds.length < 1 ||
670
+ input.selectedMessageIds.length > 16 ||
671
+ new Set(input.selectedMessageIds).size !== input.selectedMessageIds.length
672
+ ) {
673
+ throw new SwapPlanningDiagnosticError(
674
+ "validate",
675
+ "invalid_selected_candidates",
676
+ "Model-directed swap planning requires 1 to 16 unique candidates and an active turn boundary.",
677
+ );
678
+ }
679
+ } else if (input.selectedMessageIds !== undefined) {
680
+ throw new SwapPlanningDiagnosticError(
681
+ "validate",
682
+ "unexpected_selected_candidates",
683
+ "Only model-directed swap planning accepts selected candidates.",
684
+ );
685
+ }
526
686
  }
527
687
 
528
688
  function assertActiveFingerprint(
@@ -647,7 +807,10 @@ function basicExclusionReason(input: {
647
807
  return undefined;
648
808
  }
649
809
 
650
- function compareCandidates(left: EligibleCandidate, right: EligibleCandidate): number {
810
+ function compareCandidates(
811
+ left: EligibleSwapCandidate,
812
+ right: EligibleSwapCandidate,
813
+ ): number {
651
814
  return (
652
815
  right.override.byteSavings - left.override.byteSavings ||
653
816
  right.override.originalBytes - left.override.originalBytes ||
@@ -724,7 +887,7 @@ function createPlan(input: {
724
887
  }
725
888
 
726
889
  function countRawKinds(
727
- candidates: readonly EligibleCandidate[],
890
+ candidates: readonly EligibleSwapCandidate[],
728
891
  ): Readonly<Record<string, number>> {
729
892
  const counts = new Map<string, number>();
730
893
  for (const candidate of candidates) {
@@ -227,6 +227,7 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
227
227
  case "web_search":
228
228
  case "web_fetch":
229
229
  case "recall":
230
+ case "context_maintenance":
230
231
  case "memory_search":
231
232
  case "memory_get":
232
233
  case "wait":
@@ -53,7 +53,7 @@ export type ContextUsageUpdatedData = {
53
53
  export type ContextRevisionStartedData =
54
54
  | {
55
55
  strategy: "swap";
56
- reason: "manual" | "runtime_pressure";
56
+ reason: "manual" | "runtime_pressure" | "model_directed";
57
57
  policyVersion: "swap-only-v1";
58
58
  rendererFormat: "swap-observation-v1";
59
59
  qualificationId?: string;
@@ -81,7 +81,7 @@ export type ContextRevisionStartedData =
81
81
  export type ContextRevisionFinishedData =
82
82
  | {
83
83
  strategy: "swap";
84
- reason: "manual" | "runtime_pressure";
84
+ reason: "manual" | "runtime_pressure" | "model_directed";
85
85
  policyVersion: "swap-only-v1";
86
86
  outcome:
87
87
  | "below_trigger"
@@ -162,7 +162,7 @@ export type ContextRevisionFinishedData =
162
162
  export type ContextRevisionFailedData =
163
163
  | {
164
164
  strategy: "swap";
165
- reason: "manual" | "runtime_pressure";
165
+ reason: "manual" | "runtime_pressure" | "model_directed";
166
166
  stage: "snapshot" | "plan" | "validate" | "commit" | "activate";
167
167
  errorCode: string;
168
168
  error: string;
@@ -21,6 +21,7 @@ import {
21
21
  type ImageAssetId,
22
22
  type ImageAssetRef,
23
23
  } from "./image-types";
24
+ import { canonicalHomeRoot, workspaceStorageRoot } from "../session/workspace-storage";
24
25
 
25
26
  const STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
26
27
  const STAGING_PATTERN =
@@ -47,13 +48,18 @@ export class ImageAssetStore {
47
48
  static async open(input: {
48
49
  workspaceRoot: string;
49
50
  onWarning?: (message: string) => void;
51
+ homeRoot?: string;
50
52
  }): Promise<ImageAssetStore> {
51
53
  const workspaceRoot = await realpath(input.workspaceRoot);
52
54
  const workspaceStat = await stat(workspaceRoot);
53
55
  if (!workspaceStat.isDirectory()) {
54
56
  throw new Error(`Workspace root is not a directory: ${workspaceRoot}.`);
55
57
  }
56
- const root = await ensureAssetRoot(workspaceRoot);
58
+ const storageRoot = workspaceStorageRoot(
59
+ workspaceRoot,
60
+ await canonicalHomeRoot(input.homeRoot),
61
+ );
62
+ const root = await ensureAssetRoot(storageRoot);
57
63
  const store = new ImageAssetStore(workspaceRoot, root, input.onWarning);
58
64
  await store.cleanupStagingFiles();
59
65
  return store;
@@ -271,9 +277,18 @@ export class ImageAssetStore {
271
277
  }
272
278
  }
273
279
 
274
- async function ensureAssetRoot(workspaceRoot: string): Promise<string> {
275
- let current = workspaceRoot;
276
- for (const name of [".tinker", "assets", "images"]) {
280
+ async function ensureAssetRoot(storageRoot: string): Promise<string> {
281
+ await mkdir(storageRoot, { recursive: true, mode: 0o700 });
282
+ const storageEntry = await lstat(storageRoot);
283
+ if (storageEntry.isSymbolicLink() || !storageEntry.isDirectory()) {
284
+ throw new Error(
285
+ `Workspace storage root is not a regular directory: ${storageRoot}.`,
286
+ );
287
+ }
288
+ await chmod(storageRoot, 0o700);
289
+
290
+ let current = storageRoot;
291
+ for (const name of ["assets", "images"]) {
277
292
  current = path.join(current, name);
278
293
  try {
279
294
  const entry = await lstat(current);
@@ -288,12 +303,10 @@ async function ensureAssetRoot(workspaceRoot: string): Promise<string> {
288
303
  }
289
304
  await mkdir(current, { mode: 0o700 });
290
305
  }
291
- if (name !== ".tinker") {
292
- await chmod(current, 0o700);
293
- }
306
+ await chmod(current, 0o700);
294
307
  }
295
308
  const canonical = await realpath(current);
296
- assertContained(workspaceRoot, canonical, "Image asset root");
309
+ assertContained(storageRoot, canonical, "Image asset root");
297
310
  if (canonical !== current) {
298
311
  throw new Error("Image asset root is not canonical.");
299
312
  }
@@ -131,6 +131,12 @@ export type MemoryExtractionDiagnostic = {
131
131
  readonly written: number;
132
132
  readonly rejected: MemoryExtractionRejectedCounts;
133
133
  readonly ms: number;
134
+ /**
135
+ * Bounded single-line error detail (message plus cause chain) recorded for
136
+ * failed and skipped outcomes so provider/parse failures are diagnosable
137
+ * from the log alone. Absent on success.
138
+ */
139
+ readonly detail?: string;
134
140
  };
135
141
 
136
142
  export type MemorySearchDiagnostic = {
@@ -196,6 +202,31 @@ export function boundedMemoryError(error: unknown): string {
196
202
  return truncateUtf8(singleLine, 400);
197
203
  }
198
204
 
205
+ export function boundedMemoryErrorDetail(error: unknown): string {
206
+ const parts: string[] = [];
207
+ let current: unknown = error;
208
+ for (
209
+ let depth = 0;
210
+ depth < 4 && current !== undefined && current !== null;
211
+ depth += 1
212
+ ) {
213
+ const raw =
214
+ current instanceof Error
215
+ ? current.message
216
+ : typeof current === "string" ||
217
+ typeof current === "number" ||
218
+ typeof current === "boolean"
219
+ ? String(current)
220
+ : "unknown non-error cause";
221
+ const singleLine = raw.replaceAll(/\s+/g, " ").trim();
222
+ if (singleLine !== "" && !parts.includes(singleLine)) {
223
+ parts.push(singleLine);
224
+ }
225
+ current = current instanceof Error ? current.cause : undefined;
226
+ }
227
+ return truncateUtf8(parts.join(" | ") || "unknown memory error", 400);
228
+ }
229
+
199
230
  export function truncateUtf8(value: string, maxBytes: number): string {
200
231
  if (Buffer.byteLength(value, "utf8") <= maxBytes) {
201
232
  return value;
@@ -25,6 +25,7 @@ import {
25
25
  MEMORY_SEARCH_LIMIT,
26
26
  MEMORY_SEARCH_TOOL_NAME,
27
27
  MemoryError,
28
+ boundedMemoryErrorDetail,
28
29
  truncateUtf8,
29
30
  type MemoryEmbeddingConfig,
30
31
  type MemoryExtractionDiagnostic,
@@ -248,6 +249,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
248
249
  turnId: task.turnId,
249
250
  inputTokens,
250
251
  ms: elapsedMs(startedAt),
252
+ detail: boundedMemoryErrorDetail(error),
251
253
  }),
252
254
  );
253
255
  return;
@@ -272,6 +274,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
272
274
  returned,
273
275
  rejected,
274
276
  ms: elapsedMs(startedAt),
277
+ detail: boundedMemoryErrorDetail(error),
275
278
  }),
276
279
  );
277
280
  return;
@@ -343,6 +346,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
343
346
  returned,
344
347
  rejected,
345
348
  ms: elapsedMs(startedAt),
349
+ detail: boundedMemoryErrorDetail(error),
346
350
  }),
347
351
  );
348
352
  return;
@@ -401,6 +405,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
401
405
  returned,
402
406
  rejected,
403
407
  ms: elapsedMs(startedAt),
408
+ detail: boundedMemoryErrorDetail(error),
404
409
  }),
405
410
  );
406
411
  }
@@ -663,6 +668,7 @@ function extractionDiagnostic(input: {
663
668
  readonly returned?: number;
664
669
  readonly written?: number;
665
670
  readonly rejected?: MemoryExtractionRejectedCounts;
671
+ readonly detail?: string;
666
672
  }): MemoryExtractionDiagnostic {
667
673
  return Object.freeze({
668
674
  at: input.clock(),
@@ -676,6 +682,7 @@ function extractionDiagnostic(input: {
676
682
  written: input.written ?? 0,
677
683
  rejected: input.rejected ?? emptyRejectedCounts(),
678
684
  ms: input.ms,
685
+ ...(input.detail === undefined ? {} : { detail: input.detail }),
679
686
  });
680
687
  }
681
688
 
@@ -13,6 +13,7 @@ import {
13
13
  MAX_MEMORY_SUMMARY_BYTES,
14
14
  MAX_MEMORY_TEXT_BYTES,
15
15
  MemoryError,
16
+ truncateUtf8,
16
17
  } from "./contracts";
17
18
 
18
19
  const EXTRACTION_SYSTEM_PROMPT = `You record one faithful historical summary of a completed coding-agent turn. Your job is to record what happened, not to judge what deserves long-term storage.
@@ -35,6 +36,10 @@ Rules:
35
36
  - Do not copy long passages. Keep both fields dense and within their byte budgets.
36
37
  `;
37
38
 
39
+ const MEMORY_EXTRACTION_RESPONSE_FORMAT = Object.freeze({
40
+ type: "json_object" as const,
41
+ });
42
+
38
43
  export type MemoryExtractionCandidate = {
39
44
  readonly text: string;
40
45
  readonly summary: string;
@@ -102,7 +107,14 @@ export class MemoryExtractor {
102
107
  let prepared: PreparedModelRequest;
103
108
  let inputTokens: number;
104
109
  try {
105
- prepared = this.model.prepare({ messages, tools: [] });
110
+ prepared = this.model.prepare({
111
+ messages,
112
+ tools: [],
113
+ // Provider-enforced JSON mode: prompt wording alone lets the model
114
+ // wrap the record in markdown fences or prose, which previously
115
+ // surfaced as extraction_output_invalid failures and dropped memories.
116
+ responseFormat: MEMORY_EXTRACTION_RESPONSE_FORMAT,
117
+ });
106
118
  const rawInputTokens = estimatePromptSegments(
107
119
  prepared.promptSegments,
108
120
  ).totalTokens;
@@ -160,6 +172,15 @@ export class MemoryExtractor {
160
172
  }
161
173
  }
162
174
 
175
+ function outputPreview(content: string | null | undefined): string {
176
+ if (typeof content !== "string" || content.trim() === "") {
177
+ return "(empty)";
178
+ }
179
+ const singleLine = content.replaceAll(/\s+/g, " ").trim();
180
+ const preview = truncateUtf8(singleLine, 160);
181
+ return preview === singleLine ? preview : `${preview}…`;
182
+ }
183
+
163
184
  function parseExtractionOutput(message: {
164
185
  readonly content?: string | null;
165
186
  readonly toolCalls?: readonly unknown[];
@@ -169,7 +190,7 @@ function parseExtractionOutput(message: {
169
190
  (message.toolCalls !== undefined && message.toolCalls.length > 0)
170
191
  ) {
171
192
  throw new MemoryExtractionOutputError(
172
- "Memory extraction response must contain only JSON text.",
193
+ `Memory extraction response must contain only JSON text. output=${outputPreview(message.content)}`,
173
194
  0,
174
195
  );
175
196
  }
@@ -179,7 +200,7 @@ function parseExtractionOutput(message: {
179
200
  value = JSON.parse(message.content);
180
201
  } catch (error) {
181
202
  throw new MemoryExtractionOutputError(
182
- "Memory extraction response is not valid JSON.",
203
+ `Memory extraction response is not valid JSON. output=${outputPreview(message.content)}`,
183
204
  0,
184
205
  0,
185
206
  { cause: error },
@@ -187,7 +208,7 @@ function parseExtractionOutput(message: {
187
208
  }
188
209
  if (!isRecord(value)) {
189
210
  throw new MemoryExtractionOutputError(
190
- "Memory extraction response must be an object.",
211
+ `Memory extraction response must be an object. output=${outputPreview(message.content)}`,
191
212
  0,
192
213
  );
193
214
  }
@@ -200,7 +221,7 @@ function parseExtractionOutput(message: {
200
221
  typeof value.summary !== "string"
201
222
  ) {
202
223
  throw new MemoryExtractionOutputError(
203
- 'Memory extraction response must contain only "text" and "summary" strings.',
224
+ `Memory extraction response must contain only "text" and "summary" strings. output=${outputPreview(message.content)}`,
204
225
  0,
205
226
  );
206
227
  }