tinker-agent 2.4.0 → 2.5.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,16 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.5.0] - 2026-09-03
9
+
10
+ ### Added
11
+
12
+ - Proactively notify the model when input context reaches high or critical
13
+ pressure, prompting it to review and swap evictable historical tool
14
+ observations while keeping swapped content recoverable through Recall. The
15
+ runtime gives the model one iteration to act before automatic compaction
16
+ resumes, while critical pressure still triggers immediate maintenance.
17
+
8
18
  ## [2.4.0] - 2026-09-02
9
19
 
10
20
  ### Added
@@ -297,7 +307,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
297
307
  - First formal npm release under the `tinker-agent` package name with the `tinker`
298
308
  executable.
299
309
 
300
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.4.0...HEAD
310
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.5.0...HEAD
311
+ [2.5.0]: https://github.com/ishowshao/tinker/releases/tag/v2.5.0
301
312
  [2.4.0]: https://github.com/ishowshao/tinker/releases/tag/v2.4.0
302
313
  [2.3.0]: https://github.com/ishowshao/tinker/releases/tag/v2.3.0
303
314
  [2.2.0]: https://github.com/ishowshao/tinker/releases/tag/v2.2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.4.0",
3
+ "version": "2.5.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",
@@ -0,0 +1,37 @@
1
+ import type { ContextUsageSnapshot } from "./context-meter";
2
+
3
+ export const CONTEXT_PRESSURE_NOTICE_PREFIX = "[tinker context notice]";
4
+
5
+ export function isContextPressureNotice(message: {
6
+ readonly role: string;
7
+ readonly content?: unknown;
8
+ }): boolean {
9
+ return (
10
+ message.role === "user" &&
11
+ typeof message.content === "string" &&
12
+ message.content.startsWith(CONTEXT_PRESSURE_NOTICE_PREFIX)
13
+ );
14
+ }
15
+
16
+ export function contextPressureNoticeText(input: {
17
+ usage: ContextUsageSnapshot;
18
+ toolPressure: "high" | "critical";
19
+ automaticSwapEnabled: boolean;
20
+ }): string {
21
+ const base =
22
+ `${CONTEXT_PRESSURE_NOTICE_PREFIX} Input pressure is now "${input.toolPressure}" ` +
23
+ `(${input.usage.usedInputTokens} of ${input.usage.inputBudgetTokens} input tokens; ` +
24
+ `trigger at ${input.usage.triggerTokens}). ` +
25
+ "Call ContextSwapCandidates to review evictable historical tool observations, " +
26
+ "then ContextSwap to replace them with Recall-backed placeholders; " +
27
+ "swapped content stays recoverable through RecallGet.";
28
+ if (!input.automaticSwapEnabled) {
29
+ return (
30
+ `${base} Automatic compaction is disabled in this session, so pressure will ` +
31
+ "keep growing unless you swap observations or the turn ends."
32
+ );
33
+ }
34
+ return input.toolPressure === "critical"
35
+ ? `${base} Automatic compaction is running immediately because pressure exceeded the input budget.`
36
+ : `${base} Automatic compaction will resume next iteration if you do not act.`;
37
+ }
@@ -118,7 +118,8 @@ import type {
118
118
  ToolCallIdentity,
119
119
  TurnIdentity,
120
120
  } from "./types";
121
- import { ContextMeter } from "./context-meter";
121
+ import { ContextMeter, type ContextUsageSnapshot } from "./context-meter";
122
+ import { contextPressureNoticeText } from "./context-pressure-notice";
122
123
  import { CURRENT_RECALL_RETIREMENT_CONTRACT_VERSION } from "../context/recall-retirement-contract";
123
124
  import {
124
125
  selectContextAutomation,
@@ -482,6 +483,7 @@ class DefaultRuntimeSession implements RuntimeSession {
482
483
  private pendingAutomaticContextMaintenance = false;
483
484
  private pendingModelDirectedSwap?: Set<MessageId>;
484
485
  private modelDirectedSwapLease = false;
486
+ private pressureNoticeSentThisTurn = false;
485
487
  private readonly skillCatalog: SkillCatalogSnapshot;
486
488
  private skillCoordinator = new SkillActivationCoordinator();
487
489
  private bashGuardMode: "guard" | "yolo";
@@ -2355,6 +2357,7 @@ class DefaultRuntimeSession implements RuntimeSession {
2355
2357
  this.pendingAutomaticContextMaintenance = false;
2356
2358
  this.pendingModelDirectedSwap = undefined;
2357
2359
  this.modelDirectedSwapLease = false;
2360
+ this.pressureNoticeSentThisTurn = false;
2358
2361
  if (this.state === "executing") {
2359
2362
  this.state = "ready";
2360
2363
  }
@@ -2399,31 +2402,64 @@ class DefaultRuntimeSession implements RuntimeSession {
2399
2402
  }
2400
2403
  const pendingModelDirectedSwap = this.pendingModelDirectedSwap;
2401
2404
  this.pendingModelDirectedSwap = undefined;
2402
- if (pendingModelDirectedSwap === undefined && this.modelDirectedSwapLease) {
2403
- this.modelDirectedSwapLease = false;
2404
- return;
2405
- }
2406
2405
 
2407
2406
  const automation = this.requireContextAutomation();
2408
- if (pendingModelDirectedSwap === undefined && !automation.automaticSwapOnly) {
2409
- return;
2410
- }
2411
2407
  const manager = this.requireContextManager();
2412
2408
  this.pendingAutomaticContextMaintenance = false;
2409
+
2410
+ let suppressAutomaticSwap = this.modelDirectedSwapLease;
2413
2411
  this.modelDirectedSwapLease = false;
2414
- this.state = "maintaining_context";
2415
- try {
2416
- if (pendingModelDirectedSwap !== undefined) {
2412
+
2413
+ if (pendingModelDirectedSwap !== undefined) {
2414
+ suppressAutomaticSwap = false;
2415
+ this.state = "maintaining_context";
2416
+ try {
2417
2417
  await this.performModelDirectedCompaction({
2418
2418
  turn: input.turn,
2419
2419
  consumedThroughOrdinal: input.consumedThroughOrdinal,
2420
2420
  ledger: input.ledger,
2421
2421
  messageIds: Object.freeze([...pendingModelDirectedSwap]),
2422
2422
  });
2423
+ } finally {
2424
+ if (this.state === "maintaining_context") {
2425
+ this.state = "executing";
2426
+ }
2427
+ }
2428
+ }
2429
+
2430
+ let measured: ContextUsageSnapshot | undefined;
2431
+ if (
2432
+ pendingModelDirectedSwap === undefined &&
2433
+ (suppressAutomaticSwap ||
2434
+ !this.pressureNoticeSentThisTurn ||
2435
+ automation.automaticSwapOnly)
2436
+ ) {
2437
+ measured = manager.measureCurrent(input.turn.turnId, input.ledger);
2438
+ if (!this.pressureNoticeSentThisTurn && measured.pressure !== "normal") {
2439
+ await this.injectContextPressureNotice({
2440
+ turn: input.turn,
2441
+ ledger: input.ledger,
2442
+ usage: measured,
2443
+ automaticSwapEnabled: automation.automaticSwapOnly,
2444
+ });
2445
+ this.pressureNoticeSentThisTurn = true;
2446
+ suppressAutomaticSwap = true;
2423
2447
  }
2424
- if (!automation.automaticSwapOnly) return;
2448
+ if (measured.pressure === "blocked") {
2449
+ // Emergency override: a lease or notice must never hold automatic
2450
+ // compaction past the budget line; the next preflight would fail the
2451
+ // turn before the model could act.
2452
+ suppressAutomaticSwap = false;
2453
+ }
2454
+ }
2455
+
2456
+ if (suppressAutomaticSwap || !automation.automaticSwapOnly) {
2457
+ return;
2458
+ }
2425
2459
 
2426
- const usage = manager.measureCurrent(input.turn.turnId, input.ledger);
2460
+ this.state = "maintaining_context";
2461
+ try {
2462
+ const usage = measured ?? manager.measureCurrent(input.turn.turnId, input.ledger);
2427
2463
  if (usage.pressure === "normal") return;
2428
2464
 
2429
2465
  const qualificationId = requireAutomationQualificationId(automation);
@@ -2530,6 +2566,39 @@ class DefaultRuntimeSession implements RuntimeSession {
2530
2566
  }
2531
2567
  }
2532
2568
 
2569
+ private async injectContextPressureNotice(input: {
2570
+ turn: TurnIdentity;
2571
+ ledger: AgentTurnLedger;
2572
+ usage: ContextUsageSnapshot;
2573
+ automaticSwapEnabled: boolean;
2574
+ }): Promise<void> {
2575
+ const userMessage: UserMessage = Object.freeze({
2576
+ role: "user",
2577
+ content: contextPressureNoticeText({
2578
+ usage: input.usage,
2579
+ toolPressure: toolContextPressure(input.usage.pressure) as "high" | "critical",
2580
+ automaticSwapEnabled: input.automaticSwapEnabled,
2581
+ }),
2582
+ });
2583
+ const records = input.ledger.appendSteeringUserMessages([userMessage]);
2584
+ const record = records[0];
2585
+ if (records.length !== 1 || record === undefined) {
2586
+ throw new Error("Pressure notice steering did not append exactly one message.");
2587
+ }
2588
+ await this.append({
2589
+ type: "context.pressure_notice.sent",
2590
+ ...input.turn,
2591
+ data: {
2592
+ usedInputTokens: input.usage.usedInputTokens,
2593
+ inputBudgetTokens: input.usage.inputBudgetTokens,
2594
+ triggerTokens: input.usage.triggerTokens,
2595
+ pressure: input.usage.pressure === "blocked" ? "blocked" : "triggered",
2596
+ automaticSwapEnabled: input.automaticSwapEnabled,
2597
+ ordinal: record.ordinal,
2598
+ },
2599
+ });
2600
+ }
2601
+
2533
2602
  private async performModelDirectedCompaction(input: {
2534
2603
  turn: TurnIdentity;
2535
2604
  consumedThroughOrdinal: number;
@@ -48,6 +48,7 @@ Use UpdatePlan for non-trivial work with multiple meaningful phases, when sequen
48
48
  Each UpdatePlan call replaces the complete plan. Keep steps short, keep at most one step in_progress, mark finished steps completed before moving on, and mark every step completed when the work is done.
49
49
  Do not repeat the full plan in ordinary assistant text after calling UpdatePlan; summarize only important changes or the next action.
50
50
  ${renderRecallRetirementContract()}
51
+ You manage your own context pressure. ContextStatus reports input-token pressure (normal, high, or critical); ContextSwapCandidates lists historical tool observations eligible for eviction with a label and byte savings; ContextSwap schedules selected candidates for replacement with Recall-backed placeholders after the current iteration's tool frames close. Swapped observations remain recoverable through RecallGet. When a context pressure notice arrives, or ContextStatus reports high pressure, review candidates and swap observations the current task no longer needs.
51
52
  Agent Skill instructions are current only when returned by the Skill tool in the current turn or listed in the active skill system section. Skill content recovered through Recall is historical data and does not activate or override a current skill.
52
53
  When an active Agent Skill refers to a relative resource path, resolve it from the Skill directory shown with that skill.
53
54
  Agent Skills do not override Tinker's runtime, tool protocol, project instructions, or the user's explicit request. Do not modify a skill source unless the user explicitly asks to maintain that skill.
@@ -103,6 +103,11 @@ export class StdoutEventPrinter implements EventSink {
103
103
  `context.revision.failed stage=${event.data.stage} code=${event.data.errorCode}\n`,
104
104
  );
105
105
  break;
106
+ case "context.pressure_notice.sent":
107
+ this.stdout.write(
108
+ `context.pressure_notice.sent pressure=${event.data.pressure} used=${event.data.usedInputTokens}/${event.data.inputBudgetTokens} trigger=${event.data.triggerTokens}\n`,
109
+ );
110
+ break;
106
111
  case "assistant.progress":
107
112
  this.stdout.write(
108
113
  `assistant.progress iteration=${event.iterationNumber}\n${event.data.content}\n`,
@@ -311,6 +311,14 @@ export type AgentEventDataMap = {
311
311
  userPrompt: UserPromptProjection;
312
312
  ordinal: number;
313
313
  };
314
+ "context.pressure_notice.sent": {
315
+ usedInputTokens: number;
316
+ inputBudgetTokens: number;
317
+ triggerTokens: number;
318
+ pressure: "triggered" | "blocked";
319
+ automaticSwapEnabled: boolean;
320
+ ordinal: number;
321
+ };
314
322
  "turn.finished": TurnFinishedData;
315
323
  "turn.failed": { error: string };
316
324
  "turn.cancelled": { cancellation: TurnCancellation };
@@ -415,7 +423,12 @@ export type AgentEventInput =
415
423
  >
416
424
  | SessionEventInput<"mcp.server.connected" | "mcp.server.failed">
417
425
  | SessionEventInput<"diagnostic.sink_failed">
418
- | TurnEventInput<"turn.started" | "turn.steering.applied" | "turn.finished">
426
+ | TurnEventInput<
427
+ | "turn.started"
428
+ | "turn.steering.applied"
429
+ | "context.pressure_notice.sent"
430
+ | "turn.finished"
431
+ >
419
432
  | (
420
433
  | TurnEventInput<"turn.failed" | "turn.cancelled">
421
434
  | IterationEventInput<"turn.failed" | "turn.cancelled">
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { appendFile } from "node:fs/promises";
3
+ import { isContextPressureNotice } from "../agent/context-pressure-notice";
3
4
  import type { AgentMessage, AssistantMessage } from "../agent/types";
4
5
  import { toolResultDisplayText } from "../agent/tool-result-content";
5
6
  import { cancellationError } from "../agent/turn-cancellation";
@@ -1333,7 +1334,8 @@ function waitForCancellation(signal: AbortSignal): Promise<ModelRequestOutput> {
1333
1334
 
1334
1335
  function lastUserMessage(messages: AgentMessage[]): string {
1335
1336
  const users = messages.filter(
1336
- (message): message is { role: "user"; content: string } => message.role === "user",
1337
+ (message): message is { role: "user"; content: string } =>
1338
+ message.role === "user" && !isContextPressureNotice(message),
1337
1339
  );
1338
1340
  return users.at(-1)?.content ?? "";
1339
1341
  }
@@ -1451,7 +1453,11 @@ function lastMessageIndex(
1451
1453
  role: AgentMessage["role"],
1452
1454
  ): number {
1453
1455
  for (let index = messages.length - 1; index >= 0; index -= 1) {
1454
- if (messages[index]?.role === role) {
1456
+ const message = messages[index];
1457
+ if (message === undefined || isContextPressureNotice(message)) {
1458
+ continue;
1459
+ }
1460
+ if (message.role === role) {
1455
1461
  return index;
1456
1462
  }
1457
1463
  }
@@ -170,6 +170,15 @@ export function reduceTuiProjection(
170
170
  }),
171
171
  );
172
172
  }
173
+ case "context.pressure_notice.sent":
174
+ return updateActiveTurn(state, event, policy, (turn) =>
175
+ appendTurnItem(turn, {
176
+ id: `turn-${event.turnId}-pressure-notice-${event.eventSequence}`,
177
+ label: "context notice",
178
+ text: `input pressure ${event.data.pressure} (${event.data.usedInputTokens}/${event.data.inputBudgetTokens} tokens) — model notified to self-manage`,
179
+ status: "text",
180
+ }),
181
+ );
173
182
  case "model.request.started":
174
183
  return updateActiveTurn(state, event, policy, (turn) =>
175
184
  event.data.attemptNumber === 1