deepline 0.3.64 → 0.3.65

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 {
@@ -207,6 +208,14 @@ import {
207
208
  waitForCompletedRuntimeReceipts,
208
209
  type DurableReceiptExecutionStore,
209
210
  } from './durable-receipt-execution';
211
+ import {
212
+ asyncOperationDurableStartInput,
213
+ asyncOperationShouldWait,
214
+ classifyAsyncOperationResult,
215
+ executeAsyncOperationLifecycle,
216
+ resolveAsyncOperationPolling,
217
+ type AsyncOperationContractWire,
218
+ } from './async-operation';
210
219
  import {
211
220
  QUERY_RESULT_DATASET_PAGE_SIZE,
212
221
  isCustomerDbDatasetTool,
@@ -1228,6 +1237,27 @@ function publicToolResponseEnvelope(value: unknown): {
1228
1237
  };
1229
1238
  }
1230
1239
 
1240
+ function materializedAsyncToolResponse(
1241
+ response: ParsedToolExecuteResponse['toolResponse'] | undefined,
1242
+ result: unknown,
1243
+ ): ParsedToolExecuteResponse['toolResponse'] {
1244
+ const hasRawV2 =
1245
+ response !== undefined &&
1246
+ Object.prototype.hasOwnProperty.call(response, 'rawV2');
1247
+ const materializedRawV2 =
1248
+ response?.view === 'data'
1249
+ ? {
1250
+ ...(recordOrNull(response.rawV2) ?? {}),
1251
+ data: result,
1252
+ }
1253
+ : result;
1254
+ return {
1255
+ ...(response ?? {}),
1256
+ raw: result,
1257
+ ...(hasRawV2 ? { rawV2: materializedRawV2 } : {}),
1258
+ };
1259
+ }
1260
+
1231
1261
  /**
1232
1262
  * A batched provider request returns one envelope for several logical source
1233
1263
  * tool calls. Each source call persists only its own item-shaped canonical
@@ -1384,6 +1414,8 @@ type ToolExecutionApiOptions = {
1384
1414
  pageSize: number;
1385
1415
  totalRows: number;
1386
1416
  };
1417
+ /** Internal recursion guard for lifecycle poll/finish calls. */
1418
+ skipAsyncOperationResolution?: boolean;
1387
1419
  };
1388
1420
  const IN_MEMORY_STEP_RESULT_PREVIEW_LIMIT = 25;
1389
1421
  /**
@@ -2048,6 +2080,10 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
2048
2080
  string,
2049
2081
  { resolve: (value: unknown) => void; reject: (reason: unknown) => void }
2050
2082
  >();
2083
+ private readonly asyncOperationCapacity = new Map<
2084
+ string,
2085
+ { active: number; waiters: Array<() => void> }
2086
+ >();
2051
2087
  #options: ContextOptions;
2052
2088
  private readonly executionScope: RunExecutionScope;
2053
2089
  private logBuffer: string[] = [];
@@ -13123,6 +13159,154 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13123
13159
  });
13124
13160
  }
13125
13161
 
13162
+ private async acquireAsyncOperationCapacity(input: {
13163
+ toolId: string;
13164
+ provider: string;
13165
+ contract: AsyncOperationContractWire;
13166
+ authScopeDigest: string | null;
13167
+ }): Promise<() => void> {
13168
+ const scopeSuffix =
13169
+ input.contract.capacity.scope === 'organization_action'
13170
+ ? input.toolId
13171
+ : input.contract.capacity.scope === 'organization_connection'
13172
+ ? (input.authScopeDigest ?? 'default-connection')
13173
+ : input.provider;
13174
+ const key = `${this.#options.orgId ?? 'unknown-org'}:${input.provider}:${input.contract.capacity.scope}:${scopeSuffix}`;
13175
+ let limit = 1;
13176
+ if (input.contract.capacity.limit.kind === 'fixed') {
13177
+ limit = input.contract.capacity.limit.maxConcurrentJobs;
13178
+ } else {
13179
+ const hints =
13180
+ (await this.#options.getToolQueueHints?.(input.toolId)) ?? [];
13181
+ const declaredLimits = hints
13182
+ .map((hint) => hint.maxConcurrency)
13183
+ .filter(
13184
+ (value): value is number =>
13185
+ typeof value === 'number' && Number.isFinite(value) && value > 0,
13186
+ );
13187
+ limit = declaredLimits.length > 0 ? Math.min(...declaredLimits) : 1;
13188
+ }
13189
+ const state = this.asyncOperationCapacity.get(key) ?? {
13190
+ active: 0,
13191
+ waiters: [],
13192
+ };
13193
+ this.asyncOperationCapacity.set(key, state);
13194
+ if (state.active >= limit) {
13195
+ if (input.contract.capacity.onConflict === 'fail') {
13196
+ throw new Error(
13197
+ `Async provider capacity is full for ${input.provider}; the contract allows ${limit} active job(s).`,
13198
+ );
13199
+ }
13200
+ await new Promise<void>((resolve) => state.waiters.push(resolve));
13201
+ }
13202
+ state.active += 1;
13203
+ let released = false;
13204
+ return () => {
13205
+ if (released) return;
13206
+ released = true;
13207
+ state.active = Math.max(0, state.active - 1);
13208
+ state.waiters.shift()?.();
13209
+ if (state.active === 0 && state.waiters.length === 0) {
13210
+ this.asyncOperationCapacity.delete(key);
13211
+ }
13212
+ };
13213
+ }
13214
+
13215
+ private asyncOperationActionReceiptKey(input: {
13216
+ action: string;
13217
+ parentReceiptKey: string | null;
13218
+ jobId: string;
13219
+ phase: string;
13220
+ }): string | null {
13221
+ if (!input.parentReceiptKey) return null;
13222
+ return `${buildDurableToolReceiptPrefix({
13223
+ orgId: this.#options.orgId,
13224
+ toolId: input.action,
13225
+ })}${stableDigest(
13226
+ stableStringify({
13227
+ parentReceiptKey: input.parentReceiptKey,
13228
+ jobId: input.jobId,
13229
+ phase: input.phase,
13230
+ }),
13231
+ )}`;
13232
+ }
13233
+
13234
+ private async resolveAsyncOperation(input: {
13235
+ toolId: string;
13236
+ startInput: Record<string, unknown>;
13237
+ startResponse: ParsedToolExecuteResponse;
13238
+ contract: AsyncOperationContractWire;
13239
+ receiptKey: string | null;
13240
+ }): Promise<ParsedToolExecuteResponse> {
13241
+ const responseValue = (response: ParsedToolExecuteResponse): unknown => {
13242
+ if (response.toolResponse && 'raw' in response.toolResponse) {
13243
+ return response.toolResponse.raw;
13244
+ }
13245
+ const result = recordOrNull(response.result);
13246
+ return result && Object.prototype.hasOwnProperty.call(result, 'data')
13247
+ ? result.data
13248
+ : response.result;
13249
+ };
13250
+ const startResult = responseValue(input.startResponse);
13251
+ const lifecycle = await executeAsyncOperationLifecycle({
13252
+ contract: input.contract,
13253
+ startInput: input.startInput,
13254
+ startResult,
13255
+ jobId: input.startResponse.jobId,
13256
+ sleep: async (delayMs) =>
13257
+ await this.sleepWithCheckpointHeartbeat(delayMs),
13258
+ responseValue,
13259
+ executeAction: async (request) => {
13260
+ const receiptKey = this.asyncOperationActionReceiptKey({
13261
+ action: request.action,
13262
+ parentReceiptKey: input.receiptKey,
13263
+ jobId: request.jobId,
13264
+ phase: `${request.phase}:${request.pollAttempt}:${request.page ?? ''}`,
13265
+ });
13266
+ return await this.callToolExecutionAPI(request.action, request.input, {
13267
+ skipAsyncOperationResolution: true,
13268
+ durableCallReceiptKey: receiptKey,
13269
+ providerIdempotencyReceiptKey: receiptKey,
13270
+ providerIdempotencyKey: receiptKey,
13271
+ });
13272
+ },
13273
+ });
13274
+ if (lifecycle.kind === 'timed_out') {
13275
+ const polling = resolveAsyncOperationPolling({
13276
+ contract: input.contract,
13277
+ startInput: input.startInput,
13278
+ });
13279
+ throw new Error(
13280
+ `Async operation ${input.toolId} (${lifecycle.jobId}) did not reach a terminal state within ${polling.timeoutMs}ms.`,
13281
+ );
13282
+ }
13283
+ if (
13284
+ lifecycle.classification.outcome === 'failed' ||
13285
+ lifecycle.classification.outcome === 'cancelled'
13286
+ ) {
13287
+ throw new Error(
13288
+ lifecycle.classification.message ??
13289
+ `Async operation ${input.toolId} (${lifecycle.jobId}) ended ${lifecycle.classification.outcome}.`,
13290
+ );
13291
+ }
13292
+ const materialized = lifecycle.materialized!;
13293
+ const selectedResponse =
13294
+ lifecycle.finishResponse ?? lifecycle.pollResponse;
13295
+ return {
13296
+ ...selectedResponse,
13297
+ status:
13298
+ lifecycle.classification.outcome === 'no_result' ||
13299
+ materialized.outcome === 'no_result'
13300
+ ? 'no_result'
13301
+ : 'completed',
13302
+ result: { data: materialized.result },
13303
+ toolResponse: materializedAsyncToolResponse(
13304
+ selectedResponse.toolResponse,
13305
+ materialized.result,
13306
+ ),
13307
+ };
13308
+ }
13309
+
13126
13310
  private async callToolExecutionAPI(
13127
13311
  toolId: string,
13128
13312
  input: Record<string, unknown>,
@@ -13152,6 +13336,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13152
13336
  this.currentAuthoringContractEdition,
13153
13337
  );
13154
13338
  const provider = toolId.split(/[._]/)[0]?.trim() || 'provider';
13339
+ const asyncContract = options?.skipAsyncOperationResolution
13340
+ ? null
13341
+ : ((await this.#options.getToolAsyncOperation?.(toolId)) ?? null);
13342
+ const shouldWaitForAsyncOperation = Boolean(
13343
+ asyncContract && asyncOperationShouldWait(asyncContract, input),
13344
+ );
13345
+ const effectiveInput =
13346
+ asyncContract && shouldWaitForAsyncOperation
13347
+ ? asyncOperationDurableStartInput(asyncContract, input)
13348
+ : input;
13155
13349
  const activityId = `provider:${toolId}`;
13156
13350
  let retryActivityEmitted = false;
13157
13351
  let toolCallSucceeded = false;
@@ -13160,39 +13354,51 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13160
13354
  // execution. Per-provider rate admission is intentionally deferred until
13161
13355
  // each physical fetch is ready to leave this process.
13162
13356
  const admissionStartedAt = Date.now();
13163
- const toolSlot = await this.resourceGovernor
13164
- .acquireTool({
13357
+ const asyncCapacityRelease =
13358
+ asyncContract && shouldWaitForAsyncOperation
13359
+ ? await this.acquireAsyncOperationCapacity({
13360
+ toolId,
13361
+ provider,
13362
+ contract: asyncContract,
13363
+ authScopeDigest:
13364
+ (await this.resolveToolAuthScopeDigest(toolId))?.trim() ?? null,
13365
+ })
13366
+ : null;
13367
+ let toolSlot: RuntimeResourceLease;
13368
+ try {
13369
+ toolSlot = await this.resourceGovernor.acquireTool({
13165
13370
  orgId: this.#options.orgId ?? null,
13166
13371
  providerResourceKey: `tool:${toolId}`,
13167
13372
  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
13373
  });
13374
+ } catch (error) {
13375
+ asyncCapacityRelease?.();
13376
+ if (!(error instanceof ProviderExhaustedError)) throw error;
13377
+ const retryAtMs = Date.parse(error.retryAt);
13378
+ throw createToolHttpError(
13379
+ toolErrorSchemaVersion,
13380
+ error.message,
13381
+ null,
13382
+ 429,
13383
+ 'repairable',
13384
+ {
13385
+ toolId,
13386
+ provider: error.provider,
13387
+ operation: toolId,
13388
+ code: error.code,
13389
+ origin: 'provider',
13390
+ category: 'rate_limit',
13391
+ retryable: true,
13392
+ statusCode: 429,
13393
+ requestId: null,
13394
+ retryAfterMs: Number.isFinite(retryAtMs)
13395
+ ? Math.max(0, retryAtMs - Date.now())
13396
+ : null,
13397
+ networkKind: null,
13398
+ networkScope: null,
13399
+ },
13400
+ );
13401
+ }
13196
13402
  if (runtimeReceiptReadTraceEnabled) {
13197
13403
  this.log(
13198
13404
  `[perf] tool call id=${toolId} phase=governor_admission elapsed_ms=${Date.now() - admissionStartedAt}`,
@@ -13222,10 +13428,9 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13222
13428
  // Snapshot the caller-controlled payload exactly once. The replay
13223
13429
  // decision and every physical attempt must refer to the same bytes,
13224
13430
  // 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
- >;
13431
+ const toolInputSnapshot = JSON.parse(
13432
+ JSON.stringify(effectiveInput),
13433
+ ) as Record<string, unknown>;
13229
13434
  const retryPolicy = await this.#options
13230
13435
  .getToolRetryPolicy?.(toolId, toolInputSnapshot)
13231
13436
  .catch(() => null);
@@ -13865,7 +14070,10 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13865
14070
  );
13866
14071
  }
13867
14072
  if (failure.shouldRetry) {
13868
- if (failure.reason !== 'gateway_invocation_in_progress') {
14073
+ if (
14074
+ failure.reason !== 'gateway_invocation_in_progress' &&
14075
+ failure.reason !== 'concurrency_backpressure'
14076
+ ) {
13869
14077
  invocationAttempt += 1;
13870
14078
  }
13871
14079
  if (failure.chargeRetryBudget) {
@@ -13949,8 +14157,29 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13949
14157
  ? 'array'
13950
14158
  : typeof parsed.result,
13951
14159
  });
14160
+ const parsedValue =
14161
+ parsed.toolResponse && 'raw' in parsed.toolResponse
14162
+ ? parsed.toolResponse.raw
14163
+ : (recordOrNull(parsed.result)?.data ?? parsed.result);
14164
+ const asyncStartPending =
14165
+ asyncContract && shouldWaitForAsyncOperation
14166
+ ? classifyAsyncOperationResult(asyncContract, parsedValue)
14167
+ .outcome === 'running'
14168
+ : false;
14169
+ const resolved =
14170
+ asyncContract &&
14171
+ shouldWaitForAsyncOperation &&
14172
+ (parsed.status === 'running' || asyncStartPending)
14173
+ ? await this.resolveAsyncOperation({
14174
+ toolId,
14175
+ startInput: input,
14176
+ startResponse: parsed,
14177
+ contract: asyncContract,
14178
+ receiptKey: durableCallReceiptKey,
14179
+ })
14180
+ : parsed;
13952
14181
  toolCallSucceeded = true;
13953
- return parsed;
14182
+ return resolved;
13954
14183
  }
13955
14184
  },
13956
14185
  );
@@ -13977,6 +14206,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
13977
14206
  if (!toolSlotTransferred) {
13978
14207
  toolSlot.release();
13979
14208
  }
14209
+ asyncCapacityRelease?.();
13980
14210
  }
13981
14211
  }
13982
14212
 
@@ -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
  ) =>
@@ -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(
@@ -0,0 +1,141 @@
1
+ import {
2
+ defineBatchStrategyMap,
3
+ type BatchOperationStrategy,
4
+ } from './batching-types';
5
+
6
+ type TestAsyncLookupPayload = Record<string, unknown> & {
7
+ key: string;
8
+ row_number: number;
9
+ capacity_key: string;
10
+ polls_before_terminal?: number;
11
+ result_page_size?: number;
12
+ pre_attach_delay_ms?: number;
13
+ };
14
+
15
+ type TestAsyncBatchPayload = Record<string, unknown> & {
16
+ key: string;
17
+ capacity_key: string;
18
+ wait_for_completion: true;
19
+ terminal_outcome: 'completed';
20
+ polls_before_terminal?: number;
21
+ result_page_size?: number;
22
+ pre_attach_delay_ms?: number;
23
+ items: Array<{
24
+ itemKey: string;
25
+ payload: { key: string; row_number: number };
26
+ }>;
27
+ };
28
+
29
+ type TestAsyncBatchResult = Record<string, unknown> & {
30
+ items?: Array<{
31
+ itemKey?: string;
32
+ result?: Record<string, unknown>;
33
+ }>;
34
+ };
35
+
36
+ function resultItems(
37
+ value: unknown,
38
+ ): NonNullable<TestAsyncBatchResult['items']> {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
40
+ const record = value as Record<string, unknown>;
41
+ if (Array.isArray(record.items)) {
42
+ return record.items as NonNullable<TestAsyncBatchResult['items']>;
43
+ }
44
+ for (const candidate of [record.data, record.result, record.output]) {
45
+ const nested = resultItems(candidate);
46
+ if (nested.length > 0) return nested;
47
+ }
48
+ return [];
49
+ }
50
+
51
+ export const testAsyncContractLookupBatchStrategy: BatchOperationStrategy<
52
+ TestAsyncLookupPayload,
53
+ TestAsyncBatchPayload,
54
+ TestAsyncBatchResult,
55
+ Record<string, unknown>
56
+ > = {
57
+ sourceOperation: 'test_async_contract_lookup',
58
+ batchOperation: 'test_async_contract_start',
59
+ kind: 'async_dataset_job',
60
+ maxBatchSize: 3,
61
+ bucketKeyPayloadFields: [
62
+ 'capacity_key',
63
+ 'polls_before_terminal',
64
+ 'result_page_size',
65
+ 'pre_attach_delay_ms',
66
+ ],
67
+ canBatchWith(left, right) {
68
+ return (
69
+ left.capacity_key === right.capacity_key &&
70
+ left.polls_before_terminal === right.polls_before_terminal &&
71
+ left.result_page_size === right.result_page_size &&
72
+ left.pre_attach_delay_ms === right.pre_attach_delay_ms
73
+ );
74
+ },
75
+ toBucketKey(payload) {
76
+ return JSON.stringify([
77
+ payload.capacity_key,
78
+ payload.polls_before_terminal ?? null,
79
+ payload.result_page_size ?? null,
80
+ payload.pre_attach_delay_ms ?? null,
81
+ ]);
82
+ },
83
+ toItemKey(payload) {
84
+ return payload.key;
85
+ },
86
+ compile(payloads) {
87
+ const first = payloads[0]!;
88
+ const items = payloads.map((payload) => ({
89
+ itemKey: payload.key,
90
+ payload: { key: payload.key, row_number: payload.row_number },
91
+ }));
92
+ return {
93
+ batchOperation: 'test_async_contract_start',
94
+ batchPayload: {
95
+ key: `async-contract:${first.capacity_key}:${items.map((item) => item.itemKey).join(',')}`,
96
+ capacity_key: first.capacity_key,
97
+ wait_for_completion: true,
98
+ terminal_outcome: 'completed',
99
+ ...(first.polls_before_terminal !== undefined
100
+ ? { polls_before_terminal: first.polls_before_terminal }
101
+ : {}),
102
+ ...(first.result_page_size !== undefined
103
+ ? { result_page_size: first.result_page_size }
104
+ : {}),
105
+ ...(first.pre_attach_delay_ms !== undefined
106
+ ? { pre_attach_delay_ms: first.pre_attach_delay_ms }
107
+ : {}),
108
+ items,
109
+ },
110
+ items: payloads.map((payload) => ({
111
+ itemKey: payload.key,
112
+ payload,
113
+ })),
114
+ };
115
+ },
116
+ splitResult(fullResult, compiled) {
117
+ const items = resultItems(fullResult);
118
+ if (items.length !== compiled.items.length) {
119
+ throw new Error(
120
+ `Synthetic async batch returned ${items.length} results for ${compiled.items.length} inputs.`,
121
+ );
122
+ }
123
+ return compiled.items.map((item, index) => {
124
+ const resultItem = items[index];
125
+ if (!resultItem || resultItem.itemKey !== item.itemKey) {
126
+ throw new Error(
127
+ `Synthetic async batch result ${index} did not preserve item identity ${item.itemKey}.`,
128
+ );
129
+ }
130
+ return {
131
+ itemKey: item.itemKey,
132
+ result: resultItem.result ?? {},
133
+ rawResult: resultItem,
134
+ };
135
+ });
136
+ },
137
+ };
138
+
139
+ export const testAsyncBatchStrategies = defineBatchStrategyMap({
140
+ test_async_contract_lookup: testAsyncContractLookupBatchStrategy,
141
+ });