tinker-agent 2.3.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;
@@ -6,6 +6,7 @@ import {
6
6
  } from "../agent/tool-result-content";
7
7
  import type {
8
8
  BashRawResult,
9
+ ContextMaintenanceRawResult,
9
10
  DeleteFileRawResult,
10
11
  EditFileRawResult,
11
12
  GenericToolRawResult,
@@ -48,6 +49,8 @@ export class ObservationBuilder {
48
49
  return renderViewImageObservation(input.raw);
49
50
  case "recall":
50
51
  return textObservation(renderRecallObservation(input.raw));
52
+ case "context_maintenance":
53
+ return textObservation(renderContextMaintenanceObservation(input.raw));
51
54
  case "memory_search":
52
55
  return textObservation(renderMemorySearchObservation(input.raw));
53
56
  case "memory_get":
@@ -117,6 +120,39 @@ function renderWaitObservation(raw: WaitRawResult): string {
117
120
  : `Wait failed: ${raw.error}`;
118
121
  }
119
122
 
123
+ function renderContextMaintenanceObservation(raw: ContextMaintenanceRawResult): string {
124
+ if (!raw.ok) {
125
+ if (raw.operation === "swap" && raw.rejected.length > 0) {
126
+ return JSON.stringify({ ok: false, rejected: raw.rejected });
127
+ }
128
+ return JSON.stringify({ ok: false, error: raw.error });
129
+ }
130
+ switch (raw.operation) {
131
+ case "status":
132
+ return JSON.stringify({
133
+ ok: true,
134
+ usedInputTokens: raw.usedInputTokens,
135
+ inputBudgetTokens: raw.inputBudgetTokens,
136
+ pressure: raw.pressure,
137
+ triggerTokens: raw.triggerTokens,
138
+ source: raw.source,
139
+ });
140
+ case "candidates":
141
+ return JSON.stringify({
142
+ ok: true,
143
+ total: raw.total,
144
+ candidates: raw.candidates,
145
+ });
146
+ case "swap":
147
+ return JSON.stringify({
148
+ ok: true,
149
+ scheduled: raw.scheduled,
150
+ rejected: raw.rejected,
151
+ note: raw.note,
152
+ });
153
+ }
154
+ }
155
+
120
156
  function assertNever(value: never): never {
121
157
  throw new Error(`Unhandled tool raw result: ${JSON.stringify(value)}`);
122
158
  }