@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.
- package/README.md +127 -0
- package/index.ts +396 -0
- package/openclaw.plugin.json +72 -0
- package/package.json +41 -0
- package/src/agentrank-client.ts +295 -0
- package/src/config.ts +31 -0
- package/src/output-guard.ts +108 -0
- package/src/task-guard.ts +182 -0
- package/src/task-runner.ts +564 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
}
|