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/src/cli/config.ts CHANGED
@@ -12,9 +12,9 @@ import {
12
12
  persistDefaultProfile,
13
13
  profileToContextProfile,
14
14
  type ModelInputModality,
15
+ type ToolResultModality,
15
16
  type ModelProfile,
16
17
  type ModelProfiles,
17
- type ModelTokenEstimatorProfile,
18
18
  unknownProfileError,
19
19
  } from "./model-profiles";
20
20
  import type { MemoryEmbeddingConfig } from "../memory/contracts";
@@ -40,7 +40,7 @@ export type RunnerConfig = {
40
40
  readonly contextBudget: ModelContextBudget;
41
41
  readonly profileName?: string;
42
42
  readonly inputModalities: readonly ModelInputModality[];
43
- readonly tokenEstimator?: ModelTokenEstimatorProfile;
43
+ readonly toolResultModalities: readonly ToolResultModality[];
44
44
  readonly bashGuardMode: "guard" | "yolo";
45
45
  readonly bashGuardSource: "default" | "environment" | "cli";
46
46
  };
@@ -182,11 +182,9 @@ function runnerConfigTemplateFromProfile(
182
182
  contextBudget: deriveModelContextBudget(contextProfile),
183
183
  profileName: profile.name,
184
184
  inputModalities: profile.inputModalities,
185
+ toolResultModalities: profile.toolResultModalities,
185
186
  bashGuardMode: environment.bashGuardMode,
186
187
  bashGuardSource: environment.bashGuardSource,
187
- ...(profile.tokenEstimator === undefined
188
- ? {}
189
- : { tokenEstimator: profile.tokenEstimator }),
190
188
  });
191
189
  }
192
190
 
@@ -210,6 +208,7 @@ function runnerConfigTemplateFromEnvironment(
210
208
  contextProfile,
211
209
  contextBudget: deriveModelContextBudget(contextProfile),
212
210
  inputModalities: Object.freeze(["text"] as const),
211
+ toolResultModalities: Object.freeze(["text"] as const),
213
212
  bashGuardMode: environment.bashGuardMode,
214
213
  bashGuardSource: environment.bashGuardSource,
215
214
  });
@@ -10,12 +10,10 @@ import {
10
10
  MODEL_PROFILE_FIELDS,
11
11
  MODEL_PROFILES_DOCUMENT_FIELDS,
12
12
  MODEL_REASONING_FIELDS,
13
- MODEL_TOKEN_ESTIMATOR_FIELDS,
14
- type ModelTokenEstimatorKind,
15
- type ModelTokenEstimatorMaxRetries,
16
13
  } from "./public-config-contract";
17
14
  import type { MemoryEmbeddingConfig } from "../memory/contracts";
18
15
  import type { ReasoningEffortConfig } from "../model/reasoning-effort";
16
+ import type { ModelInputModality, ToolResultModality } from "../model/model-client";
19
17
 
20
18
  export type ModelProfile = {
21
19
  readonly name: string;
@@ -29,19 +27,9 @@ export type ModelProfile = {
29
27
  readonly includeReasoningContent: boolean;
30
28
  readonly stream: boolean;
31
29
  readonly inputModalities: readonly ModelInputModality[];
32
- readonly tokenEstimator?: ModelTokenEstimatorProfile;
33
- };
34
-
35
- export type ModelInputModality = "text" | "image";
36
-
37
- export type ModelTokenEstimatorProfile = {
38
- readonly kind: ModelTokenEstimatorKind;
39
- readonly model: string;
40
- readonly apiBase: string;
41
- readonly apiKey: string;
42
- readonly timeoutMs: number;
43
- readonly maxRetries: ModelTokenEstimatorMaxRetries;
30
+ readonly toolResultModalities: readonly ToolResultModality[];
44
31
  };
32
+ export type { ModelInputModality, ToolResultModality } from "../model/model-client";
45
33
 
46
34
  export type ModelProfiles = {
47
35
  readonly defaultProfile: string;
@@ -285,16 +273,15 @@ function parseProfile(
285
273
  value.inputModalities,
286
274
  `${where}: "inputModalities"`,
287
275
  );
288
- const tokenEstimator =
289
- value.tokenEstimator === undefined
290
- ? undefined
291
- : parseTokenEstimator(value.tokenEstimator, `${where}: "tokenEstimator"`);
292
- if (inputModalities.includes("image") && tokenEstimator === undefined) {
276
+ const toolResultModalities = parseToolResultModalities(
277
+ value.toolResultModalities,
278
+ `${where}: "toolResultModalities"`,
279
+ );
280
+ if (toolResultModalities.includes("image") && !inputModalities.includes("image")) {
293
281
  throw new Error(
294
- `${where}: image input requires a complete "tokenEstimator" configuration.`,
282
+ `${where}: "toolResultModalities" cannot include "image" unless "inputModalities" also includes "image".`,
295
283
  );
296
284
  }
297
-
298
285
  createModelContextProfile({
299
286
  contextWindowTokens,
300
287
  maxSupportedOutputTokens,
@@ -312,10 +299,38 @@ function parseProfile(
312
299
  includeReasoningContent,
313
300
  stream,
314
301
  inputModalities,
315
- ...(tokenEstimator === undefined ? {} : { tokenEstimator }),
302
+ toolResultModalities,
316
303
  });
317
304
  }
318
305
 
306
+ function parseToolResultModalities(
307
+ value: unknown,
308
+ name: string,
309
+ ): readonly ToolResultModality[] {
310
+ if (value === undefined) {
311
+ return modelProfileField("toolResultModalities").defaultValue;
312
+ }
313
+ if (!Array.isArray(value) || value.length === 0) {
314
+ throw new Error(`${name} must be a non-empty array.`);
315
+ }
316
+ const modalities = value.map((rawEntry): ToolResultModality => {
317
+ const entry: unknown = rawEntry;
318
+ if (entry !== "text" && entry !== "image") {
319
+ throw new Error(`${name} contains an unsupported modality.`);
320
+ }
321
+ return entry;
322
+ });
323
+ if (new Set(modalities).size !== modalities.length) {
324
+ throw new Error(`${name} must not contain duplicates.`);
325
+ }
326
+ if (!modalities.includes("text")) {
327
+ throw new Error(`${name} must include "text".`);
328
+ }
329
+ return Object.freeze(
330
+ modalities.includes("image") ? (["text", "image"] as const) : (["text"] as const),
331
+ );
332
+ }
333
+
319
334
  function parseReasoning(value: unknown, where: string): ReasoningEffortConfig {
320
335
  if (!isRecord(value)) {
321
336
  throw new Error(`${where} must be an object.`);
@@ -434,54 +449,7 @@ function parseInputModalities(
434
449
  );
435
450
  }
436
451
 
437
- function parseTokenEstimator(value: unknown, name: string): ModelTokenEstimatorProfile {
438
- if (!isRecord(value)) {
439
- throw new Error(`${name} must be an object.`);
440
- }
441
- assertKnownKeys(
442
- value,
443
- MODEL_TOKEN_ESTIMATOR_FIELDS.map((field) => field.name),
444
- name,
445
- );
446
- const kindField = tokenEstimatorField("kind");
447
- if (value.kind !== kindField.literalValue) {
448
- throw new Error(`${name}.kind must be ${JSON.stringify(kindField.literalValue)}.`);
449
- }
450
- const model = parseTokenEstimatorString(value, "model", name);
451
- const apiBase = parseTokenEstimatorString(value, "apiBase", name);
452
- const apiKey = parseTokenEstimatorString(value, "apiKey", name);
453
- const timeoutField = tokenEstimatorField("timeoutMs");
454
- if (timeoutField.valueKind !== "positive-integer") {
455
- throw new Error("Token estimator timeoutMs contract kind is invalid.");
456
- }
457
- const timeoutMs = requirePositiveInteger(value.timeoutMs, `${name}.timeoutMs`);
458
- if (
459
- timeoutField.minimum === undefined ||
460
- timeoutField.maximum === undefined ||
461
- timeoutMs < timeoutField.minimum ||
462
- timeoutMs > timeoutField.maximum
463
- ) {
464
- throw new Error(
465
- `${name}.timeoutMs must be between ${timeoutField.minimum} and ${timeoutField.maximum}.`,
466
- );
467
- }
468
- const maxRetriesField = tokenEstimatorField("maxRetries");
469
- if (value.maxRetries !== maxRetriesField.literalValue) {
470
- throw new Error(`${name}.maxRetries must be ${maxRetriesField.literalValue}.`);
471
- }
472
- return Object.freeze({
473
- kind: kindField.literalValue,
474
- model,
475
- apiBase,
476
- apiKey,
477
- timeoutMs,
478
- maxRetries: maxRetriesField.literalValue,
479
- });
480
- }
481
-
482
452
  type ModelProfileFieldName = (typeof MODEL_PROFILE_FIELDS)[number]["name"];
483
- type ModelTokenEstimatorFieldName =
484
- (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number]["name"];
485
453
 
486
454
  function modelProfileField<Name extends ModelProfileFieldName>(
487
455
  name: Name,
@@ -496,21 +464,6 @@ function modelProfileField<Name extends ModelProfileFieldName>(
496
464
  >;
497
465
  }
498
466
 
499
- function tokenEstimatorField<Name extends ModelTokenEstimatorFieldName>(
500
- name: Name,
501
- ): Extract<(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number], { readonly name: Name }> {
502
- const field = MODEL_TOKEN_ESTIMATOR_FIELDS.find(
503
- (candidate) => candidate.name === name,
504
- );
505
- if (field === undefined) {
506
- throw new Error(`Missing token estimator field contract for ${name}.`);
507
- }
508
- return field as Extract<
509
- (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
510
- { readonly name: Name }
511
- >;
512
- }
513
-
514
467
  function parseProfileString(
515
468
  value: Record<string, unknown>,
516
469
  name: "model" | "apiBase" | "apiKey",
@@ -555,18 +508,6 @@ function parseProfileBoolean(
555
508
  : parseBoolean(value[name], `${where}: ${JSON.stringify(name)}`);
556
509
  }
557
510
 
558
- function parseTokenEstimatorString(
559
- value: Record<string, unknown>,
560
- name: "model" | "apiBase" | "apiKey",
561
- where: string,
562
- ): string {
563
- const field = tokenEstimatorField(name);
564
- if (field.valueKind !== "non-empty-string") {
565
- throw new Error(`Token estimator field ${name} has an invalid contract kind.`);
566
- }
567
- return requireString(value[name], `${where}.${name}`);
568
- }
569
-
570
511
  function assertKnownKeys(
571
512
  value: Record<string, unknown>,
572
513
  allowed: readonly string[],
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import { rgPath } from "@vscode/ripgrep";
3
4
  import { parseModelApi, type ModelApi } from "../model/model-api";
@@ -29,7 +30,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
29
30
  secret: false,
30
31
  section: "model",
31
32
  description:
32
- "Optional model profiles JSON path. Relative paths resolve from the process cwd.",
33
+ "Optional model profiles JSON path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd.",
33
34
  }),
34
35
  publicField({
35
36
  name: "TINKER_MODEL",
@@ -126,7 +127,8 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
126
127
  defaultSource: "process-cwd",
127
128
  secret: false,
128
129
  section: "workspace",
129
- description: "Workspace path. Relative paths resolve from the process cwd.",
130
+ description:
131
+ "Workspace path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd.",
130
132
  }),
131
133
  publicField({
132
134
  name: "TINKER_MAX_ITERATIONS",
@@ -245,8 +247,8 @@ export type ModelProfileField = {
245
247
  readonly valueKind:
246
248
  | PublicConfigValueKind
247
249
  | "input-modalities"
248
- | "reasoning"
249
- | "token-estimator";
250
+ | "tool-result-modalities"
251
+ | "reasoning";
250
252
  readonly required: boolean;
251
253
  readonly defaultValue?: string | number | boolean | readonly string[];
252
254
  readonly secret: boolean;
@@ -338,11 +340,13 @@ export const MODEL_PROFILE_FIELDS = Object.freeze([
338
340
  'Accepted model input modalities; normalizes to ["text"] or ["text", "image"].',
339
341
  }),
340
342
  profileField({
341
- name: "tokenEstimator",
342
- valueKind: "token-estimator",
343
+ name: "toolResultModalities",
344
+ valueKind: "tool-result-modalities",
343
345
  required: false,
344
- secret: true,
345
- description: "Independent token estimator required for image profiles.",
346
+ defaultValue: Object.freeze(["text"] as const),
347
+ secret: false,
348
+ description:
349
+ 'Accepted tool-result modalities; normalizes to ["text"] or ["text", "image"].',
346
350
  }),
347
351
  ]);
348
352
 
@@ -375,82 +379,6 @@ export const MODEL_REASONING_FIELDS = Object.freeze([
375
379
  }),
376
380
  ]);
377
381
 
378
- export type ModelTokenEstimatorField = {
379
- readonly name: string;
380
- readonly valueKind: PublicConfigValueKind | "literal-string" | "literal-number";
381
- readonly required: true;
382
- readonly secret: boolean;
383
- readonly literalValue?: string | number;
384
- readonly minimum?: number;
385
- readonly maximum?: number;
386
- readonly description: string;
387
- };
388
-
389
- function estimatorField<const T extends ModelTokenEstimatorField>(
390
- field: T,
391
- ): Readonly<T> {
392
- return Object.freeze(field);
393
- }
394
-
395
- export const MODEL_TOKEN_ESTIMATOR_FIELDS = Object.freeze([
396
- estimatorField({
397
- name: "kind",
398
- valueKind: "literal-string",
399
- required: true,
400
- secret: false,
401
- literalValue: "moonshot-estimate-token-count-v1",
402
- description: "Estimator protocol discriminator.",
403
- }),
404
- estimatorField({
405
- name: "model",
406
- valueKind: "non-empty-string",
407
- required: true,
408
- secret: false,
409
- description: "Estimator model name.",
410
- }),
411
- estimatorField({
412
- name: "apiBase",
413
- valueKind: "non-empty-string",
414
- required: true,
415
- secret: false,
416
- description: "Estimator API base URL.",
417
- }),
418
- estimatorField({
419
- name: "apiKey",
420
- valueKind: "non-empty-string",
421
- required: true,
422
- secret: true,
423
- description: "Estimator API credential.",
424
- }),
425
- estimatorField({
426
- name: "timeoutMs",
427
- valueKind: "positive-integer",
428
- required: true,
429
- secret: false,
430
- minimum: 1_000,
431
- maximum: 60_000,
432
- description: "Estimator request timeout in milliseconds.",
433
- }),
434
- estimatorField({
435
- name: "maxRetries",
436
- valueKind: "literal-number",
437
- required: true,
438
- secret: false,
439
- literalValue: 0,
440
- description: "Estimator retry count; retries are disabled.",
441
- }),
442
- ]);
443
-
444
- export type ModelTokenEstimatorKind = Extract<
445
- (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
446
- { readonly name: "kind" }
447
- >["literalValue"];
448
-
449
- export type ModelTokenEstimatorMaxRetries = Extract<
450
- (typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
451
- { readonly name: "maxRetries" }
452
- >["literalValue"];
453
-
454
382
  export type MemoryConfigField = {
455
383
  readonly name: "profile" | "embedding";
456
384
  readonly valueKind: "non-empty-string" | "embedding-profile";
@@ -610,7 +538,7 @@ export function parsePublicEnvironment(
610
538
  values[field.name] = parseEnvironmentField(field, env[field.name], mode, cwd);
611
539
  }
612
540
 
613
- const workspaceRoot = path.resolve(
541
+ const workspaceRoot = resolveUserPath(
614
542
  cwd,
615
543
  requiredStringValue(values, "TINKER_WORKSPACE"),
616
544
  );
@@ -659,9 +587,7 @@ export function parsePublicEnvironment(
659
587
  return Object.freeze({
660
588
  ...common,
661
589
  mode,
662
- modelsPath: path.isAbsolute(configuredModelsPath)
663
- ? configuredModelsPath
664
- : path.resolve(cwd, configuredModelsPath),
590
+ modelsPath: resolveUserPath(cwd, configuredModelsPath),
665
591
  });
666
592
  }
667
593
 
@@ -760,6 +686,16 @@ function optionalRawString(value: string | undefined): string | undefined {
760
686
  return normalized === undefined || normalized === "" ? undefined : normalized;
761
687
  }
762
688
 
689
+ function resolveUserPath(cwd: string, value: string): string {
690
+ let expanded = value;
691
+ if (value === "~") {
692
+ expanded = os.homedir();
693
+ } else if (value.startsWith("~/") || value.startsWith(`~${path.sep}`)) {
694
+ expanded = path.join(os.homedir(), value.slice(2));
695
+ }
696
+ return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
697
+ }
698
+
763
699
  function optionalStringValue(
764
700
  values: Record<PublicConfigFieldName, ParsedPrimitive>,
765
701
  name: PublicConfigFieldName,
@@ -66,7 +66,8 @@ export function createModelClient(
66
66
  | "apiKey"
67
67
  | "apiBase"
68
68
  | "inputModalities"
69
- | "tokenEstimator"
69
+ | "toolResultModalities"
70
+ | "profileName"
70
71
  >,
71
72
  env: NodeJS.ProcessEnv = process.env,
72
73
  reasoningEffort?: ReasoningEffortController,
@@ -79,12 +80,10 @@ export function createModelClient(
79
80
  model: config.modelName,
80
81
  contextBudget: config.contextBudget,
81
82
  inputModalities: config.inputModalities,
83
+ toolResultModalities: config.toolResultModalities,
82
84
  ...(activeReasoningEffort === undefined
83
85
  ? {}
84
86
  : { reasoningEffort: activeReasoningEffort }),
85
- ...(config.tokenEstimator === undefined
86
- ? {}
87
- : { tokenEstimator: config.tokenEstimator }),
88
87
  ...(env.TINKER_TEST_FAKE_MODEL_REQUEST_LOG === undefined ||
89
88
  env.TINKER_TEST_FAKE_MODEL_REQUEST_LOG === ""
90
89
  ? {}
@@ -99,12 +98,11 @@ export function createModelClient(
99
98
  stream: config.stream,
100
99
  contextBudget: config.contextBudget,
101
100
  inputModalities: config.inputModalities,
101
+ toolResultModalities: config.toolResultModalities,
102
+ profileName: config.profileName,
102
103
  ...(activeReasoningEffort === undefined
103
104
  ? {}
104
105
  : { reasoningEffort: activeReasoningEffort }),
105
- ...(config.tokenEstimator === undefined
106
- ? {}
107
- : { tokenEstimator: config.tokenEstimator }),
108
106
  };
109
107
  if (config.api === "responses") {
110
108
  return new OpenAIResponsesModelClient(common);
@@ -47,9 +47,7 @@ export async function initializeTuiMemory(input: {
47
47
  stream: profile.stream,
48
48
  contextBudget: memoryConfig.contextBudget,
49
49
  inputModalities: profile.inputModalities,
50
- ...(profile.tokenEstimator === undefined
51
- ? {}
52
- : { tokenEstimator: profile.tokenEstimator }),
50
+ toolResultModalities: profile.toolResultModalities,
53
51
  },
54
52
  input.env,
55
53
  );
@@ -129,6 +129,10 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
129
129
  workspaceRoot,
130
130
  sessionId,
131
131
  }),
132
+ memoryGet: memoryCoordinator.createGetToolExecutor({
133
+ workspaceRoot,
134
+ sessionId,
135
+ }),
132
136
  completedTurnHook: memoryCoordinator,
133
137
  }),
134
138
  };
@@ -1,4 +1,5 @@
1
1
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
2
+ import { toolResultText } from "../agent/tool-result-content";
2
3
  import type { CompiledContextEntry, SwapOverride } from "./context-revision";
3
4
  import {
4
5
  contentHash,
@@ -132,7 +133,7 @@ function renderedMessageDescriptor(entry: CompiledContextEntry): unknown {
132
133
  contentSha256:
133
134
  entry.representation === "canonical"
134
135
  ? entry.sourceContentSha256
135
- : contentHash(message.content),
136
+ : contentHash(toolResultText(message.content)),
136
137
  };
137
138
  }
138
139
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "../agent/types";
2
+ import { toolResultDisplayText, toolResultText } from "../agent/tool-result-content";
2
3
  import { stableJsonStringify } from "../model/model-request-preflight";
3
4
  import { formatMessageSource } from "./context-source";
4
5
  import type { CompiledRevisionContext, SwapOverride } from "./context-revision";
@@ -157,7 +158,7 @@ export class CompiledContextValidator {
157
158
  entry.message.toolCallId !== record.toolCallId ||
158
159
  entry.message.providerToolCallId !== record.providerToolCallId ||
159
160
  entry.message.name !== record.name ||
160
- entry.message.content !== override.renderedContent
161
+ toolResultText(entry.message.content) !== override.renderedContent
161
162
  ) {
162
163
  fail(
163
164
  `Swapped tool entry changed protocol identity at ordinal ${entry.ordinal}.`,
@@ -197,7 +198,11 @@ function validateOverrideIdentity(
197
198
  fail(`Swap override changed canonical identity at ordinal ${record.ordinal}.`);
198
199
  }
199
200
  const originalBytes = Buffer.byteLength(
200
- record.role === "assistant" ? (record.content ?? "") : record.content,
201
+ record.role === "assistant"
202
+ ? (record.content ?? "")
203
+ : record.role === "tool"
204
+ ? record.displayText
205
+ : record.content,
201
206
  "utf8",
202
207
  );
203
208
  const renderedBytes = Buffer.byteLength(override.renderedContent, "utf8");
@@ -208,8 +213,11 @@ function validateOverrideIdentity(
208
213
  override.originalBytes !== originalBytes ||
209
214
  override.renderedBytes !== renderedBytes ||
210
215
  override.byteSavings !== originalBytes - renderedBytes ||
211
- (rendererFormat === "swap-observation-v1" && override.byteSavings <= 0) ||
216
+ ((rendererFormat === "swap-observation-v1" ||
217
+ rendererFormat === "skill-activation-receipt-v1") &&
218
+ override.byteSavings <= 0) ||
212
219
  (rendererFormat !== "swap-observation-v1" &&
220
+ rendererFormat !== "swap-tool-image-v1" &&
213
221
  rendererFormat !== "skill-activation-receipt-v1")
214
222
  ) {
215
223
  fail(`Swap override metadata is invalid at ordinal ${record.ordinal}.`);
@@ -266,7 +274,8 @@ function assertSameMessage(actual: AgentMessage, record: CanonicalMessageRecord)
266
274
  actual.toolCallId !== record.toolCallId ||
267
275
  actual.providerToolCallId !== record.providerToolCallId ||
268
276
  actual.name !== record.name ||
269
- actual.content !== record.content
277
+ stableJsonStringify(actual.content) !== stableJsonStringify(record.content) ||
278
+ toolResultDisplayText(actual.content) !== record.displayText
270
279
  ) {
271
280
  fail(`Canonical entry changed tool data at ordinal ${record.ordinal}.`);
272
281
  }
@@ -1,8 +1,14 @@
1
1
  import type { MessageId, ProtocolFrameId, ToolCallId } from "../ids/runtime-id";
2
+ import {
3
+ canonicalToolResultContentHash,
4
+ toolResultDisplayText,
5
+ validateToolResultContent,
6
+ } from "../agent/tool-result-content";
2
7
  import {
3
8
  contentHash,
4
9
  userMessageHash,
5
10
  rawResultHash,
11
+ validateReturnedToolObservation,
6
12
  type CanonicalMessageRecord,
7
13
  type ProtocolContextView,
8
14
  type ProtocolFrame,
@@ -113,7 +119,9 @@ export class ContextProtocolValidator {
113
119
  ? {}
114
120
  : { attachments: message.attachments }),
115
121
  })
116
- : contentHash(message.content))
122
+ : message.role === "tool"
123
+ ? canonicalToolResultContentHash(message.content)
124
+ : contentHash(message.content))
117
125
  ) {
118
126
  fail(
119
127
  "content_hash_mismatch",
@@ -405,11 +413,19 @@ function validateToolExchange(
405
413
  );
406
414
  }
407
415
  const result = requireItem(results, 0, "tool result");
416
+ validateToolResultContent(message.content);
417
+ if (message.displayText !== toolResultDisplayText(message.content)) {
418
+ fail(
419
+ "content_hash_mismatch",
420
+ `Tool display projection does not match message ${message.messageId}.`,
421
+ identityForMessage(message),
422
+ );
423
+ }
408
424
  if (
409
425
  result.sessionId !== frame.sessionId ||
410
426
  result.frameId !== frame.frameId ||
411
427
  result.toolMessageId !== message.messageId ||
412
- result.observationSha256 !== contentHash(message.content)
428
+ result.observationSha256 !== canonicalToolResultContentHash(message.content)
413
429
  ) {
414
430
  fail(
415
431
  "tool_result_mismatch",
@@ -428,6 +444,21 @@ function validateToolExchange(
428
444
  { ...identityForMessage(message), toolCallId: call.toolCallId },
429
445
  );
430
446
  }
447
+ if (input.fullIntegrity && result.completion.kind === "returned") {
448
+ try {
449
+ validateReturnedToolObservation({
450
+ toolName: message.name,
451
+ raw: result.completion.raw,
452
+ content: message.content,
453
+ });
454
+ } catch (error) {
455
+ fail(
456
+ "tool_result_mismatch",
457
+ `Tool result for ${call.toolCallId} has invalid canonical content: ${error instanceof Error ? error.message : String(error)}`,
458
+ { ...identityForMessage(message), toolCallId: call.toolCallId },
459
+ );
460
+ }
461
+ }
431
462
  input.usedResultCallIds.add(call.toolCallId);
432
463
  }
433
464
  }
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "../agent/types";
2
+ import { textToolResultContent } from "../agent/tool-result-content";
2
3
  import type { ContextRevisionId } from "../ids/runtime-id";
3
4
  import { CompiledContextValidator } from "./compiled-context-validator";
4
5
  import {
@@ -263,7 +264,7 @@ function swappedToolMessage(
263
264
  }
264
265
  return {
265
266
  ...message,
266
- content: override.renderedContent,
267
+ content: textToolResultContent(override.renderedContent),
267
268
  };
268
269
  }
269
270
 
@@ -133,12 +133,18 @@ export type SwapOverride = {
133
133
  readonly originalBytes: number;
134
134
  readonly renderedBytes: number;
135
135
  readonly byteSavings: number;
136
- readonly rendererFormat?: "swap-observation-v1" | "skill-activation-receipt-v1";
136
+ readonly rendererFormat?:
137
+ | "swap-observation-v1"
138
+ | "swap-tool-image-v1"
139
+ | "skill-activation-receipt-v1";
137
140
  };
138
141
 
139
142
  export type StoredContextOverrideV8 = SwapOverride & {
140
143
  readonly introducedRevisionId: ContextRevisionId;
141
- readonly rendererFormat: "swap-observation-v1" | "skill-activation-receipt-v1";
144
+ readonly rendererFormat:
145
+ | "swap-observation-v1"
146
+ | "swap-tool-image-v1"
147
+ | "skill-activation-receipt-v1";
142
148
  readonly createdAt: string;
143
149
  };
144
150