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.
- package/CHANGELOG.md +36 -1
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/agent/loop.ts +1 -1
- package/src/agent/runtime-session.ts +279 -28
- package/src/agent/session-ledger.ts +10 -3
- package/src/cli/config.ts +8 -12
- package/src/cli/tui-runner.tsx +3 -1
- package/src/context/context-manager.ts +185 -8
- package/src/context/context-policy.ts +1 -1
- package/src/context/context-revision-compiler.ts +7 -2
- package/src/context/context-swap-label.ts +132 -0
- package/src/context/swap-planner.ts +219 -56
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/events/types.ts +3 -3
- package/src/image/image-asset-store.ts +21 -8
- package/src/memory/contracts.ts +31 -0
- package/src/memory/memory-coordinator.ts +7 -0
- package/src/memory/memory-extractor.ts +26 -5
- package/src/model/fake-model-client.ts +7 -0
- package/src/model/model-client.ts +11 -0
- package/src/model/openai-chat-model-client.ts +7 -1
- package/src/model/openai-responses-model-client.ts +8 -1
- package/src/observation/observation-builder.ts +36 -0
- package/src/session/resume-projection.ts +8 -6
- package/src/session/session-catalog.ts +18 -3
- package/src/session/session-last-response-reader.ts +10 -6
- package/src/session/session-store.ts +340 -8
- package/src/session/workspace-storage.ts +84 -0
- package/src/tools/bash-task.ts +14 -12
- package/src/tools/context-maintenance.ts +266 -0
- package/src/tools/registry.ts +20 -1
- package/src/tools/types.ts +83 -1
- package/src/tui/event-store.ts +15 -0
|
@@ -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 =
|
|
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
|
|
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
|
|
132
|
-
readonly eligible: readonly
|
|
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
|
|
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
|
|
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
|
-
):
|
|
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:
|
|
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
|
-
|
|
505
|
+
exclude(message, "retired_prefix");
|
|
385
506
|
continue;
|
|
386
507
|
}
|
|
387
508
|
if (alreadySwapped.has(message.messageId)) {
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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) {
|
package/src/events/types.ts
CHANGED
|
@@ -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
|
|
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(
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
292
|
-
await chmod(current, 0o700);
|
|
293
|
-
}
|
|
306
|
+
await chmod(current, 0o700);
|
|
294
307
|
}
|
|
295
308
|
const canonical = await realpath(current);
|
|
296
|
-
assertContained(
|
|
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
|
}
|
package/src/memory/contracts.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
`Memory extraction response must contain only "text" and "summary" strings. output=${outputPreview(message.content)}`,
|
|
204
225
|
0,
|
|
205
226
|
);
|
|
206
227
|
}
|