diff --git a/src/const.ts b/src/const.ts index 73a804c9a..5dcd29c7a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -247,6 +247,7 @@ export const APIFY_ERROR_TYPE_FULL_PERMISSION_NOT_APPROVED = 'full-permission-ac export const APIFY_ERROR_TYPE_MEMORY_LIMIT_EXCEEDED = 'memory-limit-exceeded'; export const APIFY_ERROR_TYPE_CANNOT_START_ACTOR_RUNS = 'cannot-start-actor-runs'; export const APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK = 'cannot-publish-actor-task'; +export const APIFY_ERROR_TYPE_INVALID_INPUT = 'invalid-input'; // HTTP status codes export const HTTP_UNAUTHORIZED = 401; diff --git a/src/mcp/tool_dispatch.ts b/src/mcp/tool_dispatch.ts index 4ce71a458..77a19d2b4 100644 --- a/src/mcp/tool_dispatch.ts +++ b/src/mcp/tool_dispatch.ts @@ -241,6 +241,7 @@ export async function dispatchToolCall(params: { }); const executorResult = await actorExecutor.executeActorTool({ actorFullName: tool.actorFullName, + actorId: tool.actorId, input: toolArgs, apifyClient, callOptions: { memory: tool.memoryMbytes }, diff --git a/src/tools/actors/actor_executor.ts b/src/tools/actors/actor_executor.ts index 9bc1fe51b..0166bc5a3 100644 --- a/src/tools/actors/actor_executor.ts +++ b/src/tools/actors/actor_executor.ts @@ -1,8 +1,11 @@ import log from '@apify/log'; +import { FAILURE_CATEGORY } from '../../const.js'; import type { ActorExecutionParams, ActorExecutionResult, ActorExecutor } from '../../types.js'; +import { isActorInputValidationError } from '../../utils/apify_errors.js'; import { getConsoleLinkContext } from '../../utils/console_link.js'; import { redactSkyfirePayId } from '../../utils/logging.js'; +import { buildInvalidInputTexts, respondUserError } from '../../utils/mcp.js'; import { buildGetActorRunResponse } from '../runs/get_actor_run.js'; import { abortRunOnSignal, CALL_ACTOR_WAIT_SECS_DEFAULT, fetchActorRunData } from './actor_run_response.js'; @@ -37,7 +40,22 @@ export const actorExecutor: ActorExecutor = { return null; } - const actorRun = await apifyClient.actor(actorFullName).start(actorInput, params.callOptions); + let actorRun; + try { + actorRun = await apifyClient.actor(actorFullName).start(actorInput, params.callOptions); + } catch (error) { + // Platform can reject input our own AJV gate passed (see isActorInputValidationError). + if (!isActorInputValidationError(error)) throw error; + log.softFail('Actor input failed platform validation', { + actorName: actorFullName, + mcpSessionId, + failureCategory: FAILURE_CATEGORY.INVALID_INPUT, + }); + return respondUserError(buildInvalidInputTexts(actorFullName, error.message), { + actorId: params.actorId, + detail: error.message.slice(0, 200), + }) as ActorExecutionResult; + } log.debug('Started Actor run (direct actor tool)', { actorName: actorFullName, diff --git a/src/tools/actors/call_actor.ts b/src/tools/actors/call_actor.ts index 2cd74cc87..b8c6dc7ed 100644 --- a/src/tools/actors/call_actor.ts +++ b/src/tools/actors/call_actor.ts @@ -31,6 +31,7 @@ import { getActorDefinitionCached, getActorMcpUrlCached } from '../../utils/acto import { compileSchema } from '../../utils/ajv.js'; import { ACTOR_RUN_LIMIT_MESSAGE, + isActorInputValidationError, isActorRunLimitError, isMemoryQuotaError, isPermissionApprovalError, @@ -40,6 +41,7 @@ import { getConsoleLinkContext } from '../../utils/console_link.js'; import { wrapJsonText } from '../../utils/encode_text.js'; import { logHttpError } from '../../utils/logging.js'; import { + buildInvalidInputTexts, respondAborted, respondRaw, respondServerError, @@ -97,6 +99,8 @@ type CallActorErrorResponseParams = { mcpSessionId?: string; /** Names of all currently loaded tools — gates which recovery tools this error may name. */ loadedToolNames: readonly string[]; + /** Actor's input schema, echoed back only on a confirmed platform input-validation error. */ + inputSchema?: ToolInputSchema; }; /** Names only the recovery tools actually loaded in this session — omits the sentence if none are. */ @@ -169,7 +173,7 @@ export function buildCallActorAppsDescription(ctx: ToolDescriptionContext = ALL_ } export function buildCallActorErrorResponse(params: CallActorErrorResponseParams): ToolResponse { - const { actorName, error, actorId, mcpSessionId, loadedToolNames } = params; + const { actorName, error, actorId, mcpSessionId, loadedToolNames, inputSchema } = params; if (isPermissionApprovalError(error)) { logHttpError(error, 'Failed to call Actor — permission approval required', { @@ -212,6 +216,14 @@ export function buildCallActorErrorResponse(params: CallActorErrorResponseParams }); } + // Checked before the generic fallback below: the Actor was found fine, only its input wasn't. + if (isActorInputValidationError(error)) { + return respondUserError(buildInvalidInputTexts(actorName, errMsg, inputSchema), { + actorId, + detail: errMsg.slice(0, 200), + }); + } + return respondServerError( [ `Failed to call Actor '${actorName}': ${errMsg}`, @@ -614,6 +626,7 @@ export async function executeCallActor(toolArgs: InternalToolArgs): Promise; /** Apify client (may include payment headers) */ diff --git a/src/utils/apify_errors.ts b/src/utils/apify_errors.ts index 4baad47df..e8e1b724f 100644 --- a/src/utils/apify_errors.ts +++ b/src/utils/apify_errors.ts @@ -4,6 +4,7 @@ import { APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK, APIFY_ERROR_TYPE_CANNOT_START_ACTOR_RUNS, APIFY_ERROR_TYPE_FULL_PERMISSION_NOT_APPROVED, + APIFY_ERROR_TYPE_INVALID_INPUT, APIFY_ERROR_TYPE_MEMORY_LIMIT_EXCEEDED, } from '../const.js'; @@ -26,6 +27,14 @@ export function isCannotPublishTaskError(error: unknown): error is ApifyApiError return error instanceof ApifyApiError && error.type === APIFY_ERROR_TYPE_CANNOT_PUBLISH_ACTOR_TASK; } +/** + * True when the platform rejected `actor.start()` because the input fails the Actor's real + * schema — a stricter, server-side check than our own AJV gate's derived/shortened copy. + */ +export function isActorInputValidationError(error: unknown): error is ApifyApiError { + return error instanceof ApifyApiError && error.type === APIFY_ERROR_TYPE_INVALID_INPUT; +} + /** * The Apify platform refuses to start a run when the user hits their concurrent-run / usage limit. * A direct Actor run surfaces it as an `ApifyApiError` whose `type` is `cannot-start-actor-runs`; diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 9bbc4a0b4..9635c34ea 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -1,7 +1,13 @@ import type { CallToolResult, ContentBlock } from '@modelcontextprotocol/sdk/types.js'; import { FAILURE_CATEGORY, TOOL_STATUS } from '../const.js'; -import type { AjvErrorDetails, ApifyRequestParams, FailureCategory, ToolTelemetryContext } from '../types.js'; +import type { + AjvErrorDetails, + ApifyRequestParams, + FailureCategory, + ToolInputSchema, + ToolTelemetryContext, +} from '../types.js'; import { ACTOR_RUN_LIMIT_MESSAGE, isActorRunLimitError, isCannotPublishTaskError } from './apify_errors.js'; import { wrapJsonText } from './encode_text.js'; import { getHttpStatusCode } from './logging.js'; @@ -298,3 +304,12 @@ export function getToolCallErrorUserText(toolName: string, error: unknown): stri const hint = getHttpErrorHint(getHttpStatusCode(error)) ?? 'Verify the tool name and input parameters.'; return `Error calling tool "${toolName}": ${msg}. ${hint}`; } + +/** Texts for a confirmed platform input-validation error — same shape wherever start() can throw one. */ +export function buildInvalidInputTexts(actorName: string, errMsg: string, inputSchema?: ToolInputSchema): string[] { + return [ + `Failed to call Actor '${actorName}': ${errMsg}`, + `Please ensure the input is correct and matches the Actor's input schema.`, + ...(inputSchema ? [`Input schema:\n${wrapJsonText(inputSchema)}`] : []), + ]; +} diff --git a/tests/unit/tools.actor_executor.test.ts b/tests/unit/tools.actor_executor.test.ts index 9d336e1c7..96d3e3860 100644 --- a/tests/unit/tools.actor_executor.test.ts +++ b/tests/unit/tools.actor_executor.test.ts @@ -1,8 +1,11 @@ import type { ActorRun } from 'apify-client'; -import { describe, expect, it } from 'vitest'; +import { ApifyApiError } from 'apify-client'; +import type { AxiosResponse } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; import { actorExecutor } from '../../src/tools/actors/actor_executor.js'; import type { ActorExecutionParams } from '../../src/types.js'; +import { textOf } from './helpers/tool_context.js'; /** * The executor's three migration-specific responsibilities: @@ -145,6 +148,42 @@ describe('actorExecutor', () => { }); }); + describe('platform input-validation error', () => { + function invalidInputError(message: string): ApifyApiError { + return new ApifyApiError( + { data: { error: { type: 'invalid-input', message } }, status: 400 } as AxiosResponse, + 1, + ); + } + + it('returns the platform message instead of throwing, on a confirmed invalid-input error (no schema — already in tools/list)', async () => { + const { params } = buildParams({ query: 'foo' }, { actorId: 'actor-1' }); + params.apifyClient = { + ...params.apifyClient, + actor: () => ({ + start: vi.fn().mockRejectedValue(invalidInputError('query: must be a non-empty string')), + }), + } as unknown as ActorExecutionParams['apifyClient']; + + const result = await actorExecutor.executeActorTool(params); + + const text = (result?.content ?? []).map(textOf).join('\n'); + expect(text).toContain('query: must be a non-empty string'); + expect(text).toContain('Please ensure the input is correct'); + expect(text).not.toContain('Input schema'); + }); + + it('rethrows any other actor.start() error unchanged', async () => { + const { params } = buildParams({ query: 'foo' }); + params.apifyClient = { + ...params.apifyClient, + actor: () => ({ start: vi.fn().mockRejectedValue(new Error('socket hang up')) }), + } as unknown as ActorExecutionParams['apifyClient']; + + await expect(actorExecutor.executeActorTool(params)).rejects.toThrow('socket hang up'); + }); + }); + describe('success path', () => { it('returns the canonical RunResponse shape (no inline items, dataset id under storages)', async () => { const { params } = buildParams({ query: 'foo' }); diff --git a/tests/unit/tools.call_actor_common.test.ts b/tests/unit/tools.call_actor_common.test.ts index 8c516963b..615608024 100644 --- a/tests/unit/tools.call_actor_common.test.ts +++ b/tests/unit/tools.call_actor_common.test.ts @@ -249,6 +249,65 @@ describe('call_actor_common', () => { }), ); }); + + it('echoes the input schema and platform message for a start() invalid-input error, not the generic fallback', () => { + const error = new ApifyApiError( + { + data: { + error: { + type: 'invalid-input', + message: 'Input is not valid: query: must be a non-empty string', + }, + }, + status: 400, + } as AxiosResponse, + 1, + ); + const inputSchema = { type: 'object', properties: { query: { type: 'string' } } } as const; + + const response = buildCallActorErrorResponse({ + actorName: 'apify/instagram-scraper', + error, + actorId: 'actor-321', + loadedToolNames: [HELPER_TOOLS.STORE_SEARCH, HELPER_TOOLS.ACTOR_GET_DETAILS], + inputSchema, + }); + + expect(response.isError).toBe(true); + const allText = (response.content ?? []).map(textOf).join('\n'); + expect(allText).toContain(JSON.stringify(inputSchema)); + expect(allText).toContain('query: must be a non-empty string'); + expect(allText).toContain('Please ensure the input is correct'); + // Must not point the agent at the wrong problem (Actor name / existence). + expect(allText).not.toContain('verify the Actor name'); + expect(response.toolTelemetry).toEqual( + expect.objectContaining({ + toolStatus: TOOL_STATUS.SOFT_FAIL, + failureCategory: FAILURE_CATEGORY.INVALID_INPUT, + actorId: 'actor-321', + }), + ); + }); + + it('still returns the platform message (without a schema) when no inputSchema was resolved', () => { + const error = new ApifyApiError( + { + data: { error: { type: 'invalid-input', message: 'Input is not valid: bad JSON' } }, + status: 400, + } as AxiosResponse, + 1, + ); + + const response = buildCallActorErrorResponse({ + actorName: 'apify/instagram-scraper', + error, + loadedToolNames: [HELPER_TOOLS.STORE_SEARCH, HELPER_TOOLS.ACTOR_GET_DETAILS], + }); + + const allText = (response.content ?? []).map(textOf).join('\n'); + expect(allText).toContain('Input is not valid: bad JSON'); + expect(allText).not.toContain('verify the Actor name'); + }); }); describe('callActorArgs.callOptions', () => { diff --git a/tests/unit/utils.apify_errors.test.ts b/tests/unit/utils.apify_errors.test.ts index fb89c916d..fb2aba7fe 100644 --- a/tests/unit/utils.apify_errors.test.ts +++ b/tests/unit/utils.apify_errors.test.ts @@ -3,6 +3,7 @@ import type { AxiosResponse } from 'axios'; import { describe, expect, it } from 'vitest'; import { + isActorInputValidationError, isActorRunLimitError, isMemoryQuotaError, isPermissionApprovalError, @@ -41,6 +42,14 @@ describe('isPermissionApprovalError', () => { }); }); +describe('isActorInputValidationError', () => { + it('matches only the invalid-input ApifyApiError type, not the sibling invalid-input-schema type', () => { + expect(isActorInputValidationError(apifyApiError('invalid-input', 400))).toBe(true); + expect(isActorInputValidationError(apifyApiError('invalid-input-schema', 400))).toBe(false); + expect(isActorInputValidationError(new Error('Input is not valid'))).toBe(false); + }); +}); + describe('remoteMcpFailureDetail', () => { it('returns the billing message for the wrapped concurrent-run limit', () => { const detail = remoteMcpFailureDetail(