From 4022aa8de99816fcd2cca32c61a4a67338734f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:58:52 +0000 Subject: [PATCH 1/2] fix: State facts instead of asking for a retry in served hints The 429 and 5xx hints every tool returns, the task publish error, the delete-actor unfinished-runs error, and call-actor's memory quota error and callOptions.memory description now say what happened or what an option does instead of asking for another call. Closes #1558 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CPzYK17FoL2F3cuVonqnej --- src/tools/actors/call_actor.ts | 4 ++-- src/tools/actors/delete_actor.ts | 4 ++-- src/utils/mcp.ts | 10 +++++----- tests/unit/resources.api.test.ts | 4 ++-- tests/unit/tool_call_error_mapper.test.ts | 4 ++++ tests/unit/tools.apify_api.test.ts | 2 +- tests/unit/tools.call_actor_common.test.ts | 17 +++++++++++++++-- tests/unit/tools.delete_actor.test.ts | 3 ++- tests/unit/utils.mcp.test.ts | 14 ++++++++------ 9 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/tools/actors/call_actor.ts b/src/tools/actors/call_actor.ts index 1f35b6415..912f9c387 100644 --- a/src/tools/actors/call_actor.ts +++ b/src/tools/actors/call_actor.ts @@ -244,7 +244,7 @@ export function buildCallActorErrorResponse(params: CallActorErrorResponseParams return respondServerError( [ `Failed to call Actor '${actorName}': ${errMsg}`, - `Account memory quota exceeded for your plan. Retry with a smaller callOptions.memory, or wait for current runs to finish before retrying.`, + `Account memory quota exceeded for your plan. A smaller callOptions.memory may fit the remaining quota; memory is freed when current runs finish.`, ], { error, detail: APIFY_ERROR_TYPE_MEMORY_LIMIT_EXCEEDED, actorId }, ); @@ -283,7 +283,7 @@ export const callOptionsSchema = z.object({ .max(32768, 'Memory cannot exceed 32 GB (32768 MB)') .optional().describe(dedent` Memory per run in MB. Power of 2 from 128 to 32768. - Apify also caps total memory across all your concurrent runs (account plan limit); if a run is rejected because that quota would be exceeded, retry with a smaller value. + Apify also caps total memory across all your concurrent runs (account plan limit); a run that would exceed that quota is rejected. `), timeout: z.number().min(0, 'Timeout must be 0 or greater').optional().describe(dedent` Maximum runtime for the Actor in seconds. After this time elapses, the Actor will be automatically terminated. diff --git a/src/tools/actors/delete_actor.ts b/src/tools/actors/delete_actor.ts index ff39f4c3a..b18d17237 100644 --- a/src/tools/actors/delete_actor.ts +++ b/src/tools/actors/delete_actor.ts @@ -121,8 +121,8 @@ export const deleteActor: ToolEntry = Object.freeze({ const more = unfinishedRunCount > unfinishedRuns.items.length ? ', and more' : ''; return respondUserError( `${fullName} has ${unfinishedRunCount} unfinished ${unfinishedRunCount === 1 ? 'run' : 'runs'} ` + - `(${runIds}${more}), and deleting it aborts them. Ask the user whether to abort them, and if ` + - 'so call again with abortRunningRuns set to true; otherwise wait until they finish.', + `(${runIds}${more}), and deleting it aborts them. Ask the user whether to abort them. ` + + 'abortRunningRuns=true deletes it anyway, which aborts them; otherwise the Actor can be deleted once they finish.', ); } await client.actor(actor.id).delete(); diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 1ee08fa62..b395592a4 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -282,16 +282,16 @@ export function computeToolResponseBytes(result: unknown): { } /** - * Actionable hint for an HTTP failure status, or `undefined` when the status carries no specific - * remedy. Shared by the tool-call and resources/read error paths so both differentiate auth, rate-limit + * Hint for an HTTP failure status (auth, access, rate limit, server failure), or `undefined` when the + * status has none. Shared by the tool-call and resources/read error paths so both differentiate auth, rate-limit * and server failures the same way (the model's only lever is the text it gets back). */ export function getHttpErrorHint(status: number | undefined): string | undefined { if (status === 403) return 'The resource may be private or your token may lack access.'; if (status === 401) return 'Authentication failed, check APIFY_TOKEN is set and valid.'; - if (status === 429) return 'Rate limit exceeded, wait before retrying.'; + if (status === 429) return 'The Apify API rate limit was reached.'; if (status !== undefined && status >= 500 && status < 600) { - return 'The server failed to process the request, likely a temporary issue. Retry later.'; + return 'The Apify API failed to process the request; this is usually temporary.'; } return undefined; } @@ -312,7 +312,7 @@ export function getToolCallErrorUserText(toolName: string, error: unknown): stri return ( `Error calling tool "${toolName}": ${msg}.\n\n` + 'The API validates supplied `publicConfig` fields on create and update, and all publication ' + - 'requirements on publish. Fix the stated requirement before retrying. If `datasetView` is invalid, ' + + 'requirements on publish. The error message names the requirement that is not met. If `datasetView` is invalid, ' + 'ask the user for its key; no tool can list dataset views.' ); } diff --git a/tests/unit/resources.api.test.ts b/tests/unit/resources.api.test.ts index 02b2f171d..82f2543d4 100644 --- a/tests/unit/resources.api.test.ts +++ b/tests/unit/resources.api.test.ts @@ -456,7 +456,7 @@ describe('readApiResource()', () => { expect(error).toBeInstanceOf(InternalError); expect(error.message).toContain(`Failed to read ${uri}: HTTP 500: Internal Server Error`); - expect(error.message).toContain('Retry later'); + expect(error.message).toContain('this is usually temporary'); }); it('falls back to statusText when a non-2xx error body itself crosses the limit', async () => { @@ -499,7 +499,7 @@ describe('readApiResource()', () => { expect(error).toBeInstanceOf(InternalError); expect(error.message).toContain('HTTP 429: Too many requests'); - expect(error.message).toContain('Rate limit exceeded'); + expect(error.message).toContain('rate limit was reached'); }); it('appends the private-resource hint for a 403 response', async () => { diff --git a/tests/unit/tool_call_error_mapper.test.ts b/tests/unit/tool_call_error_mapper.test.ts index c5c722f87..77d5f44f4 100644 --- a/tests/unit/tool_call_error_mapper.test.ts +++ b/tests/unit/tool_call_error_mapper.test.ts @@ -151,6 +151,10 @@ describe('buildToolCallErrorResult()', () => { expect('userText' in result && result.userText).toContain( `Error calling tool "${HELPER_TOOLS.ACTOR_TASK_PUBLISH}":`, ); + expect('userText' in result && result.userText).toContain( + 'all publication requirements on publish. The error message names the requirement that is not met.', + ); + expect('userText' in result && result.userText).not.toMatch(/retry/i); }); it('returns ABORTED toolStatus for an aborted execution error', () => { diff --git a/tests/unit/tools.apify_api.test.ts b/tests/unit/tools.apify_api.test.ts index 5b4d25b7a..12dacfa41 100644 --- a/tests/unit/tools.apify_api.test.ts +++ b/tests/unit/tools.apify_api.test.ts @@ -1197,7 +1197,7 @@ describe('apify-api-write', () => { it.each([ { statusCode: 300, hint: '' }, { statusCode: 400, hint: '' }, - { statusCode: 429, hint: ' Rate limit exceeded, wait before retrying.' }, + { statusCode: 429, hint: ' The Apify API rate limit was reached.' }, ])( 'reports a failed write whose error body is over the inline limit with its status $statusCode', async ({ statusCode, hint }) => { diff --git a/tests/unit/tools.call_actor_common.test.ts b/tests/unit/tools.call_actor_common.test.ts index 86cbf83e9..f29738e01 100644 --- a/tests/unit/tools.call_actor_common.test.ts +++ b/tests/unit/tools.call_actor_common.test.ts @@ -16,6 +16,7 @@ import { buildCallActorDescription, buildCallActorErrorResponse, callActorArgs, + callOptionsSchema, handleMcpToolCall, resolveAndValidateActor, } from '../../src/tools/actors/call_actor.js'; @@ -260,8 +261,11 @@ describe('call_actor_common', () => { expect(response.isError).toBe(true); const allText = (response.content ?? []).map(textOf).join('\n'); expect(allText).toContain('memory limit of 8192MB'); - expect(allText).toContain('Account memory quota exceeded'); - expect(allText).toContain('callOptions.memory'); + expect(allText).toContain( + 'Account memory quota exceeded for your plan. A smaller callOptions.memory may fit the remaining ' + + 'quota; memory is freed when current runs finish.', + ); + expect(allText).not.toMatch(/retry/i); // Regression: must not nudge the LLM toward aborting unrelated runs to free capacity. expect(allText).not.toContain(HELPER_TOOLS.ACTOR_RUNS_ABORT); expect(allText).not.toContain('verify the Actor name'); @@ -401,6 +405,15 @@ describe('call_actor_common', () => { }); expect(result.success).toBe(false); }); + + it('states the account memory quota without asking for a retry', () => { + const { description } = callOptionsSchema.shape.memory; + expect(description).toContain( + 'Apify also caps total memory across all your concurrent runs (account plan limit); a run that ' + + 'would exceed that quota is rejected.', + ); + expect(description).not.toMatch(/retry/i); + }); }); describe('resolveAndValidateActor', () => { diff --git a/tests/unit/tools.delete_actor.test.ts b/tests/unit/tools.delete_actor.test.ts index 775c30fb7..a4fb867ff 100644 --- a/tests/unit/tools.delete_actor.test.ts +++ b/tests/unit/tools.delete_actor.test.ts @@ -132,7 +132,8 @@ describe('delete-actor', () => { }); expect(text).toBe( 'john/my-actor has 2 unfinished runs (run-1, run-2), and deleting it aborts them. Ask the user whether ' + - 'to abort them, and if so call again with abortRunningRuns set to true; otherwise wait until they finish.', + 'to abort them. abortRunningRuns=true deletes it anyway, which aborts them; otherwise the Actor ' + + 'can be deleted once they finish.', ); expect(actorDeleteMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/utils.mcp.test.ts b/tests/unit/utils.mcp.test.ts index 6fb71a028..ad32cb20a 100644 --- a/tests/unit/utils.mcp.test.ts +++ b/tests/unit/utils.mcp.test.ts @@ -236,12 +236,14 @@ describe('getHttpErrorHint()', () => { }); it('returns the rate-limit hint for 429', () => { - expect(getHttpErrorHint(429)).toContain('Rate limit exceeded'); + expect(getHttpErrorHint(429)).toBe('The Apify API rate limit was reached.'); }); it('returns the server-error hint for 5xx', () => { for (const status of [500, 503, 599]) { - expect(getHttpErrorHint(status)).toContain('server failed'); + expect(getHttpErrorHint(status)).toBe( + 'The Apify API failed to process the request; this is usually temporary.', + ); } }); @@ -270,14 +272,14 @@ describe('getToolCallErrorUserText()', () => { it('replaces the input hint with the rate-limit hint for 429', () => { const error = Object.assign(new Error('Too Many Requests'), { statusCode: 429 }); const text = getToolCallErrorUserText('apify/instagram-scraper', error); - expect(text).toContain('Rate limit exceeded'); + expect(text).toContain('rate limit was reached'); expect(text).not.toContain('Verify the tool name'); }); it('replaces the input hint with the server-error hint for 5xx', () => { const error = Object.assign(new Error('Service Unavailable'), { statusCode: 503 }); const text = getToolCallErrorUserText('apify/instagram-scraper', error); - expect(text).toContain('server failed'); + expect(text).toContain('failed to process the request'); expect(text).not.toContain('Verify the tool name'); }); @@ -293,8 +295,8 @@ describe('getToolCallErrorUserText()', () => { it.each([ [401, 'Authentication failed, check APIFY_TOKEN is set and valid.'], [403, 'The resource may be private or your token may lack access.'], - [429, 'Rate limit exceeded, wait before retrying.'], - [500, 'The server failed to process the request, likely a temporary issue. Retry later.'], + [429, 'The Apify API rate limit was reached.'], + [500, 'The Apify API failed to process the request; this is usually temporary.'], ])('keeps the HTTP %i hint', (statusCode, hint) => { const error = Object.assign(new Error('Request failed'), { statusCode }); const text = getToolCallErrorUserText('get-actor-run', error); From 8adf62e3f62d19b89982458692437683151c69e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:05:02 +0000 Subject: [PATCH 2/2] fix: Say that abortRunningRuns aborts the runs before deleting Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PDo97anS1pnMowW1uNQeHD --- src/tools/actors/delete_actor.ts | 2 +- tests/unit/tools.delete_actor.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/actors/delete_actor.ts b/src/tools/actors/delete_actor.ts index b18d17237..7496d5c6f 100644 --- a/src/tools/actors/delete_actor.ts +++ b/src/tools/actors/delete_actor.ts @@ -122,7 +122,7 @@ export const deleteActor: ToolEntry = Object.freeze({ return respondUserError( `${fullName} has ${unfinishedRunCount} unfinished ${unfinishedRunCount === 1 ? 'run' : 'runs'} ` + `(${runIds}${more}), and deleting it aborts them. Ask the user whether to abort them. ` + - 'abortRunningRuns=true deletes it anyway, which aborts them; otherwise the Actor can be deleted once they finish.', + 'abortRunningRuns=true aborts them before deleting; otherwise the Actor can be deleted once they finish.', ); } await client.actor(actor.id).delete(); diff --git a/tests/unit/tools.delete_actor.test.ts b/tests/unit/tools.delete_actor.test.ts index a4fb867ff..2e0206338 100644 --- a/tests/unit/tools.delete_actor.test.ts +++ b/tests/unit/tools.delete_actor.test.ts @@ -132,7 +132,7 @@ describe('delete-actor', () => { }); expect(text).toBe( 'john/my-actor has 2 unfinished runs (run-1, run-2), and deleting it aborts them. Ask the user whether ' + - 'to abort them. abortRunningRuns=true deletes it anyway, which aborts them; otherwise the Actor ' + + 'to abort them. abortRunningRuns=true aborts them before deleting; otherwise the Actor ' + 'can be deleted once they finish.', ); expect(actorDeleteMock).not.toHaveBeenCalled();