deepline 0.3.64 → 0.3.66

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.
@@ -95,6 +95,7 @@ import { resolveExecutionPolicy } from './governor/policy';
95
95
  import {
96
96
  createRuntimeResourceGovernor,
97
97
  type RuntimeResourceGovernor,
98
+ type RuntimeResourceLease,
98
99
  } from './resource-governor';
99
100
  import { CTX_FETCH_EGRESS_TOOL_ID } from './builtin-pacing';
100
101
  import {
@@ -193,6 +194,10 @@ import {
193
194
  type PlayAuthoringRunScope,
194
195
  type PlayAuthoringRuntimeContext,
195
196
  } from '../plays/authoring-contract';
197
+ import {
198
+ formatCtxFetchHttpFailureDiagnostic,
199
+ tagLogProvenance,
200
+ } from './log-provenance';
196
201
  import {
197
202
  DURABLE_RECEIPT_WAIT_DELAY_MS,
198
203
  DURABLE_RECEIPT_WAIT_MAX_ATTEMPTS,
@@ -207,6 +212,14 @@ import {
207
212
  waitForCompletedRuntimeReceipts,
208
213
  type DurableReceiptExecutionStore,
209
214
  } from './durable-receipt-execution';
215
+ import {
216
+ asyncOperationDurableStartInput,
217
+ asyncOperationShouldWait,
218
+ classifyAsyncOperationResult,
219
+ executeAsyncOperationLifecycle,
220
+ resolveAsyncOperationPolling,
221
+ type AsyncOperationContractWire,
222
+ } from './async-operation';
210
223
  import {
211
224
  QUERY_RESULT_DATASET_PAGE_SIZE,
212
225
  isCustomerDbDatasetTool,
@@ -442,6 +455,10 @@ const DEFAULT_TOOL_RUNTIME_TIMEOUT_MS = 15 * 60 * 1000 + 30_000;
442
455
  const FETCH_TRANSPORT_MAX_ATTEMPTS =
443
456
  RUNTIME_RELIABILITY_POLICY.egress.fetchMaxAttempts;
444
457
  const FETCH_TRANSPORT_RETRY_DELAY_MS = 100;
458
+ // Diagnostic logs are retained in the context until terminalization. Keep a
459
+ // representative, deduplicated set so row-heavy continued failures cannot turn
460
+ // customer-safe observability into unbounded runner memory or log traffic.
461
+ const MAX_CTX_FETCH_HTTP_FAILURE_DIAGNOSTICS = 16;
445
462
  const CTX_FETCH_HEADERS_TIMEOUT_MS =
446
463
  RUNTIME_RELIABILITY_POLICY.egress.fetchHeadersTimeoutMs;
447
464
  const CTX_FETCH_BODY_TIMEOUT_MS =
@@ -1228,6 +1245,27 @@ function publicToolResponseEnvelope(value: unknown): {
1228
1245
  };
1229
1246
  }
1230
1247
 
1248
+ function materializedAsyncToolResponse(
1249
+ response: ParsedToolExecuteResponse['toolResponse'] | undefined,
1250
+ result: unknown,
1251
+ ): ParsedToolExecuteResponse['toolResponse'] {
1252
+ const hasRawV2 =
1253
+ response !== undefined &&
1254
+ Object.prototype.hasOwnProperty.call(response, 'rawV2');
1255
+ const materializedRawV2 =
1256
+ response?.view === 'data'
1257
+ ? {
1258
+ ...(recordOrNull(response.rawV2) ?? {}),
1259
+ data: result,
1260
+ }
1261
+ : result;
1262
+ return {
1263
+ ...(response ?? {}),
1264
+ raw: result,
1265
+ ...(hasRawV2 ? { rawV2: materializedRawV2 } : {}),
1266
+ };
1267
+ }
1268
+
1231
1269
  /**
1232
1270
  * A batched provider request returns one envelope for several logical source
1233
1271
  * tool calls. Each source call persists only its own item-shaped canonical
@@ -1384,6 +1422,8 @@ type ToolExecutionApiOptions = {
1384
1422
  pageSize: number;
1385
1423
  totalRows: number;
1386
1424
  };
1425
+ /** Internal recursion guard for lifecycle poll/finish calls. */
1426
+ skipAsyncOperationResolution?: boolean;
1387
1427
  };
1388
1428
  const IN_MEMORY_STEP_RESULT_PREVIEW_LIMIT = 25;
1389
1429
  /**
@@ -2048,9 +2088,14 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2048
2088
  string,
2049
2089
  { resolve: (value: unknown) => void; reject: (reason: unknown) => void }
2050
2090
  >();
2091
+ private readonly asyncOperationCapacity = new Map<
2092
+ string,
2093
+ { active: number; waiters: Array<() => void> }
2094
+ >();
2051
2095
  #options: ContextOptions;
2052
2096
  private readonly executionScope: RunExecutionScope;
2053
2097
  private logBuffer: string[] = [];
2098
+ private readonly ctxFetchHttpFailureDiagnosticIdentities = new Set<string>();
2054
2099
  private checkpoint: PlayCheckpoint;
2055
2100
  private readonly durableCallCacheEpochMs: number;
2056
2101
  /**
@@ -10124,6 +10169,58 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10124
10169
  if (this.#options.verbose) console.log(line);
10125
10170
  }
10126
10171
 
10172
+ /**
10173
+ * Emit a runtime-authored diagnostic through the same durable log path as
10174
+ * ctx.log without changing the customer-authored log wire format.
10175
+ */
10176
+ private runtimeDiagnosticLog(message: string): void {
10177
+ assertNoSecretTaint(message, 'runtime diagnostic log');
10178
+ const line = tagLogProvenance(
10179
+ 'diagnostic',
10180
+ `[${new Date().toISOString()}] ${this.secretRedactor.redactRegisteredSecrets(message)}`,
10181
+ );
10182
+ this.logBuffer.push(line);
10183
+ this.#options.onLog?.(line);
10184
+ if (this.#options.verbose) console.log(line);
10185
+ }
10186
+
10187
+ /** A URL safe to persist in a customer-visible run log (origin only). */
10188
+ private ctxFetchDiagnosticUrl(url: string): string {
10189
+ try {
10190
+ const parsed = new URL(url);
10191
+ return parsed.origin;
10192
+ } catch {
10193
+ // ctx.fetch already parses its URL before this helper is reachable.
10194
+ return '[invalid-url]';
10195
+ }
10196
+ }
10197
+
10198
+ private logCtxFetchHttpFailure(input: {
10199
+ key: string;
10200
+ method: string;
10201
+ url: string;
10202
+ httpStatus: number;
10203
+ }): void {
10204
+ const url = this.ctxFetchDiagnosticUrl(input.url);
10205
+ const identity = `${input.key}\u0000${input.method}\u0000${url}\u0000${input.httpStatus}`;
10206
+ if (
10207
+ this.ctxFetchHttpFailureDiagnosticIdentities.has(identity) ||
10208
+ this.ctxFetchHttpFailureDiagnosticIdentities.size >=
10209
+ MAX_CTX_FETCH_HTTP_FAILURE_DIAGNOSTICS
10210
+ ) {
10211
+ return;
10212
+ }
10213
+ this.ctxFetchHttpFailureDiagnosticIdentities.add(identity);
10214
+ this.runtimeDiagnosticLog(
10215
+ formatCtxFetchHttpFailureDiagnostic({
10216
+ key: input.key,
10217
+ method: input.method,
10218
+ url,
10219
+ http_status: input.httpStatus,
10220
+ }),
10221
+ );
10222
+ }
10223
+
10127
10224
  async sleep(ms: number): Promise<void> {
10128
10225
  this.assertInlineChildContract('suspending_child');
10129
10226
  const delayMs =
@@ -10176,6 +10273,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10176
10273
 
10177
10274
  const url = input.toString();
10178
10275
  const parsedUrl = new URL(url);
10276
+ const method = (init.method ?? 'GET').toUpperCase();
10179
10277
  const urlContainsResolvedSecret =
10180
10278
  this.secretRedactor.containsRegisteredSecret(url, {
10181
10279
  includeEncoded: true,
@@ -10267,6 +10365,14 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10267
10365
  staleAfterSeconds: options?.staleAfterSeconds,
10268
10366
  transient: options?.transient === true,
10269
10367
  onRecovered: (output) => {
10368
+ if (!output.ok) {
10369
+ this.logCtxFetchHttpFailure({
10370
+ key: normalizedKey,
10371
+ method,
10372
+ url: output.url || url,
10373
+ httpStatus: output.status,
10374
+ });
10375
+ }
10270
10376
  if (!output.ok && this.currentAuthoringContractEdition >= 5) {
10271
10377
  throw new CtxFetchHttpError(output);
10272
10378
  }
@@ -10278,7 +10384,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10278
10384
  this.currentAuthoringContractEdition >= 5
10279
10385
  ),
10280
10386
  execute: async ({ retainExternalCallSlot }) => {
10281
- const method = (init.method ?? 'GET').toUpperCase();
10282
10387
  const secretHeaders = await this.resolveSecretAuth(secretAuth);
10283
10388
  const headers: Record<string, string> = {
10284
10389
  ...normalizeFetchHeaders(init.headers),
@@ -10313,6 +10418,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10313
10418
  'output' in existing
10314
10419
  ) {
10315
10420
  this.log(`ctx.fetch(${url}): recovered response from checkpoint`);
10421
+ const checkpointOutput = existing.output as PlayFetchResponse;
10422
+ if (!checkpointOutput.ok) {
10423
+ this.logCtxFetchHttpFailure({
10424
+ key: normalizedKey,
10425
+ method,
10426
+ url: checkpointOutput.url || url,
10427
+ httpStatus: checkpointOutput.status,
10428
+ });
10429
+ }
10316
10430
  if (this.durableDirectToolResultsBackedByReceipts) {
10317
10431
  // The outer durable receipt is the replay authority in hosted
10318
10432
  // runtimes. A legacy checkpoint fetch may seed that receipt
@@ -10320,7 +10434,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10320
10434
  // cache for the lifetime of a large map.
10321
10435
  delete this.checkpoint.resolvedBoundaries?.[boundaryId];
10322
10436
  }
10323
- return existing.output as PlayFetchResponse;
10437
+ return checkpointOutput;
10324
10438
  }
10325
10439
 
10326
10440
  if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
@@ -10460,6 +10574,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
10460
10574
  json: this.secretRedactor.redactKnownSecrets(rawJson),
10461
10575
  };
10462
10576
 
10577
+ if (!output.ok) {
10578
+ this.logCtxFetchHttpFailure({
10579
+ key: normalizedKey,
10580
+ method,
10581
+ url: output.url || url,
10582
+ httpStatus: output.status,
10583
+ });
10584
+ }
10585
+
10463
10586
  // Edition 5 adopts normal fetch semantics: a non-2xx response is
10464
10587
  // a failed durable operation. Throw before checkpoint/receipt
10465
10588
  // completion so the failure is never cached. Editions 1–4 retain
@@ -13123,6 +13246,154 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13123
13246
  });
13124
13247
  }
13125
13248
 
13249
+ private async acquireAsyncOperationCapacity(input: {
13250
+ toolId: string;
13251
+ provider: string;
13252
+ contract: AsyncOperationContractWire;
13253
+ authScopeDigest: string | null;
13254
+ }): Promise<() => void> {
13255
+ const scopeSuffix =
13256
+ input.contract.capacity.scope === 'organization_action'
13257
+ ? input.toolId
13258
+ : input.contract.capacity.scope === 'organization_connection'
13259
+ ? (input.authScopeDigest ?? 'default-connection')
13260
+ : input.provider;
13261
+ const key = `${this.#options.orgId ?? 'unknown-org'}:${input.provider}:${input.contract.capacity.scope}:${scopeSuffix}`;
13262
+ let limit = 1;
13263
+ if (input.contract.capacity.limit.kind === 'fixed') {
13264
+ limit = input.contract.capacity.limit.maxConcurrentJobs;
13265
+ } else {
13266
+ const hints =
13267
+ (await this.#options.getToolQueueHints?.(input.toolId)) ?? [];
13268
+ const declaredLimits = hints
13269
+ .map((hint) => hint.maxConcurrency)
13270
+ .filter(
13271
+ (value): value is number =>
13272
+ typeof value === 'number' && Number.isFinite(value) && value > 0,
13273
+ );
13274
+ limit = declaredLimits.length > 0 ? Math.min(...declaredLimits) : 1;
13275
+ }
13276
+ const state = this.asyncOperationCapacity.get(key) ?? {
13277
+ active: 0,
13278
+ waiters: [],
13279
+ };
13280
+ this.asyncOperationCapacity.set(key, state);
13281
+ if (state.active >= limit) {
13282
+ if (input.contract.capacity.onConflict === 'fail') {
13283
+ throw new Error(
13284
+ `Async provider capacity is full for ${input.provider}; the contract allows ${limit} active job(s).`,
13285
+ );
13286
+ }
13287
+ await new Promise<void>((resolve) => state.waiters.push(resolve));
13288
+ }
13289
+ state.active += 1;
13290
+ let released = false;
13291
+ return () => {
13292
+ if (released) return;
13293
+ released = true;
13294
+ state.active = Math.max(0, state.active - 1);
13295
+ state.waiters.shift()?.();
13296
+ if (state.active === 0 && state.waiters.length === 0) {
13297
+ this.asyncOperationCapacity.delete(key);
13298
+ }
13299
+ };
13300
+ }
13301
+
13302
+ private asyncOperationActionReceiptKey(input: {
13303
+ action: string;
13304
+ parentReceiptKey: string | null;
13305
+ jobId: string;
13306
+ phase: string;
13307
+ }): string | null {
13308
+ if (!input.parentReceiptKey) return null;
13309
+ return `${buildDurableToolReceiptPrefix({
13310
+ orgId: this.#options.orgId,
13311
+ toolId: input.action,
13312
+ })}${stableDigest(
13313
+ stableStringify({
13314
+ parentReceiptKey: input.parentReceiptKey,
13315
+ jobId: input.jobId,
13316
+ phase: input.phase,
13317
+ }),
13318
+ )}`;
13319
+ }
13320
+
13321
+ private async resolveAsyncOperation(input: {
13322
+ toolId: string;
13323
+ startInput: Record<string, unknown>;
13324
+ startResponse: ParsedToolExecuteResponse;
13325
+ contract: AsyncOperationContractWire;
13326
+ receiptKey: string | null;
13327
+ }): Promise<ParsedToolExecuteResponse> {
13328
+ const responseValue = (response: ParsedToolExecuteResponse): unknown => {
13329
+ if (response.toolResponse && 'raw' in response.toolResponse) {
13330
+ return response.toolResponse.raw;
13331
+ }
13332
+ const result = recordOrNull(response.result);
13333
+ return result && Object.prototype.hasOwnProperty.call(result, 'data')
13334
+ ? result.data
13335
+ : response.result;
13336
+ };
13337
+ const startResult = responseValue(input.startResponse);
13338
+ const lifecycle = await executeAsyncOperationLifecycle({
13339
+ contract: input.contract,
13340
+ startInput: input.startInput,
13341
+ startResult,
13342
+ jobId: input.startResponse.jobId,
13343
+ sleep: async (delayMs) =>
13344
+ await this.sleepWithCheckpointHeartbeat(delayMs),
13345
+ responseValue,
13346
+ executeAction: async (request) => {
13347
+ const receiptKey = this.asyncOperationActionReceiptKey({
13348
+ action: request.action,
13349
+ parentReceiptKey: input.receiptKey,
13350
+ jobId: request.jobId,
13351
+ phase: `${request.phase}:${request.pollAttempt}:${request.page ?? ''}`,
13352
+ });
13353
+ return await this.callToolExecutionAPI(request.action, request.input, {
13354
+ skipAsyncOperationResolution: true,
13355
+ durableCallReceiptKey: receiptKey,
13356
+ providerIdempotencyReceiptKey: receiptKey,
13357
+ providerIdempotencyKey: receiptKey,
13358
+ });
13359
+ },
13360
+ });
13361
+ if (lifecycle.kind === 'timed_out') {
13362
+ const polling = resolveAsyncOperationPolling({
13363
+ contract: input.contract,
13364
+ startInput: input.startInput,
13365
+ });
13366
+ throw new Error(
13367
+ `Async operation ${input.toolId} (${lifecycle.jobId}) did not reach a terminal state within ${polling.timeoutMs}ms.`,
13368
+ );
13369
+ }
13370
+ if (
13371
+ lifecycle.classification.outcome === 'failed' ||
13372
+ lifecycle.classification.outcome === 'cancelled'
13373
+ ) {
13374
+ throw new Error(
13375
+ lifecycle.classification.message ??
13376
+ `Async operation ${input.toolId} (${lifecycle.jobId}) ended ${lifecycle.classification.outcome}.`,
13377
+ );
13378
+ }
13379
+ const materialized = lifecycle.materialized!;
13380
+ const selectedResponse =
13381
+ lifecycle.finishResponse ?? lifecycle.pollResponse;
13382
+ return {
13383
+ ...selectedResponse,
13384
+ status:
13385
+ lifecycle.classification.outcome === 'no_result' ||
13386
+ materialized.outcome === 'no_result'
13387
+ ? 'no_result'
13388
+ : 'completed',
13389
+ result: { data: materialized.result },
13390
+ toolResponse: materializedAsyncToolResponse(
13391
+ selectedResponse.toolResponse,
13392
+ materialized.result,
13393
+ ),
13394
+ };
13395
+ }
13396
+
13126
13397
  private async callToolExecutionAPI(
13127
13398
  toolId: string,
13128
13399
  input: Record<string, unknown>,
@@ -13152,6 +13423,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13152
13423
  this.currentAuthoringContractEdition,
13153
13424
  );
13154
13425
  const provider = toolId.split(/[._]/)[0]?.trim() || 'provider';
13426
+ const asyncContract = options?.skipAsyncOperationResolution
13427
+ ? null
13428
+ : ((await this.#options.getToolAsyncOperation?.(toolId)) ?? null);
13429
+ const shouldWaitForAsyncOperation = Boolean(
13430
+ asyncContract && asyncOperationShouldWait(asyncContract, input),
13431
+ );
13432
+ const effectiveInput =
13433
+ asyncContract && shouldWaitForAsyncOperation
13434
+ ? asyncOperationDurableStartInput(asyncContract, input)
13435
+ : input;
13155
13436
  const activityId = `provider:${toolId}`;
13156
13437
  let retryActivityEmitted = false;
13157
13438
  let toolCallSucceeded = false;
@@ -13160,39 +13441,51 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13160
13441
  // execution. Per-provider rate admission is intentionally deferred until
13161
13442
  // each physical fetch is ready to leave this process.
13162
13443
  const admissionStartedAt = Date.now();
13163
- const toolSlot = await this.resourceGovernor
13164
- .acquireTool({
13444
+ const asyncCapacityRelease =
13445
+ asyncContract && shouldWaitForAsyncOperation
13446
+ ? await this.acquireAsyncOperationCapacity({
13447
+ toolId,
13448
+ provider,
13449
+ contract: asyncContract,
13450
+ authScopeDigest:
13451
+ (await this.resolveToolAuthScopeDigest(toolId))?.trim() ?? null,
13452
+ })
13453
+ : null;
13454
+ let toolSlot: RuntimeResourceLease;
13455
+ try {
13456
+ toolSlot = await this.resourceGovernor.acquireTool({
13165
13457
  orgId: this.#options.orgId ?? null,
13166
13458
  providerResourceKey: `tool:${toolId}`,
13167
13459
  toolId,
13168
- })
13169
- .catch((error: unknown) => {
13170
- if (!(error instanceof ProviderExhaustedError)) throw error;
13171
- const retryAtMs = Date.parse(error.retryAt);
13172
- throw createToolHttpError(
13173
- toolErrorSchemaVersion,
13174
- error.message,
13175
- null,
13176
- 429,
13177
- 'repairable',
13178
- {
13179
- toolId,
13180
- provider: error.provider,
13181
- operation: toolId,
13182
- code: error.code,
13183
- origin: 'provider',
13184
- category: 'rate_limit',
13185
- retryable: true,
13186
- statusCode: 429,
13187
- requestId: null,
13188
- retryAfterMs: Number.isFinite(retryAtMs)
13189
- ? Math.max(0, retryAtMs - Date.now())
13190
- : null,
13191
- networkKind: null,
13192
- networkScope: null,
13193
- },
13194
- );
13195
13460
  });
13461
+ } catch (error) {
13462
+ asyncCapacityRelease?.();
13463
+ if (!(error instanceof ProviderExhaustedError)) throw error;
13464
+ const retryAtMs = Date.parse(error.retryAt);
13465
+ throw createToolHttpError(
13466
+ toolErrorSchemaVersion,
13467
+ error.message,
13468
+ null,
13469
+ 429,
13470
+ 'repairable',
13471
+ {
13472
+ toolId,
13473
+ provider: error.provider,
13474
+ operation: toolId,
13475
+ code: error.code,
13476
+ origin: 'provider',
13477
+ category: 'rate_limit',
13478
+ retryable: true,
13479
+ statusCode: 429,
13480
+ requestId: null,
13481
+ retryAfterMs: Number.isFinite(retryAtMs)
13482
+ ? Math.max(0, retryAtMs - Date.now())
13483
+ : null,
13484
+ networkKind: null,
13485
+ networkScope: null,
13486
+ },
13487
+ );
13488
+ }
13196
13489
  if (runtimeReceiptReadTraceEnabled) {
13197
13490
  this.log(
13198
13491
  `[perf] tool call id=${toolId} phase=governor_admission elapsed_ms=${Date.now() - admissionStartedAt}`,
@@ -13222,10 +13515,9 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13222
13515
  // Snapshot the caller-controlled payload exactly once. The replay
13223
13516
  // decision and every physical attempt must refer to the same bytes,
13224
13517
  // even if the original object has getters or is later mutated.
13225
- const toolInputSnapshot = JSON.parse(JSON.stringify(input)) as Record<
13226
- string,
13227
- unknown
13228
- >;
13518
+ const toolInputSnapshot = JSON.parse(
13519
+ JSON.stringify(effectiveInput),
13520
+ ) as Record<string, unknown>;
13229
13521
  const retryPolicy = await this.#options
13230
13522
  .getToolRetryPolicy?.(toolId, toolInputSnapshot)
13231
13523
  .catch(() => null);
@@ -13865,7 +14157,10 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13865
14157
  );
13866
14158
  }
13867
14159
  if (failure.shouldRetry) {
13868
- if (failure.reason !== 'gateway_invocation_in_progress') {
14160
+ if (
14161
+ failure.reason !== 'gateway_invocation_in_progress' &&
14162
+ failure.reason !== 'concurrency_backpressure'
14163
+ ) {
13869
14164
  invocationAttempt += 1;
13870
14165
  }
13871
14166
  if (failure.chargeRetryBudget) {
@@ -13949,8 +14244,29 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13949
14244
  ? 'array'
13950
14245
  : typeof parsed.result,
13951
14246
  });
14247
+ const parsedValue =
14248
+ parsed.toolResponse && 'raw' in parsed.toolResponse
14249
+ ? parsed.toolResponse.raw
14250
+ : (recordOrNull(parsed.result)?.data ?? parsed.result);
14251
+ const asyncStartPending =
14252
+ asyncContract && shouldWaitForAsyncOperation
14253
+ ? classifyAsyncOperationResult(asyncContract, parsedValue)
14254
+ .outcome === 'running'
14255
+ : false;
14256
+ const resolved =
14257
+ asyncContract &&
14258
+ shouldWaitForAsyncOperation &&
14259
+ (parsed.status === 'running' || asyncStartPending)
14260
+ ? await this.resolveAsyncOperation({
14261
+ toolId,
14262
+ startInput: input,
14263
+ startResponse: parsed,
14264
+ contract: asyncContract,
14265
+ receiptKey: durableCallReceiptKey,
14266
+ })
14267
+ : parsed;
13952
14268
  toolCallSucceeded = true;
13953
- return parsed;
14269
+ return resolved;
13954
14270
  }
13955
14271
  },
13956
14272
  );
@@ -13977,6 +14293,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13977
14293
  if (!toolSlotTransferred) {
13978
14294
  toolSlot.release();
13979
14295
  }
14296
+ asyncCapacityRelease?.();
13980
14297
  }
13981
14298
  }
13982
14299
 
@@ -815,6 +815,13 @@ export interface ContextOptions {
815
815
  getToolResultMetadata?: (
816
816
  toolId: string,
817
817
  ) => Promise<ToolResultMetadataInput | null> | ToolResultMetadataInput | null;
818
+ /** Canonical provider-job lifecycle executed below scalar and native batch calls. */
819
+ getToolAsyncOperation?: (
820
+ toolId: string,
821
+ ) =>
822
+ | Promise<import('./async-operation').AsyncOperationContractWire | null>
823
+ | import('./async-operation').AsyncOperationContractWire
824
+ | null;
818
825
  getIntegrationEventWaitHandler?: (
819
826
  toolId: string,
820
827
  ) =>
@@ -125,6 +125,85 @@ export function tagLogProvenance(
125
125
  return `${PROVENANCE_PREFIX}${provenance}${PROVENANCE_SENTINEL}${line}`;
126
126
  }
127
127
 
128
+ /**
129
+ * A customer-safe record of a `ctx.fetch` response that reached the server but
130
+ * was not successful. Its URL is destination-origin-only; it deliberately
131
+ * omits paths, query strings, request/response bodies, headers, receipt ids,
132
+ * and row identity: all of those can contain customer data or credentials.
133
+ * This one stable shape is shared by the
134
+ * runtime (emission), finalization (terminal warning), and CLI/tests
135
+ * (inspection), rather than each layer trying to infer an HTTP failure from
136
+ * arbitrary user logs.
137
+ */
138
+ export type CtxFetchHttpFailureDiagnostic = {
139
+ key: string;
140
+ method: string;
141
+ url: string;
142
+ http_status: number;
143
+ };
144
+
145
+ /** Guard terminal-transport diagnostics before they reach customer surfaces. */
146
+ export function isCtxFetchHttpFailureDiagnostic(
147
+ value: unknown,
148
+ ): value is CtxFetchHttpFailureDiagnostic {
149
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
150
+ return false;
151
+ }
152
+ const record = value as Record<string, unknown>;
153
+ return (
154
+ typeof record.key === 'string' &&
155
+ typeof record.method === 'string' &&
156
+ typeof record.url === 'string' &&
157
+ typeof record.http_status === 'number' &&
158
+ Number.isInteger(record.http_status)
159
+ );
160
+ }
161
+
162
+ export const CTX_FETCH_HTTP_FAILURE_LOG_PREFIX =
163
+ '[runtime.ctx_fetch_http_failure]';
164
+
165
+ /** Format the canonical durable log line for an observed non-2xx ctx.fetch. */
166
+ export function formatCtxFetchHttpFailureDiagnostic(
167
+ diagnostic: CtxFetchHttpFailureDiagnostic,
168
+ ): string {
169
+ return `${CTX_FETCH_HTTP_FAILURE_LOG_PREFIX} ${JSON.stringify(diagnostic)}`;
170
+ }
171
+
172
+ /**
173
+ * Parse only runtime-authored, canonical ctx.fetch diagnostics. Untagged
174
+ * legacy/user lines are never interpreted as evidence, even if they happen to
175
+ * contain the same words.
176
+ */
177
+ export function parseCtxFetchHttpFailureDiagnostic(
178
+ rawLine: string,
179
+ ): CtxFetchHttpFailureDiagnostic | null {
180
+ const tagged = readProvenanceTag(rawLine);
181
+ if (tagged.provenance !== 'diagnostic') return null;
182
+ const index = tagged.line.indexOf(CTX_FETCH_HTTP_FAILURE_LOG_PREFIX);
183
+ if (index === -1) return null;
184
+ const json = tagged.line
185
+ .slice(index + CTX_FETCH_HTTP_FAILURE_LOG_PREFIX.length)
186
+ .trim();
187
+ try {
188
+ const parsed: unknown = JSON.parse(json);
189
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
190
+ return null;
191
+ }
192
+ const record = parsed as Record<string, unknown>;
193
+ if (!isCtxFetchHttpFailureDiagnostic(record)) {
194
+ return null;
195
+ }
196
+ return {
197
+ key: record.key,
198
+ method: record.method,
199
+ url: record.url,
200
+ http_status: record.http_status,
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
128
207
  /**
129
208
  * Read a structural provenance tag off a line, if present, and return the tag
130
209
  * plus the original untagged line. Returns `null` provenance when untagged.
@@ -141,9 +220,7 @@ export function readProvenanceTag(line: string): {
141
220
  return { provenance: null, line };
142
221
  }
143
222
  const candidate = line.slice(PROVENANCE_PREFIX.length, end);
144
- const provenance = LOG_PROVENANCE_CLASSES.includes(
145
- candidate as LogProvenance,
146
- )
223
+ const provenance = LOG_PROVENANCE_CLASSES.includes(candidate as LogProvenance)
147
224
  ? (candidate as LogProvenance)
148
225
  : null;
149
226
  return { provenance, line: line.slice(end + 1) };
@@ -3,6 +3,7 @@ import { bettercontactBatchStrategies } from './bettercontact-batching';
3
3
  import { DEFAULT_PLAY_RUNTIME_BATCH_STRATEGIES } from './default-batch-strategies';
4
4
  import { fullenrichBatchStrategies } from './fullenrich-batching';
5
5
  import { opensosdataBatchStrategies } from './opensosdata-batching';
6
+ import { testAsyncBatchStrategies } from './test-async-batching';
6
7
 
7
8
  export const PLAY_RUNTIME_BATCH_OPERATION_REGISTRY: Record<
8
9
  string,
@@ -12,6 +13,7 @@ export const PLAY_RUNTIME_BATCH_OPERATION_REGISTRY: Record<
12
13
  ...bettercontactBatchStrategies,
13
14
  ...fullenrichBatchStrategies,
14
15
  ...opensosdataBatchStrategies,
16
+ ...testAsyncBatchStrategies,
15
17
  };
16
18
 
17
19
  export function getPlayRuntimeBatchStrategy(
@@ -17,6 +17,7 @@ import type { PlayRunFailureDetails } from './run-failure';
17
17
  import type { ToolExecutionErrorSchemaVersion } from '../plays/tool-execution-error';
18
18
  import type { ToolResponseContract } from '../plays/tool-response-contract';
19
19
  import type { FixtureBehavior } from './fixture-behavior';
20
+ import type { CtxFetchHttpFailureDiagnostic } from './log-provenance';
20
21
 
21
22
  export type PlayRunnerRateStateBackendConfig =
22
23
  | {
@@ -307,6 +308,8 @@ export type PlayRunnerResult =
307
308
  status: 'completed';
308
309
  output: unknown;
309
310
  outputWarnings?: PlayRunOutputWarning[];
311
+ /** Customer-safe runtime observations that must survive bounded log tails. */
312
+ runtimeDiagnostics?: CtxFetchHttpFailureDiagnostic[];
310
313
  outputRowCount?: number;
311
314
  logs: string[];
312
315
  stats: Record<string, unknown>;