tinker-agent 2.3.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 +33 -1
- package/package.json +1 -1
- package/src/agent/context-pressure-notice.ts +37 -0
- package/src/agent/loop.ts +1 -1
- package/src/agent/runtime-session.ts +329 -17
- package/src/agent/session-ledger.ts +10 -3
- package/src/cli/runner-dependencies.ts +1 -0
- package/src/context/context-manager.ts +185 -8
- package/src/context/context-policy.ts +1 -1
- package/src/context/context-revision-compiler.ts +7 -2
- package/src/context/context-swap-label.ts +132 -0
- package/src/context/swap-planner.ts +219 -56
- package/src/events/stdout-event-printer.ts +6 -0
- package/src/events/types.ts +17 -4
- package/src/model/fake-model-client.ts +8 -2
- package/src/observation/observation-builder.ts +36 -0
- package/src/session/session-store.ts +300 -0
- package/src/tools/context-maintenance.ts +266 -0
- package/src/tools/registry.ts +18 -1
- package/src/tools/types.ts +83 -1
- package/src/tui/event-store.ts +24 -0
|
@@ -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 {
|
|
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
|
|
791
|
-
|
|
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
|
}
|
|
@@ -36,9 +36,14 @@ export class ContextRevisionCompiler {
|
|
|
36
36
|
private readonly compiledValidator = new CompiledContextValidator(),
|
|
37
37
|
) {}
|
|
38
38
|
|
|
39
|
-
compileActive(
|
|
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
|
+
}
|