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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.4.0] - 2026-09-02
9
+
10
+ ### Added
11
+
12
+ - Add model-directed context management. Three new built-in tools open context
13
+ maintenance to the model itself: `ContextStatus` reports live input-token
14
+ usage and pressure, `ContextSwapCandidates` lists swappable historical tool
15
+ observations with short labels and byte savings, and `ContextSwap` schedules
16
+ selected observations for eviction. Scheduled swaps are validated immediately
17
+ and committed as one context revision when the iteration's tool frames close,
18
+ leaving Recall-recoverable placeholders behind. A one-shot lease pauses
19
+ automatic compaction for exactly one iteration after a candidate listing so
20
+ the model keeps the choice under real pressure.
21
+
22
+ ### Changed
23
+
24
+ - Lower the swap eligibility floor from 8 KiB to 2 KiB per observation. Both
25
+ automatic compaction and model-directed swaps can now evict medium-sized
26
+ tool observations, individually or in batches of up to 16 per swap.
27
+
8
28
  ## [2.3.0] - 2026-08-31
9
29
 
10
30
  ### Changed
@@ -277,7 +297,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
277
297
  - First formal npm release under the `tinker-agent` package name with the `tinker`
278
298
  executable.
279
299
 
280
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.3.0...HEAD
300
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.4.0...HEAD
301
+ [2.4.0]: https://github.com/ishowshao/tinker/releases/tag/v2.4.0
281
302
  [2.3.0]: https://github.com/ishowshao/tinker/releases/tag/v2.3.0
282
303
  [2.2.0]: https://github.com/ishowshao/tinker/releases/tag/v2.2.0
283
304
  [2.1.0]: https://github.com/ishowshao/tinker/releases/tag/v2.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent/loop.ts CHANGED
@@ -56,7 +56,7 @@ export type RunAgentInput = {
56
56
  preflight: ReturnType<ContextMeter["measure"]>;
57
57
  }):
58
58
  | {
59
- trigger: Exclude<SwapPlanningTrigger, "manual">;
59
+ trigger: Exclude<SwapPlanningTrigger, "manual" | "model_directed">;
60
60
  forcedTargetTokens?: number;
61
61
  }
62
62
  | undefined;
@@ -11,6 +11,7 @@ import type {
11
11
  } from "../events/types";
12
12
  import {
13
13
  runtimeIdFactory,
14
+ type MessageId,
14
15
  type RuntimeIdFactory,
15
16
  type SessionId,
16
17
  type TurnId,
@@ -27,6 +28,7 @@ import {
27
28
  type MaterializedModelRequest,
28
29
  type ModelClient,
29
30
  } from "../model/model-client";
31
+ import type { ContextPressure } from "../model/model-request-preflight";
30
32
  import { ImageAssetStore, type ImportedImageAsset } from "../image/image-asset-store";
31
33
  import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
32
34
  import {
@@ -72,7 +74,14 @@ import {
72
74
  renderedMessageHash,
73
75
  } from "../context/compiled-context-hash";
74
76
  import { createDefaultTooling, type DefaultTooling } from "../tools/registry";
75
- import type { ToolExecutor } from "../tools/types";
77
+ import {
78
+ ToolExecutionFatalError,
79
+ type ContextMaintenanceHandle,
80
+ type ContextStatusRawResult,
81
+ type ContextSwapCandidatesRawResult,
82
+ type ContextSwapRawResult,
83
+ type ToolExecutor,
84
+ } from "../tools/types";
76
85
  import type { TurnUndoResult } from "../tools/turn-undo-manager";
77
86
  import type { Refiner } from "../tools/web-fetch/refiner";
78
87
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
@@ -105,6 +114,7 @@ import type { PublicToolingConfig } from "../cli/public-config-contract";
105
114
  import type {
106
115
  IterationIdentity,
107
116
  RunAgentResult,
117
+ ToolCall,
108
118
  ToolCallIdentity,
109
119
  TurnIdentity,
110
120
  } from "./types";
@@ -221,6 +231,7 @@ export type RuntimeSkillsSnapshot = {
221
231
 
222
232
  export type RuntimeSessionContext = {
223
233
  readonly sessionId: SessionId;
234
+ readonly contextMaintenance: ContextMaintenanceHandle;
224
235
  createIteration(turn: TurnIdentity, iterationNumber: number): IterationIdentity;
225
236
  createToolCall(
226
237
  iteration: IterationIdentity,
@@ -379,6 +390,8 @@ type RuntimeSessionState =
379
390
 
380
391
  type ActiveTurn = {
381
392
  turn: TurnIdentity;
393
+ ledger: AgentTurnLedger;
394
+ consumedThroughOrdinal?: number;
382
395
  controller: AbortController;
383
396
  completion: Promise<RunAgentResult>;
384
397
  };
@@ -467,6 +480,8 @@ class DefaultRuntimeSession implements RuntimeSession {
467
480
  private contextManager?: ContextManager;
468
481
  private contextAutomationDecision?: ContextAutomationDecision;
469
482
  private pendingAutomaticContextMaintenance = false;
483
+ private pendingModelDirectedSwap?: Set<MessageId>;
484
+ private modelDirectedSwapLease = false;
470
485
  private readonly skillCatalog: SkillCatalogSnapshot;
471
486
  private skillCoordinator = new SkillActivationCoordinator();
472
487
  private bashGuardMode: "guard" | "yolo";
@@ -517,6 +532,11 @@ class DefaultRuntimeSession implements RuntimeSession {
517
532
  this.shadowPlanner = new SwapPlanner(input.modelClient);
518
533
  this.context = {
519
534
  sessionId: this.sessionId,
535
+ contextMaintenance: {
536
+ status: (call) => this.contextStatus(call),
537
+ candidates: (call, page) => this.contextSwapCandidates(call, page),
538
+ swap: (call, selection) => this.contextSwap(call, selection),
539
+ },
520
540
  createIteration: (turn, iterationNumber) =>
521
541
  this.createIteration(turn, iterationNumber),
522
542
  createToolCall: (iteration, toolCallNumber) =>
@@ -1248,6 +1268,133 @@ class DefaultRuntimeSession implements RuntimeSession {
1248
1268
  return this.contextAutomationDecision;
1249
1269
  }
1250
1270
 
1271
+ private async contextStatus(call: ToolCall): Promise<ContextStatusRawResult> {
1272
+ const active = this.requireActiveContextTool(call, "ContextStatus");
1273
+ try {
1274
+ const usage = this.requireContextManager().measureActive(
1275
+ active.turn.turnId,
1276
+ active.ledger,
1277
+ );
1278
+ return Object.freeze({
1279
+ ok: true,
1280
+ operation: "status",
1281
+ usedInputTokens: usage.usedInputTokens,
1282
+ inputBudgetTokens: usage.inputBudgetTokens,
1283
+ pressure: toolContextPressure(usage.pressure),
1284
+ triggerTokens: usage.triggerTokens,
1285
+ source: usage.source,
1286
+ });
1287
+ } catch (error) {
1288
+ return {
1289
+ ok: false,
1290
+ operation: "status",
1291
+ error: this.contextToolFailure("status", error),
1292
+ };
1293
+ }
1294
+ }
1295
+
1296
+ private async contextSwapCandidates(
1297
+ call: ToolCall,
1298
+ page: { readonly limit: number; readonly offset: number },
1299
+ ): Promise<ContextSwapCandidatesRawResult> {
1300
+ const active = this.requireActiveContextTool(call, "ContextSwapCandidates");
1301
+ try {
1302
+ const result = this.requireContextManager().listActiveSwapCandidates({
1303
+ turnId: active.turn.turnId,
1304
+ consumedThroughOrdinal: active.consumedThroughOrdinal,
1305
+ activeLedger: active.ledger,
1306
+ limit: page.limit,
1307
+ offset: page.offset,
1308
+ });
1309
+ if (result.total > 0 && result.usage.pressure !== "normal") {
1310
+ this.modelDirectedSwapLease = true;
1311
+ }
1312
+ return Object.freeze({
1313
+ ok: true,
1314
+ operation: "candidates",
1315
+ total: result.total,
1316
+ candidates: result.candidates,
1317
+ });
1318
+ } catch (error) {
1319
+ return {
1320
+ ok: false,
1321
+ operation: "candidates",
1322
+ error: this.contextToolFailure("candidate listing", error),
1323
+ };
1324
+ }
1325
+ }
1326
+
1327
+ private async contextSwap(
1328
+ call: ToolCall,
1329
+ selection: { readonly candidateIds: readonly MessageId[] },
1330
+ ): Promise<ContextSwapRawResult> {
1331
+ const active = this.requireActiveContextTool(call, "ContextSwap");
1332
+ try {
1333
+ const result = this.requireContextManager().validateActiveSwapSelection({
1334
+ turnId: active.turn.turnId,
1335
+ consumedThroughOrdinal: active.consumedThroughOrdinal,
1336
+ activeLedger: active.ledger,
1337
+ messageIds: selection.candidateIds,
1338
+ });
1339
+ if (result.scheduled.length === 0) {
1340
+ return Object.freeze({
1341
+ ok: false,
1342
+ operation: "swap",
1343
+ scheduled: Object.freeze([]),
1344
+ rejected: result.rejected,
1345
+ });
1346
+ }
1347
+ const pending = (this.pendingModelDirectedSwap ??= new Set<MessageId>());
1348
+ for (const candidate of result.scheduled) pending.add(candidate.candidateId);
1349
+ this.modelDirectedSwapLease = false;
1350
+ return Object.freeze({
1351
+ ok: true,
1352
+ operation: "swap",
1353
+ scheduled: result.scheduled,
1354
+ rejected: result.rejected,
1355
+ note: "Swap executes when this iteration's tool frames close.",
1356
+ });
1357
+ } catch (error) {
1358
+ return {
1359
+ ok: false,
1360
+ operation: "swap",
1361
+ scheduled: [],
1362
+ rejected: [],
1363
+ error: this.contextToolFailure("swap scheduling", error),
1364
+ };
1365
+ }
1366
+ }
1367
+
1368
+ private requireActiveContextTool(
1369
+ call: ToolCall,
1370
+ expectedName: "ContextStatus" | "ContextSwapCandidates" | "ContextSwap",
1371
+ ): ActiveTurn & { consumedThroughOrdinal: number } {
1372
+ this.requireToolCall(call);
1373
+ const active = this.activeTurn;
1374
+ if (
1375
+ call.name !== expectedName ||
1376
+ active === undefined ||
1377
+ active.turn.turnId !== call.turnId ||
1378
+ active.consumedThroughOrdinal === undefined ||
1379
+ this.state !== "executing"
1380
+ ) {
1381
+ throw new ToolExecutionFatalError(
1382
+ `${expectedName} was called outside an active model iteration.`,
1383
+ );
1384
+ }
1385
+ return active as ActiveTurn & { consumedThroughOrdinal: number };
1386
+ }
1387
+
1388
+ private contextToolFailure(operation: string, error: unknown): string {
1389
+ if (error instanceof ContextManagerError && !error.fatal) {
1390
+ return `Context ${operation} failed (${boundedContextErrorCode(error.code)}).`;
1391
+ }
1392
+ throw new ToolExecutionFatalError(
1393
+ `Context ${operation} required canonical session state that could not be read safely.`,
1394
+ { cause: error },
1395
+ );
1396
+ }
1397
+
1251
1398
  private appendSkillsCatalogLoaded(): Promise<void> {
1252
1399
  const activeNames = this.skillCoordinator
1253
1400
  .activeEntries()
@@ -1307,6 +1454,15 @@ class DefaultRuntimeSession implements RuntimeSession {
1307
1454
  iteration: IterationIdentity;
1308
1455
  built: BuiltContextRequest;
1309
1456
  }): void {
1457
+ const active = this.activeTurn;
1458
+ if (
1459
+ active === undefined ||
1460
+ active.turn.turnId !== input.iteration.turnId ||
1461
+ active.turn.sessionId !== input.iteration.sessionId
1462
+ ) {
1463
+ throw new Error("Model dispatch does not belong to the active runtime turn.");
1464
+ }
1465
+ active.consumedThroughOrdinal = input.built.canonical.messages.length;
1310
1466
  const pending = this.store.loadSkillActivations(["pending"]);
1311
1467
  if (pending.length === 0) {
1312
1468
  return;
@@ -1675,7 +1831,12 @@ class DefaultRuntimeSession implements RuntimeSession {
1675
1831
  usage: admissionSnapshot,
1676
1832
  },
1677
1833
  });
1678
- this.activeTurn = { turn, controller, completion };
1834
+ this.activeTurn = {
1835
+ turn,
1836
+ ledger: pendingLedgerTurn.agent,
1837
+ controller,
1838
+ completion,
1839
+ };
1679
1840
  this.notifyPromptScheduler();
1680
1841
  return Object.freeze({
1681
1842
  turnId: turn.turnId,
@@ -2192,6 +2353,8 @@ class DefaultRuntimeSession implements RuntimeSession {
2192
2353
  this.activeTurn = undefined;
2193
2354
  this.notifyPromptScheduler();
2194
2355
  this.pendingAutomaticContextMaintenance = false;
2356
+ this.pendingModelDirectedSwap = undefined;
2357
+ this.modelDirectedSwapLease = false;
2195
2358
  if (this.state === "executing") {
2196
2359
  this.state = "ready";
2197
2360
  }
@@ -2229,28 +2392,48 @@ class DefaultRuntimeSession implements RuntimeSession {
2229
2392
  consumedThroughOrdinal: number;
2230
2393
  ledger: AgentTurnLedger;
2231
2394
  }): Promise<void> {
2232
- const automation = this.requireContextAutomation();
2233
- if (!automation.automaticSwapOnly) return;
2234
2395
  if (this.state !== "executing") {
2235
2396
  throw new Error(
2236
2397
  `Cannot maintain active-turn context while RuntimeSession is ${this.state}.`,
2237
2398
  );
2238
2399
  }
2239
- const manager = this.requireContextManager();
2240
- const usage = manager.measureCurrent(input.turn.turnId, input.ledger);
2241
- if (usage.pressure === "normal") return;
2400
+ const pendingModelDirectedSwap = this.pendingModelDirectedSwap;
2401
+ this.pendingModelDirectedSwap = undefined;
2402
+ if (pendingModelDirectedSwap === undefined && this.modelDirectedSwapLease) {
2403
+ this.modelDirectedSwapLease = false;
2404
+ return;
2405
+ }
2242
2406
 
2243
- const qualificationId = requireAutomationQualificationId(automation);
2244
- const compactionTrigger = {
2245
- kind: "runtime_pressure",
2246
- activeTurn: {
2247
- turnId: input.turn.turnId,
2248
- consumedThroughOrdinal: input.consumedThroughOrdinal,
2249
- },
2250
- } as const;
2407
+ const automation = this.requireContextAutomation();
2408
+ if (pendingModelDirectedSwap === undefined && !automation.automaticSwapOnly) {
2409
+ return;
2410
+ }
2411
+ const manager = this.requireContextManager();
2251
2412
  this.pendingAutomaticContextMaintenance = false;
2413
+ this.modelDirectedSwapLease = false;
2252
2414
  this.state = "maintaining_context";
2253
2415
  try {
2416
+ if (pendingModelDirectedSwap !== undefined) {
2417
+ await this.performModelDirectedCompaction({
2418
+ turn: input.turn,
2419
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2420
+ ledger: input.ledger,
2421
+ messageIds: Object.freeze([...pendingModelDirectedSwap]),
2422
+ });
2423
+ }
2424
+ if (!automation.automaticSwapOnly) return;
2425
+
2426
+ const usage = manager.measureCurrent(input.turn.turnId, input.ledger);
2427
+ if (usage.pressure === "normal") return;
2428
+
2429
+ const qualificationId = requireAutomationQualificationId(automation);
2430
+ const compactionTrigger = {
2431
+ kind: "runtime_pressure",
2432
+ activeTurn: {
2433
+ turnId: input.turn.turnId,
2434
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2435
+ },
2436
+ } as const;
2254
2437
  await this.append({
2255
2438
  type: "context.revision.started",
2256
2439
  sessionId: this.sessionId,
@@ -2347,6 +2530,56 @@ class DefaultRuntimeSession implements RuntimeSession {
2347
2530
  }
2348
2531
  }
2349
2532
 
2533
+ private async performModelDirectedCompaction(input: {
2534
+ turn: TurnIdentity;
2535
+ consumedThroughOrdinal: number;
2536
+ ledger: AgentTurnLedger;
2537
+ messageIds: readonly MessageId[];
2538
+ }): Promise<void> {
2539
+ await this.append({
2540
+ type: "context.revision.started",
2541
+ sessionId: this.sessionId,
2542
+ data: {
2543
+ strategy: "swap",
2544
+ reason: "model_directed",
2545
+ policyVersion: "swap-only-v1",
2546
+ rendererFormat: "swap-observation-v1",
2547
+ },
2548
+ });
2549
+ try {
2550
+ const result = await this.requireContextManager().compact(
2551
+ {
2552
+ kind: "model_directed",
2553
+ messageIds: input.messageIds,
2554
+ activeTurn: {
2555
+ turnId: input.turn.turnId,
2556
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2557
+ },
2558
+ },
2559
+ input.ledger,
2560
+ );
2561
+ await this.append({
2562
+ type: "context.revision.finished",
2563
+ sessionId: this.sessionId,
2564
+ data: contextRevisionFinishedData(result, "model_directed"),
2565
+ });
2566
+ } catch (error) {
2567
+ const failure = automaticContextFailure(error, "compaction");
2568
+ await this.append({
2569
+ type: "context.revision.failed",
2570
+ sessionId: this.sessionId,
2571
+ data: {
2572
+ strategy: "swap",
2573
+ reason: "model_directed",
2574
+ stage: failure.stage,
2575
+ errorCode: boundedContextErrorCode(failure.code),
2576
+ error: `Model-directed context compaction failed at ${failure.stage}.`,
2577
+ },
2578
+ }).catch(() => undefined);
2579
+ if (failure.fatal) throw error;
2580
+ }
2581
+ }
2582
+
2350
2583
  private notifyCompletedTurn(turn: TurnIdentity): void {
2351
2584
  const hook = this.input.completedTurnHook;
2352
2585
  if (hook === undefined) {
@@ -3086,7 +3319,7 @@ function errorMessage(error: unknown): string {
3086
3319
 
3087
3320
  function contextRevisionFinishedData(
3088
3321
  result: ContextCompactionResult,
3089
- reason: "manual" | "runtime_pressure" = "manual",
3322
+ reason: "manual" | "runtime_pressure" | "model_directed" = "manual",
3090
3323
  qualificationId?: string,
3091
3324
  ): ContextRevisionFinishedData {
3092
3325
  if (result.status === "unchanged") {
@@ -3224,6 +3457,16 @@ function boundedContextErrorCode(code: string): string {
3224
3457
  : "CONTEXT_COMPACTION_FAILED";
3225
3458
  }
3226
3459
 
3460
+ function toolContextPressure(
3461
+ pressure: ContextPressure,
3462
+ ): "normal" | "high" | "critical" {
3463
+ return pressure === "triggered"
3464
+ ? "high"
3465
+ : pressure === "blocked"
3466
+ ? "critical"
3467
+ : "normal";
3468
+ }
3469
+
3227
3470
  function requirePositiveNumber(value: number, name: string): void {
3228
3471
  if (!Number.isInteger(value) || value < 1) {
3229
3472
  throw new Error(`${name} must be a positive integer; received ${value}.`);
@@ -116,7 +116,10 @@ export type AgentTurnLedger = {
116
116
  commitToolCompletions(
117
117
  completions: readonly ToolCompletionInput[],
118
118
  ): readonly CommittedToolCompletion[];
119
- buildModelRequest(tools: readonly ToolDefinition[]): BuiltContextRequest;
119
+ buildModelRequest(
120
+ tools: readonly ToolDefinition[],
121
+ options?: { readonly allowOpenTail?: boolean },
122
+ ): BuiltContextRequest;
120
123
  activateContextSnapshot(snapshot: StoredContextSnapshotV8): void;
121
124
  };
122
125
 
@@ -657,9 +660,10 @@ export class InMemorySessionLedger implements SessionLedger {
657
660
  buildTurnModelRequest(
658
661
  pending: InMemoryPendingLedgerTurn,
659
662
  tools: readonly ToolDefinition[],
663
+ options: { readonly allowOpenTail?: boolean } = {},
660
664
  ): BuiltContextRequest {
661
665
  this.requirePending(pending, "build a model request");
662
- return this.buildRequest(tools);
666
+ return this.buildRequest(tools, undefined, options);
663
667
  }
664
668
 
665
669
  finishTurn(pending: InMemoryPendingLedgerTurn, result: RunAgentResult): void {
@@ -702,11 +706,13 @@ export class InMemorySessionLedger implements SessionLedger {
702
706
  private buildRequest(
703
707
  tools: readonly ToolDefinition[],
704
708
  candidateUserMessage?: UserMessage,
709
+ options: { readonly allowOpenTail?: boolean } = {},
705
710
  ): BuiltContextRequest {
706
711
  try {
707
712
  const canonical = this.view;
708
713
  const compiled = this.revisionCompiler.compileActive(
709
714
  snapshotFor(canonical, this.revision, this.surface, this.activeOverrides),
715
+ { allowOpenTail: options.allowOpenTail },
710
716
  );
711
717
  return this.contextBuilder.build({
712
718
  canonical,
@@ -865,7 +871,8 @@ class InMemoryPendingLedgerTurn implements PendingLedgerTurn {
865
871
  assertCanExecuteTool: (call) => this.ledger.assertCanExecuteTool(this, call),
866
872
  commitToolCompletions: (completions) =>
867
873
  this.ledger.commitToolCompletions(this, completions),
868
- buildModelRequest: (tools) => this.ledger.buildTurnModelRequest(this, tools),
874
+ buildModelRequest: (tools, options) =>
875
+ this.ledger.buildTurnModelRequest(this, tools, options),
869
876
  activateContextSnapshot: (snapshot) =>
870
877
  this.ledger.activateContextSnapshot(snapshot),
871
878
  };