@lobu/worker 6.0.1 → 7.0.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.
Files changed (104) hide show
  1. package/dist/embedded/exec-sandbox.d.ts +2 -2
  2. package/dist/embedded/exec-sandbox.js +7 -7
  3. package/dist/embedded/exec-sandbox.js.map +1 -1
  4. package/dist/embedded/just-bash-bootstrap.d.ts +2 -2
  5. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  6. package/dist/embedded/just-bash-bootstrap.js +30 -6
  7. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  8. package/dist/embedded/mcp-cli-commands.d.ts +5 -5
  9. package/dist/gateway/gateway-integration.js +4 -4
  10. package/dist/gateway/gateway-integration.js.map +1 -1
  11. package/dist/gateway/message-batcher.d.ts.map +1 -1
  12. package/dist/gateway/message-batcher.js +3 -5
  13. package/dist/gateway/message-batcher.js.map +1 -1
  14. package/dist/gateway/sse-client.d.ts +1 -0
  15. package/dist/gateway/sse-client.d.ts.map +1 -1
  16. package/dist/gateway/sse-client.js +13 -8
  17. package/dist/gateway/sse-client.js.map +1 -1
  18. package/dist/gateway/types.d.ts +1 -1
  19. package/dist/gateway/types.d.ts.map +1 -1
  20. package/dist/instructions/builder.d.ts +4 -0
  21. package/dist/instructions/builder.d.ts.map +1 -1
  22. package/dist/instructions/builder.js +8 -11
  23. package/dist/instructions/builder.js.map +1 -1
  24. package/dist/instructions/providers.d.ts +5 -5
  25. package/dist/instructions/providers.d.ts.map +1 -1
  26. package/dist/instructions/providers.js +3 -2
  27. package/dist/instructions/providers.js.map +1 -1
  28. package/dist/openclaw/custom-tools.d.ts +1 -1
  29. package/dist/openclaw/custom-tools.js +1 -1
  30. package/dist/openclaw/instructions.d.ts +9 -9
  31. package/dist/openclaw/instructions.d.ts.map +1 -1
  32. package/dist/openclaw/instructions.js +4 -4
  33. package/dist/openclaw/instructions.js.map +1 -1
  34. package/dist/openclaw/tools.js.map +1 -1
  35. package/dist/openclaw/worker.d.ts +0 -1
  36. package/dist/openclaw/worker.d.ts.map +1 -1
  37. package/dist/openclaw/worker.js +18 -75
  38. package/dist/openclaw/worker.js.map +1 -1
  39. package/dist/shared/tool-implementations.d.ts.map +1 -1
  40. package/dist/shared/tool-implementations.js +37 -13
  41. package/dist/shared/tool-implementations.js.map +1 -1
  42. package/package.json +14 -4
  43. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  44. package/src/__tests__/custom-tools.test.ts +92 -0
  45. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  46. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  47. package/src/__tests__/embedded-tools.test.ts +744 -0
  48. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  49. package/src/__tests__/exec-sandbox.test.ts +550 -0
  50. package/src/__tests__/generated-media.test.ts +142 -0
  51. package/src/__tests__/instructions.test.ts +60 -0
  52. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  53. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  54. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  55. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  56. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  57. package/src/__tests__/memory-flush.test.ts +64 -0
  58. package/src/__tests__/message-batcher.test.ts +247 -0
  59. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  60. package/src/__tests__/model-resolver.test.ts +156 -0
  61. package/src/__tests__/processor-harden.test.ts +269 -0
  62. package/src/__tests__/processor.test.ts +225 -0
  63. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  64. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  65. package/src/__tests__/sandbox-leak.test.ts +167 -0
  66. package/src/__tests__/setup.ts +102 -0
  67. package/src/__tests__/sse-client-harden.test.ts +588 -0
  68. package/src/__tests__/sse-client.test.ts +90 -0
  69. package/src/__tests__/tool-implementations.test.ts +196 -0
  70. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  71. package/src/__tests__/tool-policy.test.ts +269 -0
  72. package/src/__tests__/worker.test.ts +89 -0
  73. package/src/core/error-handler.ts +62 -0
  74. package/src/core/project-scanner.ts +65 -0
  75. package/src/core/types.ts +128 -0
  76. package/src/core/workspace.ts +89 -0
  77. package/src/embedded/exec-sandbox.ts +372 -0
  78. package/src/embedded/just-bash-bootstrap.ts +543 -0
  79. package/src/embedded/mcp-cli-commands.ts +402 -0
  80. package/src/gateway/gateway-integration.ts +298 -0
  81. package/src/gateway/message-batcher.ts +123 -0
  82. package/src/gateway/sse-client.ts +951 -0
  83. package/src/gateway/types.ts +68 -0
  84. package/src/index.ts +141 -0
  85. package/src/instructions/builder.ts +45 -0
  86. package/src/instructions/providers.ts +27 -0
  87. package/src/modules/lifecycle.ts +92 -0
  88. package/src/openclaw/custom-tools.ts +315 -0
  89. package/src/openclaw/instructions.ts +36 -0
  90. package/src/openclaw/model-resolver.ts +150 -0
  91. package/src/openclaw/plugin-loader.ts +427 -0
  92. package/src/openclaw/processor.ts +198 -0
  93. package/src/openclaw/sandbox-leak.ts +105 -0
  94. package/src/openclaw/session-context.ts +320 -0
  95. package/src/openclaw/tool-policy.ts +248 -0
  96. package/src/openclaw/tools.ts +277 -0
  97. package/src/openclaw/worker.ts +1847 -0
  98. package/src/server.ts +334 -0
  99. package/src/shared/audio-provider-suggestions.ts +132 -0
  100. package/src/shared/processor-utils.ts +33 -0
  101. package/src/shared/provider-auth-hints.ts +68 -0
  102. package/src/shared/tool-display-config.ts +75 -0
  103. package/src/shared/tool-implementations.ts +940 -0
  104. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared types for gateway communication
3
+ */
4
+
5
+ import type { AgentOptions, ThreadResponsePayload } from "@lobu/core";
6
+
7
+ /**
8
+ * Platform-specific metadata (e.g., Slack team_id, channel, thread_ts)
9
+ */
10
+ interface PlatformMetadata {
11
+ team_id?: string;
12
+ channel?: string;
13
+ ts?: string;
14
+ thread_ts?: string;
15
+ files?: unknown[];
16
+ traceId?: string; // Trace ID for end-to-end observability
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ /**
21
+ * Job type for queue messages
22
+ * - message: Standard agent message execution
23
+ * - exec: Direct command execution in sandbox
24
+ */
25
+ export type JobType = "message" | "exec";
26
+
27
+ /**
28
+ * Message payload for agent execution
29
+ */
30
+ export interface MessagePayload {
31
+ botId: string;
32
+ userId: string;
33
+ agentId: string;
34
+ conversationId: string;
35
+ platform: string;
36
+ channelId: string;
37
+ messageId: string;
38
+ messageText: string;
39
+ platformMetadata: PlatformMetadata;
40
+ agentOptions: AgentOptions;
41
+ jobId?: string; // Optional job ID from gateway
42
+ teamId?: string; // Optional team ID (WhatsApp uses top-level, Slack uses platformMetadata)
43
+
44
+ // Job type (default: "message")
45
+ jobType?: JobType;
46
+
47
+ // Exec-specific fields (only used when jobType === "exec")
48
+ execId?: string; // Unique ID for exec job (for response routing)
49
+ execCommand?: string; // Command to execute
50
+ execCwd?: string; // Working directory for command
51
+ execEnv?: Record<string, string>; // Additional environment variables
52
+ execTimeout?: number; // Timeout in milliseconds
53
+ }
54
+
55
+ /**
56
+ * Queued message with timestamp
57
+ */
58
+ export interface QueuedMessage {
59
+ payload: MessagePayload;
60
+ timestamp: number;
61
+ }
62
+
63
+ /**
64
+ * Response data sent back to gateway
65
+ */
66
+ export type ResponseData = ThreadResponsePayload & {
67
+ originalMessageId: string;
68
+ };
package/src/index.ts ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {
4
+ createLogger,
5
+ initSentry,
6
+ initTracing,
7
+ moduleRegistry,
8
+ } from "@lobu/core";
9
+
10
+ const logger = createLogger("worker");
11
+
12
+ import { GatewayClient } from "./gateway/sse-client";
13
+ import { startWorkerHttpServer, stopWorkerHttpServer } from "./server";
14
+
15
+ /**
16
+ * Main entry point for gateway-based persistent worker
17
+ */
18
+ async function main() {
19
+ // Register global rejection/exception handlers early
20
+ process.on("unhandledRejection", (reason) => {
21
+ logger.error("Unhandled rejection:", reason);
22
+ process.exit(1);
23
+ });
24
+
25
+ process.on("uncaughtException", (error) => {
26
+ logger.error("Uncaught exception:", error);
27
+ process.exit(1);
28
+ });
29
+
30
+ logger.info("Starting worker...");
31
+
32
+ // Initialize Sentry for error tracking
33
+ await initSentry();
34
+
35
+ // Initialize OpenTelemetry tracing for distributed tracing
36
+ const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
37
+ if (otlpEndpoint) {
38
+ initTracing({
39
+ serviceName: "lobu-worker",
40
+ otlpEndpoint,
41
+ });
42
+ logger.info(`Tracing initialized: lobu-worker -> ${otlpEndpoint}`);
43
+ }
44
+
45
+ // Discover and register available modules
46
+ await moduleRegistry.registerAvailableModules();
47
+
48
+ // Initialize all registered modules
49
+ await moduleRegistry.initAll();
50
+ logger.info("✅ Modules initialized");
51
+
52
+ logger.info("🔄 Starting in gateway mode (SSE/HTTP-based persistent worker)");
53
+
54
+ // Get user ID from environment
55
+ const userId = process.env.USER_ID;
56
+
57
+ if (!userId) {
58
+ logger.error(
59
+ "❌ USER_ID environment variable is required for gateway mode"
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ try {
65
+ // Get required environment variables
66
+ const deploymentName = process.env.DEPLOYMENT_NAME;
67
+ const dispatcherUrl = process.env.DISPATCHER_URL;
68
+ const workerToken = process.env.WORKER_TOKEN;
69
+
70
+ if (!deploymentName) {
71
+ logger.error("❌ DEPLOYMENT_NAME environment variable is required");
72
+ process.exit(1);
73
+ }
74
+ if (!dispatcherUrl) {
75
+ logger.error("❌ DISPATCHER_URL environment variable is required");
76
+ process.exit(1);
77
+ }
78
+ if (!workerToken) {
79
+ logger.error("❌ WORKER_TOKEN environment variable is required");
80
+ process.exit(1);
81
+ }
82
+
83
+ // Start HTTP server before connecting to gateway
84
+ const httpPort = await startWorkerHttpServer();
85
+ logger.info(`Worker HTTP server started on port ${httpPort}`);
86
+
87
+ // Initialize gateway client directly
88
+ logger.info(`🚀 Starting Gateway-based Persistent Worker`);
89
+ logger.info(`- User ID: ${userId}`);
90
+ logger.info(`- Deployment: ${deploymentName}`);
91
+ logger.info(`- Dispatcher URL: ${dispatcherUrl}`);
92
+
93
+ const gatewayClient = new GatewayClient(
94
+ dispatcherUrl,
95
+ workerToken,
96
+ userId,
97
+ deploymentName,
98
+ httpPort
99
+ );
100
+
101
+ // Register signal handlers before async operations
102
+ let isShuttingDown = false;
103
+
104
+ process.on("SIGTERM", async () => {
105
+ if (isShuttingDown) return;
106
+ isShuttingDown = true;
107
+ logger.info("Received SIGTERM, shutting down gateway worker...");
108
+ await gatewayClient.stop();
109
+ await stopWorkerHttpServer();
110
+ process.exit(0);
111
+ });
112
+
113
+ process.on("SIGINT", async () => {
114
+ if (isShuttingDown) return;
115
+ isShuttingDown = true;
116
+ logger.info("Received SIGINT, shutting down gateway worker...");
117
+ await gatewayClient.stop();
118
+ await stopWorkerHttpServer();
119
+ process.exit(0);
120
+ });
121
+
122
+ logger.info("🔌 Connecting to dispatcher...");
123
+ await gatewayClient.start();
124
+ logger.info("✅ Gateway worker started successfully");
125
+
126
+ // Keep process alive
127
+ await new Promise(() => {
128
+ // Keep process running indefinitely so we can listen messages from the queue
129
+ }); // Wait forever
130
+ } catch (error) {
131
+ logger.error("❌ Gateway worker failed:", error);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ export type { WorkerConfig } from "./core/types";
137
+
138
+ main().catch((error) => {
139
+ logger.error("Fatal error in main:", error);
140
+ process.exit(1);
141
+ });
@@ -0,0 +1,45 @@
1
+ import {
2
+ createLogger,
3
+ type InstructionContext,
4
+ type InstructionProvider,
5
+ } from "@lobu/core";
6
+
7
+ const logger = createLogger("instruction-generator");
8
+
9
+ /**
10
+ * Generate custom instructions using modular providers.
11
+ * Only generates worker-local instructions (core, projects) — platform and
12
+ * MCP instructions are provided by the gateway.
13
+ *
14
+ * Per-provider error handling lives on `BaseInstructionProvider` (a thrown
15
+ * provider returns `""`). The fallback below covers the unlikely case of a
16
+ * provider that bypasses the base class throwing during the loop itself.
17
+ */
18
+ export async function generateCustomInstructions(
19
+ providers: InstructionProvider[],
20
+ context: InstructionContext
21
+ ): Promise<string> {
22
+ try {
23
+ const sections: string[] = [];
24
+ for (const provider of [...providers].sort(
25
+ (a, b) => a.priority - b.priority
26
+ )) {
27
+ const instructions = await provider.getInstructions(context);
28
+ if (instructions?.trim()) {
29
+ sections.push(instructions.trim());
30
+ }
31
+ }
32
+
33
+ const instructions = sections.join("\n\n");
34
+ logger.info(
35
+ `[WORKER-INSTRUCTIONS] Generated ${instructions.length} characters from ${providers.length} local providers`
36
+ );
37
+ logger.debug(`[WORKER-INSTRUCTIONS] \n${instructions}`);
38
+ return instructions;
39
+ } catch (error) {
40
+ logger.error("Failed to generate worker instructions:", error);
41
+ const fallback = `You are a helpful AI agent for user ${context.userId}.`;
42
+ logger.warn(`[WORKER-INSTRUCTIONS] Using fallback: ${fallback}`);
43
+ return fallback;
44
+ }
45
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Instruction providers for worker
3
+ */
4
+
5
+ import { BaseInstructionProvider, type InstructionContext } from "@lobu/core";
6
+
7
+ /**
8
+ * Provides information about available projects in the workspace
9
+ */
10
+ export class ProjectsInstructionProvider extends BaseInstructionProvider {
11
+ readonly name = "projects";
12
+ readonly priority = 30;
13
+
14
+ protected buildInstructions(context: InstructionContext): string {
15
+ if (!context.availableProjects || context.availableProjects.length === 0) {
16
+ return `**Available projects:**
17
+ - none`;
18
+ }
19
+
20
+ const projectList = context.availableProjects
21
+ .map((project: string) => ` - ${project}`)
22
+ .join("\n");
23
+
24
+ return `**Available projects:**
25
+ ${projectList}`;
26
+ }
27
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ createLogger,
3
+ type ModuleSessionContext,
4
+ moduleRegistry,
5
+ type SessionContext,
6
+ type WorkerModule,
7
+ } from "@lobu/core";
8
+
9
+ const logger = createLogger("worker");
10
+
11
+ /**
12
+ * Execute an operation on all worker modules with consistent error handling.
13
+ * Errors in individual modules are logged but do not halt iteration.
14
+ */
15
+ async function executeForAllModules<T>(
16
+ operation: (module: WorkerModule) => Promise<T>,
17
+ operationName: string
18
+ ): Promise<T[]> {
19
+ const workerModules = moduleRegistry.getWorkerModules();
20
+ const results: T[] = [];
21
+ for (const module of workerModules) {
22
+ try {
23
+ results.push(await operation(module));
24
+ } catch (error) {
25
+ logger.error(
26
+ `Failed to execute ${operationName} for module ${module.name}:`,
27
+ error
28
+ );
29
+ }
30
+ }
31
+ return results;
32
+ }
33
+
34
+ export async function onSessionStart(
35
+ context: SessionContext
36
+ ): Promise<SessionContext> {
37
+ // Convert to module session context
38
+ const moduleContext: ModuleSessionContext = {
39
+ userId: context.userId,
40
+ conversationId: context.conversationId || "",
41
+ systemPrompt: context.customInstructions || "",
42
+ workspace: undefined,
43
+ };
44
+
45
+ let updatedContext = moduleContext;
46
+
47
+ await executeForAllModules(async (module) => {
48
+ updatedContext = await module.onSessionStart(updatedContext);
49
+ }, "onSessionStart");
50
+
51
+ // Merge back into original context, mapping systemPrompt back to customInstructions
52
+ return {
53
+ ...context,
54
+ customInstructions:
55
+ updatedContext.systemPrompt || context.customInstructions,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Configuration for module workspace initialization
61
+ */
62
+ interface ModuleWorkspaceConfig {
63
+ workspaceDir: string;
64
+ username: string;
65
+ sessionKey: string;
66
+ }
67
+
68
+ export async function initModuleWorkspace(
69
+ config: ModuleWorkspaceConfig
70
+ ): Promise<void> {
71
+ await executeForAllModules(
72
+ (module) => module.initWorkspace(config),
73
+ "initWorkspace"
74
+ );
75
+ }
76
+
77
+ export async function collectModuleData(context: {
78
+ workspaceDir: string;
79
+ userId: string;
80
+ conversationId: string;
81
+ }): Promise<Record<string, unknown>> {
82
+ const moduleData: Record<string, unknown> = {};
83
+
84
+ await executeForAllModules(async (module) => {
85
+ const data = await module.onBeforeResponse(context);
86
+ if (data !== null) {
87
+ moduleData[module.name] = data;
88
+ }
89
+ }, "onBeforeResponse");
90
+
91
+ return moduleData;
92
+ }
@@ -0,0 +1,315 @@
1
+ import { getCustomToolDescription, type McpToolDef } from "@lobu/core";
2
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
3
+ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
4
+ import type { Static } from "@sinclair/typebox";
5
+ import { type TSchema, Type } from "@sinclair/typebox";
6
+ import type { GatewayParams, TextResult } from "../shared/tool-implementations";
7
+ import {
8
+ askUserQuestion,
9
+ callMcpTool,
10
+ checkMcpLogin,
11
+ generateAudio,
12
+ generateImage,
13
+ getChannelHistory,
14
+ logoutMcp,
15
+ startMcpLogin,
16
+ uploadUserFile,
17
+ } from "../shared/tool-implementations";
18
+
19
+ type ToolResult = AgentToolResult<Record<string, unknown>>;
20
+
21
+ /** Adapt shared TextResult to OpenClaw's ToolResult (adds details field) */
22
+ function toToolResult(result: TextResult): ToolResult {
23
+ return { content: result.content, details: {} };
24
+ }
25
+
26
+ /**
27
+ * Create a ToolDefinition with proper type bridging between TypeBox schemas
28
+ * and the shared tool implementation functions. Eliminates per-tool `as` casts
29
+ * by casting once at the boundary.
30
+ */
31
+ function defineTool<T extends TSchema>(config: {
32
+ name: string;
33
+ description: string;
34
+ parameters: T;
35
+ run: (args: Static<T>) => Promise<TextResult>;
36
+ }): ToolDefinition {
37
+ return {
38
+ name: config.name,
39
+ label: config.name,
40
+ description: config.description,
41
+ parameters: config.parameters,
42
+ execute: async (_toolCallId, args) =>
43
+ toToolResult(await config.run(args as Static<T>)),
44
+ };
45
+ }
46
+
47
+ export function createOpenClawCustomTools(params: {
48
+ gatewayUrl: string;
49
+ workerToken: string;
50
+ channelId: string;
51
+ conversationId: string;
52
+ platform?: string;
53
+ /** Session workspace directory. Required — UploadUserFile resolves relative paths against it. */
54
+ workspaceDir: string;
55
+ onCustomEvent?: (
56
+ name: string,
57
+ data: Record<string, unknown>
58
+ ) => Promise<void> | void;
59
+ }): ToolDefinition[] {
60
+ const gw: GatewayParams = {
61
+ gatewayUrl: params.gatewayUrl,
62
+ workerToken: params.workerToken,
63
+ channelId: params.channelId,
64
+ conversationId: params.conversationId,
65
+ platform: params.platform || "slack",
66
+ workspaceDir: params.workspaceDir,
67
+ };
68
+
69
+ const tools: ToolDefinition[] = [
70
+ defineTool({
71
+ name: "UploadUserFile",
72
+ description: getCustomToolDescription("UploadUserFile"),
73
+ parameters: Type.Object({
74
+ file_path: Type.String({
75
+ description:
76
+ "Path to the file to show (absolute or relative to workspace)",
77
+ }),
78
+ description: Type.Optional(
79
+ Type.String({
80
+ description:
81
+ "Optional description of what the file contains or shows",
82
+ })
83
+ ),
84
+ }),
85
+ run: (args) =>
86
+ uploadUserFile(gw, args, {
87
+ onUploaded: (data) => params.onCustomEvent?.("file-uploaded", data),
88
+ }),
89
+ }),
90
+
91
+ defineTool({
92
+ name: "GenerateImage",
93
+ description: getCustomToolDescription("GenerateImage"),
94
+ parameters: Type.Object({
95
+ prompt: Type.String({
96
+ description: "The image prompt to generate",
97
+ }),
98
+ size: Type.Optional(
99
+ Type.Union(
100
+ [
101
+ Type.Literal("1024x1024"),
102
+ Type.Literal("1024x1536"),
103
+ Type.Literal("1536x1024"),
104
+ Type.Literal("auto"),
105
+ ],
106
+ {
107
+ description: "Output image size (default: 1024x1024)",
108
+ }
109
+ )
110
+ ),
111
+ quality: Type.Optional(
112
+ Type.Union(
113
+ [
114
+ Type.Literal("low"),
115
+ Type.Literal("medium"),
116
+ Type.Literal("high"),
117
+ Type.Literal("auto"),
118
+ ],
119
+ {
120
+ description: "Image quality (default: auto)",
121
+ }
122
+ )
123
+ ),
124
+ background: Type.Optional(
125
+ Type.Union(
126
+ [
127
+ Type.Literal("transparent"),
128
+ Type.Literal("opaque"),
129
+ Type.Literal("auto"),
130
+ ],
131
+ {
132
+ description: "Background style (default: auto)",
133
+ }
134
+ )
135
+ ),
136
+ format: Type.Optional(
137
+ Type.Union(
138
+ [Type.Literal("png"), Type.Literal("jpeg"), Type.Literal("webp")],
139
+ {
140
+ description: "Output image format (default: png)",
141
+ }
142
+ )
143
+ ),
144
+ }),
145
+ run: (args) => generateImage(gw, args),
146
+ }),
147
+
148
+ defineTool({
149
+ name: "GenerateAudio",
150
+ description: getCustomToolDescription("GenerateAudio"),
151
+ parameters: Type.Object({
152
+ text: Type.String({
153
+ description: "The text to convert to speech (max 4096 characters)",
154
+ }),
155
+ voice: Type.Optional(
156
+ Type.String({
157
+ description:
158
+ "Voice ID (provider-specific). OpenAI: alloy, echo, fable, onyx, nova, shimmer. Leave empty for default.",
159
+ })
160
+ ),
161
+ speed: Type.Optional(
162
+ Type.Number({
163
+ description: "Speech speed (0.5-2.0, default 1.0).",
164
+ })
165
+ ),
166
+ }),
167
+ run: (args) => generateAudio(gw, args),
168
+ }),
169
+
170
+ defineTool({
171
+ name: "GetChannelHistory",
172
+ description: getCustomToolDescription("GetChannelHistory"),
173
+ parameters: Type.Object({
174
+ limit: Type.Optional(
175
+ Type.Number({
176
+ description: "Number of messages to fetch (default 50, max 100)",
177
+ })
178
+ ),
179
+ before: Type.Optional(
180
+ Type.String({
181
+ description:
182
+ "ISO timestamp cursor - fetch messages before this time (for pagination)",
183
+ })
184
+ ),
185
+ }),
186
+ run: (args) => getChannelHistory(gw, args),
187
+ }),
188
+
189
+ defineTool({
190
+ name: "AskUserQuestion",
191
+ description: getCustomToolDescription("AskUserQuestion"),
192
+ parameters: Type.Object({
193
+ question: Type.String({
194
+ description: "The question to ask the user",
195
+ }),
196
+ options: Type.Array(Type.String(), {
197
+ description: "Array of button labels for the user to choose from",
198
+ }),
199
+ }),
200
+ run: (args) => askUserQuestion(gw, args),
201
+ }),
202
+ ];
203
+
204
+ return tools;
205
+ }
206
+
207
+ /**
208
+ * Convert MCP tool definitions from session context into first-class
209
+ * OpenClaw ToolDefinition objects that call the MCP proxy directly.
210
+ * Tools are dynamically discovered from each MCP server (e.g. lobu).
211
+ */
212
+ export function createMcpToolDefinitions(
213
+ mcpTools: Record<string, McpToolDef[]>,
214
+ gw: GatewayParams,
215
+ mcpContext?: Record<string, string>
216
+ ): ToolDefinition[] {
217
+ const tools: ToolDefinition[] = [];
218
+
219
+ for (const [mcpId, defs] of Object.entries(mcpTools)) {
220
+ const contextPrefix = mcpContext?.[mcpId];
221
+ for (const def of defs) {
222
+ if (!def.name || typeof def.name !== "string" || !def.name.trim()) {
223
+ continue;
224
+ }
225
+ const schema = def.inputSchema
226
+ ? Type.Unsafe(def.inputSchema)
227
+ : Type.Object({});
228
+
229
+ const baseDescription = def.description || `MCP tool from ${mcpId}`;
230
+ const description = contextPrefix
231
+ ? `[${contextPrefix}] ${baseDescription}`
232
+ : baseDescription;
233
+
234
+ tools.push({
235
+ name: def.name,
236
+ label: `${mcpId}/${def.name}`,
237
+ description,
238
+ parameters: schema,
239
+ execute: async (_toolCallId, args) =>
240
+ toToolResult(
241
+ await callMcpTool(
242
+ gw,
243
+ mcpId,
244
+ def.name,
245
+ (args || {}) as Record<string, unknown>
246
+ )
247
+ ),
248
+ });
249
+ }
250
+ }
251
+
252
+ return tools;
253
+ }
254
+
255
+ export function createMcpAuthToolDefinitions(
256
+ mcpStatus: Array<{
257
+ id: string;
258
+ name: string;
259
+ requiresAuth: boolean;
260
+ requiresInput?: boolean;
261
+ authenticated?: boolean;
262
+ configured?: boolean;
263
+ }>,
264
+ gw: GatewayParams,
265
+ existingToolNames: Set<string> = new Set()
266
+ ): ToolDefinition[] {
267
+ const tools: ToolDefinition[] = [];
268
+
269
+ for (const mcp of mcpStatus) {
270
+ if (!mcp.requiresAuth) {
271
+ continue;
272
+ }
273
+
274
+ const loginToolName = `${mcp.id}_login`;
275
+ if (!existingToolNames.has(loginToolName)) {
276
+ tools.push(
277
+ defineTool({
278
+ name: loginToolName,
279
+ description: `Start the authentication flow for the ${mcp.name} MCP. Use this when ${mcp.name} requires login before its tools can be used.`,
280
+ parameters: Type.Object({}),
281
+ run: () => startMcpLogin(gw, { mcpId: mcp.id }),
282
+ })
283
+ );
284
+ existingToolNames.add(loginToolName);
285
+ }
286
+
287
+ const checkToolName = `${mcp.id}_login_check`;
288
+ if (!existingToolNames.has(checkToolName)) {
289
+ tools.push(
290
+ defineTool({
291
+ name: checkToolName,
292
+ description: `Check whether authentication for the ${mcp.name} MCP has completed. Call this after the user finishes login.`,
293
+ parameters: Type.Object({}),
294
+ run: () => checkMcpLogin(gw, { mcpId: mcp.id }),
295
+ })
296
+ );
297
+ existingToolNames.add(checkToolName);
298
+ }
299
+
300
+ const logoutToolName = `${mcp.id}_logout`;
301
+ if (!existingToolNames.has(logoutToolName)) {
302
+ tools.push(
303
+ defineTool({
304
+ name: logoutToolName,
305
+ description: `Remove the stored authentication credential for the ${mcp.name} MCP.`,
306
+ parameters: Type.Object({}),
307
+ run: () => logoutMcp(gw, { mcpId: mcp.id }),
308
+ })
309
+ );
310
+ existingToolNames.add(logoutToolName);
311
+ }
312
+ }
313
+
314
+ return tools;
315
+ }