@lobu/worker 3.0.5 → 3.0.7

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.
Files changed (46) hide show
  1. package/USAGE.md +120 -0
  2. package/docs/custom-base-image.md +88 -0
  3. package/package.json +2 -2
  4. package/scripts/worker-entrypoint.sh +184 -0
  5. package/src/__tests__/audio-provider-suggestions.test.ts +198 -0
  6. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +39 -0
  7. package/src/__tests__/embedded-tools.test.ts +558 -0
  8. package/src/__tests__/instructions.test.ts +59 -0
  9. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  10. package/src/__tests__/memory-flush.test.ts +64 -0
  11. package/src/__tests__/model-resolver.test.ts +156 -0
  12. package/src/__tests__/processor.test.ts +225 -0
  13. package/src/__tests__/setup.ts +109 -0
  14. package/src/__tests__/sse-client.test.ts +48 -0
  15. package/src/__tests__/tool-policy.test.ts +269 -0
  16. package/src/__tests__/worker.test.ts +89 -0
  17. package/src/core/error-handler.ts +70 -0
  18. package/src/core/project-scanner.ts +65 -0
  19. package/src/core/types.ts +125 -0
  20. package/src/core/url-utils.ts +9 -0
  21. package/src/core/workspace.ts +138 -0
  22. package/src/embedded/just-bash-bootstrap.ts +228 -0
  23. package/src/gateway/gateway-integration.ts +287 -0
  24. package/src/gateway/message-batcher.ts +128 -0
  25. package/src/gateway/sse-client.ts +955 -0
  26. package/src/gateway/types.ts +68 -0
  27. package/src/index.ts +146 -0
  28. package/src/instructions/builder.ts +80 -0
  29. package/src/instructions/providers.ts +27 -0
  30. package/src/modules/lifecycle.ts +92 -0
  31. package/src/openclaw/custom-tools.ts +290 -0
  32. package/src/openclaw/instructions.ts +38 -0
  33. package/src/openclaw/model-resolver.ts +150 -0
  34. package/src/openclaw/plugin-loader.ts +427 -0
  35. package/src/openclaw/processor.ts +216 -0
  36. package/src/openclaw/session-context.ts +277 -0
  37. package/src/openclaw/tool-policy.ts +212 -0
  38. package/src/openclaw/tools.ts +208 -0
  39. package/src/openclaw/worker.ts +1792 -0
  40. package/src/server.ts +329 -0
  41. package/src/shared/audio-provider-suggestions.ts +132 -0
  42. package/src/shared/processor-utils.ts +33 -0
  43. package/src/shared/provider-auth-hints.ts +64 -0
  44. package/src/shared/tool-display-config.ts +75 -0
  45. package/src/shared/tool-implementations.ts +768 -0
  46. package/tsconfig.json +21 -0
@@ -0,0 +1,768 @@
1
+ import * as nodeFs from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { createLogger } from "@lobu/core";
5
+ import FormData from "form-data";
6
+ import { fetchAudioProviderSuggestions } from "./audio-provider-suggestions";
7
+
8
+ const logger = createLogger("shared-tools");
9
+
10
+ /** Standard text result shape used by both SDK wrappers */
11
+ export interface TextResult {
12
+ [key: string]: unknown;
13
+ content: Array<{ [key: string]: unknown; type: "text"; text: string }>;
14
+ }
15
+
16
+ function textResult(text: string): TextResult {
17
+ return { content: [{ type: "text" as const, text }] };
18
+ }
19
+
20
+ function formatError(error: unknown): string {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
23
+
24
+ function withErrorHandling(
25
+ label: string,
26
+ fn: () => Promise<TextResult>
27
+ ): Promise<TextResult> {
28
+ return fn().catch((error) => {
29
+ logger.error(`${label} error:`, error);
30
+ return textResult(`Error: ${formatError(error)}`);
31
+ });
32
+ }
33
+
34
+ async function parseErrorBody(response: Response): Promise<{ error?: string }> {
35
+ return response
36
+ .json()
37
+ .catch(() => ({ error: response.statusText })) as Promise<{
38
+ error?: string;
39
+ }>;
40
+ }
41
+
42
+ interface GatewayRequestOptions {
43
+ method?: string;
44
+ headers?: Record<string, string>;
45
+ body?: string;
46
+ }
47
+
48
+ async function gatewayFetch<T>(
49
+ gw: GatewayParams,
50
+ urlPath: string,
51
+ options: GatewayRequestOptions = {},
52
+ errorPrefix: string
53
+ ): Promise<{ data?: T; error?: TextResult }> {
54
+ const { method, body, headers: extraHeaders } = options;
55
+ const headers: Record<string, string> = {
56
+ Authorization: `Bearer ${gw.workerToken}`,
57
+ ...extraHeaders,
58
+ };
59
+ if (body) {
60
+ headers["Content-Type"] = "application/json";
61
+ }
62
+
63
+ const response = await fetch(`${gw.gatewayUrl}${urlPath}`, {
64
+ method,
65
+ headers,
66
+ body,
67
+ });
68
+
69
+ if (!response.ok) {
70
+ const errorData = await parseErrorBody(response);
71
+ logger.error(`${errorPrefix}: ${response.status}`, errorData);
72
+ return {
73
+ error: textResult(`Error: ${errorData.error || errorPrefix}`),
74
+ };
75
+ }
76
+
77
+ const data = (await response.json()) as T;
78
+ return { data };
79
+ }
80
+
81
+ /**
82
+ * Gateway connection params shared by all tool implementations.
83
+ */
84
+ export interface GatewayParams {
85
+ gatewayUrl: string;
86
+ workerToken: string;
87
+ channelId: string;
88
+ conversationId: string;
89
+ platform?: string;
90
+ }
91
+
92
+ // ============================================================================
93
+ // Utility: Content type detection
94
+ // ============================================================================
95
+
96
+ const CONTENT_TYPES: Record<string, string> = {
97
+ ".png": "image/png",
98
+ ".jpg": "image/jpeg",
99
+ ".jpeg": "image/jpeg",
100
+ ".gif": "image/gif",
101
+ ".webp": "image/webp",
102
+ ".pdf": "application/pdf",
103
+ ".csv": "text/csv",
104
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
105
+ ".json": "application/json",
106
+ ".html": "text/html",
107
+ ".svg": "image/svg+xml",
108
+ ".mp4": "video/mp4",
109
+ ".webm": "video/webm",
110
+ ".txt": "text/plain",
111
+ ".md": "text/markdown",
112
+ ".py": "text/x-python",
113
+ ".js": "text/javascript",
114
+ ".ts": "text/typescript",
115
+ ".zip": "application/zip",
116
+ ".tar": "application/x-tar",
117
+ ".gz": "application/gzip",
118
+ };
119
+
120
+ function getContentType(fileName: string): string {
121
+ const ext = path.extname(fileName).toLowerCase();
122
+ return CONTENT_TYPES[ext] || "application/octet-stream";
123
+ }
124
+
125
+ // ============================================================================
126
+ // Utility: FormData buffer serialisation
127
+ // ============================================================================
128
+
129
+ async function formDataToBuffer(formData: FormData): Promise<Buffer> {
130
+ return new Promise<Buffer>((resolve, reject) => {
131
+ const chunks: Buffer[] = [];
132
+ formData.on("data", (chunk: string | Buffer) => {
133
+ if (typeof chunk === "string") {
134
+ chunks.push(Buffer.from(chunk));
135
+ } else {
136
+ chunks.push(chunk);
137
+ }
138
+ });
139
+ formData.on("end", () => resolve(Buffer.concat(chunks)));
140
+ formData.on("error", (err: Error) => reject(err));
141
+ formData.resume();
142
+ });
143
+ }
144
+
145
+ // ============================================================================
146
+ // UploadUserFile
147
+ // ============================================================================
148
+
149
+ export async function uploadUserFile(
150
+ gw: GatewayParams,
151
+ args: { file_path: string; description?: string }
152
+ ): Promise<TextResult> {
153
+ return withErrorHandling("Show file tool", async () => {
154
+ logger.info(
155
+ `Show file to user: ${args.file_path}, description: ${args.description || "none"}`
156
+ );
157
+
158
+ const filePath = path.isAbsolute(args.file_path)
159
+ ? args.file_path
160
+ : path.join(process.cwd(), args.file_path);
161
+
162
+ const stats = await fs.stat(filePath).catch(() => null);
163
+ if (!stats || !stats.isFile()) {
164
+ return textResult(
165
+ `Error: Cannot show file - not found or is not a file: ${args.file_path}`
166
+ );
167
+ }
168
+ if (stats.size === 0) {
169
+ return textResult(`Error: Cannot show empty file: ${args.file_path}`);
170
+ }
171
+
172
+ const fileName = path.basename(filePath);
173
+ const fileBuffer = await fs.readFile(filePath);
174
+
175
+ const formData = new FormData();
176
+ formData.append("file", fileBuffer, {
177
+ filename: fileName,
178
+ contentType: getContentType(fileName),
179
+ });
180
+ formData.append("filename", fileName);
181
+ if (args.description) {
182
+ formData.append("comment", args.description);
183
+ }
184
+
185
+ const formDataBuffer = await formDataToBuffer(formData);
186
+ const fdHeaders = formData.getHeaders();
187
+
188
+ const response = await fetch(`${gw.gatewayUrl}/internal/files/upload`, {
189
+ method: "POST",
190
+ headers: {
191
+ Authorization: `Bearer ${gw.workerToken}`,
192
+ "X-Channel-Id": gw.channelId,
193
+ "X-Conversation-Id": gw.conversationId,
194
+ ...fdHeaders,
195
+ "Content-Length": formDataBuffer.length.toString(),
196
+ },
197
+ body: formDataBuffer,
198
+ });
199
+
200
+ if (!response.ok) {
201
+ const error = await response.text();
202
+ logger.error(`Failed to show file: ${response.status} - ${error}`);
203
+ return textResult(
204
+ `Error: Failed to show file to user: ${response.status} - ${error}`
205
+ );
206
+ }
207
+
208
+ const result = (await response.json()) as {
209
+ fileId: string;
210
+ name: string;
211
+ permalink: string;
212
+ };
213
+ logger.info(
214
+ `Successfully showed file to user: ${result.fileId} - ${result.name}`
215
+ );
216
+ return textResult(`Successfully showed ${fileName} to the user`);
217
+ });
218
+ }
219
+
220
+ // ============================================================================
221
+ // AskUserQuestion
222
+ // ============================================================================
223
+
224
+ export async function askUserQuestion(
225
+ gw: GatewayParams,
226
+ args: { question: string; options: unknown }
227
+ ): Promise<TextResult> {
228
+ return withErrorHandling("AskUserQuestion", async () => {
229
+ logger.info(`AskUserQuestion: ${args.question}`);
230
+
231
+ const { error } = await gatewayFetch<{ id: string }>(
232
+ gw,
233
+ "/internal/interactions/create",
234
+ {
235
+ method: "POST",
236
+ body: JSON.stringify({
237
+ interactionType: "question",
238
+ question: args.question,
239
+ options: args.options,
240
+ }),
241
+ },
242
+ "Failed to post question"
243
+ );
244
+ if (error) return error;
245
+
246
+ return textResult(
247
+ "Question posted with buttons. Your session will end now. The user's answer will arrive as your next message."
248
+ );
249
+ });
250
+ }
251
+
252
+ // ============================================================================
253
+ // ScheduleReminder
254
+ // ============================================================================
255
+
256
+ export async function scheduleReminder(
257
+ gw: GatewayParams,
258
+ args: {
259
+ task: string;
260
+ delayMinutes?: number;
261
+ cron?: string;
262
+ maxIterations?: number;
263
+ }
264
+ ): Promise<TextResult> {
265
+ return withErrorHandling("ScheduleReminder", async () => {
266
+ const scheduleType = args.cron
267
+ ? `cron: ${args.cron}`
268
+ : `${args.delayMinutes} minutes`;
269
+ logger.info(
270
+ `ScheduleReminder: ${scheduleType} - ${args.task.substring(0, 50)}...`
271
+ );
272
+
273
+ interface ScheduleResult {
274
+ scheduleId: string;
275
+ scheduledFor: string;
276
+ isRecurring: boolean;
277
+ cron?: string;
278
+ maxIterations: number;
279
+ message: string;
280
+ }
281
+
282
+ const { data, error } = await gatewayFetch<ScheduleResult>(
283
+ gw,
284
+ "/internal/schedule",
285
+ {
286
+ method: "POST",
287
+ body: JSON.stringify({
288
+ delayMinutes: args.delayMinutes,
289
+ cron: args.cron,
290
+ maxIterations: args.maxIterations,
291
+ task: args.task,
292
+ }),
293
+ },
294
+ "Failed to schedule reminder"
295
+ );
296
+ if (error) return error;
297
+ const result = data!;
298
+
299
+ logger.info(
300
+ `Scheduled reminder: ${result.scheduleId} for ${result.scheduledFor}${result.isRecurring ? ` (recurring: ${result.cron})` : ""}`
301
+ );
302
+
303
+ const recurringInfo = result.isRecurring
304
+ ? `\nRecurring: ${result.cron} (max ${result.maxIterations} iterations)`
305
+ : "";
306
+
307
+ return textResult(
308
+ `Reminder scheduled successfully!\n\nSchedule ID: ${result.scheduleId}\nFirst trigger: ${new Date(result.scheduledFor).toLocaleString()}${recurringInfo}\n\nYou can cancel this with CancelReminder if needed.`
309
+ );
310
+ });
311
+ }
312
+
313
+ // ============================================================================
314
+ // CancelReminder
315
+ // ============================================================================
316
+
317
+ export async function cancelReminder(
318
+ gw: GatewayParams,
319
+ args: { scheduleId: string }
320
+ ): Promise<TextResult> {
321
+ return withErrorHandling("CancelReminder", async () => {
322
+ logger.info(`CancelReminder: ${args.scheduleId}`);
323
+
324
+ interface CancelResult {
325
+ success: boolean;
326
+ message: string;
327
+ }
328
+
329
+ const { data, error } = await gatewayFetch<CancelResult>(
330
+ gw,
331
+ `/internal/schedule/${encodeURIComponent(args.scheduleId)}`,
332
+ { method: "DELETE" },
333
+ "Failed to cancel reminder"
334
+ );
335
+ if (error) return error;
336
+ const result = data!;
337
+
338
+ return textResult(
339
+ result.success
340
+ ? "Reminder cancelled successfully."
341
+ : `Could not cancel reminder: ${result.message}`
342
+ );
343
+ });
344
+ }
345
+
346
+ // ============================================================================
347
+ // ListReminders
348
+ // ============================================================================
349
+
350
+ export async function listReminders(gw: GatewayParams): Promise<TextResult> {
351
+ return withErrorHandling("ListReminders", async () => {
352
+ logger.info("ListReminders");
353
+
354
+ interface ReminderEntry {
355
+ scheduleId: string;
356
+ task: string;
357
+ scheduledFor: string;
358
+ minutesRemaining: number;
359
+ isRecurring: boolean;
360
+ cron?: string;
361
+ iteration: number;
362
+ maxIterations: number;
363
+ }
364
+
365
+ const { data, error } = await gatewayFetch<{ reminders: ReminderEntry[] }>(
366
+ gw,
367
+ "/internal/schedule",
368
+ {},
369
+ "Failed to list reminders"
370
+ );
371
+ if (error) return error;
372
+ const { reminders } = data!;
373
+
374
+ if (reminders.length === 0) {
375
+ return textResult("No pending reminders scheduled.");
376
+ }
377
+
378
+ const formatted = reminders
379
+ .map((r, i) => {
380
+ const timeStr =
381
+ r.minutesRemaining < 60
382
+ ? `${r.minutesRemaining} minutes`
383
+ : `${Math.round(r.minutesRemaining / 60)} hours`;
384
+ const recurringInfo = r.isRecurring
385
+ ? `\n Recurring: ${r.cron} (iteration ${r.iteration}/${r.maxIterations})`
386
+ : "";
387
+ return `${i + 1}. [${r.scheduleId}]\n Task: ${r.task}\n Next trigger in: ${timeStr} (${new Date(r.scheduledFor).toLocaleString()})${recurringInfo}`;
388
+ })
389
+ .join("\n\n");
390
+
391
+ return textResult(
392
+ `Pending reminders (${reminders.length}):\n\n${formatted}`
393
+ );
394
+ });
395
+ }
396
+
397
+ // ============================================================================
398
+ // Utility: Upload generated file (image/audio) to gateway
399
+ // ============================================================================
400
+
401
+ async function uploadGeneratedFile(
402
+ gw: GatewayParams,
403
+ buffer: ArrayBuffer,
404
+ filename: string,
405
+ mimeType: string,
406
+ extraHeaders?: Record<string, string>
407
+ ): Promise<TextResult | null> {
408
+ let tempPath: string | null = null;
409
+ try {
410
+ tempPath = `/tmp/${filename}_${Date.now()}`;
411
+ await fs.writeFile(tempPath, Buffer.from(buffer));
412
+
413
+ const formData = new FormData();
414
+ formData.append("file", nodeFs.createReadStream(tempPath), {
415
+ filename,
416
+ contentType: mimeType,
417
+ });
418
+ formData.append("filename", filename);
419
+ formData.append("comment", "Generated content");
420
+
421
+ const formDataBuffer = await formDataToBuffer(formData);
422
+ const fdHeaders = formData.getHeaders();
423
+
424
+ const uploadResponse = await fetch(
425
+ `${gw.gatewayUrl}/internal/files/upload`,
426
+ {
427
+ method: "POST",
428
+ headers: {
429
+ Authorization: `Bearer ${gw.workerToken}`,
430
+ "X-Channel-Id": gw.channelId,
431
+ "X-Conversation-Id": gw.conversationId,
432
+ ...fdHeaders,
433
+ "Content-Length": formDataBuffer.length.toString(),
434
+ ...extraHeaders,
435
+ },
436
+ body: formDataBuffer,
437
+ }
438
+ );
439
+
440
+ if (!uploadResponse.ok) {
441
+ const uploadError = await uploadResponse.text();
442
+ return textResult(`Generated content but failed to send: ${uploadError}`);
443
+ }
444
+
445
+ return null;
446
+ } finally {
447
+ if (tempPath) {
448
+ await fs.unlink(tempPath).catch(() => undefined);
449
+ }
450
+ }
451
+ }
452
+
453
+ // ============================================================================
454
+ // GenerateImage
455
+ // ============================================================================
456
+
457
+ function imageExtFromMime(mimeType: string): string {
458
+ if (mimeType.includes("jpeg")) return "jpg";
459
+ if (mimeType.includes("webp")) return "webp";
460
+ return "png";
461
+ }
462
+
463
+ export async function generateImage(
464
+ gw: GatewayParams,
465
+ args: {
466
+ prompt: string;
467
+ size?: "1024x1024" | "1024x1536" | "1536x1024" | "auto";
468
+ quality?: "low" | "medium" | "high" | "auto";
469
+ background?: "transparent" | "opaque" | "auto";
470
+ format?: "png" | "jpeg" | "webp";
471
+ }
472
+ ): Promise<TextResult> {
473
+ return withErrorHandling("GenerateImage", async () => {
474
+ logger.info(`GenerateImage: ${args.prompt.substring(0, 80)}...`);
475
+
476
+ const capResponse = await fetch(
477
+ `${gw.gatewayUrl}/internal/images/capabilities`,
478
+ {
479
+ headers: { Authorization: `Bearer ${gw.workerToken}` },
480
+ }
481
+ );
482
+
483
+ if (capResponse.ok) {
484
+ const capabilities = (await capResponse.json()) as {
485
+ available: boolean;
486
+ providers?: Array<{ provider: string; name: string }>;
487
+ };
488
+ if (!capabilities.available) {
489
+ const providerList =
490
+ capabilities.providers?.map((p) => p.name).join(", ") || "OpenAI";
491
+ return textResult(
492
+ `Image generation is not configured. Supported providers: ${providerList}.\n\nAsk an admin to connect one of these providers for the base agent.`
493
+ );
494
+ }
495
+ }
496
+
497
+ const response = await fetch(`${gw.gatewayUrl}/internal/images/generate`, {
498
+ method: "POST",
499
+ headers: {
500
+ Authorization: `Bearer ${gw.workerToken}`,
501
+ "Content-Type": "application/json",
502
+ },
503
+ body: JSON.stringify({
504
+ prompt: args.prompt,
505
+ size: args.size,
506
+ quality: args.quality,
507
+ background: args.background,
508
+ format: args.format,
509
+ }),
510
+ });
511
+
512
+ if (!response.ok) {
513
+ const errorData = (await parseErrorBody(response)) as {
514
+ error?: string;
515
+ availableProviders?: string[];
516
+ };
517
+ const errorMessage = errorData.error || "Unknown error";
518
+ const lowerError = errorMessage.toLowerCase();
519
+ const missingImagePermission =
520
+ lowerError.includes("missing scopes") ||
521
+ lowerError.includes("missing_scope") ||
522
+ (lowerError.includes("scope") &&
523
+ (lowerError.includes("image") ||
524
+ lowerError.includes("model.request")));
525
+
526
+ if (errorData.availableProviders?.length) {
527
+ return textResult(
528
+ `Image generation failed: ${errorMessage}.\n\nAsk an admin to connect one of the supported providers for the base agent.`
529
+ );
530
+ }
531
+
532
+ if (missingImagePermission) {
533
+ return textResult(
534
+ `Image generation failed because the current credential lacks required image permissions.\n\nAsk an admin to connect a provider with image generation access for the base agent.`
535
+ );
536
+ }
537
+
538
+ return textResult(`Error generating image: ${errorMessage}`);
539
+ }
540
+
541
+ const imageBuffer = await response.arrayBuffer();
542
+ const mimeType = response.headers.get("Content-Type") || "image/png";
543
+ const provider = response.headers.get("X-Image-Provider") || "unknown";
544
+ const ext = imageExtFromMime(mimeType);
545
+
546
+ const uploadError = await uploadGeneratedFile(
547
+ gw,
548
+ imageBuffer,
549
+ `generated_image.${ext}`,
550
+ mimeType
551
+ );
552
+ if (uploadError) return uploadError;
553
+
554
+ logger.info(`Image generated and sent using ${provider}`);
555
+ return textResult(`Image sent successfully (generated with ${provider}).`);
556
+ });
557
+ }
558
+
559
+ // ============================================================================
560
+ // GenerateAudio
561
+ // ============================================================================
562
+
563
+ function audioExtFromMime(mimeType: string): string {
564
+ if (mimeType.includes("opus")) return "opus";
565
+ if (mimeType.includes("ogg")) return "ogg";
566
+ return "mp3";
567
+ }
568
+
569
+ export async function generateAudio(
570
+ gw: GatewayParams,
571
+ args: { text: string; voice?: string; speed?: number }
572
+ ): Promise<TextResult> {
573
+ return withErrorHandling("GenerateAudio", async () => {
574
+ logger.info(`GenerateAudio: ${args.text.substring(0, 50)}...`);
575
+
576
+ const suggestions = await fetchAudioProviderSuggestions({
577
+ gatewayUrl: gw.gatewayUrl,
578
+ workerToken: gw.workerToken,
579
+ });
580
+ const providerList =
581
+ suggestions.providerDisplayList || "an audio-capable provider";
582
+
583
+ if (suggestions.available === false) {
584
+ return textResult(
585
+ `Audio generation is not configured. To enable it, ask an admin to connect one of the available providers for the base agent: ${providerList}.`
586
+ );
587
+ }
588
+
589
+ const response = await fetch(`${gw.gatewayUrl}/internal/audio/synthesize`, {
590
+ method: "POST",
591
+ headers: {
592
+ Authorization: `Bearer ${gw.workerToken}`,
593
+ "Content-Type": "application/json",
594
+ },
595
+ body: JSON.stringify({
596
+ text: args.text,
597
+ voice: args.voice,
598
+ speed: args.speed,
599
+ }),
600
+ });
601
+
602
+ if (!response.ok) {
603
+ const errorData = (await parseErrorBody(response)) as {
604
+ error?: string;
605
+ availableProviders?: string[];
606
+ };
607
+ const errorMessage = errorData.error || "Unknown error";
608
+ const lowerError = errorMessage.toLowerCase();
609
+ const missingOpenAiAudioScope =
610
+ (lowerError.includes("missing scopes") ||
611
+ lowerError.includes("missing_scope")) &&
612
+ lowerError.includes("api.model.audio.request");
613
+
614
+ if (errorData.availableProviders?.length) {
615
+ return textResult(
616
+ `Audio generation failed: ${errorMessage}. No provider configured.\n\nAsk an admin to connect an audio provider for the base agent.`
617
+ );
618
+ }
619
+
620
+ if (missingOpenAiAudioScope) {
621
+ return textResult(
622
+ `Audio generation failed because the current OpenAI token lacks api.model.audio.request.\n\nAsk an admin to connect a provider with audio permission for the base agent, or to connect an alternative audio provider (${providerList}).`
623
+ );
624
+ }
625
+
626
+ return textResult(`Error generating audio: ${errorMessage}`);
627
+ }
628
+
629
+ const audioBuffer = await response.arrayBuffer();
630
+ const mimeType = response.headers.get("Content-Type") || "audio/mpeg";
631
+ const provider = response.headers.get("X-Audio-Provider") || "unknown";
632
+ const ext = audioExtFromMime(mimeType);
633
+
634
+ const uploadError = await uploadGeneratedFile(
635
+ gw,
636
+ audioBuffer,
637
+ `voice_response.${ext}`,
638
+ mimeType,
639
+ { "X-Voice-Message": "true" }
640
+ );
641
+ if (uploadError) return uploadError;
642
+
643
+ logger.info(`Audio generated and sent using ${provider}`);
644
+ return textResult(
645
+ `Voice message sent successfully (generated with ${provider}).`
646
+ );
647
+ });
648
+ }
649
+
650
+ // ============================================================================
651
+ // GetChannelHistory
652
+ // ============================================================================
653
+
654
+ export async function getChannelHistory(
655
+ gw: GatewayParams,
656
+ args: { limit?: number; before?: string }
657
+ ): Promise<TextResult> {
658
+ return withErrorHandling("GetChannelHistory", async () => {
659
+ const limit = Math.min(Math.max(args.limit || 50, 1), 100);
660
+ const platform = gw.platform || "slack";
661
+ logger.info(
662
+ `GetChannelHistory: limit=${limit}, before=${args.before || "none"}`
663
+ );
664
+
665
+ const params = new URLSearchParams({
666
+ platform,
667
+ channelId: gw.channelId,
668
+ conversationId: gw.conversationId,
669
+ limit: String(limit),
670
+ });
671
+
672
+ if (args.before) {
673
+ params.set("before", args.before);
674
+ }
675
+
676
+ interface HistoryResult {
677
+ messages: Array<{
678
+ timestamp: string;
679
+ user: string;
680
+ text: string;
681
+ isBot?: boolean;
682
+ }>;
683
+ nextCursor: string | null;
684
+ hasMore: boolean;
685
+ note?: string;
686
+ }
687
+
688
+ const { data, error } = await gatewayFetch<HistoryResult>(
689
+ gw,
690
+ `/internal/history?${params}`,
691
+ {},
692
+ "Failed to fetch channel history"
693
+ );
694
+ if (error) return error;
695
+ const history = data!;
696
+
697
+ if (history.note) {
698
+ return textResult(history.note);
699
+ }
700
+
701
+ if (history.messages.length === 0) {
702
+ return textResult("No messages found in channel history.");
703
+ }
704
+
705
+ const formatted = history.messages
706
+ .map((msg) => {
707
+ const time = new Date(msg.timestamp).toLocaleString();
708
+ const sender = msg.isBot ? `[Bot] ${msg.user}` : msg.user;
709
+ return `[${time}] ${sender}: ${msg.text}`;
710
+ })
711
+ .join("\n\n");
712
+
713
+ let result = `Found ${history.messages.length} messages:\n\n${formatted}`;
714
+
715
+ if (history.hasMore && history.nextCursor) {
716
+ result += `\n\n---\nMore messages available. Use before="${history.nextCursor}" to fetch older messages.`;
717
+ }
718
+
719
+ return textResult(result);
720
+ });
721
+ }
722
+
723
+ // ============================================================================
724
+ // MCP Tools (route to MCP proxy /mcp/{mcpId}/tools/{toolName})
725
+ // ============================================================================
726
+
727
+ export async function callMcpTool(
728
+ gw: GatewayParams,
729
+ mcpId: string,
730
+ toolName: string,
731
+ args: Record<string, unknown>
732
+ ): Promise<TextResult> {
733
+ return withErrorHandling(`${mcpId}/${toolName}`, async () => {
734
+ const response = await fetch(
735
+ `${gw.gatewayUrl}/mcp/${mcpId}/tools/${toolName}`,
736
+ {
737
+ method: "POST",
738
+ headers: {
739
+ Authorization: `Bearer ${gw.workerToken}`,
740
+ "Content-Type": "application/json",
741
+ },
742
+ body: JSON.stringify(args),
743
+ }
744
+ );
745
+
746
+ const data = (await response.json()) as {
747
+ content?: Array<{ type: string; text: string }>;
748
+ error?: string;
749
+ isError?: boolean;
750
+ };
751
+
752
+ if (!response.ok || data.isError) {
753
+ const contentText = data.content
754
+ ?.filter((c) => c.type === "text")
755
+ .map((c) => c.text)
756
+ .join("\n");
757
+ const errorMsg =
758
+ data.error || contentText || `${toolName} failed (${response.status})`;
759
+ return textResult(`Error: ${errorMsg}`);
760
+ }
761
+
762
+ const text = data.content
763
+ ?.filter((c) => c.type === "text")
764
+ .map((c) => c.text)
765
+ .join("\n");
766
+ return textResult(text || `${toolName} completed.`);
767
+ });
768
+ }