@agrentingai/paperclip-adapter 0.2.0

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,1044 @@
1
+ import { AgrentingClient } from "./client.js";
2
+ import type {
3
+ AgrentingAdapterConfig,
4
+ AgrentingExecutionResult,
5
+ AgrentingTaskStatus,
6
+ AgentInfo,
7
+ AgentProfile,
8
+ BalanceInfo as BalanceInfoRaw,
9
+ HireAgentResult,
10
+ PaymentInfo,
11
+ ReassignTaskResult,
12
+ SendMessageResult,
13
+ TransactionInfo,
14
+ DiscoverAgentsOptions,
15
+ CreateTaskPaymentOptions,
16
+ Hiring,
17
+ TaskMessage,
18
+ HiringMessage,
19
+ Capability,
20
+ AutoSelectOptions,
21
+ } from "./types.js";
22
+ import { registerTaskMapping } from "./webhook-handler.js";
23
+ import { pollTaskUntilDone } from "./polling.js";
24
+ import { canSubmitTask } from "./balance-monitor.js";
25
+ import { verifyWebhookSignature } from "./crypto.js";
26
+ import { formatAgentResponse } from "./comment-sync.js";
27
+
28
+ const DEFAULT_WEBHOOK_PORT = 8765;
29
+ const MAX_WEBHOOK_BODY_SIZE = 1024 * 1024; // 1MB — prevent OOM from oversized bodies
30
+ const STALE_TASK_CLEANUP_INTERVAL_MS = 60_000; // 60s
31
+ const STALE_TASK_TTL_MS = 2 * 60 * 60 * 1000; // 2h — max age before cleanup sweeps it
32
+
33
+ /** In-memory store for webhook listeners keyed by task ID */
34
+ const pendingTasks = new Map<
35
+ string,
36
+ {
37
+ resolve: (result: AgrentingExecutionResult) => void;
38
+ status: AgrentingTaskStatus;
39
+ progressPercent: number;
40
+ progressMessage?: string;
41
+ startedAt: number;
42
+ createdAt: number;
43
+ settled: boolean;
44
+ }
45
+ >();
46
+
47
+ let webhookServer: ReturnType<typeof import("http").createServer> | null = null;
48
+ let staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
49
+
50
+ /**
51
+ * Sweep pendingTasks for entries that are settled or older than the TTL,
52
+ * removing them from the map to bound memory usage.
53
+ */
54
+ function sweepStaleTasks(): void {
55
+ const now = Date.now();
56
+ for (const [id, entry] of pendingTasks) {
57
+ if (entry.settled || now - entry.createdAt > STALE_TASK_TTL_MS) {
58
+ pendingTasks.delete(id);
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * JSON Schema for Agrenting adapter configuration fields.
65
+ * Used by Paperclip server to validate adapter config at creation time.
66
+ */
67
+ export function getConfigSchema(): Record<string, unknown> {
68
+ return {
69
+ type: "object",
70
+ required: ["agrentingUrl", "apiKey", "agentDid"],
71
+ properties: {
72
+ agrentingUrl: {
73
+ type: "string",
74
+ format: "uri",
75
+ description: "Agrenting platform URL (e.g. https://www.agrenting.com)",
76
+ default: "https://www.agrenting.com",
77
+ },
78
+ apiKey: {
79
+ type: "string",
80
+ description: "Agrenting API key for authentication",
81
+ sensitive: true,
82
+ },
83
+ agentDid: {
84
+ type: "string",
85
+ description:
86
+ "Decentralized identifier of the target agent (did:agrenting:...)",
87
+ },
88
+ webhookSecret: {
89
+ type: "string",
90
+ description: "Webhook signing secret for task completion callbacks",
91
+ sensitive: true,
92
+ },
93
+ webhookCallbackUrl: {
94
+ type: "string",
95
+ format: "uri",
96
+ description:
97
+ "URL where Agrenting should POST task events (e.g. https://your-host:8765/webhook)",
98
+ },
99
+ pricingModel: {
100
+ type: "string",
101
+ enum: ["fixed", "per-token", "subscription"],
102
+ description: "Pricing model for this agent",
103
+ default: "fixed",
104
+ },
105
+ timeoutSec: {
106
+ type: "integer",
107
+ minimum: 10,
108
+ maximum: 3600,
109
+ description: "Task timeout in seconds",
110
+ default: 600,
111
+ },
112
+ instructionsBundleMode: {
113
+ type: "string",
114
+ enum: ["managed", "inline"],
115
+ description: "How agent instructions are delivered",
116
+ default: "inline",
117
+ },
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Validate the adapter configuration and test connectivity.
124
+ */
125
+ export async function testEnvironment(
126
+ config: AgrentingAdapterConfig
127
+ ): Promise<{ ok: boolean; message: string }> {
128
+ const client = new AgrentingClient(config);
129
+ return client.testConnection();
130
+ }
131
+
132
+ /**
133
+ * Start an HTTP listener that receives webhook callbacks from Agrenting.
134
+ * Returns the base URL of the listener (for registering with Agrenting).
135
+ *
136
+ * The listener resolves pending `execute()` calls when task events arrive.
137
+ * Only call this once per process — subsequent calls return the existing server.
138
+ */
139
+ export async function startWebhookListener(
140
+ config: AgrentingAdapterConfig
141
+ ): Promise<string> {
142
+ if (webhookServer) {
143
+ const addr = webhookServer.address();
144
+ const port =
145
+ typeof addr === "object" && addr ? addr.port : DEFAULT_WEBHOOK_PORT;
146
+ return `http://localhost:${port}/webhook`;
147
+ }
148
+
149
+ const http = await import("http");
150
+ const port = process.env.PAPERCLIP_WEBHOOK_PORT
151
+ ? parseInt(process.env.PAPERCLIP_WEBHOOK_PORT, 10)
152
+ : DEFAULT_WEBHOOK_PORT;
153
+
154
+ return new Promise((resolve, reject) => {
155
+ const server = http.createServer(async (req, res) => {
156
+ if (req.method !== "POST" || req.url !== "/webhook") {
157
+ res.writeHead(404);
158
+ res.end("Not found");
159
+ return;
160
+ }
161
+
162
+ let bodyChunks: Buffer[] = [];
163
+ let bodyLength = 0;
164
+ let bodyTooLarge = false;
165
+ req.on("data", (chunk: Buffer) => {
166
+ bodyLength += chunk.length;
167
+ if (bodyLength > MAX_WEBHOOK_BODY_SIZE) {
168
+ bodyTooLarge = true;
169
+ res.writeHead(413);
170
+ res.end("Request body too large");
171
+ req.destroy();
172
+ return;
173
+ }
174
+ bodyChunks.push(chunk);
175
+ });
176
+
177
+ req.on("end", async () => {
178
+ if (bodyTooLarge) return;
179
+ const rawBody = Buffer.concat(bodyChunks).toString("utf8");
180
+ bodyChunks = []; // free reference for GC
181
+ const taskId = req.headers["x-webhook-task-id"] as string | undefined;
182
+ const signature =
183
+ (req.headers["x-webhook-signature"] as string) || "";
184
+
185
+ let payload: Record<string, unknown>;
186
+ try {
187
+ payload = JSON.parse(rawBody);
188
+ } catch {
189
+ res.writeHead(400);
190
+ res.end("Invalid JSON");
191
+ return;
192
+ }
193
+
194
+ // Verify signature if secret is configured
195
+ if (config.webhookSecret) {
196
+ if (!signature) {
197
+ res.writeHead(401);
198
+ res.end("Missing signature");
199
+ return;
200
+ }
201
+ const valid = await verifyWebhookSignature(
202
+ rawBody,
203
+ signature,
204
+ config.webhookSecret
205
+ );
206
+ if (!valid) {
207
+ res.writeHead(401);
208
+ res.end("Invalid signature");
209
+ return;
210
+ }
211
+ }
212
+
213
+ // Extract task ID from payload if not in header
214
+ const resolvedTaskId =
215
+ taskId ??
216
+ (payload.task_id as string) ??
217
+ (payload.taskId as string);
218
+
219
+ if (resolvedTaskId) {
220
+ const pending = pendingTasks.get(resolvedTaskId);
221
+ if (pending && !pending.settled) {
222
+ const status = (payload.status as AgrentingTaskStatus) ?? pending.status;
223
+ pending.status = status;
224
+ pending.progressPercent =
225
+ (payload.progress_percent as number) ?? pending.progressPercent;
226
+ pending.progressMessage =
227
+ (payload.progress_message as string) ?? pending.progressMessage;
228
+
229
+ if (status === "completed") {
230
+ pending.settled = true;
231
+ pending.resolve({
232
+ success: true,
233
+ output: (payload.output as string) ?? JSON.stringify(payload.output ?? {}),
234
+ taskId: resolvedTaskId,
235
+ durationMs: Date.now() - pending.startedAt,
236
+ });
237
+ } else if (status === "failed") {
238
+ pending.settled = true;
239
+ pending.resolve({
240
+ success: false,
241
+ error:
242
+ (payload.error_reason as string) ??
243
+ "Task failed with no reason provided",
244
+ taskId: resolvedTaskId,
245
+ durationMs: Date.now() - pending.startedAt,
246
+ });
247
+ } else if (status === "cancelled") {
248
+ pending.settled = true;
249
+ pending.resolve({
250
+ success: false,
251
+ error: "Task was cancelled",
252
+ taskId: resolvedTaskId,
253
+ durationMs: Date.now() - pending.startedAt,
254
+ });
255
+ }
256
+ }
257
+ }
258
+
259
+ res.writeHead(200, { "Content-Type": "text/plain" });
260
+ res.end("OK");
261
+ });
262
+ });
263
+
264
+ server.listen(port, () => {
265
+ webhookServer = server;
266
+ // Start periodic cleanup of stale entries
267
+ if (!staleCleanupTimer) {
268
+ staleCleanupTimer = setInterval(sweepStaleTasks, STALE_TASK_CLEANUP_INTERVAL_MS);
269
+ staleCleanupTimer.unref();
270
+ }
271
+ resolve(`http://localhost:${port}/webhook`);
272
+ });
273
+
274
+ server.on("error", reject);
275
+ });
276
+ }
277
+
278
+ const WEBHOOK_STOP_TIMEOUT_MS = 5_000;
279
+
280
+ /**
281
+ * Stop the webhook listener if it was started.
282
+ * Closes all active connections and waits up to 5s for a clean shutdown.
283
+ */
284
+ export async function stopWebhookListener(): Promise<void> {
285
+ if (!webhookServer) {
286
+ return;
287
+ }
288
+
289
+ if (staleCleanupTimer) {
290
+ clearInterval(staleCleanupTimer);
291
+ staleCleanupTimer = null;
292
+ }
293
+
294
+ const server = webhookServer;
295
+ webhookServer = null;
296
+
297
+ // Force-close all active connections (Node 18.2+) so server.close() doesn't hang
298
+ if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
299
+ (server as import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>).closeAllConnections();
300
+ }
301
+
302
+ return new Promise((resolve) => {
303
+ const timeout = setTimeout(() => {
304
+ resolve();
305
+ }, WEBHOOK_STOP_TIMEOUT_MS);
306
+ timeout.unref();
307
+
308
+ server.close(() => {
309
+ clearTimeout(timeout);
310
+ resolve();
311
+ });
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Register a webhook with Agrenting to receive task lifecycle events.
317
+ * Returns the webhook ID and secret key.
318
+ */
319
+ export async function registerWebhook(
320
+ config: AgrentingAdapterConfig,
321
+ callbackUrl?: string
322
+ ): Promise<{
323
+ id: string;
324
+ secretKey: string;
325
+ callbackUrl: string;
326
+ }> {
327
+ const client = new AgrentingClient(config);
328
+ const url = callbackUrl ?? (await startWebhookListener(config));
329
+
330
+ const result = await client.registerWebhook({
331
+ callbackUrl: url,
332
+ eventTypes: [
333
+ "task.created",
334
+ "task.claimed",
335
+ "task.in_progress",
336
+ "task.completed",
337
+ "task.failed",
338
+ "task.cancelled",
339
+ ],
340
+ });
341
+
342
+ return {
343
+ id: result.id,
344
+ secretKey: result.secret_key,
345
+ callbackUrl: result.callback_url,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Deregister a webhook from Agrenting to stop receiving task lifecycle events.
351
+ * Use this to clean up orphaned webhooks when they are no longer needed.
352
+ */
353
+ export async function deregisterWebhook(
354
+ config: AgrentingAdapterConfig,
355
+ webhookId: string
356
+ ): Promise<void> {
357
+ const client = new AgrentingClient(config);
358
+ await client.deleteWebhook(webhookId);
359
+ }
360
+
361
+ /**
362
+ * Execute a task by submitting it to the Agrenting platform.
363
+ *
364
+ * Uses webhook callbacks when `webhookCallbackUrl` is configured in the adapter config
365
+ * (or when `startWebhookListener()` has been called). Falls back to polling
366
+ * when webhooks are not available.
367
+ *
368
+ * When `maxPrice` is provided, the task is created with a budget and escrow funds
369
+ * are locked via `createTaskPayment()` after submission.
370
+ */
371
+ export async function execute(
372
+ config: AgrentingAdapterConfig,
373
+ params: {
374
+ input: string;
375
+ capability: string;
376
+ instructions?: string;
377
+ /** Maximum price in USD to budget for this task. Triggers escrow payment. */
378
+ maxPrice?: string;
379
+ /** Payment type: "crypto" | "escrow" | "nowpayments". Defaults to "crypto". */
380
+ paymentType?: string;
381
+ }
382
+ ): Promise<AgrentingExecutionResult> {
383
+ const client = new AgrentingClient(config);
384
+ const startTime = Date.now();
385
+
386
+ // Upload instructions if managed mode is configured
387
+ if (
388
+ config.instructionsBundleMode === "managed" &&
389
+ params.instructions
390
+ ) {
391
+ await client.uploadDocument({
392
+ name: "instructions",
393
+ content: params.instructions,
394
+ documentType: "instructions",
395
+ });
396
+ }
397
+
398
+ // Pre-submission balance check (non-blocking — logs warning but doesn't prevent)
399
+ const balanceCheck = await canSubmitTask({ config });
400
+ if (!balanceCheck.ok) {
401
+ // Log but don't block — let the task fail naturally
402
+ console.warn(`[adapter-agrenting] ${balanceCheck.reason}`);
403
+ }
404
+
405
+ // Submit the task to Agrenting
406
+ const task = await client.createTask({
407
+ providerAgentId: config.agentDid,
408
+ capability: params.capability,
409
+ input: params.input,
410
+ maxPrice: params.maxPrice,
411
+ paymentType: params.paymentType,
412
+ });
413
+
414
+ const taskId = task.id;
415
+
416
+ // Lock escrow funds if a max price was specified.
417
+ // If payment fails, cancel the orphaned task to avoid leaving it stuck on the server.
418
+ let payment: PaymentInfo | undefined;
419
+ if (params.maxPrice) {
420
+ try {
421
+ const paymentOptions: CreateTaskPaymentOptions = {};
422
+ if (params.paymentType) paymentOptions.paymentType = params.paymentType;
423
+ payment = await client.createTaskPayment(taskId, paymentOptions);
424
+ console.log(`[adapter-agrenting] Escrow locked for task ${taskId}: ${payment.amount} ${payment.currency} (${payment.status})`);
425
+ } catch (err) {
426
+ console.error(`[adapter-agrenting] Failed to lock escrow for task ${taskId}, cancelling orphaned task:`, err);
427
+ try {
428
+ await client.cancelTask(taskId);
429
+ } catch {
430
+ // Best-effort cleanup — log but don't throw, the payment error is the real problem
431
+ }
432
+ return {
433
+ success: false,
434
+ error: `Escrow payment failed: ${err instanceof Error ? err.message : String(err)}`,
435
+ taskId,
436
+ durationMs: Date.now() - startTime,
437
+ };
438
+ }
439
+ }
440
+
441
+ // Register for webhook callbacks only when webhook mode is actually configured.
442
+ // The taskRegistry in webhook-handler.ts is for the Paperclip-side webhook handler
443
+ // (issue status updates), while pendingTasks below is for the in-process listener
444
+ // (resolving execute() promises). They serve different purposes.
445
+ if (config.webhookCallbackUrl || config.webhookSecret) {
446
+ registerTaskMapping(taskId, taskId, config.agrentingUrl, config);
447
+ return executeWithWebhook(client, config, taskId, startTime);
448
+ }
449
+
450
+ // Fall back to polling — delegate to pollTaskUntilDone
451
+ return executeWithPolling(config, taskId, startTime);
452
+ }
453
+
454
+ /**
455
+ * Execute with webhook: register a listener, submit task, wait for callback.
456
+ * Falls back to polling if no webhook received within the grace period.
457
+ */
458
+ async function executeWithWebhook(
459
+ _client: AgrentingClient,
460
+ config: AgrentingAdapterConfig,
461
+ taskId: string,
462
+ startTime: number
463
+ ): Promise<AgrentingExecutionResult> {
464
+ // Ensure the listener is running
465
+ await startWebhookListener(config);
466
+
467
+ const deadline = startTime + (config.timeoutSec ?? 600) * 1000;
468
+
469
+ // AbortController for clean cancellation when webhook resolves first
470
+ const abortController = new AbortController();
471
+
472
+ // Register the pending task so the webhook handler can resolve it
473
+ const pending = new Promise<AgrentingExecutionResult>((resolve) => {
474
+ pendingTasks.set(taskId, {
475
+ resolve,
476
+ status: "pending",
477
+ progressPercent: 0,
478
+ startedAt: startTime,
479
+ createdAt: Date.now(),
480
+ settled: false,
481
+ });
482
+ }).then((result) => {
483
+ // Webhook resolved — abort any in-flight polling
484
+ abortController.abort();
485
+ return result;
486
+ });
487
+
488
+ // Race between webhook callback and timeout
489
+ const timeout = new Promise<AgrentingExecutionResult>((resolve) => {
490
+ const ms = deadline - Date.now();
491
+ setTimeout(() => {
492
+ const entry = pendingTasks.get(taskId);
493
+ if (entry && !entry.settled) {
494
+ entry.settled = true;
495
+ }
496
+ resolve({
497
+ success: false,
498
+ error: `Task timed out after ${config.timeoutSec ?? 600}s`,
499
+ taskId,
500
+ durationMs: Date.now() - startTime,
501
+ });
502
+ }, Math.max(ms, 0));
503
+ });
504
+
505
+ // If webhook doesn't resolve within grace period, fall back to polling.
506
+ // Polling is deferred so it only starts when the timeout actually fires,
507
+ // avoiding wasted HTTP calls when the webhook wins.
508
+ return Promise.race([pending, timeout.then(async (result) => {
509
+ if (!result.success && result.error?.includes("timed out")) {
510
+ const pollingFallback = await pollTaskUntilDone({
511
+ config,
512
+ taskId,
513
+ deadline,
514
+ signal: abortController.signal,
515
+ });
516
+ return pollingFallback.result;
517
+ }
518
+ return result;
519
+ })]);
520
+ }
521
+
522
+ /**
523
+ * Execute with polling by delegating to pollTaskUntilDone.
524
+ * Avoids reimplementing the backoff loop that polling.ts already provides.
525
+ */
526
+ async function executeWithPolling(
527
+ config: AgrentingAdapterConfig,
528
+ taskId: string,
529
+ startTime: number
530
+ ): Promise<AgrentingExecutionResult> {
531
+ const deadline = startTime + (config.timeoutSec ?? 600) * 1000;
532
+ const { result } = await pollTaskUntilDone({ config, taskId, deadline });
533
+ return result;
534
+ }
535
+
536
+ /**
537
+ * Get the current progress of a task including percentage and message.
538
+ * Useful for progress monitoring in UI dashboards.
539
+ */
540
+ export async function getTaskProgress(
541
+ config: AgrentingAdapterConfig,
542
+ taskId: string
543
+ ): Promise<{
544
+ status: AgrentingTaskStatus;
545
+ progressPercent: number;
546
+ progressMessage?: string;
547
+ timeline: Array<{
548
+ event_type: string;
549
+ timestamp: string;
550
+ progress_percent?: number;
551
+ progress_message?: string;
552
+ }>;
553
+ }> {
554
+ const client = new AgrentingClient(config);
555
+ const [progress, timeline] = await Promise.all([
556
+ client.getTaskProgress(taskId),
557
+ client.getTaskTimeline(taskId),
558
+ ]);
559
+
560
+ return {
561
+ status: progress.status as AgrentingTaskStatus,
562
+ progressPercent: progress.progress_percent,
563
+ progressMessage: progress.progress_message,
564
+ timeline: timeline.events,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Discover marketplace agents available for hire.
570
+ * Filters by capability, price range, reputation, and availability.
571
+ */
572
+ export async function discoverAgents(
573
+ config: AgrentingAdapterConfig,
574
+ options: DiscoverAgentsOptions = {}
575
+ ): Promise<AgentInfo[]> {
576
+ const client = new AgrentingClient(config);
577
+ return client.discoverAgents(options);
578
+ }
579
+
580
+ /**
581
+ * Get the current platform balance including available, escrowed, and total amounts.
582
+ */
583
+ export async function getBalance(
584
+ config: AgrentingAdapterConfig
585
+ ): Promise<BalanceInfoRaw> {
586
+ const client = new AgrentingClient(config);
587
+ return client.getBalance();
588
+ }
589
+
590
+ /**
591
+ * List recent ledger transactions.
592
+ */
593
+ export async function getTransactions(
594
+ config: AgrentingAdapterConfig,
595
+ options: { limit?: number; offset?: number; type?: string } = {}
596
+ ): Promise<TransactionInfo[]> {
597
+ const client = new AgrentingClient(config);
598
+ return client.getTransactions(options);
599
+ }
600
+
601
+ /**
602
+ * Deposit funds into the Agrenting ledger.
603
+ */
604
+ export async function deposit(
605
+ config: AgrentingAdapterConfig,
606
+ params: { amount: string; currency?: string; paymentMethod?: string }
607
+ ): Promise<{ transaction_id: string; status: string; deposit_address?: string; payment_url?: string }> {
608
+ const client = new AgrentingClient(config);
609
+ return client.deposit(params);
610
+ }
611
+
612
+ /**
613
+ * Withdraw funds from the Agrenting ledger to an external wallet.
614
+ */
615
+ export async function withdraw(
616
+ config: AgrentingAdapterConfig,
617
+ params: { amount: string; currency?: string; withdrawalAddressId?: string }
618
+ ): Promise<{ transaction_id: string; status: string }> {
619
+ const client = new AgrentingClient(config);
620
+ return client.withdraw(params);
621
+ }
622
+
623
+ /**
624
+ * Get the payment status and escrow details for a task.
625
+ */
626
+ export async function getTaskPayment(
627
+ config: AgrentingAdapterConfig,
628
+ taskId: string
629
+ ): Promise<PaymentInfo | undefined> {
630
+ const client = new AgrentingClient(config);
631
+ try {
632
+ return await client.getTaskPayment(taskId);
633
+ } catch {
634
+ return undefined;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Cancel a running task.
640
+ */
641
+ export async function cancelTask(
642
+ config: AgrentingAdapterConfig,
643
+ taskId: string
644
+ ): Promise<{ success: boolean; error?: string }> {
645
+ const client = new AgrentingClient(config);
646
+ try {
647
+ await client.cancelTask(taskId);
648
+ pendingTasks.delete(taskId);
649
+ return { success: true };
650
+ } catch (err) {
651
+ return {
652
+ success: false,
653
+ error: err instanceof Error ? err.message : "Failed to cancel task",
654
+ };
655
+ }
656
+ }
657
+
658
+ // -------------------------------------------------------------------------
659
+ // New adapter functions for hire, messaging, auto-select, and retry
660
+ // -------------------------------------------------------------------------
661
+
662
+ /** Task retry configuration */
663
+ const TASK_MAX_RETRIES = 2;
664
+ const TASK_RETRY_BASE_DELAY_MS = 1000; // 1s initial delay
665
+ const TASK_RETRY_MAX_DELAY_MS = 30_000; // 30s max delay
666
+
667
+ /**
668
+ * Hire an agent by DID. Returns hiring record and adapter config for auto-provisioning.
669
+ * This is the primary entry point for the "browse marketplace, click Hire" flow.
670
+ */
671
+ export async function hireAgent(
672
+ config: AgrentingAdapterConfig,
673
+ agentDid: string
674
+ ): Promise<HireAgentResult> {
675
+ const client = new AgrentingClient(config);
676
+ return client.hireAgent(agentDid);
677
+ }
678
+
679
+ /**
680
+ * Get full agent profile by DID.
681
+ * Returns description, capabilities, pricing, reputation, and availability.
682
+ */
683
+ export async function getAgentProfile(
684
+ config: AgrentingAdapterConfig,
685
+ agentDid: string
686
+ ): Promise<AgentProfile> {
687
+ const client = new AgrentingClient(config);
688
+ return client.getAgentProfile(agentDid);
689
+ }
690
+
691
+ /**
692
+ * Send a message to an active task for bidirectional communication.
693
+ * Used for sending follow-up instructions to the remote agent mid-task.
694
+ */
695
+ export async function sendMessageToTask(
696
+ config: AgrentingAdapterConfig,
697
+ taskId: string,
698
+ message: string
699
+ ): Promise<SendMessageResult> {
700
+ const client = new AgrentingClient(config);
701
+ return client.sendMessageToTask(taskId, { message });
702
+ }
703
+
704
+ /**
705
+ * Get messages for a task (bidirectional comment history).
706
+ */
707
+ export async function getTaskMessages(
708
+ config: AgrentingAdapterConfig,
709
+ taskId: string
710
+ ): Promise<TaskMessage[]> {
711
+ const client = new AgrentingClient(config);
712
+ return client.getTaskMessages(taskId);
713
+ }
714
+
715
+ /**
716
+ * Reassign a failed/cancelled task to a different agent.
717
+ * If newAgentDid is not provided, the system will auto-select a replacement.
718
+ */
719
+ export async function reassignTask(
720
+ config: AgrentingAdapterConfig,
721
+ taskId: string,
722
+ newAgentDid?: string
723
+ ): Promise<ReassignTaskResult> {
724
+ const client = new AgrentingClient(config);
725
+ return client.reassignTask(taskId, newAgentDid);
726
+ }
727
+
728
+ /**
729
+ * List all available capabilities with descriptions and usage stats.
730
+ * Helps with agent discovery and validation.
731
+ */
732
+ export async function listCapabilities(
733
+ config: AgrentingAdapterConfig
734
+ ): Promise<Capability[]> {
735
+ const client = new AgrentingClient(config);
736
+ return client.listCapabilities();
737
+ }
738
+
739
+ /**
740
+ * Send a message to a hiring for communication with the hired agent.
741
+ */
742
+ export async function sendMessageToHiring(
743
+ config: AgrentingAdapterConfig,
744
+ hiringId: string,
745
+ message: string
746
+ ): Promise<HiringMessage> {
747
+ const client = new AgrentingClient(config);
748
+ return client.sendMessageToHiring(hiringId, message);
749
+ }
750
+
751
+ /**
752
+ * Get messages for a hiring.
753
+ */
754
+ export async function getHiringMessages(
755
+ config: AgrentingAdapterConfig,
756
+ hiringId: string
757
+ ): Promise<HiringMessage[]> {
758
+ const client = new AgrentingClient(config);
759
+ return client.getHiringMessages(hiringId);
760
+ }
761
+
762
+ /**
763
+ * Retry a failed hiring.
764
+ */
765
+ export async function retryHiring(
766
+ config: AgrentingAdapterConfig,
767
+ hiringId: string,
768
+ options?: { reason?: string }
769
+ ): Promise<Hiring> {
770
+ const client = new AgrentingClient(config);
771
+ return client.retryHiring(hiringId, options);
772
+ }
773
+
774
+ /**
775
+ * Get a hiring by ID.
776
+ */
777
+ export async function getHiring(
778
+ config: AgrentingAdapterConfig,
779
+ hiringId: string
780
+ ): Promise<Hiring> {
781
+ const client = new AgrentingClient(config);
782
+ return client.getHiring(hiringId);
783
+ }
784
+
785
+ /**
786
+ * List hirings for the authenticated agent.
787
+ */
788
+ export async function listHirings(
789
+ config: AgrentingAdapterConfig,
790
+ options?: { status?: string; limit?: number; offset?: number }
791
+ ): Promise<Hiring[]> {
792
+ const client = new AgrentingClient(config);
793
+ return client.listHirings(options);
794
+ }
795
+
796
+ /**
797
+ * Auto-select mode: given a capability requirement, discover the best agent,
798
+ * hire them, and return the adapter config for immediate use.
799
+ *
800
+ * Selection algorithm:
801
+ * 1. Call listCapabilities() to validate capability exists
802
+ * 2. Call GET /api/v1/agents filtered by capability
803
+ * 3. Sort by: availability first, then reputation_score desc, then base_price asc
804
+ * 4. Call hireAgent() to auto-provision
805
+ * 5. Return adapter config
806
+ */
807
+ export async function autoSelectAgent(
808
+ config: AgrentingAdapterConfig,
809
+ options: AutoSelectOptions
810
+ ): Promise<HireAgentResult & { selectedAgent: AgentProfile }> {
811
+ const client = new AgrentingClient(config);
812
+
813
+ // 1. Validate capability exists
814
+ const capabilities = await client.listCapabilities();
815
+ const capabilityExists = capabilities.some(
816
+ (c) => c.name === options.capability || c.name.toLowerCase() === options.capability.toLowerCase()
817
+ );
818
+ if (!capabilityExists) {
819
+ throw new Error(`Capability "${options.capability}" not found. Available: ${capabilities.map(c => c.name).join(", ")}`);
820
+ }
821
+
822
+ // 2. Get agents filtered by capability
823
+ const agents = await client.listAgentsByCapability(options.capability);
824
+ if (agents.length === 0) {
825
+ throw new Error(`No agents available for capability "${options.capability}"`);
826
+ }
827
+
828
+ // 3. Filter by options and sort
829
+ let filtered = agents;
830
+
831
+ // Filter by max price
832
+ if (options.maxPrice) {
833
+ const maxPriceNum = parseFloat(options.maxPrice);
834
+ filtered = filtered.filter((a) => {
835
+ if (!a.base_price) return true; // No price info = assume fits budget
836
+ return parseFloat(a.base_price) <= maxPriceNum;
837
+ });
838
+ }
839
+
840
+ // Filter by min reputation
841
+ if (options.minReputation) {
842
+ const minRep = options.minReputation;
843
+ filtered = filtered.filter((a) => {
844
+ if (!a.reputation_score) return false; // No reputation = excluded
845
+ return a.reputation_score >= minRep;
846
+ });
847
+ }
848
+
849
+ if (filtered.length === 0) {
850
+ throw new Error(
851
+ `No agents match criteria for capability "${options.capability}" (maxPrice=${options.maxPrice ?? "any"}, minReputation=${options.minReputation ?? "any"})`
852
+ );
853
+ }
854
+
855
+ // Sort: availability first, then by specified sort criteria
856
+ const sortBy = options.sortBy ?? "reputation_score";
857
+
858
+ // Prefer available agents if requested
859
+ if (options.preferAvailable ?? true) {
860
+ filtered.sort((a, b) => {
861
+ const aAvail = a.availability_status === "available" ? 0 : 1;
862
+ const bAvail = b.availability_status === "available" ? 0 : 1;
863
+ if (aAvail !== bAvail) return aAvail - bAvail;
864
+ return 0;
865
+ });
866
+ }
867
+
868
+ // Secondary sort
869
+ filtered.sort((a, b) => {
870
+ if (sortBy === "reputation_score") {
871
+ return (b.reputation_score ?? 0) - (a.reputation_score ?? 0);
872
+ }
873
+ if (sortBy === "base_price") {
874
+ const aPrice = parseFloat(a.base_price ?? "999999");
875
+ const bPrice = parseFloat(b.base_price ?? "999999");
876
+ return aPrice - bPrice;
877
+ }
878
+ if (sortBy === "availability") {
879
+ const aAvail = a.availability_status === "available" ? 0 : 1;
880
+ const bAvail = b.availability_status === "available" ? 0 : 1;
881
+ return aAvail - bAvail;
882
+ }
883
+ return 0;
884
+ });
885
+
886
+ // 4. Hire the best agent
887
+ const selectedAgent = filtered[0];
888
+ const hireResult = await client.hireAgent(selectedAgent.did);
889
+
890
+ // 5. Return combined result
891
+ return {
892
+ ...hireResult,
893
+ selectedAgent,
894
+ };
895
+ }
896
+
897
+ /**
898
+ * Execute a task with retry logic.
899
+ * If the task fails, it will be retried up to TASK_MAX_RETRIES times
900
+ * with exponential backoff.
901
+ */
902
+ export async function executeWithRetry(
903
+ config: AgrentingAdapterConfig,
904
+ params: {
905
+ input: string;
906
+ capability: string;
907
+ instructions?: string;
908
+ maxPrice?: string;
909
+ paymentType?: string;
910
+ maxRetries?: number;
911
+ }
912
+ ): Promise<AgrentingExecutionResult> {
913
+ const maxRetries = params.maxRetries ?? TASK_MAX_RETRIES;
914
+ let lastResult: AgrentingExecutionResult | undefined;
915
+ let lastError: Error | null = null;
916
+
917
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
918
+ try {
919
+ lastResult = await execute(config, params);
920
+
921
+ if (lastResult.success) {
922
+ return lastResult;
923
+ }
924
+
925
+ // If task failed but we have retries remaining, wait and retry
926
+ if (attempt < maxRetries && lastResult.error) {
927
+ const delayMs = Math.min(
928
+ TASK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
929
+ TASK_RETRY_MAX_DELAY_MS
930
+ );
931
+ console.warn(
932
+ `[adapter-agrenting] Task failed on attempt ${attempt + 1}, retrying in ${delayMs}ms: ${lastResult.error}`
933
+ );
934
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
935
+ continue;
936
+ }
937
+
938
+ return lastResult;
939
+ } catch (err) {
940
+ lastError = err instanceof Error ? err : new Error(String(err));
941
+
942
+ if (attempt < maxRetries) {
943
+ const delayMs = Math.min(
944
+ TASK_RETRY_BASE_DELAY_MS * Math.pow(2, attempt),
945
+ TASK_RETRY_MAX_DELAY_MS
946
+ );
947
+ console.warn(
948
+ `[adapter-agrenting] Execution error on attempt ${attempt + 1}, retrying in ${delayMs}ms: ${lastError.message}`
949
+ );
950
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
951
+ continue;
952
+ }
953
+ }
954
+ }
955
+
956
+ // All retries exhausted
957
+ return {
958
+ success: false,
959
+ error: lastError?.message ?? lastResult?.error ?? "Task failed after all retries",
960
+ taskId: lastResult?.taskId,
961
+ durationMs: lastResult?.durationMs ?? 0,
962
+ };
963
+ }
964
+
965
+ /**
966
+ * Forward a comment from Paperclip to the Agrenting task.
967
+ * Used for bidirectional comment sync when the user adds a comment
968
+ * to a Paperclip issue that has an active Agrenting task.
969
+ */
970
+ export async function forwardCommentToAgrenting(
971
+ config: AgrentingAdapterConfig,
972
+ taskId: string,
973
+ comment: string,
974
+ authorName?: string
975
+ ): Promise<SendMessageResult | null> {
976
+ const client = new AgrentingClient(config);
977
+
978
+ // Format the comment for Agrenting
979
+ const formattedComment = authorName
980
+ ? `[${authorName}]: ${comment}`
981
+ : comment;
982
+
983
+ try {
984
+ return await client.sendMessageToTask(taskId, { message: formattedComment });
985
+ } catch (err) {
986
+ // Log but don't throw — comment sync is non-critical
987
+ console.error(
988
+ `[adapter-agrenting] Failed to forward comment to task ${taskId}:`,
989
+ err instanceof Error ? err.message : String(err)
990
+ );
991
+ return null;
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Process incoming Agrenting messages and format them for Paperclip.
997
+ * Called by the webhook handler when it receives task messages.
998
+ */
999
+ export function processIncomingMessage(
1000
+ message: TaskMessage
1001
+ ): string {
1002
+ const senderName = message.sender_name ?? "Agent";
1003
+ return formatAgentResponse(senderName, message.content);
1004
+ }
1005
+
1006
+ /**
1007
+ * Create the server-side adapter module.
1008
+ * This is the entry point for Paperclip's server adapter registry.
1009
+ */
1010
+ export function createServerAdapter() {
1011
+ return {
1012
+ name: "agrenting" as const,
1013
+ execute,
1014
+ testEnvironment,
1015
+ getConfigSchema,
1016
+ startWebhookListener,
1017
+ stopWebhookListener,
1018
+ registerWebhook,
1019
+ deregisterWebhook,
1020
+ getTaskProgress,
1021
+ getTaskPayment,
1022
+ cancelTask,
1023
+ discoverAgents,
1024
+ getAgentProfile,
1025
+ hireAgent,
1026
+ sendMessageToTask,
1027
+ getTaskMessages,
1028
+ reassignTask,
1029
+ listCapabilities,
1030
+ sendMessageToHiring,
1031
+ getHiringMessages,
1032
+ retryHiring,
1033
+ getHiring,
1034
+ listHirings,
1035
+ autoSelectAgent,
1036
+ executeWithRetry,
1037
+ forwardCommentToAgrenting,
1038
+ processIncomingMessage,
1039
+ getBalance,
1040
+ getTransactions,
1041
+ deposit,
1042
+ withdraw,
1043
+ };
1044
+ }