deepline 0.1.284 → 0.1.285

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.
@@ -155,7 +155,7 @@ export const SDK_RELEASE = {
155
155
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
156
156
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
157
157
  // Operators use the checkout-local deepline-admin binary instead.
158
- version: '0.1.284',
158
+ version: '0.1.285',
159
159
  contracts: {
160
160
  api: {
161
161
  name: 'sdk-http-api',
@@ -107,6 +107,7 @@ import {
107
107
  TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS,
108
108
  classifyToolExecuteHttpFailure,
109
109
  createToolExecuteHttpFailureAttemptTracker,
110
+ parseToolExecuteRetryAfterMs,
110
111
  parseToolExecuteAuthScopeChangedError,
111
112
  ToolExecuteAuthScopeChangedError,
112
113
  } from './tool-execute-retry-policy';
@@ -303,9 +304,10 @@ const MAP_INCREMENTAL_PERSIST_CHUNK_ROWS = 100;
303
304
  const MAP_INCREMENTAL_PERSIST_CHUNK_BYTES = 1 * 1024 * 1024;
304
305
  const MAP_INCREMENTAL_PERSIST_INTERVAL_MS = 100;
305
306
  const MAP_FRAME_FLUSH_INTERVAL_MS = 250;
306
- // Batchable calls wait at most this long for same-key row continuations. The
307
- // window is fixed from the first item and never resets on later arrivals.
307
+ // Tool scheduling lanes wait at most this long for same-lane row continuations.
308
+ // The window is fixed from the first item and never resets on later arrivals.
308
309
  const TOOL_BATCH_COALESCE_WINDOW_MS = 5;
310
+ const TOOL_SCALAR_COALESCE_WINDOW_MS = 5;
309
311
  const TOOL_RETRY_AFTER_FALLBACK_MS = 1_000;
310
312
  const TOOL_RETRY_HEARTBEAT_INTERVAL_MS = 30_000;
311
313
  const DEEPLINEAGENT_TOOL_RUNTIME_TIMEOUT_MS = 15 * 60 * 1000;
@@ -335,6 +337,31 @@ const MAX_INLINE_CHILD_INVOCATIONS_PER_ROW = 512;
335
337
  // Bound the retained failure detail carried in parent aggregates so a fully
336
338
  // failing large map cannot grow the progress event without limit.
337
339
  const MAX_INLINE_CHILD_FAILURE_DETAIL = 25;
340
+
341
+ class ToolExecuteResponseBodyTransportError extends Error {
342
+ readonly cause: unknown;
343
+
344
+ constructor(cause: unknown) {
345
+ super(
346
+ cause instanceof Error
347
+ ? cause.message
348
+ : 'Tool execute response body transport failed.',
349
+ );
350
+ this.name = 'ToolExecuteResponseBodyTransportError';
351
+ this.cause = cause;
352
+ }
353
+ }
354
+
355
+ class ToolExecuteInvalidJsonError extends Error {
356
+ readonly cause: unknown;
357
+
358
+ constructor(cause: unknown) {
359
+ super('Tool execute response body was not valid JSON.');
360
+ this.name = 'ToolExecuteInvalidJsonError';
361
+ this.cause = cause;
362
+ }
363
+ }
364
+
338
365
  // A single batched runtime-step-receipt request (get/claim/complete/fail/seed)
339
366
  // carries at most this many keys. At map scale a run can hold 5k-10k receipt
340
367
  // keys; sending them all in one request made the server claim/complete them
@@ -1211,11 +1238,11 @@ export class PlayContextImpl {
1211
1238
  private rowStates = new Map<number, RowState>();
1212
1239
  private toolCallQueue: ToolCallRequest[] = [];
1213
1240
  /**
1214
- * Fixed, non-resetting coalescing deadlines for batchable ready work. A
1241
+ * Fixed, non-resetting coalescing deadlines for ready scheduling lanes. A
1215
1242
  * deadline starts when the first request enters an empty batch bucket. New
1216
1243
  * arrivals never push it back, so sustained traffic cannot starve dispatch.
1217
1244
  */
1218
- private readonly toolBatchQueuedAtByKey = new Map<string, number>();
1245
+ private readonly toolDispatchQueuedAtByLane = new Map<string, number>();
1219
1246
  private readonly toolDispatcherWakeWaiters = new Set<() => void>();
1220
1247
  private toolDispatcherFailure: unknown | null = null;
1221
1248
  private toolCallResolvers = new Map<
@@ -1396,14 +1423,14 @@ export class PlayContextImpl {
1396
1423
  this.checkpoint = options.checkpoint ?? emptyCheckpoint();
1397
1424
  this.durableMappedToolResultsBackedByReceipts = Boolean(
1398
1425
  (options.claimRuntimeStepReceipt || options.claimRuntimeStepReceipts) &&
1399
- (options.getRuntimeStepReceipt || options.getRuntimeStepReceipts) &&
1400
- (options.completeRuntimeStepReceipt ||
1401
- options.completeRuntimeStepReceipts),
1426
+ (options.getRuntimeStepReceipt || options.getRuntimeStepReceipts) &&
1427
+ (options.completeRuntimeStepReceipt ||
1428
+ options.completeRuntimeStepReceipts),
1402
1429
  );
1403
1430
  this.durableDirectToolResultsBackedByReceipts = Boolean(
1404
1431
  options.claimRuntimeStepReceipt &&
1405
- options.getRuntimeStepReceipt &&
1406
- options.completeRuntimeStepReceipt,
1432
+ options.getRuntimeStepReceipt &&
1433
+ options.completeRuntimeStepReceipt,
1407
1434
  );
1408
1435
  if (this.durableDirectToolResultsBackedByReceipts) {
1409
1436
  // A resumed durable runner may receive a legacy checkpoint containing
@@ -6078,15 +6105,41 @@ export class PlayContextImpl {
6078
6105
  }
6079
6106
  }
6080
6107
 
6081
- private toolBatchCoalesceKey(request: ToolCallRequest): string | null {
6108
+ private toolDispatchLane(request: ToolCallRequest): {
6109
+ key: string;
6110
+ readyCount: number;
6111
+ maxGroupSize: number;
6112
+ coalesceWindowMs: number;
6113
+ } {
6082
6114
  const strategy =
6083
6115
  this.#options.getBatchOperationStrategy?.(request.toolId) ?? null;
6084
- if (!strategy) return null;
6085
- return [
6086
- request.toolId,
6087
- request.executionAuthScopeDigest ?? '',
6088
- String(strategy.toBucketKey(request.input)),
6089
- ].join('\u0000');
6116
+ if (strategy) {
6117
+ return {
6118
+ key: [
6119
+ 'batch',
6120
+ request.toolId,
6121
+ request.executionAuthScopeDigest ?? '',
6122
+ String(strategy.toBucketKey(request.input)),
6123
+ ].join('\u0000'),
6124
+ readyCount: strategy.maxBatchSize,
6125
+ maxGroupSize: this.governor.policy.concurrency.toolCalls,
6126
+ coalesceWindowMs:
6127
+ this.#options.toolBatchCoalesceWindowMs ??
6128
+ TOOL_BATCH_COALESCE_WINDOW_MS,
6129
+ };
6130
+ }
6131
+ return {
6132
+ key: [
6133
+ 'scalar',
6134
+ request.toolId,
6135
+ request.executionAuthScopeDigest ?? '',
6136
+ ].join('\u0000'),
6137
+ readyCount: this.governor.policy.pacing.workerToolBatchDefaultParallelism,
6138
+ maxGroupSize: this.governor.policy.concurrency.toolCalls,
6139
+ coalesceWindowMs:
6140
+ this.#options.toolScalarCoalesceWindowMs ??
6141
+ TOOL_SCALAR_COALESCE_WINDOW_MS,
6142
+ };
6090
6143
  }
6091
6144
 
6092
6145
  private wakeToolDispatcher(): void {
@@ -6109,10 +6162,10 @@ export class PlayContextImpl {
6109
6162
  return;
6110
6163
  }
6111
6164
  this.toolCallQueue.push(request);
6112
- const batchKey = this.toolBatchCoalesceKey(request);
6113
- if (batchKey && !this.toolBatchQueuedAtByKey.has(batchKey)) {
6114
- this.toolBatchQueuedAtByKey.set(
6115
- batchKey,
6165
+ const lane = this.toolDispatchLane(request);
6166
+ if (!this.toolDispatchQueuedAtByLane.has(lane.key)) {
6167
+ this.toolDispatchQueuedAtByLane.set(
6168
+ lane.key,
6116
6169
  this.toolBatchDispatcherNowMs(),
6117
6170
  );
6118
6171
  }
@@ -6122,7 +6175,7 @@ export class PlayContextImpl {
6122
6175
  private rejectQueuedToolCalls(error: unknown): void {
6123
6176
  const queued = this.toolCallQueue;
6124
6177
  this.toolCallQueue = [];
6125
- this.toolBatchQueuedAtByKey.clear();
6178
+ this.toolDispatchQueuedAtByLane.clear();
6126
6179
  for (const request of queued) {
6127
6180
  const resolver = this.toolCallResolvers.get(request.callId);
6128
6181
  if (!resolver) continue;
@@ -6131,39 +6184,45 @@ export class PlayContextImpl {
6131
6184
  }
6132
6185
  }
6133
6186
 
6134
- private takeDispatchableToolCalls(nowMs: number): {
6187
+ private takeDispatchableToolCalls(
6188
+ nowMs: number,
6189
+ blockedLaneKeys: ReadonlySet<string>,
6190
+ ): {
6135
6191
  requests: ToolCallRequest[];
6192
+ laneKeys: Set<string>;
6136
6193
  nextDeadlineMs: number | null;
6137
6194
  } {
6138
6195
  if (this.toolCallQueue.length === 0) {
6139
- return { requests: [], nextDeadlineMs: null };
6196
+ return { requests: [], laneKeys: new Set(), nextDeadlineMs: null };
6140
6197
  }
6141
6198
 
6142
- const batchCounts = new Map<string, number>();
6143
- const batchMaxSizes = new Map<string, number>();
6199
+ const laneCounts = new Map<string, number>();
6200
+ const lanes = new Map<
6201
+ string,
6202
+ {
6203
+ readyCount: number;
6204
+ maxGroupSize: number;
6205
+ coalesceWindowMs: number;
6206
+ }
6207
+ >();
6144
6208
  for (const request of this.toolCallQueue) {
6145
- const batchKey = this.toolBatchCoalesceKey(request);
6146
- if (!batchKey) continue;
6147
- batchCounts.set(batchKey, (batchCounts.get(batchKey) ?? 0) + 1);
6148
- const strategy =
6149
- this.#options.getBatchOperationStrategy?.(request.toolId) ?? null;
6150
- if (strategy) batchMaxSizes.set(batchKey, strategy.maxBatchSize);
6209
+ const lane = this.toolDispatchLane(request);
6210
+ laneCounts.set(lane.key, (laneCounts.get(lane.key) ?? 0) + 1);
6211
+ lanes.set(lane.key, lane);
6151
6212
  }
6152
6213
 
6153
- const readyBatchKeys = new Set<string>();
6214
+ const readyLaneKeys = new Set<string>();
6154
6215
  let nextDeadlineMs: number | null = null;
6155
- for (const [batchKey, count] of batchCounts) {
6156
- const queuedAt = this.toolBatchQueuedAtByKey.get(batchKey) ?? nowMs;
6157
- if (!this.toolBatchQueuedAtByKey.has(batchKey)) {
6158
- this.toolBatchQueuedAtByKey.set(batchKey, queuedAt);
6216
+ for (const [laneKey, count] of laneCounts) {
6217
+ if (blockedLaneKeys.has(laneKey)) continue;
6218
+ const queuedAt = this.toolDispatchQueuedAtByLane.get(laneKey) ?? nowMs;
6219
+ if (!this.toolDispatchQueuedAtByLane.has(laneKey)) {
6220
+ this.toolDispatchQueuedAtByLane.set(laneKey, queuedAt);
6159
6221
  }
6160
- const coalesceWindowMs =
6161
- this.#options.toolBatchCoalesceWindowMs ??
6162
- TOOL_BATCH_COALESCE_WINDOW_MS;
6163
- const deadline = queuedAt + coalesceWindowMs;
6164
- const maxBatchSize = batchMaxSizes.get(batchKey) ?? 1;
6165
- if (count >= maxBatchSize || deadline <= nowMs) {
6166
- readyBatchKeys.add(batchKey);
6222
+ const lane = lanes.get(laneKey)!;
6223
+ const deadline = queuedAt + lane.coalesceWindowMs;
6224
+ if (count >= lane.readyCount || deadline <= nowMs) {
6225
+ readyLaneKeys.add(laneKey);
6167
6226
  } else {
6168
6227
  nextDeadlineMs =
6169
6228
  nextDeadlineMs == null
@@ -6174,19 +6233,27 @@ export class PlayContextImpl {
6174
6233
 
6175
6234
  const requests: ToolCallRequest[] = [];
6176
6235
  const remaining: ToolCallRequest[] = [];
6236
+ const selectedByLane = new Map<string, number>();
6177
6237
  for (const request of this.toolCallQueue) {
6178
- const batchKey = this.toolBatchCoalesceKey(request);
6179
- if (!batchKey || readyBatchKeys.has(batchKey)) {
6238
+ const lane = this.toolDispatchLane(request);
6239
+ const selectedCount = selectedByLane.get(lane.key) ?? 0;
6240
+ if (
6241
+ readyLaneKeys.has(lane.key) &&
6242
+ requests.length < this.governor.policy.concurrency.toolCalls &&
6243
+ selectedCount < lane.maxGroupSize
6244
+ ) {
6180
6245
  requests.push(request);
6246
+ selectedByLane.set(lane.key, selectedCount + 1);
6181
6247
  } else {
6182
6248
  remaining.push(request);
6183
6249
  }
6184
6250
  }
6185
6251
  this.toolCallQueue = remaining;
6186
- for (const batchKey of readyBatchKeys) {
6187
- this.toolBatchQueuedAtByKey.delete(batchKey);
6252
+ const selectedLaneKeys = new Set(selectedByLane.keys());
6253
+ for (const laneKey of selectedLaneKeys) {
6254
+ this.toolDispatchQueuedAtByLane.delete(laneKey);
6188
6255
  }
6189
- return { requests, nextDeadlineMs };
6256
+ return { requests, laneKeys: selectedLaneKeys, nextDeadlineMs };
6190
6257
  }
6191
6258
 
6192
6259
  private waitForToolDispatcherWake(deadlineMs: number | null): Promise<void> {
@@ -6216,10 +6283,25 @@ export class PlayContextImpl {
6216
6283
  }
6217
6284
 
6218
6285
  private async drainQueuedWork<T>(promises: Promise<T>[]): Promise<void> {
6219
- // One dispatcher owns ready work, fixed batch coalescing deadlines, and
6220
- // the in-flight registry. A row resumes after its own receipt persists; it
6221
- // never waits for unrelated sibling calls from the previous column.
6286
+ // One dispatcher owns ready work, fixed lane coalescing deadlines, and the
6287
+ // bounded in-flight registry. A row resumes after its own receipt persists;
6288
+ // it never waits for unrelated sibling calls from the previous column.
6222
6289
  const inFlightToolExecutions = new Set<Promise<void>>();
6290
+ const activeGroupsByLane = new Map<string, number>();
6291
+ const maxInFlightGroups = Math.max(
6292
+ 1,
6293
+ Math.floor(
6294
+ this.#options.toolDispatcherMaxInFlightGroups ??
6295
+ this.governor.policy.concurrency.toolDispatchGroups,
6296
+ ),
6297
+ );
6298
+ const maxInFlightGroupsPerLane = Math.max(
6299
+ 1,
6300
+ Math.floor(
6301
+ this.#options.toolDispatcherMaxInFlightGroupsPerLane ??
6302
+ this.governor.policy.concurrency.toolDispatchGroupsPerLane,
6303
+ ),
6304
+ );
6223
6305
  let rowsSettled = false;
6224
6306
  void Promise.allSettled(promises).then(() => {
6225
6307
  rowsSettled = true;
@@ -6238,9 +6320,22 @@ export class PlayContextImpl {
6238
6320
  throw this.toolDispatcherFailure;
6239
6321
  }
6240
6322
 
6241
- const dispatchable = this.takeDispatchableToolCalls(
6242
- this.toolBatchDispatcherNowMs(),
6323
+ const blockedLaneKeys = new Set(
6324
+ [...activeGroupsByLane]
6325
+ .filter(([, count]) => count >= maxInFlightGroupsPerLane)
6326
+ .map(([laneKey]) => laneKey),
6243
6327
  );
6328
+ const dispatchable =
6329
+ inFlightToolExecutions.size >= maxInFlightGroups
6330
+ ? {
6331
+ requests: [] as ToolCallRequest[],
6332
+ laneKeys: new Set<string>(),
6333
+ nextDeadlineMs: null,
6334
+ }
6335
+ : this.takeDispatchableToolCalls(
6336
+ this.toolBatchDispatcherNowMs(),
6337
+ blockedLaneKeys,
6338
+ );
6244
6339
  if (dispatchable.requests.length > 0) {
6245
6340
  pass += 1;
6246
6341
  this.log(` Batch pass ${pass}`);
@@ -6249,12 +6344,27 @@ export class PlayContextImpl {
6249
6344
  `queued=${this.toolCallQueue.length} ` +
6250
6345
  `in_flight_groups=${inFlightToolExecutions.size}`,
6251
6346
  );
6347
+ for (const laneKey of dispatchable.laneKeys) {
6348
+ activeGroupsByLane.set(
6349
+ laneKey,
6350
+ (activeGroupsByLane.get(laneKey) ?? 0) + 1,
6351
+ );
6352
+ }
6252
6353
  const tracked = this.executeBatchedToolCalls(dispatchable.requests)
6253
6354
  .catch((error) => {
6254
6355
  this.toolDispatcherFailure ??= error;
6255
6356
  this.rejectQueuedToolCalls(error);
6256
6357
  })
6257
6358
  .finally(() => {
6359
+ for (const laneKey of dispatchable.laneKeys) {
6360
+ const remainingGroups =
6361
+ (activeGroupsByLane.get(laneKey) ?? 1) - 1;
6362
+ if (remainingGroups > 0) {
6363
+ activeGroupsByLane.set(laneKey, remainingGroups);
6364
+ } else {
6365
+ activeGroupsByLane.delete(laneKey);
6366
+ }
6367
+ }
6258
6368
  inFlightToolExecutions.delete(tracked);
6259
6369
  this.wakeToolDispatcher();
6260
6370
  });
@@ -8547,7 +8657,9 @@ export class PlayContextImpl {
8547
8657
  };
8548
8658
 
8549
8659
  while (true) {
8550
- let response: Response;
8660
+ let response: Response | null = null;
8661
+ let responseData: Record<string, unknown> | null = null;
8662
+ let responseErrorText: string | null = null;
8551
8663
  let providerCallStartedAt: number | null = null;
8552
8664
  let providerCallElapsedMs: number | null = null;
8553
8665
  const durableCallReceiptKey =
@@ -8700,12 +8812,31 @@ export class PlayContextImpl {
8700
8812
  : {}),
8701
8813
  }),
8702
8814
  });
8815
+ if (response.ok) {
8816
+ try {
8817
+ responseData = (await response.json()) as Record<
8818
+ string,
8819
+ unknown
8820
+ >;
8821
+ } catch (error) {
8822
+ if (error instanceof SyntaxError) {
8823
+ throw new ToolExecuteInvalidJsonError(error);
8824
+ }
8825
+ throw new ToolExecuteResponseBodyTransportError(error);
8826
+ }
8827
+ } else {
8828
+ try {
8829
+ responseErrorText = await response.text();
8830
+ } catch (error) {
8831
+ throw new ToolExecuteResponseBodyTransportError(error);
8832
+ }
8833
+ }
8703
8834
  } finally {
8704
8835
  providerPermit.release();
8705
8836
  }
8706
8837
  if (runtimeReceiptReadTraceEnabled) {
8707
8838
  this.log(
8708
- `[perf] tool call id=${toolId} phase=integration_fetch_headers elapsed_ms=${Date.now() - integrationFetchStartedAt} status=${response.status}`,
8839
+ `[perf] tool call id=${toolId} phase=integration_fetch_body elapsed_ms=${Date.now() - integrationFetchStartedAt} status=${response.status}`,
8709
8840
  );
8710
8841
  }
8711
8842
  providerCallElapsedMs = Date.now() - providerCallStartedAt;
@@ -8723,13 +8854,80 @@ export class PlayContextImpl {
8723
8854
  if (heartbeatFailure) {
8724
8855
  throw heartbeatFailure;
8725
8856
  }
8857
+ if (error instanceof ToolExecuteInvalidJsonError) {
8858
+ throw new ToolHttpError(
8859
+ `Tool ${toolId} returned an invalid JSON response body.`,
8860
+ null,
8861
+ response?.status ?? 0,
8862
+ 'repairable',
8863
+ );
8864
+ }
8726
8865
  const transportError = abortController?.signal.aborted
8727
8866
  ? abortController.signal.reason instanceof Error
8728
8867
  ? abortController.signal.reason
8729
8868
  : new Error(
8730
8869
  `Tool ${toolId} runtime API call timed out after ${timeoutMs}ms.`,
8731
8870
  )
8732
- : error;
8871
+ : error instanceof ToolExecuteResponseBodyTransportError
8872
+ ? error.cause
8873
+ : error;
8874
+ if (
8875
+ error instanceof ToolExecuteResponseBodyTransportError &&
8876
+ response?.status === 402
8877
+ ) {
8878
+ const diagnostic = describeTransportError(transportError);
8879
+ throw new ToolHttpError(
8880
+ `Tool ${toolId} returned HTTP 402 but its response body could not be read; the run was halted because the payment or capacity denial could not be classified safely: ${diagnostic.message ?? 'unknown transport error'}`,
8881
+ {
8882
+ kind: 'billing_cap_exceeded',
8883
+ code: 'HTTP_402_BODY_UNREADABLE',
8884
+ error_category: 'billing',
8885
+ failure_origin: 'unknown',
8886
+ },
8887
+ 402,
8888
+ );
8889
+ }
8890
+ const responseBodyReplaySafe =
8891
+ !(error instanceof ToolExecuteResponseBodyTransportError) ||
8892
+ retrySafeTransientHttp ||
8893
+ response?.status === 429;
8894
+ if (!responseBodyReplaySafe) {
8895
+ const diagnostic = describeTransportError(transportError);
8896
+ this.log(
8897
+ `[runtime.transport_failure] ${JSON.stringify({
8898
+ tool_id: toolId,
8899
+ gateway_origin: transportGatewayOriginForDiagnostic(url),
8900
+ attempt: 1,
8901
+ max_attempts: 1,
8902
+ elapsed_ms:
8903
+ providerCallStartedAt === null
8904
+ ? 0
8905
+ : Date.now() - providerCallStartedAt,
8906
+ request_id: deeplineRequestId,
8907
+ aborted: abortController?.signal.aborted === true,
8908
+ response_headers_received: true,
8909
+ retry_safe: false,
8910
+ error: diagnostic,
8911
+ })}`,
8912
+ );
8913
+ throw new ToolHttpError(
8914
+ `Tool ${toolId} response body transport failed after response headers; the ambiguous call was not retried because this operation is not declared retry-safe: ${diagnostic.message ?? 'unknown transport error'}`,
8915
+ null,
8916
+ 0,
8917
+ 'repairable',
8918
+ );
8919
+ }
8920
+ if (
8921
+ error instanceof ToolExecuteResponseBodyTransportError &&
8922
+ response?.status === 429
8923
+ ) {
8924
+ await this.reportToolBackpressure(
8925
+ toolId,
8926
+ parseToolExecuteRetryAfterMs(
8927
+ response.headers.get('retry-after'),
8928
+ ),
8929
+ );
8930
+ }
8733
8931
  await retryToolTransportFailure({
8734
8932
  error: transportError,
8735
8933
  elapsedMs:
@@ -8746,10 +8944,15 @@ export class PlayContextImpl {
8746
8944
  }
8747
8945
  }
8748
8946
 
8947
+ if (!response) {
8948
+ throw new Error(
8949
+ `Tool ${toolId} transport completed without an HTTP response.`,
8950
+ );
8951
+ }
8749
8952
  span.setAttribute('plays.http_status_code', response.status);
8750
8953
 
8751
8954
  if (!response.ok) {
8752
- const text = await response.text();
8955
+ const text = responseErrorText ?? '';
8753
8956
  if (
8754
8957
  isDeeplineDeveloperTunnelOrigin502({
8755
8958
  url,
@@ -8864,8 +9067,15 @@ export class PlayContextImpl {
8864
9067
  throw failure.error;
8865
9068
  }
8866
9069
 
8867
- const data = (await response.json()) as Record<string, unknown>;
8868
- const parsed = parseToolExecuteResponse(toolId, data);
9070
+ if (!responseData) {
9071
+ throw new ToolHttpError(
9072
+ `Tool ${toolId} returned an empty successful response body.`,
9073
+ null,
9074
+ response.status,
9075
+ 'repairable',
9076
+ );
9077
+ }
9078
+ const parsed = parseToolExecuteResponse(toolId, responseData);
8869
9079
  setSpanAttributes(span, {
8870
9080
  'plays.tool_result_kind':
8871
9081
  parsed.result == null
@@ -690,11 +690,16 @@ export interface ContextOptions {
690
690
  nowMs: () => number;
691
691
  schedule: (delayMs: number, wake: () => void) => () => void;
692
692
  };
693
- // How long batchable calls wait for same-key row continuations before
694
- // dispatching, measured from the first queued item. Defaults to 5ms in
695
- // production; tests override it to a large value to make coalescing
696
- // deterministic instead of racing wall-clock milliseconds on slow CI.
693
+ // How long provider-native batch calls wait for same-key row continuations
694
+ // before dispatching, measured from the first queued item.
697
695
  toolBatchCoalesceWindowMs?: number;
696
+ // Scalar calls remain separate physical requests, but wait in one scheduling
697
+ // lane for this fixed window so pipelined replacement rows launch together.
698
+ toolScalarCoalesceWindowMs?: number;
699
+ /** Deterministic test override for the runner-local scheduling-group cap. */
700
+ toolDispatcherMaxInFlightGroups?: number;
701
+ /** Deterministic test override for the active-group cap per scheduling lane. */
702
+ toolDispatcherMaxInFlightGroupsPerLane?: number;
698
703
  queryCustomerDb?: CustomerDbQueryHandler;
699
704
  executeStructuredPlayDefinition?: (input: {
700
705
  definition: PlayStructuredDefinition;
@@ -268,6 +268,7 @@ export function createPlayExecutionGovernor(
268
268
  const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
269
269
  const providerByTool = new Map<string, string>();
270
270
  const scopeByTool = new Map<string, [string, string]>();
271
+ const providerConcurrencySlotsByTool = new Map<string, Semaphore>();
271
272
 
272
273
  // When the rate-state backend owns pacing authoritatively (the Absurd/Node
273
274
  // app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
@@ -405,12 +406,38 @@ export function createPlayExecutionGovernor(
405
406
  // the declared rolling window under concurrent runners.
406
407
  const pacing = await resolveAdaptivePacing(toolId);
407
408
  const rateScope = await resolveScope(toolId, pacing.provider);
408
- return await input.rateState.acquire({
409
- bucketId: rateScope[0],
410
- rateScopeToken: rateScope[1],
411
- rules: pacing.rules,
412
- signal: opts?.signal,
413
- });
409
+ const declaredMaxConcurrency = Math.min(
410
+ ...pacing.rules.flatMap((rule) =>
411
+ rule.maxConcurrency != null ? [rule.maxConcurrency] : [],
412
+ ),
413
+ );
414
+ const localConcurrencySlot = Number.isFinite(declaredMaxConcurrency)
415
+ ? await (() => {
416
+ let slots = providerConcurrencySlotsByTool.get(toolId);
417
+ if (!slots) {
418
+ slots = new Semaphore(Math.max(1, declaredMaxConcurrency));
419
+ providerConcurrencySlotsByTool.set(toolId, slots);
420
+ }
421
+ return slots.acquire(opts?.signal);
422
+ })()
423
+ : null;
424
+ try {
425
+ const rateLease = await input.rateState.acquire({
426
+ bucketId: rateScope[0],
427
+ rateScopeToken: rateScope[1],
428
+ rules: pacing.rules,
429
+ signal: opts?.signal,
430
+ });
431
+ return {
432
+ release() {
433
+ rateLease.release();
434
+ localConcurrencySlot?.release();
435
+ },
436
+ };
437
+ } catch (error) {
438
+ localConcurrencySlot?.release();
439
+ throw error;
440
+ }
414
441
  },
415
442
 
416
443
  async suggestedParallelism(toolId, fallback) {
@@ -28,6 +28,10 @@ export interface ExecutionConcurrencyPolicy {
28
28
  readonly rowMax: number;
29
29
  /** Global backstop on concurrently in-flight tool calls across all providers. */
30
30
  readonly toolCalls: number;
31
+ /** Runner-local cap on concurrently active tool scheduling groups. */
32
+ readonly toolDispatchGroups: number;
33
+ /** Runner-local cap on active scheduling groups for one scalar/batch lane. */
34
+ readonly toolDispatchGroupsPerLane: number;
31
35
  }
32
36
 
33
37
  /**
@@ -88,6 +92,14 @@ export const SHARED_EXECUTION_POLICY: ResolvedExecutionPolicy = {
88
92
  // Global all-provider backstop. Per-provider pacing is the real limit; this
89
93
  // just stops a single run from opening an absurd number of sockets at once.
90
94
  toolCalls: 256,
95
+ // Scheduling groups contain bounded-dispatch workers and receipt buffers.
96
+ // Keep their promise topology finite even when pipelined replacement rows
97
+ // become ready one at a time behind a slower provider pacer.
98
+ toolDispatchGroups: 32,
99
+ // Preserve limited overlap so a fast row can enter its next column while a
100
+ // prior group's receipt persistence is still settling. Provider permits
101
+ // enforce declared maxConcurrency across these overlapping groups.
102
+ toolDispatchGroupsPerLane: 4,
91
103
  },
92
104
  budgets: {
93
105
  // Runaway guards, not workload limits. A 5,000-row map calling several tools
@@ -17,10 +17,7 @@ export function isAbortLikeError(error: unknown): boolean {
17
17
  if (!error) return false;
18
18
  if (error instanceof WorkflowAbortError) return true;
19
19
  if (error instanceof Error) {
20
- if (error.name === 'WorkflowAbort' || error.name === 'AbortError') {
21
- return true;
22
- }
23
- return /\b(cancell?ed|aborted|terminate[d]?)\b/i.test(error.message);
20
+ return error.name === 'WorkflowAbort' || error.name === 'AbortError';
24
21
  }
25
22
  return false;
26
23
  }
@@ -107,6 +107,10 @@ export type PlaySchedulerSubmitInput = {
107
107
  userEmail: string;
108
108
  userId?: string | null;
109
109
  source?: 'published' | 'ad_hoc' | 'draft';
110
+ /** Invocation origin, distinct from the artifact source above. */
111
+ triggerSource?: 'api' | 'webhook' | 'cron' | 'sql_listener';
112
+ /** Null lets a durable trigger wait for capacity; a number bounds queue delay. */
113
+ queueMaxDelaySeconds?: number | null;
110
114
  executionProfile?: string | null;
111
115
  /** runner backend to use for executing attempts */
112
116
  runtimeBackend: string;
package/dist/cli/index.js CHANGED
@@ -718,7 +718,7 @@ var SDK_RELEASE = {
718
718
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
719
719
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
720
720
  // Operators use the checkout-local deepline-admin binary instead.
721
- version: "0.1.284",
721
+ version: "0.1.285",
722
722
  contracts: {
723
723
  api: {
724
724
  name: "sdk-http-api",
@@ -703,7 +703,7 @@ var SDK_RELEASE = {
703
703
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
704
704
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
705
705
  // Operators use the checkout-local deepline-admin binary instead.
706
- version: "0.1.284",
706
+ version: "0.1.285",
707
707
  contracts: {
708
708
  api: {
709
709
  name: "sdk-http-api",
package/dist/index.js CHANGED
@@ -438,7 +438,7 @@ var SDK_RELEASE = {
438
438
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
439
439
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
440
440
  // Operators use the checkout-local deepline-admin binary instead.
441
- version: "0.1.284",
441
+ version: "0.1.285",
442
442
  contracts: {
443
443
  api: {
444
444
  name: "sdk-http-api",
package/dist/index.mjs CHANGED
@@ -367,7 +367,7 @@ var SDK_RELEASE = {
367
367
  // 0.1.253 makes play-page browser opening opt-in and retires --no-open.
368
368
  // 0.1.254 removes the internal operations tree from the published SDK CLI.
369
369
  // Operators use the checkout-local deepline-admin binary instead.
370
- version: "0.1.284",
370
+ version: "0.1.285",
371
371
  contracts: {
372
372
  api: {
373
373
  name: "sdk-http-api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepline",
3
- "version": "0.1.284",
3
+ "version": "0.1.285",
4
4
  "description": "Deepline SDK + CLI — B2B data enrichment powered by durable cloud execution",
5
5
  "license": "MIT",
6
6
  "repository": {