deepline 0.3.72 → 0.3.74

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.
@@ -100,7 +100,6 @@ import {
100
100
  import { CTX_FETCH_EGRESS_TOOL_ID } from './builtin-pacing';
101
101
  import {
102
102
  legacyRawFromToolResponseRawV2,
103
- normalizeToolResponseContract,
104
103
  providerMetaFromToolResponseRawV2,
105
104
  RAW_V2_TOOL_RESPONSE_CONTRACT,
106
105
  type ToolResponseContract,
@@ -115,7 +114,6 @@ import {
115
114
  isProviderUnavailable,
116
115
  serializeToolExecutionFailure,
117
116
  ToolExecutionError,
118
- TOOL_EXECUTION_ERROR_SCHEMA_VERSION,
119
117
  TOOL_EXECUTION_ERROR_SCHEMA_HEADER,
120
118
  type ToolExecutionErrorSchemaVersion,
121
119
  type ToolExecutionFailureV1,
@@ -175,6 +173,7 @@ import {
175
173
  buildDurableToolAggregateReceiptKey,
176
174
  buildDurableToolCallAuthScopeDigest,
177
175
  buildDurableToolCallCacheKey,
176
+ buildLegacyRevisionedDurableToolCallCacheKey,
178
177
  buildDurableToolPayloadProviderIdempotencyKey,
179
178
  buildDurableToolProviderIdempotencyKey,
180
179
  buildDurableToolReceiptPrefix,
@@ -194,6 +193,10 @@ import {
194
193
  type PlayAuthoringRunScope,
195
194
  type PlayAuthoringRuntimeContext,
196
195
  } from '../plays/authoring-contract';
196
+ import {
197
+ resolvePlayArtifactRuntimeContract,
198
+ type PlayArtifactRuntimeContract,
199
+ } from './play-artifact-runtime-contract';
197
200
  import {
198
201
  LOG_LEVELS,
199
202
  formatCtxFetchHttpFailureDiagnostic,
@@ -406,14 +409,8 @@ type InlineCompositionStore = {
406
409
  governor: PlayExecutionGovernor;
407
410
  playName: string;
408
411
  staticPipeline: ContextOptions['staticPipeline'];
409
- /** Immutable error contract pinned by the child play artifact. */
410
- toolErrorSchemaVersion: ToolExecutionErrorSchemaVersion;
411
- /** Immutable authoring semantics pinned by the child play artifact. */
412
- authoringContractEdition: PlayAuthoringContractEdition;
413
- /** Execute response contract pinned by the child artifact. */
414
- toolResponseContract: ToolResponseContract;
415
- /** Explicit response-transform revision used for durable receipt identity. */
416
- toolResponseReceiptRevision?: string;
412
+ /** Immutable compatibility projection of the child Play artifact. */
413
+ runtimeContract: PlayArtifactRuntimeContract;
417
414
  };
418
415
  const inlineCompositionContext =
419
416
  new AsyncLocalStorage<InlineCompositionStore>();
@@ -1088,7 +1085,10 @@ export function resolveToolRuntimeTimeoutMs(
1088
1085
  authoringContractEdition: PlayAuthoringContractEdition = PLAY_AUTHORING_CONTRACT_EDITION,
1089
1086
  ): number | undefined {
1090
1087
  if (requestedTimeoutMs !== undefined) {
1091
- if (authoringContractEdition >= 2) {
1088
+ if (
1089
+ resolvePlayArtifactRuntimeContract({ authoringContractEdition }).semantics
1090
+ .validation === 'strict'
1091
+ ) {
1092
1092
  validatePlayAuthoringField(
1093
1093
  'ctx.tools.execute.timeoutMs',
1094
1094
  requestedTimeoutMs,
@@ -1395,6 +1395,8 @@ function playNodeScopeForToolCallRequest(
1395
1395
  type ToolExecutionApiOptions = {
1396
1396
  timeoutMs?: number;
1397
1397
  durableCallReceiptKey?: string | null;
1398
+ /** The receipt key is a completed-result cache for this read-only call. */
1399
+ completedResultCache?: boolean;
1398
1400
  executionAuthScopeDigest?: string | null;
1399
1401
  /**
1400
1402
  * Historical receipt identity used for the API's provider/billing
@@ -2112,6 +2114,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2112
2114
  { active: number; waiters: Array<() => void> }
2113
2115
  >();
2114
2116
  #options: ContextOptions;
2117
+ private readonly rootRuntimeContract: PlayArtifactRuntimeContract;
2115
2118
  private readonly executionScope: RunExecutionScope;
2116
2119
  private logBuffer: string[] = [];
2117
2120
  private readonly logRateLimitState = new Map<
@@ -2250,7 +2253,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2250
2253
  assertNoSecretTaint(request.input, 'ctx.tools.execute input');
2251
2254
  if (
2252
2255
  request.timeoutMs !== undefined &&
2253
- this.currentAuthoringContractEdition >= 2
2256
+ this.currentRuntimeSemantics.validation === 'strict'
2254
2257
  ) {
2255
2258
  validatePlayAuthoringField(
2256
2259
  'ctx.tools.execute.timeoutMs',
@@ -2259,7 +2262,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2259
2262
  }
2260
2263
  if (
2261
2264
  request.receiptWaitMs !== undefined &&
2262
- this.currentAuthoringContractEdition >= 2
2265
+ this.currentRuntimeSemantics.validation === 'strict'
2263
2266
  ) {
2264
2267
  validatePlayAuthoringField(
2265
2268
  'ctx.tools.execute.receiptWaitMs',
@@ -2403,7 +2406,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2403
2406
  }
2404
2407
  const handle = createSecretHandle(name.trim());
2405
2408
  const value =
2406
- this.currentAuthoringContractEdition >= 4
2409
+ this.currentRuntimeSemantics.secrets.value === 'plaintext_promise'
2407
2410
  ? createPlaintextSecretPromise(handle.name, () =>
2408
2411
  this.resolveSecretHandle(handle),
2409
2412
  )
@@ -2427,6 +2430,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2427
2430
  };
2428
2431
 
2429
2432
  constructor(options: ContextOptions) {
2433
+ this.rootRuntimeContract =
2434
+ options.artifactRuntimeContract ??
2435
+ resolvePlayArtifactRuntimeContract({
2436
+ authoringContractEdition: options.authoringContractEdition,
2437
+ toolResponseContract: options.toolResponseContract,
2438
+ toolResponseReceiptRevision: options.toolResponseReceiptRevision,
2439
+ });
2430
2440
  const fixtureBehavior = validateFixtureBehavior(options.fixtureBehavior);
2431
2441
  if (fixtureBehavior.ok === false) {
2432
2442
  throw new Error(fixtureBehavior.error);
@@ -2746,42 +2756,54 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2746
2756
 
2747
2757
  /**
2748
2758
  * Inline children share this context's queues, but not its artifact contract.
2749
- * Each child tool call must preserve the error shape pinned when that child
2750
- * was published, even when the parent was published under another schema.
2759
+ * Each child uses its own admitted semantics and response projection even
2760
+ * when the parent was published under another artifact contract.
2751
2761
  */
2752
2762
  private get currentToolErrorSchemaVersion(): ToolExecutionErrorSchemaVersion {
2753
- // Historical artifacts must not turn durable structured failures back into
2754
- // strings. Every runtime tool call and receipt rehydration uses v1.
2755
- return TOOL_EXECUTION_ERROR_SCHEMA_VERSION;
2763
+ return this.currentRuntimeContract.toolErrorSchemaVersion;
2756
2764
  }
2757
2765
 
2758
2766
  private get currentToolResponseContract(): ToolResponseContract {
2759
- return normalizeToolResponseContract(
2760
- this.activeInlineComposition?.toolResponseContract ??
2761
- this.#options.toolResponseContract,
2767
+ return this.currentRuntimeContract.toolResponseContract;
2768
+ }
2769
+
2770
+ /**
2771
+ * A real published artifact pins its response presentation in its launch
2772
+ * contract. In-process callers that omit the option are runtime consumers,
2773
+ * not historical Play artifacts, and retain the canonical result they pass
2774
+ * in. This keeps the context API backward-compatible while letting a pinned
2775
+ * legacy artifact request its old projection after a cache read.
2776
+ */
2777
+ private get hasPinnedToolResponseContract(): boolean {
2778
+ return (
2779
+ this.activeInlineComposition !== null ||
2780
+ this.#options.artifactRuntimeContract !== undefined ||
2781
+ this.#options.toolResponseContract !== undefined
2762
2782
  );
2763
2783
  }
2764
2784
 
2765
2785
  /**
2766
- * Only explicitly declared response transformations affect receipt reuse.
2767
- * Missing preserves historical artifact/receipt identity, while the public
2768
- * response header still normalizes it to the legacy V2 behavior above.
2786
+ * Compatibility metadata retained for already-published artifacts. It never
2787
+ * changes the completed-result cache identity.
2769
2788
  */
2770
2789
  private get currentToolResponseReceiptRevision(): string | undefined {
2771
- if (this.activeInlineComposition) {
2772
- return this.activeInlineComposition.toolResponseReceiptRevision;
2773
- }
2774
- return this.#options.toolResponseReceiptRevision;
2790
+ return this.currentRuntimeContract.toolResponseReceiptRevision;
2775
2791
  }
2776
2792
 
2777
2793
  private get currentAuthoringContractEdition(): PlayAuthoringContractEdition {
2794
+ return this.currentRuntimeContract.semantics.authoringContractEdition;
2795
+ }
2796
+
2797
+ private get currentRuntimeContract(): PlayArtifactRuntimeContract {
2778
2798
  return (
2779
- this.activeInlineComposition?.authoringContractEdition ??
2780
- this.#options.authoringContractEdition ??
2781
- PLAY_AUTHORING_CONTRACT_EDITION
2799
+ this.activeInlineComposition?.runtimeContract ?? this.rootRuntimeContract
2782
2800
  );
2783
2801
  }
2784
2802
 
2803
+ private get currentRuntimeSemantics() {
2804
+ return this.currentRuntimeContract.semantics;
2805
+ }
2806
+
2785
2807
  private recordPlayCallStep(input: {
2786
2808
  playId: string;
2787
2809
  execution?: 'inline';
@@ -4020,14 +4042,14 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4020
4042
  );
4021
4043
  if (normalized) byKey.set(normalized.key, normalized);
4022
4044
  }
4023
- await this.reconcileMissingRuntimeStepReceiptCompletions(
4045
+ await this.reconcileIncompleteRuntimeStepReceiptCompletions(
4024
4046
  normalizedInputs,
4025
4047
  byKey,
4026
4048
  );
4027
4049
  return byKey;
4028
4050
  }
4029
4051
 
4030
- private async reconcileMissingRuntimeStepReceiptCompletions(
4052
+ private async reconcileIncompleteRuntimeStepReceiptCompletions(
4031
4053
  receipts: Array<{
4032
4054
  key: string;
4033
4055
  runId: string;
@@ -4038,32 +4060,45 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4038
4060
  completedByKey: Map<string, RuntimeStepReceipt>,
4039
4061
  ): Promise<void> {
4040
4062
  if (
4041
- receipts.length === completedByKey.size ||
4042
- (!this.#options.getRuntimeStepReceipt &&
4043
- !this.#options.getRuntimeStepReceipts)
4063
+ !this.#options.getRuntimeStepReceipt &&
4064
+ !this.#options.getRuntimeStepReceipts
4044
4065
  ) {
4045
4066
  return;
4046
4067
  }
4047
- const missingKeys = receipts
4068
+ // The receipt gateway is allowed to return a compact terminal
4069
+ // acknowledgement without `output`. That is sufficient for old callers
4070
+ // which keep the live result, but a completed-result cache must hydrate
4071
+ // the stored output before handing it to a cache cohort.
4072
+ const incompleteKeys = receipts
4048
4073
  .map((receipt) => receipt.key.trim())
4049
- .filter((key) => key && !completedByKey.has(key));
4050
- if (missingKeys.length === 0) return;
4051
- const latest = await this.getRuntimeStepReceipts(missingKeys);
4052
- for (const key of missingKeys) {
4074
+ .filter((key) => {
4075
+ if (!key) return false;
4076
+ const completed = completedByKey.get(key);
4077
+ return (
4078
+ !completed ||
4079
+ ((completed.status === 'completed' ||
4080
+ completed.status === 'skipped') &&
4081
+ completed.output === undefined)
4082
+ );
4083
+ });
4084
+ if (incompleteKeys.length === 0) return;
4085
+ const latest = await this.getRuntimeStepReceipts(incompleteKeys);
4086
+ for (const key of incompleteKeys) {
4053
4087
  const recovered = latest.get(key);
4054
4088
  if (
4055
- recovered?.status === 'completed' ||
4056
- recovered?.status === 'skipped'
4089
+ (recovered?.status === 'completed' ||
4090
+ recovered?.status === 'skipped') &&
4091
+ recovered.output !== undefined
4057
4092
  ) {
4058
4093
  completedByKey.set(key, recovered);
4059
4094
  }
4060
4095
  }
4061
- const recoveredCount = missingKeys.filter((key) =>
4062
- completedByKey.has(key),
4096
+ const recoveredCount = incompleteKeys.filter(
4097
+ (key) => completedByKey.get(key)?.output !== undefined,
4063
4098
  ).length;
4064
4099
  if (recoveredCount > 0) {
4065
4100
  this.runtimeLog(
4066
- `Runtime tool receipt completion response reconciled ${recoveredCount}/${missingKeys.length} missing receipt(s) from durable store read-back.`,
4101
+ `Runtime tool receipt completion response hydrated ${recoveredCount}/${incompleteKeys.length} incomplete receipt(s) from durable store read-back.`,
4067
4102
  );
4068
4103
  }
4069
4104
  }
@@ -4178,17 +4213,39 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4178
4213
  );
4179
4214
  }
4180
4215
 
4181
- private async durableToolCallCacheKeyForScope(
4182
- input: {
4183
- toolId: string;
4184
- requestInput: Record<string, unknown>;
4185
- executionAuthScopeDigest?: string | null;
4186
- staleAfterSeconds?: number | null;
4187
- playLocalScope?: string | null;
4188
- },
4189
- toolResponseReceiptRevision: string | null | undefined = this
4190
- .currentToolResponseReceiptRevision,
4191
- ): Promise<string> {
4216
+ private async durableToolCallCacheKeyForScope(input: {
4217
+ toolId: string;
4218
+ requestInput: Record<string, unknown>;
4219
+ executionAuthScopeDigest?: string | null;
4220
+ staleAfterSeconds?: number | null;
4221
+ playLocalScope?: string | null;
4222
+ }): Promise<string> {
4223
+ const identity = await this.durableToolCallCacheIdentity(input);
4224
+ return buildDurableToolCallCacheKey(identity);
4225
+ }
4226
+
4227
+ private async legacyDurableToolCallCacheKeyForScope(input: {
4228
+ toolId: string;
4229
+ requestInput: Record<string, unknown>;
4230
+ executionAuthScopeDigest?: string | null;
4231
+ staleAfterSeconds?: number | null;
4232
+ playLocalScope?: string | null;
4233
+ }): Promise<string | null> {
4234
+ const revision = this.currentToolResponseReceiptRevision;
4235
+ if (!revision) return null;
4236
+ return buildLegacyRevisionedDurableToolCallCacheKey({
4237
+ ...(await this.durableToolCallCacheIdentity(input)),
4238
+ toolResponseReceiptRevision: revision,
4239
+ });
4240
+ }
4241
+
4242
+ private async durableToolCallCacheIdentity(input: {
4243
+ toolId: string;
4244
+ requestInput: Record<string, unknown>;
4245
+ executionAuthScopeDigest?: string | null;
4246
+ staleAfterSeconds?: number | null;
4247
+ playLocalScope?: string | null;
4248
+ }): Promise<Parameters<typeof buildDurableToolCallCacheKey>[0]> {
4192
4249
  const providerActionVersion =
4193
4250
  (await this.#options.getToolActionCacheVersion?.(input.toolId))?.trim() ??
4194
4251
  '';
@@ -4196,7 +4253,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4196
4253
  input.executionAuthScopeDigest?.trim() ||
4197
4254
  (await this.resolveToolAuthScopeDigest(input.toolId))?.trim() ||
4198
4255
  null;
4199
- return buildDurableToolCallCacheKey({
4256
+ return {
4200
4257
  orgId: this.#options.orgId,
4201
4258
  playLocalScope: input.playLocalScope,
4202
4259
  toolId: input.toolId,
@@ -4207,29 +4264,33 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4207
4264
  executionAuthScopeDigest,
4208
4265
  }),
4209
4266
  providerActionVersion,
4210
- toolResponseReceiptRevision,
4211
4267
  staleAfterSeconds: input.staleAfterSeconds,
4212
4268
  cacheEpochMs: this.durableCallCacheEpochMs,
4269
+ };
4270
+ }
4271
+
4272
+ private async durableToolCallCacheKey(input: {
4273
+ toolId: string;
4274
+ requestInput: Record<string, unknown>;
4275
+ executionAuthScopeDigest?: string | null;
4276
+ staleAfterSeconds?: number | null;
4277
+ }): Promise<string> {
4278
+ return await this.durableToolCallCacheKeyForScope({
4279
+ ...input,
4280
+ playLocalScope: this.currentGovernance.currentPlayId,
4213
4281
  });
4214
4282
  }
4215
4283
 
4216
- private async durableToolCallCacheKey(
4217
- input: {
4218
- toolId: string;
4219
- requestInput: Record<string, unknown>;
4220
- executionAuthScopeDigest?: string | null;
4221
- staleAfterSeconds?: number | null;
4222
- },
4223
- toolResponseReceiptRevision: string | null | undefined = this
4224
- .currentToolResponseReceiptRevision,
4225
- ): Promise<string> {
4226
- return await this.durableToolCallCacheKeyForScope(
4227
- {
4228
- ...input,
4229
- playLocalScope: this.currentGovernance.currentPlayId,
4230
- },
4231
- toolResponseReceiptRevision,
4232
- );
4284
+ private async legacyDurableToolCallCacheKey(input: {
4285
+ toolId: string;
4286
+ requestInput: Record<string, unknown>;
4287
+ executionAuthScopeDigest?: string | null;
4288
+ staleAfterSeconds?: number | null;
4289
+ }): Promise<string | null> {
4290
+ return await this.legacyDurableToolCallCacheKeyForScope({
4291
+ ...input,
4292
+ playLocalScope: this.currentGovernance.currentPlayId,
4293
+ });
4233
4294
  }
4234
4295
 
4235
4296
  private async resolveToolAuthScopeDigest(
@@ -5379,6 +5440,23 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
5379
5440
  return null;
5380
5441
  }
5381
5442
 
5443
+ private canReuseToolResultForCurrentResponseContract(
5444
+ result: unknown,
5445
+ ): boolean {
5446
+ if (this.currentToolResponseContract !== RAW_V2_TOOL_RESPONSE_CONTRACT) {
5447
+ return true;
5448
+ }
5449
+ const toolResult = isToolExecuteResult(result)
5450
+ ? result
5451
+ : isSerializedToolExecuteResult(result)
5452
+ ? deserializeToolExecuteResult(result)
5453
+ : null;
5454
+ return (
5455
+ toolResult !== null &&
5456
+ Object.prototype.hasOwnProperty.call(toolResult.toolResponse, 'rawV2')
5457
+ );
5458
+ }
5459
+
5382
5460
  private cacheToolResult(
5383
5461
  toolId: string,
5384
5462
  rowCacheKey: string,
@@ -5491,6 +5569,38 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
5491
5569
  );
5492
5570
  }
5493
5571
 
5572
+ /**
5573
+ * Receipts retain the canonical raw-v2 provider outcome. Older artifacts
5574
+ * receive their legacy projection only at the Play boundary, after cache
5575
+ * lookup and persistence have finished.
5576
+ */
5577
+ private materializeToolExecutionResult(
5578
+ result: ToolExecuteResult,
5579
+ ): ToolExecuteResult {
5580
+ if (
5581
+ this.currentToolResponseContract === RAW_V2_TOOL_RESPONSE_CONTRACT ||
5582
+ !this.hasPinnedToolResponseContract ||
5583
+ !Object.prototype.hasOwnProperty.call(result.toolResponse, 'rawV2')
5584
+ ) {
5585
+ return result;
5586
+ }
5587
+ const serialized = serializeToolExecuteResult(result);
5588
+ if (serialized.__kind !== 'deepline.tool_execute_result.v2') {
5589
+ return result;
5590
+ }
5591
+ return deserializeToolExecuteResult({
5592
+ ...serialized,
5593
+ __kind: 'deepline.tool_execute_result.v1',
5594
+ toolResponse: {
5595
+ raw: legacyRawFromToolResponseRawV2(
5596
+ serialized.toolResponse.rawV2,
5597
+ serialized.toolResponse.view,
5598
+ serialized.toolResponse.responseMeta,
5599
+ ),
5600
+ },
5601
+ });
5602
+ }
5603
+
5494
5604
  private attachCustomerDbDatasetResult(
5495
5605
  toolId: string,
5496
5606
  requestInput: Record<string, unknown> | undefined,
@@ -9222,7 +9332,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9222
9332
  }
9223
9333
  if (
9224
9334
  options?.timeoutMs !== undefined &&
9225
- this.currentAuthoringContractEdition >= 2
9335
+ this.currentRuntimeSemantics.validation === 'strict'
9226
9336
  ) {
9227
9337
  validatePlayAuthoringField(
9228
9338
  'ctx.tools.execute.timeoutMs',
@@ -9231,7 +9341,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9231
9341
  }
9232
9342
  if (
9233
9343
  options?.receiptWaitMs !== undefined &&
9234
- this.currentAuthoringContractEdition >= 2
9344
+ this.currentRuntimeSemantics.validation === 'strict'
9235
9345
  ) {
9236
9346
  validatePlayAuthoringField(
9237
9347
  'ctx.tools.execute.receiptWaitMs',
@@ -9272,16 +9382,26 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9272
9382
  executionAuthScopeDigest,
9273
9383
  staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9274
9384
  });
9275
- let providerIdempotencyKeyBase = await this.durableToolCallCacheKey(
9276
- {
9277
- toolId,
9278
- requestInput: input,
9279
- executionAuthScopeDigest,
9280
- staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9281
- },
9282
- null,
9283
- );
9284
- const checkpointCacheKeys = [durableCacheKey];
9385
+ let legacyDurableCacheKey = await this.legacyDurableToolCallCacheKey({
9386
+ toolId,
9387
+ requestInput: input,
9388
+ executionAuthScopeDigest,
9389
+ staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9390
+ });
9391
+ // Only explicitly fenced operations use this key as a provider identity.
9392
+ // Ordinary calls use it solely as their completed-result cache key.
9393
+ let providerIdempotencyKeyBase = durableCacheKey;
9394
+ let directResultReceiptKey = durableCacheKey;
9395
+ // A raw-v2 artifact must never read an older receipt that cannot render
9396
+ // raw-v2. If both the primary and its revisioned recovery receipt are
9397
+ // unrenderable, make a live call rather than returning either cache entry.
9398
+ // We cannot safely replace an immutable completed receipt, so that rare
9399
+ // incompatible state deliberately forgoes caching until it is repaired.
9400
+ let bypassDirectResultCache = false;
9401
+ const checkpointCacheKeys = [
9402
+ durableCacheKey,
9403
+ ...(legacyDurableCacheKey ? [legacyDurableCacheKey] : []),
9404
+ ];
9285
9405
 
9286
9406
  const executeTool = async (context?: {
9287
9407
  leaseId?: string | null;
@@ -9310,7 +9430,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9310
9430
  if (!store) {
9311
9431
  const directCacheKey = durableCacheKey;
9312
9432
  let initialDirectReceipt: RuntimeStepReceipt | null | undefined;
9313
- const cached = !cacheableToolResult
9433
+ const cachedCandidate = !cacheableToolResult
9314
9434
  ? null
9315
9435
  : toolCachePolicy.force
9316
9436
  ? null
@@ -9319,6 +9439,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9319
9439
  checkpointCacheKeys,
9320
9440
  'direct',
9321
9441
  );
9442
+ const cached =
9443
+ cachedCandidate &&
9444
+ this.canReuseToolResultForCurrentResponseContract(
9445
+ cachedCandidate.result.result,
9446
+ )
9447
+ ? cachedCandidate
9448
+ : null;
9322
9449
  if (cached) {
9323
9450
  this.runtimeLog(`Calling tool: ${toolId} recovered from checkpoint`);
9324
9451
  // Started and settled are paired in a finally: an uncovered throw
@@ -9342,7 +9469,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9342
9469
  outcome: 'cached',
9343
9470
  durationMs: 0,
9344
9471
  });
9345
- return recovered;
9472
+ return this.materializeToolExecutionResult(recovered);
9346
9473
  } finally {
9347
9474
  if (!settled) {
9348
9475
  this.emitToolCallLifecycle('settled', toolId, normalizedKey, {
@@ -9361,17 +9488,36 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9361
9488
  const historicalReceipt = await this.withRuntimeReceiptHydrationTurn(
9362
9489
  async () => await this.getRuntimeStepReceipt(directCacheKey),
9363
9490
  );
9364
- initialDirectReceipt = historicalReceipt;
9491
+ const historicalOutput = historicalReceipt
9492
+ ? this.runtimeReceiptOutput<unknown>(historicalReceipt)
9493
+ : undefined;
9494
+ const historicalIsTerminal =
9495
+ historicalReceipt?.status === 'completed' ||
9496
+ historicalReceipt?.status === 'skipped';
9497
+ const historicalCanServeCurrentContract =
9498
+ !historicalIsTerminal ||
9499
+ this.canReuseToolResultForCurrentResponseContract(historicalOutput);
9500
+ initialDirectReceipt = historicalCanServeCurrentContract
9501
+ ? historicalReceipt
9502
+ : null;
9503
+ if (!historicalCanServeCurrentContract) {
9504
+ if (!legacyDurableCacheKey) {
9505
+ throw new Error(
9506
+ `Tool ${toolId} has an unrenderable legacy result but no response-contract recovery cache key.`,
9507
+ );
9508
+ }
9509
+ directResultReceiptKey = legacyDurableCacheKey;
9510
+ }
9365
9511
  if (
9366
9512
  historicalReceipt &&
9367
- (historicalReceipt.status === 'completed' ||
9368
- historicalReceipt.status === 'skipped') &&
9513
+ historicalIsTerminal &&
9514
+ historicalCanServeCurrentContract &&
9369
9515
  !isSerializedToolExecuteResult(historicalReceipt.output) &&
9370
9516
  !isToolExecuteResult(historicalReceipt.output)
9371
9517
  ) {
9372
9518
  const historicalOutput =
9373
9519
  this.runtimeReceiptOutput<unknown>(historicalReceipt);
9374
- return markToolExecuteResultExecutionOutcome(
9520
+ const recoveredHistorical = markToolExecuteResultExecutionOutcome(
9375
9521
  historicalOutput,
9376
9522
  toolExecutionOutcomeForDurableReceipt({
9377
9523
  source: 'cache',
@@ -9379,6 +9525,43 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9379
9525
  cacheKey: directCacheKey,
9380
9526
  }),
9381
9527
  );
9528
+ return isToolExecuteResult(recoveredHistorical)
9529
+ ? this.materializeToolExecutionResult(recoveredHistorical)
9530
+ : recoveredHistorical;
9531
+ }
9532
+ if (
9533
+ (!historicalReceipt || !historicalCanServeCurrentContract) &&
9534
+ legacyDurableCacheKey
9535
+ ) {
9536
+ const legacyReceipt = await this.withRuntimeReceiptHydrationTurn(
9537
+ async () =>
9538
+ await this.getRuntimeStepReceipt(legacyDurableCacheKey!),
9539
+ );
9540
+ if (
9541
+ legacyReceipt &&
9542
+ (legacyReceipt.status === 'completed' ||
9543
+ legacyReceipt.status === 'skipped') &&
9544
+ legacyReceipt.output !== undefined
9545
+ ) {
9546
+ const recoveredLegacy = markToolExecuteResultExecutionOutcome(
9547
+ this.runtimeReceiptOutput(legacyReceipt),
9548
+ toolExecutionOutcomeForDurableReceipt({
9549
+ source: 'cache',
9550
+ receiptKey: legacyDurableCacheKey,
9551
+ cacheKey: directCacheKey,
9552
+ }),
9553
+ );
9554
+ if (
9555
+ this.canReuseToolResultForCurrentResponseContract(
9556
+ recoveredLegacy,
9557
+ )
9558
+ ) {
9559
+ return isToolExecuteResult(recoveredLegacy)
9560
+ ? this.materializeToolExecutionResult(recoveredLegacy)
9561
+ : recoveredLegacy;
9562
+ }
9563
+ bypassDirectResultCache = true;
9564
+ }
9382
9565
  }
9383
9566
  }
9384
9567
  this.runtimeLog(
@@ -9416,23 +9599,27 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9416
9599
  'Direct tool execution requires a stable logical call identity.',
9417
9600
  );
9418
9601
  }
9419
- const physicalDirectKey = cacheableToolResult
9602
+ const useDirectResultReceipts =
9603
+ cacheableToolResult && !bypassDirectResultCache;
9604
+ const physicalDirectKey = useDirectResultReceipts
9420
9605
  ? directCacheKey
9421
9606
  : `${buildDurableToolReceiptPrefix({
9422
9607
  orgId: this.#options.orgId ?? 'unknown-org',
9423
9608
  toolId,
9424
9609
  })}${stableDigest(`${this.currentRunId}:${logicalCallId}:always-fresh`)}`;
9610
+ const resultReceiptKey = directResultReceiptKey;
9425
9611
  const providerOperationKey = providerIdempotencyKeyBase;
9426
- const resultReceiptKey = directCacheKey;
9427
9612
  const toolRetryPolicy = await this.#options
9428
9613
  .getToolRetryPolicy?.(toolId, input)
9429
9614
  .catch(() => null);
9430
- // Direct cacheable calls now give ToolCall the real completed-result
9431
- // receipt Adapter. The provider-operation fence remains below the
9432
- // dispatcher: V2 and legacy presentation keys can therefore publish
9433
- // independent Tool Result Receipts without a second provider effect.
9434
- // Map members retain their cohort-owned receipt Adapter until their
9435
- // batched claim/heartbeat protocol moves behind the same Interface.
9615
+ if (
9616
+ bypassDirectResultCache &&
9617
+ toolRetryPolicy?.requiresExecutionFence === true
9618
+ ) {
9619
+ throw new Error(
9620
+ `Tool ${toolId} has an unrenderable completed result under an execution fence and cannot safely refresh it.`,
9621
+ );
9622
+ }
9436
9623
  const dispatcher = {
9437
9624
  dispatch: async ({
9438
9625
  providerOperationKey: operationKey,
@@ -9440,7 +9627,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9440
9627
  providerOperationKey: string | null;
9441
9628
  }) => {
9442
9629
  if (
9443
- cacheableToolResult &&
9630
+ useDirectResultReceipts &&
9444
9631
  operationKey !== providerOperationKey
9445
9632
  ) {
9446
9633
  throw new Error(
@@ -9453,18 +9640,11 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9453
9640
  startedAt: directCallStartedAt,
9454
9641
  input,
9455
9642
  options: {
9456
- ...(cacheableToolResult
9643
+ ...(useDirectResultReceipts
9457
9644
  ? {
9458
9645
  durableCallReceiptKey: physicalDirectKey,
9646
+ completedResultCache: true,
9459
9647
  executionAuthScopeDigest,
9460
- providerIdempotencyReceiptKey: operationKey,
9461
- providerIdempotencyKey:
9462
- this.providerIdempotencyKeyForToolCall({
9463
- cacheKey: providerIdempotencyKeyBase,
9464
- force: toolCachePolicy.force,
9465
- leaseId: directReceiptLeaseId,
9466
- logicalCallId,
9467
- }),
9468
9648
  ...(directReceiptLeaseId &&
9469
9649
  (this.#options.heartbeatRuntimeStepReceipts ||
9470
9650
  this.#options.getRuntimeStepReceipt ||
@@ -9487,6 +9667,24 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9487
9667
  : {}),
9488
9668
  }
9489
9669
  : {}),
9670
+ ...(toolRetryPolicy?.requiresExecutionFence === true
9671
+ ? {
9672
+ providerIdempotencyReceiptKey:
9673
+ cacheableToolResult && operationKey
9674
+ ? operationKey
9675
+ : physicalDirectKey,
9676
+ providerIdempotencyKey:
9677
+ this.providerIdempotencyKeyForToolCall({
9678
+ cacheKey:
9679
+ useDirectResultReceipts && operationKey
9680
+ ? providerIdempotencyKeyBase
9681
+ : physicalDirectKey,
9682
+ force: cacheableToolResult && toolCachePolicy.force,
9683
+ leaseId: directReceiptLeaseId,
9684
+ logicalCallId,
9685
+ }),
9686
+ }
9687
+ : {}),
9490
9688
  playNodeScope: buildPlayNodeScope({
9491
9689
  toolId,
9492
9690
  callKey: normalizedKey,
@@ -9511,14 +9709,14 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9511
9709
  execution: toolExecutionMetadataForOutcome({
9512
9710
  kind: 'live',
9513
9711
  cacheKey: directCacheKey,
9514
- ...(cacheableToolResult
9712
+ ...(useDirectResultReceipts
9515
9713
  ? { receiptKey: resultReceiptKey }
9516
9714
  : {}),
9517
9715
  }),
9518
9716
  });
9519
9717
  },
9520
9718
  };
9521
- const toolCall = cacheableToolResult
9719
+ const toolCall = useDirectResultReceipts
9522
9720
  ? createToolCallJob({
9523
9721
  resultReceipts: createRuntimeStepToolResultReceipts({
9524
9722
  store: this.durableReceiptExecutionStore(),
@@ -9526,7 +9724,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9526
9724
  ...(initialDirectReceipt !== undefined
9527
9725
  ? {
9528
9726
  initialReceipt: {
9529
- key: directCacheKey,
9727
+ key: directResultReceiptKey,
9530
9728
  receipt: initialDirectReceipt,
9531
9729
  },
9532
9730
  }
@@ -9537,6 +9735,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9537
9735
  ? {
9538
9736
  executionLock: {
9539
9737
  required: true,
9738
+ key: providerOperationKey,
9540
9739
  ttlMs: Math.min(
9541
9740
  600_000,
9542
9741
  resolveToolRuntimeTimeoutMs(
@@ -9599,11 +9798,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9599
9798
  })
9600
9799
  : await createAlwaysFreshToolCallAdapter({ dispatcher }).call(call);
9601
9800
  settleDirectCall(toolAttemptOutcomeForResult(wrapped));
9602
- if (cacheableToolResult) {
9603
- this.cacheToolResult(toolId, directCacheKey, wrapped, 'direct');
9801
+ if (useDirectResultReceipts) {
9802
+ this.cacheToolResult(
9803
+ toolId,
9804
+ directResultReceiptKey,
9805
+ wrapped,
9806
+ 'direct',
9807
+ );
9604
9808
  this.#options.onBatchComplete?.(this.checkpoint);
9605
9809
  }
9606
- return wrapped;
9810
+ return this.materializeToolExecutionResult(wrapped);
9607
9811
  } catch (error) {
9608
9812
  settleDirectCall('failed', error);
9609
9813
  throw error;
@@ -9637,17 +9841,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9637
9841
  : this.getCachedToolResultCandidate(toolId, checkpointCacheKeys);
9638
9842
  if (cached) {
9639
9843
  this.runtimeLog(` Row ${rowId} ${toolId}: recovered from checkpoint`);
9640
- return await this.wrapToolExecutionResult({
9641
- toolId,
9642
- status: cached.result.result == null ? 'no_result' : 'completed',
9643
- result: cached.result.result,
9644
- requestInput: input,
9645
- execution: toolExecutionMetadataForOutcome({
9646
- kind: 'checkpoint',
9647
- cacheKey: cached.cacheKey,
9844
+ return this.materializeToolExecutionResult(
9845
+ await this.wrapToolExecutionResult({
9846
+ toolId,
9847
+ status: cached.result.result == null ? 'no_result' : 'completed',
9848
+ result: cached.result.result,
9849
+ requestInput: input,
9850
+ execution: toolExecutionMetadataForOutcome({
9851
+ kind: 'checkpoint',
9852
+ cacheKey: cached.cacheKey,
9853
+ }),
9648
9854
  }),
9649
- });
9855
+ );
9650
9856
  }
9857
+ const rowToolRetryPolicy = await this.#options
9858
+ .getToolRetryPolicy?.(toolId, input)
9859
+ .catch(() => null);
9651
9860
 
9652
9861
  // ADR 0018: record this leg before it runs. `producer` still names the
9653
9862
  // last leg (the usual cascade winner); `producers` keeps every leg, which
@@ -9702,7 +9911,12 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9702
9911
  providerIdempotencyKeyBase,
9703
9912
  cacheable: cacheableToolResult,
9704
9913
  receiptKey: cacheableToolResult ? durableCacheKey : null,
9914
+ legacyReceiptKeys: legacyDurableCacheKey
9915
+ ? [legacyDurableCacheKey]
9916
+ : [],
9705
9917
  executionAuthScopeDigest,
9918
+ requiresExecutionFence:
9919
+ rowToolRetryPolicy?.requiresExecutionFence === true,
9706
9920
  force: toolCachePolicy.force,
9707
9921
  forceFailedRefresh: toolCachePolicy.forceFailedRefresh,
9708
9922
  rowId,
@@ -9722,7 +9936,9 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9722
9936
  return await pendingToolCall.then(
9723
9937
  (value) => {
9724
9938
  settleProducerAttempt(toolAttemptOutcomeForResult(value));
9725
- return value;
9939
+ return this.materializeToolExecutionResult(
9940
+ value as ToolExecuteResult,
9941
+ );
9726
9942
  },
9727
9943
  (error: unknown) => {
9728
9944
  settleProducerAttempt('failed');
@@ -9771,16 +9987,21 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9771
9987
  executionAuthScopeDigest,
9772
9988
  staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9773
9989
  });
9774
- providerIdempotencyKeyBase = await this.durableToolCallCacheKey(
9775
- {
9776
- toolId,
9777
- requestInput: input,
9778
- executionAuthScopeDigest,
9779
- staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9780
- },
9781
- null,
9990
+ legacyDurableCacheKey = await this.legacyDurableToolCallCacheKey({
9991
+ toolId,
9992
+ requestInput: input,
9993
+ executionAuthScopeDigest,
9994
+ staleAfterSeconds: toolCachePolicy.staleAfterSeconds,
9995
+ });
9996
+ providerIdempotencyKeyBase = durableCacheKey;
9997
+ directResultReceiptKey = durableCacheKey;
9998
+ bypassDirectResultCache = false;
9999
+ checkpointCacheKeys.splice(
10000
+ 0,
10001
+ checkpointCacheKeys.length,
10002
+ durableCacheKey,
10003
+ ...(legacyDurableCacheKey ? [legacyDurableCacheKey] : []),
9782
10004
  );
9783
- checkpointCacheKeys[0] = durableCacheKey;
9784
10005
  }
9785
10006
  }
9786
10007
 
@@ -9949,8 +10170,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9949
10170
  const childCompatibility = normalizePlayContractCompatibility(
9950
10171
  childCompatibilitySnapshot,
9951
10172
  );
9952
- const childToolErrorSchemaVersion =
9953
- childCompatibility.toolErrorSchemaVersion;
9954
10173
  const childToolResponseContract = childCompatibility.toolResponseContract;
9955
10174
  const childToolResponseReceiptRevision =
9956
10175
  childCompatibility.toolResponseReceiptRevision;
@@ -10025,11 +10244,12 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10025
10244
  governor: inlineChildGovernor,
10026
10245
  playName: resolvedName,
10027
10246
  staticPipeline: resolvedPlay.staticPipeline ?? null,
10028
- toolErrorSchemaVersion: childToolErrorSchemaVersion,
10029
- toolResponseContract: childToolResponseContract,
10030
- toolResponseReceiptRevision: childToolResponseReceiptRevision,
10031
- authoringContractEdition:
10032
- childCompatibility.authoringContractEdition,
10247
+ runtimeContract: resolvePlayArtifactRuntimeContract({
10248
+ authoringContractEdition:
10249
+ childCompatibility.authoringContractEdition,
10250
+ toolResponseContract: childToolResponseContract,
10251
+ toolResponseReceiptRevision: childToolResponseReceiptRevision,
10252
+ }),
10033
10253
  },
10034
10254
  () => this.executeResolvedPlay(resolvedPlay, this, input),
10035
10255
  );
@@ -10338,7 +10558,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10338
10558
  async sleep(ms: number): Promise<void> {
10339
10559
  this.assertInlineChildContract('suspending_child');
10340
10560
  const delayMs =
10341
- this.currentAuthoringContractEdition >= 2
10561
+ this.currentRuntimeSemantics.validation === 'strict'
10342
10562
  ? (validatePlayAuthoringField('ctx.sleep.ms', ms), ms)
10343
10563
  : Math.max(0, Math.round(ms));
10344
10564
  if (this.#options.durableBoundaries) {
@@ -10464,14 +10684,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10464
10684
  ...secretHeaderMarkers,
10465
10685
  },
10466
10686
  row: rowFetchScope,
10467
- // Edition 5 changes non-2xx responses from cached values to
10468
- // non-cacheable errors. Partition those receipts from editions
10469
- // 1–4 so a republished artifact makes a fresh request instead of
10470
- // inheriting a legacy error record.
10471
- ...(this.currentAuthoringContractEdition >= 5
10687
+ // A pinned fetch policy that turns non-2xx responses into typed,
10688
+ // non-cacheable errors needs its own receipt epoch. Preserve the
10689
+ // historical epoch selected by the artifact rather than deriving a
10690
+ // new cache key from every future authoring edition.
10691
+ ...(this.currentRuntimeSemantics.fetch.receiptIdentityEdition !==
10692
+ null
10472
10693
  ? {
10473
10694
  authoringContractEdition:
10474
- this.currentAuthoringContractEdition,
10695
+ this.currentRuntimeSemantics.fetch.receiptIdentityEdition,
10475
10696
  }
10476
10697
  : {}),
10477
10698
  }),
@@ -10487,7 +10708,11 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10487
10708
  httpStatus: output.status,
10488
10709
  });
10489
10710
  }
10490
- if (!output.ok && this.currentAuthoringContractEdition >= 5) {
10711
+ if (
10712
+ !output.ok &&
10713
+ this.currentRuntimeSemantics.fetch.httpFailure ===
10714
+ 'throw_typed_error'
10715
+ ) {
10491
10716
  throw new CtxFetchHttpError(output);
10492
10717
  }
10493
10718
  return output;
@@ -10495,7 +10720,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10495
10720
  shouldPersistFailure: (error) =>
10496
10721
  !(
10497
10722
  error instanceof CtxFetchHttpError &&
10498
- this.currentAuthoringContractEdition >= 5
10723
+ !this.currentRuntimeSemantics.fetch.persistHttpFailure
10499
10724
  ),
10500
10725
  execute: async ({ retainExternalCallSlot }) => {
10501
10726
  const secretHeaders = await this.resolveSecretAuth(secretAuth);
@@ -10699,11 +10924,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10699
10924
  });
10700
10925
  }
10701
10926
 
10702
- // Edition 5 adopts normal fetch semantics: a non-2xx response is
10703
- // a failed durable operation. Throw before checkpoint/receipt
10704
- // completion so the failure is never cached. Editions 1–4 retain
10705
- // their response-record behavior for the same upstream response.
10706
- if (!output.ok && this.currentAuthoringContractEdition >= 5) {
10927
+ // The artifact-pinned fetch policy decides whether a non-2xx
10928
+ // response is a returned record or a failed durable operation.
10929
+ // Throw before checkpoint/receipt completion so the throwing
10930
+ // policy never caches its failure.
10931
+ if (
10932
+ !output.ok &&
10933
+ this.currentRuntimeSemantics.fetch.httpFailure ===
10934
+ 'throw_typed_error'
10935
+ ) {
10707
10936
  throw new CtxFetchHttpError(output);
10708
10937
  }
10709
10938
 
@@ -11009,75 +11238,59 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11009
11238
  /**
11010
11239
  * Concrete RuntimeStepReceipt adapter for the map receipt-cohort Module.
11011
11240
  *
11012
- * It is intentionally shared by scalar and provider-native map calls. The
11013
- * two call shapes differ at provider dispatch, not at the member receipt
11014
- * protocol: both need an atomic claim/reclaim, a single cohort heartbeat,
11015
- * and lease-fenced terminal fanout. Keeping that at one seam prevents a
11016
- * native batch from becoming a second, subtly different receipt runtime.
11241
+ * Scalar and provider-native map calls share completed-result cache reads
11242
+ * and atomic successful inserts. A miss has no durable execution owner.
11017
11243
  */
11018
11244
  private createRuntimeStepToolCallReceiptCohortStore(input: {
11019
11245
  toolId: string;
11020
11246
  requestForReceipt: ReadonlyMap<string, ToolCallRequest>;
11021
11247
  }): ToolCallReceiptCohortStore {
11022
11248
  const asRecord = async (
11023
- receiptKey: string,
11249
+ resultReceiptKey: string,
11250
+ storedReceiptKey: string,
11024
11251
  receipt: RuntimeStepReceipt | null | undefined,
11025
11252
  ): Promise<ToolCallReceiptRecord | null> => {
11026
11253
  if (!receipt) return null;
11027
- const normalized = this.normalizeRuntimeStepReceipt(receiptKey, receipt);
11254
+ const normalized = this.normalizeRuntimeStepReceipt(
11255
+ storedReceiptKey,
11256
+ receipt,
11257
+ );
11028
11258
  if (!normalized) return null;
11029
- const request = input.requestForReceipt.get(receiptKey);
11259
+ if (
11260
+ normalized.status !== 'completed' &&
11261
+ normalized.status !== 'skipped'
11262
+ ) {
11263
+ return null;
11264
+ }
11265
+ // A terminal acknowledgement without output is not a cache hit. The
11266
+ // completion path hydrates it first; if that read cannot provide an
11267
+ // output, retain the live result rather than resolving `undefined`.
11268
+ if (normalized.output === undefined) return null;
11269
+ const request = input.requestForReceipt.get(resultReceiptKey);
11030
11270
  if (!request) {
11031
11271
  throw new Error(
11032
- `Unknown Tool Call Receipt Cohort receipt ${receiptKey}.`,
11272
+ `Unknown Tool Call Receipt Cohort receipt ${resultReceiptKey}.`,
11033
11273
  );
11034
11274
  }
11035
- const terminal =
11036
- normalized.status === 'completed' || normalized.status === 'skipped';
11037
- const result = terminal
11038
- ? await this.wrapToolExecutionResult({
11039
- toolId: input.toolId,
11040
- status:
11041
- normalized.output === null || normalized.output === undefined
11042
- ? 'no_result'
11043
- : 'completed',
11044
- result: this.runtimeReceiptOutput(normalized),
11045
- requestInput: request.input,
11046
- execution: toolExecutionMetadataForOutcome({
11047
- kind: 'cache',
11048
- cacheKey: request.cacheKey,
11049
- receiptKey,
11050
- attachedToReceiptKey: receiptKey,
11051
- }),
11052
- })
11053
- : undefined;
11275
+ const result = await this.wrapToolExecutionResult({
11276
+ toolId: input.toolId,
11277
+ status:
11278
+ normalized.output === null || normalized.output === undefined
11279
+ ? 'no_result'
11280
+ : 'completed',
11281
+ result: this.runtimeReceiptOutput(normalized),
11282
+ requestInput: request.input,
11283
+ execution: toolExecutionMetadataForOutcome({
11284
+ kind: 'cache',
11285
+ cacheKey: request.cacheKey,
11286
+ receiptKey: resultReceiptKey,
11287
+ attachedToReceiptKey: storedReceiptKey,
11288
+ }),
11289
+ });
11054
11290
  return {
11055
- resultReceiptKey: receiptKey,
11291
+ resultReceiptKey,
11056
11292
  status: normalized.status,
11057
- ...(result !== undefined ? { result } : {}),
11058
- ...(normalized.status === 'failed'
11059
- ? {
11060
- error: runtimeReceiptFailureError(
11061
- normalized,
11062
- 'Durable tool call failed',
11063
- this.currentToolErrorSchemaVersion,
11064
- ),
11065
- }
11066
- : {}),
11067
- ...(normalized.leaseId &&
11068
- (normalized.leaseOwnerRunId ?? normalized.runId)?.trim()
11069
- ? {
11070
- lease: {
11071
- leaseId: normalized.leaseId,
11072
- ownerRunId: (normalized.leaseOwnerRunId ??
11073
- normalized.runId)!.trim(),
11074
- },
11075
- }
11076
- : {}),
11077
- ...(normalized.leaseExpiresAt
11078
- ? { leaseExpiresAt: normalized.leaseExpiresAt }
11079
- : {}),
11080
- ...(normalized.claimState ? { claimState: normalized.claimState } : {}),
11293
+ result,
11081
11294
  };
11082
11295
  };
11083
11296
  const recordsFor = async (
@@ -11086,10 +11299,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11086
11299
  ): Promise<ToolCallReceiptRecord[]> =>
11087
11300
  (
11088
11301
  await Promise.all(
11089
- receiptKeys.map(
11090
- async (receiptKey) =>
11091
- await asRecord(receiptKey, receipts.get(receiptKey)),
11092
- ),
11302
+ receiptKeys.map(async (receiptKey) => {
11303
+ const request = input.requestForReceipt.get(receiptKey);
11304
+ const candidates = [
11305
+ receiptKey,
11306
+ ...(request?.legacyReceiptKeys ?? []),
11307
+ ];
11308
+ for (const candidate of candidates) {
11309
+ const record = await asRecord(
11310
+ receiptKey,
11311
+ candidate,
11312
+ receipts.get(candidate),
11313
+ );
11314
+ if (record) return record;
11315
+ }
11316
+ return null;
11317
+ }),
11093
11318
  )
11094
11319
  ).filter((record): record is ToolCallReceiptRecord => record !== null);
11095
11320
 
@@ -11099,83 +11324,17 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11099
11324
  async () =>
11100
11325
  await recordsFor(
11101
11326
  resultReceiptKeys,
11102
- await this.getRuntimeStepReceipts(resultReceiptKeys),
11327
+ await this.getRuntimeStepReceipts([
11328
+ ...new Set(
11329
+ resultReceiptKeys.flatMap((receiptKey) => [
11330
+ receiptKey,
11331
+ ...(input.requestForReceipt.get(receiptKey)
11332
+ ?.legacyReceiptKeys ?? []),
11333
+ ]),
11334
+ ),
11335
+ ]),
11103
11336
  ),
11104
11337
  ),
11105
- claim: async ({ receipts }) => {
11106
- const claims = new Map<string, RuntimeStepReceipt>();
11107
- const groups = new Map<string, Array<(typeof receipts)[number]>>();
11108
- for (const receipt of receipts) {
11109
- const groupKey = [
11110
- receipt.reclaimRunning ? 'reclaim' : 'ordinary',
11111
- receipt.force ? 'force' : 'cached',
11112
- receipt.forceFailedRefresh ? 'failed-force' : 'failed-cached',
11113
- ].join(':');
11114
- const group = groups.get(groupKey) ?? [];
11115
- group.push(receipt);
11116
- groups.set(groupKey, group);
11117
- }
11118
- for (const group of groups.values()) {
11119
- const first = group[0]!;
11120
- const claimed = await this.claimRuntimeStepReceipts(
11121
- group.map((receipt) => receipt.resultReceiptKey),
11122
- this.currentReceiptOwnerRunId,
11123
- first.reclaimRunning,
11124
- first.force,
11125
- first.forceFailedRefresh,
11126
- );
11127
- for (const [key, receipt] of claimed) claims.set(key, receipt);
11128
- }
11129
- const safeFailedKeys = receipts.flatMap((receipt) => {
11130
- const claimed = claims.get(receipt.resultReceiptKey);
11131
- return claimed && this.canRetryWithoutProviderDispatch(claimed)
11132
- ? [receipt.resultReceiptKey]
11133
- : [];
11134
- });
11135
- if (safeFailedKeys.length > 0) {
11136
- const refreshed = await this.claimRuntimeStepReceipts(
11137
- safeFailedKeys,
11138
- this.currentReceiptOwnerRunId,
11139
- false,
11140
- false,
11141
- true,
11142
- );
11143
- for (const [key, receipt] of refreshed) claims.set(key, receipt);
11144
- }
11145
- return await recordsFor(
11146
- receipts.map((receipt) => receipt.resultReceiptKey),
11147
- claims,
11148
- );
11149
- },
11150
- heartbeat: async ({ receipts }) => {
11151
- if (!this.#options.heartbeatRuntimeStepReceipts) {
11152
- return await recordsFor(
11153
- receipts.map((receipt) => receipt.resultReceiptKey),
11154
- await this.getRuntimeStepReceipts(
11155
- receipts.map((receipt) => receipt.resultReceiptKey),
11156
- ),
11157
- );
11158
- }
11159
- const response = await this.#options.heartbeatRuntimeStepReceipts({
11160
- runId: this.currentReceiptOwnerRunId,
11161
- runAttempt: this.currentRunAttempt,
11162
- keys: receipts.map((receipt) => receipt.resultReceiptKey),
11163
- leaseIds: receipts.map((receipt) => receipt.lease.leaseId),
11164
- });
11165
- const byKey = new Map<string, RuntimeStepReceipt>();
11166
- for (let index = 0; index < receipts.length; index += 1) {
11167
- const request = receipts[index]!;
11168
- const normalized = this.normalizeRuntimeStepReceipt(
11169
- request.resultReceiptKey,
11170
- response[index],
11171
- );
11172
- if (normalized) byKey.set(request.resultReceiptKey, normalized);
11173
- }
11174
- return await recordsFor(
11175
- receipts.map((receipt) => receipt.resultReceiptKey),
11176
- byKey,
11177
- );
11178
- },
11179
11338
  complete: async ({ receipts }) => {
11180
11339
  const completed = await this.completeRuntimeStepReceipts(
11181
11340
  receipts.map((receipt) => ({
@@ -11190,33 +11349,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11190
11349
  completed,
11191
11350
  );
11192
11351
  },
11193
- fail: async ({ receipts }) => {
11194
- const failed = await this.failRuntimeStepReceipts(
11195
- receipts.map((receipt) => ({
11196
- key: receipt.resultReceiptKey,
11197
- runId: this.currentReceiptOwnerRunId,
11198
- leaseId: receipt.lease.leaseId,
11199
- error: this.formatRuntimeError(receipt.error),
11200
- errorPayload: serializeToolExecutionFailure(receipt.error),
11201
- failureKind: runtimeReceiptFailureKindForError(receipt.error),
11202
- })),
11203
- );
11204
- return await recordsFor(
11205
- receipts.map((receipt) => receipt.resultReceiptKey),
11206
- failed,
11207
- );
11208
- },
11209
11352
  };
11210
11353
  }
11211
11354
 
11212
11355
  /**
11213
- * The scalar map path used to duplicate the receipt state machine in
11214
- * `executeBatchedToolCalls`: one loop claimed/reclaimed/followed rows and a
11215
- * later loop heartbeated and completed them. That made it too easy for the
11216
- * Tool Call boundary to be only cosmetic. This is the narrow replacement
11217
- * slice: cacheable members of a *non-native* map hand their whole receipt
11218
- * lifecycle to the cohort Module, while this Context remains only the
11219
- * transport/presentation Adapter.
11356
+ * Cacheable scalar map members share completed-result reads and successful
11357
+ * writes through the cohort Module. This Context only transports and
11358
+ * presents results.
11220
11359
  *
11221
11360
  * Do not widen this to physical native batches or always-fresh calls. A
11222
11361
  * native batch owns a different aggregate operation receipt, and
@@ -11318,7 +11457,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11318
11457
  const resolve = async (
11319
11458
  request: ToolCallRequest,
11320
11459
  result: ToolExecuteResult,
11321
- source: 'cache' | 'in_flight' | 'owner',
11460
+ source: 'cache' | 'owner',
11322
11461
  ): Promise<void> => {
11323
11462
  const receiptKey = request.receiptKey!;
11324
11463
  const finalResult = cloneToolExecuteResultWithExecution(
@@ -11353,180 +11492,111 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11353
11492
  };
11354
11493
  const reject = async (request: ToolCallRequest, error: unknown) => {
11355
11494
  await this.rejectToolCall(toolId, request, error, {
11356
- // ToolCallReceiptCohort is the one failure writer. Re-entering the
11357
- // legacy writer here would race its member terminal transition.
11495
+ // Failures are never cache entries, including failed local misses.
11358
11496
  persistReceiptFailure: false,
11359
11497
  });
11360
11498
  };
11361
11499
 
11362
- try {
11363
- const handled = new Set<string>();
11364
- const followOrRecover: Promise<void>[] = [];
11365
- const collectReadyMembers = (): Array<{
11500
+ const handled = new Set<string>();
11501
+ const followerResults: Promise<void>[] = [];
11502
+ const collectReadyMembers = (): Array<{
11503
+ request: ToolCallRequest;
11504
+ receipts: ToolResultReceipts;
11505
+ }> => {
11506
+ const owned: Array<{
11366
11507
  request: ToolCallRequest;
11367
11508
  receipts: ToolResultReceipts;
11368
- }> => {
11369
- const owned: Array<{
11370
- request: ToolCallRequest;
11371
- receipts: ToolResultReceipts;
11372
- }> = [];
11373
- for (const request of requests) {
11374
- if (handled.has(request.callId)) continue;
11375
- const member = cohort.member(request.callId);
11376
- if (member.kind === 'recovering') continue;
11377
- handled.add(request.callId);
11378
- if (member.kind === 'completed') {
11379
- followOrRecover.push(
11380
- resolve(request, member.result, member.source),
11381
- );
11382
- } else if (member.kind === 'failed') {
11383
- followOrRecover.push(reject(request, member.error));
11384
- } else if (member.kind === 'following') {
11385
- followOrRecover.push(
11386
- member.result.then(
11387
- async (result) => await resolve(request, result, 'in_flight'),
11388
- async (error: unknown) => await reject(request, error),
11389
- ),
11390
- );
11391
- } else {
11392
- request.receiptLeaseId = member.lease.leaseId;
11393
- request.receiptLeaseExpiresAt = member.leaseExpiresAt ?? null;
11394
- owned.push({ request, receipts: member.receipts });
11395
- }
11509
+ }> = [];
11510
+ for (const request of requests) {
11511
+ if (handled.has(request.callId)) continue;
11512
+ const member = cohort.member(request.callId);
11513
+ handled.add(request.callId);
11514
+ if (member.kind === 'completed') {
11515
+ followerResults.push(resolve(request, member.result, member.source));
11516
+ } else if (member.kind === 'following') {
11517
+ followerResults.push(
11518
+ member.result.then(
11519
+ async (result) => await resolve(request, result, 'owner'),
11520
+ async (error: unknown) => await reject(request, error),
11521
+ ),
11522
+ );
11523
+ } else {
11524
+ owned.push({ request, receipts: member.receipts });
11396
11525
  }
11397
- return owned;
11398
- };
11526
+ }
11527
+ return owned;
11528
+ };
11399
11529
 
11400
- const width = Math.min(
11401
- this.governor.policy.concurrency.toolCalls,
11402
- Math.max(
11403
- 1,
11404
- await this.resourceGovernor.suggestedToolParallelism(
11405
- toolId,
11406
- this.governor.policy.concurrency.toolCalls,
11407
- ),
11530
+ const width = Math.min(
11531
+ this.governor.policy.concurrency.toolCalls,
11532
+ Math.max(
11533
+ 1,
11534
+ await this.resourceGovernor.suggestedToolParallelism(
11535
+ toolId,
11536
+ this.governor.policy.concurrency.toolCalls,
11408
11537
  ),
11409
- );
11410
- const dispatchOwned = async (
11411
- owned: Array<{
11412
- request: ToolCallRequest;
11413
- receipts: ToolResultReceipts;
11414
- }>,
11415
- ) =>
11416
- await dispatchBoundedSettled(
11417
- owned,
11418
- width,
11419
- async ({ request, receipts }) => {
11420
- if (this.persistenceLatch.tripped) {
11421
- const error = new RuntimePersistenceCircuitOpenError(
11422
- this.persistenceLatch,
11423
- );
11424
- await cohort.fail([{ memberId: request.callId, error }]);
11425
- throw error;
11426
- }
11427
- let releaseToolSlot: () => void = () => undefined;
11428
- try {
11429
- const result = await this.executeMappedToolCallThroughModule({
11430
- request,
11431
- dispatchToolId: toolId,
11432
- dispatchPayload: request.input,
11433
- resultReceiptKey: request.receiptKey!,
11434
- providerOperationKey:
11435
- request.providerIdempotencyKeyBase ?? request.receiptKey!,
11436
- resultReceipts: receipts,
11437
- apiOptions: {
11438
- beforeProviderCall: () => {
11439
- if (cohortLeaseLost) throw cohortLeaseLost;
11440
- heartbeat.assertOwned();
11441
- },
11442
- heartbeatReceipt: async () => {
11443
- try {
11444
- const outcome = await cohort.heartbeat();
11445
- if (outcome === 'terminal') {
11446
- throw new ToolCallReceiptLeaseLostError(
11447
- request.receiptKey!,
11448
- );
11449
- }
11450
- } catch (error) {
11451
- if (error instanceof ToolCallReceiptLeaseLostError) {
11452
- throw new RuntimeReceiptLeaseLostError({
11453
- receiptKey: request.receiptKey!,
11454
- runId: this.currentReceiptOwnerRunId,
11455
- leaseId:
11456
- request.receiptLeaseId ?? 'missing-cohort-lease',
11457
- });
11458
- }
11459
- throw error;
11460
- }
11461
- },
11462
- playNodeScope: playNodeScopeForToolCallRequest(request),
11463
- durableCallReceiptKey: request.receiptKey,
11464
- executionAuthScopeDigest: request.executionAuthScopeDigest,
11465
- providerIdempotencyReceiptKey:
11466
- request.providerIdempotencyKeyBase ?? request.receiptKey,
11467
- providerIdempotencyKey:
11468
- this.providerIdempotencyKeyForToolCall({
11469
- cacheKey:
11470
- request.providerIdempotencyKeyBase ??
11471
- request.receiptKey!,
11472
- force: request.force === true,
11473
- leaseId: request.receiptLeaseId,
11474
- logicalCallId: request.logicalCallId,
11475
- }),
11476
- receiptLeaseExpiresAt: request.receiptLeaseExpiresAt,
11477
- timeoutMs:
11478
- typeof request.timeoutMs === 'number' &&
11479
- request.timeoutMs > 0
11480
- ? request.timeoutMs
11481
- : undefined,
11482
- retainToolSlot: (release) => {
11483
- releaseToolSlot = release;
11484
- },
11538
+ ),
11539
+ );
11540
+ const dispatchOwned = async (
11541
+ owned: Array<{
11542
+ request: ToolCallRequest;
11543
+ receipts: ToolResultReceipts;
11544
+ }>,
11545
+ ) =>
11546
+ await dispatchBoundedSettled(
11547
+ owned,
11548
+ width,
11549
+ async ({ request, receipts }) => {
11550
+ if (this.persistenceLatch.tripped) {
11551
+ const error = new RuntimePersistenceCircuitOpenError(
11552
+ this.persistenceLatch,
11553
+ );
11554
+ throw error;
11555
+ }
11556
+ let releaseToolSlot: () => void = () => undefined;
11557
+ try {
11558
+ const result = await this.executeMappedToolCallThroughModule({
11559
+ request,
11560
+ dispatchToolId: toolId,
11561
+ dispatchPayload: request.input,
11562
+ resultReceiptKey: request.receiptKey!,
11563
+ providerOperationKey:
11564
+ request.providerIdempotencyKeyBase ?? request.receiptKey!,
11565
+ resultReceipts: receipts,
11566
+ apiOptions: {
11567
+ playNodeScope: playNodeScopeForToolCallRequest(request),
11568
+ durableCallReceiptKey: request.receiptKey,
11569
+ completedResultCache: true,
11570
+ executionAuthScopeDigest: request.executionAuthScopeDigest,
11571
+ timeoutMs:
11572
+ typeof request.timeoutMs === 'number' && request.timeoutMs > 0
11573
+ ? request.timeoutMs
11574
+ : undefined,
11575
+ retainToolSlot: (release) => {
11576
+ releaseToolSlot = release;
11485
11577
  },
11486
- });
11487
- await resolve(request, result, 'owner');
11488
- } catch (error) {
11489
- await reject(request, error);
11490
- } finally {
11491
- releaseToolSlot();
11492
- }
11493
- },
11494
- );
11578
+ },
11579
+ });
11580
+ await resolve(request, result, 'owner');
11581
+ } catch (error) {
11582
+ await reject(request, error);
11583
+ } finally {
11584
+ releaseToolSlot();
11585
+ }
11586
+ },
11587
+ );
11495
11588
 
11496
- // Eager cohort acquisition makes the initial claim available before a
11497
- // foreign receipt's read/reclaim path completes. This preserves map
11498
- // overlap: a broken foreign read must not strand an already-owned
11499
- // provider operation behind it.
11500
- const initialDispatch = dispatchOwned(collectReadyMembers());
11501
- const settlement = cohort.settle();
11502
- const [initialResult, settlementResult] = await Promise.allSettled([
11503
- initialDispatch,
11504
- settlement,
11505
- ]);
11506
- // A recovery can reclaim an expired receipt and turn it into another
11507
- // owned member. Collect its final state after the initial work settles.
11508
- const recoveredDispatch = await dispatchOwned(collectReadyMembers());
11509
- await Promise.allSettled(followOrRecover);
11510
- // dispatchBoundedSettled intentionally captures provider failures; all
11511
- // affected row resolvers have already been rejected above.
11512
- void initialResult;
11513
- void recoveredDispatch;
11514
- if (settlementResult.status === 'rejected') {
11515
- throw settlementResult.reason;
11516
- }
11517
- } finally {
11518
- heartbeat.stop();
11519
- }
11589
+ await dispatchOwned(collectReadyMembers());
11590
+ await Promise.allSettled(followerResults);
11520
11591
  return successful;
11521
11592
  }
11522
11593
 
11523
11594
  /**
11524
11595
  * Executes provider-native batches through the Native Batch Tool Call
11525
- * Module. Unlike the compatibility path below, the Module owns the member
11526
- * receipt cohort from claim through recovery, heartbeat and terminal split
11527
- * fanout. This method is only the runtime Adapter: it compiles provider
11528
- * payloads, invokes transport, and presents already-terminal member values
11529
- * to map rows.
11596
+ * Module. The Module reads exact completed-result cache entries, batches
11597
+ * misses, then publishes each successful split result. This Context only
11598
+ * compiles provider payloads, invokes transport, and presents terminal
11599
+ * member values to map rows.
11530
11600
  */
11531
11601
  private async executeCacheableNativeMapToolCallCohort(input: {
11532
11602
  toolId: string;
@@ -11581,9 +11651,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11581
11651
  batchParallelismCeiling,
11582
11652
  )
11583
11653
  : batchParallelismCeiling;
11584
- const batchProviderActionVersion =
11585
- (await this.#options.getToolActionCacheVersion?.(toolId))?.trim() ??
11586
- 'unversioned';
11587
11654
  const successful: ToolCallRequest[] = [];
11588
11655
 
11589
11656
  const deliver = (request: ToolCallRequest, result: ToolExecuteResult) => {
@@ -11613,7 +11680,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11613
11680
  };
11614
11681
  const reject = async (request: ToolCallRequest, error: unknown) => {
11615
11682
  await this.rejectToolCall(toolId, request, error, {
11616
- // NativeBatchToolCall owns the durable member failure transition.
11683
+ // Failed provider calls are never cache entries.
11617
11684
  persistReceiptFailure: false,
11618
11685
  });
11619
11686
  };
@@ -11675,15 +11742,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11675
11742
  call.payload,
11676
11743
  {
11677
11744
  durableCallReceiptKey: compatibility.resultReceiptKey,
11745
+ completedResultCache: true,
11678
11746
  playNodeScope: playNodeScopeForToolCallRequest(
11679
11747
  detail.members[0]!,
11680
11748
  call.tool.id,
11681
11749
  ),
11682
11750
  executionAuthScopeDigest:
11683
11751
  detail.members[0]?.executionAuthScopeDigest ?? null,
11684
- providerIdempotencyReceiptKey:
11685
- detail.providerOperationKey,
11686
- providerIdempotencyKey: detail.providerOperationKey,
11687
11752
  receiptLeaseExpiresAt: detail.receiptLeaseExpiresAt,
11688
11753
  timeoutMs: detail.timeoutMs,
11689
11754
  },
@@ -11751,8 +11816,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11751
11816
  },
11752
11817
  compatibility: {
11753
11818
  resultReceiptKey: request.receiptKey!,
11754
- providerOperationKey:
11755
- request.providerIdempotencyKeyBase ?? request.receiptKey!,
11819
+ providerOperationKey: request.receiptKey!,
11756
11820
  },
11757
11821
  },
11758
11822
  })),
@@ -11775,27 +11839,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11775
11839
  'Native batch owned members changed provider operation during compilation.',
11776
11840
  );
11777
11841
  }
11778
- const providerOperationKey =
11779
- buildDurableToolPayloadProviderIdempotencyKey({
11780
- orgId: this.#options.orgId,
11781
- toolId: batch.batchOperation,
11782
- executionAuthScopeDigest:
11783
- owned[0]?.executionAuthScopeDigest ?? null,
11784
- providerActionVersion: batchProviderActionVersion,
11785
- payload: compiled.batchPayload,
11786
- forceRefreshIdentities: owned
11787
- .filter((request) => request.force === true)
11788
- .map((request) =>
11789
- this.providerIdempotencyKeyForToolCall({
11790
- cacheKey:
11791
- request.providerIdempotencyKeyBase ??
11792
- request.receiptKey!,
11793
- force: true,
11794
- leaseId: request.receiptLeaseId,
11795
- logicalCallId: request.logicalCallId,
11796
- }),
11797
- ),
11798
- });
11842
+ const providerOperationKey = aggregateReceiptKey;
11799
11843
  dispatchDetails.set(aggregateReceiptKey, {
11800
11844
  members: owned,
11801
11845
  compiled,
@@ -11885,8 +11929,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11885
11929
  toolResponse: publicToolResponseForBatchedItem(
11886
11930
  aggregate,
11887
11931
  item,
11888
- this.currentToolResponseContract ===
11889
- RAW_V2_TOOL_RESPONSE_CONTRACT,
11932
+ true,
11890
11933
  ),
11891
11934
  requestInput: request.input,
11892
11935
  execution: toolExecutionMetadataForOutcome({
@@ -11899,60 +11942,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11899
11942
  }),
11900
11943
  );
11901
11944
  },
11902
- heartbeat: {
11903
- // Claim replies carry the real expiry. A fixed fallback interval
11904
- // can be too slow for a short-lived gateway lease, so derive the
11905
- // one cohort supervisor from the earliest owned member instead.
11906
- intervalMs: ({ members }) =>
11907
- Math.max(
11908
- 1,
11909
- Math.floor(
11910
- runtimeLeaseHeartbeatIntervalFromExpiry({
11911
- leaseExpiresAt: members.reduce<string | null>(
11912
- (earliest, member) => {
11913
- const expiresAt = member.leaseExpiresAt ?? null;
11914
- if (!expiresAt) return earliest;
11915
- if (!earliest) return expiresAt;
11916
- return Date.parse(expiresAt) < Date.parse(earliest)
11917
- ? expiresAt
11918
- : earliest;
11919
- },
11920
- null,
11921
- ),
11922
- fallbackTtlMs: PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
11923
- }) / 2,
11924
- ),
11925
- ),
11926
- onTransientFailure: (error) => {
11927
- this.runtimeLog(
11928
- `Native Tool Call Receipt Cohort heartbeat transport failed; retrying: ${
11929
- error instanceof Error ? error.message : String(error)
11930
- }`,
11931
- );
11932
- },
11933
- },
11934
11945
  });
11935
11946
  return { batch, result };
11936
11947
  },
11937
11948
  onChunkComplete: async (entries) => {
11938
11949
  for (const entry of entries) {
11939
11950
  if (entry.error !== undefined) {
11940
- // The cohort may have recovered each member through its
11941
- // lease-fenced single-receipt fallback, but that only prevents
11942
- // duplicate provider work. It must not convert a failed bulk
11943
- // persistence boundary into a successful map result: callers need
11944
- // the loud failure so this run can be retried from the durable
11945
- // receipts instead of continuing after an ambiguous bulk write.
11951
+ // A failed batch is not a cache entry. Surface it to every row so
11952
+ // a later run can make a fresh provider attempt.
11946
11953
  for (const request of entry.request.memberRequests) {
11947
11954
  await reject(request, entry.error);
11948
11955
  }
11949
11956
  throw entry.error;
11950
11957
  }
11951
11958
  if (!entry.result) {
11952
- // The NativeBatchToolCall already attempted a lease-fenced failure
11953
- // fanout. Read the actual durable terminal facts so cache hits in
11954
- // the same cohort still reach their rows and a stale failure never
11955
- // causes this Context to write a second terminal transition.
11959
+ // Successful split results must be present in the completed cache
11960
+ // before they are delivered to rows.
11956
11961
  const durable = await store.read({
11957
11962
  resultReceiptKeys: entry.request.memberRequests.map(
11958
11963
  (request) => request.receiptKey!,
@@ -11973,8 +11978,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
11973
11978
  } else {
11974
11979
  await reject(
11975
11980
  request,
11976
- record?.error ??
11977
- entry.error ??
11981
+ entry.error ??
11978
11982
  new Error(
11979
11983
  'Native Tool Call did not reach a terminal receipt.',
11980
11984
  ),
@@ -12198,6 +12202,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
12198
12202
  const cohortRequests = pendingRequests.filter(
12199
12203
  (request) =>
12200
12204
  request.cacheable !== false &&
12205
+ request.requiresExecutionFence !== true &&
12201
12206
  Boolean(request.receiptKey?.trim()),
12202
12207
  );
12203
12208
  if (cohortRequests.length > 0) {
@@ -12230,6 +12235,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
12230
12235
  const cohortRequests = pendingRequests.filter(
12231
12236
  (request) =>
12232
12237
  request.cacheable !== false &&
12238
+ request.requiresExecutionFence !== true &&
12233
12239
  Boolean(request.receiptKey?.trim()),
12234
12240
  );
12235
12241
  if (cohortRequests.length > 0) {
@@ -13056,8 +13062,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13056
13062
  : publicToolResponseForBatchedItem(
13057
13063
  batchExecution.execution,
13058
13064
  splitResults[index] ?? null,
13059
- this.currentToolResponseContract ===
13060
- RAW_V2_TOOL_RESPONSE_CONTRACT,
13065
+ true,
13061
13066
  ),
13062
13067
  }),
13063
13068
  ),
@@ -13213,6 +13218,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13213
13218
  ...(request.receiptKey
13214
13219
  ? {
13215
13220
  durableCallReceiptKey: request.receiptKey,
13221
+ completedResultCache: true,
13216
13222
  executionAuthScopeDigest:
13217
13223
  request.executionAuthScopeDigest,
13218
13224
  providerIdempotencyReceiptKey:
@@ -13675,6 +13681,19 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13675
13681
  .catch(() => null);
13676
13682
  const retrySafeTransientHttp =
13677
13683
  retryPolicy?.retrySafeTransientHttp === true;
13684
+ const requiresExecutionFence =
13685
+ retryPolicy?.requiresExecutionFence === true;
13686
+ const fixtureReplaySafe = this.#options.integrationMode === 'fixture';
13687
+ const readOnlyQuery = isQueryResultDatasetReadRequest(
13688
+ toolId,
13689
+ toolInputSnapshot,
13690
+ );
13691
+ // A completed result cache eliminates repeated successful work; it
13692
+ // does not prove an ambiguous provider write can be replayed. Only
13693
+ // a read-only query or an action which explicitly declares transient
13694
+ // retries safe may issue another physical request after dispatch.
13695
+ const ambiguousTransportReplayAllowed =
13696
+ readOnlyQuery || retrySafeTransientHttp;
13678
13697
  const invocationOrgId = this.#options.orgId?.trim() ?? '';
13679
13698
  const durableCallReceiptKey =
13680
13699
  options?.durableCallReceiptKey?.trim() ||
@@ -13689,26 +13708,25 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13689
13708
  const executionAuthScopeDigest = options?.executionAuthScopeDigest
13690
13709
  ? options.executionAuthScopeDigest.trim() || null
13691
13710
  : ((await this.resolveToolAuthScopeDigest(toolId))?.trim() ?? null);
13692
- const canonicalOperation = {
13693
- version: 1 as const,
13694
- tool_id: toolId,
13695
- action_version:
13696
- (
13697
- await this.#options.getToolActionCacheVersion?.(toolId)
13698
- )?.trim() || 'unversioned',
13699
- play_scope: this.currentGovernance.currentPlayId,
13700
- output_intent: 'dataset',
13701
- };
13702
- const providerIdempotencyKey =
13703
- options?.providerIdempotencyKey?.trim() || durableCallReceiptKey;
13711
+ const executionRequestNonce = crypto.randomUUID();
13712
+ // This identifies one logical tool call to billing. Physical retry
13713
+ // count remains in request metadata, so a transport retry cannot
13714
+ // reserve or charge the same call twice.
13715
+ const deeplineRequestId = `ctx-tool-${stableDigest(executionRequestNonce).slice(0, 32)}`;
13716
+ // The provider operation has a semantic identity independent of
13717
+ // the response receipt. Its key is supplied by the caller that owns
13718
+ // that compatibility contract; never substitute request/billing
13719
+ // attribution for it.
13704
13720
  const providerIdempotencyReceiptKey =
13705
- options?.providerIdempotencyReceiptKey?.trim() ||
13706
- durableCallReceiptKey;
13707
- // Correlation identity is stable across every transport retry,
13708
- // including calls without a durable receipt.
13709
- const deeplineRequestId = providerIdempotencyKey
13710
- ? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
13711
- : `ctx-tool-${crypto.randomUUID()}`;
13721
+ options?.providerIdempotencyReceiptKey?.trim() || null;
13722
+ const providerExecutionFenceKey = requiresExecutionFence
13723
+ ? options?.providerIdempotencyKey?.trim() || null
13724
+ : null;
13725
+ if (requiresExecutionFence && !providerExecutionFenceKey) {
13726
+ throw new Error(
13727
+ `Tool ${toolId} requires a provider execution fence but no provider operation identity was supplied.`,
13728
+ );
13729
+ }
13712
13730
  // Every runtime provider call carries at least a tool-scoped
13713
13731
  // attribution. Callers that know the execution location (row-scoped
13714
13732
  // map cells, batched owners) supply the fuller scope.
@@ -13723,16 +13741,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13723
13741
  ...(requestsDurableInvocationFence
13724
13742
  ? { invocation_fence_version: 1 }
13725
13743
  : {}),
13726
- // Canonical provider-operation receipts validate this semantic
13727
- // plan before replay. It is intentionally removed from the
13728
- // legacy byte-receipt fingerprint so mixed runner artifacts
13729
- // retain their existing provider-once behavior.
13730
- ...(requestsDurableInvocationFence
13731
- ? { canonical_operation: canonicalOperation }
13732
- : {}),
13733
- // The worker-owned receipt coordinates this physical durable
13734
- // call. Provider idempotency is carried separately below so
13735
- // exact-payload reuse never replaces batch receipt ownership.
13736
13744
  ...(durableCallReceiptKey
13737
13745
  ? {
13738
13746
  durable_call_receipt_key: durableCallReceiptKey,
@@ -13744,10 +13752,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13744
13752
  : {}),
13745
13753
  }
13746
13754
  : {}),
13747
- ...(providerIdempotencyReceiptKey
13755
+ ...(providerExecutionFenceKey
13748
13756
  ? {
13749
- provider_idempotency_receipt_key:
13750
- providerIdempotencyReceiptKey,
13757
+ provider_idempotency_key: providerExecutionFenceKey,
13758
+ ...(providerIdempotencyReceiptKey
13759
+ ? {
13760
+ provider_idempotency_receipt_key:
13761
+ providerIdempotencyReceiptKey,
13762
+ }
13763
+ : {}),
13751
13764
  }
13752
13765
  : {}),
13753
13766
  ...(options?.customerDbDataset
@@ -13770,9 +13783,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13770
13783
  : {}),
13771
13784
  }
13772
13785
  : {}),
13773
- ...(providerIdempotencyKey
13774
- ? { provider_idempotency_key: providerIdempotencyKey }
13775
- : {}),
13776
13786
  ...(playNodeScope
13777
13787
  ? { play_node_scope: playNodeScopeToWire(playNodeScope) }
13778
13788
  : {}),
@@ -13789,6 +13799,8 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13789
13799
  elapsedMs: number;
13790
13800
  requestId: string | null;
13791
13801
  aborted: boolean;
13802
+ /** The ingress rejected the request before any provider dispatch. */
13803
+ knownNotDispatched?: boolean;
13792
13804
  }): Promise<void> => {
13793
13805
  transportAttempt += 1;
13794
13806
  const diagnostic = describeTransportError(input.error);
@@ -13804,7 +13816,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13804
13816
  error: diagnostic,
13805
13817
  })}`,
13806
13818
  );
13807
- if (transportAttempt < TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS) {
13819
+ if (
13820
+ transportAttempt < TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS &&
13821
+ (input.knownNotDispatched === true ||
13822
+ ambiguousTransportReplayAllowed ||
13823
+ fixtureReplaySafe)
13824
+ ) {
13825
+ invocationAttempt += 1;
13808
13826
  await this.governor.chargeBudget('retry');
13809
13827
  const retryAfterMs =
13810
13828
  TOOL_RETRY_AFTER_FALLBACK_MS * transportAttempt;
@@ -14033,17 +14051,17 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
14033
14051
  'Content-Type': 'application/json',
14034
14052
  Authorization: `Bearer ${this.#options.executorToken}`,
14035
14053
  [EXECUTE_RESPONSE_CONTRACT_HEADER]:
14036
- this.currentToolResponseContract,
14054
+ RAW_V2_TOOL_RESPONSE_CONTRACT,
14037
14055
  [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
14038
14056
  [EXECUTE_TOOL_METADATA_HEADER]: 'true',
14039
14057
  [TOOL_EXECUTION_ERROR_SCHEMA_HEADER]: String(
14040
14058
  toolErrorSchemaVersion,
14041
14059
  ),
14042
14060
  'x-deepline-request-id': deeplineRequestId,
14043
- ...(providerIdempotencyKey
14061
+ ...(providerExecutionFenceKey
14044
14062
  ? {
14045
14063
  'x-deepline-idempotency-key':
14046
- providerIdempotencyKey,
14064
+ providerExecutionFenceKey,
14047
14065
  }
14048
14066
  : {}),
14049
14067
  ...protectionHeaders,
@@ -14145,40 +14163,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
14145
14163
  (response === null ||
14146
14164
  error instanceof ToolExecuteResponseBodyTransportError ||
14147
14165
  error instanceof ToolExecuteInvalidJsonError);
14148
- const hasDurableInvocationIdentity = Boolean(
14149
- durableCallReceiptKey &&
14150
- providerIdempotencyKey &&
14151
- executionAuthScopeDigest,
14152
- );
14153
14166
  // A post-header body deadline aborts the fetch controller solely
14154
14167
  // to stop consuming that response. It must not also cancel the
14155
14168
  // independent gateway health check that establishes whether the
14156
14169
  // committed response can be replayed safely. Preserve genuine
14157
14170
  // caller/runtime cancellation by dropping the signal only when
14158
14171
  // this exact body deadline caused the abort.
14159
- const durableFenceVerificationSignal =
14160
- abortController?.signal.aborted === true &&
14161
- abortController.signal.reason instanceof
14162
- ToolExecuteResponseBodyTimeoutError
14163
- ? undefined
14164
- : abortController?.signal;
14165
- const durableInvocationFenceVerified =
14166
- hasDurableInvocationIdentity &&
14167
- requestsDurableInvocationFence &&
14168
- (this.#options.durableInvocationFence === true ||
14169
- (await this.#options
14170
- .verifyDurableInvocationFence?.(
14171
- durableFenceVerificationSignal,
14172
- )
14173
- .catch(() => false)) === true);
14174
- // Fixture execution has no provider-side effect. Replaying the
14175
- // same stable request after our own transport fails is always
14176
- // safe, even when this runner lacks a live-provider fence.
14177
- const fixtureReplaySafe =
14178
- this.#options.integrationMode === 'fixture';
14179
14172
  const transportReplaySafe =
14180
14173
  !ambiguousDispatchedFailure ||
14181
- durableInvocationFenceVerified ||
14174
+ ambiguousTransportReplayAllowed ||
14182
14175
  fixtureReplaySafe;
14183
14176
  if (!transportReplaySafe) {
14184
14177
  const diagnostic = describeTransportError(transportError);
@@ -14271,6 +14264,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
14271
14264
  elapsedMs: providerCallElapsedMs ?? 0,
14272
14265
  requestId: deeplineRequestId,
14273
14266
  aborted: false,
14267
+ knownNotDispatched: true,
14274
14268
  });
14275
14269
  continue;
14276
14270
  }
@@ -14308,13 +14302,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
14308
14302
  failure.backpressureDelayMs,
14309
14303
  );
14310
14304
  }
14311
- if (failure.shouldRetry) {
14312
- if (
14313
- failure.reason !== 'gateway_invocation_in_progress' &&
14314
- failure.reason !== 'concurrency_backpressure'
14315
- ) {
14316
- invocationAttempt += 1;
14317
- }
14305
+ if (
14306
+ failure.shouldRetry &&
14307
+ (ambiguousTransportReplayAllowed ||
14308
+ retrySafeTransientHttp ||
14309
+ fixtureReplaySafe)
14310
+ ) {
14311
+ invocationAttempt += 1;
14318
14312
  if (failure.chargeRetryBudget) {
14319
14313
  await this.governor.chargeBudget('retry');
14320
14314
  }