experimental-ash 0.22.0 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/docs/public/sandbox.md +25 -0
  3. package/dist/src/chunks/{dev-authored-source-watcher-DKDaaPea.js → dev-authored-source-watcher-BLzYWh05.js} +1 -1
  4. package/dist/src/chunks/host-DREC8e8Z.js +65 -0
  5. package/dist/src/chunks/{paths-DZTgjrW-.js → paths-C6sp4T2U.js} +25 -25
  6. package/dist/src/chunks/{prewarm-BELT37PI.js → prewarm-hz8p2jlZ.js} +1 -1
  7. package/dist/src/cli/commands/info.js +1 -1
  8. package/dist/src/cli/run.js +1 -1
  9. package/dist/src/evals/cli/eval.js +1 -1
  10. package/dist/src/execution/sandbox/bindings/vercel.d.ts +1 -1
  11. package/dist/src/execution/sandbox/bindings/vercel.js +38 -6
  12. package/dist/src/harness/action-result-helpers.d.ts +9 -6
  13. package/dist/src/harness/action-result-helpers.js +23 -16
  14. package/dist/src/harness/model-call-error.d.ts +16 -0
  15. package/dist/src/harness/model-call-error.js +71 -0
  16. package/dist/src/harness/provider-tools.d.ts +33 -2
  17. package/dist/src/harness/provider-tools.js +81 -0
  18. package/dist/src/harness/step-hooks.d.ts +21 -0
  19. package/dist/src/harness/step-hooks.js +7 -2
  20. package/dist/src/harness/tool-loop.js +284 -143
  21. package/dist/src/harness/tools.d.ts +12 -0
  22. package/dist/src/harness/tools.js +23 -5
  23. package/dist/src/internal/application/package.js +1 -1
  24. package/dist/src/internal/nitro/host/build-application.js +67 -1
  25. package/dist/src/internal/workflow-bundle/ash-service-route-output.d.ts +4 -0
  26. package/dist/src/internal/workflow-bundle/ash-service-route-output.js +134 -0
  27. package/dist/src/internal/workflow-bundle/vercel-workflow-output.d.ts +17 -0
  28. package/dist/src/internal/workflow-bundle/vercel-workflow-output.js +141 -1
  29. package/dist/src/public/definitions/connections/mcp.js +2 -0
  30. package/dist/src/public/definitions/tool.js +2 -0
  31. package/dist/src/public/next/index.js +7 -2
  32. package/dist/src/public/sandbox/backends/vercel.d.ts +7 -0
  33. package/dist/src/public/sandbox/backends/vercel.js +7 -0
  34. package/dist/src/public/sandbox/vercel-sandbox.d.ts +14 -4
  35. package/dist/src/public/tool-result-narrowing.d.ts +10 -7
  36. package/dist/src/public/tool-result-narrowing.js +42 -13
  37. package/dist/src/runtime/resolve-connection.js +5 -2
  38. package/dist/src/runtime/resolve-tool.js +5 -2
  39. package/package.json +1 -1
  40. package/dist/src/chunks/host-Btr4S69C.js +0 -22
@@ -9,6 +9,7 @@ import { createCompactionCompletedEvent, createCompactionRequestedEvent, createI
9
9
  import { ASK_QUESTION_TOOL_NAME } from "#runtime/framework-tools/ask-question.js";
10
10
  import { ConnectionRegistryKey, DiscoveredConnectionToolsKey, } from "#runtime/framework-tools/connection-search.js";
11
11
  import { resolveConnectionToolsFromState } from "#runtime/framework-tools/connection-tools.js";
12
+ import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js";
12
13
  import { hydrateSandboxAttachments, stageAttachmentsToSandbox, } from "#harness/attachment-staging.js";
13
14
  import { compactMessages, getInputTokenCount, resolveCompactionModel, shouldCompact, } from "#harness/compaction.js";
14
15
  import { advanceStep, emitFailedStep, emitRecoverableFailedTurn, emitStreamContent, emitTurnEpilogue, emitTurnPreamble, getHarnessEmissionState, setHarnessEmissionState, } from "#harness/emission.js";
@@ -16,9 +17,10 @@ import { extractQuestionInputRequests, extractToolApprovalInputRequests, } from
16
17
  import { consumeDeferredStepInput, getApprovedTools, hasDeferredStepInput, hasStepInput, resolvePendingInput, setPendingInputBatch, } from "#harness/input-requests.js";
17
18
  import { getInstrumentationConfig } from "#harness/instrumentation-config.js";
18
19
  import { resolveAssistantStepText } from "#harness/messages.js";
19
- import { classifyModelCallError, extractModelCallErrorDetails, summarizeKnownModelCallConfigError, summarizeKnownModelCallRequestError, } from "#harness/model-call-error.js";
20
+ import { classifyModelCallError, extractModelCallErrorDetails, extractUnsupportedProviderToolTypes, summarizeKnownModelCallConfigError, summarizeKnownModelCallRequestError, } from "#harness/model-call-error.js";
20
21
  import { ensureOtelIntegration } from "#harness/otel-integration.js";
21
22
  import { applyLastToolCacheBreakpoint, detectPromptCachePath, getAnthropicCacheMarker, } from "#harness/prompt-cache.js";
23
+ import { resolveFrameworkToolFromUpstreamType, resolveGatewayPinForWebSearchBackend, resolveWebSearchBackend, } from "#harness/provider-tools.js";
22
24
  import { createRuntimeActionRequestFromToolCall, resolvePendingRuntimeActions, setPendingRuntimeActionBatch, } from "#harness/runtime-actions.js";
23
25
  import { buildStepHooks, emitStepActions, isInvalidToolCall, } from "#harness/step-hooks.js";
24
26
  import { pruneToolResults } from "#harness/tool-result-pruning.js";
@@ -81,6 +83,36 @@ function buildTelemetryRuntimeContext(authored, session) {
81
83
  "ash.version": ashVersion,
82
84
  };
83
85
  }
86
+ /**
87
+ * Resolves the gateway provider slug to pin via
88
+ * `providerOptions.gateway.only` for one harness step, or `undefined`
89
+ * when no pin is needed.
90
+ *
91
+ * A pin is added when all of:
92
+ * 1. The model is gateway-routed (the `gateway-auto` cache path —
93
+ * matches the existing `gateway.caching` hint condition).
94
+ * 2. The effective toolset includes a framework provider tool whose
95
+ * backend pins to one provider (e.g. `web_search` on Anthropic).
96
+ *
97
+ * The author keeps the final say via `providerOptions.gateway.only` or
98
+ * `.order` on their model reference — those overrides flow through
99
+ * {@link mergeGatewayProviderPin} which is a no-op when either field is
100
+ * already set.
101
+ */
102
+ function resolveGatewayPinForStep(input) {
103
+ if (input.cachePath.kind !== "gateway-auto") {
104
+ return undefined;
105
+ }
106
+ if (input.tools[WEB_SEARCH_TOOL_DEFINITION.name] === undefined) {
107
+ return undefined;
108
+ }
109
+ const backend = resolveWebSearchBackend(input.modelReference);
110
+ if (backend === null) {
111
+ return undefined;
112
+ }
113
+ const pin = resolveGatewayPinForWebSearchBackend(backend);
114
+ return pin ?? undefined;
115
+ }
84
116
  /**
85
117
  * Builds AI Gateway app attribution headers when the model is gateway-routed.
86
118
  *
@@ -260,33 +292,11 @@ export function createToolLoopHarness(config) {
260
292
  telemetry: enrichTelemetry(telemetryConfig, agentName) ?? undefined,
261
293
  }));
262
294
  const approvedTools = getApprovedTools(session);
263
- const tools = await buildToolSetWithProviderTools({
264
- approvedTools,
265
- capabilities: config.capabilities,
266
- modelReference: session.agent.modelReference,
267
- tools: config.tools,
268
- });
269
295
  // Inject connection tools discovered by a prior `connection_search`.
270
296
  // Direct harness unit tests may run without an ambient context.
271
297
  const ctx = contextStorage.getStore();
272
298
  const registry = ctx?.get(ConnectionRegistryKey);
273
299
  const discovered = ctx?.get(DiscoveredConnectionToolsKey);
274
- if (registry !== undefined && discovered !== undefined) {
275
- const connectionTools = await resolveConnectionToolsFromState(registry, discovered, {
276
- approvedTools,
277
- existingToolNames: new Set(Object.keys(tools)),
278
- });
279
- Object.assign(tools, connectionTools);
280
- }
281
- const effectiveTools = marker ? applyLastToolCacheBreakpoint(tools, marker) : tools;
282
- // --- Build hooks --------------------------------------------------------
283
- const hooks = buildStepHooks({
284
- cachePath,
285
- emit,
286
- emissionState,
287
- marker,
288
- session,
289
- });
290
300
  // --- Execute via ToolLoopAgent ------------------------------------------
291
301
  /*
292
302
  * The `onError` override suppresses the AI SDK's default
@@ -321,146 +331,215 @@ export function createToolLoopHarness(config) {
321
331
  }
322
332
  }
323
333
  }
324
- const instructions = systemMessages.length > 0
325
- ? [
326
- ...(session.agent.system
327
- ? [{ role: "system", content: session.agent.system }]
328
- : []),
329
- ...systemMessages,
330
- ]
331
- : session.agent.system || undefined;
332
- const agentSettings = {
333
- headers: attributionHeaders,
334
- instructions,
335
- model,
336
- onError() { },
337
- onStepFinish: hooks.onStepFinish,
338
- prepareStep: hooks.prepareStep,
339
- runtimeContext: buildTelemetryRuntimeContext(telemetryConfig, session),
340
- stopWhen: isStepCount(1),
341
- telemetry: enrichTelemetry(telemetryConfig, agentName),
342
- tools: effectiveTools,
343
- };
344
- const agent = new ToolLoopAgent(agentSettings);
345
334
  const modelMessages = nonSystemMessages;
346
- let result;
347
- const executeModelCall = async () => {
348
- if (emit) {
349
- const streamResult = await agent.stream({ messages: modelMessages });
350
- const { inlineActionResultCallIds, inlineToolResultParts } = await emitStreamContent(emit, emissionState, streamResult.fullStream);
351
- const stepResult = await hooks.stepResult;
352
- await emitStepActions(emit, emissionState, stepResult, {
353
- excludedActionToolNames: new Set([ASK_QUESTION_TOOL_NAME]),
354
- inlineActionResultCallIds,
355
- tools: config.tools,
335
+ /**
336
+ * Assembles the effective toolset, instructions, and ToolLoopAgent
337
+ * for one attempt of this step, then runs the model call.
338
+ *
339
+ * Called twice in the recovery path: once for the original attempt
340
+ * with full tools, and once after the gateway rejects a
341
+ * provider-specific tool the retry passes `disabledProviderTools`
342
+ * to drop the offending tool and `extraSystemNote` to tell the
343
+ * model why a capability was removed.
344
+ */
345
+ const runOneModelCall = async (opts) => {
346
+ const tools = await buildToolSetWithProviderTools({
347
+ approvedTools,
348
+ capabilities: config.capabilities,
349
+ disabledProviderTools: opts.disabledProviderTools,
350
+ modelReference: session.agent.modelReference,
351
+ tools: config.tools,
352
+ });
353
+ if (registry !== undefined && discovered !== undefined) {
354
+ const connectionTools = await resolveConnectionToolsFromState(registry, discovered, {
355
+ approvedTools,
356
+ existingToolNames: new Set(Object.keys(tools)),
356
357
  });
357
- if (inlineToolResultParts.length > 0) {
358
- /*
359
- * AI SDK `StepResult` is a class whose `content`,
360
- * `toolCalls`, `toolResults`, `text` are prototype getters.
361
- * Each field is read explicitly here rather than via spread
362
- * so the returned plain object carries the values — spread
363
- * would copy only own enumerable properties and the
364
- * downstream `extractQuestionInputRequests` would crash on
365
- * `toolCalls === undefined`.
366
- */
367
- return {
368
- content: stepResult.content,
369
- finishReason: stepResult.finishReason,
370
- response: {
371
- ...stepResult.response,
372
- messages: [
373
- { role: "tool", content: [...inlineToolResultParts] },
374
- ...stepResult.response.messages,
375
- ],
376
- },
377
- text: stepResult.text,
378
- toolCalls: stepResult.toolCalls,
379
- toolResults: stepResult.toolResults,
380
- usage: stepResult.usage,
381
- };
382
- }
383
- return stepResult;
358
+ Object.assign(tools, connectionTools);
384
359
  }
385
- await agent.generate({ messages: modelMessages });
386
- return await hooks.stepResult;
387
- };
388
- try {
389
- result = await runModelCallWithRetries(executeModelCall, {
360
+ const effectiveTools = marker ? applyLastToolCacheBreakpoint(tools, marker) : tools;
361
+ // Pin gateway routing to the provider that owns any
362
+ // provider-specific tool in this step's toolset. Converts a
363
+ // transient primary outage into a retryable 503 instead of
364
+ // routing to an incompatible fallback provider. Skipped on the
365
+ // recovery retry because the offending tool was dropped — any
366
+ // provider can serve the request now.
367
+ const gatewayPinProvider = resolveGatewayPinForStep({
368
+ cachePath,
369
+ modelReference: session.agent.modelReference,
370
+ tools: effectiveTools,
371
+ });
372
+ // Build instructions, optionally prepending the recovery
373
+ // system note so the model sees one explicit signal that a
374
+ // capability was removed for this attempt.
375
+ const extraSystemEntry = opts.extraSystemNote
376
+ ? [{ role: "system", content: opts.extraSystemNote }]
377
+ : [];
378
+ const baseSystemEntry = session.agent.system
379
+ ? [{ role: "system", content: session.agent.system }]
380
+ : [];
381
+ const instructions = systemMessages.length > 0 || extraSystemEntry.length > 0
382
+ ? [...extraSystemEntry, ...baseSystemEntry, ...systemMessages]
383
+ : session.agent.system || undefined;
384
+ const hooks = buildStepHooks({
385
+ cachePath,
386
+ emit,
387
+ emissionState,
388
+ emitStepStarted: opts.suppressStepStartedEmission !== true,
389
+ gatewayPinProvider,
390
+ marker,
391
+ session,
392
+ });
393
+ const agentSettings = {
394
+ headers: attributionHeaders,
395
+ instructions,
396
+ model,
397
+ onError() { },
398
+ onStepFinish: hooks.onStepFinish,
399
+ prepareStep: hooks.prepareStep,
400
+ runtimeContext: buildTelemetryRuntimeContext(telemetryConfig, session),
401
+ stopWhen: isStepCount(1),
402
+ telemetry: enrichTelemetry(telemetryConfig, agentName),
403
+ tools: effectiveTools,
404
+ };
405
+ const agent = new ToolLoopAgent(agentSettings);
406
+ const executeModelCall = async () => {
407
+ if (emit) {
408
+ const streamResult = await agent.stream({ messages: modelMessages });
409
+ const { inlineActionResultCallIds, inlineToolResultParts } = await emitStreamContent(emit, emissionState, streamResult.fullStream);
410
+ const stepResult = await hooks.stepResult;
411
+ await emitStepActions(emit, emissionState, stepResult, {
412
+ excludedActionToolNames: new Set([ASK_QUESTION_TOOL_NAME]),
413
+ inlineActionResultCallIds,
414
+ tools: config.tools,
415
+ });
416
+ if (inlineToolResultParts.length > 0) {
417
+ /*
418
+ * AI SDK `StepResult` is a class whose `content`,
419
+ * `toolCalls`, `toolResults`, `text` are prototype getters.
420
+ * Each field is read explicitly here rather than via spread
421
+ * so the returned plain object carries the values — spread
422
+ * would copy only own enumerable properties and the
423
+ * downstream `extractQuestionInputRequests` would crash on
424
+ * `toolCalls === undefined`.
425
+ */
426
+ return {
427
+ content: stepResult.content,
428
+ finishReason: stepResult.finishReason,
429
+ response: {
430
+ ...stepResult.response,
431
+ messages: [
432
+ { role: "tool", content: [...inlineToolResultParts] },
433
+ ...stepResult.response.messages,
434
+ ],
435
+ },
436
+ text: stepResult.text,
437
+ toolCalls: stepResult.toolCalls,
438
+ toolResults: stepResult.toolResults,
439
+ usage: stepResult.usage,
440
+ };
441
+ }
442
+ return stepResult;
443
+ }
444
+ await agent.generate({ messages: modelMessages });
445
+ return await hooks.stepResult;
446
+ };
447
+ return runModelCallWithRetries(executeModelCall, {
390
448
  sessionId: session.sessionId,
391
449
  turnId: emissionState.turnId,
392
450
  });
451
+ };
452
+ let result;
453
+ try {
454
+ result = await runOneModelCall({});
393
455
  }
394
456
  catch (error) {
395
- // Surface the full cause chain + upstream responseBody to OTel
396
- // via the turn span. The AI SDK's automatic
397
- // `span.recordException(err)` on its own `ai.streamText` span
398
- // only captures `error.stack` and does not traverse `cause`,
399
- // so the gateway-wrapped upstream 4xx body would otherwise be
400
- // invisible to OTel providers.
401
- if (turnSpan) {
402
- recordErrorOnSpan(turnSpan, error);
403
- }
404
- if (!emit) {
405
- // Internal harness callers without an emit fn (tests, task-only
406
- // code paths) get the raw throw. Only runtime-connected harness
407
- // calls go through the structured failure path below.
408
- throw error;
409
- }
410
- const classification = classifyModelCallError(error);
411
- const errorId = createErrorId();
412
- const configSummary = classification === "terminal" ? summarizeKnownModelCallConfigError(error) : null;
413
- const requestSummary = configSummary === null ? summarizeKnownModelCallRequestError(error) : null;
414
- const errorMessage = configSummary?.message ?? requestSummary?.message ?? toErrorMessage(error);
415
- const modelCallDetails = extractModelCallErrorDetails(error);
416
- const details = buildModelCallFailureDetails({
417
- configSummary,
457
+ // First, try to recover by dropping any provider-specific tool
458
+ // that the AI Gateway's fallback provider rejected. This converts
459
+ // the otherwise-terminal failure into one extra model call with a
460
+ // degraded toolset the agent's turn proceeds without the
461
+ // capability the chosen provider could not serve.
462
+ const recoveryResult = await attemptUnsupportedProviderToolRecovery({
418
463
  error,
419
- errorId,
420
- modelCallDetails,
421
- requestSummary,
422
- });
423
- const modelCallLogFields = buildModelCallFailureLogFields({
424
- error,
425
- errorId,
426
- modelCallDetails,
427
- requestSummary,
464
+ runOneModelCall,
428
465
  sessionId: session.sessionId,
429
466
  turnId: emissionState.turnId,
430
467
  });
431
- if (classification === "terminal") {
432
- if (configSummary !== null) {
433
- // Recognized configuration failure: log a concise single line
434
- // and skip the structured SDK dump so the user sees an
435
- // actionable hint instead of a wall of inspector output.
436
- log.error(`${configSummary.name}: ${configSummary.message}`, {
437
- errorId,
468
+ if (recoveryResult.outcome === "recovered") {
469
+ result = recoveryResult.result;
470
+ }
471
+ else {
472
+ // Surface the full cause chain + upstream responseBody to OTel
473
+ // via the turn span. The AI SDK's automatic
474
+ // `span.recordException(err)` on its own `ai.streamText` span
475
+ // only captures `error.stack` and does not traverse `cause`,
476
+ // so the gateway-wrapped upstream 4xx body would otherwise be
477
+ // invisible to OTel providers.
478
+ const finalError = recoveryResult.error;
479
+ if (turnSpan) {
480
+ recordErrorOnSpan(turnSpan, finalError);
481
+ }
482
+ if (!emit) {
483
+ // Internal harness callers without an emit fn (tests, task-only
484
+ // code paths) get the raw throw. Only runtime-connected harness
485
+ // calls go through the structured failure path below.
486
+ throw finalError;
487
+ }
488
+ const classification = classifyModelCallError(finalError);
489
+ const errorId = createErrorId();
490
+ const configSummary = classification === "terminal" ? summarizeKnownModelCallConfigError(finalError) : null;
491
+ const requestSummary = configSummary === null ? summarizeKnownModelCallRequestError(finalError) : null;
492
+ const errorMessage = configSummary?.message ?? requestSummary?.message ?? toErrorMessage(finalError);
493
+ const modelCallDetails = extractModelCallErrorDetails(finalError);
494
+ const details = buildModelCallFailureDetails({
495
+ configSummary,
496
+ error: finalError,
497
+ errorId,
498
+ modelCallDetails,
499
+ requestSummary,
500
+ });
501
+ const modelCallLogFields = buildModelCallFailureLogFields({
502
+ error: finalError,
503
+ errorId,
504
+ modelCallDetails,
505
+ requestSummary,
506
+ sessionId: session.sessionId,
507
+ turnId: emissionState.turnId,
508
+ });
509
+ if (classification === "terminal") {
510
+ if (configSummary !== null) {
511
+ // Recognized configuration failure: log a concise single line
512
+ // and skip the structured SDK dump so the user sees an
513
+ // actionable hint instead of a wall of inspector output.
514
+ log.error(`${configSummary.name}: ${configSummary.message}`, {
515
+ errorId,
516
+ sessionId: session.sessionId,
517
+ turnId: emissionState.turnId,
518
+ });
519
+ }
520
+ else {
521
+ log.error(requestSummary?.message ?? "model call failed terminally", modelCallLogFields);
522
+ }
523
+ await emitFailedStep(emit, emissionState, {
524
+ code: "MODEL_CALL_FAILED",
525
+ details,
526
+ message: errorMessage,
438
527
  sessionId: session.sessionId,
439
- turnId: emissionState.turnId,
440
528
  });
529
+ return {
530
+ next: { done: true, output: "" },
531
+ session,
532
+ };
441
533
  }
442
- else {
443
- log.error(requestSummary?.message ?? "model call failed terminally", modelCallLogFields);
444
- }
445
- await emitFailedStep(emit, emissionState, {
534
+ log.error(requestSummary?.message ?? "model call failed — parking session for retry by the user", modelCallLogFields);
535
+ emissionState = await emitRecoverableFailedTurn(emit, emissionState, {
446
536
  code: "MODEL_CALL_FAILED",
447
537
  details,
448
538
  message: errorMessage,
449
- sessionId: session.sessionId,
450
539
  });
451
- return {
452
- next: { done: true, output: "" },
453
- session,
454
- };
540
+ const parkedSession = setHarnessEmissionState(session, emissionState);
541
+ return { next: null, session: parkedSession };
455
542
  }
456
- log.error(requestSummary?.message ?? "model call failed — parking session for retry by the user", modelCallLogFields);
457
- emissionState = await emitRecoverableFailedTurn(emit, emissionState, {
458
- code: "MODEL_CALL_FAILED",
459
- details,
460
- message: errorMessage,
461
- });
462
- const parkedSession = setHarnessEmissionState(session, emissionState);
463
- return { next: null, session: parkedSession };
464
543
  }
465
544
  // --- Handle result ------------------------------------------------------
466
545
  return handleStepResult({
@@ -533,6 +612,68 @@ function buildModelCallFailureLogFields(input) {
533
612
  }
534
613
  return { ...base, error: input.error };
535
614
  }
615
+ /**
616
+ * Inspects a model-call failure for the "tool type 'X' is not supported"
617
+ * provider-attempt rejection that AI Gateway returns when a fallback
618
+ * provider cannot serve a provider-specific tool. On a match, retries the
619
+ * step once with the offending tool dropped and a one-shot system note
620
+ * telling the model which capability has been removed.
621
+ *
622
+ * Returns `recovered` when the retry succeeded so the caller can hand
623
+ * the result off to the usual post-step handler. Returns `failed`
624
+ * (with the original error, or the retry's error if the retry also
625
+ * threw) otherwise so the caller's existing terminal/recoverable
626
+ * cascade still runs.
627
+ *
628
+ * Recovery is intentionally scoped to known provider tools — entries in
629
+ * {@link UPSTREAM_TOOL_TYPE_TO_FRAMEWORK_NAME} — so an unrelated
630
+ * upstream rejection cannot accidentally drop a user-authored tool.
631
+ */
632
+ async function attemptUnsupportedProviderToolRecovery(input) {
633
+ const unsupportedTypes = extractUnsupportedProviderToolTypes(input.error);
634
+ if (unsupportedTypes.length === 0) {
635
+ return { outcome: "failed", error: input.error };
636
+ }
637
+ const toolsToDisable = [];
638
+ for (const type of unsupportedTypes) {
639
+ const frameworkName = resolveFrameworkToolFromUpstreamType(type);
640
+ if (frameworkName !== null && !toolsToDisable.includes(frameworkName)) {
641
+ toolsToDisable.push(frameworkName);
642
+ }
643
+ }
644
+ if (toolsToDisable.length === 0) {
645
+ return { outcome: "failed", error: input.error };
646
+ }
647
+ log.warn("disabling unsupported provider tool(s); retrying step once", {
648
+ disabled: toolsToDisable,
649
+ sessionId: input.sessionId,
650
+ turnId: input.turnId,
651
+ upstreamTypes: unsupportedTypes,
652
+ });
653
+ try {
654
+ const result = await input.runOneModelCall({
655
+ disabledProviderTools: new Set(toolsToDisable),
656
+ extraSystemNote: buildDisabledToolNote(toolsToDisable),
657
+ suppressStepStartedEmission: true,
658
+ });
659
+ return { outcome: "recovered", result };
660
+ }
661
+ catch (retryError) {
662
+ return { outcome: "failed", error: retryError };
663
+ }
664
+ }
665
+ /**
666
+ * Builds the one-shot system note prepended to the recovery retry's
667
+ * instructions so the model has explicit context for why a capability
668
+ * disappeared mid-turn.
669
+ */
670
+ function buildDisabledToolNote(toolNames) {
671
+ const list = toolNames.join(", ");
672
+ const noun = toolNames.length === 1 ? "tool is" : "tools are";
673
+ return (`The following ${noun} not available with the current model and ` +
674
+ `has been removed: ${list}. Proceed using the remaining tools or your ` +
675
+ `training knowledge.`);
676
+ }
536
677
  // ---------------------------------------------------------------------------
537
678
  // Post-step result handling
538
679
  // ---------------------------------------------------------------------------
@@ -12,10 +12,16 @@ import type { HarnessToolMap } from "#harness/types.js";
12
12
  * {@link SessionCapabilities.requestInput} is `true`. Sessions without
13
13
  * the HITL capability (scheduled task roots and any subagent chain
14
14
  * descending from one) never see the tool.
15
+ *
16
+ * Entries listed in `disabledProviderTools` are skipped entirely. Used
17
+ * by the harness recovery path when a gateway fallback provider has
18
+ * rejected a provider-specific tool — the tool is dropped for the
19
+ * retry call so the request can proceed without it.
15
20
  */
16
21
  export declare function buildToolSet(input: {
17
22
  readonly approvedTools?: ReadonlySet<string>;
18
23
  readonly capabilities?: SessionCapabilities;
24
+ readonly disabledProviderTools?: ReadonlySet<string>;
19
25
  readonly tools: HarnessToolMap;
20
26
  }): ToolSet;
21
27
  /**
@@ -29,10 +35,16 @@ export declare function buildToolSet(input: {
29
35
  * When a user overrides a provider-managed tool via `defineTool()`, their
30
36
  * tool has a real executor and flows through the normal path — no
31
37
  * replacement occurs.
38
+ *
39
+ * Tool names listed in `disabledProviderTools` are skipped entirely —
40
+ * both the framework definition and the injected provider tool are
41
+ * omitted from the returned set. Used by the harness recovery path when
42
+ * a gateway fallback provider has rejected a provider-specific tool.
32
43
  */
33
44
  export declare function buildToolSetWithProviderTools(input: {
34
45
  readonly approvedTools?: ReadonlySet<string>;
35
46
  readonly capabilities?: SessionCapabilities;
47
+ readonly disabledProviderTools?: ReadonlySet<string>;
36
48
  readonly modelReference: RuntimeModelReference;
37
49
  readonly tools: HarnessToolMap;
38
50
  }): Promise<ToolSet>;
@@ -13,14 +13,23 @@ import { resolveWebSearchBackend, resolveWebSearchProviderTool } from "#harness/
13
13
  * {@link SessionCapabilities.requestInput} is `true`. Sessions without
14
14
  * the HITL capability (scheduled task roots and any subagent chain
15
15
  * descending from one) never see the tool.
16
+ *
17
+ * Entries listed in `disabledProviderTools` are skipped entirely. Used
18
+ * by the harness recovery path when a gateway fallback provider has
19
+ * rejected a provider-specific tool — the tool is dropped for the
20
+ * retry call so the request can proceed without it.
16
21
  */
17
22
  export function buildToolSet(input) {
18
23
  const tools = {};
19
24
  const canRequestInput = input.capabilities?.requestInput === true;
25
+ const disabled = input.disabledProviderTools;
20
26
  for (const definition of input.tools.values()) {
21
27
  if (definition.name === ASK_QUESTION_TOOL_NAME && !canRequestInput) {
22
28
  continue;
23
29
  }
30
+ if (disabled?.has(definition.name)) {
31
+ continue;
32
+ }
24
33
  tools[definition.name] = tool({
25
34
  description: definition.description,
26
35
  execute: definition.execute,
@@ -46,22 +55,31 @@ export function buildToolSet(input) {
46
55
  * When a user overrides a provider-managed tool via `defineTool()`, their
47
56
  * tool has a real executor and flows through the normal path — no
48
57
  * replacement occurs.
58
+ *
59
+ * Tool names listed in `disabledProviderTools` are skipped entirely —
60
+ * both the framework definition and the injected provider tool are
61
+ * omitted from the returned set. Used by the harness recovery path when
62
+ * a gateway fallback provider has rejected a provider-specific tool.
49
63
  */
50
64
  export async function buildToolSetWithProviderTools(input) {
65
+ const disabled = input.disabledProviderTools;
51
66
  const tools = {
52
67
  ...buildToolSet({
53
68
  approvedTools: input.approvedTools,
54
69
  capabilities: input.capabilities,
70
+ disabledProviderTools: disabled,
55
71
  tools: input.tools,
56
72
  }),
57
73
  };
58
74
  // Inject the real provider tool for web_search when the definition has
59
75
  // no local execute (i.e. the framework definition uses the provider sentinel).
60
- const webSearchTool = input.tools.get(WEB_SEARCH_TOOL_DEFINITION.name);
61
- if (webSearchTool !== undefined && webSearchTool.execute === undefined) {
62
- const backend = resolveWebSearchBackend(input.modelReference);
63
- if (backend !== null) {
64
- tools[WEB_SEARCH_TOOL_DEFINITION.name] = await resolveWebSearchProviderTool(backend);
76
+ if (!disabled?.has(WEB_SEARCH_TOOL_DEFINITION.name)) {
77
+ const webSearchTool = input.tools.get(WEB_SEARCH_TOOL_DEFINITION.name);
78
+ if (webSearchTool !== undefined && webSearchTool.execute === undefined) {
79
+ const backend = resolveWebSearchBackend(input.modelReference);
80
+ if (backend !== null) {
81
+ tools[WEB_SEARCH_TOOL_DEFINITION.name] = await resolveWebSearchProviderTool(backend);
82
+ }
65
83
  }
66
84
  }
67
85
  return tools;
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
6
6
  let cachedPackageInfo;
7
7
  // The package build stamps the published version into `dist` so bundled
8
8
  // deployments can still report package metadata without resolving package.json.
9
- const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.22.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.22.2";
10
10
  const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
11
11
  const WORKFLOW_MODULE_ALIASES = {
12
12
  "workflow/api": "src/compiled/@workflow/core/runtime.js",