Skip to content

Commit e5aea96

Browse files
committed
feat: Return markdown search-actors tools
1 parent c1f4ce1 commit e5aea96

File tree

4 files changed

+126
-115
lines changed

4 files changed

+126
-115
lines changed

src/tools/get-actor-details.ts

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { z } from 'zod';
33
import zodToJsonSchema from 'zod-to-json-schema';
44

55
import { ApifyClient } from '../apify-client.js';
6-
import { APIFY_STORE_URL, HelperTools } from '../const.js';
7-
import type { ExtendedPricingInfo, IActorInputSchema, InternalTool, ToolEntry } from '../types.js';
6+
import { HelperTools } from '../const.js';
7+
import type { IActorInputSchema, InternalTool, ToolEntry } from '../types.js';
8+
import { formatActorToActorCard } from '../utils/actor-card.js';
89
import { ajv } from '../utils/ajv.js';
9-
import { getCurrentPricingInfo, pricingInfoToString } from '../utils/pricing-info.js';
1010
import { filterSchemaProperties, shortenProperties } from './utils.js';
1111

1212
const getActorDetailsToolArgsSchema = z.object({
@@ -15,31 +15,16 @@ const getActorDetailsToolArgsSchema = z.object({
1515
.describe(`Actor ID or full name in the format "username/name", e.g., "apify/rag-web-browser".`),
1616
});
1717

18-
// Helper function to format categories from uppercase with underscores to proper case
19-
function formatCategories(categories?: string[]): string[] {
20-
if (!categories) return [];
21-
22-
return categories.map((category) => {
23-
const formatted = category
24-
.toLowerCase()
25-
.split('_')
26-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
27-
.join(' ');
28-
// Special case for MCP server, AI, and SEO tools
29-
return formatted.replace('Mcp Server', 'MCP Server').replace('Ai', 'AI').replace('Seo', 'SEO');
30-
});
31-
}
32-
3318
export const getActorDetailsTool: ToolEntry = {
3419
type: 'internal',
3520
tool: {
3621
name: HelperTools.ACTOR_GET_DETAILS,
37-
description: `Retrieve comprehensive details about an Actor using its ID or full name.\n`
38-
+ `This tool provides the Actor's title, description, URL, documentation (README), input schema, categories, pricing, and usage statistics.\n`
39-
+ `Specify the Actor name in the format "username/name" (e.g., "apify/rag-web-browser").\n`
40-
+ `The response is formatted in markdown and should be rendered as-is.\n`
22+
description: `Get detailed information about an Actor by its ID or full name.\n`
23+
+ `This tool returns title, description, URL, README (Actor's documentation), input schema, and usage statistics. \n`
24+
+ `The Actor name is always composed of "username/name", for example, "apify/rag-web-browser".\n`
25+
+ `Returns Actor information which must be displayed in the same format.\n`
4126
+ `USAGE:\n`
42-
+ `- Use when a user requests information about an Actor, such as its details, description, input schema, or documentation.\n`
27+
+ `- Use when user asks about an Actor its details, description, input schema, etc.\n`
4328
+ `EXAMPLES:\n`
4429
+ `- user_input: How to use apify/rag-web-browser\n`
4530
+ `- user_input: What is the input schema for apify/rag-web-browser`,
@@ -69,35 +54,14 @@ export const getActorDetailsTool: ToolEntry = {
6954
inputSchema.properties = filterSchemaProperties(inputSchema.properties);
7055
inputSchema.properties = shortenProperties(inputSchema.properties);
7156

72-
const currentPricingInfo = getCurrentPricingInfo(actorInfo.pricingInfos || [], new Date());
73-
74-
// Format categories for display
75-
const formattedCategories = formatCategories(actorInfo.categories);
76-
77-
// Note: In the public API, we are missing maintainedByApify property, so we cannot use it here.
78-
// Note: Actor rating is not in public API, we need to add it (actorUtils.getActorReviewRatingNumber(actorId))
79-
const actorFullName = `${actorInfo.username}/${actorInfo.name}`;
80-
const markdownLines = [
81-
`Actor details (always present Actor information in this format, always include URL):\n`,
82-
`# [${actorInfo.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`,
83-
`**Developed by:** ${actorInfo.username} Maintained by ${actorInfo.username === 'apify' ? '(Apify)' : '(community)'}`,
84-
`**Description:** ${actorInfo.description || 'No description provided.'}`,
85-
`**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`,
86-
`**Pricing:** ${pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null))}`,
87-
`**Stats:** ${actorInfo.stats.totalUsers.toLocaleString()} total users, ${actorInfo.stats.totalUsers30Days.toLocaleString()} monthly users`,
88-
`Last modified: ${actorInfo.modifiedAt.toISOString()}`,
89-
];
90-
if (actorInfo.isDeprecated) {
91-
markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.');
92-
}
93-
const actorCard = markdownLines.join('\n');
57+
// Use the actor formatter to get the main actor details
58+
const actorCard = formatActorToActorCard(actorInfo);
9459

9560
return {
9661
content: [
9762
{ type: 'text', text: actorCard },
98-
// LLM properly format Actor card, if README and input schema are separate text blocks
99-
{ type: 'text', text: `**README**:\n\n${buildInfo.actorDefinition.readme || 'No README provided.'}` },
100-
{ type: 'text', text: `**Input Schema**:\n\n${JSON.stringify(inputSchema, null, 0)}` },
63+
{ type: 'text', text: `**README:**\n${buildInfo.actorDefinition.readme || 'No README provided.'}` },
64+
{ type: 'text', text: `**Input Schema:**\n${JSON.stringify(inputSchema, null, 0)}` },
10165
],
10266
};
10367
},

src/tools/store_collection.ts

Lines changed: 8 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import zodToJsonSchema from 'zod-to-json-schema';
55

66
import { ApifyClient } from '../apify-client.js';
77
import { ACTOR_SEARCH_ABOVE_LIMIT, HelperTools } from '../const.js';
8-
import type { ActorPricingModel, ExtendedActorStoreList, ExtendedPricingInfo, HelperTool, ToolEntry } from '../types.js';
9-
import { pricingInfoToString } from '../utils/pricing-info.js';
8+
import type { ActorPricingModel, ExtendedActorStoreList, HelperTool, ToolEntry } from '../types.js';
9+
import { formatActorsListToActorCard } from '../utils/actor-card.js';
1010

1111
export async function searchActorsByKeywords(
1212
search: string,
@@ -42,31 +42,6 @@ Do not use complex queries, AND/OR operators, or other advanced syntax, as this
4242
.describe('Filter the results by the specified category.'),
4343
});
4444

45-
export interface ISearchActorsResult {
46-
total: number;
47-
actors: {
48-
actorFullName: string;
49-
50-
categories?: string[];
51-
description: string;
52-
53-
actorRating: string; // We convert the star (out of 5) rating into a string representation (e.g., "4.5 out of 5")
54-
bookmarkCount: string; // We convert the bookmark count into a string representation (e.g., "100 users bookmarked this Actor")
55-
56-
pricingInfo: string; // We convert the pricing info into a string representation
57-
58-
usageStatistics: {
59-
totalUsers: {
60-
allTime: number;
61-
last7Days: number;
62-
last30Days: number;
63-
last90Days: number;
64-
};
65-
failedRunsInLast30Days: number | string; // string for 'unknown' case
66-
}
67-
}[];
68-
}
69-
7045
/**
7146
* Filters out actors with the 'FLAT_PRICE_PER_MONTH' pricing model (rental actors),
7247
* unless the actor's ID is present in the user's rented actor IDs list.
@@ -117,45 +92,13 @@ export const searchActors: ToolEntry = {
11792
parsed.offset,
11893
);
11994
actors = filterRentalActors(actors || [], userRentedActorIds || []).slice(0, parsed.limit);
120-
121-
const result: ISearchActorsResult = {
122-
total: actors.length,
123-
actors: actors.map((actor) => {
124-
return {
125-
actorFullName: `${actor.username}/${actor.name}`,
126-
127-
categories: actor.categories,
128-
description: actor.description || 'No description provided.',
129-
130-
actorRating: actor.actorReviewRating
131-
? `${actor.actorReviewRating.toFixed(2)} out of 5`
132-
: 'unknown',
133-
bookmarkCount: actor.bookmarkCount
134-
? `${actor.bookmarkCount} users have bookmarked this Actor`
135-
: 'unknown',
136-
137-
pricingInfo: pricingInfoToString(actor.currentPricingInfo as ExtendedPricingInfo),
138-
139-
usageStatistics: {
140-
totalUsers: {
141-
allTime: actor.stats.totalUsers,
142-
last7Days: actor.stats.totalUsers7Days,
143-
last30Days: actor.stats.totalUsers30Days,
144-
last90Days: actor.stats.totalUsers90Days,
145-
},
146-
failedRunsInLast30Days: (
147-
'publicActorRunStats30Days' in actor.stats && 'FAILED' in (actor.stats.publicActorRunStats30Days as object)
148-
) ? (actor.stats.publicActorRunStats30Days as { FAILED: number }).FAILED : 'unknown',
149-
},
150-
};
151-
}),
152-
};
153-
95+
const actorCards = formatActorsListToActorCard(actors);
15496
return {
155-
content: [{
156-
type: 'text',
157-
text: JSON.stringify(result),
158-
}],
97+
content: [
98+
{ type: 'text', text: `**Search query:** ${parsed.search}` },
99+
{ type: 'text', text: `**Number of Actors found:** ${actorCards.length}` },
100+
{ type: 'text', text: actorCards.join('\n\n') },
101+
],
159102
};
160103
},
161104
} as HelperTool,

src/utils/actor-card.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Actor } from 'apify-client';
2+
3+
import { APIFY_STORE_URL } from '../const.js';
4+
import type { ExtendedActorStoreList, ExtendedPricingInfo } from '../types.js';
5+
import { getCurrentPricingInfo, pricingInfoToString } from './pricing-info.js';
6+
7+
// Helper function to format categories from uppercase with underscores to proper case
8+
function formatCategories(categories?: string[]): string[] {
9+
if (!categories) return [];
10+
11+
return categories.map((category) => {
12+
const formatted = category
13+
.toLowerCase()
14+
.split('_')
15+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
16+
.join(' ');
17+
// Special case for MCP server, AI, and SEO tools
18+
return formatted.replace('Mcp Server', 'MCP Server').replace('Ai', 'AI').replace('Seo', 'SEO');
19+
});
20+
}
21+
22+
/**
23+
* Formats Actor details into an Actor card (Actor markdown representation).
24+
* @param actor - Actor information from the API
25+
* @returns Formatted actor card
26+
*/
27+
export function formatActorToActorCard(
28+
actor: Actor | ExtendedActorStoreList,
29+
): string {
30+
// Format categories for display
31+
const formattedCategories = formatCategories('categories' in actor ? actor.categories : undefined);
32+
33+
// Get pricing info
34+
let pricingInfo: string;
35+
if ('currentPricingInfo' in actor) {
36+
// ActorStoreList has currentPricingInfo
37+
pricingInfo = pricingInfoToString(actor.currentPricingInfo as ExtendedPricingInfo);
38+
} else {
39+
// Actor has pricingInfos array
40+
const currentPricingInfo = getCurrentPricingInfo(actor.pricingInfos || [], new Date());
41+
pricingInfo = pricingInfoToString(currentPricingInfo as (ExtendedPricingInfo | null));
42+
}
43+
44+
const actorFullName = `${actor.username}/${actor.name}`;
45+
46+
// Build the markdown lines
47+
const markdownLines = [
48+
`# [${actor.title}](${APIFY_STORE_URL}/${actorFullName}) (${actorFullName})`,
49+
`**Developed by:** ${actor.username} ${actor.username === 'apify' ? '(Apify)' : '(community)'}`,
50+
`**Description:** ${actor.description || 'No description provided.'}`,
51+
`**Categories:** ${formattedCategories.length ? formattedCategories.join(', ') : 'Uncategorized'}`,
52+
`**Pricing:** ${pricingInfo}`,
53+
];
54+
55+
// Add stats - handle different stat structures
56+
if ('stats' in actor) {
57+
const { stats } = actor;
58+
const statsParts = [];
59+
60+
if ('totalUsers' in stats && 'totalUsers30Days' in stats) {
61+
// Both Actor and ActorStoreList have the same stats structure
62+
statsParts.push(`${stats.totalUsers.toLocaleString()} total users, ${stats.totalUsers30Days.toLocaleString()} monthly users`);
63+
}
64+
65+
// Add bookmark count if available (ActorStoreList only)
66+
if ('bookmarkCount' in actor && actor.bookmarkCount) {
67+
statsParts.push(`${actor.bookmarkCount} bookmarks`);
68+
}
69+
70+
if (statsParts.length > 0) {
71+
markdownLines.push(`**Stats:** ${statsParts.join(', ')}`);
72+
}
73+
}
74+
75+
// Add rating if available (ActorStoreList only)
76+
if ('actorReviewRating' in actor && actor.actorReviewRating) {
77+
markdownLines.push(`**Rating:** ${actor.actorReviewRating.toFixed(2)} out of 5`);
78+
}
79+
80+
// Add modification date if available
81+
if ('modifiedAt' in actor) {
82+
markdownLines.push(`**Last modified:** ${actor.modifiedAt.toISOString()}`);
83+
}
84+
85+
// Add deprecation warning if applicable
86+
if ('isDeprecated' in actor && actor.isDeprecated) {
87+
markdownLines.push('\n>This Actor is deprecated and may not be maintained anymore.');
88+
}
89+
return markdownLines.join('\n');
90+
}
91+
92+
/**
93+
* Formats a list of Actors into Actor cards
94+
* @param actors - Array of Actor information
95+
* @returns Formatted markdown string
96+
*/
97+
export function formatActorsListToActorCard(actors: (Actor | ExtendedActorStoreList)[]): string[] {
98+
if (actors.length === 0) {
99+
return [];
100+
}
101+
return actors.map((actor, index) => {
102+
const card = formatActorToActorCard(actor);
103+
return `${index + 1}. ${card}`;
104+
});
105+
}

tests/integration/suite.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
66
import { defaults, HelperTools } from '../../src/const.js';
77
import { latestNewsOnTopicPrompt } from '../../src/prompts/latest-news-on-topic.js';
88
import { addRemoveTools, defaultTools, toolCategories, toolCategoriesEnabledByDefault } from '../../src/tools/index.js';
9-
import type { ISearchActorsResult } from '../../src/tools/store_collection.js';
109
import { actorNameToToolName } from '../../src/tools/utils.js';
1110
import type { ToolCategory } from '../../src/types.js';
1211
import { ACTOR_MCP_SERVER_ACTOR_NAME, ACTOR_PYTHON_EXAMPLE, DEFAULT_ACTOR_NAMES, DEFAULT_TOOL_NAMES } from '../const.js';
@@ -282,7 +281,7 @@ export function createIntegrationTestsSuite(
282281
});
283282
const content = result.content as {text: string}[];
284283
expect(content.length).toBe(1);
285-
const resultJson = JSON.parse(content[0].text) as ISearchActorsResult;
284+
const resultJson = JSON.parse(content[0].text);
286285
const { actors } = resultJson;
287286
expect(actors.length).toBe(resultJson.total);
288287
expect(actors.length).toBeGreaterThan(0);

0 commit comments

Comments
 (0)