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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.1.0] - 2026-08-29
9
+
10
+ ### Added
11
+
12
+ - Add cross-session derived memory. Completed turns are distilled into memory
13
+ records that the agent can recall in later sessions and workspaces through
14
+ `MemorySearch`, which fuses vector similarity with FTS5 keyword matching via
15
+ RRF hybrid ranking, and `MemoryGet`, which reads a full record by its id.
16
+ - Add a `toolResultModalities` model-profile setting. Image-capable profiles can
17
+ now receive `ViewImage` results as real image content in the model request
18
+ instead of text-only descriptions.
19
+ - Add a `Wait` tool that pauses the agent loop for a cancellable whole number of
20
+ seconds (1 to 3600), useful for spacing polling attempts.
21
+ - Expand a leading `~` to the user's home directory in `TINKER_MODELS` and
22
+ `TINKER_WORKSPACE` paths.
23
+
24
+ ### Changed
25
+
26
+ - Show `MemorySearch` keywords alongside the query in TUI tool summaries, so
27
+ hybrid recall behavior is visible while observing the agent.
28
+
29
+ ### Fixed
30
+
31
+ - Accept `memory_get` raw results in stored tool history, so sessions that
32
+ contain MemoryGet calls remain resumable.
33
+
34
+ ## [2.0.0] - 2026-08-20
35
+
36
+ ### Changed
37
+
38
+ - Estimate image input locally with deterministic size buckets instead of a
39
+ provider-side token-estimation request. Images are normalized for orientation
40
+ and provider limits before estimation and upload, keeping preflight accounting
41
+ aligned with the payload sent to the model.
42
+
43
+ ### Removed
44
+
45
+ - Remove the `tokenEstimator` model-profile setting. Existing image profiles must
46
+ delete that field; sessions created under the previous image policy remain
47
+ inspectable but cannot be resumed for execution.
48
+
8
49
  ## [1.11.0] - 2026-08-15
9
50
 
10
51
  ### Added
@@ -208,7 +249,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
208
249
  - First formal npm release under the `tinker-agent` package name with the `tinker`
209
250
  executable.
210
251
 
211
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.11.0...HEAD
252
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.1.0...HEAD
253
+ [2.1.0]: https://github.com/ishowshao/tinker/releases/tag/v2.1.0
254
+ [2.0.0]: https://github.com/ishowshao/tinker/releases/tag/v2.0.0
212
255
  [1.11.0]: https://github.com/ishowshao/tinker/releases/tag/v1.11.0
213
256
  [1.10.1]: https://github.com/ishowshao/tinker/releases/tag/v1.10.1
214
257
  [1.9.0]: https://github.com/ishowshao/tinker/releases/tag/v1.9.0
package/README.md CHANGED
@@ -138,7 +138,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
138
138
  <!-- BEGIN GENERATED: PUBLIC ENVIRONMENT VARIABLES -->
139
139
  | Variable | Area | Applies | Required | Type | Default | Secret | Description |
140
140
  | --- | --- | --- | --- | --- | --- | --- | --- |
141
- | `TINKER_MODELS` | Model | All modes | No | Non-empty string | — | No | Optional model profiles JSON path. Relative paths resolve from the process cwd. |
141
+ | `TINKER_MODELS` | Model | All modes | No | Non-empty string | — | No | Optional model profiles JSON path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd. |
142
142
  | `TINKER_MODEL` | Model | Env mode | Env mode | Non-empty string | — | No | Model name used when model profiles are not configured. |
143
143
  | `TINKER_API` | Model | Env mode | No | Non-empty string | `"chat-completions"` | No | Model API adapter: "chat-completions" or "responses". |
144
144
  | `TINKER_BASE_URL` | Model | Env mode | Env mode | Non-empty string | — | No | OpenAI-compatible API root URL; do not append /chat/completions or /responses. |
@@ -148,7 +148,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
148
148
  | `TINKER_INCLUDE_REASONING_CONTENT` | Model | Env mode | No | Boolean | `false` | No | Replay provider reasoning_content in Chat Completions history; ignored by Responses. |
149
149
  | `TINKER_STREAM` | Model | Env mode | No | Boolean | `true` | No | Use streaming transport for the selected model API. |
150
150
  | `TINKER_WEBFETCH_REFINE_MODEL` | Model | Env mode | No | Non-empty string | — | No | Optional WebFetch refiner model; currently must match TINKER_MODEL. |
151
- | `TINKER_WORKSPACE` | Workspace | All modes | No | Non-empty string | Process cwd | No | Workspace path. Relative paths resolve from the process cwd. |
151
+ | `TINKER_WORKSPACE` | Workspace | All modes | No | Non-empty string | Process cwd | No | Workspace path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd. |
152
152
  | `TINKER_MAX_ITERATIONS` | Workspace | All modes | No | Positive integer | `512` | No | Maximum agent-loop iterations per turn. |
153
153
  | `EXA_API_KEY` | Tooling | All modes | No | Non-empty string | — | Yes | Enables WebSearch and the Exa WebFetch backend when set. |
154
154
  | `TINKER_MCP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `60000` | No | MCP tool-call timeout in milliseconds. |
@@ -194,7 +194,7 @@ Profile fields:
194
194
  | `includeReasoningContent` | No | JSON boolean | `false` | No | Replay provider reasoning_content in Chat Completions history; ignored by Responses. |
195
195
  | `stream` | No | JSON boolean | `true` | No | Use streaming transport for the selected model API. |
196
196
  | `inputModalities` | No | Normalized modality array | `["text"]` | No | Accepted model input modalities; normalizes to ["text"] or ["text", "image"]. |
197
- | `tokenEstimator` | With image | Object | | Yes | Independent token estimator required for image profiles. |
197
+ | `toolResultModalities` | No | Normalized modality array | `["text"]` | No | Accepted tool-result modalities; normalizes to ["text"] or ["text", "image"]. |
198
198
 
199
199
  `reasoning` fields:
200
200
 
@@ -205,17 +205,6 @@ Profile fields:
205
205
 
206
206
  The optional `reasoning` object declares provider-specific effort values. Efforts must be unique non-whitespace strings, `reset` is reserved by the TUI command, and `defaultEffort` must appear in `supportedEfforts`. Omitting `reasoning` sends no effort parameter and disables `/reasoning` for that profile.
207
207
 
208
- `tokenEstimator` fields:
209
-
210
- | Field | Type / constraint | Secret | Description |
211
- | --- | --- | --- | --- |
212
- | `kind` | Literal `"moonshot-estimate-token-count-v1"` | No | Estimator protocol discriminator. |
213
- | `model` | Non-empty string | No | Estimator model name. |
214
- | `apiBase` | Non-empty string | No | Estimator API base URL. |
215
- | `apiKey` | Non-empty string | Yes | Estimator API credential. |
216
- | `timeoutMs` | Integer 1000–60000 | No | Estimator request timeout in milliseconds. |
217
- | `maxRetries` | Literal `0` | No | Estimator retry count; retries are disabled. |
218
-
219
208
  Text-only profile example:
220
209
 
221
210
  ```json
@@ -224,6 +213,7 @@ Text-only profile example:
224
213
  "profiles": {
225
214
  "text": {
226
215
  "model": "example-text-model",
216
+ "api": "chat-completions",
227
217
  "apiBase": "https://api.example.com/v1",
228
218
  "apiKey": "your-model-api-key",
229
219
  "contextWindowTokens": 128000,
@@ -240,6 +230,9 @@ Text-only profile example:
240
230
  "stream": true,
241
231
  "inputModalities": [
242
232
  "text"
233
+ ],
234
+ "toolResultModalities": [
235
+ "text"
243
236
  ]
244
237
  }
245
238
  }
@@ -254,6 +247,7 @@ Image-capable profile example:
254
247
  "profiles": {
255
248
  "image": {
256
249
  "model": "example-vision-model",
250
+ "api": "responses",
257
251
  "apiBase": "https://api.example.com/v1",
258
252
  "apiKey": "your-model-api-key",
259
253
  "contextWindowTokens": 128000,
@@ -272,14 +266,10 @@ Image-capable profile example:
272
266
  "text",
273
267
  "image"
274
268
  ],
275
- "tokenEstimator": {
276
- "kind": "moonshot-estimate-token-count-v1",
277
- "model": "example-token-estimator",
278
- "apiBase": "https://estimator.example.com/v1",
279
- "apiKey": "your-estimator-api-key",
280
- "timeoutMs": 30000,
281
- "maxRetries": 0
282
- }
269
+ "toolResultModalities": [
270
+ "text",
271
+ "image"
272
+ ]
283
273
  }
284
274
  }
285
275
  }
@@ -313,6 +303,7 @@ Atomic-memory profile example:
313
303
  "profiles": {
314
304
  "text": {
315
305
  "model": "example-text-model",
306
+ "api": "chat-completions",
316
307
  "apiBase": "https://api.example.com/v1",
317
308
  "apiKey": "your-model-api-key",
318
309
  "contextWindowTokens": 128000,
@@ -329,6 +320,9 @@ Atomic-memory profile example:
329
320
  "stream": true,
330
321
  "inputModalities": [
331
322
  "text"
323
+ ],
324
+ "toolResultModalities": [
325
+ "text"
332
326
  ]
333
327
  }
334
328
  },
@@ -370,7 +364,7 @@ configured profile.
370
364
  ### Image Input
371
365
 
372
366
  Image attachment is enabled only for a profile whose `inputModalities` explicitly
373
- includes `image` and which supplies a valid `tokenEstimator`. In the interactive
367
+ includes `image`. In the interactive
374
368
  TUI, type `@` and select a file that is inside the workspace and visible to the
375
369
  workspace search rules. One-shot commands, clipboard image bytes, remote URLs, and
376
370
  files outside or ignored by the workspace search are not supported.
@@ -378,10 +372,25 @@ files outside or ignored by the workspace search are not supported.
378
372
  Tinker accepts PNG (not APNG), JPEG, and static WebP. It rejects GIF, animated
379
373
  WebP, and other formats. A message and provider request may contain at most eight
380
374
  images; each image may be at most 20 MiB, 4096 pixels on either edge, and 8,847,360
381
- pixels in total. See the
375
+ pixels in total. Provider requests preserve smaller images and proportionally
376
+ downscale larger images to a maximum 2048-pixel long edge. Context planning uses
377
+ fixed local token buckets derived from the materialized dimensions and performs no
378
+ independent token-estimator request. See the
379
+ [`image token bucket design`](docs/image-token-bucket-estimation-design.md) and
382
380
  [`multimodal image input design`](docs/multimodal-image-input-design.md) for the
383
381
  complete fixed policy and persistence contract.
384
382
 
383
+ `ViewImage(file_path)` is registered only when the selected profile declares both
384
+ `inputModalities: ["text", "image"]` and
385
+ `toolResultModalities: ["text", "image"]`. The first implementation supports
386
+ image tool results through the Responses adapter; Chat Completions remains
387
+ text-only for tool results. Relative paths stay inside the workspace, absolute
388
+ paths may explicitly select an external local file, and symbolic links are
389
+ rejected. Canonical history stores content-addressed image references rather than
390
+ Base64, while stdout, TUI, Recall, and logs show deterministic text summaries.
391
+ See the [`ViewImage tool design`](docs/view-image-tool-design.md) for the complete
392
+ capability, persistence, provider, and compaction contract.
393
+
385
394
  ### Built-in Slash Commands
386
395
 
387
396
  <!-- BEGIN GENERATED: BUILT-IN SLASH COMMANDS -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.11.0",
3
+ "version": "2.1.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "bench:long-session": "bun scripts/bench-long-session-memory.ts",
51
51
  "bench:i3-provider-smoke": "bun scripts/bench-i3-provider-smoke.ts",
52
52
  "bench:k3-image-live": "bun scripts/k3-image-live-smoke.ts",
53
+ "bench:view-image-live": "bun scripts/view-image-live-smoke.ts",
53
54
  "bench:i4-active-recall": "bun scripts/bench-i4-active-recall.ts",
54
55
  "qualify:i4-active-recall": "bun scripts/qualify-i4-active-recall.ts",
55
56
  "release:verify": "bun scripts/verify-release-package.ts",
@@ -2,13 +2,8 @@ import type { ModelContextBudget } from "../model/model-context-profile";
2
2
  import type {
3
3
  ModelRequestOutput,
4
4
  ModelUsage,
5
- MaterializedModelRequest,
6
5
  PreparedModelRequest,
7
6
  } from "../model/model-client";
8
- import type {
9
- InputTokenEstimate,
10
- InputTokenEstimator,
11
- } from "../model/input-token-estimator";
12
7
  import {
13
8
  assertContextBudget,
14
9
  contextPressure,
@@ -18,6 +13,7 @@ import {
18
13
  import { lastPromptPrefixHash, promptPrefixHashes } from "../model/prompt-prefix-hash";
19
14
  import {
20
15
  estimatePromptSegments,
16
+ guardedContextTokens,
21
17
  RollingTokenCalibration,
22
18
  type RawContextBreakdown,
23
19
  } from "../model/token-estimator";
@@ -68,7 +64,6 @@ export class ContextMeter {
68
64
  private anchor?: MeasuredContextAnchor;
69
65
  private lastProviderUsage?: ModelUsage;
70
66
  private calibrationIdentity?: string;
71
- private readonly providerEstimateCache = new Map<string, InputTokenEstimate>();
72
67
 
73
68
  constructor(
74
69
  private readonly budget: ModelContextBudget,
@@ -129,13 +124,14 @@ export class ContextMeter {
129
124
  let guardedDeltaTokens: number | undefined;
130
125
  if (anchor === undefined) {
131
126
  source = "estimated_full";
132
- usedInputTokens = Math.ceil(rawFullEstimate.totalTokens * correctionFactor);
127
+ usedInputTokens = guardedEstimate(rawFullEstimate, correctionFactor);
133
128
  } else {
134
129
  source = "measured_plus_estimated_delta";
135
- rawDeltaTokens = estimatePromptSegments(
130
+ const rawDelta = estimatePromptSegments(
136
131
  prepared.promptSegments.slice(anchor.segmentCount),
137
- ).totalTokens;
138
- guardedDeltaTokens = Math.ceil(rawDeltaTokens * correctionFactor);
132
+ );
133
+ rawDeltaTokens = rawDelta.totalTokens;
134
+ guardedDeltaTokens = guardedEstimate(rawDelta, correctionFactor);
139
135
  usedInputTokens = anchor.totalTokens + guardedDeltaTokens;
140
136
  }
141
137
 
@@ -217,62 +213,6 @@ export class ContextMeter {
217
213
  };
218
214
  }
219
215
 
220
- applyProviderEstimate(
221
- prepared: PreparedModelRequest,
222
- estimate: {
223
- inputTokens: number;
224
- coverage: "messages" | "full_request";
225
- },
226
- ): ContextUsageSnapshot {
227
- if (!Number.isSafeInteger(estimate.inputTokens) || estimate.inputTokens < 0) {
228
- throw new Error("Provider input estimate must be a non-negative safe integer.");
229
- }
230
- const local = this.measure(prepared);
231
- const measurement = this.measurements.get(prepared)!;
232
- const guardedTools =
233
- estimate.coverage === "messages"
234
- ? Math.ceil(
235
- measurement.rawFullEstimate.toolSchemaTokens * local.correctionFactor,
236
- )
237
- : 0;
238
- const providerGuarded = estimate.inputTokens + guardedTools;
239
- if (providerGuarded <= local.usedInputTokens) {
240
- return local;
241
- }
242
- const snapshot: ContextUsageSnapshot = {
243
- ...local,
244
- usedInputTokens: providerGuarded,
245
- source: "provider_estimated",
246
- pressure: contextPressure(providerGuarded, this.budget),
247
- };
248
- this.measurements.set(prepared, {
249
- rawFullEstimate: measurement.rawFullEstimate,
250
- snapshot,
251
- });
252
- return snapshot;
253
- }
254
-
255
- async estimateProviderInput(
256
- prepared: MaterializedModelRequest,
257
- estimator: InputTokenEstimator,
258
- options: { signal: AbortSignal },
259
- ): Promise<InputTokenEstimate> {
260
- options.signal.throwIfAborted();
261
- const key = providerEstimateCacheKey(
262
- prepared,
263
- estimator.compatibility.coverageVersion,
264
- );
265
- const cached = this.providerEstimateCache.get(key);
266
- if (cached !== undefined) {
267
- return cached;
268
- }
269
- const estimate = await estimator.estimate(prepared, options);
270
- options.signal.throwIfAborted();
271
- const frozen = Object.freeze({ ...estimate });
272
- this.providerEstimateCache.set(key, frozen);
273
- return frozen;
274
- }
275
-
276
216
  assertWithinBudget(snapshot: ContextUsageSnapshot): void {
277
217
  assertContextBudget({
278
218
  usedInputTokens: snapshot.usedInputTokens,
@@ -295,7 +235,6 @@ export class ContextMeter {
295
235
  this.lastProviderUsage = undefined;
296
236
  this.measurements = new WeakMap();
297
237
  this.calibration.clear();
298
- this.providerEstimateCache.clear();
299
238
  this.calibrationIdentity = nextIdentity;
300
239
  throw new Error(
301
240
  "Context revision changed the request configuration or tool schema.",
@@ -304,7 +243,6 @@ export class ContextMeter {
304
243
  this.anchor = undefined;
305
244
  this.lastProviderUsage = undefined;
306
245
  this.measurements = new WeakMap();
307
- this.providerEstimateCache.clear();
308
246
  this.calibrationIdentity = nextIdentity;
309
247
  }
310
248
 
@@ -315,7 +253,6 @@ export class ContextMeter {
315
253
  this.measurements = new WeakMap();
316
254
  this.calibration.clear();
317
255
  this.calibrationIdentity = undefined;
318
- this.providerEstimateCache.clear();
319
256
  }
320
257
 
321
258
  private usableAnchor(
@@ -330,10 +267,7 @@ export class ContextMeter {
330
267
  anchor.requestConfigHash !== prepared.requestConfigHash ||
331
268
  anchor.toolSchemaHash !== prepared.toolSchemaHash ||
332
269
  anchor.segmentCount > prepared.promptSegments.length ||
333
- prefixHashes[anchor.segmentCount] !== anchor.prefixHash ||
334
- prepared.promptSegments
335
- .slice(anchor.segmentCount)
336
- .some((segment) => (segment.media?.length ?? 0) > 0)
270
+ prefixHashes[anchor.segmentCount] !== anchor.prefixHash
337
271
  ) {
338
272
  this.anchor = undefined;
339
273
  return undefined;
@@ -359,18 +293,11 @@ export class ContextMeter {
359
293
  }
360
294
  }
361
295
 
362
- function providerEstimateCacheKey(
363
- prepared: PreparedModelRequest,
364
- coverageVersion: string,
365
- ): string {
366
- return [
367
- prepared.requestConfigHash,
368
- prepared.toolSchemaHash,
369
- lastPromptPrefixHash(
370
- promptPrefixHashes(prepared.requestConfigHash, prepared.promptSegments),
371
- ),
372
- coverageVersion,
373
- ].join(":");
296
+ function guardedEstimate(
297
+ breakdown: RawContextBreakdown,
298
+ correctionFactor: number,
299
+ ): number {
300
+ return guardedContextTokens(breakdown, correctionFactor);
374
301
  }
375
302
 
376
303
  function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
package/src/agent/loop.ts CHANGED
@@ -175,20 +175,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
175
175
  assetStore: input.assetStore,
176
176
  signal: input.signal,
177
177
  });
178
- if (preflight.source === "measured_plus_estimated_delta") {
179
- preflight = input.contextMeter.measure(request);
180
- } else {
181
- const estimator = input.model.inputTokenEstimator;
182
- if (estimator === undefined) {
183
- throw new Error("Image model request has no input token estimator.");
184
- }
185
- const estimate = await input.contextMeter.estimateProviderInput(
186
- request as MaterializedModelRequest,
187
- estimator,
188
- { signal: input.signal },
189
- );
190
- preflight = input.contextMeter.applyProviderEstimate(request, estimate);
191
- }
178
+ preflight = input.contextMeter.measure(request);
192
179
  input.contextMeter.assertWithinBudget(preflight);
193
180
  await input.runtimeSession.append({
194
181
  type: "context.usage.updated",
@@ -308,6 +308,7 @@ type CommonRuntimeSessionInput = {
308
308
  webFetchRefiner?: Refiner;
309
309
  toolingConfig?: PublicToolingConfig;
310
310
  memorySearch?: ToolExecutor;
311
+ memoryGet?: ToolExecutor;
311
312
  completedTurnHook?: CompletedTurnHook;
312
313
  enableTurnUndo?: boolean;
313
314
  bashGuard?: {
@@ -580,12 +581,8 @@ class DefaultRuntimeSession implements RuntimeSession {
580
581
  includeReasoningContent: input.includeReasoningContent,
581
582
  contextProfile: input.contextProfile,
582
583
  messageProtocol: input.modelClient.messageProtocol,
583
- inputModalities: input.modelClient.inputModalities ?? ["text"],
584
- ...(input.modelClient.inputTokenEstimator === undefined
585
- ? {}
586
- : {
587
- tokenEstimator: input.modelClient.inputTokenEstimator.compatibility,
588
- }),
584
+ inputModalities: input.modelClient.inputModalities,
585
+ toolResultModalities: input.modelClient.toolResultModalities,
589
586
  });
590
587
  if (input.selection.mode === "resume") {
591
588
  store.assertSessionCompatibility(compatibility);
@@ -659,6 +656,10 @@ class DefaultRuntimeSession implements RuntimeSession {
659
656
  workspaceRoot: input.workspaceRoot,
660
657
  runtimeSession: session.context,
661
658
  historyReader: store.historyReader(),
659
+ imageAssetStore: assetStore,
660
+ supportsViewImage:
661
+ input.modelClient.inputModalities.includes("image") &&
662
+ input.modelClient.toolResultModalities.includes("image"),
662
663
  ...(input.enableTurnUndo === true ? { enableTurnUndo: true } : {}),
663
664
  webFetchRefiner: input.webFetchRefiner,
664
665
  toolingConfig: input.toolingConfig,
@@ -670,6 +671,7 @@ class DefaultRuntimeSession implements RuntimeSession {
670
671
  ...(input.memorySearch === undefined
671
672
  ? {}
672
673
  : { memorySearch: input.memorySearch }),
674
+ ...(input.memoryGet === undefined ? {} : { memoryGet: input.memoryGet }),
673
675
  ...(session.skillCatalog.skills.size === 0
674
676
  ? {}
675
677
  : {
@@ -1402,7 +1404,7 @@ class DefaultRuntimeSession implements RuntimeSession {
1402
1404
  messageId: message.messageId,
1403
1405
  frameId: message.frameId,
1404
1406
  ordinal: message.ordinal,
1405
- content: message.content,
1407
+ content: message.displayText,
1406
1408
  contentSha256: message.contentSha256,
1407
1409
  },
1408
1410
  name: activation.name,
@@ -1641,24 +1643,6 @@ class DefaultRuntimeSession implements RuntimeSession {
1641
1643
  { assetStore: this.assetStore, signal: controller.signal },
1642
1644
  );
1643
1645
  admissionSnapshot = this.contextMeter.measure(admissionPrepared);
1644
- if (
1645
- prepared.mediaOccurrenceCount > 0 &&
1646
- admissionSnapshot.source !== "measured_plus_estimated_delta"
1647
- ) {
1648
- const estimator = this.input.modelClient.inputTokenEstimator;
1649
- if (estimator === undefined) {
1650
- throw new Error("Image model request has no input token estimator.");
1651
- }
1652
- const estimate = await this.contextMeter.estimateProviderInput(
1653
- admissionPrepared,
1654
- estimator,
1655
- { signal: controller.signal },
1656
- );
1657
- admissionSnapshot = this.contextMeter.applyProviderEstimate(
1658
- admissionPrepared,
1659
- estimate,
1660
- );
1661
- }
1662
1646
  this.contextMeter.assertWithinBudget(admissionSnapshot);
1663
1647
  controller.signal.throwIfAborted();
1664
1648
  const turn = this.stageTurn(input.userMessage);
@@ -26,12 +26,15 @@ import {
26
26
  } from "../context/context-protocol-validator";
27
27
  import {
28
28
  CURRENT_TOOL_OBSERVATION_FORMAT,
29
+ canonicalToolResultContentHash,
29
30
  contentHash,
31
+ displayTextForCompletion,
30
32
  immutableCanonicalClone,
31
33
  immutableRecord,
32
34
  observationForCompletion,
33
35
  rawResultHash,
34
36
  userMessageHash,
37
+ validateReturnedToolObservation,
35
38
  type CanonicalMessageRecord,
36
39
  type ProtocolContextView,
37
40
  type ProtocolFrame,
@@ -583,6 +586,7 @@ export class InMemorySessionLedger implements SessionLedger {
583
586
  assertSameToolCall(expectedCall, completionInput.call);
584
587
  validateCompletionInput(completionInput);
585
588
  const content = observationForCompletion(completionInput);
589
+ const displayText = displayTextForCompletion(completionInput);
586
590
  const createdAt = this.clock();
587
591
  const messageId = this.input.idFactory.createMessageId();
588
592
  const message = immutableRecord<CanonicalMessageRecord>({
@@ -590,7 +594,7 @@ export class InMemorySessionLedger implements SessionLedger {
590
594
  sessionId: this.input.sessionId,
591
595
  frameId: frameBefore.frameId,
592
596
  ordinal: this.view.messages.length + messages.length + 1,
593
- contentSha256: contentHash(content),
597
+ contentSha256: canonicalToolResultContentHash(content),
594
598
  createdAt,
595
599
  role: "tool",
596
600
  turnId: pending.turn.turnId,
@@ -599,6 +603,7 @@ export class InMemorySessionLedger implements SessionLedger {
599
603
  providerToolCallId: expectedCall.providerToolCallId,
600
604
  name: expectedCall.name,
601
605
  content,
606
+ displayText,
602
607
  origin: completionInput.kind === "returned" ? "tool" : "runtime",
603
608
  });
604
609
  const completion = canonicalCompletion(completionInput);
@@ -608,7 +613,7 @@ export class InMemorySessionLedger implements SessionLedger {
608
613
  toolCallId: expectedCall.toolCallId,
609
614
  toolMessageId: messageId,
610
615
  completion,
611
- observationSha256: contentHash(content),
616
+ observationSha256: canonicalToolResultContentHash(content),
612
617
  createdAt,
613
618
  });
614
619
  messages.push(message);
@@ -1006,9 +1011,11 @@ function canonicalCompletion(input: ToolCompletionInput): ToolCompletion {
1006
1011
 
1007
1012
  function validateCompletionInput(input: ToolCompletionInput): void {
1008
1013
  if (input.kind === "returned") {
1009
- if (typeof input.observation !== "string") {
1010
- throw new Error("Returned tool completion observation must be a string.");
1011
- }
1014
+ validateReturnedToolObservation({
1015
+ toolName: input.call.name,
1016
+ raw: input.raw,
1017
+ content: input.observation,
1018
+ });
1012
1019
  immutableCanonicalClone(input.raw);
1013
1020
  return;
1014
1021
  }
@@ -0,0 +1,76 @@
1
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
2
+ import { validateImageAssetRef } from "../image/image-types";
3
+ import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
4
+ import type { ToolResultContent } from "./types";
5
+
6
+ export function canonicalToolResultContentHash(
7
+ content: readonly ToolResultContent[],
8
+ ): string {
9
+ validateToolResultContent(content);
10
+ return sha256(stableJsonStringify(content));
11
+ }
12
+
13
+ export function validateToolResultContent(content: readonly ToolResultContent[]): void {
14
+ if (content.length === 0) {
15
+ throw new Error("Tool result content must be a non-empty array.");
16
+ }
17
+ let previousWasText = false;
18
+ let imageCount = 0;
19
+ for (const block of content) {
20
+ switch (block.type) {
21
+ case "text":
22
+ if (block.text.trim() === "") {
23
+ throw new Error("Tool result text blocks must not be empty.");
24
+ }
25
+ if (previousWasText) {
26
+ throw new Error("Consecutive tool result text blocks must be merged.");
27
+ }
28
+ previousWasText = true;
29
+ break;
30
+ case "image":
31
+ validateImageAssetRef(block.asset);
32
+ imageCount += 1;
33
+ previousWasText = false;
34
+ break;
35
+ }
36
+ }
37
+ if (imageCount > IMAGE_INPUT_POLICY.maxImagesPerMessage) {
38
+ throw new Error("Tool result content exceeds the per-message image limit.");
39
+ }
40
+ }
41
+
42
+ export function textToolResultContent(text: string): readonly ToolResultContent[] {
43
+ const content = Object.freeze([Object.freeze({ type: "text" as const, text })]);
44
+ validateToolResultContent(content);
45
+ return content;
46
+ }
47
+
48
+ export function toolResultDisplayText(content: readonly ToolResultContent[]): string {
49
+ validateToolResultContent(content);
50
+ return content
51
+ .map((block) =>
52
+ block.type === "text"
53
+ ? block.text
54
+ : `[Image: ${block.asset.mimeType}, ${block.asset.width}x${block.asset.height}, ${block.asset.byteLength} bytes, asset=${shortAssetId(block.asset.assetId)}]`,
55
+ )
56
+ .join("\n");
57
+ }
58
+
59
+ export function toolResultText(content: readonly ToolResultContent[]): string {
60
+ validateToolResultContent(content);
61
+ if (content.some((block) => block.type === "image")) {
62
+ throw new Error("Text-only tool result mapping received an image block.");
63
+ }
64
+ return content
65
+ .map((block) => {
66
+ if (block.type !== "text") {
67
+ throw new Error("Text-only tool result mapping received an image block.");
68
+ }
69
+ return block.text;
70
+ })
71
+ .join("\n");
72
+ }
73
+
74
+ function shortAssetId(assetId: string): string {
75
+ return `${assetId.slice(0, 12)}…`;
76
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IterationId, SessionId, ToolCallId, TurnId } from "../ids/runtime-id";
2
- import type { UserMessage } from "../image/image-types";
2
+ import type { ImageAssetRef, UserMessage } from "../image/image-types";
3
3
 
4
4
  export type {
5
5
  CodePointRange,
@@ -45,12 +45,24 @@ export type AssistantMessage = {
45
45
  toolCalls?: readonly ToolCall[];
46
46
  };
47
47
 
48
+ export type ToolResultTextContent = {
49
+ readonly type: "text";
50
+ readonly text: string;
51
+ };
52
+
53
+ export type ToolResultImageContent = {
54
+ readonly type: "image";
55
+ readonly asset: ImageAssetRef;
56
+ };
57
+
58
+ export type ToolResultContent = ToolResultTextContent | ToolResultImageContent;
59
+
48
60
  export type ToolMessage = {
49
61
  role: "tool";
50
62
  toolCallId: ToolCallId;
51
63
  providerToolCallId: string;
52
64
  name: string;
53
- content: string;
65
+ content: readonly ToolResultContent[];
54
66
  };
55
67
 
56
68
  export type AgentMessage =