tinker-agent 2.0.0 → 2.2.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 (51) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +27 -2
  3. package/package.json +2 -1
  4. package/src/agent/context-meter.ts +2 -4
  5. package/src/agent/runtime-session.ts +9 -2
  6. package/src/agent/session-ledger.ts +12 -5
  7. package/src/agent/tool-result-content.ts +76 -0
  8. package/src/agent/types.ts +14 -2
  9. package/src/cli/config.ts +4 -0
  10. package/src/cli/model-profiles.ts +41 -2
  11. package/src/cli/public-config-contract.ts +30 -7
  12. package/src/cli/runner-dependencies.ts +5 -0
  13. package/src/cli/tui-memory.ts +1 -0
  14. package/src/cli/tui-runner.tsx +4 -0
  15. package/src/context/compiled-context-hash.ts +2 -1
  16. package/src/context/compiled-context-validator.ts +13 -4
  17. package/src/context/context-protocol-validator.ts +33 -2
  18. package/src/context/context-revision-compiler.ts +2 -1
  19. package/src/context/context-revision.ts +8 -2
  20. package/src/context/context-swap-renderer.ts +46 -12
  21. package/src/context/prefix-retirement-planner.ts +13 -9
  22. package/src/context/protocol-frame.ts +74 -7
  23. package/src/context/swap-planner.ts +19 -14
  24. package/src/events/observation-text-log.ts +1 -1
  25. package/src/events/stdout-event-printer.ts +6 -0
  26. package/src/image/image-asset-store.ts +32 -3
  27. package/src/memory/contracts.ts +63 -3
  28. package/src/memory/memory-coordinator.ts +319 -49
  29. package/src/memory/memory-extractor.ts +48 -48
  30. package/src/memory/memory-get-tool.ts +86 -0
  31. package/src/memory/memory-search-tool.ts +122 -33
  32. package/src/memory/memory-store.ts +227 -20
  33. package/src/model/fake-model-client.ts +129 -76
  34. package/src/model/model-client.ts +62 -11
  35. package/src/model/openai-chat-mapping.ts +2 -1
  36. package/src/model/openai-chat-model-client.ts +22 -10
  37. package/src/model/openai-model-utils.ts +61 -30
  38. package/src/model/openai-responses-mapping.ts +25 -1
  39. package/src/model/openai-responses-model-client.ts +27 -11
  40. package/src/model/token-estimator.ts +10 -0
  41. package/src/observation/observation-builder.ts +100 -25
  42. package/src/session/session-history-reader.ts +128 -5
  43. package/src/session/session-schema.ts +59 -9
  44. package/src/session/session-store.ts +343 -196
  45. package/src/tools/registry.ts +18 -0
  46. package/src/tools/types.ts +46 -0
  47. package/src/tools/view-image.ts +89 -0
  48. package/src/tools/wait.ts +85 -0
  49. package/src/tui/components/memory-browser.tsx +3 -0
  50. package/src/tui/components/prompt-input.tsx +48 -25
  51. package/src/tui/event-store.ts +61 -2
@@ -5,6 +5,12 @@ import {
5
5
  type MessageSource,
6
6
  } from "../context/context-source";
7
7
  import { contentHash, userMessageHash } from "../context/protocol-frame";
8
+ import type { ToolResultContent } from "../agent/types";
9
+ import {
10
+ canonicalToolResultContentHash,
11
+ toolResultDisplayText,
12
+ validateToolResultContent,
13
+ } from "../agent/tool-result-content";
8
14
  import {
9
15
  parseImageAssetId,
10
16
  parseImageAttachmentId,
@@ -132,6 +138,18 @@ type RecallImageRow = {
132
138
  height: unknown;
133
139
  };
134
140
 
141
+ type RecallToolBlockRow = {
142
+ message_id: unknown;
143
+ position: unknown;
144
+ kind: unknown;
145
+ text_content: unknown;
146
+ asset_id: unknown;
147
+ mime_type: unknown;
148
+ byte_length: unknown;
149
+ width: unknown;
150
+ height: unknown;
151
+ };
152
+
135
153
  export function createSessionHistoryReader(input: {
136
154
  database: Database;
137
155
  sessionId: SessionId;
@@ -233,11 +251,15 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
233
251
  const imageAttachments = this.loadImageAttachments(
234
252
  rows.map((row) => nonEmptyString(row.message_id, "message_id") as MessageId),
235
253
  );
254
+ const toolBlocks = this.loadToolContentBlocks(
255
+ rows.map((row) => nonEmptyString(row.message_id, "message_id") as MessageId),
256
+ );
236
257
  decoded = rows.map((row) => {
237
258
  const metadata = decodeRecallRow(row);
238
259
  const attachments = imageAttachments.get(metadata.messageId) ?? [];
239
- verifyRecallContent(metadata, attachments);
240
- const content = renderRecallContent(metadata.content, attachments);
260
+ const blocks = toolBlocks.get(metadata.messageId);
261
+ verifyRecallContent(metadata, attachments, blocks);
262
+ const content = renderRecallContent(metadata.content, attachments, blocks);
241
263
  return {
242
264
  ...metadata,
243
265
  content,
@@ -293,14 +315,18 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
293
315
 
294
316
  let metadata: ReturnType<typeof decodeRecallRow>;
295
317
  let attachments: readonly UserImageAttachment[];
318
+ let toolBlocks: readonly ToolResultContent[] | undefined;
296
319
  try {
297
320
  metadata = decodeRecallRow(row);
298
321
  attachments =
299
322
  this.loadImageAttachments([metadata.messageId]).get(metadata.messageId) ?? [];
323
+ toolBlocks = this.loadToolContentBlocks([metadata.messageId]).get(
324
+ metadata.messageId,
325
+ );
300
326
  } catch (error) {
301
327
  throw sessionReadError("decode_recall_get", this.sessionId, error);
302
328
  }
303
- const actualHash = recallContentHash(metadata, attachments);
329
+ const actualHash = recallContentHash(metadata, attachments, toolBlocks);
304
330
  const observationHash = row.observation_sha256;
305
331
  if (
306
332
  actualHash !== metadata.contentSha256 ||
@@ -314,7 +340,11 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
314
340
  );
315
341
  }
316
342
 
317
- const renderedContent = renderRecallContent(metadata.content, attachments);
343
+ const renderedContent = renderRecallContent(
344
+ metadata.content,
345
+ attachments,
346
+ toolBlocks,
347
+ );
318
348
  const page = sliceUtf8Page(renderedContent, input.byteOffset, input.byteLimit);
319
349
  return Object.freeze({
320
350
  source: formatMessageSource(metadata.messageId),
@@ -428,12 +458,90 @@ class SqliteSessionHistoryReader implements SessionHistoryReader {
428
458
  ]),
429
459
  );
430
460
  }
461
+
462
+ private loadToolContentBlocks(
463
+ messageIds: readonly MessageId[],
464
+ ): ReadonlyMap<MessageId, readonly ToolResultContent[]> {
465
+ if (messageIds.length === 0) return new Map();
466
+ const unique = [...new Set(messageIds)];
467
+ let rows: RecallToolBlockRow[];
468
+ try {
469
+ this.requireStoreOpen();
470
+ rows = this.database
471
+ .query(
472
+ `SELECT tcb.message_id, tcb.position, tcb.kind, tcb.text_content,
473
+ ia.asset_id, ia.mime_type, ia.byte_length, ia.width, ia.height
474
+ FROM tool_message_content_blocks tcb
475
+ LEFT JOIN image_assets ia ON ia.asset_id = tcb.asset_id
476
+ WHERE tcb.message_id IN (${unique.map(() => "?").join(", ")})
477
+ ORDER BY tcb.message_id, tcb.position`,
478
+ )
479
+ .all(...unique) as RecallToolBlockRow[];
480
+ } catch (error) {
481
+ throw sessionReadError("read_recall_tool_blocks", this.sessionId, error);
482
+ }
483
+ const grouped = new Map<MessageId, ToolResultContent[]>();
484
+ for (const row of rows) {
485
+ const messageId = nonEmptyString(row.message_id, "message_id") as MessageId;
486
+ const blocks = grouped.get(messageId) ?? [];
487
+ if (
488
+ nonNegativeSafeInteger(row.position, "tool block position") !== blocks.length
489
+ ) {
490
+ throw new Error("Recall tool content block positions are not contiguous.");
491
+ }
492
+ const kind = enumValue(row.kind, ["text", "image"] as const, "tool block kind");
493
+ blocks.push(
494
+ kind === "text"
495
+ ? Object.freeze({
496
+ type: "text",
497
+ text: nonEmptyString(row.text_content, "tool block text"),
498
+ })
499
+ : Object.freeze({
500
+ type: "image",
501
+ asset: Object.freeze({
502
+ assetId: parseImageAssetId(
503
+ nonEmptyString(row.asset_id, "tool block asset_id"),
504
+ ),
505
+ mimeType: enumValue(
506
+ row.mime_type,
507
+ ["image/png", "image/jpeg", "image/webp"] as const,
508
+ "tool block MIME type",
509
+ ),
510
+ byteLength: safeInteger(row.byte_length, "tool block byte length"),
511
+ width: safeInteger(row.width, "tool block width"),
512
+ height: safeInteger(row.height, "tool block height"),
513
+ }),
514
+ }),
515
+ );
516
+ grouped.set(messageId, blocks);
517
+ }
518
+ return new Map(
519
+ [...grouped].map(([messageId, blocks]) => {
520
+ validateToolResultContent(blocks);
521
+ return [messageId, Object.freeze(blocks)] as const;
522
+ }),
523
+ );
524
+ }
431
525
  }
432
526
 
433
527
  export function renderRecallContent(
434
528
  content: string,
435
529
  attachments: readonly UserImageAttachment[],
530
+ toolBlocks?: readonly ToolResultContent[],
436
531
  ): string {
532
+ if (toolBlocks !== undefined) {
533
+ const text = toolBlocks
534
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
535
+ .join("\n");
536
+ const notes = toolBlocks.flatMap((block) =>
537
+ block.type === "image"
538
+ ? [
539
+ `[Historical tool image omitted: ${block.asset.mimeType}, ${block.asset.width}x${block.asset.height}, asset=${block.asset.assetId.slice(0, 12)}….]`,
540
+ ]
541
+ : [],
542
+ );
543
+ return notes.length === 0 ? text : `${text}\n\n${notes.join("\n")}`;
544
+ }
437
545
  if (attachments.length === 0) return content;
438
546
  const notes = attachments.map(
439
547
  (attachment) =>
@@ -445,8 +553,9 @@ export function renderRecallContent(
445
553
  function verifyRecallContent(
446
554
  metadata: ReturnType<typeof decodeRecallRow>,
447
555
  attachments: readonly UserImageAttachment[],
556
+ toolBlocks?: readonly ToolResultContent[],
448
557
  ): void {
449
- if (recallContentHash(metadata, attachments) !== metadata.contentSha256) {
558
+ if (recallContentHash(metadata, attachments, toolBlocks) !== metadata.contentSha256) {
450
559
  throw new Error("Canonical Recall content failed its integrity check.");
451
560
  }
452
561
  }
@@ -454,7 +563,21 @@ function verifyRecallContent(
454
563
  function recallContentHash(
455
564
  metadata: ReturnType<typeof decodeRecallRow>,
456
565
  attachments: readonly UserImageAttachment[],
566
+ toolBlocks?: readonly ToolResultContent[],
457
567
  ): string {
568
+ if (metadata.role === "tool") {
569
+ if (attachments.length > 0 || toolBlocks === undefined) {
570
+ throw new Error("Tool Recall message has invalid media relations.");
571
+ }
572
+ validateToolResultContent(toolBlocks);
573
+ if (toolResultDisplayText(toolBlocks) !== metadata.content) {
574
+ throw new Error("Tool Recall display projection does not match its blocks.");
575
+ }
576
+ return canonicalToolResultContentHash(toolBlocks);
577
+ }
578
+ if (toolBlocks !== undefined) {
579
+ throw new Error("Non-tool Recall message has tool content blocks.");
580
+ }
458
581
  if (metadata.role !== "user") {
459
582
  if (attachments.length > 0) {
460
583
  throw new Error("Non-user Recall message has image attachments.");
@@ -4,7 +4,7 @@ import type { SessionId } from "../ids/runtime-id";
4
4
  import { SessionError } from "./session-errors";
5
5
 
6
6
  export const SESSION_APPLICATION_ID = 0x544b5231;
7
- export const SESSION_SCHEMA_VERSION = 9;
7
+ export const SESSION_SCHEMA_VERSION = 10;
8
8
 
9
9
  type SchemaDefinition = {
10
10
  type: "table" | "index" | "trigger" | "view";
@@ -27,7 +27,7 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
27
27
  name: "session_meta",
28
28
  sql: `CREATE TABLE session_meta (
29
29
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
30
- schema_version INTEGER NOT NULL CHECK (schema_version = 9),
30
+ schema_version INTEGER NOT NULL CHECK (schema_version = 10),
31
31
  schema_fingerprint TEXT NOT NULL,
32
32
  initialization_state TEXT NOT NULL CHECK (initialization_state IN ('creating', 'ready')),
33
33
  session_id TEXT NOT NULL UNIQUE,
@@ -231,6 +231,24 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
231
231
  FOREIGN KEY (asset_id) REFERENCES image_assets(asset_id)
232
232
  ) STRICT`,
233
233
  },
234
+ {
235
+ type: "table",
236
+ name: "tool_message_content_blocks",
237
+ sql: `CREATE TABLE tool_message_content_blocks (
238
+ message_id TEXT NOT NULL,
239
+ position INTEGER NOT NULL CHECK (position >= 0),
240
+ kind TEXT NOT NULL CHECK (kind IN ('text', 'image')),
241
+ text_content TEXT,
242
+ asset_id TEXT,
243
+ PRIMARY KEY (message_id, position),
244
+ FOREIGN KEY (message_id) REFERENCES messages(message_id),
245
+ FOREIGN KEY (asset_id) REFERENCES image_assets(asset_id),
246
+ CHECK (
247
+ (kind = 'text' AND text_content IS NOT NULL AND length(text_content) > 0 AND asset_id IS NULL) OR
248
+ (kind = 'image' AND text_content IS NULL AND asset_id IS NOT NULL)
249
+ )
250
+ ) STRICT`,
251
+ },
234
252
  {
235
253
  type: "table",
236
254
  name: "context_surfaces",
@@ -334,7 +352,7 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
334
352
  frame_id TEXT NOT NULL,
335
353
  ordinal INTEGER NOT NULL CHECK (ordinal >= 1),
336
354
  representation TEXT NOT NULL CHECK (representation = 'swapped'),
337
- renderer_format TEXT NOT NULL CHECK (renderer_format IN ('swap-observation-v1', 'skill-activation-receipt-v1')),
355
+ renderer_format TEXT NOT NULL CHECK (renderer_format IN ('swap-observation-v1', 'swap-tool-image-v1', 'skill-activation-receipt-v1')),
338
356
  source TEXT NOT NULL,
339
357
  original_content_sha256 TEXT NOT NULL CHECK (length(original_content_sha256) = 64),
340
358
  rendered_content TEXT NOT NULL CHECK (length(rendered_content) > 0),
@@ -351,7 +369,7 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
351
369
  FOREIGN KEY (message_id) REFERENCES messages(message_id),
352
370
  FOREIGN KEY (frame_id) REFERENCES protocol_frames(frame_id),
353
371
  CHECK (original_bytes = rendered_bytes + byte_savings),
354
- CHECK (renderer_format <> 'swap-observation-v1' OR byte_savings > 0)
372
+ CHECK (renderer_format = 'swap-tool-image-v1' OR byte_savings > 0)
355
373
  ) STRICT`,
356
374
  },
357
375
  {
@@ -428,6 +446,11 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
428
446
  name: "idx_message_image_attachments_asset",
429
447
  sql: "CREATE INDEX idx_message_image_attachments_asset ON message_image_attachments(asset_id)",
430
448
  },
449
+ {
450
+ type: "index",
451
+ name: "idx_tool_message_content_blocks_asset",
452
+ sql: "CREATE INDEX idx_tool_message_content_blocks_asset ON tool_message_content_blocks(asset_id)",
453
+ },
431
454
  {
432
455
  type: "index",
433
456
  name: "idx_turns_session_recent",
@@ -476,9 +499,32 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
476
499
  ...immutableTriggers("tool_results"),
477
500
  ...immutableTriggers("image_assets"),
478
501
  ...immutableTriggers("message_image_attachments"),
502
+ ...immutableTriggers("tool_message_content_blocks"),
479
503
  ...immutableTriggers("context_surfaces"),
480
504
  ...immutableTriggers("context_revisions"),
481
505
  ...immutableTriggers("context_overrides"),
506
+ {
507
+ type: "trigger",
508
+ name: "tool_message_content_blocks_validate_insert",
509
+ sql: `CREATE TRIGGER tool_message_content_blocks_validate_insert
510
+ BEFORE INSERT ON tool_message_content_blocks
511
+ WHEN NOT (
512
+ EXISTS (
513
+ SELECT 1 FROM messages m
514
+ WHERE m.message_id = NEW.message_id AND m.role = 'tool'
515
+ ) AND
516
+ NEW.position = (
517
+ SELECT COUNT(*) FROM tool_message_content_blocks
518
+ WHERE message_id = NEW.message_id
519
+ ) AND
520
+ NOT (
521
+ NEW.kind = 'text' AND NEW.position > 0 AND
522
+ (SELECT kind FROM tool_message_content_blocks
523
+ WHERE message_id = NEW.message_id AND position = NEW.position - 1) = 'text'
524
+ )
525
+ )
526
+ BEGIN SELECT RAISE(ABORT, 'invalid tool message content block insert'); END`,
527
+ },
482
528
  {
483
529
  type: "trigger",
484
530
  name: "skill_activations_no_delete",
@@ -636,7 +682,7 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
636
682
  SELECT 1 FROM context_revisions cr
637
683
  WHERE cr.revision_id = NEW.introduced_revision_id
638
684
  AND cr.session_id = NEW.session_id
639
- AND ((cr.kind = 'swap_only' AND NEW.renderer_format = 'swap-observation-v1') OR
685
+ AND ((cr.kind = 'swap_only' AND NEW.renderer_format IN ('swap-observation-v1', 'swap-tool-image-v1')) OR
640
686
  (cr.kind = 'skills_update' AND NEW.renderer_format = 'skill-activation-receipt-v1'))
641
687
  AND NEW.ordinal >= cr.keep_from_ordinal
642
688
  AND NEW.ordinal <= cr.source_through_ordinal
@@ -659,6 +705,10 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
659
705
  AND tr.session_id = NEW.session_id
660
706
  AND tr.frame_id = NEW.frame_id
661
707
  AND tr.observation_sha256 = NEW.original_content_sha256
708
+ AND (NEW.renderer_format <> 'swap-tool-image-v1' OR
709
+ (m.name = 'ViewImage' AND tr.completion_kind = 'returned' AND
710
+ json_extract(tr.raw_json, '$.kind') = 'view_image' AND
711
+ json_extract(tr.raw_json, '$.ok') = 1))
662
712
  AND (NEW.renderer_format <> 'skill-activation-receipt-v1' OR
663
713
  (m.name = 'Skill' AND tr.completion_kind = 'returned' AND
664
714
  json_extract(tr.raw_json, '$.kind') = 'skill' AND
@@ -845,7 +895,7 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
845
895
  },
846
896
  ];
847
897
 
848
- export const SESSION_SCHEMA_V9_FINGERPRINT = sha256(
898
+ export const SESSION_SCHEMA_V10_FINGERPRINT = sha256(
849
899
  stableJsonStringify({
850
900
  definitions: schemaDefinitions.map((definition) => ({
851
901
  type: definition.type,
@@ -1016,7 +1066,7 @@ export function upgradeActiveTurnRetirementContract(database: Database): boolean
1016
1066
  WHERE singleton = 1 AND schema_fingerprint = ?`,
1017
1067
  )
1018
1068
  .run(
1019
- SESSION_SCHEMA_V9_FINGERPRINT,
1069
+ SESSION_SCHEMA_V10_FINGERPRINT,
1020
1070
  PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT,
1021
1071
  );
1022
1072
  database.exec(metadataTrigger.sql);
@@ -1092,7 +1142,7 @@ export function verifyReadableSessionSchema(
1092
1142
 
1093
1143
  let expectedDefinitions: readonly SchemaDefinition[];
1094
1144
  let compatibility: "current" | "migratable";
1095
- if (meta.schema_fingerprint === SESSION_SCHEMA_V9_FINGERPRINT) {
1145
+ if (meta.schema_fingerprint === SESSION_SCHEMA_V10_FINGERPRINT) {
1096
1146
  expectedDefinitions = schemaDefinitions;
1097
1147
  compatibility = "current";
1098
1148
  } else if (
@@ -1195,7 +1245,7 @@ function verifySessionSchemaDefinitions(
1195
1245
  throw new SessionError(
1196
1246
  "SESSION_SCHEMA_INVALID",
1197
1247
  "verify_schema",
1198
- "Session FTS configuration does not match schema v9.",
1248
+ "Session FTS configuration does not match schema v10.",
1199
1249
  { sessionId },
1200
1250
  );
1201
1251
  }