tinker-agent 2.7.0 → 2.9.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +64 -10
  3. package/package.json +4 -3
  4. package/src/agent/runtime-context-capabilities.ts +19 -0
  5. package/src/agent/runtime-context-events.ts +127 -0
  6. package/src/agent/runtime-context-maintenance.ts +780 -0
  7. package/src/agent/runtime-interactions.ts +291 -0
  8. package/src/agent/runtime-prompt-scheduler.ts +182 -0
  9. package/src/agent/runtime-session-contracts.ts +317 -0
  10. package/src/agent/runtime-session.ts +253 -2117
  11. package/src/agent/runtime-skills.ts +544 -0
  12. package/src/cli/runner-dependencies.ts +6 -5
  13. package/src/context/context-automation-policy.ts +12 -118
  14. package/src/events/types.ts +13 -1
  15. package/src/memory/memory-get-tool.ts +1 -1
  16. package/src/observation/observation-builder.ts +41 -11
  17. package/src/session/resume-projection.ts +47 -21
  18. package/src/session/session-history-access.ts +238 -0
  19. package/src/session/session-store-context-readers.ts +183 -0
  20. package/src/session/session-store-ledger-writer.ts +315 -0
  21. package/src/session/session-store-record-writer.ts +318 -0
  22. package/src/session/session-store-recovery.ts +225 -0
  23. package/src/session/session-store-revisions.ts +1004 -0
  24. package/src/session/session-store-sql.ts +40 -0
  25. package/src/session/session-store-validation.ts +657 -0
  26. package/src/session/session-store.ts +756 -3186
  27. package/src/tools/bash-task.ts +20 -2
  28. package/src/tools/bash.ts +1 -1
  29. package/src/tools/context-maintenance.ts +1 -1
  30. package/src/tools/read.ts +1 -1
  31. package/src/tools/recall.ts +106 -50
  32. package/src/tools/registry.ts +4 -6
  33. package/src/tools/task-output-range.ts +146 -0
  34. package/src/tools/task-output-tool.ts +35 -5
  35. package/src/tools/task-output.ts +35 -0
  36. package/src/tools/task-tool-args.ts +34 -0
  37. package/src/tools/types.ts +9 -0
  38. package/src/tools/wait.ts +1 -3
  39. package/src/tui/event-store.ts +8 -3
@@ -0,0 +1,183 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { StoredContextRevisionV8 } from "../context/context-revision";
3
+ import type {
4
+ ActiveTurnBoundary,
5
+ ClosedTurnBoundary,
6
+ } from "../context/prefix-retirement-planner";
7
+ import { type ProtocolContextView } from "../context/protocol-frame";
8
+ import type { ContextRevisionId, SessionId, TurnId } from "../ids/runtime-id";
9
+ import {
10
+ type StoredMeasuredContextState,
11
+ type StoredSessionMetaV10,
12
+ } from "./session-store-contracts";
13
+ import { requireItem } from "./session-store-sql";
14
+ import {
15
+ enumFromSql,
16
+ numberFromSql,
17
+ recordFromSql,
18
+ sha256FromSql,
19
+ stringFromSql,
20
+ timestampFromSql,
21
+ } from "./session-store-value-codecs";
22
+
23
+ export function requireActiveRevisionId(meta: StoredSessionMetaV10): ContextRevisionId {
24
+ if (meta.initializationState !== "ready" || meta.activeRevisionId === null) {
25
+ throw new Error("Session has no active context revision.");
26
+ }
27
+ return meta.activeRevisionId;
28
+ }
29
+
30
+ export function previousRevision(
31
+ revisions: readonly StoredContextRevisionV8[],
32
+ revision: StoredContextRevisionV8,
33
+ ): StoredContextRevisionV8 | undefined {
34
+ return revisions[revision.revisionNumber - 2];
35
+ }
36
+
37
+ function decodeMeasuredContextState(
38
+ value: unknown,
39
+ expectedSessionId: SessionId,
40
+ ): StoredMeasuredContextState {
41
+ const row = recordFromSql(value, "context measurement state");
42
+ const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
43
+ if (sessionId !== expectedSessionId) {
44
+ throw new Error(
45
+ `Context measurement session ID ${sessionId} does not match store.`,
46
+ );
47
+ }
48
+ const promptTokens = numberFromSql(row.prompt_tokens, "prompt_tokens");
49
+ const completionTokens = numberFromSql(row.completion_tokens, "completion_tokens");
50
+ const totalTokens = numberFromSql(row.total_tokens, "total_tokens");
51
+ if (totalTokens !== promptTokens + completionTokens) {
52
+ throw new Error(
53
+ "Context measurement total_tokens must equal prompt_tokens + completion_tokens.",
54
+ );
55
+ }
56
+
57
+ timestampFromSql(row.updated_at, "updated_at");
58
+ return Object.freeze({
59
+ revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
60
+ anchor: Object.freeze({
61
+ totalTokens,
62
+ promptTokens,
63
+ completionTokens,
64
+ segmentCount: numberFromSql(row.segment_count, "segment_count"),
65
+ prefixHash: sha256FromSql(row.prefix_hash, "prefix_hash"),
66
+ requestConfigHash: sha256FromSql(row.request_config_hash, "request_config_hash"),
67
+ toolSchemaHash: sha256FromSql(row.tool_schema_hash, "tool_schema_hash"),
68
+ }),
69
+ });
70
+ }
71
+
72
+ export function readRetirementBoundaries(
73
+ database: Database,
74
+ canonical: ProtocolContextView,
75
+ activeTurnId?: TurnId,
76
+ ): {
77
+ readonly closedTurns: readonly ClosedTurnBoundary[];
78
+ readonly activeTurn?: ActiveTurnBoundary;
79
+ } {
80
+ const rows = database
81
+ .query("SELECT * FROM turns ORDER BY turn_number")
82
+ .all() as Array<Record<string, unknown>>;
83
+ const boundaries: ClosedTurnBoundary[] = [];
84
+ let activeTurn: ActiveTurnBoundary | undefined;
85
+ let expectedOrdinal = 2;
86
+ for (let index = 0; index < rows.length; index += 1) {
87
+ const row = requireItem(rows, index, "turn row");
88
+ const turnId = stringFromSql(row.turn_id, "turn_id") as TurnId;
89
+ const turnNumber = numberFromSql(row.turn_number, "turn_number");
90
+ const status = enumFromSql(
91
+ row.status,
92
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
93
+ "turn status",
94
+ );
95
+ const frames = canonical.frames.filter((frame) => frame.turnId === turnId);
96
+ const messages = canonical.messages.filter(
97
+ (message) => message.role !== "system" && message.turnId === turnId,
98
+ );
99
+ const firstMessage = messages[0];
100
+ const lastMessage = messages.at(-1);
101
+ if (status === "open") {
102
+ if (
103
+ activeTurnId === undefined ||
104
+ turnId !== activeTurnId ||
105
+ index !== rows.length - 1 ||
106
+ turnNumber !== index + 1 ||
107
+ messages.length < 1 ||
108
+ frames.length < 1 ||
109
+ firstMessage?.role !== "user" ||
110
+ firstMessage.ordinal !== expectedOrdinal ||
111
+ lastMessage?.ordinal !== canonical.messages.length ||
112
+ frames.some((frame) => frame.state !== "closed")
113
+ ) {
114
+ throw new Error(`Turn ${turnId} has an invalid active boundary.`);
115
+ }
116
+ activeTurn = Object.freeze({
117
+ turnId,
118
+ turnNumber,
119
+ firstOrdinal: expectedOrdinal,
120
+ });
121
+ expectedOrdinal = canonical.messages.length + 1;
122
+ continue;
123
+ }
124
+ let nextFrameOrdinal = expectedOrdinal;
125
+ for (const frame of frames) {
126
+ if (
127
+ frame.state !== "closed" ||
128
+ frame.firstOrdinal !== nextFrameOrdinal ||
129
+ frame.lastOrdinal === undefined
130
+ ) {
131
+ throw new Error(`Turn ${turnId} has an invalid closed frame boundary.`);
132
+ }
133
+ nextFrameOrdinal = frame.lastOrdinal + 1;
134
+ }
135
+ if (
136
+ turnNumber !== index + 1 ||
137
+ frames.length < 1 ||
138
+ messages.length < 1 ||
139
+ firstMessage?.role !== "user" ||
140
+ firstMessage.ordinal !== expectedOrdinal ||
141
+ lastMessage === undefined ||
142
+ nextFrameOrdinal !== lastMessage.ordinal + 1
143
+ ) {
144
+ throw new Error(`Turn ${turnId} has an invalid canonical boundary.`);
145
+ }
146
+ boundaries.push(
147
+ Object.freeze({
148
+ turnId,
149
+ turnNumber,
150
+ status,
151
+ firstOrdinal: expectedOrdinal,
152
+ lastOrdinal: lastMessage.ordinal,
153
+ frameCount: frames.length,
154
+ messageCount: messages.length,
155
+ }),
156
+ );
157
+ expectedOrdinal = lastMessage.ordinal + 1;
158
+ }
159
+ if (expectedOrdinal !== canonical.messages.length + 1) {
160
+ throw new Error("Closed turn boundaries do not cover canonical history.");
161
+ }
162
+ if ((activeTurnId === undefined) !== (activeTurn === undefined)) {
163
+ throw new Error("Active retirement boundary does not match the open turn.");
164
+ }
165
+ return Object.freeze({
166
+ closedTurns: Object.freeze(boundaries),
167
+ ...(activeTurn === undefined ? {} : { activeTurn }),
168
+ });
169
+ }
170
+
171
+ export function loadMeasuredContextState(
172
+ database: Database,
173
+ sessionId: SessionId,
174
+ ): StoredMeasuredContextState | undefined {
175
+ const rows = database.query("SELECT * FROM context_measurement_state").all();
176
+ if (rows.length > 1) {
177
+ throw new Error(
178
+ `Expected at most one context measurement row; found ${rows.length}.`,
179
+ );
180
+ }
181
+ const row = rows[0];
182
+ return row === undefined ? undefined : decodeMeasuredContextState(row, sessionId);
183
+ }
@@ -0,0 +1,315 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { AdmissionStaleError, type LedgerMutation } from "../agent/session-ledger";
3
+ import type { IterationId, MessageId, SessionId, TurnId } from "../ids/runtime-id";
4
+ import { stableJsonStringify } from "../model/model-request-preflight";
5
+ import { SessionError, sessionWriteError } from "./session-errors";
6
+ import type { SessionStore } from "./session-store";
7
+ import {
8
+ insertFrame,
9
+ insertMessage,
10
+ insertPendingSkillActivation,
11
+ insertToolResult,
12
+ } from "./session-store-record-writer";
13
+ import { requireItem, requireSingleChange, runTransaction } from "./session-store-sql";
14
+ import { numberFromSql } from "./session-store-value-codecs";
15
+
16
+ /** Writes canonical ledger mutations using the session's existing connection. */
17
+ export class SessionStoreLedgerWriter {
18
+ constructor(
19
+ private readonly database: Database,
20
+ private readonly sessionId: SessionId,
21
+ private readonly clock: () => string,
22
+ private readonly requireOpen: () => void,
23
+ private readonly store: Pick<SessionStore, "readMeta" | "loadContextSnapshot">,
24
+ ) {}
25
+
26
+ commit(mutation: LedgerMutation): void {
27
+ this.requireOpen();
28
+ const now = this.clock();
29
+ try {
30
+ runTransaction(this.database, () => {
31
+ switch (mutation.kind) {
32
+ case "begin_turn":
33
+ this.commitBeginTurn(mutation, now);
34
+ break;
35
+ case "append_steering_users":
36
+ this.commitSteeringUsers(mutation, now);
37
+ break;
38
+ case "append_assistant":
39
+ this.commitAssistant(mutation, now);
40
+ break;
41
+ case "commit_tool_completions":
42
+ this.commitToolCompletions(mutation, now);
43
+ break;
44
+ case "finish_turn":
45
+ this.commitFinishTurn(mutation, now);
46
+ break;
47
+ }
48
+ });
49
+ } catch (error) {
50
+ if (error instanceof AdmissionStaleError) {
51
+ throw error;
52
+ }
53
+ throw sessionWriteError(mutation.kind, this.sessionId, error);
54
+ }
55
+ }
56
+
57
+ private commitBeginTurn(
58
+ mutation: Extract<LedgerMutation, { kind: "begin_turn" }>,
59
+ now: string,
60
+ ): void {
61
+ const meta = this.store.readMeta();
62
+ if (mutation.admissionBase !== undefined) {
63
+ const snapshot = this.store.loadContextSnapshot();
64
+ const head = snapshot.canonical.messages.at(-1);
65
+ const base = mutation.admissionBase;
66
+ if (
67
+ head === undefined ||
68
+ base.canonicalMessageCount !== snapshot.canonical.messages.length ||
69
+ base.canonicalHeadMessageId !== head.messageId ||
70
+ base.canonicalHeadContentSha256 !== head.contentSha256 ||
71
+ base.activeRevisionId !== snapshot.revision.revisionId ||
72
+ base.activeRevisionNumber !== snapshot.revision.revisionNumber ||
73
+ base.surfaceSha256 !== snapshot.surface.surfaceSha256 ||
74
+ base.sessionCompatibilitySha256 !== meta.sessionCompatibilitySha256 ||
75
+ base.nextTurnNumber !== meta.nextTurnNumber
76
+ ) {
77
+ throw new AdmissionStaleError();
78
+ }
79
+ }
80
+ if (
81
+ meta.initializationState !== "ready" ||
82
+ meta.nextTurnNumber !== mutation.turn.turnNumber
83
+ ) {
84
+ throw new Error("Session turn counter or state changed before begin_turn.");
85
+ }
86
+ this.database
87
+ .query(
88
+ `INSERT INTO turns (
89
+ session_id, turn_id, turn_number, status, next_iteration_number,
90
+ last_iteration_id, final_message_id, terminal_detail_json, started_at, finished_at
91
+ ) VALUES (?, ?, ?, 'open', 1, NULL, NULL, NULL, ?, NULL)`,
92
+ )
93
+ .run(this.sessionId, mutation.turn.turnId, mutation.turn.turnNumber, now);
94
+ insertFrame(this.database, mutation.frame);
95
+ insertMessage(this.database, mutation.message);
96
+ const updated = this.database
97
+ .query(
98
+ `UPDATE session_meta SET next_turn_number = ?, updated_at = ?
99
+ WHERE singleton = 1 AND next_turn_number = ?`,
100
+ )
101
+ .run(mutation.turn.turnNumber + 1, now, mutation.turn.turnNumber);
102
+ requireSingleChange(this.database, updated.changes, "advance turn counter");
103
+ }
104
+
105
+ private commitSteeringUsers(
106
+ mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
107
+ now: string,
108
+ ): void {
109
+ const turn = this.requireTurnRow(mutation.turn.turnId);
110
+ if (turn.status !== "open") {
111
+ throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
112
+ }
113
+ if (
114
+ mutation.frames.length === 0 ||
115
+ mutation.frames.length !== mutation.messages.length
116
+ ) {
117
+ throw new Error(
118
+ "Steering user mutation must contain matching frames and messages.",
119
+ );
120
+ }
121
+ for (let index = 0; index < mutation.frames.length; index += 1) {
122
+ insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
123
+ insertMessage(
124
+ this.database,
125
+ requireItem(mutation.messages, index, "steering message"),
126
+ );
127
+ }
128
+ this.touch(now);
129
+ }
130
+
131
+ private commitAssistant(
132
+ mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
133
+ now: string,
134
+ ): void {
135
+ const iteration = this.requireIterationRow(mutation.iteration.iterationId);
136
+ if (iteration.outcome !== "open") {
137
+ throw new Error(`Iteration ${mutation.iteration.iterationId} is not open.`);
138
+ }
139
+ insertFrame(this.database, mutation.frame);
140
+ insertMessage(this.database, mutation.message);
141
+ if (
142
+ mutation.message.role === "assistant" &&
143
+ mutation.message.toolCalls !== undefined
144
+ ) {
145
+ const expected = numberFromSql(
146
+ iteration.next_tool_call_number,
147
+ "next_tool_call_number",
148
+ );
149
+ if (expected !== 1) {
150
+ throw new Error(
151
+ "Assistant tool calls were already allocated for this iteration.",
152
+ );
153
+ }
154
+ const updated = this.database
155
+ .query(
156
+ `UPDATE iterations SET next_tool_call_number = ?
157
+ WHERE iteration_id = ? AND outcome = 'open' AND next_tool_call_number = 1`,
158
+ )
159
+ .run(mutation.message.toolCalls.length + 1, mutation.iteration.iterationId);
160
+ requireSingleChange(this.database, updated.changes, "advance tool call counter");
161
+ }
162
+ this.touch(now);
163
+ }
164
+
165
+ private commitToolCompletions(
166
+ mutation: Extract<LedgerMutation, { kind: "commit_tool_completions" }>,
167
+ now: string,
168
+ ): void {
169
+ const current = this.database
170
+ .query("SELECT state, last_ordinal FROM protocol_frames WHERE frame_id = ?")
171
+ .get(mutation.frameBefore.frameId) as {
172
+ state: string;
173
+ last_ordinal: unknown;
174
+ } | null;
175
+ if (current?.state !== "open" || current.last_ordinal !== null) {
176
+ throw new Error(`Frame ${mutation.frameBefore.frameId} is not open.`);
177
+ }
178
+ for (let index = 0; index < mutation.messages.length; index += 1) {
179
+ const message = requireItem(mutation.messages, index, "tool message");
180
+ const result = requireItem(mutation.toolResults, index, "tool result");
181
+ insertMessage(this.database, message);
182
+ insertToolResult(this.database, result);
183
+ insertPendingSkillActivation(this.database, message, result, now);
184
+ }
185
+ if (mutation.frameAfter.state === "closed") {
186
+ const updated = this.database
187
+ .query(
188
+ `UPDATE protocol_frames SET state = 'closed', last_ordinal = ?, closed_at = ?
189
+ WHERE frame_id = ? AND state = 'open' AND last_ordinal IS NULL`,
190
+ )
191
+ .run(
192
+ mutation.frameAfter.lastOrdinal!,
193
+ mutation.frameAfter.closedAt!,
194
+ mutation.frameAfter.frameId,
195
+ );
196
+ requireSingleChange(this.database, updated.changes, "close tool exchange frame");
197
+ }
198
+ this.touch(now);
199
+ }
200
+
201
+ private commitFinishTurn(
202
+ mutation: Extract<LedgerMutation, { kind: "finish_turn" }>,
203
+ now: string,
204
+ ): void {
205
+ const result = mutation.result;
206
+ const turnStatus = result.status;
207
+ const iterationOutcome = result.status;
208
+ const detail =
209
+ result.status === "completed"
210
+ ? stableJsonStringify({ version: 1, finalTextLength: result.finalText.length })
211
+ : result.status === "failed"
212
+ ? stableJsonStringify({ version: 1, error: result.error.slice(0, 2_000) })
213
+ : stableJsonStringify({ version: 1, cancellation: result.cancellation });
214
+ this.markTerminalRows(
215
+ mutation.turn.turnId,
216
+ result.lastIteration.iterationId,
217
+ turnStatus,
218
+ iterationOutcome,
219
+ mutation.finalMessageId ?? null,
220
+ detail,
221
+ now,
222
+ );
223
+ }
224
+
225
+ markTerminalRows(
226
+ turnId: TurnId,
227
+ iterationId: IterationId,
228
+ turnStatus: "completed" | "failed" | "cancelled" | "interrupted",
229
+ iterationOutcome: "completed" | "failed" | "cancelled" | "interrupted",
230
+ finalMessageId: MessageId | null,
231
+ terminalDetailJson: string,
232
+ now: string,
233
+ ): void {
234
+ const iteration = this.database
235
+ .query(
236
+ `UPDATE iterations SET outcome = ?, finished_at = ?
237
+ WHERE iteration_id = ? AND turn_id = ? AND outcome = 'open'`,
238
+ )
239
+ .run(iterationOutcome, now, iterationId, turnId);
240
+ requireSingleChange(this.database, iteration.changes, "finish iteration");
241
+ const turn = this.database
242
+ .query(
243
+ `UPDATE turns SET status = ?, last_iteration_id = ?, final_message_id = ?,
244
+ terminal_detail_json = ?, finished_at = ?
245
+ WHERE turn_id = ? AND status = 'open'`,
246
+ )
247
+ .run(turnStatus, iterationId, finalMessageId, terminalDetailJson, now, turnId);
248
+ requireSingleChange(this.database, turn.changes, "finish turn");
249
+ this.touch(now);
250
+ }
251
+
252
+ markOpenTurnInterrupted(turnId: TurnId, iterationId: IterationId | undefined): void {
253
+ const now = this.clock();
254
+ try {
255
+ runTransaction(this.database, () => {
256
+ if (iterationId !== undefined) {
257
+ const iteration = this.database
258
+ .query(
259
+ `UPDATE iterations SET outcome = 'interrupted', finished_at = ?
260
+ WHERE iteration_id = ? AND outcome = 'open'`,
261
+ )
262
+ .run(now, iterationId);
263
+ requireSingleChange(this.database, iteration.changes, "interrupt iteration");
264
+ }
265
+ const turn = this.database
266
+ .query(
267
+ `UPDATE turns SET status = 'interrupted', finished_at = ?,
268
+ terminal_detail_json = ?
269
+ WHERE turn_id = ? AND status = 'open'`,
270
+ )
271
+ .run(
272
+ now,
273
+ stableJsonStringify({ version: 1, reason: "process_interrupted" }),
274
+ turnId,
275
+ );
276
+ requireSingleChange(this.database, turn.changes, "interrupt turn");
277
+ this.touch(now);
278
+ });
279
+ } catch (error) {
280
+ throw new SessionError(
281
+ "SESSION_RECOVERY_FAILED",
282
+ "recover_open_turn",
283
+ `Failed to mark turn ${turnId} interrupted.`,
284
+ { sessionId: this.sessionId, cause: error },
285
+ );
286
+ }
287
+ }
288
+
289
+ requireTurnRow(turnId: TurnId): Record<string, unknown> {
290
+ const row = this.database
291
+ .query("SELECT * FROM turns WHERE turn_id = ?")
292
+ .get(turnId) as Record<string, unknown> | null;
293
+ if (row === null) {
294
+ throw new Error(`Unknown turn ${turnId}.`);
295
+ }
296
+ return row;
297
+ }
298
+
299
+ requireIterationRow(iterationId: IterationId): Record<string, unknown> {
300
+ const row = this.database
301
+ .query("SELECT * FROM iterations WHERE iteration_id = ?")
302
+ .get(iterationId) as Record<string, unknown> | null;
303
+ if (row === null) {
304
+ throw new Error(`Unknown iteration ${iterationId}.`);
305
+ }
306
+ return row;
307
+ }
308
+
309
+ touch(timestamp: string): void {
310
+ const updated = this.database
311
+ .query("UPDATE session_meta SET updated_at = ? WHERE singleton = 1")
312
+ .run(timestamp);
313
+ requireSingleChange(this.database, updated.changes, "touch session");
314
+ }
315
+ }