-
Notifications
You must be signed in to change notification settings - Fork 167
Expand file tree
/
Copy pathtools_loader.ts
More file actions
320 lines (289 loc) · 13.2 KB
/
tools_loader.ts
File metadata and controls
320 lines (289 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
/**
* Two-phase tool loading: {@link getActors} fetches Actor metadata (async, mode-agnostic);
* {@link loadToolsFromInput} runs both in sequence.
*/
import type { ApifyClient } from 'apify-client';
import log from '@apify/log';
import { defaults, HelperTools } from '../const.js';
import {
CATEGORY_NAME_SET,
CATEGORY_NAMES,
getCategoryTools,
toolCategoriesEnabledByDefault,
WIDGET_BY_BASE_TOOL,
} from '../tools/categories.js';
import { abortActorRun } from '../tools/common/abort_actor_run.js';
import type { PaymentProvider } from '../payments/types.js';
import { addTool } from '../tools/common/add_actor.js';
import { getDatasetItems } from '../tools/common/get_dataset_items.js';
import { getKeyValueStoreRecord } from '../tools/common/get_key_value_store_record.js';
import { defaultGetActorRun } from '../tools/default/get_actor_run.js';
import { getActorsAsTools } from '../tools/index.js';
import type { ActorStore, Input, ToolCategory, ToolEntry } from '../types.js';
import { SERVER_MODES, ServerMode } from '../types.js';
/**
* Tools auto-injected alongside any actor-running tool (call-actor / direct
* actor tools / add-actor). Order matches the workflow: fetch items → fetch
* KV record → abort.
*/
export const AUTO_INJECTED_TOOLS: readonly ToolEntry[] = [
getDatasetItems,
getKeyValueStoreRecord,
abortActorRun,
] as const;
// All internal tool names across all modes. Selectors matching these are not treated as Actor IDs.
// Built eagerly at module load; inputs (SERVER_MODES, getCategoryTools, CATEGORY_NAMES,
// WIDGET_BY_BASE_TOOL) are module-level constants available at import time.
const ALL_INTERNAL_TOOL_NAMES: Set<string> = (() => {
const names = new Set<string>();
// Collect tool names from both modes to ensure complete classification
for (const mode of SERVER_MODES) {
const categories = getCategoryTools(mode);
for (const name of CATEGORY_NAMES) {
for (const tool of categories[name]) names.add(tool.name);
}
}
// Widgets live only in WIDGET_BY_BASE_TOOL, not in any category
for (const widget of WIDGET_BY_BASE_TOOL.values()) names.add(widget.name);
return names;
})();
type NormalizedInput = {
/**
* Cleaned tool selectors (trimmed, non-empty). `undefined` when `input.tools`
* was not provided at all. Use `selectors?.length === 0` to detect an
* explicitly-empty list.
*/
selectors: string[] | undefined;
/** `true` when `input.enableAddingActors === true`. */
addActorEnabled: boolean;
/** `true` when `input.actors` was explicitly empty (`[]` or `''`). */
actorsExplicitlyEmpty: boolean;
};
/**
* Normalize the raw {@link Input} into cleaned selectors + two non-derivable flags.
* Shared by both loader phases so semantics stay consistent.
*/
function normalizeInput(input: Input): NormalizedInput {
const raw = input.tools;
const selectors = raw === undefined
? undefined
: (Array.isArray(raw) ? raw : [raw])
.map(String)
.map((s) => s.trim())
.filter((s) => s !== '');
return {
selectors,
addActorEnabled: input.enableAddingActors === true,
actorsExplicitlyEmpty: input.actors === '' || (Array.isArray(input.actors) && input.actors.length === 0),
};
}
/**
* Resolve the list of Actor names (`username/name`) to fetch from the input.
*
* **Mode-agnostic** — the result does NOT depend on `ServerMode`. An Actor tool
* is identified by name, and the same Actor entry is reused across modes; only
* the *internal* tool variants around it differ by mode.
*
* Selectors classified as "actor names":
* - NOT the deprecated `'preview'` pseudo-category
* - NOT a category name (from `CATEGORY_NAME_SET`)
* - NOT the name of an internal tool in any mode (from `ALL_INTERNAL_TOOL_NAMES`)
*
* If no selectors / no explicit actors: the defaults apply (or empty when
* add-actor mode is on).
*/
function resolveActorsToLoad(input: Input): string[] {
const { selectors, addActorEnabled, actorsExplicitlyEmpty } = normalizeInput(input);
// Selectors that aren't categories or internal tools in any mode → Actor names.
const actorSelectorsFromTools: string[] = [];
if (selectors !== undefined) {
for (const sel of selectors) {
if (sel === 'preview') continue;
if (CATEGORY_NAME_SET.has(sel)) continue;
if (ALL_INTERNAL_TOOL_NAMES.has(sel)) continue;
actorSelectorsFromTools.push(sel);
}
}
let actorsFromField: string[] | undefined;
if (input.actors === undefined) {
actorsFromField = undefined;
} else if (Array.isArray(input.actors)) {
actorsFromField = input.actors;
} else {
actorsFromField = [input.actors];
}
if (actorsFromField !== undefined) return actorsFromField;
if (actorSelectorsFromTools.length > 0) return actorSelectorsFromTools;
if (selectors === undefined) {
// No selectors supplied: use defaults unless add-actor mode is enabled
return addActorEnabled || actorsExplicitlyEmpty ? [] : defaults.actors;
}
// Selectors provided but none are actors => do not load defaults
return [];
}
/**
* Fetch Actor tool entries for all Actor names in `input`.
*
* Pass `paymentProvider` for sessions authenticated via an external payment
* provider (x402, Skyfire) so standby/MCP-server Actors are filtered out —
* see `getActorsAsTools` for the full rationale.
*/
export async function getActors(
input: Input,
apifyClient: ApifyClient,
options?: { actorStore?: ActorStore; paymentProvider?: PaymentProvider },
): Promise<ToolEntry[]> {
const actorNames = resolveActorsToLoad(input);
if (actorNames.length === 0) return [];
const { tools } = await getActorsAsTools(actorNames, apifyClient, options);
return tools;
}
/** Build a restore {@link Input} from concrete tool names: internal names → `tools`, actor names → `actors`. */
export function toolNamesToInput(toolNames: string[]): Input {
const internalToolNames: string[] = [];
const actorToolNames: string[] = [];
for (const toolName of toolNames) {
if (ALL_INTERNAL_TOOL_NAMES.has(toolName)) {
internalToolNames.push(toolName);
} else {
actorToolNames.push(toolName);
}
}
const input: Input = {
tools: internalToolNames,
};
if (actorToolNames.length > 0) {
input.actors = actorToolNames;
}
return input;
}
/** Compose the final tool list from pre-fetched actor tools and the original input for the given mode. */
export function getToolsForServerMode(input: Input, actorTools: ToolEntry[], mode: ServerMode = ServerMode.DEFAULT): ToolEntry[] {
// Build mode-resolved categories — tools are already the correct variant for this mode
const categories = getCategoryTools(mode);
const { selectors, addActorEnabled, actorsExplicitlyEmpty } = normalizeInput(input);
const selectorsExplicitEmpty = selectors?.length === 0;
// Build mode-specific tool-by-name map for individual tool selection
const toolsByName = new Map<string, ToolEntry>();
for (const name of CATEGORY_NAMES) {
for (const tool of categories[name]) {
toolsByName.set(tool.name, tool);
}
}
// Widgets are apps-only and not in any category; include for direct selection
if (mode === ServerMode.APPS) {
for (const widget of WIDGET_BY_BASE_TOOL.values()) {
toolsByName.set(widget.name, widget);
}
}
// Walk selectors for internal picks (mode-specific). Actor-name classification
// happened in `resolveActorsToLoad`; we don't need to partition again here.
const internalSelections: ToolEntry[] = [];
if (selectors !== undefined && selectors.length > 0) {
for (const sel of selectors) {
if (sel === 'preview') {
// 'preview' category is deprecated. It contained `call-actor` which is now default.
log.warning('Tool category "preview" is deprecated');
const callActorTool = toolsByName.get(HelperTools.ACTOR_CALL);
if (callActorTool) internalSelections.push(callActorTool);
continue;
}
const categoryTools = categories[sel as ToolCategory];
if (categoryTools) {
internalSelections.push(...categoryTools);
continue;
}
const internalByName = toolsByName.get(sel);
if (internalByName) {
internalSelections.push(internalByName);
continue;
}
// Internal tool from another mode → skip silently (getActors already
// routed it away from actor names).
if (ALL_INTERNAL_TOOL_NAMES.has(sel)) {
log.debug(`Skipping selector "${sel}" — it is an internal tool from another mode (current: "${mode}")`);
}
// Else: selector was an Actor name; it's already in `actorTools`.
}
}
// Compose final tool list
const result: ToolEntry[] = [];
// Internal tools
if (selectors !== undefined) {
result.push(...internalSelections);
// If add-actor mode is enabled, ensure add-actor tool is available alongside selected tools.
if (addActorEnabled && !selectorsExplicitEmpty && !actorsExplicitlyEmpty) {
const hasAddActor = result.some((e) => e.name === addTool.name);
if (!hasAddActor) result.push(addTool);
}
} else if (addActorEnabled && !actorsExplicitlyEmpty) {
// No selectors: either expose only add-actor (when enabled), or default categories
result.push(addTool);
} else if (!actorsExplicitlyEmpty) {
// Use mode-resolved default categories
for (const cat of toolCategoriesEnabledByDefault) {
result.push(...categories[cat]);
}
}
// Actor tools (pre-fetched, mode-agnostic)
if (actorTools.length > 0) {
result.push(...actorTools);
}
/**
* Auto-inject run-status and storage tools when call-actor, actor tools, or add-actor are present.
* Insert them right after call-actor (or appended at the end when call-actor is absent) so the
* default tool list reads in workflow order: call → get-actor-run → get-dataset-items →
* get-key-value-store-record → abort-actor-run. If the user explicitly selected these tools
* via category before `actors`, the de-dup pass below preserves their selector order.
*/
const hasCallActor = result.some((entry) => entry.name === HelperTools.ACTOR_CALL);
const hasActorTools = result.some((entry) => entry.type === 'actor');
const hasAddActorTool = result.some((entry) => entry.name === HelperTools.ACTOR_ADD);
// `get-actor-run`'s nextStep templates point at `get-dataset-items` / `get-key-value-store-record`,
// and the apps-mode widget calls `get-dataset-items` to fetch its preview. A runs-only session
// (e.g. `tools: ['runs']`) would otherwise land on an unrecommendable tool / empty widget.
const hasGetActorRun = result.some((entry) => entry.name === HelperTools.ACTOR_RUNS_GET);
// No presence guards here — the de-dup pass at the end drops any duplicates.
const toolsToInject: ToolEntry[] = [];
// `call-actor` and direct actor tools return a RunResponse whose `nextStep` may point at
// `get-actor-run` for polling (when the run is non-terminal at waitSecs cap), so the LLM
// needs that tool available. `call-actor-widget` returns immediately and the widget UI
// polls run status itself — that path doesn't drive the auto-inject decision here.
if (hasCallActor || (hasActorTools && mode === ServerMode.APPS)) {
toolsToInject.push(defaultGetActorRun);
}
if (hasCallActor || hasActorTools || hasAddActorTool || hasGetActorRun) {
toolsToInject.push(...AUTO_INJECTED_TOOLS);
}
if (toolsToInject.length > 0) {
const callActorIndex = result.findIndex((entry) => entry.name === HelperTools.ACTOR_CALL);
if (callActorIndex !== -1) {
result.splice(callActorIndex + 1, 0, ...toolsToInject);
} else {
result.push(...toolsToInject);
}
}
// Apps mode: append a widget tool for each base tool already in the result.
// Runs after the get-actor-run auto-inject so an auto-injected base still
// brings its widget sibling.
if (mode === ServerMode.APPS) {
for (const entry of [...result]) {
const widget = WIDGET_BY_BASE_TOOL.get(entry.name as HelperTools);
// Push unconditionally; any duplicates are stripped by the de-dup pass below.
if (widget) result.push(widget);
}
}
// De-duplicate by tool name for safety
const seen = new Set<string>();
return result.filter((entry) => !seen.has(entry.name) && seen.add(entry.name));
}
/** Convenience wrapper: {@link getActors} + {@link getToolsForServerMode} in sequence. */
export async function loadToolsFromInput(
input: Input,
apifyClient: ApifyClient,
mode: ServerMode = ServerMode.DEFAULT,
actorStore?: ActorStore,
): Promise<ToolEntry[]> {
const actorTools = await getActors(input, apifyClient, { actorStore });
return getToolsForServerMode(input, actorTools, mode);
}