Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,44 +123,7 @@ export const REPORT_PROBLEM_BLOCKED_CLIENTS: string[] = ['claude', 'anthropic',
export const APIFY_AI_CLIENT_NAME = 'apify-ai';

export const RAG_WEB_BROWSER = 'apify/rag-web-browser';
export const RAG_WEB_BROWSER_WHITELISTED_FIELDS = ['query', 'maxResults', 'outputFormats'];
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.
This tool scrapes the data itself - it does NOT just find tools.

Examples of when to use:
- 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?")

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.`;

export const WEB_FETCH = 'apify/web-fetch';
/** Follows RAG_WEB_BROWSER_ADDITIONAL_DESC on its own line when the session serves the apify/web-fetch tool. */
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.`;
/**
* Appended to the `apify/web-fetch` Actor tool description. Client-agnostic on purpose:
* no references to any specific client or its built-in tools, so the same text works
* for every MCP client.
*/
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).
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.
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.

Examples of when to use:
- User provides a URL and wants its content, its data or links, or an answer that requires reading that page
- A previous attempt to fetch a page was blocked, returned an error or CAPTCHA, or came back incomplete because the page needs JavaScript rendering
- Verbatim page content is needed, e.g. for quoting, extraction, or archiving

This tool does not search the web - it needs a URL. To find pages by query, use a web search tool instead.
If the exact URL cannot be fetched (e.g. a non-http(s) scheme), say so rather than silently substituting a different URL.`;

/**
* Appended to the `url` parameter description of the `apify/web-fetch` tool. Lives on the
* parameter because that is what an agent reads while writing the argument: agents
* silently rewrote ftp:// URLs to https:// instead of telling the user the scheme is
* unsupported.
*/
export const WEB_FETCH_URL_SCHEME_NOTE =
'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.';

export const defaults = {
actors: [RAG_WEB_BROWSER, WEB_FETCH],
};
Expand Down
5 changes: 3 additions & 2 deletions src/tools/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ direct actor tools, `search-actors`, `fetch-actor-details`) is mode-agnostic.
- `actors/` — search, details, call, the list of the account's Actors (`get-actor-list`), delete
(`delete_actor.ts`), the actor-tools factory, the direct actor-tool executor (`actor_executor.ts`),
`actor_definition.ts` (fetches and prunes an Actor's definition, `getActorDefinition`),
`actor_tool_contract.ts` (direct-tool input restrictions and defaults, shared by schema generation,
the call engine's denied-input check and execution; generic calls use the original contract), and
`actor_tool_contract.ts` (Actor-specific presentation policies plus direct-tool input restrictions
and defaults, shared by schema generation, the call engine's denied-input check and execution;
generic calls skip direct-only restrictions and defaults), and
`actor_run_response.ts` —
the one canonical run shape `call-actor` and `get-actor-run` share across sync, task
and wait-timeout modes: storage IDs plus a `summary` (past) / `nextStep` (one primary
Expand Down
27 changes: 10 additions & 17 deletions src/tools/actor_input_schema.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import type { ValidateFunction } from 'ajv';
import type Ajv from 'ajv';

import {
ACTOR_ENUM_MAX_LENGTH,
ACTOR_MAX_DESCRIPTION_LENGTH,
RAG_WEB_BROWSER_WHITELISTED_FIELDS,
WEB_FETCH,
WEB_FETCH_URL_SCHEME_NOTE,
} from '../const.js';
import { ACTOR_ENUM_MAX_LENGTH, ACTOR_MAX_DESCRIPTION_LENGTH } from '../const.js';
import { SchemaTooLargeError } from '../errors.js';
import type { ActorInputSchema, SchemaProperties } from '../types.js';
import { fixZodSchemaRequired } from '../utils/ajv.js';
Expand All @@ -20,6 +14,7 @@ import {
addResourcePickerProperties as addArrayResourcePickerProperties,
} from '../utils/apify_properties.js';
import { getToolSchemaID } from './actor_tool_naming.js';
import { getActorToolPolicy } from './actors/actor_tool_contract.js';

type ActorInputSchemaProperties = Record<string, SchemaProperties>;

Expand Down Expand Up @@ -165,7 +160,7 @@ export function markInputPropertiesAsRequired(input: ActorInputSchema): Record<s
/**
* Builds the final Actor input schema for MCP tool usage.
*/
export function buildActorInputSchema(actorFullName: string, input: ActorInputSchema | undefined, isRag: boolean) {
export function buildActorInputSchema(actorFullName: string, input: ActorInputSchema | undefined) {
if (!input) {
return {
inputSchema: {
Expand Down Expand Up @@ -201,13 +196,12 @@ export function buildActorInputSchema(actorFullName: string, input: ActorInputSc
delete (working as { $schema?: string }).$schema;
}

let finalSchema = working;
if (isRag) {
finalSchema = pruneSchemaPropertiesByWhitelist(finalSchema, RAG_WEB_BROWSER_WHITELISTED_FIELDS);
}

if (actorFullName === WEB_FETCH && finalSchema.properties.url) {
finalSchema.properties.url.description += ` ${WEB_FETCH_URL_SCHEME_NOTE}`;
const policy = getActorToolPolicy(actorFullName);
const finalSchema = policy?.exposedInputs
? pruneSchemaPropertiesByWhitelist(working, policy.exposedInputs)
: working;
for (const [name, description] of Object.entries(policy?.inputDescriptionSuffixes ?? {})) {
if (finalSchema.properties[name]) finalSchema.properties[name].description += ` ${description}`;
}

finalSchema.$id = getToolSchemaID(actorFullName);
Expand All @@ -219,8 +213,7 @@ export function buildActorInputSchema(actorFullName: string, input: ActorInputSc
* and filters the required array accordingly. All other top-level fields are preserved.
* If properties are missing, the original input is returned unchanged.
*
* This is used specifically for apify/rag-web-browser where we want to expose
* only a subset of input properties to the MCP tool without redefining the schema.
* Exposure policies can offer a subset of Actor inputs without redefining the schema.
*/
export function pruneSchemaPropertiesByWhitelist(
input: ActorInputSchema,
Expand Down
83 changes: 75 additions & 8 deletions src/tools/actors/actor_tool_contract.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,91 @@
import type { ActorInputSchema } from '../../types.js';
import { RAG_WEB_BROWSER, WEB_FETCH } from '../../const.js';
import type { ActorInputSchema, ToolDescriptionContext } from '../../types.js';
import { actorNameToToolName } from '../actor_tool_naming.js';

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.
This tool scrapes the data itself - it does NOT just find tools.

Examples of when to use:
- 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?")

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.`;

/** Follows RAG_WEB_BROWSER_ADDITIONAL_DESC on its own line when the session serves the apify/web-fetch tool. */
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.`;
/**
* Appended to the `apify/web-fetch` Actor tool description. Client-agnostic on purpose:
* no references to any specific client or its built-in tools, so the same text works
* for every MCP client.
*/
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).
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.
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.

Examples of when to use:
- User provides a URL and wants its content, its data or links, or an answer that requires reading that page
- A previous attempt to fetch a page was blocked, returned an error or CAPTCHA, or came back incomplete because the page needs JavaScript rendering
- Verbatim page content is needed, e.g. for quoting, extraction, or archiving

This tool does not search the web - it needs a URL. To find pages by query, use a web search tool instead.
If the exact URL cannot be fetched (e.g. a non-http(s) scheme), say so rather than silently substituting a different URL.`;

/**
* Appended to the `url` parameter description of the `apify/web-fetch` tool. Lives on the
* parameter because that is what an agent reads while writing the argument: agents
* silently rewrote ftp:// URLs to https:// instead of telling the user the scheme is
* unsupported.
*/
const WEB_FETCH_URL_SCHEME_NOTE =
'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.';

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

/** MCP-only contracts; the Actor's original API contract stays unchanged (#1574, #1575). */
const DIRECT_ACTOR_POLICIES: Readonly<Record<string, DirectActorPolicyConfig>> = {
type ActorToolPolicyConfig = {
/** Direct tools only; `call-actor` and the widgets keep the Actor's own contract. */
directInput?: DirectActorPolicyConfig;
/** Only these inputs are served; the others are hidden but still accepted and passed to the run. */
exposedInputs?: readonly string[];
inputDescriptionSuffixes?: Readonly<Record<string, string>>;
buildDescription?: (context: ToolDescriptionContext) => string;
};

/** Presentation rules preserve existing generic-call behavior; directInput is direct-tool-only. */
const ACTOR_TOOL_POLICIES: Readonly<Record<string, ActorToolPolicyConfig>> = {
'apify/website-content-crawler': {
defaults: { respectRobotsTxtFile: true },
deniedInputs: ['pageFunction', 'initialCookies'],
deniedInputsReason:
'page code can act on the crawled sites and session cookies can give access to accounts, which a read-only tool must not allow',
directInput: {
defaults: { respectRobotsTxtFile: true },
deniedInputs: ['pageFunction', 'initialCookies'],
deniedInputsReason:
'page code can act on the crawled sites and session cookies can give access to accounts, which a read-only tool must not allow',
},
},
[RAG_WEB_BROWSER]: {
exposedInputs: ['query', 'maxResults', 'outputFormats'],
buildDescription: ({ hasTool }) => {
let description = RAG_WEB_BROWSER_ADDITIONAL_DESC;
if (hasTool(actorNameToToolName(WEB_FETCH))) description += `\n${RAG_WEB_BROWSER_WEB_FETCH_HINT}`;
return description;
},
},
[WEB_FETCH]: {
inputDescriptionSuffixes: { url: WEB_FETCH_URL_SCHEME_NOTE },
buildDescription: () => WEB_FETCH_ADDITIONAL_DESC,
},
};

export function getActorToolPolicy(actorFullName: string): ActorToolPolicyConfig | undefined {
return ACTOR_TOOL_POLICIES[actorFullName];
}

export function getDirectActorPolicy(actorFullName: string): DirectActorPolicyConfig | undefined {
return DIRECT_ACTOR_POLICIES[actorFullName];
return getActorToolPolicy(actorFullName)?.directInput;
}

export function buildDirectActorContract(actorFullName: string, input: ActorInputSchema | undefined) {
Expand Down
22 changes: 6 additions & 16 deletions src/tools/actors/actor_tools_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ import {
HELPER_TOOLS,
OFFICIAL_ACTORS_WITH_SIDE_EFFECTS,
OFFICIAL_APIFY_USERNAMES,
RAG_WEB_BROWSER,
RAG_WEB_BROWSER_ADDITIONAL_DESC,
RAG_WEB_BROWSER_WEB_FETCH_HINT,
WEB_FETCH,
WEB_FETCH_ADDITIONAL_DESC,
} from '../../const.js';
import { ActorLoadError } from '../../errors.js';
import { getActorMCPServerPath, getActorMCPServerURL } from '../../mcp/actors.js';
Expand Down Expand Up @@ -41,7 +36,7 @@ import { buildActorInputSchema, fixedAjvCompile } from '../actor_input_schema.js
import { actorNameToToolName, isActorBlockedUnderPaymentProvider, resolveActorToolMode } from '../actor_tool_naming.js';
import { buildEnrichedDirectActorOutputSchema, actorRunOutputSchema } from '../structured_output_schemas.js';
import { CALL_ACTOR_WAIT_SECS_DEFAULT, WAIT_SECS_MAX } from './actor_run_response.js';
import { buildDirectActorContract } from './actor_tool_contract.js';
import { buildDirectActorContract, getActorToolPolicy } from './actor_tool_contract.js';

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

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

// Names call-actor only when the session actually has it.
const buildDescription = ({ hasTool }: ToolDescriptionContext): string => {
const buildDescription = (context: ToolDescriptionContext): string => {
const { hasTool } = context;
let description = `This tool calls the Actor "${actorFullName}" and retrieves its output results.
${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}`;
if (isRag) {
description += `\n\n${RAG_WEB_BROWSER_ADDITIONAL_DESC}`;
if (hasTool(actorNameToToolName(WEB_FETCH))) description += `\n${RAG_WEB_BROWSER_WEB_FETCH_HINT}`;
} else if (isWebFetch) {
description += `\n\n${WEB_FETCH_ADDITIONAL_DESC}`;
}
const policyDescription = getActorToolPolicy(actorFullName)?.buildDescription?.(context);
if (policyDescription) description += `\n\n${policyDescription}`;
if (contract.description) description += `\n\n${contract.description}`;
return description;
};
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/tools.actor_tools_factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,48 @@ describe('getNormalActorsAsTools()', () => {
});
});

describe('existing Actor presentation policies', () => {
it.each([true, false])('preserves RAG exposure and validation with isDirectTool %s', async (isDirectTool) => {
const actorInfo = createMockActorInfo(RAG_WEB_BROWSER, {
...LIMITED,
inputProperties: {
query: { type: 'string' },
maxResults: { type: 'integer', default: 3 },
outputFormats: { type: 'array', default: ['markdown'] },
hiddenSecret: { type: 'string', isSecret: true },
},
inputRequired: ['query', 'maxResults'],
});
const original = structuredClone(actorInfo);
const [tool] = await getNormalActorsAsTools([actorInfo], { isDirectTool });
expect(Object.keys(tool.inputSchema.properties ?? {})).toEqual([
'query',
'maxResults',
'outputFormats',
'waitSecs',
]);
expect(tool.inputSchema.required).toEqual(['query']);
const input = { query: 'apify', hiddenSecret: 'original-input' };
expect(tool.ajvValidate(input)).toBe(true);
expect(input).toEqual({ query: 'apify', hiddenSecret: 'original-input' });
expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true });
expect(actorInfo).toEqual(original);
});

it.each([true, false])('preserves Web Fetch descriptions with isDirectTool %s', async (isDirectTool) => {
const actorInfo = createMockActorInfo(WEB_FETCH, { ...LIMITED });
const original = structuredClone(actorInfo);
const [tool] = await getNormalActorsAsTools([actorInfo], { isDirectTool });
expect(tool.description).toContain('Use this tool to fetch a specific http(s) URL');
expect(tool.inputSchema.properties?.url).toMatchObject({
description:
'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.',
});
expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
expect(actorInfo).toEqual(original);
});
});

// Regression: #1532 — the RAG description named apify/web-fetch when the session did not serve it.
it('names apify/web-fetch in the RAG description only when its tool is served', async () => {
const [tool] = await getNormalActorsAsTools([createMockActorInfo(RAG_WEB_BROWSER)]);
Expand Down
Loading
Loading