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
@@ -1,6 +1,7 @@
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
6
  import {
6
7
  IMAGE_INPUT_POLICY,
@@ -19,15 +20,17 @@ import type {
19
20
  ModelRequestInput,
20
21
  ModelRequestOptions,
21
22
  ModelRequestOutput,
22
- PreparedMediaDescriptor,
23
+ PreparedMediaOccurrence,
23
24
  PreparedModelRequest,
24
25
  PreparedPromptSegment,
25
26
  } from "./model-client";
27
+ import { validateModelModalities } from "./model-client";
26
28
  import { sha256, stableJsonStringify } from "./model-request-preflight";
27
29
  import { estimatePromptSegments } from "./token-estimator";
28
30
 
29
31
  export class FakeModelClient implements ModelClient {
30
32
  readonly inputModalities: readonly ("text" | "image")[];
33
+ readonly toolResultModalities: readonly ("text" | "image")[];
31
34
  readonly reasoningEffort?: ReasoningEffortController;
32
35
  readonly messageProtocol: ModelMessageProtocol = Object.freeze({
33
36
  adapter: "fake",
@@ -43,17 +46,20 @@ export class FakeModelClient implements ModelClient {
43
46
  model: string;
44
47
  contextBudget: ModelContextBudget;
45
48
  inputModalities?: readonly ("text" | "image")[];
49
+ toolResultModalities?: readonly ("text" | "image")[];
46
50
  reasoningEffort?: ReasoningEffortController;
47
51
  requestLogPath?: string;
48
52
  },
49
53
  ) {
50
54
  this.reasoningEffort = options.reasoningEffort;
51
- this.inputModalities = Object.freeze([
52
- ...(options.inputModalities ?? (["text"] as const)),
53
- ]);
54
- if (!this.inputModalities.includes("text")) {
55
- throw new Error('Fake model input modalities must include "text".');
56
- }
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;
57
63
  }
58
64
 
59
65
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -63,7 +69,9 @@ export class FakeModelClient implements ModelClient {
63
69
  normalizedText: stableJsonStringify(tool),
64
70
  }),
65
71
  );
66
- const messageSegments = input.messages.map(toPromptSegment);
72
+ const messageSegments = input.messages.map((message, index) =>
73
+ toPromptSegment(message, index + 1),
74
+ );
67
75
  const mediaOccurrenceCount = messageSegments.reduce(
68
76
  (total, segment) => total + (segment.media?.length ?? 0),
69
77
  0,
@@ -77,6 +85,7 @@ export class FakeModelClient implements ModelClient {
77
85
  model: this.options.model,
78
86
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
79
87
  inputModalities: this.inputModalities,
88
+ toolResultModalities: this.toolResultModalities,
80
89
  }),
81
90
  );
82
91
  const prepared: PreparedModelRequest = Object.freeze({
@@ -348,7 +357,7 @@ export class FakeModelClient implements ModelClient {
348
357
  description: "Exercise static history live tail",
349
358
  });
350
359
  }
351
- if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
360
+ if (!toolMessageText(bash).includes("PTY_STATIC_LIVE_LINE_20")) {
352
361
  throw new Error("PTY static-history Bash output was incomplete.");
353
362
  }
354
363
  return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
@@ -492,9 +501,9 @@ export class FakeModelClient implements ModelClient {
492
501
  });
493
502
  }
494
503
  if (
495
- !bash.content.includes("Bash failed") ||
496
- !bash.content.includes("exitCode=7") ||
497
- !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")
498
507
  ) {
499
508
  throw new Error("PTY Bash failure branch returned an unexpected result.");
500
509
  }
@@ -524,7 +533,7 @@ export class FakeModelClient implements ModelClient {
524
533
  content: "alpha\n",
525
534
  });
526
535
  }
527
- if (!write.content.includes("Write succeeded")) {
536
+ if (!toolMessageText(write).includes("Write succeeded")) {
528
537
  throw new Error("PTY Write tool did not succeed.");
529
538
  }
530
539
 
@@ -536,7 +545,7 @@ export class FakeModelClient implements ModelClient {
536
545
  new_string: "beta",
537
546
  });
538
547
  }
539
- if (!edit.content.includes("Edit succeeded")) {
548
+ if (!toolMessageText(edit).includes("Edit succeeded")) {
540
549
  throw new Error("PTY Edit tool did not succeed.");
541
550
  }
542
551
 
@@ -548,8 +557,8 @@ export class FakeModelClient implements ModelClient {
548
557
  });
549
558
  }
550
559
  if (
551
- !bash.content.includes("Bash completed") ||
552
- !bash.content.includes("PTY_BASH_OK:beta")
560
+ !toolMessageText(bash).includes("Bash completed") ||
561
+ !toolMessageText(bash).includes("PTY_BASH_OK:beta")
553
562
  ) {
554
563
  throw new Error("PTY Bash tool did not verify the edited file.");
555
564
  }
@@ -574,7 +583,7 @@ export class FakeModelClient implements ModelClient {
574
583
  file_path: "pty-undo-modified.txt",
575
584
  });
576
585
  }
577
- if (!read.content.includes("Read succeeded")) {
586
+ if (!toolMessageText(read).includes("Read succeeded")) {
578
587
  throw new Error("PTY undo Read tool did not succeed.");
579
588
  }
580
589
 
@@ -585,7 +594,10 @@ export class FakeModelClient implements ModelClient {
585
594
  content: "after undo turn\n",
586
595
  });
587
596
  }
588
- if (!writes[0]?.content.includes("Write succeeded")) {
597
+ if (
598
+ writes[0] === undefined ||
599
+ !toolMessageText(writes[0]).includes("Write succeeded")
600
+ ) {
589
601
  throw new Error("PTY undo modifying Write did not succeed.");
590
602
  }
591
603
  if (writes.length === 1) {
@@ -594,7 +606,10 @@ export class FakeModelClient implements ModelClient {
594
606
  content: "created by undo turn\n",
595
607
  });
596
608
  }
597
- if (!writes[1]?.content.includes("Write succeeded")) {
609
+ if (
610
+ writes[1] === undefined ||
611
+ !toolMessageText(writes[1]).includes("Write succeeded")
612
+ ) {
598
613
  throw new Error("PTY undo creating Write did not succeed.");
599
614
  }
600
615
 
@@ -604,7 +619,7 @@ export class FakeModelClient implements ModelClient {
604
619
  file_path: "pty-undo-deleted.bin",
605
620
  });
606
621
  }
607
- if (!deletion.content.includes("Delete succeeded")) {
622
+ if (!toolMessageText(deletion).includes("Delete succeeded")) {
608
623
  throw new Error("PTY undo Delete tool did not succeed.");
609
624
  }
610
625
  return textOutput(prepared, "PTY_UNDO_MUTATIONS_DONE");
@@ -633,10 +648,10 @@ export class FakeModelClient implements ModelClient {
633
648
  run_in_background: true,
634
649
  });
635
650
  }
636
- if (!bash.content.includes("Bash command is running in background")) {
651
+ if (!toolMessageText(bash).includes("Bash command is running in background")) {
637
652
  throw new Error("PTY Bash task did not enter the background.");
638
653
  }
639
- const taskId = requireObservationValue(bash.content, "taskId");
654
+ const taskId = requireObservationValue(toolMessageText(bash), "taskId");
640
655
 
641
656
  if (prompt === "PTY_BACKGROUND_QUIT") {
642
657
  return textOutput(prepared, "PTY_BACKGROUND_RUNNING");
@@ -644,7 +659,10 @@ export class FakeModelClient implements ModelClient {
644
659
 
645
660
  const outputs = tools.filter((message) => message.name === "TaskOutput");
646
661
  const output = outputs.at(-1);
647
- if (output === undefined || !output.content.includes("PTY_BACKGROUND_READY")) {
662
+ if (
663
+ output === undefined ||
664
+ !toolMessageText(output).includes("PTY_BACKGROUND_READY")
665
+ ) {
648
666
  if (outputs.length >= 20) {
649
667
  throw new Error("PTY background task did not produce its ready marker.");
650
668
  }
@@ -652,7 +670,7 @@ export class FakeModelClient implements ModelClient {
652
670
  task_id: taskId,
653
671
  });
654
672
  }
655
- if (!output.content.includes(`taskId=${taskId}`)) {
673
+ if (!toolMessageText(output).includes(`taskId=${taskId}`)) {
656
674
  throw new Error("PTY TaskOutput returned the wrong task.");
657
675
  }
658
676
 
@@ -663,8 +681,8 @@ export class FakeModelClient implements ModelClient {
663
681
  });
664
682
  }
665
683
  if (
666
- !stop.content.includes(`taskId=${taskId}`) ||
667
- !stop.content.includes("status=killed")
684
+ !toolMessageText(stop).includes(`taskId=${taskId}`) ||
685
+ !toolMessageText(stop).includes("status=killed")
668
686
  ) {
669
687
  throw new Error("PTY TaskStop did not kill the background task.");
670
688
  }
@@ -698,14 +716,17 @@ export class FakeModelClient implements ModelClient {
698
716
  timeout: 25,
699
717
  });
700
718
  }
701
- if (!bash.content.includes("taskId=") || !bash.content.includes("tty=true")) {
719
+ if (
720
+ !toolMessageText(bash).includes("taskId=") ||
721
+ !toolMessageText(bash).includes("tty=true")
722
+ ) {
702
723
  throw new Error("PTY Bash task did not return an interactive task ID.");
703
724
  }
704
- const taskId = requireObservationValue(bash.content, "taskId");
725
+ const taskId = requireObservationValue(toolMessageText(bash), "taskId");
705
726
 
706
727
  const outputs = tools.filter((message) => message.name === "TaskOutput");
707
728
  const output = outputs.at(-1);
708
- if (output === undefined || !output.content.includes(">>>")) {
729
+ if (output === undefined || !toolMessageText(output).includes(">>>")) {
709
730
  if (outputs.length >= 20) {
710
731
  throw new Error("Interactive Python fixture did not show its prompt.");
711
732
  }
@@ -728,7 +749,7 @@ export class FakeModelClient implements ModelClient {
728
749
 
729
750
  const latestInput = inputs.at(-1);
730
751
  const expected = prompt === "PTY_INTERACTIVE_QUIT" ? "PTY_INTERACTIVE_PID=" : "42";
731
- if (!latestInput?.content.includes(expected)) {
752
+ if (latestInput === undefined || !toolMessageText(latestInput).includes(expected)) {
732
753
  if (inputs.length >= 20) {
733
754
  throw new Error(`Interactive Python fixture did not show ${expected}.`);
734
755
  }
@@ -742,7 +763,9 @@ export class FakeModelClient implements ModelClient {
742
763
  if (prompt === "PTY_INTERACTIVE_QUIT") {
743
764
  return textOutput(prepared, "PTY_INTERACTIVE_RUNNING");
744
765
  }
745
- if (!inputs.some((message) => message.content.includes("status=completed"))) {
766
+ if (
767
+ !inputs.some((message) => toolMessageText(message).includes("status=completed"))
768
+ ) {
746
769
  return toolCallOutput(prepared, options, "TaskInput", {
747
770
  task_id: taskId,
748
771
  chars: "exit()\n",
@@ -777,7 +800,7 @@ export class FakeModelClient implements ModelClient {
777
800
  content: "PTY_RESUME_SIDE_EFFECT\n",
778
801
  });
779
802
  }
780
- if (!write.content.includes("Write succeeded")) {
803
+ if (!toolMessageText(write).includes("Write succeeded")) {
781
804
  throw new Error("PTY resume seed Write did not succeed.");
782
805
  }
783
806
  return textOutput(prepared, "PTY_RESUME_SEED_DONE");
@@ -809,7 +832,7 @@ export class FakeModelClient implements ModelClient {
809
832
  content: "PTY_INTERRUPT_SIDE_EFFECT\n",
810
833
  });
811
834
  }
812
- if (!write.content.includes("Write succeeded")) {
835
+ if (!toolMessageText(write).includes("Write succeeded")) {
813
836
  throw new Error("PTY interrupted Write did not succeed.");
814
837
  }
815
838
  return waitForCancellation(options.signal);
@@ -900,7 +923,7 @@ export class FakeModelClient implements ModelClient {
900
923
  content: "PTY_FORK_SHARED_HISTORY\n",
901
924
  });
902
925
  }
903
- if (!write.content.includes("Write succeeded")) {
926
+ if (!toolMessageText(write).includes("Write succeeded")) {
904
927
  throw new Error("PTY fork seed Write did not succeed.");
905
928
  }
906
929
  return textOutput(prepared, "PTY_FORK_SEED_DONE");
@@ -977,7 +1000,7 @@ export class FakeModelClient implements ModelClient {
977
1000
  file_path: "context-heavy.txt",
978
1001
  });
979
1002
  }
980
- if (!read.content.includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
1003
+ if (!toolMessageText(read).includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
981
1004
  throw new Error("PTY context Read did not return the original marker.");
982
1005
  }
983
1006
  return textOutput(prepared, "PTY_CONTEXT_HEAVY_DONE");
@@ -1069,7 +1092,7 @@ export class FakeModelClient implements ModelClient {
1069
1092
  name: "pty-review",
1070
1093
  });
1071
1094
  }
1072
- if (!skill.content.includes("PTY_SKILL_INSTRUCTIONS")) {
1095
+ if (!toolMessageText(skill).includes("PTY_SKILL_INSTRUCTIONS")) {
1073
1096
  throw new Error("PTY Skill result did not contain the fixture instructions.");
1074
1097
  }
1075
1098
  return textOutput(prepared, "PTY_SKILL_DONE");
@@ -1101,7 +1124,7 @@ export class FakeModelClient implements ModelClient {
1101
1124
  message: "PTY_MCP_PAYLOAD",
1102
1125
  });
1103
1126
  }
1104
- if (!echo.content.includes("echo: PTY_MCP_PAYLOAD")) {
1127
+ if (!toolMessageText(echo).includes("echo: PTY_MCP_PAYLOAD")) {
1105
1128
  throw new Error("PTY MCP echo returned unexpected content.");
1106
1129
  }
1107
1130
  return textOutput(prepared, "PTY_MCP_DONE\n\necho: PTY_MCP_PAYLOAD");
@@ -1189,10 +1212,9 @@ export class FakeModelClient implements ModelClient {
1189
1212
  "tool_calls",
1190
1213
  );
1191
1214
  }
1192
- if (latestRecallResult.content.startsWith("Recall searched")) {
1193
- const source = latestRecallResult.content.match(
1194
- /^source=(ctx:\/\/message\/[0-9a-f-]+)$/m,
1195
- )?.[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];
1196
1218
  if (source === undefined) {
1197
1219
  throw new Error("Fake RecallSearch did not return a source.");
1198
1220
  }
@@ -1215,7 +1237,7 @@ export class FakeModelClient implements ModelClient {
1215
1237
  "tool_calls",
1216
1238
  );
1217
1239
  }
1218
- if (!latestRecallResult.content.includes("recall-smoke-marker")) {
1240
+ if (!recallText.includes("recall-smoke-marker")) {
1219
1241
  throw new Error("Fake RecallGet did not recover the expected marker.");
1220
1242
  }
1221
1243
  return outputWithUsage(
@@ -1320,6 +1342,10 @@ function toolMessagesAfterLastUser(
1320
1342
  );
1321
1343
  }
1322
1344
 
1345
+ function toolMessageText(message: Extract<AgentMessage, { role: "tool" }>): string {
1346
+ return toolResultDisplayText(message.content);
1347
+ }
1348
+
1323
1349
  function requireMessage(
1324
1350
  messages: AgentMessage[],
1325
1351
  role: "user" | "assistant",
@@ -1390,7 +1416,7 @@ function requireToolMessage(
1390
1416
  (message) =>
1391
1417
  message.role === "tool" &&
1392
1418
  message.name === name &&
1393
- message.content.includes(content),
1419
+ toolMessageText(message).includes(content),
1394
1420
  );
1395
1421
  if (!found) {
1396
1422
  throw new Error(`Fake PTY context is missing ${name} tool content ${content}.`);
@@ -1425,29 +1451,61 @@ function lastMessageIndex(
1425
1451
  return -1;
1426
1452
  }
1427
1453
 
1428
- function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
1454
+ function toPromptSegment(
1455
+ message: AgentMessage,
1456
+ messageOrdinal = 0,
1457
+ ): PreparedPromptSegment {
1429
1458
  if (message.role === "user" && message.attachments !== undefined) {
1430
- const media = message.attachments.map((attachment): PreparedMediaDescriptor => {
1431
- const dimensions = providerImageDimensions(attachment.width, attachment.height);
1432
- return Object.freeze({
1433
- assetId: attachment.assetId,
1434
- label: attachment.label,
1435
- range: Object.freeze({ ...attachment.range }),
1436
- mimeType: attachment.mimeType,
1437
- byteLength: attachment.byteLength,
1438
- sourceWidth: attachment.width,
1439
- sourceHeight: attachment.height,
1440
- width: dimensions.width,
1441
- height: dimensions.height,
1442
- planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
1443
- });
1444
- });
1459
+ const media = message.attachments.map(
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
+ },
1478
+ );
1445
1479
  return Object.freeze({
1446
1480
  kind: "user",
1447
1481
  normalizedText: message.content,
1448
1482
  media: Object.freeze(media),
1449
1483
  });
1450
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
+ }
1451
1509
  return {
1452
1510
  kind:
1453
1511
  message.role === "system"
@@ -1465,21 +1523,15 @@ function distinctPreparedAssets(
1465
1523
  const assets = new Map<ImageAssetId, ImageAssetRef>();
1466
1524
  for (const segment of segments) {
1467
1525
  for (const media of segment.media ?? []) {
1468
- const asset = Object.freeze({
1469
- assetId: media.assetId,
1470
- mimeType: media.mimeType,
1471
- byteLength: media.byteLength,
1472
- width: media.sourceWidth,
1473
- height: media.sourceHeight,
1474
- });
1475
- const existing = assets.get(media.assetId);
1526
+ const asset = media.asset;
1527
+ const existing = assets.get(asset.assetId);
1476
1528
  if (
1477
1529
  existing !== undefined &&
1478
1530
  stableJsonStringify(existing) !== stableJsonStringify(asset)
1479
1531
  ) {
1480
- throw new Error(`Conflicting fake image descriptors for ${media.assetId}.`);
1532
+ throw new Error(`Conflicting fake image descriptors for ${asset.assetId}.`);
1481
1533
  }
1482
- assets.set(media.assetId, asset);
1534
+ assets.set(asset.assetId, asset);
1483
1535
  }
1484
1536
  }
1485
1537
  return assets;
@@ -1503,9 +1555,11 @@ function materializedFakePromptSegments(
1503
1555
  ...segment,
1504
1556
  media: Object.freeze(
1505
1557
  segment.media.map((media) => {
1506
- const image = byId.get(media.assetId);
1558
+ const image = byId.get(media.asset.assetId);
1507
1559
  if (image === undefined) {
1508
- throw new Error(`Fake image ${media.assetId} was not materialized.`);
1560
+ throw new Error(
1561
+ `Fake image ${media.asset.assetId} was not materialized.`,
1562
+ );
1509
1563
  }
1510
1564
  return Object.freeze({
1511
1565
  ...media,
@@ -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);
@@ -2,12 +2,13 @@ import type { AgentMessage, AssistantMessage, IterationIdentity } from "../agent
2
2
  import type { RuntimeSessionContext } from "../agent/runtime-session";
3
3
  import type { ToolDefinition } from "../tools/types";
4
4
  import type { ImageAssetStore } from "../image/image-asset-store";
5
- import type { CodePointRange, ImageAssetId, ImageMimeType } from "../image/image-types";
5
+ import type { ImageAssetRef } from "../image/image-types";
6
6
  import type { ReasoningEffortController } from "./reasoning-effort";
7
7
 
8
8
  export interface ModelClient {
9
9
  readonly messageProtocol: ModelMessageProtocol;
10
- readonly inputModalities?: readonly ("text" | "image")[];
10
+ readonly inputModalities: readonly ModelInputModality[];
11
+ readonly toolResultModalities: readonly ToolResultModality[];
11
12
  readonly reasoningEffort?: ReasoningEffortController;
12
13
  prepare(input: ModelRequestInput): PreparedModelRequest;
13
14
  materialize?(
@@ -20,6 +21,59 @@ export interface ModelClient {
20
21
  ): Promise<ModelRequestOutput>;
21
22
  }
22
23
 
24
+ export type ModelInputModality = "text" | "image";
25
+ export type ToolResultModality = "text" | "image";
26
+
27
+ export function validateModelModalities(input: {
28
+ readonly profileName?: string;
29
+ readonly adapter: ModelMessageProtocol["adapter"];
30
+ readonly inputModalities: readonly ModelInputModality[];
31
+ readonly toolResultModalities: readonly ToolResultModality[];
32
+ readonly adapterToolResultModalities: readonly ToolResultModality[];
33
+ }): {
34
+ readonly inputModalities: readonly ModelInputModality[];
35
+ readonly toolResultModalities: readonly ToolResultModality[];
36
+ } {
37
+ const inputModalities = normalizeModalities(input.inputModalities, "model input");
38
+ const toolResultModalities = normalizeModalities(
39
+ input.toolResultModalities,
40
+ "tool result",
41
+ );
42
+ if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
43
+ throw new Error(
44
+ 'Image tool results require "image" in the model input modalities.',
45
+ );
46
+ }
47
+ const unsupported = toolResultModalities.find(
48
+ (modality) => !input.adapterToolResultModalities.includes(modality),
49
+ );
50
+ if (unsupported !== undefined) {
51
+ const subject =
52
+ input.profileName === undefined
53
+ ? "Model configuration"
54
+ : `Profile ${JSON.stringify(input.profileName)}`;
55
+ throw new Error(
56
+ `${subject} declares ${unsupported} tool results, but adapter ${JSON.stringify(input.adapter)} does not support them.`,
57
+ );
58
+ }
59
+ return Object.freeze({ inputModalities, toolResultModalities });
60
+ }
61
+
62
+ function normalizeModalities(
63
+ modalities: readonly (ModelInputModality | ToolResultModality)[],
64
+ label: string,
65
+ ): readonly ("text" | "image")[] {
66
+ if (
67
+ modalities.length === 0 ||
68
+ modalities.some((modality) => modality !== "text" && modality !== "image") ||
69
+ new Set(modalities).size !== modalities.length ||
70
+ !modalities.includes("text")
71
+ ) {
72
+ throw new Error(`${label} modalities must be unique and include "text".`);
73
+ }
74
+ return Object.freeze(modalities.includes("image") ? ["text", "image"] : ["text"]);
75
+ }
76
+
23
77
  export class ModelRequestMediaAggregateError extends Error {
24
78
  readonly code = "MODEL_REQUEST_MEDIA_AGGREGATE_LIMIT";
25
79
 
@@ -65,17 +119,14 @@ export type PreparedPromptSegmentKind =
65
119
  export type PreparedPromptSegment = {
66
120
  kind: PreparedPromptSegmentKind;
67
121
  normalizedText: string;
68
- media?: readonly PreparedMediaDescriptor[];
122
+ media?: readonly PreparedMediaOccurrence[];
69
123
  };
70
124
 
71
- export type PreparedMediaDescriptor = {
72
- assetId: ImageAssetId;
73
- label: string;
74
- range: CodePointRange;
75
- mimeType: ImageMimeType;
76
- byteLength: number;
77
- sourceWidth: number;
78
- sourceHeight: number;
125
+ export type PreparedMediaOccurrence = {
126
+ readonly asset: ImageAssetRef;
127
+ readonly source: "user_attachment" | "tool_result";
128
+ readonly messageOrdinal: number;
129
+ readonly blockPosition: number;
79
130
  width: number;
80
131
  height: number;
81
132
  planningTokens: number;
@@ -21,6 +21,7 @@ import type {
21
21
  } from "openai/resources/chat/completions";
22
22
  import { validateUserMessage, type ImageAssetId } from "../image/image-types";
23
23
  import { imageAssetUrlMarker } from "./openai-image-mapping";
24
+ import { toolResultText } from "../agent/tool-result-content";
24
25
 
25
26
  type DeepSeekAssistantMessageParam = ChatCompletionAssistantMessageParam & {
26
27
  reasoning_content?: string | null;
@@ -57,7 +58,7 @@ export function toOpenAIChatMessages(
57
58
  return {
58
59
  role: "tool",
59
60
  tool_call_id: message.providerToolCallId,
60
- content: message.content,
61
+ content: toolResultText(message.content),
61
62
  };
62
63
  }
63
64
 
@@ -9,7 +9,7 @@ import {
9
9
  IMAGE_INPUT_POLICY_VERSION,
10
10
  } from "../image/image-input-policy";
11
11
  import type { ModelContextBudget } from "./model-context-profile";
12
- import { ProviderResponseError } from "./model-client";
12
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
13
13
  import type {
14
14
  MaterializedModelRequest,
15
15
  ModelClient,
@@ -29,6 +29,7 @@ import {
29
29
  import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
30
30
  import {
31
31
  deepFreeze,
32
+ imageToolSegment,
32
33
  imageUserSegment,
33
34
  materializeOpenAIRequest,
34
35
  normalizedEndpointPolicy,
@@ -53,6 +54,7 @@ export class OpenAIChatModelClient implements ModelClient {
53
54
  private readonly provider: string;
54
55
  private readonly stream: boolean;
55
56
  readonly inputModalities: readonly ("text" | "image")[];
57
+ readonly toolResultModalities: readonly ("text" | "image")[];
56
58
 
57
59
  constructor(
58
60
  private readonly options: {
@@ -61,6 +63,8 @@ export class OpenAIChatModelClient implements ModelClient {
61
63
  baseURL?: string;
62
64
  includeReasoningContent?: boolean;
63
65
  inputModalities?: readonly ("text" | "image")[];
66
+ toolResultModalities?: readonly ("text" | "image")[];
67
+ profileName?: string;
64
68
  model: string;
65
69
  providerName?: string;
66
70
  reasoningEffort?: ReasoningEffortController;
@@ -72,10 +76,15 @@ export class OpenAIChatModelClient implements ModelClient {
72
76
  this.provider = options.providerName ?? "openai-compatible";
73
77
  this.stream = options.stream ?? true;
74
78
  this.reasoningEffort = options.reasoningEffort;
75
- this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
76
- if (!this.inputModalities.includes("text")) {
77
- throw new Error('OpenAI chat input modalities must include "text".');
78
- }
79
+ const modalities = validateModelModalities({
80
+ profileName: options.profileName,
81
+ adapter: this.messageProtocol.adapter,
82
+ inputModalities: options.inputModalities ?? ["text"],
83
+ toolResultModalities: options.toolResultModalities ?? ["text"],
84
+ adapterToolResultModalities: ["text"],
85
+ });
86
+ this.inputModalities = modalities.inputModalities;
87
+ this.toolResultModalities = modalities.toolResultModalities;
79
88
  this.client = new OpenAI({
80
89
  apiKey: options.apiKey,
81
90
  baseURL: options.baseURL,
@@ -115,11 +124,13 @@ export class OpenAIChatModelClient implements ModelClient {
115
124
  const messageSegments = input.messages.map(
116
125
  (message, index): PreparedPromptSegment =>
117
126
  message.role === "user" && message.attachments !== undefined
118
- ? imageUserSegment(message)
119
- : {
120
- kind: segmentKind(message.role),
121
- normalizedText: stableJsonStringify(messages[index]),
122
- },
127
+ ? imageUserSegment(message, index + 1)
128
+ : message.role === "tool"
129
+ ? imageToolSegment(message, index + 1, stableJsonStringify(messages[index]))
130
+ : {
131
+ kind: segmentKind(message.role),
132
+ normalizedText: stableJsonStringify(messages[index]),
133
+ },
123
134
  );
124
135
  const mediaOccurrenceCount = messageSegments.reduce(
125
136
  (total, segment) => total + (segment.media?.length ?? 0),
@@ -135,6 +146,7 @@ export class OpenAIChatModelClient implements ModelClient {
135
146
  includeReasoningContent: this.options.includeReasoningContent === true,
136
147
  stream: this.stream,
137
148
  inputModalities: this.inputModalities,
149
+ toolResultModalities: this.toolResultModalities,
138
150
  requestPolicy: { toolChoice: "auto" },
139
151
  imagePolicy: {
140
152
  version: IMAGE_INPUT_POLICY_VERSION,