@lingda_ai/agentrank 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,564 @@
1
+ import type { AgentRankClient, TaskInfo, SubmitResult } from "./agentrank-client.js";
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
+ import { mkdir, writeFile, readdir, readFile, rm } from "node:fs/promises";
4
+ import { join, extname } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { checkTask as guardCheckTask } from "./task-guard.js";
7
+ import { scanFileContent } from "./output-guard.js";
8
+
9
+ export interface TaskRunnerOptions {
10
+ workspaceRoot: string;
11
+ taskTimeoutSeconds: number;
12
+ logger: PluginRuntime["logger"];
13
+ }
14
+
15
+ interface ActiveTask {
16
+ taskId: string;
17
+ startTime: number;
18
+ controller: AbortController;
19
+ }
20
+
21
+ /**
22
+ * TaskRunner manages the lifecycle of individual tasks.
23
+ * For each task, it:
24
+ * 1. Creates an isolated workspace
25
+ * 2. Runs the task via OpenClaw's subagent runtime
26
+ * 3. Reports progress and submits results back to AgentRank
27
+ */
28
+ export class TaskRunner {
29
+ private client: AgentRankClient;
30
+ private options: TaskRunnerOptions;
31
+ private activeTasks = new Map<string, ActiveTask>();
32
+ private maxConcurrent: number;
33
+ private subagent: PluginRuntime["subagent"];
34
+
35
+ constructor(
36
+ client: AgentRankClient,
37
+ subagent: PluginRuntime["subagent"],
38
+ options: TaskRunnerOptions,
39
+ maxConcurrent: number,
40
+ ) {
41
+ this.client = client;
42
+ this.subagent = subagent;
43
+ this.options = options;
44
+ this.maxConcurrent = maxConcurrent;
45
+
46
+ // Listen for task cancellation
47
+ this.client.on("cancel", (taskId: unknown, reason: unknown) => {
48
+ this.cancelTask(taskId as string, reason as string);
49
+ });
50
+ }
51
+
52
+ get activeCount() {
53
+ return this.activeTasks.size;
54
+ }
55
+
56
+ get canAcceptMore() {
57
+ return this.activeTasks.size < this.maxConcurrent;
58
+ }
59
+
60
+ /** Handle a new task assignment */
61
+ async handleTask(task: TaskInfo): Promise<boolean> {
62
+ if (!this.canAcceptMore) {
63
+ this.options.logger.warn(
64
+ `[agentrank] Task ${task.taskId} rejected: max concurrent tasks reached (${this.maxConcurrent})`,
65
+ );
66
+ this.client.rejectTask(task.taskId, "Agent at capacity");
67
+ return false;
68
+ }
69
+
70
+ // Security pre-check: scan task for malicious patterns
71
+ const guardResult = guardCheckTask(task);
72
+ if (!guardResult.safe) {
73
+ this.options.logger.warn(
74
+ `[agentrank][guard] Task ${task.taskId} BLOCKED: ${guardResult.reasons.join(", ")}`,
75
+ );
76
+ this.client.rejectTask(task.taskId, `Blocked by safety guard: ${guardResult.reasons.join("; ")}`);
77
+ return false;
78
+ }
79
+
80
+ // Accept the task
81
+ this.client.acceptTask(task.taskId);
82
+
83
+ // Set up workspace
84
+ const workspaceDir = join(
85
+ this.options.workspaceRoot.replace("~", homedir()),
86
+ task.taskId,
87
+ );
88
+ await mkdir(workspaceDir, { recursive: true });
89
+
90
+ // Write task context to workspace
91
+ const taskContext = {
92
+ taskId: task.taskId,
93
+ title: task.title,
94
+ description: task.description,
95
+ category: task.category,
96
+ price: task.price,
97
+ deadline: task.deadline,
98
+ inputData: task.inputData,
99
+ workspaceDir,
100
+ };
101
+ await writeFile(
102
+ join(workspaceDir, "task.json"),
103
+ JSON.stringify(taskContext, null, 2),
104
+ );
105
+
106
+ // Track the task
107
+ const controller = new AbortController();
108
+ this.activeTasks.set(task.taskId, {
109
+ taskId: task.taskId,
110
+ startTime: Date.now(),
111
+ controller,
112
+ });
113
+
114
+ // Report status busy
115
+ this.client.updateStatus("busy", `Working on task: ${task.title}`);
116
+
117
+ // Start task execution in background
118
+ this.executeTask(task, workspaceDir, controller.signal).catch((err) => {
119
+ this.options.logger.error(
120
+ `[agentrank] Task ${task.taskId} execution failed: ${err instanceof Error ? err.message : String(err)}`,
121
+ );
122
+ });
123
+
124
+ return true;
125
+ }
126
+
127
+ /** Execute a task using OpenClaw's subagent runtime */
128
+ private async executeTask(
129
+ task: TaskInfo,
130
+ workspaceDir: string,
131
+ signal: AbortSignal,
132
+ ): Promise<void> {
133
+ const timeoutMs = this.options.taskTimeoutSeconds * 1000;
134
+ if (!this.activeTasks.has(task.taskId)) return;
135
+
136
+ try {
137
+ // Report progress: starting
138
+ this.client.updateProgress(task.taskId, {
139
+ percent: 5,
140
+ message: "Starting task execution",
141
+ stage: "initializing",
142
+ });
143
+
144
+ // Build the prompt for the subagent
145
+ const taskMessage = this.buildTaskMessage(task, workspaceDir);
146
+
147
+ // Use subagent runtime: run -> wait -> get messages
148
+ const sessionKey = `agentrank:${task.taskId}`;
149
+
150
+ this.client.updateProgress(task.taskId, {
151
+ percent: 20,
152
+ message: "Dispatching to subagent",
153
+ stage: "running",
154
+ });
155
+
156
+ const { runId } = await this.subagent.run({
157
+ sessionKey,
158
+ message: taskMessage,
159
+ extraSystemPrompt: this.buildSafetyPrompt(workspaceDir),
160
+ idempotencyKey: `agentrank-${task.taskId}-${Date.now()}`,
161
+ });
162
+
163
+ if (signal.aborted) return;
164
+
165
+ // Wait for the run to complete, polling progress periodically
166
+ const waitResult = await this.runWithTimeout(
167
+ this.waitForRunWithProgress(runId, task.taskId, timeoutMs, signal),
168
+ timeoutMs,
169
+ signal,
170
+ );
171
+
172
+ if (signal.aborted) return;
173
+
174
+ if (waitResult.status === "error") {
175
+ throw new Error(waitResult.error || "Subagent run failed");
176
+ }
177
+
178
+ if (waitResult.status === "timeout") {
179
+ throw new Error("Subagent run timed out");
180
+ }
181
+
182
+ this.client.updateProgress(task.taskId, {
183
+ percent: 80,
184
+ message: "Reading subagent output",
185
+ stage: "finalizing",
186
+ });
187
+
188
+ // Get the session messages to extract the result
189
+ const { messages } = await this.subagent.getSessionMessages({
190
+ sessionKey,
191
+ limit: 10,
192
+ });
193
+
194
+ // Extract the last assistant message as the summary
195
+ const summary = this.extractSummary(messages);
196
+
197
+ // Check if subagent refused the task (safety rules)
198
+ if (summary.startsWith("[REFUSED]")) {
199
+ this.options.logger.warn(
200
+ `[agentrank] Task ${task.taskId} refused by subagent: ${summary}`,
201
+ );
202
+ this.client.failTask(task.taskId, summary, false);
203
+ return;
204
+ }
205
+
206
+ if (signal.aborted) return;
207
+
208
+ // Save result to workspace
209
+ await writeFile(
210
+ join(workspaceDir, "result.json"),
211
+ JSON.stringify({
212
+ taskId: task.taskId,
213
+ runId,
214
+ messages,
215
+ summary,
216
+ completedAt: new Date().toISOString(),
217
+ }, null, 2),
218
+ );
219
+
220
+ // Upload output files to AgentRank server
221
+ this.client.updateProgress(task.taskId, {
222
+ percent: 90,
223
+ message: "Uploading output files to server",
224
+ stage: "uploading",
225
+ });
226
+
227
+ const outputRefs = await this.uploadWorkspaceFiles(task.taskId, workspaceDir);
228
+
229
+ // Submit result to AgentRank
230
+ const submitResult: SubmitResult = {
231
+ summary: String(summary).slice(0, 5000),
232
+ outputRefs,
233
+ };
234
+
235
+ this.client.submitTask(task.taskId, submitResult);
236
+
237
+ // Clean up local workspace to protect task sender privacy
238
+ try {
239
+ await rm(workspaceDir, { recursive: true, force: true });
240
+ } catch {
241
+ // Non-critical: best-effort cleanup
242
+ }
243
+
244
+ this.options.logger.info(
245
+ `[agentrank] Task ${task.taskId} completed and workspace cleaned`,
246
+ );
247
+ } catch (err) {
248
+ if (signal.aborted) return;
249
+
250
+ const errorMessage = err instanceof Error ? err.message : String(err);
251
+ this.options.logger.error(
252
+ `[agentrank] Task ${task.taskId} failed: ${errorMessage}`,
253
+ );
254
+
255
+ // Check if it's a timeout
256
+ const isTimeout = errorMessage.includes("timeout") || errorMessage.includes("Timeout");
257
+ this.client.failTask(task.taskId, errorMessage, isTimeout);
258
+ } finally {
259
+ this.activeTasks.delete(task.taskId);
260
+
261
+ // Always clean up workspace on failure too
262
+ try {
263
+ await rm(workspaceDir, { recursive: true, force: true });
264
+ } catch {
265
+ // Non-critical
266
+ }
267
+
268
+ // If no more active tasks, go back to online
269
+ if (this.activeTasks.size === 0) {
270
+ this.client.updateStatus("online", "Ready for tasks");
271
+ } else {
272
+ this.client.updateStatus("busy", `Working on ${this.activeTasks.size} task(s)`);
273
+ }
274
+ }
275
+ }
276
+
277
+ /** Extract a summary from subagent session messages */
278
+ private extractSummary(messages: unknown[]): string {
279
+ if (!Array.isArray(messages) || messages.length === 0) {
280
+ return "Task completed (no output captured)";
281
+ }
282
+
283
+ // Find the last assistant message with text content
284
+ for (let i = messages.length - 1; i >= 0; i--) {
285
+ const msg = messages[i] as { role?: string; content?: string | Array<{ type?: string; text?: string }> };
286
+ if (msg.role === "assistant" && msg.content) {
287
+ if (typeof msg.content === "string") {
288
+ return msg.content;
289
+ }
290
+ if (Array.isArray(msg.content)) {
291
+ const textParts = msg.content
292
+ .filter((c): c is { type: string; text: string } => c.type === "text" && typeof c.text === "string")
293
+ .map((c) => c.text);
294
+ if (textParts.length > 0) return textParts.join("\n");
295
+ }
296
+ }
297
+ }
298
+
299
+ return "Task completed";
300
+ }
301
+
302
+ /** Cancel a running task */
303
+ cancelTask(taskId: string, reason: string) {
304
+ const activeTask = this.activeTasks.get(taskId);
305
+ if (activeTask) {
306
+ activeTask.controller.abort();
307
+ this.activeTasks.delete(taskId);
308
+ this.options.logger.info(`[agentrank] Task ${taskId} cancelled: ${reason}`);
309
+
310
+ if (this.activeTasks.size === 0) {
311
+ this.client.updateStatus("online", "Ready for tasks");
312
+ }
313
+ }
314
+ }
315
+
316
+ /** Scan workspace for output files and upload them to AgentRank server via HTTP */
317
+ private async uploadWorkspaceFiles(
318
+ taskId: string,
319
+ workspaceDir: string,
320
+ ): Promise<SubmitResult["outputRefs"]> {
321
+ const MIME_MAP: Record<string, string> = {
322
+ ".md": "text/markdown",
323
+ ".txt": "text/plain",
324
+ ".json": "application/json",
325
+ ".ts": "text/typescript",
326
+ ".js": "text/javascript",
327
+ ".py": "text/x-python",
328
+ ".csv": "text/csv",
329
+ ".html": "text/html",
330
+ ".png": "image/png",
331
+ ".jpg": "image/jpeg",
332
+ ".jpeg": "image/jpeg",
333
+ ".pdf": "application/pdf",
334
+ };
335
+
336
+ const entries = await readdir(workspaceDir);
337
+ // Skip task.json and result.json (internal metadata)
338
+ const outputFiles = entries.filter((f) => f !== "task.json" && f !== "result.json");
339
+
340
+ if (outputFiles.length === 0) return [];
341
+
342
+ const refs: SubmitResult["outputRefs"] = [];
343
+
344
+ for (const fileName of outputFiles) {
345
+ const filePath = join(workspaceDir, fileName);
346
+ try {
347
+ const content = await readFile(filePath);
348
+
349
+ // Security scan: check for sensitive data before uploading
350
+ const scan = scanFileContent(fileName, content);
351
+ if (!scan.safe) {
352
+ for (const finding of scan.findings) {
353
+ this.options.logger.warn(
354
+ `[agentrank][guard] Output BLOCKED: ${finding.label} in ${finding.file}`,
355
+ );
356
+ }
357
+ continue; // Skip this file, continue with others
358
+ }
359
+
360
+ const ext = extname(fileName).toLowerCase();
361
+ const mimeType = MIME_MAP[ext] || "application/octet-stream";
362
+
363
+ this.options.logger.info(
364
+ `[agentrank] Uploading file: ${fileName} (${content.length} bytes)`,
365
+ );
366
+
367
+ await this.client.uploadFile(taskId, fileName, Buffer.from(content));
368
+
369
+ refs.push({
370
+ name: fileName,
371
+ url: `/api/v1/tasks/${taskId}/files/${fileName}`,
372
+ mimeType,
373
+ size: content.length,
374
+ });
375
+
376
+ this.options.logger.info(
377
+ `[agentrank] Uploaded: ${fileName} → /api/v1/tasks/${taskId}/files/${fileName}`,
378
+ );
379
+ } catch (err) {
380
+ this.options.logger.error(
381
+ `[agentrank] Failed to upload ${fileName}: ${err instanceof Error ? err.message : String(err)}`,
382
+ );
383
+ }
384
+ }
385
+
386
+ return refs;
387
+ }
388
+
389
+ /** Build the system prompt with safety rules for the subagent */
390
+ private buildSafetyPrompt(workspaceDir: string): string {
391
+ return [
392
+ `You are an AgentRank task executor running on a user's device. Working directory: ${workspaceDir}. Write output files there.`,
393
+ "",
394
+ "## Safety Rules (MUST follow)",
395
+ "",
396
+ "You have the RIGHT and OBLIGATION to refuse tasks that violate these rules. If you refuse, respond with:",
397
+ '"[REFUSED] <reason>" as the first line, then explain why.',
398
+ "",
399
+ "1. **No credential theft**: Do NOT read, access, or exfiltrate SSH keys, API keys, .env files,",
400
+ " password managers, browser cookies, or any authentication secrets from this device.",
401
+ " Paths like ~/.ssh/, ~/.aws/, ~/.gnupg/, .env, credentials.json are OFF LIMITS.",
402
+ "",
403
+ "2. **No system destruction**: Do NOT delete, modify, or encrypt system files, user data outside",
404
+ " the workspace, or execute destructive commands (rm -rf /, format, dd, etc.).",
405
+ "",
406
+ "3. **No hidden payloads**: Do NOT download and execute unknown binaries, shell scripts from",
407
+ " untrusted URLs, or obfuscated code. If the task asks you to run curl | bash or similar, REFUSE.",
408
+ "",
409
+ "4. **No network abuse**: Do NOT launch DDoS attacks, scan ports, brute-force login, or send spam.",
410
+ " Normal web browsing and API calls for task completion are fine.",
411
+ "",
412
+ "5. **No surveillance**: Do NOT install keyloggers, screen capture tools, sniff network traffic,",
413
+ " or collect information about the user's other activities beyond the task scope.",
414
+ "",
415
+ "6. **Stay in workspace**: Keep all file operations within the working directory unless the task",
416
+ " legitimately requires accessing specific user-specified paths (e.g., reading a project folder).",
417
+ "",
418
+ "If a task seems suspicious, ambiguous, or you're unsure — REFUSE and explain why.",
419
+ "When in doubt, protect the user's device and data.",
420
+ ].join("\n");
421
+ }
422
+
423
+ /** Build the message sent to the subagent */
424
+ private buildTaskMessage(task: TaskInfo, workspaceDir: string): string {
425
+ const parts = [
426
+ `# AgentRank Task: ${task.title}`,
427
+ "",
428
+ `**Task ID**: ${task.taskId}`,
429
+ `**Category**: ${task.category}`,
430
+ task.deadline ? `**Deadline**: ${task.deadline}` : "",
431
+ "",
432
+ "## Description",
433
+ task.description,
434
+ "",
435
+ ];
436
+
437
+ if (task.inputData && Object.keys(task.inputData).length > 0) {
438
+ parts.push(
439
+ "## Input Data",
440
+ "```json",
441
+ JSON.stringify(task.inputData, null, 2),
442
+ "```",
443
+ "",
444
+ );
445
+ }
446
+
447
+ parts.push(
448
+ "## Instructions",
449
+ `Complete the task described above. Your working directory is: ${workspaceDir}`,
450
+ "Write any output files to the working directory.",
451
+ "Provide a clear summary of what you did and the results.",
452
+ );
453
+
454
+ return parts.filter(Boolean).join("\n");
455
+ }
456
+
457
+ /** Wait for a subagent run while polling for progress updates */
458
+ private async waitForRunWithProgress(
459
+ runId: string,
460
+ taskId: string,
461
+ timeoutMs: number,
462
+ signal: AbortSignal,
463
+ ): Promise<{ status: string; error?: string }> {
464
+ const pollIntervalMs = 15_000; // Poll every 15 seconds
465
+ const startTime = Date.now();
466
+ let lastPercent = 20;
467
+
468
+ // Start the actual wait in background
469
+ const waitPromise = this.subagent.waitForRun({ runId, timeoutMs });
470
+
471
+ // Poll progress while waiting
472
+ const pollTimer = setInterval(async () => {
473
+ if (signal.aborted) {
474
+ clearInterval(pollTimer);
475
+ return;
476
+ }
477
+
478
+ const elapsed = Date.now() - startTime;
479
+ const remaining = Math.max(0, timeoutMs - elapsed);
480
+ const elapsedPercent = Math.min(70, Math.floor((elapsed / timeoutMs) * 70));
481
+
482
+ if (elapsedPercent > lastPercent) {
483
+ lastPercent = elapsedPercent;
484
+ this.client.updateProgress(taskId, {
485
+ percent: 20 + elapsedPercent,
486
+ message: `Subagent running (${Math.floor(elapsed / 1000)}s elapsed, ${Math.floor(remaining / 1000)}s remaining)`,
487
+ stage: "running",
488
+ });
489
+ }
490
+
491
+ // Try to get session messages to see latest activity
492
+ try {
493
+ const { messages } = await this.subagent.getSessionMessages({
494
+ sessionKey: `agentrank:${taskId}`,
495
+ limit: 3,
496
+ });
497
+ const lastMsg = this.extractSummary(messages);
498
+ if (lastMsg && lastMsg !== "Task completed") {
499
+ const snippet = lastMsg.slice(0, 100).replace(/\n/g, " ");
500
+ this.options.logger.info(
501
+ `[agentrank] Subagent progress for ${taskId}: ${snippet}...`,
502
+ );
503
+ }
504
+ } catch {
505
+ // Ignore polling errors
506
+ }
507
+ }, pollIntervalMs);
508
+
509
+ try {
510
+ const result = await waitPromise;
511
+ clearInterval(pollTimer);
512
+ return result;
513
+ } catch (err) {
514
+ clearInterval(pollTimer);
515
+ throw err;
516
+ }
517
+ }
518
+
519
+ /** Run a promise with timeout and abort signal support */
520
+ private runWithTimeout<T>(
521
+ promise: Promise<T>,
522
+ timeoutMs: number,
523
+ signal: AbortSignal,
524
+ ): Promise<T> {
525
+ return new Promise<T>((resolve, reject) => {
526
+ const timer = setTimeout(() => {
527
+ reject(new Error(`Task timed out after ${timeoutMs / 1000}s`));
528
+ }, timeoutMs);
529
+
530
+ const onAbort = () => {
531
+ clearTimeout(timer);
532
+ reject(new Error("Task cancelled"));
533
+ };
534
+
535
+ if (signal.aborted) {
536
+ clearTimeout(timer);
537
+ reject(new Error("Task cancelled"));
538
+ return;
539
+ }
540
+ signal.addEventListener("abort", onAbort, { once: true });
541
+
542
+ promise
543
+ .then((result) => {
544
+ clearTimeout(timer);
545
+ signal.removeEventListener("abort", onAbort);
546
+ resolve(result);
547
+ })
548
+ .catch((err) => {
549
+ clearTimeout(timer);
550
+ signal.removeEventListener("abort", onAbort);
551
+ reject(err);
552
+ });
553
+ });
554
+ }
555
+
556
+ /** Stop all active tasks */
557
+ async stopAll() {
558
+ for (const [taskId, activeTask] of this.activeTasks) {
559
+ activeTask.controller.abort();
560
+ this.client.failTask(taskId, "Agent shutting down", true);
561
+ }
562
+ this.activeTasks.clear();
563
+ }
564
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": ".",
11
+ "declaration": true,
12
+ "sourceMap": true,
13
+ "types": ["bun-types"]
14
+ },
15
+ "include": ["index.ts", "src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }