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
@@ -2,14 +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";
6
- import type { InputTokenEstimator } from "./input-token-estimator";
5
+ import type { ImageAssetRef } from "../image/image-types";
7
6
  import type { ReasoningEffortController } from "./reasoning-effort";
8
7
 
9
8
  export interface ModelClient {
10
9
  readonly messageProtocol: ModelMessageProtocol;
11
- readonly inputTokenEstimator?: InputTokenEstimator;
12
- readonly inputModalities?: readonly ("text" | "image")[];
10
+ readonly inputModalities: readonly ModelInputModality[];
11
+ readonly toolResultModalities: readonly ToolResultModality[];
13
12
  readonly reasoningEffort?: ReasoningEffortController;
14
13
  prepare(input: ModelRequestInput): PreparedModelRequest;
15
14
  materialize?(
@@ -22,6 +21,59 @@ export interface ModelClient {
22
21
  ): Promise<ModelRequestOutput>;
23
22
  }
24
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
+
25
77
  export class ModelRequestMediaAggregateError extends Error {
26
78
  readonly code = "MODEL_REQUEST_MEDIA_AGGREGATE_LIMIT";
27
79
 
@@ -67,15 +119,14 @@ export type PreparedPromptSegmentKind =
67
119
  export type PreparedPromptSegment = {
68
120
  kind: PreparedPromptSegmentKind;
69
121
  normalizedText: string;
70
- media?: readonly PreparedMediaDescriptor[];
122
+ media?: readonly PreparedMediaOccurrence[];
71
123
  };
72
124
 
73
- export type PreparedMediaDescriptor = {
74
- assetId: ImageAssetId;
75
- label: string;
76
- range: CodePointRange;
77
- mimeType: ImageMimeType;
78
- byteLength: 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;
@@ -3,7 +3,6 @@ import type { ModelContextBudget } from "./model-context-profile";
3
3
 
4
4
  export type ContextUsageSource =
5
5
  | "estimated_full"
6
- | "provider_estimated"
7
6
  | "provider_measured"
8
7
  | "measured_plus_estimated_delta";
9
8
 
@@ -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,8 +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 type { InputTokenEstimator } from "./input-token-estimator";
13
- import { ProviderResponseError } from "./model-client";
12
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
14
13
  import type {
15
14
  MaterializedModelRequest,
16
15
  ModelClient,
@@ -30,13 +29,13 @@ import {
30
29
  import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
31
30
  import {
32
31
  deepFreeze,
32
+ imageToolSegment,
33
33
  imageUserSegment,
34
34
  materializeOpenAIRequest,
35
35
  normalizedEndpointPolicy,
36
36
  sanitizedProviderError,
37
37
  segmentKind,
38
38
  } from "./openai-model-utils";
39
- import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
40
39
  import type { ReasoningEffortController } from "./reasoning-effort";
41
40
  import { sha256, stableJsonStringify } from "./model-request-preflight";
42
41
 
@@ -48,7 +47,6 @@ export class OpenAIChatModelClient implements ModelClient {
48
47
  adapter: "openai-chat",
49
48
  serializationVersion: OPENAI_CHAT_SERIALIZATION_VERSION,
50
49
  });
51
- readonly inputTokenEstimator?: InputTokenEstimator;
52
50
  readonly reasoningEffort?: ReasoningEffortController;
53
51
  private readonly client: OpenAI;
54
52
  private readonly preparedRequests = new WeakSet<object>();
@@ -56,6 +54,7 @@ export class OpenAIChatModelClient implements ModelClient {
56
54
  private readonly provider: string;
57
55
  private readonly stream: boolean;
58
56
  readonly inputModalities: readonly ("text" | "image")[];
57
+ readonly toolResultModalities: readonly ("text" | "image")[];
59
58
 
60
59
  constructor(
61
60
  private readonly options: {
@@ -64,14 +63,8 @@ export class OpenAIChatModelClient implements ModelClient {
64
63
  baseURL?: string;
65
64
  includeReasoningContent?: boolean;
66
65
  inputModalities?: readonly ("text" | "image")[];
67
- tokenEstimator?: {
68
- kind: "moonshot-estimate-token-count-v1";
69
- model: string;
70
- apiBase: string;
71
- apiKey: string;
72
- timeoutMs: number;
73
- maxRetries: 0;
74
- };
66
+ toolResultModalities?: readonly ("text" | "image")[];
67
+ profileName?: string;
75
68
  model: string;
76
69
  providerName?: string;
77
70
  reasoningEffort?: ReasoningEffortController;
@@ -83,14 +76,15 @@ export class OpenAIChatModelClient implements ModelClient {
83
76
  this.provider = options.providerName ?? "openai-compatible";
84
77
  this.stream = options.stream ?? true;
85
78
  this.reasoningEffort = options.reasoningEffort;
86
- this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
87
- const supportsImages = this.inputModalities.includes("image");
88
- if (!this.inputModalities.includes("text")) {
89
- throw new Error('OpenAI chat input modalities must include "text".');
90
- }
91
- if (supportsImages && options.tokenEstimator === undefined) {
92
- throw new Error("Image-capable OpenAI chat requires a token estimator.");
93
- }
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;
94
88
  this.client = new OpenAI({
95
89
  apiKey: options.apiKey,
96
90
  baseURL: options.baseURL,
@@ -100,15 +94,6 @@ export class OpenAIChatModelClient implements ModelClient {
100
94
  maxRetries: 0,
101
95
  fetch: options.fetch,
102
96
  });
103
- if (options.tokenEstimator !== undefined) {
104
- this.inputTokenEstimator = new MoonshotInputTokenEstimator({
105
- apiKey: options.tokenEstimator.apiKey,
106
- baseURL: options.tokenEstimator.apiBase,
107
- model: options.tokenEstimator.model,
108
- timeoutMs: options.tokenEstimator.timeoutMs,
109
- fetch: options.fetch,
110
- });
111
- }
112
97
  }
113
98
 
114
99
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -139,11 +124,13 @@ export class OpenAIChatModelClient implements ModelClient {
139
124
  const messageSegments = input.messages.map(
140
125
  (message, index): PreparedPromptSegment =>
141
126
  message.role === "user" && message.attachments !== undefined
142
- ? imageUserSegment(message)
143
- : {
144
- kind: segmentKind(message.role),
145
- normalizedText: stableJsonStringify(messages[index]),
146
- },
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
+ },
147
134
  );
148
135
  const mediaOccurrenceCount = messageSegments.reduce(
149
136
  (total, segment) => total + (segment.media?.length ?? 0),
@@ -159,14 +146,12 @@ export class OpenAIChatModelClient implements ModelClient {
159
146
  includeReasoningContent: this.options.includeReasoningContent === true,
160
147
  stream: this.stream,
161
148
  inputModalities: this.inputModalities,
149
+ toolResultModalities: this.toolResultModalities,
162
150
  requestPolicy: { toolChoice: "auto" },
163
151
  imagePolicy: {
164
152
  version: IMAGE_INPUT_POLICY_VERSION,
165
153
  ...IMAGE_INPUT_POLICY,
166
154
  },
167
- ...(this.inputTokenEstimator === undefined
168
- ? {}
169
- : { tokenEstimator: this.inputTokenEstimator.compatibility }),
170
155
  }),
171
156
  );
172
157
  const prepared: PreparedModelRequest = {
@@ -299,4 +284,7 @@ export class OpenAIChatModelClient implements ModelClient {
299
284
  }
300
285
  }
301
286
 
302
- export { exactJsonBodyBytes } from "./openai-model-utils";
287
+ export {
288
+ assertOpenAIRequestBodyLimit,
289
+ exactJsonBodyBytes,
290
+ } from "./openai-model-utils";
@@ -1,14 +1,19 @@
1
1
  import OpenAI from "openai";
2
- import type { UserMessage } from "../agent/types";
3
- import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
2
+ import type { AgentMessage, UserMessage } from "../agent/types";
3
+ import {
4
+ IMAGE_INPUT_POLICY,
5
+ imagePlanningTokens,
6
+ providerImageDimensions,
7
+ } from "../image/image-input-policy";
4
8
  import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
9
+ import { materializeProviderImage, type ProviderImage } from "../image/provider-image";
5
10
  import {
6
11
  ModelRequestMediaAggregateError,
7
12
  ProviderResponseError,
8
13
  type MaterializedModelRequest,
9
14
  type ModelMaterializeOptions,
10
15
  type ModelRequestInput,
11
- type PreparedMediaDescriptor,
16
+ type PreparedMediaOccurrence,
12
17
  type PreparedModelRequest,
13
18
  type PreparedPromptSegment,
14
19
  type ProviderResponseErrorCode,
@@ -28,26 +33,22 @@ export async function materializeOpenAIRequest(
28
33
  }
29
34
 
30
35
  const assets = distinctPreparedAssets(prepared.promptSegments);
31
- const lowerLengths = new Map<ImageAssetId, number>();
32
- for (const asset of assets.values()) {
33
- lowerLengths.set(asset.assetId, dataUrlLength(asset));
34
- }
35
36
  const markerCount = countImageMarkers(prepared.payload);
36
37
  if (markerCount !== prepared.mediaOccurrenceCount) {
37
38
  throw new Error("Prepared image marker count does not match media descriptors.");
38
39
  }
39
- const lowerBodyBytes = exactJsonBodyBytes(prepared.payload, lowerLengths);
40
- assertBodyLimit(lowerBodyBytes, prepared.mediaOccurrenceCount);
41
-
42
40
  const dataUrls = new Map<ImageAssetId, string>();
41
+ const providerImages = new Map<ImageAssetId, ProviderImage>();
43
42
  for (const asset of assets.values()) {
44
43
  options.signal.throwIfAborted();
45
44
  const bytes = await options.assetStore.readVerified(asset, {
46
45
  signal: options.signal,
47
46
  });
47
+ const image = await materializeProviderImage(bytes, asset.mimeType);
48
+ providerImages.set(asset.assetId, image);
48
49
  dataUrls.set(
49
50
  asset.assetId,
50
- `data:${asset.mimeType};base64,${bytes.toString("base64")}`,
51
+ `data:${image.mimeType};base64,${image.bytes.toString("base64")}`,
51
52
  );
52
53
  await yieldToEventLoop();
53
54
  }
@@ -56,9 +57,14 @@ export async function materializeOpenAIRequest(
56
57
  [...dataUrls].map(([assetId, value]) => [assetId, value.length] as const),
57
58
  );
58
59
  const bodyBytes = exactJsonBodyBytes(prepared.payload, exactLengths);
59
- assertBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
60
+ assertOpenAIRequestBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
60
61
  const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
61
- return Object.freeze({ ...prepared, payload, bodyBytes });
62
+ return Object.freeze({
63
+ ...prepared,
64
+ payload,
65
+ promptSegments: materializedPromptSegments(prepared.promptSegments, providerImages),
66
+ bodyBytes,
67
+ });
62
68
  }
63
69
 
64
70
  export function exactJsonBodyBytes(
@@ -118,19 +124,29 @@ export function exactJsonBodyBytes(
118
124
  );
119
125
  }
120
126
 
121
- export function imageUserSegment(message: UserMessage): PreparedPromptSegment {
127
+ export function imageUserSegment(
128
+ message: UserMessage,
129
+ messageOrdinal: number,
130
+ ): PreparedPromptSegment {
122
131
  const media = message.attachments!.map(
123
- (attachment): PreparedMediaDescriptor =>
124
- Object.freeze({
125
- assetId: attachment.assetId,
126
- label: attachment.label,
127
- range: Object.freeze({ ...attachment.range }),
128
- mimeType: attachment.mimeType,
129
- byteLength: attachment.byteLength,
130
- width: attachment.width,
131
- height: attachment.height,
132
- planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
133
- }),
132
+ (attachment, blockPosition): PreparedMediaOccurrence => {
133
+ const dimensions = providerImageDimensions(attachment.width, attachment.height);
134
+ return Object.freeze({
135
+ asset: Object.freeze({
136
+ assetId: attachment.assetId,
137
+ mimeType: attachment.mimeType,
138
+ byteLength: attachment.byteLength,
139
+ width: attachment.width,
140
+ height: attachment.height,
141
+ }),
142
+ source: "user_attachment",
143
+ messageOrdinal,
144
+ blockPosition,
145
+ width: dimensions.width,
146
+ height: dimensions.height,
147
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
148
+ });
149
+ },
134
150
  );
135
151
  return Object.freeze({
136
152
  kind: "user",
@@ -139,6 +155,35 @@ export function imageUserSegment(message: UserMessage): PreparedPromptSegment {
139
155
  });
140
156
  }
141
157
 
158
+ export function imageToolSegment(
159
+ message: Extract<AgentMessage, { role: "tool" }>,
160
+ messageOrdinal: number,
161
+ normalizedText: string,
162
+ ): PreparedPromptSegment {
163
+ const media = message.content.flatMap((block, blockPosition) => {
164
+ if (block.type !== "image") {
165
+ return [];
166
+ }
167
+ const dimensions = providerImageDimensions(block.asset.width, block.asset.height);
168
+ return [
169
+ Object.freeze<PreparedMediaOccurrence>({
170
+ asset: Object.freeze({ ...block.asset }),
171
+ source: "tool_result",
172
+ messageOrdinal,
173
+ blockPosition,
174
+ width: dimensions.width,
175
+ height: dimensions.height,
176
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
177
+ }),
178
+ ];
179
+ });
180
+ return Object.freeze({
181
+ kind: "tool",
182
+ normalizedText,
183
+ ...(media.length === 0 ? {} : { media: Object.freeze(media) }),
184
+ });
185
+ }
186
+
142
187
  export function normalizedEndpointPolicy(baseURL: string | undefined): string {
143
188
  const url = new URL(baseURL ?? "https://api.openai.com/v1");
144
189
  url.username = "";
@@ -200,21 +245,15 @@ function distinctPreparedAssets(
200
245
  const assets = new Map<ImageAssetId, ImageAssetRef>();
201
246
  for (const segment of segments) {
202
247
  for (const media of segment.media ?? []) {
203
- const asset = Object.freeze({
204
- assetId: media.assetId,
205
- mimeType: media.mimeType,
206
- byteLength: media.byteLength,
207
- width: media.width,
208
- height: media.height,
209
- });
210
- const existing = assets.get(media.assetId);
248
+ const asset = media.asset;
249
+ const existing = assets.get(asset.assetId);
211
250
  if (
212
251
  existing !== undefined &&
213
252
  stableJsonStringify(existing) !== stableJsonStringify(asset)
214
253
  ) {
215
- throw new Error(`Conflicting descriptors for image ${media.assetId}.`);
254
+ throw new Error(`Conflicting descriptors for image ${asset.assetId}.`);
216
255
  }
217
- assets.set(media.assetId, asset);
256
+ assets.set(asset.assetId, asset);
218
257
  }
219
258
  }
220
259
  return assets;
@@ -265,11 +304,10 @@ function countImageMarkers(value: unknown): number {
265
304
  return 0;
266
305
  }
267
306
 
268
- function dataUrlLength(asset: ImageAssetRef): number {
269
- return `data:${asset.mimeType};base64,`.length + 4 * Math.ceil(asset.byteLength / 3);
270
- }
271
-
272
- function assertBodyLimit(bodyBytes: number, imageCount: number): void {
307
+ export function assertOpenAIRequestBodyLimit(
308
+ bodyBytes: number,
309
+ imageCount: number,
310
+ ): void {
273
311
  if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
274
312
  throw new ModelRequestMediaAggregateError(
275
313
  `Model request is ${bodyBytes} UTF-8 bytes with ${imageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
@@ -277,6 +315,37 @@ function assertBodyLimit(bodyBytes: number, imageCount: number): void {
277
315
  }
278
316
  }
279
317
 
318
+ function materializedPromptSegments(
319
+ segments: readonly PreparedPromptSegment[],
320
+ images: ReadonlyMap<ImageAssetId, ProviderImage>,
321
+ ): readonly PreparedPromptSegment[] {
322
+ return Object.freeze(
323
+ segments.map((segment) =>
324
+ segment.media === undefined
325
+ ? segment
326
+ : Object.freeze({
327
+ ...segment,
328
+ media: Object.freeze(
329
+ segment.media.map((media) => {
330
+ const image = images.get(media.asset.assetId);
331
+ if (image === undefined) {
332
+ throw new Error(
333
+ `Image ${media.asset.assetId.slice(0, 12)}… was not materialized.`,
334
+ );
335
+ }
336
+ return Object.freeze({
337
+ ...media,
338
+ width: image.width,
339
+ height: image.height,
340
+ planningTokens: image.planningTokens,
341
+ });
342
+ }),
343
+ ),
344
+ }),
345
+ ),
346
+ );
347
+ }
348
+
280
349
  function yieldToEventLoop(): Promise<void> {
281
350
  return new Promise((resolve) => setTimeout(resolve, 0));
282
351
  }
@@ -10,6 +10,10 @@ import type {
10
10
  ToolCall,
11
11
  UserMessage,
12
12
  } from "../agent/types";
13
+ import {
14
+ toolResultText,
15
+ validateToolResultContent,
16
+ } from "../agent/tool-result-content";
13
17
  import type { RuntimeSessionContext } from "../agent/runtime-session";
14
18
  import {
15
19
  parseImageAssetId,
@@ -61,11 +65,31 @@ export function toOpenAIResponsesItems(
61
65
  }
62
66
 
63
67
  if (message.role === "tool") {
68
+ validateToolResultContent(message.content);
69
+ const hasImage = message.content.some((block) => block.type === "image");
64
70
  return [
65
71
  {
66
72
  type: "function_call_output",
67
73
  call_id: message.providerToolCallId,
68
- output: message.content,
74
+ output: hasImage
75
+ ? message.content.map((block) =>
76
+ block.type === "text"
77
+ ? ({ type: "input_text", text: block.text } as const)
78
+ : ({
79
+ type: "input_image",
80
+ detail: "auto" as const,
81
+ image_url:
82
+ options.materializedImages === undefined
83
+ ? (imageAssetUrlMarker(
84
+ block.asset.assetId,
85
+ ) as unknown as string)
86
+ : requireMaterializedImage(
87
+ options.materializedImages,
88
+ block.asset.assetId,
89
+ ),
90
+ } as const),
91
+ )
92
+ : toolResultText(message.content),
69
93
  },
70
94
  ];
71
95
  }
@@ -8,9 +8,8 @@ import {
8
8
  IMAGE_INPUT_POLICY,
9
9
  IMAGE_INPUT_POLICY_VERSION,
10
10
  } from "../image/image-input-policy";
11
- import type { InputTokenEstimator } from "./input-token-estimator";
12
11
  import type { ModelContextBudget } from "./model-context-profile";
13
- import { ProviderResponseError } from "./model-client";
12
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
14
13
  import type {
15
14
  MaterializedModelRequest,
16
15
  ModelClient,
@@ -22,9 +21,9 @@ import type {
22
21
  PreparedModelRequest,
23
22
  PreparedPromptSegment,
24
23
  } from "./model-client";
25
- import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
26
24
  import {
27
25
  deepFreeze,
26
+ imageToolSegment,
28
27
  imageUserSegment,
29
28
  materializeOpenAIRequest,
30
29
  normalizedEndpointPolicy,
@@ -38,11 +37,10 @@ import {
38
37
  toOpenAIResponsesTools,
39
38
  } from "./openai-responses-mapping";
40
39
  import { OpenAIResponsesStreamAccumulator } from "./openai-responses-stream";
41
- import { responsesPayloadForChatTokenEstimator } from "./openai-responses-token-estimator";
42
40
  import type { ReasoningEffortController } from "./reasoning-effort";
43
41
  import { sha256, stableJsonStringify } from "./model-request-preflight";
44
42
 
45
- const OPENAI_RESPONSES_SERIALIZATION_VERSION = "openai-responses-v1";
43
+ const OPENAI_RESPONSES_SERIALIZATION_VERSION = "openai-responses-v2";
46
44
  const OPENAI_RESPONSES_TIMEOUT_MS = 30 * 60 * 1_000;
47
45
 
48
46
  export class OpenAIResponsesModelClient implements ModelClient {
@@ -50,8 +48,8 @@ export class OpenAIResponsesModelClient implements ModelClient {
50
48
  adapter: "openai-responses",
51
49
  serializationVersion: OPENAI_RESPONSES_SERIALIZATION_VERSION,
52
50
  });
53
- readonly inputTokenEstimator?: InputTokenEstimator;
54
51
  readonly inputModalities: readonly ("text" | "image")[];
52
+ readonly toolResultModalities: readonly ("text" | "image")[];
55
53
  readonly reasoningEffort?: ReasoningEffortController;
56
54
  private readonly client: OpenAI;
57
55
  private readonly preparedRequests = new WeakSet<object>();
@@ -65,14 +63,8 @@ export class OpenAIResponsesModelClient implements ModelClient {
65
63
  contextBudget: ModelContextBudget;
66
64
  baseURL?: string;
67
65
  inputModalities?: readonly ("text" | "image")[];
68
- tokenEstimator?: {
69
- kind: "moonshot-estimate-token-count-v1";
70
- model: string;
71
- apiBase: string;
72
- apiKey: string;
73
- timeoutMs: number;
74
- maxRetries: 0;
75
- };
66
+ toolResultModalities?: readonly ("text" | "image")[];
67
+ profileName?: string;
76
68
  model: string;
77
69
  providerName?: string;
78
70
  reasoningEffort?: ReasoningEffortController;
@@ -84,14 +76,15 @@ export class OpenAIResponsesModelClient implements ModelClient {
84
76
  this.provider = options.providerName ?? "responses-compatible";
85
77
  this.stream = options.stream ?? true;
86
78
  this.reasoningEffort = options.reasoningEffort;
87
- this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
88
- const supportsImages = this.inputModalities.includes("image");
89
- if (!this.inputModalities.includes("text")) {
90
- throw new Error('OpenAI Responses input modalities must include "text".');
91
- }
92
- if (supportsImages && options.tokenEstimator === undefined) {
93
- throw new Error("Image-capable OpenAI Responses requires a token estimator.");
94
- }
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", "image"],
85
+ });
86
+ this.inputModalities = modalities.inputModalities;
87
+ this.toolResultModalities = modalities.toolResultModalities;
95
88
  this.client = new OpenAI({
96
89
  apiKey: options.apiKey,
97
90
  baseURL: options.baseURL,
@@ -99,16 +92,6 @@ export class OpenAIResponsesModelClient implements ModelClient {
99
92
  maxRetries: 0,
100
93
  fetch: options.fetch,
101
94
  });
102
- if (options.tokenEstimator !== undefined) {
103
- this.inputTokenEstimator = new MoonshotInputTokenEstimator({
104
- apiKey: options.tokenEstimator.apiKey,
105
- baseURL: options.tokenEstimator.apiBase,
106
- model: options.tokenEstimator.model,
107
- timeoutMs: options.tokenEstimator.timeoutMs,
108
- fetch: options.fetch,
109
- payloadMapper: responsesPayloadForChatTokenEstimator,
110
- });
111
- }
112
95
  }
113
96
 
114
97
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -139,11 +122,17 @@ export class OpenAIResponsesModelClient implements ModelClient {
139
122
  const messageSegments = input.messages.map(
140
123
  (message, index): PreparedPromptSegment =>
141
124
  message.role === "user" && message.attachments !== undefined
142
- ? imageUserSegment(message)
143
- : {
144
- kind: segmentKind(message.role),
145
- normalizedText: stableJsonStringify(itemsByMessage[index]),
146
- },
125
+ ? imageUserSegment(message, index + 1)
126
+ : message.role === "tool"
127
+ ? imageToolSegment(
128
+ message,
129
+ index + 1,
130
+ stableJsonStringify(itemsByMessage[index]),
131
+ )
132
+ : {
133
+ kind: segmentKind(message.role),
134
+ normalizedText: stableJsonStringify(itemsByMessage[index]),
135
+ },
147
136
  );
148
137
  const mediaOccurrenceCount = messageSegments.reduce(
149
138
  (total, segment) => total + (segment.media?.length ?? 0),
@@ -158,14 +147,12 @@ export class OpenAIResponsesModelClient implements ModelClient {
158
147
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
159
148
  stream: this.stream,
160
149
  inputModalities: this.inputModalities,
150
+ toolResultModalities: this.toolResultModalities,
161
151
  requestPolicy: { store: false, toolChoice: "auto" },
162
152
  imagePolicy: {
163
153
  version: IMAGE_INPUT_POLICY_VERSION,
164
154
  ...IMAGE_INPUT_POLICY,
165
155
  },
166
- ...(this.inputTokenEstimator === undefined
167
- ? {}
168
- : { tokenEstimator: this.inputTokenEstimator.compatibility }),
169
156
  }),
170
157
  );
171
158
  const prepared: PreparedModelRequest = {