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,7 +1,7 @@
1
1
  import { ContextBuilder } from "../agent/context-builder";
2
2
  import type { ContextMeter, ContextUsageSnapshot } from "../agent/context-meter";
3
3
  import type { AgentTurnLedger, SessionLedger } from "../agent/session-ledger";
4
- import type { RuntimeIdFactory, TurnId } from "../ids/runtime-id";
4
+ import type { MessageId, RuntimeIdFactory, TurnId } from "../ids/runtime-id";
5
5
  import {
6
6
  CommittedPrefixAuditError,
7
7
  type CommittedPrefixAuditor,
@@ -15,7 +15,12 @@ import {
15
15
  type CommitSkillsUpdateInput,
16
16
  type SessionStore,
17
17
  } from "../session/session-store";
18
- import type { ToolDefinition } from "../tools/types";
18
+ import type {
19
+ ContextSwapCandidate,
20
+ ContextSwapRejectedCandidate,
21
+ ContextSwapScheduledCandidate,
22
+ ToolDefinition,
23
+ } from "../tools/types";
19
24
  import {
20
25
  activeOverrideManifestHash,
21
26
  canonicalSequenceHash,
@@ -36,10 +41,11 @@ import {
36
41
  } from "./context-surface";
37
42
  import {
38
43
  assertPlanBaseCurrent,
44
+ swapCandidateRejectionReason,
39
45
  SwapPlanner,
40
46
  SwapPlanningDiagnosticError,
41
- SwapPlanStaleError,
42
47
  } from "./swap-planner";
48
+ import { renderContextSwapCandidateLabel } from "./context-swap-label";
43
49
  import {
44
50
  assertRetirementPlanBaseCurrent,
45
51
  PrefixRetirementPlanner,
@@ -59,8 +65,28 @@ export type ContextCompactionTrigger =
59
65
  consumedThroughOrdinal: number;
60
66
  };
61
67
  }
68
+ | {
69
+ kind: "model_directed";
70
+ messageIds: readonly MessageId[];
71
+ activeTurn: {
72
+ turnId: TurnId;
73
+ consumedThroughOrdinal: number;
74
+ };
75
+ }
62
76
  | { kind: "benchmark_forced"; targetTokens: number };
63
77
 
78
+ export type ActiveSwapCandidatePage = {
79
+ readonly usage: ContextUsageSnapshot;
80
+ readonly total: number;
81
+ readonly candidates: readonly ContextSwapCandidate[];
82
+ };
83
+
84
+ export type ActiveSwapSelection = {
85
+ readonly usage: ContextUsageSnapshot;
86
+ readonly scheduled: readonly ContextSwapScheduledCandidate[];
87
+ readonly rejected: readonly ContextSwapRejectedCandidate[];
88
+ };
89
+
64
90
  export type ContextRetirementTrigger =
65
91
  | { kind: "manual" }
66
92
  | { kind: "runtime_pressure"; activeTurnId?: TurnId }
@@ -267,6 +293,155 @@ export class ContextManager {
267
293
  return this.input.contextMeter.measure(prepared);
268
294
  }
269
295
 
296
+ measureActive(turnId: TurnId, activeLedger: AgentTurnLedger): ContextUsageSnapshot {
297
+ return this.inspectActive(turnId, activeLedger).usage;
298
+ }
299
+
300
+ listActiveSwapCandidates(input: {
301
+ turnId: TurnId;
302
+ consumedThroughOrdinal: number;
303
+ activeLedger: AgentTurnLedger;
304
+ limit: number;
305
+ offset: number;
306
+ }): ActiveSwapCandidatePage {
307
+ if (
308
+ !Number.isSafeInteger(input.limit) ||
309
+ input.limit < 1 ||
310
+ input.limit > 50 ||
311
+ !Number.isSafeInteger(input.offset) ||
312
+ input.offset < 0
313
+ ) {
314
+ throw new Error("Active swap candidate pagination is invalid.");
315
+ }
316
+ const inspected = this.inspectActive(input.turnId, input.activeLedger);
317
+ let scan;
318
+ try {
319
+ scan = this.planner.scanCandidates(
320
+ inspected.built.canonical,
321
+ inspected.built.activeOverrides,
322
+ swapOnlyPolicyV1,
323
+ inspected.built.revision.keepFromOrdinal,
324
+ {
325
+ turnId: input.turnId,
326
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
327
+ },
328
+ );
329
+ } catch (error) {
330
+ throw managerError("plan", error);
331
+ }
332
+ const chronological = [...scan.eligible].sort(
333
+ (left, right) =>
334
+ left.message.ordinal - right.message.ordinal ||
335
+ left.message.messageId.localeCompare(right.message.messageId),
336
+ );
337
+ const candidates = chronological
338
+ .slice(input.offset, input.offset + input.limit)
339
+ .map((candidate) =>
340
+ Object.freeze({
341
+ candidateId: candidate.message.messageId,
342
+ label: renderContextSwapCandidateLabel({
343
+ canonical: inspected.built.canonical,
344
+ message: candidate.message,
345
+ result: candidate.result,
346
+ }),
347
+ ordinal: candidate.message.ordinal,
348
+ savingsBytes: candidate.override.byteSavings,
349
+ }),
350
+ );
351
+ return Object.freeze({
352
+ usage: inspected.usage,
353
+ total: chronological.length,
354
+ candidates: Object.freeze(candidates),
355
+ });
356
+ }
357
+
358
+ validateActiveSwapSelection(input: {
359
+ turnId: TurnId;
360
+ consumedThroughOrdinal: number;
361
+ activeLedger: AgentTurnLedger;
362
+ messageIds: readonly MessageId[];
363
+ }): ActiveSwapSelection {
364
+ if (
365
+ input.messageIds.length < 1 ||
366
+ input.messageIds.length > 16 ||
367
+ new Set(input.messageIds).size !== input.messageIds.length
368
+ ) {
369
+ throw new Error("Active swap selection must contain 1 to 16 unique IDs.");
370
+ }
371
+ const inspected = this.inspectActive(input.turnId, input.activeLedger);
372
+ let scan;
373
+ try {
374
+ scan = this.planner.scanCandidates(
375
+ inspected.built.canonical,
376
+ inspected.built.activeOverrides,
377
+ swapOnlyPolicyV1,
378
+ inspected.built.revision.keepFromOrdinal,
379
+ {
380
+ turnId: input.turnId,
381
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
382
+ },
383
+ );
384
+ } catch (error) {
385
+ throw managerError("plan", error);
386
+ }
387
+ const eligibleById = new Map(
388
+ scan.eligible.map((candidate) => [candidate.message.messageId, candidate]),
389
+ );
390
+ const scheduled: ContextSwapScheduledCandidate[] = [];
391
+ const rejected: ContextSwapRejectedCandidate[] = [];
392
+ for (const messageId of input.messageIds) {
393
+ const candidate = eligibleById.get(messageId);
394
+ if (candidate === undefined) {
395
+ rejected.push(
396
+ Object.freeze({
397
+ candidateId: messageId,
398
+ reason: swapCandidateRejectionReason(
399
+ scan,
400
+ inspected.built.canonical,
401
+ messageId,
402
+ ),
403
+ }),
404
+ );
405
+ } else {
406
+ scheduled.push(
407
+ Object.freeze({
408
+ candidateId: messageId,
409
+ savingsBytes: candidate.override.byteSavings,
410
+ }),
411
+ );
412
+ }
413
+ }
414
+ return Object.freeze({
415
+ usage: inspected.usage,
416
+ scheduled: Object.freeze(scheduled),
417
+ rejected: Object.freeze(rejected),
418
+ });
419
+ }
420
+
421
+ private inspectActive(turnId: TurnId, activeLedger: AgentTurnLedger) {
422
+ const tools = this.input.tools();
423
+ try {
424
+ const built = activeLedger.buildModelRequest(tools, {
425
+ allowOpenTail: true,
426
+ });
427
+ if (
428
+ !built.canonical.messages.some(
429
+ (message) => message.role !== "system" && message.turnId === turnId,
430
+ )
431
+ ) {
432
+ throw new ContextRevisionError(
433
+ "Active context view does not contain the requested turn.",
434
+ );
435
+ }
436
+ const prepared = this.input.model.prepare(built.request);
437
+ this.input.committedPrefixAuditor.audit(built.compiled.revisionId, prepared);
438
+ const usage = this.input.contextMeter.measure(prepared);
439
+ return Object.freeze({ built, prepared, usage });
440
+ } catch (error) {
441
+ throw managerError("snapshot", error);
442
+ }
443
+ }
444
+
270
445
  async compact(
271
446
  trigger: ContextCompactionTrigger,
272
447
  activeLedger?: AgentTurnLedger,
@@ -299,9 +474,13 @@ export class ContextManager {
299
474
  tools,
300
475
  policy: swapOnlyPolicyV1,
301
476
  trigger: trigger.kind,
302
- ...(trigger.kind === "runtime_pressure" && trigger.activeTurn !== undefined
477
+ ...((trigger.kind === "runtime_pressure" && trigger.activeTurn !== undefined) ||
478
+ trigger.kind === "model_directed"
303
479
  ? { activeTurn: trigger.activeTurn }
304
480
  : {}),
481
+ ...(trigger.kind === "model_directed"
482
+ ? { selectedMessageIds: trigger.messageIds }
483
+ : {}),
305
484
  ...(trigger.kind === "benchmark_forced"
306
485
  ? { forcedTargetTokens: trigger.targetTokens }
307
486
  : {}),
@@ -763,7 +942,6 @@ function isFatalContextError(error: unknown): boolean {
763
942
  error instanceof ContextProtocolError ||
764
943
  error instanceof ContextRevisionError ||
765
944
  error instanceof CompiledContextError ||
766
- error instanceof SwapPlanStaleError ||
767
945
  error instanceof PrefixRetirementPlanStaleError ||
768
946
  error instanceof CommittedPrefixAuditError ||
769
947
  error instanceof SessionError
@@ -787,9 +965,8 @@ function elapsedMs(startedAt: number): number {
787
965
  function activeTurnId(
788
966
  trigger: ContextCompactionTrigger | ContextRetirementTrigger,
789
967
  ): TurnId | undefined {
790
- if (trigger.kind !== "runtime_pressure") {
791
- return undefined;
792
- }
968
+ if (trigger.kind === "model_directed") return trigger.activeTurn.turnId;
969
+ if (trigger.kind !== "runtime_pressure") return undefined;
793
970
  if ("activeTurnId" in trigger) {
794
971
  return trigger.activeTurnId;
795
972
  }
@@ -1,6 +1,6 @@
1
1
  export const swapOnlyPolicyV1 = Object.freeze({
2
2
  version: "swap-only-v1",
3
- minimumObservationBytes: 8 * 1_024,
3
+ minimumObservationBytes: 2 * 1_024,
4
4
  targetInputRatio: 0.3,
5
5
  } as const);
6
6
 
@@ -36,9 +36,14 @@ export class ContextRevisionCompiler {
36
36
  private readonly compiledValidator = new CompiledContextValidator(),
37
37
  ) {}
38
38
 
39
- compileActive(snapshot: StoredContextSnapshotV8): CompiledRevisionContext {
39
+ compileActive(
40
+ snapshot: StoredContextSnapshotV8,
41
+ options: { readonly allowOpenTail?: boolean } = {},
42
+ ): CompiledRevisionContext {
40
43
  validateSnapshotIdentity(snapshot);
41
- this.protocolValidator.validate(snapshot.canonical);
44
+ this.protocolValidator.validate(snapshot.canonical, {
45
+ allowOpenTail: options.allowOpenTail,
46
+ });
42
47
  const overrides = overrideMap(snapshot.activeOverrides);
43
48
  const compiled = compileEntries({
44
49
  canonical: snapshot.canonical,
@@ -0,0 +1,132 @@
1
+ import type { ToolCall } from "../agent/types";
2
+ import type { ToolRawResult } from "../tools/types";
3
+ import type {
4
+ CanonicalMessageRecord,
5
+ ProtocolContextView,
6
+ ToolResultRecord,
7
+ } from "./protocol-frame";
8
+
9
+ const MAX_LABEL_BYTES = 80;
10
+
11
+ export function renderContextSwapCandidateLabel(input: {
12
+ canonical: ProtocolContextView;
13
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>;
14
+ result: ToolResultRecord;
15
+ }): string {
16
+ const call = toolCallForMessage(input.canonical, input.message);
17
+ const raw =
18
+ input.result.completion.kind === "returned"
19
+ ? input.result.completion.raw
20
+ : undefined;
21
+ return compactLabel(labelFor(input.message.name, call, raw));
22
+ }
23
+
24
+ function labelFor(
25
+ toolName: string,
26
+ call: ToolCall | undefined,
27
+ raw: ToolRawResult | undefined,
28
+ ): string {
29
+ const args = asRecord(call?.args);
30
+ switch (raw?.kind) {
31
+ case "bash": {
32
+ const description = nonEmptyString(args?.description);
33
+ const command = nonEmptyString(args?.command)?.split(/\r?\n/, 1)[0];
34
+ return prefixed("Bash", description ?? command, toolName);
35
+ }
36
+ case "read": {
37
+ const filePath = nonEmptyString(args?.file_path);
38
+ return prefixed("Read", readLocation(filePath, args), toolName);
39
+ }
40
+ case "grep": {
41
+ const pattern = nonEmptyString(args?.pattern);
42
+ if (pattern === undefined) return toolName;
43
+ const searchPath = nonEmptyString(args?.path);
44
+ return `Grep: ${JSON.stringify(pattern)}${searchPath === undefined ? "" : ` in ${searchPath}`}`;
45
+ }
46
+ case "glob":
47
+ return prefixed("Glob", nonEmptyString(args?.pattern), toolName);
48
+ case "task_output":
49
+ return prefixed("TaskOutput", nonEmptyString(args?.task_id), toolName);
50
+ case "web_search":
51
+ return prefixed("WebSearch", nonEmptyString(args?.query), toolName);
52
+ case "web_fetch": {
53
+ const url = nonEmptyString(args?.url)?.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "");
54
+ return prefixed("WebFetch", url, toolName);
55
+ }
56
+ case "mcp":
57
+ return `MCP: ${raw.serverName}.${raw.serverToolName}`;
58
+ case "view_image":
59
+ return prefixed(
60
+ "view_image",
61
+ nonEmptyString(args?.file_path) ?? raw.filePath,
62
+ toolName,
63
+ );
64
+ default:
65
+ return toolName;
66
+ }
67
+ }
68
+
69
+ function toolCallForMessage(
70
+ canonical: ProtocolContextView,
71
+ message: Extract<CanonicalMessageRecord, { role: "tool" }>,
72
+ ): ToolCall | undefined {
73
+ const assistant = canonical.messages.find(
74
+ (entry) => entry.role === "assistant" && entry.frameId === message.frameId,
75
+ );
76
+ return assistant?.role === "assistant"
77
+ ? assistant.toolCalls?.find((call) => call.toolCallId === message.toolCallId)
78
+ : undefined;
79
+ }
80
+
81
+ function readLocation(
82
+ filePath: string | undefined,
83
+ args: Record<string, unknown> | undefined,
84
+ ): string | undefined {
85
+ if (filePath === undefined) return undefined;
86
+ const offset = positiveInteger(args?.offset);
87
+ const limit = positiveInteger(args?.limit);
88
+ if (offset === undefined && limit === undefined) return filePath;
89
+ const start = offset ?? 1;
90
+ return limit === undefined
91
+ ? `${filePath}:${start}`
92
+ : `${filePath}:${start}-${start + limit - 1}`;
93
+ }
94
+
95
+ function prefixed(prefix: string, value: string | undefined, fallback: string): string {
96
+ return value === undefined ? fallback : `${prefix}: ${value}`;
97
+ }
98
+
99
+ function compactLabel(value: string): string {
100
+ const normalized = value.replace(/[\p{Cc}\p{Cf}\s]+/gu, " ").trim();
101
+ const label = normalized === "" ? "Tool" : normalized;
102
+ if (Buffer.byteLength(label, "utf8") <= MAX_LABEL_BYTES) return label;
103
+ return `${utf8Prefix(label, MAX_LABEL_BYTES - Buffer.byteLength("…", "utf8"))}…`;
104
+ }
105
+
106
+ function utf8Prefix(value: string, maximumBytes: number): string {
107
+ let output = "";
108
+ let bytes = 0;
109
+ for (const character of value) {
110
+ const size = Buffer.byteLength(character, "utf8");
111
+ if (bytes + size > maximumBytes) break;
112
+ output += character;
113
+ bytes += size;
114
+ }
115
+ return output;
116
+ }
117
+
118
+ function nonEmptyString(value: unknown): string | undefined {
119
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
120
+ }
121
+
122
+ function positiveInteger(value: unknown): number | undefined {
123
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
124
+ ? value
125
+ : undefined;
126
+ }
127
+
128
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value)
130
+ ? (value as Record<string, unknown>)
131
+ : undefined;
132
+ }