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.
@@ -0,0 +1,266 @@
1
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
+ import { parseMessageId, type MessageId } from "../ids/runtime-id";
3
+ import {
4
+ defineToolExecutor,
5
+ ToolExecutionFatalError,
6
+ type ContextMaintenanceHandle,
7
+ type ContextStatusRawResult,
8
+ type ContextSwapCandidatesRawResult,
9
+ type ContextSwapRawResult,
10
+ type ToolDefinition,
11
+ type ToolExecutionContext,
12
+ type ToolExecutor,
13
+ } from "./types";
14
+
15
+ const DEFAULT_CANDIDATE_LIMIT = 20;
16
+ const MAX_CANDIDATE_LIMIT = 50;
17
+ const MAX_SWAP_CANDIDATES = 16;
18
+
19
+ export const CONTEXT_STATUS_TOOL_DEFINITION: ToolDefinition = Object.freeze({
20
+ name: "ContextStatus",
21
+ description:
22
+ "Inspect the current model-input token pressure without changing context. When pressure is high or critical, use ContextSwapCandidates to review eligible historical tool observations before choosing what to swap.",
23
+ parameters: {
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: {},
27
+ },
28
+ });
29
+
30
+ export const CONTEXT_SWAP_CANDIDATES_TOOL_DEFINITION: ToolDefinition = Object.freeze({
31
+ name: "ContextSwapCandidates",
32
+ description:
33
+ "List currently eligible historical tool observations that can be replaced by compact Recall-backed placeholders. Results are ordered oldest first. Use candidate IDs with ContextSwap; listing does not change context.",
34
+ parameters: {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ properties: {
38
+ limit: {
39
+ type: "integer",
40
+ minimum: 1,
41
+ maximum: MAX_CANDIDATE_LIMIT,
42
+ default: DEFAULT_CANDIDATE_LIMIT,
43
+ },
44
+ offset: {
45
+ type: "integer",
46
+ minimum: 0,
47
+ default: 0,
48
+ },
49
+ },
50
+ },
51
+ });
52
+
53
+ export const CONTEXT_SWAP_TOOL_DEFINITION: ToolDefinition = Object.freeze({
54
+ name: "ContextSwap",
55
+ description:
56
+ "Schedule selected ContextSwapCandidates for replacement by compact Recall-backed placeholders. The swap runs after this iteration's tool frame closes and preserves canonical history for RecallGet. Pass only candidate IDs returned by ContextSwapCandidates.",
57
+ parameters: {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ properties: {
61
+ candidate_ids: {
62
+ type: "array",
63
+ minItems: 1,
64
+ maxItems: MAX_SWAP_CANDIDATES,
65
+ uniqueItems: true,
66
+ items: {
67
+ type: "string",
68
+ pattern:
69
+ "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
70
+ },
71
+ },
72
+ },
73
+ required: ["candidate_ids"],
74
+ },
75
+ });
76
+
77
+ export const CONTEXT_MAINTENANCE_TOOL_DEFINITIONS: readonly ToolDefinition[] =
78
+ Object.freeze([
79
+ CONTEXT_STATUS_TOOL_DEFINITION,
80
+ CONTEXT_SWAP_CANDIDATES_TOOL_DEFINITION,
81
+ CONTEXT_SWAP_TOOL_DEFINITION,
82
+ ]);
83
+
84
+ export function createContextStatusToolExecutor(): ToolExecutor {
85
+ return defineToolExecutor("context_maintenance", {
86
+ definition: CONTEXT_STATUS_TOOL_DEFINITION,
87
+ async execute(args, _call, context): Promise<ContextStatusRawResult> {
88
+ throwIfTurnCancelled(context.signal);
89
+ const parsed = parseEmptyArgs(args, "ContextStatus");
90
+ if (!parsed.ok) {
91
+ return { ok: false, operation: "status", error: parsed.error };
92
+ }
93
+ const result = await requireContextMaintenance(context).status(_call);
94
+ throwIfTurnCancelled(context.signal);
95
+ return result;
96
+ },
97
+ });
98
+ }
99
+
100
+ export function createContextSwapCandidatesToolExecutor(): ToolExecutor {
101
+ return defineToolExecutor("context_maintenance", {
102
+ definition: CONTEXT_SWAP_CANDIDATES_TOOL_DEFINITION,
103
+ async execute(args, call, context): Promise<ContextSwapCandidatesRawResult> {
104
+ throwIfTurnCancelled(context.signal);
105
+ const parsed = parseCandidatePageArgs(args);
106
+ if (!parsed.ok) {
107
+ return { ok: false, operation: "candidates", error: parsed.error };
108
+ }
109
+ const result = await requireContextMaintenance(context).candidates(call, {
110
+ limit: parsed.limit,
111
+ offset: parsed.offset,
112
+ });
113
+ throwIfTurnCancelled(context.signal);
114
+ return result;
115
+ },
116
+ });
117
+ }
118
+
119
+ export function createContextSwapToolExecutor(): ToolExecutor {
120
+ return defineToolExecutor("context_maintenance", {
121
+ definition: CONTEXT_SWAP_TOOL_DEFINITION,
122
+ async execute(args, call, context): Promise<ContextSwapRawResult> {
123
+ throwIfTurnCancelled(context.signal);
124
+ const parsed = parseSwapArgs(args);
125
+ if (!parsed.ok) {
126
+ return {
127
+ ok: false,
128
+ operation: "swap",
129
+ scheduled: [],
130
+ rejected: [],
131
+ error: parsed.error,
132
+ };
133
+ }
134
+ const result = await requireContextMaintenance(context).swap(call, {
135
+ candidateIds: parsed.candidateIds,
136
+ });
137
+ throwIfTurnCancelled(context.signal);
138
+ return result;
139
+ },
140
+ });
141
+ }
142
+
143
+ function requireContextMaintenance(
144
+ context: ToolExecutionContext,
145
+ ): ContextMaintenanceHandle {
146
+ if (context.contextMaintenance === undefined) {
147
+ throw new ToolExecutionFatalError(
148
+ "Context maintenance tools have no active runtime coordinator.",
149
+ );
150
+ }
151
+ return context.contextMaintenance;
152
+ }
153
+
154
+ function parseEmptyArgs(
155
+ args: unknown,
156
+ toolName: string,
157
+ ): { ok: true } | { ok: false; error: string } {
158
+ if (!isRecord(args)) {
159
+ return { ok: false, error: `${toolName} arguments must be an object.` };
160
+ }
161
+ const unexpected = Object.keys(args)[0];
162
+ return unexpected === undefined
163
+ ? { ok: true }
164
+ : {
165
+ ok: false,
166
+ error: `${toolName} received unexpected field: ${unexpected}.`,
167
+ };
168
+ }
169
+
170
+ function parseCandidatePageArgs(
171
+ args: unknown,
172
+ ): { ok: true; limit: number; offset: number } | { ok: false; error: string } {
173
+ if (!isRecord(args)) {
174
+ return {
175
+ ok: false,
176
+ error: "ContextSwapCandidates arguments must be an object.",
177
+ };
178
+ }
179
+ const unexpected = Object.keys(args).find(
180
+ (key) => key !== "limit" && key !== "offset",
181
+ );
182
+ if (unexpected !== undefined) {
183
+ return {
184
+ ok: false,
185
+ error: `ContextSwapCandidates received unexpected field: ${unexpected}.`,
186
+ };
187
+ }
188
+ const limit = args.limit ?? DEFAULT_CANDIDATE_LIMIT;
189
+ const offset = args.offset ?? 0;
190
+ if (!isIntegerInRange(limit, 1, MAX_CANDIDATE_LIMIT)) {
191
+ return {
192
+ ok: false,
193
+ error: `ContextSwapCandidates.limit must be an integer from 1 to ${MAX_CANDIDATE_LIMIT}.`,
194
+ };
195
+ }
196
+ if (!Number.isSafeInteger(offset) || (offset as number) < 0) {
197
+ return {
198
+ ok: false,
199
+ error: "ContextSwapCandidates.offset must be a non-negative integer.",
200
+ };
201
+ }
202
+ return { ok: true, limit: limit as number, offset: offset as number };
203
+ }
204
+
205
+ function parseSwapArgs(
206
+ args: unknown,
207
+ ): { ok: true; candidateIds: readonly MessageId[] } | { ok: false; error: string } {
208
+ if (!isRecord(args)) {
209
+ return { ok: false, error: "ContextSwap arguments must be an object." };
210
+ }
211
+ const unexpected = Object.keys(args).find((key) => key !== "candidate_ids");
212
+ if (unexpected !== undefined) {
213
+ return {
214
+ ok: false,
215
+ error: `ContextSwap received unexpected field: ${unexpected}.`,
216
+ };
217
+ }
218
+ if (
219
+ !Array.isArray(args.candidate_ids) ||
220
+ args.candidate_ids.length < 1 ||
221
+ args.candidate_ids.length > MAX_SWAP_CANDIDATES
222
+ ) {
223
+ return {
224
+ ok: false,
225
+ error: `ContextSwap.candidate_ids must contain 1 to ${MAX_SWAP_CANDIDATES} message IDs.`,
226
+ };
227
+ }
228
+
229
+ const candidateIds: MessageId[] = [];
230
+ const seen = new Set<string>();
231
+ for (const candidate of args.candidate_ids) {
232
+ if (typeof candidate !== "string") {
233
+ return {
234
+ ok: false,
235
+ error: "ContextSwap.candidate_ids must contain only message ID strings.",
236
+ };
237
+ }
238
+ let candidateId: MessageId;
239
+ try {
240
+ candidateId = parseMessageId(candidate);
241
+ } catch {
242
+ return {
243
+ ok: false,
244
+ error: "ContextSwap.candidate_ids contains an invalid message ID.",
245
+ };
246
+ }
247
+ if (!seen.has(candidateId)) {
248
+ seen.add(candidateId);
249
+ candidateIds.push(candidateId);
250
+ }
251
+ }
252
+ return { ok: true, candidateIds: Object.freeze(candidateIds) };
253
+ }
254
+
255
+ function isIntegerInRange(value: unknown, minimum: number, maximum: number): boolean {
256
+ return (
257
+ typeof value === "number" &&
258
+ Number.isSafeInteger(value) &&
259
+ value >= minimum &&
260
+ value <= maximum
261
+ );
262
+ }
263
+
264
+ function isRecord(value: unknown): value is Record<string, unknown> {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
266
+ }
@@ -1,6 +1,11 @@
1
1
  import { createBashToolExecutor } from "./bash";
2
2
  import { ShellTaskManager } from "./bash-task";
3
3
  import { createCwdState } from "./cwd-state";
4
+ import {
5
+ createContextStatusToolExecutor,
6
+ createContextSwapCandidatesToolExecutor,
7
+ createContextSwapToolExecutor,
8
+ } from "./context-maintenance";
4
9
  import { createDeleteToolExecutor } from "./delete";
5
10
  import { createEditToolExecutor } from "./edit";
6
11
  import { createGlobToolExecutor } from "./glob";
@@ -26,6 +31,7 @@ import type {
26
31
  } from "../agent/runtime-session";
27
32
  import type {
28
33
  FileSnapshotStore,
34
+ ContextMaintenanceHandle,
29
35
  ToolDefinition,
30
36
  ToolExecutionContext,
31
37
  ToolExecutor,
@@ -79,6 +85,7 @@ export class ToolRuntime {
79
85
  signal: AbortSignal,
80
86
  ): Promise<"allow" | "deny">;
81
87
  },
88
+ private readonly contextMaintenance?: ContextMaintenanceHandle,
82
89
  ) {}
83
90
 
84
91
  async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
@@ -107,6 +114,9 @@ export class ToolRuntime {
107
114
  try {
108
115
  return await tool.execute(call.args, call, {
109
116
  ...context,
117
+ ...(this.contextMaintenance === undefined
118
+ ? {}
119
+ : { contextMaintenance: this.contextMaintenance }),
110
120
  ...(this.bashGuard === undefined
111
121
  ? {}
112
122
  : {
@@ -153,6 +163,7 @@ export function createDefaultTooling(options: {
153
163
  workspaceRoot: string;
154
164
  runtimeSession: RuntimeSessionContext;
155
165
  historyReader: SessionHistoryReader;
166
+ homeRoot?: string;
156
167
  maxReadContentBytes?: number;
157
168
  exaApiKey?: string;
158
169
  webFetchRefiner?: Refiner;
@@ -187,6 +198,7 @@ export function createDefaultTooling(options: {
187
198
  cwdState,
188
199
  runtimeSession,
189
200
  stopGraceMs: options.taskStopGraceMs,
201
+ ...(options.homeRoot === undefined ? {} : { homeRoot: options.homeRoot }),
190
202
  });
191
203
 
192
204
  registry.register(
@@ -226,6 +238,9 @@ export function createDefaultTooling(options: {
226
238
  registry.register(
227
239
  createRecallGetToolExecutor({ historyReader: options.historyReader }),
228
240
  );
241
+ registry.register(createContextStatusToolExecutor());
242
+ registry.register(createContextSwapCandidatesToolExecutor());
243
+ registry.register(createContextSwapToolExecutor());
229
244
  if (options.memorySearch !== undefined) {
230
245
  registry.register(options.memorySearch);
231
246
  }
@@ -302,7 +317,11 @@ export function createDefaultTooling(options: {
302
317
 
303
318
  return {
304
319
  registry,
305
- runtime: new ToolRuntime(registry, options.bashGuard),
320
+ runtime: new ToolRuntime(
321
+ registry,
322
+ options.bashGuard,
323
+ options.runtimeSession.contextMaintenance,
324
+ ),
306
325
  snapshots,
307
326
  taskManager,
308
327
  ...(turnUndoManager === undefined ? {} : { turnUndoManager }),
@@ -1,6 +1,7 @@
1
1
  import type { ToolCall } from "../agent/types";
2
2
  import type { ImageAssetRef } from "../image/image-types";
3
- import type { SessionId } from "../ids/runtime-id";
3
+ import type { MessageId, SessionId } from "../ids/runtime-id";
4
+ import type { ContextUsageSource } from "../model/model-request-preflight";
4
5
  import type {
5
6
  RecallGetPage,
6
7
  RecallSearchFilters,
@@ -310,6 +311,85 @@ export type RecallGetRawResult =
310
311
 
311
312
  export type RecallRawResult = RecallSearchRawResult | RecallGetRawResult;
312
313
 
314
+ export type ContextMaintenancePressure = "normal" | "high" | "critical";
315
+
316
+ export type ContextSwapCandidate = {
317
+ candidateId: MessageId;
318
+ label: string;
319
+ ordinal: number;
320
+ savingsBytes: number;
321
+ };
322
+
323
+ export type ContextSwapScheduledCandidate = {
324
+ candidateId: MessageId;
325
+ savingsBytes: number;
326
+ };
327
+
328
+ export type ContextSwapRejectedCandidate = {
329
+ candidateId: MessageId;
330
+ reason: string;
331
+ };
332
+
333
+ type ContextMaintenanceFailure<TOperation extends "status" | "candidates" | "swap"> = {
334
+ ok: false;
335
+ operation: TOperation;
336
+ error: string;
337
+ };
338
+
339
+ export type ContextStatusRawResult =
340
+ | {
341
+ ok: true;
342
+ operation: "status";
343
+ usedInputTokens: number;
344
+ inputBudgetTokens: number;
345
+ pressure: ContextMaintenancePressure;
346
+ triggerTokens: number;
347
+ source: ContextUsageSource;
348
+ }
349
+ | ContextMaintenanceFailure<"status">;
350
+
351
+ export type ContextSwapCandidatesRawResult =
352
+ | {
353
+ ok: true;
354
+ operation: "candidates";
355
+ total: number;
356
+ candidates: readonly ContextSwapCandidate[];
357
+ }
358
+ | ContextMaintenanceFailure<"candidates">;
359
+
360
+ export type ContextSwapRawResult =
361
+ | {
362
+ ok: true;
363
+ operation: "swap";
364
+ scheduled: readonly ContextSwapScheduledCandidate[];
365
+ rejected: readonly ContextSwapRejectedCandidate[];
366
+ note: string;
367
+ }
368
+ | {
369
+ ok: false;
370
+ operation: "swap";
371
+ scheduled: readonly ContextSwapScheduledCandidate[];
372
+ rejected: readonly ContextSwapRejectedCandidate[];
373
+ error?: string;
374
+ };
375
+
376
+ export type ContextMaintenanceRawResult =
377
+ | ContextStatusRawResult
378
+ | ContextSwapCandidatesRawResult
379
+ | ContextSwapRawResult;
380
+
381
+ export type ContextMaintenanceHandle = {
382
+ status(call: ToolCall): Promise<ContextStatusRawResult>;
383
+ candidates(
384
+ call: ToolCall,
385
+ input: { readonly limit: number; readonly offset: number },
386
+ ): Promise<ContextSwapCandidatesRawResult>;
387
+ swap(
388
+ call: ToolCall,
389
+ input: { readonly candidateIds: readonly MessageId[] },
390
+ ): Promise<ContextSwapRawResult>;
391
+ };
392
+
313
393
  export type MemorySearchRawResult =
314
394
  | {
315
395
  ok: true;
@@ -425,6 +505,7 @@ export type ToolRawResultByKind = {
425
505
  web_search: WebSearchRawResult;
426
506
  web_fetch: WebFetchRawResult;
427
507
  recall: RecallRawResult;
508
+ context_maintenance: ContextMaintenanceRawResult;
428
509
  memory_search: MemorySearchRawResult;
429
510
  memory_get: MemoryGetRawResult;
430
511
  wait: WaitRawResult;
@@ -472,6 +553,7 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
472
553
 
473
554
  export type ToolExecutionContext = {
474
555
  signal: AbortSignal;
556
+ contextMaintenance?: ContextMaintenanceHandle;
475
557
  confirmBashCommand?: (request: {
476
558
  command: string;
477
559
  reason: string;
@@ -883,6 +883,19 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
883
883
  return raw.mode === "search"
884
884
  ? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
885
885
  : `${base} -> ${raw.page.returnedBytes} historical bytes`;
886
+ case "context_maintenance":
887
+ if (!raw.ok) {
888
+ return raw.operation === "swap"
889
+ ? `${base} -> 0 scheduled, ${raw.rejected.length} rejected`
890
+ : base;
891
+ }
892
+ if (raw.operation === "status") {
893
+ return `${base} -> ${raw.pressure}, ${raw.usedInputTokens}/${raw.inputBudgetTokens} tokens`;
894
+ }
895
+ if (raw.operation === "candidates") {
896
+ return `${base} -> ${raw.candidates.length}/${raw.total} candidates`;
897
+ }
898
+ return `${base} -> ${raw.scheduled.length} scheduled, ${raw.rejected.length} rejected`;
886
899
  case "memory_search":
887
900
  if (!raw.ok) {
888
901
  return base;
@@ -980,6 +993,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
980
993
  case "web_search":
981
994
  case "web_fetch":
982
995
  case "recall":
996
+ case "context_maintenance":
983
997
  case "memory_search":
984
998
  case "memory_get":
985
999
  case "wait":
@@ -1021,6 +1035,7 @@ function toolRawResultDiff(
1021
1035
  case "web_search":
1022
1036
  case "web_fetch":
1023
1037
  case "recall":
1038
+ case "context_maintenance":
1024
1039
  case "memory_search":
1025
1040
  case "memory_get":
1026
1041
  case "wait":