tinker-agent 1.11.0 → 2.1.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 (58) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +33 -24
  3. package/package.json +2 -1
  4. package/src/agent/context-meter.ts +12 -85
  5. package/src/agent/loop.ts +1 -14
  6. package/src/agent/runtime-session.ts +9 -25
  7. package/src/agent/session-ledger.ts +12 -5
  8. package/src/agent/tool-result-content.ts +76 -0
  9. package/src/agent/types.ts +14 -2
  10. package/src/cli/config.ts +4 -5
  11. package/src/cli/model-profiles.ts +38 -97
  12. package/src/cli/public-config-contract.ts +24 -88
  13. package/src/cli/runner-dependencies.ts +5 -7
  14. package/src/cli/tui-memory.ts +1 -3
  15. package/src/cli/tui-runner.tsx +4 -0
  16. package/src/context/compiled-context-hash.ts +2 -1
  17. package/src/context/compiled-context-validator.ts +13 -4
  18. package/src/context/context-protocol-validator.ts +33 -2
  19. package/src/context/context-revision-compiler.ts +2 -1
  20. package/src/context/context-revision.ts +8 -2
  21. package/src/context/context-swap-renderer.ts +46 -12
  22. package/src/context/prefix-retirement-planner.ts +13 -9
  23. package/src/context/protocol-frame.ts +74 -7
  24. package/src/context/swap-planner.ts +19 -14
  25. package/src/events/observation-text-log.ts +1 -1
  26. package/src/events/stdout-event-printer.ts +6 -0
  27. package/src/image/image-asset-store.ts +32 -3
  28. package/src/image/image-input-policy.ts +53 -2
  29. package/src/image/image-probe.ts +8 -2
  30. package/src/image/provider-image.ts +99 -0
  31. package/src/memory/contracts.ts +61 -3
  32. package/src/memory/memory-coordinator.ts +313 -49
  33. package/src/memory/memory-extractor.ts +48 -48
  34. package/src/memory/memory-get-tool.ts +86 -0
  35. package/src/memory/memory-search-tool.ts +122 -33
  36. package/src/memory/memory-store.ts +227 -20
  37. package/src/model/fake-model-client.ts +177 -124
  38. package/src/model/model-client.ts +62 -11
  39. package/src/model/model-request-preflight.ts +0 -1
  40. package/src/model/openai-chat-mapping.ts +2 -1
  41. package/src/model/openai-chat-model-client.ts +26 -38
  42. package/src/model/openai-model-utils.ts +109 -40
  43. package/src/model/openai-responses-mapping.ts +25 -1
  44. package/src/model/openai-responses-model-client.ts +27 -40
  45. package/src/model/token-estimator.ts +26 -3
  46. package/src/observation/observation-builder.ts +100 -25
  47. package/src/session/session-history-reader.ts +128 -5
  48. package/src/session/session-schema.ts +59 -9
  49. package/src/session/session-store.ts +342 -205
  50. package/src/tools/registry.ts +18 -0
  51. package/src/tools/types.ts +46 -0
  52. package/src/tools/view-image.ts +89 -0
  53. package/src/tools/wait.ts +85 -0
  54. package/src/tui/components/memory-browser.tsx +3 -0
  55. package/src/tui/event-store.ts +61 -2
  56. package/src/model/input-token-estimator.ts +0 -25
  57. package/src/model/moonshot-input-token-estimator.ts +0 -111
  58. package/src/model/openai-responses-token-estimator.ts +0 -155
@@ -1,10 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import type { AgentMessage, AssistantMessage } from "../agent/types";
4
+ import { toolResultDisplayText } from "../agent/tool-result-content";
4
5
  import { cancellationError } from "../agent/turn-cancellation";
5
- import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
6
+ import {
7
+ IMAGE_INPUT_POLICY,
8
+ imagePlanningTokens,
9
+ providerImageDimensions,
10
+ } from "../image/image-input-policy";
6
11
  import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
7
- import type { InputTokenEstimator } from "./input-token-estimator";
12
+ import { materializeProviderImage } from "../image/provider-image";
8
13
  import type { ModelContextBudget } from "./model-context-profile";
9
14
  import type { ReasoningEffortController } from "./reasoning-effort";
10
15
  import type {
@@ -15,16 +20,17 @@ import type {
15
20
  ModelRequestInput,
16
21
  ModelRequestOptions,
17
22
  ModelRequestOutput,
18
- PreparedMediaDescriptor,
23
+ PreparedMediaOccurrence,
19
24
  PreparedModelRequest,
20
25
  PreparedPromptSegment,
21
26
  } from "./model-client";
27
+ import { validateModelModalities } from "./model-client";
22
28
  import { sha256, stableJsonStringify } from "./model-request-preflight";
23
29
  import { estimatePromptSegments } from "./token-estimator";
24
30
 
25
31
  export class FakeModelClient implements ModelClient {
26
32
  readonly inputModalities: readonly ("text" | "image")[];
27
- readonly inputTokenEstimator?: InputTokenEstimator;
33
+ readonly toolResultModalities: readonly ("text" | "image")[];
28
34
  readonly reasoningEffort?: ReasoningEffortController;
29
35
  readonly messageProtocol: ModelMessageProtocol = Object.freeze({
30
36
  adapter: "fake",
@@ -40,56 +46,20 @@ export class FakeModelClient implements ModelClient {
40
46
  model: string;
41
47
  contextBudget: ModelContextBudget;
42
48
  inputModalities?: readonly ("text" | "image")[];
49
+ toolResultModalities?: readonly ("text" | "image")[];
43
50
  reasoningEffort?: ReasoningEffortController;
44
51
  requestLogPath?: string;
45
- tokenEstimator?: {
46
- kind: "moonshot-estimate-token-count-v1";
47
- model: string;
48
- apiBase: string;
49
- timeoutMs: number;
50
- maxRetries: 0;
51
- };
52
52
  },
53
53
  ) {
54
54
  this.reasoningEffort = options.reasoningEffort;
55
- this.inputModalities = Object.freeze([
56
- ...(options.inputModalities ?? (["text"] as const)),
57
- ]);
58
- if (!this.inputModalities.includes("text")) {
59
- throw new Error('Fake model input modalities must include "text".');
60
- }
61
- if (
62
- this.inputModalities.includes("image") &&
63
- options.tokenEstimator === undefined
64
- ) {
65
- throw new Error("Image-capable fake model requires a token estimator.");
66
- }
67
- if (options.tokenEstimator !== undefined) {
68
- const estimator = options.tokenEstimator;
69
- const endpoint = tokenEstimatorEndpoint(estimator.apiBase);
70
- this.inputTokenEstimator = Object.freeze({
71
- kind: estimator.kind,
72
- compatibility: Object.freeze({
73
- kind: estimator.kind,
74
- coverageVersion: "full-request-v1",
75
- model: estimator.model,
76
- endpoint,
77
- timeoutMs: estimator.timeoutMs,
78
- maxRetries: estimator.maxRetries,
79
- }),
80
- async estimate(
81
- request: MaterializedModelRequest,
82
- estimateOptions: { signal: AbortSignal },
83
- ) {
84
- estimateOptions.signal.throwIfAborted();
85
- return Object.freeze({
86
- inputTokens: estimatePromptSegments(request.promptSegments).totalTokens,
87
- source: "provider_estimated" as const,
88
- coverage: "full_request" as const,
89
- });
90
- },
91
- });
92
- }
55
+ const modalities = validateModelModalities({
56
+ adapter: this.messageProtocol.adapter,
57
+ inputModalities: options.inputModalities ?? ["text"],
58
+ toolResultModalities: options.toolResultModalities ?? ["text"],
59
+ adapterToolResultModalities: ["text", "image"],
60
+ });
61
+ this.inputModalities = modalities.inputModalities;
62
+ this.toolResultModalities = modalities.toolResultModalities;
93
63
  }
94
64
 
95
65
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -99,7 +69,9 @@ export class FakeModelClient implements ModelClient {
99
69
  normalizedText: stableJsonStringify(tool),
100
70
  }),
101
71
  );
102
- const messageSegments = input.messages.map(toPromptSegment);
72
+ const messageSegments = input.messages.map((message, index) =>
73
+ toPromptSegment(message, index + 1),
74
+ );
103
75
  const mediaOccurrenceCount = messageSegments.reduce(
104
76
  (total, segment) => total + (segment.media?.length ?? 0),
105
77
  0,
@@ -113,9 +85,7 @@ export class FakeModelClient implements ModelClient {
113
85
  model: this.options.model,
114
86
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
115
87
  inputModalities: this.inputModalities,
116
- ...(this.inputTokenEstimator === undefined
117
- ? {}
118
- : { tokenEstimator: this.inputTokenEstimator.compatibility }),
88
+ toolResultModalities: this.toolResultModalities,
119
89
  }),
120
90
  );
121
91
  const prepared: PreparedModelRequest = Object.freeze({
@@ -167,6 +137,9 @@ export class FakeModelClient implements ModelClient {
167
137
  const materializedAssets: Array<{
168
138
  readonly assetId: ImageAssetId;
169
139
  readonly byteLength: number;
140
+ readonly width: number;
141
+ readonly height: number;
142
+ readonly planningTokens: number;
170
143
  readonly bytesSha256: string;
171
144
  }> = [];
172
145
  for (const asset of assets.values()) {
@@ -174,11 +147,15 @@ export class FakeModelClient implements ModelClient {
174
147
  const bytes = await options.assetStore.readVerified(asset, {
175
148
  signal: options.signal,
176
149
  });
150
+ const image = await materializeProviderImage(bytes, asset.mimeType);
177
151
  materializedAssets.push(
178
152
  Object.freeze({
179
153
  assetId: asset.assetId,
180
- byteLength: bytes.byteLength,
181
- bytesSha256: createHash("sha256").update(bytes).digest("hex"),
154
+ byteLength: image.bytes.byteLength,
155
+ width: image.width,
156
+ height: image.height,
157
+ planningTokens: image.planningTokens,
158
+ bytesSha256: createHash("sha256").update(image.bytes).digest("hex"),
182
159
  }),
183
160
  );
184
161
  }
@@ -191,6 +168,10 @@ export class FakeModelClient implements ModelClient {
191
168
  const materialized = Object.freeze({
192
169
  ...prepared,
193
170
  payload,
171
+ promptSegments: materializedFakePromptSegments(
172
+ prepared.promptSegments,
173
+ materializedAssets,
174
+ ),
194
175
  bodyBytes: Buffer.byteLength(stableJsonStringify(payload), "utf8"),
195
176
  });
196
177
  this.preparedInputs.set(materialized, input);
@@ -376,7 +357,7 @@ export class FakeModelClient implements ModelClient {
376
357
  description: "Exercise static history live tail",
377
358
  });
378
359
  }
379
- if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
360
+ if (!toolMessageText(bash).includes("PTY_STATIC_LIVE_LINE_20")) {
380
361
  throw new Error("PTY static-history Bash output was incomplete.");
381
362
  }
382
363
  return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
@@ -520,9 +501,9 @@ export class FakeModelClient implements ModelClient {
520
501
  });
521
502
  }
522
503
  if (
523
- !bash.content.includes("Bash failed") ||
524
- !bash.content.includes("exitCode=7") ||
525
- !bash.content.includes("PTY_TOOL_FAILURE_OUTPUT")
504
+ !toolMessageText(bash).includes("Bash failed") ||
505
+ !toolMessageText(bash).includes("exitCode=7") ||
506
+ !toolMessageText(bash).includes("PTY_TOOL_FAILURE_OUTPUT")
526
507
  ) {
527
508
  throw new Error("PTY Bash failure branch returned an unexpected result.");
528
509
  }
@@ -552,7 +533,7 @@ export class FakeModelClient implements ModelClient {
552
533
  content: "alpha\n",
553
534
  });
554
535
  }
555
- if (!write.content.includes("Write succeeded")) {
536
+ if (!toolMessageText(write).includes("Write succeeded")) {
556
537
  throw new Error("PTY Write tool did not succeed.");
557
538
  }
558
539
 
@@ -564,7 +545,7 @@ export class FakeModelClient implements ModelClient {
564
545
  new_string: "beta",
565
546
  });
566
547
  }
567
- if (!edit.content.includes("Edit succeeded")) {
548
+ if (!toolMessageText(edit).includes("Edit succeeded")) {
568
549
  throw new Error("PTY Edit tool did not succeed.");
569
550
  }
570
551
 
@@ -576,8 +557,8 @@ export class FakeModelClient implements ModelClient {
576
557
  });
577
558
  }
578
559
  if (
579
- !bash.content.includes("Bash completed") ||
580
- !bash.content.includes("PTY_BASH_OK:beta")
560
+ !toolMessageText(bash).includes("Bash completed") ||
561
+ !toolMessageText(bash).includes("PTY_BASH_OK:beta")
581
562
  ) {
582
563
  throw new Error("PTY Bash tool did not verify the edited file.");
583
564
  }
@@ -602,7 +583,7 @@ export class FakeModelClient implements ModelClient {
602
583
  file_path: "pty-undo-modified.txt",
603
584
  });
604
585
  }
605
- if (!read.content.includes("Read succeeded")) {
586
+ if (!toolMessageText(read).includes("Read succeeded")) {
606
587
  throw new Error("PTY undo Read tool did not succeed.");
607
588
  }
608
589
 
@@ -613,7 +594,10 @@ export class FakeModelClient implements ModelClient {
613
594
  content: "after undo turn\n",
614
595
  });
615
596
  }
616
- if (!writes[0]?.content.includes("Write succeeded")) {
597
+ if (
598
+ writes[0] === undefined ||
599
+ !toolMessageText(writes[0]).includes("Write succeeded")
600
+ ) {
617
601
  throw new Error("PTY undo modifying Write did not succeed.");
618
602
  }
619
603
  if (writes.length === 1) {
@@ -622,7 +606,10 @@ export class FakeModelClient implements ModelClient {
622
606
  content: "created by undo turn\n",
623
607
  });
624
608
  }
625
- if (!writes[1]?.content.includes("Write succeeded")) {
609
+ if (
610
+ writes[1] === undefined ||
611
+ !toolMessageText(writes[1]).includes("Write succeeded")
612
+ ) {
626
613
  throw new Error("PTY undo creating Write did not succeed.");
627
614
  }
628
615
 
@@ -632,7 +619,7 @@ export class FakeModelClient implements ModelClient {
632
619
  file_path: "pty-undo-deleted.bin",
633
620
  });
634
621
  }
635
- if (!deletion.content.includes("Delete succeeded")) {
622
+ if (!toolMessageText(deletion).includes("Delete succeeded")) {
636
623
  throw new Error("PTY undo Delete tool did not succeed.");
637
624
  }
638
625
  return textOutput(prepared, "PTY_UNDO_MUTATIONS_DONE");
@@ -661,10 +648,10 @@ export class FakeModelClient implements ModelClient {
661
648
  run_in_background: true,
662
649
  });
663
650
  }
664
- if (!bash.content.includes("Bash command is running in background")) {
651
+ if (!toolMessageText(bash).includes("Bash command is running in background")) {
665
652
  throw new Error("PTY Bash task did not enter the background.");
666
653
  }
667
- const taskId = requireObservationValue(bash.content, "taskId");
654
+ const taskId = requireObservationValue(toolMessageText(bash), "taskId");
668
655
 
669
656
  if (prompt === "PTY_BACKGROUND_QUIT") {
670
657
  return textOutput(prepared, "PTY_BACKGROUND_RUNNING");
@@ -672,7 +659,10 @@ export class FakeModelClient implements ModelClient {
672
659
 
673
660
  const outputs = tools.filter((message) => message.name === "TaskOutput");
674
661
  const output = outputs.at(-1);
675
- if (output === undefined || !output.content.includes("PTY_BACKGROUND_READY")) {
662
+ if (
663
+ output === undefined ||
664
+ !toolMessageText(output).includes("PTY_BACKGROUND_READY")
665
+ ) {
676
666
  if (outputs.length >= 20) {
677
667
  throw new Error("PTY background task did not produce its ready marker.");
678
668
  }
@@ -680,7 +670,7 @@ export class FakeModelClient implements ModelClient {
680
670
  task_id: taskId,
681
671
  });
682
672
  }
683
- if (!output.content.includes(`taskId=${taskId}`)) {
673
+ if (!toolMessageText(output).includes(`taskId=${taskId}`)) {
684
674
  throw new Error("PTY TaskOutput returned the wrong task.");
685
675
  }
686
676
 
@@ -691,8 +681,8 @@ export class FakeModelClient implements ModelClient {
691
681
  });
692
682
  }
693
683
  if (
694
- !stop.content.includes(`taskId=${taskId}`) ||
695
- !stop.content.includes("status=killed")
684
+ !toolMessageText(stop).includes(`taskId=${taskId}`) ||
685
+ !toolMessageText(stop).includes("status=killed")
696
686
  ) {
697
687
  throw new Error("PTY TaskStop did not kill the background task.");
698
688
  }
@@ -726,14 +716,17 @@ export class FakeModelClient implements ModelClient {
726
716
  timeout: 25,
727
717
  });
728
718
  }
729
- if (!bash.content.includes("taskId=") || !bash.content.includes("tty=true")) {
719
+ if (
720
+ !toolMessageText(bash).includes("taskId=") ||
721
+ !toolMessageText(bash).includes("tty=true")
722
+ ) {
730
723
  throw new Error("PTY Bash task did not return an interactive task ID.");
731
724
  }
732
- const taskId = requireObservationValue(bash.content, "taskId");
725
+ const taskId = requireObservationValue(toolMessageText(bash), "taskId");
733
726
 
734
727
  const outputs = tools.filter((message) => message.name === "TaskOutput");
735
728
  const output = outputs.at(-1);
736
- if (output === undefined || !output.content.includes(">>>")) {
729
+ if (output === undefined || !toolMessageText(output).includes(">>>")) {
737
730
  if (outputs.length >= 20) {
738
731
  throw new Error("Interactive Python fixture did not show its prompt.");
739
732
  }
@@ -756,7 +749,7 @@ export class FakeModelClient implements ModelClient {
756
749
 
757
750
  const latestInput = inputs.at(-1);
758
751
  const expected = prompt === "PTY_INTERACTIVE_QUIT" ? "PTY_INTERACTIVE_PID=" : "42";
759
- if (!latestInput?.content.includes(expected)) {
752
+ if (latestInput === undefined || !toolMessageText(latestInput).includes(expected)) {
760
753
  if (inputs.length >= 20) {
761
754
  throw new Error(`Interactive Python fixture did not show ${expected}.`);
762
755
  }
@@ -770,7 +763,9 @@ export class FakeModelClient implements ModelClient {
770
763
  if (prompt === "PTY_INTERACTIVE_QUIT") {
771
764
  return textOutput(prepared, "PTY_INTERACTIVE_RUNNING");
772
765
  }
773
- if (!inputs.some((message) => message.content.includes("status=completed"))) {
766
+ if (
767
+ !inputs.some((message) => toolMessageText(message).includes("status=completed"))
768
+ ) {
774
769
  return toolCallOutput(prepared, options, "TaskInput", {
775
770
  task_id: taskId,
776
771
  chars: "exit()\n",
@@ -805,7 +800,7 @@ export class FakeModelClient implements ModelClient {
805
800
  content: "PTY_RESUME_SIDE_EFFECT\n",
806
801
  });
807
802
  }
808
- if (!write.content.includes("Write succeeded")) {
803
+ if (!toolMessageText(write).includes("Write succeeded")) {
809
804
  throw new Error("PTY resume seed Write did not succeed.");
810
805
  }
811
806
  return textOutput(prepared, "PTY_RESUME_SEED_DONE");
@@ -837,7 +832,7 @@ export class FakeModelClient implements ModelClient {
837
832
  content: "PTY_INTERRUPT_SIDE_EFFECT\n",
838
833
  });
839
834
  }
840
- if (!write.content.includes("Write succeeded")) {
835
+ if (!toolMessageText(write).includes("Write succeeded")) {
841
836
  throw new Error("PTY interrupted Write did not succeed.");
842
837
  }
843
838
  return waitForCancellation(options.signal);
@@ -928,7 +923,7 @@ export class FakeModelClient implements ModelClient {
928
923
  content: "PTY_FORK_SHARED_HISTORY\n",
929
924
  });
930
925
  }
931
- if (!write.content.includes("Write succeeded")) {
926
+ if (!toolMessageText(write).includes("Write succeeded")) {
932
927
  throw new Error("PTY fork seed Write did not succeed.");
933
928
  }
934
929
  return textOutput(prepared, "PTY_FORK_SEED_DONE");
@@ -1005,7 +1000,7 @@ export class FakeModelClient implements ModelClient {
1005
1000
  file_path: "context-heavy.txt",
1006
1001
  });
1007
1002
  }
1008
- if (!read.content.includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
1003
+ if (!toolMessageText(read).includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
1009
1004
  throw new Error("PTY context Read did not return the original marker.");
1010
1005
  }
1011
1006
  return textOutput(prepared, "PTY_CONTEXT_HEAVY_DONE");
@@ -1097,7 +1092,7 @@ export class FakeModelClient implements ModelClient {
1097
1092
  name: "pty-review",
1098
1093
  });
1099
1094
  }
1100
- if (!skill.content.includes("PTY_SKILL_INSTRUCTIONS")) {
1095
+ if (!toolMessageText(skill).includes("PTY_SKILL_INSTRUCTIONS")) {
1101
1096
  throw new Error("PTY Skill result did not contain the fixture instructions.");
1102
1097
  }
1103
1098
  return textOutput(prepared, "PTY_SKILL_DONE");
@@ -1129,7 +1124,7 @@ export class FakeModelClient implements ModelClient {
1129
1124
  message: "PTY_MCP_PAYLOAD",
1130
1125
  });
1131
1126
  }
1132
- if (!echo.content.includes("echo: PTY_MCP_PAYLOAD")) {
1127
+ if (!toolMessageText(echo).includes("echo: PTY_MCP_PAYLOAD")) {
1133
1128
  throw new Error("PTY MCP echo returned unexpected content.");
1134
1129
  }
1135
1130
  return textOutput(prepared, "PTY_MCP_DONE\n\necho: PTY_MCP_PAYLOAD");
@@ -1217,10 +1212,9 @@ export class FakeModelClient implements ModelClient {
1217
1212
  "tool_calls",
1218
1213
  );
1219
1214
  }
1220
- if (latestRecallResult.content.startsWith("Recall searched")) {
1221
- const source = latestRecallResult.content.match(
1222
- /^source=(ctx:\/\/message\/[0-9a-f-]+)$/m,
1223
- )?.[1];
1215
+ const recallText = toolMessageText(latestRecallResult);
1216
+ if (recallText.startsWith("Recall searched")) {
1217
+ const source = recallText.match(/^source=(ctx:\/\/message\/[0-9a-f-]+)$/m)?.[1];
1224
1218
  if (source === undefined) {
1225
1219
  throw new Error("Fake RecallSearch did not return a source.");
1226
1220
  }
@@ -1243,7 +1237,7 @@ export class FakeModelClient implements ModelClient {
1243
1237
  "tool_calls",
1244
1238
  );
1245
1239
  }
1246
- if (!latestRecallResult.content.includes("recall-smoke-marker")) {
1240
+ if (!recallText.includes("recall-smoke-marker")) {
1247
1241
  throw new Error("Fake RecallGet did not recover the expected marker.");
1248
1242
  }
1249
1243
  return outputWithUsage(
@@ -1348,6 +1342,10 @@ function toolMessagesAfterLastUser(
1348
1342
  );
1349
1343
  }
1350
1344
 
1345
+ function toolMessageText(message: Extract<AgentMessage, { role: "tool" }>): string {
1346
+ return toolResultDisplayText(message.content);
1347
+ }
1348
+
1351
1349
  function requireMessage(
1352
1350
  messages: AgentMessage[],
1353
1351
  role: "user" | "assistant",
@@ -1418,7 +1416,7 @@ function requireToolMessage(
1418
1416
  (message) =>
1419
1417
  message.role === "tool" &&
1420
1418
  message.name === name &&
1421
- message.content.includes(content),
1419
+ toolMessageText(message).includes(content),
1422
1420
  );
1423
1421
  if (!found) {
1424
1422
  throw new Error(`Fake PTY context is missing ${name} tool content ${content}.`);
@@ -1453,20 +1451,30 @@ function lastMessageIndex(
1453
1451
  return -1;
1454
1452
  }
1455
1453
 
1456
- function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
1454
+ function toPromptSegment(
1455
+ message: AgentMessage,
1456
+ messageOrdinal = 0,
1457
+ ): PreparedPromptSegment {
1457
1458
  if (message.role === "user" && message.attachments !== undefined) {
1458
1459
  const media = message.attachments.map(
1459
- (attachment): PreparedMediaDescriptor =>
1460
- Object.freeze({
1461
- assetId: attachment.assetId,
1462
- label: attachment.label,
1463
- range: Object.freeze({ ...attachment.range }),
1464
- mimeType: attachment.mimeType,
1465
- byteLength: attachment.byteLength,
1466
- width: attachment.width,
1467
- height: attachment.height,
1468
- planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
1469
- }),
1460
+ (attachment, blockPosition): PreparedMediaOccurrence => {
1461
+ const dimensions = providerImageDimensions(attachment.width, attachment.height);
1462
+ return Object.freeze({
1463
+ asset: Object.freeze({
1464
+ assetId: attachment.assetId,
1465
+ mimeType: attachment.mimeType,
1466
+ byteLength: attachment.byteLength,
1467
+ width: attachment.width,
1468
+ height: attachment.height,
1469
+ }),
1470
+ source: "user_attachment",
1471
+ messageOrdinal,
1472
+ blockPosition,
1473
+ width: dimensions.width,
1474
+ height: dimensions.height,
1475
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
1476
+ });
1477
+ },
1470
1478
  );
1471
1479
  return Object.freeze({
1472
1480
  kind: "user",
@@ -1474,6 +1482,30 @@ function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
1474
1482
  media: Object.freeze(media),
1475
1483
  });
1476
1484
  }
1485
+ if (message.role === "tool") {
1486
+ const media = message.content.flatMap((block, blockPosition) => {
1487
+ if (block.type !== "image") {
1488
+ return [];
1489
+ }
1490
+ const dimensions = providerImageDimensions(block.asset.width, block.asset.height);
1491
+ return [
1492
+ Object.freeze<PreparedMediaOccurrence>({
1493
+ asset: Object.freeze({ ...block.asset }),
1494
+ source: "tool_result",
1495
+ messageOrdinal,
1496
+ blockPosition,
1497
+ width: dimensions.width,
1498
+ height: dimensions.height,
1499
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
1500
+ }),
1501
+ ];
1502
+ });
1503
+ return Object.freeze({
1504
+ kind: "tool",
1505
+ normalizedText: stableJsonStringify(message),
1506
+ ...(media.length === 0 ? {} : { media: Object.freeze(media) }),
1507
+ });
1508
+ }
1477
1509
  return {
1478
1510
  kind:
1479
1511
  message.role === "system"
@@ -1491,33 +1523,55 @@ function distinctPreparedAssets(
1491
1523
  const assets = new Map<ImageAssetId, ImageAssetRef>();
1492
1524
  for (const segment of segments) {
1493
1525
  for (const media of segment.media ?? []) {
1494
- const asset = Object.freeze({
1495
- assetId: media.assetId,
1496
- mimeType: media.mimeType,
1497
- byteLength: media.byteLength,
1498
- width: media.width,
1499
- height: media.height,
1500
- });
1501
- const existing = assets.get(media.assetId);
1526
+ const asset = media.asset;
1527
+ const existing = assets.get(asset.assetId);
1502
1528
  if (
1503
1529
  existing !== undefined &&
1504
1530
  stableJsonStringify(existing) !== stableJsonStringify(asset)
1505
1531
  ) {
1506
- throw new Error(`Conflicting fake image descriptors for ${media.assetId}.`);
1532
+ throw new Error(`Conflicting fake image descriptors for ${asset.assetId}.`);
1507
1533
  }
1508
- assets.set(media.assetId, asset);
1534
+ assets.set(asset.assetId, asset);
1509
1535
  }
1510
1536
  }
1511
1537
  return assets;
1512
1538
  }
1513
1539
 
1514
- function tokenEstimatorEndpoint(apiBase: string): string {
1515
- const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
1516
- base.username = "";
1517
- base.password = "";
1518
- base.search = "";
1519
- base.hash = "";
1520
- return new URL("tokenizers/estimate-token-count", base).toString();
1540
+ function materializedFakePromptSegments(
1541
+ segments: readonly PreparedPromptSegment[],
1542
+ images: readonly {
1543
+ assetId: ImageAssetId;
1544
+ width: number;
1545
+ height: number;
1546
+ planningTokens: number;
1547
+ }[],
1548
+ ): readonly PreparedPromptSegment[] {
1549
+ const byId = new Map(images.map((image) => [image.assetId, image] as const));
1550
+ return Object.freeze(
1551
+ segments.map((segment) =>
1552
+ segment.media === undefined
1553
+ ? segment
1554
+ : Object.freeze({
1555
+ ...segment,
1556
+ media: Object.freeze(
1557
+ segment.media.map((media) => {
1558
+ const image = byId.get(media.asset.assetId);
1559
+ if (image === undefined) {
1560
+ throw new Error(
1561
+ `Fake image ${media.asset.assetId} was not materialized.`,
1562
+ );
1563
+ }
1564
+ return Object.freeze({
1565
+ ...media,
1566
+ width: image.width,
1567
+ height: image.height,
1568
+ planningTokens: image.planningTokens,
1569
+ });
1570
+ }),
1571
+ ),
1572
+ }),
1573
+ ),
1574
+ );
1521
1575
  }
1522
1576
 
1523
1577
  function recallMarker(
@@ -1537,10 +1591,9 @@ function recallMarker(
1537
1591
  query: marker,
1538
1592
  });
1539
1593
  }
1540
- if (latestRecallResult.content.startsWith("Recall searched")) {
1541
- const source = latestRecallResult.content.match(
1542
- /^source=(ctx:\/\/message\/[0-9a-f-]+)$/m,
1543
- )?.[1];
1594
+ const recallText = toolMessageText(latestRecallResult);
1595
+ if (recallText.startsWith("Recall searched")) {
1596
+ const source = recallText.match(/^source=(ctx:\/\/message\/[0-9a-f-]+)$/m)?.[1];
1544
1597
  if (source === undefined) {
1545
1598
  throw new Error("Fake PTY RecallSearch did not return a source.");
1546
1599
  }
@@ -1548,7 +1601,7 @@ function recallMarker(
1548
1601
  source,
1549
1602
  });
1550
1603
  }
1551
- if (!latestRecallResult.content.includes(marker)) {
1604
+ if (!recallText.includes(marker)) {
1552
1605
  throw new Error(`Fake PTY RecallGet did not recover ${marker}.`);
1553
1606
  }
1554
1607
  return textOutput(prepared, finalText);