Skip to content

Commit 76a61d9

Browse files
jirispilkaclaude
andauthored
refactor: Consolidate Actor tool presentation policies (#1586)
## Why Stacked on #1585 (`fix/direct-actor-contracts`). Merge #1585 first; this PR contains only the presentation-policy refactor. RAG Web Browser and Web Fetch still have Actor-name branches in schema generation and description assembly. The existing policy registry can hold those rules. ## What changed Move RAG's exposed inputs, Web Fetch's URL description note, and both tools' extra descriptions into policy entries. Remove the `isRag` schema-builder argument and the Actor-name branches in those consumers. Presentation rules retain their existing generic-call behavior. WCC defaults and denied inputs remain under a separate direct-only input policy. RAG's hidden inputs remain accepted, annotation checks still inspect the original properties, and description wording and session gating stay unchanged. No public/internal exports, result shapes, or hosted-server integration changes. ## Notes for reviewers (human-written) ## Proof it works - Four characterization cases passed before and after the refactor. Final unit suite: 2,663 passed, 1 skipped. Type-check, lint, format, check:agents, and build passed. - mcpc tools-list results from the pre-refactor and post-refactor builds were identical for all 10 tools in the comparison configuration, including schemas, descriptions, and annotations. - An independent review found no actionable defects and independently ran 104 focused tests. AI-assisted: Codex wrote the refactor, tests, documentation, and this description, ran verification, and used an independent review agent. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f421e1 commit 76a61d9

7 files changed

Lines changed: 140 additions & 84 deletions

File tree

‎src/const.ts‎

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -123,44 +123,7 @@ export const REPORT_PROBLEM_BLOCKED_CLIENTS: string[] = ['claude', 'anthropic',
123123
export const APIFY_AI_CLIENT_NAME = 'apify-ai';
124124

125125
export const RAG_WEB_BROWSER = 'apify/rag-web-browser';
126-
export const RAG_WEB_BROWSER_WHITELISTED_FIELDS = ['query', 'maxResults', 'outputFormats'];
127-
export const RAG_WEB_BROWSER_ADDITIONAL_DESC = `Use this tool for generic web content: it searches the web by query and returns the text of the top results.
128-
This tool scrapes the data itself - it does NOT just find tools.
129-
130-
Examples of when to use:
131-
- General questions answered by web pages (e.g., "What's the weather in Prague?", "Latest news about the EU AI Act", "How does HTTP/3 work?")
132-
133-
For detailed data from a specific platform (e.g. Amazon products with prices, Instagram posts, Google Maps places with ratings), a specialized Actor from the Apify Store returns complete, structured results; this tool returns page text only.`;
134-
135126
export const WEB_FETCH = 'apify/web-fetch';
136-
/** Follows RAG_WEB_BROWSER_ADDITIONAL_DESC on its own line when the session serves the apify/web-fetch tool. */
137-
export const RAG_WEB_BROWSER_WEB_FETCH_HINT = `When the user provides one specific URL and wants that page's full or verbatim content, prefer the dedicated ${WEB_FETCH} tool - this tool is for searching and scraping by query.`;
138-
/**
139-
* Appended to the `apify/web-fetch` Actor tool description. Client-agnostic on purpose:
140-
* no references to any specific client or its built-in tools, so the same text works
141-
* for every MCP client.
142-
*/
143-
export const WEB_FETCH_ADDITIONAL_DESC = `Use this tool to fetch a specific http(s) URL and return its complete content (one URL per call; http and https only).
144-
It renders JavaScript and bypasses anti-bot protection, so it also retrieves pages where a plain HTTP fetch gets blocked, fails with an error such as 403 or 429, or returns incomplete content.
145-
The page comes back verbatim - full content, no summarization - as Markdown, plain text, HTML, the raw response body, or the list of links on the page.
146-
147-
Examples of when to use:
148-
- User provides a URL and wants its content, its data or links, or an answer that requires reading that page
149-
- A previous attempt to fetch a page was blocked, returned an error or CAPTCHA, or came back incomplete because the page needs JavaScript rendering
150-
- Verbatim page content is needed, e.g. for quoting, extraction, or archiving
151-
152-
This tool does not search the web - it needs a URL. To find pages by query, use a web search tool instead.
153-
If the exact URL cannot be fetched (e.g. a non-http(s) scheme), say so rather than silently substituting a different URL.`;
154-
155-
/**
156-
* Appended to the `url` parameter description of the `apify/web-fetch` tool. Lives on the
157-
* parameter because that is what an agent reads while writing the argument: agents
158-
* silently rewrote ftp:// URLs to https:// instead of telling the user the scheme is
159-
* unsupported.
160-
*/
161-
export const WEB_FETCH_URL_SCHEME_NOTE =
162-
'http(s) URLs only - for any other scheme (e.g. ftp:), tell the user this tool cannot fetch it rather than substituting a different URL.';
163-
164127
export const defaults = {
165128
actors: [RAG_WEB_BROWSER, WEB_FETCH],
166129
};

‎src/tools/AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic.
1919
- `actors/` — search, details, call, the list of the account's Actors (`get-actor-list`), delete
2020
(`delete_actor.ts`), the actor-tools factory, the direct actor-tool executor (`actor_executor.ts`),
2121
`actor_definition.ts` (fetches and prunes an Actor's definition, `getActorDefinition`),
22-
`actor_tool_contract.ts` (direct-tool input restrictions and defaults, shared by schema generation,
23-
the call engine's denied-input check and execution; generic calls use the original contract), and
22+
`actor_tool_contract.ts` (Actor-specific presentation policies plus direct-tool input restrictions
23+
and defaults, shared by schema generation, the call engine's denied-input check and execution;
24+
generic calls skip direct-only restrictions and defaults), and
2425
`actor_run_response.ts` —
2526
the one canonical run shape `call-actor` and `get-actor-run` share across sync, task
2627
and wait-timeout modes: storage IDs plus a `summary` (past) / `nextStep` (one primary

‎src/tools/actor_input_schema.ts‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import type { ValidateFunction } from 'ajv';
22
import type Ajv from 'ajv';
33

4-
import {
5-
ACTOR_ENUM_MAX_LENGTH,
6-
ACTOR_MAX_DESCRIPTION_LENGTH,
7-
RAG_WEB_BROWSER_WHITELISTED_FIELDS,
8-
WEB_FETCH,
9-
WEB_FETCH_URL_SCHEME_NOTE,
10-
} from '../const.js';
4+
import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../const.js';
115
import { SchemaTooLargeError } from '../errors.js';
126
import type { ActorInputSchema, SchemaProperties } from '../types.js';
137
import { fixZodSchemaRequired } from '../utils/ajv.js';
@@ -20,6 +14,7 @@ import {
2014
addResourcePickerProperties as addArrayResourcePickerProperties,
2115
} from '../utils/apify_properties.js';
2216
import { getToolSchemaID } from './actor_tool_naming.js';
17+
import { getActorToolPolicy } from './actors/actor_tool_contract.js';
2318

2419
type ActorInputSchemaProperties = Record<string, SchemaProperties>;
2520

@@ -165,7 +160,7 @@ export function markInputPropertiesAsRequired(input: ActorInputSchema): Record<s
165160
/**
166161
* Builds the final Actor input schema for MCP tool usage.
167162
*/
168-
export function buildActorInputSchema(actorFullName: string, input: ActorInputSchema | undefined, isRag: boolean) {
163+
export function buildActorInputSchema(actorFullName: string, input: ActorInputSchema | undefined) {
169164
if (!input) {
170165
return {
171166
inputSchema: {
@@ -201,13 +196,12 @@ export function buildActorInputSchema(actorFullName: string, input: ActorInputSc
201196
delete (working as { $schema?: string }).$schema;
202197
}
203198

204-
let finalSchema = working;
205-
if (isRag) {
206-
finalSchema = pruneSchemaPropertiesByWhitelist(finalSchema, RAG_WEB_BROWSER_WHITELISTED_FIELDS);
207-
}
208-
209-
if (actorFullName === WEB_FETCH && finalSchema.properties.url) {
210-
finalSchema.properties.url.description += ` ${WEB_FETCH_URL_SCHEME_NOTE}`;
199+
const policy = getActorToolPolicy(actorFullName);
200+
const finalSchema = policy?.exposedInputs
201+
? pruneSchemaPropertiesByWhitelist(working, policy.exposedInputs)
202+
: working;
203+
for (const [name, description] of Object.entries(policy?.inputDescriptionSuffixes ?? {})) {
204+
if (finalSchema.properties[name]) finalSchema.properties[name].description += ` ${description}`;
211205
}
212206

213207
finalSchema.$id = getToolSchemaID(actorFullName);
@@ -219,8 +213,7 @@ export function buildActorInputSchema(actorFullName: string, input: ActorInputSc
219213
* and filters the required array accordingly. All other top-level fields are preserved.
220214
* If properties are missing, the original input is returned unchanged.
221215
*
222-
* This is used specifically for apify/rag-web-browser where we want to expose
223-
* only a subset of input properties to the MCP tool without redefining the schema.
216+
* Exposure policies can offer a subset of Actor inputs without redefining the schema.
224217
*/
225218
export function pruneSchemaPropertiesByWhitelist(
226219
input: ActorInputSchema,

‎src/tools/actors/actor_tool_contract.ts‎

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,91 @@
1-
import type { ActorInputSchema } from '../../types.js';
1+
import { RAG_WEB_BROWSER, WEB_FETCH } from '../../const.js';
2+
import type { ActorInputSchema, ToolDescriptionContext } from '../../types.js';
3+
import { actorNameToToolName } from '../actor_tool_naming.js';
4+
5+
const RAG_WEB_BROWSER_ADDITIONAL_DESC = `Use this tool for generic web content: it searches the web by query and returns the text of the top results.
6+
This tool scrapes the data itself - it does NOT just find tools.
7+
8+
Examples of when to use:
9+
- General questions answered by web pages (e.g., "What's the weather in Prague?", "Latest news about the EU AI Act", "How does HTTP/3 work?")
10+
11+
For detailed data from a specific platform (e.g. Amazon products with prices, Instagram posts, Google Maps places with ratings), a specialized Actor from the Apify Store returns complete, structured results; this tool returns page text only.`;
12+
13+
/** Follows RAG_WEB_BROWSER_ADDITIONAL_DESC on its own line when the session serves the apify/web-fetch tool. */
14+
const RAG_WEB_BROWSER_WEB_FETCH_HINT = `When the user provides one specific URL and wants that page's full or verbatim content, prefer the dedicated ${WEB_FETCH} tool - this tool is for searching and scraping by query.`;
15+
/**
16+
* Appended to the `apify/web-fetch` Actor tool description. Client-agnostic on purpose:
17+
* no references to any specific client or its built-in tools, so the same text works
18+
* for every MCP client.
19+
*/
20+
const WEB_FETCH_ADDITIONAL_DESC = `Use this tool to fetch a specific http(s) URL and return its complete content (one URL per call; http and https only).
21+
It renders JavaScript and bypasses anti-bot protection, so it also retrieves pages where a plain HTTP fetch gets blocked, fails with an error such as 403 or 429, or returns incomplete content.
22+
The page comes back verbatim - full content, no summarization - as Markdown, plain text, HTML, the raw response body, or the list of links on the page.
23+
24+
Examples of when to use:
25+
- User provides a URL and wants its content, its data or links, or an answer that requires reading that page
26+
- A previous attempt to fetch a page was blocked, returned an error or CAPTCHA, or came back incomplete because the page needs JavaScript rendering
27+
- Verbatim page content is needed, e.g. for quoting, extraction, or archiving
28+
29+
This tool does not search the web - it needs a URL. To find pages by query, use a web search tool instead.
30+
If the exact URL cannot be fetched (e.g. a non-http(s) scheme), say so rather than silently substituting a different URL.`;
31+
32+
/**
33+
* Appended to the `url` parameter description of the `apify/web-fetch` tool. Lives on the
34+
* parameter because that is what an agent reads while writing the argument: agents
35+
* silently rewrote ftp:// URLs to https:// instead of telling the user the scheme is
36+
* unsupported.
37+
*/
38+
const WEB_FETCH_URL_SCHEME_NOTE =
39+
'http(s) URLs only - for any other scheme (e.g. ftp:), tell the user this tool cannot fetch it rather than substituting a different URL.';
240

341
type DirectActorPolicyConfig = {
42+
/** Sent with the run input when the caller omits the field; the served schema shows it as the default. */
443
defaults?: Readonly<Record<string, unknown>>;
44+
/** Left out of the served schema and refused by the call engine when sent. */
545
deniedInputs?: readonly string[];
646
/** Why, as served to the agent in the description and the refusal. */
747
deniedInputsReason?: string;
848
};
949

10-
/** MCP-only contracts; the Actor's original API contract stays unchanged (#1574, #1575). */
11-
const DIRECT_ACTOR_POLICIES: Readonly<Record<string, DirectActorPolicyConfig>> = {
50+
type ActorToolPolicyConfig = {
51+
/** Direct tools only; `call-actor` and the widgets keep the Actor's own contract. */
52+
directInput?: DirectActorPolicyConfig;
53+
/** Only these inputs are served; the others are hidden but still accepted and passed to the run. */
54+
exposedInputs?: readonly string[];
55+
inputDescriptionSuffixes?: Readonly<Record<string, string>>;
56+
buildDescription?: (context: ToolDescriptionContext) => string;
57+
};
58+
59+
/** Presentation rules preserve existing generic-call behavior; directInput is direct-tool-only. */
60+
const ACTOR_TOOL_POLICIES: Readonly<Record<string, ActorToolPolicyConfig>> = {
1261
'apify/website-content-crawler': {
13-
defaults: { respectRobotsTxtFile: true },
14-
deniedInputs: ['pageFunction', 'initialCookies'],
15-
deniedInputsReason:
16-
'page code can act on the crawled sites and session cookies can give access to accounts, which a read-only tool must not allow',
62+
directInput: {
63+
defaults: { respectRobotsTxtFile: true },
64+
deniedInputs: ['pageFunction', 'initialCookies'],
65+
deniedInputsReason:
66+
'page code can act on the crawled sites and session cookies can give access to accounts, which a read-only tool must not allow',
67+
},
68+
},
69+
[RAG_WEB_BROWSER]: {
70+
exposedInputs: ['query', 'maxResults', 'outputFormats'],
71+
buildDescription: ({ hasTool }) => {
72+
let description = RAG_WEB_BROWSER_ADDITIONAL_DESC;
73+
if (hasTool(actorNameToToolName(WEB_FETCH))) description += `\n${RAG_WEB_BROWSER_WEB_FETCH_HINT}`;
74+
return description;
75+
},
76+
},
77+
[WEB_FETCH]: {
78+
inputDescriptionSuffixes: { url: WEB_FETCH_URL_SCHEME_NOTE },
79+
buildDescription: () => WEB_FETCH_ADDITIONAL_DESC,
1780
},
1881
};
1982

83+
export function getActorToolPolicy(actorFullName: string): ActorToolPolicyConfig | undefined {
84+
return ACTOR_TOOL_POLICIES[actorFullName];
85+
}
86+
2087
export function getDirectActorPolicy(actorFullName: string): DirectActorPolicyConfig | undefined {
21-
return DIRECT_ACTOR_POLICIES[actorFullName];
88+
return getActorToolPolicy(actorFullName)?.directInput;
2289
}
2390

2491
export function buildDirectActorContract(actorFullName: string, input: ActorInputSchema | undefined) {

‎src/tools/actors/actor_tools_factory.ts‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ import {
99
HELPER_TOOLS,
1010
OFFICIAL_ACTORS_WITH_SIDE_EFFECTS,
1111
OFFICIAL_APIFY_USERNAMES,
12-
RAG_WEB_BROWSER,
13-
RAG_WEB_BROWSER_ADDITIONAL_DESC,
14-
RAG_WEB_BROWSER_WEB_FETCH_HINT,
15-
WEB_FETCH,
16-
WEB_FETCH_ADDITIONAL_DESC,
1712
} from '../../const.js';
1813
import { ActorLoadError } from '../../errors.js';
1914
import { getActorMCPServerPath, getActorMCPServerURL } from '../../mcp/actors.js';
@@ -41,7 +36,7 @@ import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js
4136
import { actorNameToToolName, isActorBlockedUnderPaymentProvider, resolveActorToolMode } from '../actor_tool_naming.js';
4237
import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js';
4338
import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js';
44-
import { buildDirectActorContract } from './actor_tool_contract.js';
39+
import { buildDirectActorContract, getActorToolPolicy } from './actor_tool_contract.js';
4540

4641
/**
4742
* MCP-only opt-in injected next to the Actor's own input fields. Same contract as `call-actor`'s
@@ -150,9 +145,7 @@ export async function getNormalActorsAsTools(
150145
? buildDirectActorContract(actorFullName, definition.input)
151146
: { input: definition.input, description: '' };
152147
const isReadOnly = isReadOnlyActor({ ...actorInfo, definition: { ...definition, input: contract.input } });
153-
const isRag = actorFullName === RAG_WEB_BROWSER;
154-
const isWebFetch = actorFullName === WEB_FETCH;
155-
const { inputSchema } = buildActorInputSchema(definition.actorFullName, contract.input, isRag);
148+
const { inputSchema } = buildActorInputSchema(definition.actorFullName, contract.input);
156149

157150
// Inject the MCP-only `waitSecs` opt-in before AJV compile so the LLM can cap the wait.
158151
const inputSchemaWithWaitSecs = inputSchema as { properties?: Record<string, unknown> };
@@ -162,15 +155,12 @@ export async function getNormalActorsAsTools(
162155
};
163156

164157
// Names call-actor only when the session actually has it.
165-
const buildDescription = ({ hasTool }: ToolDescriptionContext): string => {
158+
const buildDescription = (context: ToolDescriptionContext): string => {
159+
const { hasTool } = context;
166160
let description = `This tool calls the Actor "${actorFullName}" and retrieves its output results.
167161
${hasTool(HELPER_TOOLS.ACTOR_CALL) ? `Use this tool instead of the "${HELPER_TOOLS.ACTOR_CALL}" if user requests this specific Actor.\n` : ''}Actor description: ${actorDescription}`;
168-
if (isRag) {
169-
description += `\n\n${RAG_WEB_BROWSER_ADDITIONAL_DESC}`;
170-
if (hasTool(actorNameToToolName(WEB_FETCH))) description += `\n${RAG_WEB_BROWSER_WEB_FETCH_HINT}`;
171-
} else if (isWebFetch) {
172-
description += `\n\n${WEB_FETCH_ADDITIONAL_DESC}`;
173-
}
162+
const policyDescription = getActorToolPolicy(actorFullName)?.buildDescription?.(context);
163+
if (policyDescription) description += `\n\n${policyDescription}`;
174164
if (contract.description) description += `\n\n${contract.description}`;
175165
return description;
176166
};

‎tests/unit/tools.actor_tools_factory.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,48 @@ describe('getNormalActorsAsTools()', () => {
247247
});
248248
});
249249

250+
describe('existing Actor presentation policies', () => {
251+
it.each([true, false])('preserves RAG exposure and validation with isDirectTool %s', async (isDirectTool) => {
252+
const actorInfo = createMockActorInfo(RAG_WEB_BROWSER, {
253+
...LIMITED,
254+
inputProperties: {
255+
query: { type: 'string' },
256+
maxResults: { type: 'integer', default: 3 },
257+
outputFormats: { type: 'array', default: ['markdown'] },
258+
hiddenSecret: { type: 'string', isSecret: true },
259+
},
260+
inputRequired: ['query', 'maxResults'],
261+
});
262+
const original = structuredClone(actorInfo);
263+
const [tool] = await getNormalActorsAsTools([actorInfo], { isDirectTool });
264+
expect(Object.keys(tool.inputSchema.properties ?? {})).toEqual([
265+
'query',
266+
'maxResults',
267+
'outputFormats',
268+
'waitSecs',
269+
]);
270+
expect(tool.inputSchema.required).toEqual(['query']);
271+
const input = { query: 'apify', hiddenSecret: 'original-input' };
272+
expect(tool.ajvValidate(input)).toBe(true);
273+
expect(input).toEqual({ query: 'apify', hiddenSecret: 'original-input' });
274+
expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true });
275+
expect(actorInfo).toEqual(original);
276+
});
277+
278+
it.each([true, false])('preserves Web Fetch descriptions with isDirectTool %s', async (isDirectTool) => {
279+
const actorInfo = createMockActorInfo(WEB_FETCH, { ...LIMITED });
280+
const original = structuredClone(actorInfo);
281+
const [tool] = await getNormalActorsAsTools([actorInfo], { isDirectTool });
282+
expect(tool.description).toContain('Use this tool to fetch a specific http(s) URL');
283+
expect(tool.inputSchema.properties?.url).toMatchObject({
284+
description:
285+
'The URL to process http(s) URLs only - for any other scheme (e.g. ftp:), tell the user this tool cannot fetch it rather than substituting a different URL.',
286+
});
287+
expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
288+
expect(actorInfo).toEqual(original);
289+
});
290+
});
291+
250292
// Regression: #1532 — the RAG description named apify/web-fetch when the session did not serve it.
251293
it('names apify/web-fetch in the RAG description only when its tool is served', async () => {
252294
const [tool] = await getNormalActorsAsTools([createMockActorInfo(RAG_WEB_BROWSER)]);

0 commit comments

Comments
 (0)