@lobu/worker 6.1.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 (82) hide show
  1. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  2. package/dist/embedded/just-bash-bootstrap.js +26 -2
  3. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  4. package/dist/gateway/gateway-integration.js +4 -4
  5. package/dist/gateway/gateway-integration.js.map +1 -1
  6. package/dist/gateway/message-batcher.d.ts.map +1 -1
  7. package/dist/gateway/message-batcher.js +3 -5
  8. package/dist/gateway/message-batcher.js.map +1 -1
  9. package/dist/gateway/sse-client.d.ts +1 -0
  10. package/dist/gateway/sse-client.d.ts.map +1 -1
  11. package/dist/gateway/sse-client.js +8 -0
  12. package/dist/gateway/sse-client.js.map +1 -1
  13. package/dist/openclaw/worker.d.ts +0 -1
  14. package/dist/openclaw/worker.d.ts.map +1 -1
  15. package/dist/openclaw/worker.js +18 -75
  16. package/dist/openclaw/worker.js.map +1 -1
  17. package/dist/shared/tool-implementations.d.ts.map +1 -1
  18. package/dist/shared/tool-implementations.js +37 -13
  19. package/dist/shared/tool-implementations.js.map +1 -1
  20. package/package.json +14 -4
  21. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  22. package/src/__tests__/custom-tools.test.ts +92 -0
  23. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  24. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  25. package/src/__tests__/embedded-tools.test.ts +744 -0
  26. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  27. package/src/__tests__/exec-sandbox.test.ts +550 -0
  28. package/src/__tests__/generated-media.test.ts +142 -0
  29. package/src/__tests__/instructions.test.ts +60 -0
  30. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  31. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  32. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  33. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  34. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  35. package/src/__tests__/memory-flush.test.ts +64 -0
  36. package/src/__tests__/message-batcher.test.ts +247 -0
  37. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  38. package/src/__tests__/model-resolver.test.ts +156 -0
  39. package/src/__tests__/processor-harden.test.ts +269 -0
  40. package/src/__tests__/processor.test.ts +225 -0
  41. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  42. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  43. package/src/__tests__/sandbox-leak.test.ts +167 -0
  44. package/src/__tests__/setup.ts +102 -0
  45. package/src/__tests__/sse-client-harden.test.ts +588 -0
  46. package/src/__tests__/sse-client.test.ts +90 -0
  47. package/src/__tests__/tool-implementations.test.ts +196 -0
  48. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  49. package/src/__tests__/tool-policy.test.ts +269 -0
  50. package/src/__tests__/worker.test.ts +89 -0
  51. package/src/core/error-handler.ts +62 -0
  52. package/src/core/project-scanner.ts +65 -0
  53. package/src/core/types.ts +128 -0
  54. package/src/core/workspace.ts +89 -0
  55. package/src/embedded/exec-sandbox.ts +372 -0
  56. package/src/embedded/just-bash-bootstrap.ts +543 -0
  57. package/src/embedded/mcp-cli-commands.ts +402 -0
  58. package/src/gateway/gateway-integration.ts +298 -0
  59. package/src/gateway/message-batcher.ts +123 -0
  60. package/src/gateway/sse-client.ts +951 -0
  61. package/src/gateway/types.ts +68 -0
  62. package/src/index.ts +141 -0
  63. package/src/instructions/builder.ts +45 -0
  64. package/src/instructions/providers.ts +27 -0
  65. package/src/modules/lifecycle.ts +92 -0
  66. package/src/openclaw/custom-tools.ts +315 -0
  67. package/src/openclaw/instructions.ts +36 -0
  68. package/src/openclaw/model-resolver.ts +150 -0
  69. package/src/openclaw/plugin-loader.ts +427 -0
  70. package/src/openclaw/processor.ts +198 -0
  71. package/src/openclaw/sandbox-leak.ts +105 -0
  72. package/src/openclaw/session-context.ts +320 -0
  73. package/src/openclaw/tool-policy.ts +248 -0
  74. package/src/openclaw/tools.ts +277 -0
  75. package/src/openclaw/worker.ts +1847 -0
  76. package/src/server.ts +334 -0
  77. package/src/shared/audio-provider-suggestions.ts +132 -0
  78. package/src/shared/processor-utils.ts +33 -0
  79. package/src/shared/provider-auth-hints.ts +68 -0
  80. package/src/shared/tool-display-config.ts +75 -0
  81. package/src/shared/tool-implementations.ts +940 -0
  82. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,1847 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import {
8
+ createLogger,
9
+ getOptionalEnv,
10
+ type PluginsConfig,
11
+ type ToolsConfig,
12
+ type WorkerTransport,
13
+ } from "@lobu/core";
14
+ import { getModel, type ImageContent } from "@mariozechner/pi-ai";
15
+ import {
16
+ AuthStorage,
17
+ createAgentSession,
18
+ ModelRegistry,
19
+ SettingsManager,
20
+ } from "@mariozechner/pi-coding-agent";
21
+ import * as Sentry from "@sentry/node";
22
+ import { handleExecutionError } from "../core/error-handler";
23
+ import { listAppDirectories } from "../core/project-scanner";
24
+ import type {
25
+ ProgressUpdate,
26
+ SessionExecutionResult,
27
+ WorkerConfig,
28
+ WorkerExecutor,
29
+ } from "../core/types";
30
+ import { WorkspaceManager } from "../core/workspace";
31
+ import { HttpWorkerTransport } from "../gateway/gateway-integration";
32
+ import { generateCustomInstructions } from "../instructions/builder";
33
+ import { ProjectsInstructionProvider } from "../instructions/providers";
34
+ import { fetchAudioProviderSuggestions } from "../shared/audio-provider-suggestions";
35
+ import {
36
+ getApiKeyEnvVarForProvider,
37
+ getProviderAuthHintFromError,
38
+ } from "../shared/provider-auth-hints";
39
+ import type { GatewayParams } from "../shared/tool-implementations";
40
+ import {
41
+ createMcpAuthToolDefinitions,
42
+ createMcpToolDefinitions,
43
+ createOpenClawCustomTools,
44
+ } from "./custom-tools";
45
+ import {
46
+ OpenClawCoreInstructionProvider,
47
+ OpenClawPromptIntentInstructionProvider,
48
+ } from "./instructions";
49
+ import {
50
+ DEFAULT_PROVIDER_BASE_URL_ENV,
51
+ openOrCreateSessionManager,
52
+ PROVIDER_REGISTRY_ALIASES,
53
+ registerDynamicProvider,
54
+ resolveModelRef,
55
+ } from "./model-resolver";
56
+ import { checkSandboxLeak } from "./sandbox-leak";
57
+ import {
58
+ loadPlugins,
59
+ runPluginHooks,
60
+ startPluginServices,
61
+ stopPluginServices,
62
+ } from "./plugin-loader";
63
+ import { OpenClawProgressProcessor } from "./processor";
64
+ import { getOpenClawSessionContext } from "./session-context";
65
+ import {
66
+ buildToolPolicy,
67
+ enforceBashCommandPolicy,
68
+ isToolAllowedByPolicy,
69
+ } from "./tool-policy";
70
+ import { createOpenClawTools } from "./tools";
71
+
72
+ const logger = createLogger("worker");
73
+
74
+ const MEMORY_FLUSH_STATE_CUSTOM_TYPE = "lobu.memory_flush_state";
75
+ const APPROX_IMAGE_TOKENS = 1200;
76
+
77
+ interface ResolvedMemoryFlushConfig {
78
+ enabled: boolean;
79
+ softThresholdTokens: number;
80
+ systemPrompt: string;
81
+ prompt: string;
82
+ }
83
+
84
+ interface MemoryFlushStateData {
85
+ compactionCount: number;
86
+ outcome: "no_reply" | "stored";
87
+ timestamp: number;
88
+ }
89
+
90
+ const DEFAULT_MEMORY_FLUSH_CONFIG: ResolvedMemoryFlushConfig = {
91
+ enabled: true,
92
+ softThresholdTokens: 4000,
93
+ systemPrompt: "Session nearing compaction. Store durable memories now.",
94
+ prompt:
95
+ "Write any lasting notes to memory using available memory tools. Reply with NO_REPLY if nothing to store.",
96
+ };
97
+
98
+ /**
99
+ * Pi-coding-agent's buildSystemPrompt() (in `@mariozechner/pi-coding-agent`)
100
+ * always opens the system prompt with this exact sentence. Lobu agents can
101
+ * override their identity via IDENTITY.md, but unless we strip out this
102
+ * opener the model sees two competing role declarations and tends to favour
103
+ * "expert coding assistant" because it appears first.
104
+ *
105
+ * This helper substitutes the opener with the agent's identity and keeps the
106
+ * rest of the base prompt (tools list, guidelines, docs paths, cwd) intact.
107
+ *
108
+ * If the upstream package ever changes the opener wording, this becomes a
109
+ * no-op and `replaced === original`. In that case we fall back to prepending
110
+ * the identity with a small framing note so identity still wins ordering.
111
+ */
112
+ const PI_CODING_AGENT_OPENER_RE =
113
+ /^You are an expert coding assistant operating inside pi, a coding agent harness\.[^\n]*/;
114
+
115
+ export function replaceBasePromptIdentity(
116
+ basePrompt: string,
117
+ identity: string
118
+ ): string {
119
+ if (PI_CODING_AGENT_OPENER_RE.test(basePrompt)) {
120
+ return basePrompt.replace(PI_CODING_AGENT_OPENER_RE, identity);
121
+ }
122
+ // Upstream wording drifted — prepend identity with a framing note rather
123
+ // than silently letting the upstream opener win.
124
+ return `${identity}\n\nThe section below describes the runtime tooling available to you. It does not change your role.\n\n${basePrompt}`;
125
+ }
126
+
127
+ /**
128
+ * Returns true iff the given URL points at OpenAI's real API host.
129
+ * Uses URL parsing + exact host match so spoofed hosts like
130
+ * `https://api.openai.com.evil.example/v1` are not mistaken for real OpenAI.
131
+ */
132
+ function isRealOpenAIBaseUrl(baseUrl: string): boolean {
133
+ try {
134
+ return new URL(baseUrl).host.toLowerCase() === "api.openai.com";
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ function isRecord(value: unknown): value is Record<string, unknown> {
141
+ return typeof value === "object" && value !== null;
142
+ }
143
+
144
+ function readStringOrFallback(
145
+ value: unknown,
146
+ fallback: string,
147
+ allowEmpty = false
148
+ ): string {
149
+ if (typeof value !== "string") {
150
+ return fallback;
151
+ }
152
+ const trimmed = value.trim();
153
+ if (!trimmed && !allowEmpty) {
154
+ return fallback;
155
+ }
156
+ return allowEmpty ? value : trimmed;
157
+ }
158
+
159
+ function readNonNegativeNumberOrFallback(
160
+ value: unknown,
161
+ fallback: number
162
+ ): number {
163
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
164
+ return fallback;
165
+ }
166
+ return value;
167
+ }
168
+
169
+ function countCompactionsOnCurrentBranch(
170
+ sessionManager: Awaited<ReturnType<typeof openOrCreateSessionManager>>
171
+ ): number {
172
+ const branch = sessionManager.getBranch();
173
+ return branch.reduce((count, entry) => {
174
+ if (entry.type === "compaction") {
175
+ return count + 1;
176
+ }
177
+ return count;
178
+ }, 0);
179
+ }
180
+
181
+ function readLastFlushedCompactionCount(
182
+ sessionManager: Awaited<ReturnType<typeof openOrCreateSessionManager>>
183
+ ): number | null {
184
+ const branch = sessionManager.getBranch();
185
+ for (let i = branch.length - 1; i >= 0; i--) {
186
+ const entry = branch[i];
187
+ if (!entry) continue;
188
+ if (entry.type !== "custom") continue;
189
+ if (entry.customType !== MEMORY_FLUSH_STATE_CUSTOM_TYPE) continue;
190
+ if (!isRecord(entry.data)) continue;
191
+ const compactionCount = entry.data.compactionCount;
192
+ if (
193
+ typeof compactionCount === "number" &&
194
+ Number.isFinite(compactionCount) &&
195
+ compactionCount >= 0
196
+ ) {
197
+ return compactionCount;
198
+ }
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function getLatestAssistantText(
204
+ messages: unknown[]
205
+ ): { text: string; normalizedNoReply: boolean } | null {
206
+ for (let i = messages.length - 1; i >= 0; i--) {
207
+ const message = messages[i];
208
+ if (!isRecord(message) || message.role !== "assistant") continue;
209
+ const content = message.content;
210
+
211
+ let text = "";
212
+ if (typeof content === "string") {
213
+ text = content;
214
+ } else if (Array.isArray(content)) {
215
+ text = content
216
+ .flatMap((block) => {
217
+ if (!isRecord(block)) return [];
218
+ if (block.type !== "text") return [];
219
+ return typeof block.text === "string" ? [block.text] : [];
220
+ })
221
+ .join("");
222
+ }
223
+
224
+ const normalized = text.trim().toUpperCase();
225
+ return {
226
+ text,
227
+ normalizedNoReply: normalized === "NO_REPLY",
228
+ };
229
+ }
230
+ return null;
231
+ }
232
+
233
+ export function estimatePromptTokenCost(
234
+ promptText: string,
235
+ imageCount: number
236
+ ): number {
237
+ const textTokens = Math.ceil(promptText.length / 4);
238
+ const imageTokens = Math.max(0, imageCount) * APPROX_IMAGE_TOKENS;
239
+ return textTokens + imageTokens;
240
+ }
241
+
242
+ export function resolveMemoryFlushConfig(
243
+ rawOptions: Record<string, unknown>
244
+ ): ResolvedMemoryFlushConfig {
245
+ const compaction = isRecord(rawOptions.compaction)
246
+ ? rawOptions.compaction
247
+ : undefined;
248
+ const memoryFlush =
249
+ compaction && isRecord(compaction.memoryFlush)
250
+ ? compaction.memoryFlush
251
+ : undefined;
252
+
253
+ return {
254
+ enabled:
255
+ typeof memoryFlush?.enabled === "boolean"
256
+ ? memoryFlush.enabled
257
+ : DEFAULT_MEMORY_FLUSH_CONFIG.enabled,
258
+ softThresholdTokens: readNonNegativeNumberOrFallback(
259
+ memoryFlush?.softThresholdTokens,
260
+ DEFAULT_MEMORY_FLUSH_CONFIG.softThresholdTokens
261
+ ),
262
+ systemPrompt: readStringOrFallback(
263
+ memoryFlush?.systemPrompt,
264
+ DEFAULT_MEMORY_FLUSH_CONFIG.systemPrompt
265
+ ),
266
+ prompt: readStringOrFallback(
267
+ memoryFlush?.prompt,
268
+ DEFAULT_MEMORY_FLUSH_CONFIG.prompt
269
+ ),
270
+ };
271
+ }
272
+
273
+ export class OpenClawWorker implements WorkerExecutor {
274
+ private workspaceManager: WorkspaceManager;
275
+ public workerTransport: WorkerTransport;
276
+ private config: WorkerConfig;
277
+ private progressProcessor: OpenClawProgressProcessor;
278
+
279
+ constructor(config: WorkerConfig) {
280
+ this.config = config;
281
+ this.workspaceManager = new WorkspaceManager(config.workspace);
282
+ this.progressProcessor = new OpenClawProgressProcessor();
283
+
284
+ // Verify required environment variables
285
+ const gatewayUrl = process.env.DISPATCHER_URL;
286
+ const workerToken = process.env.WORKER_TOKEN;
287
+
288
+ if (!gatewayUrl || !workerToken) {
289
+ throw new Error(
290
+ "DISPATCHER_URL and WORKER_TOKEN environment variables are required"
291
+ );
292
+ }
293
+
294
+ if (!config.teamId) {
295
+ throw new Error("teamId is required for worker initialization");
296
+ }
297
+ if (!config.conversationId) {
298
+ throw new Error("conversationId is required for worker initialization");
299
+ }
300
+ this.workerTransport = new HttpWorkerTransport({
301
+ gatewayUrl,
302
+ workerToken,
303
+ userId: config.userId,
304
+ channelId: config.channelId,
305
+ conversationId: config.conversationId,
306
+ originalMessageTs: config.responseId,
307
+ botResponseTs: config.botResponseId,
308
+ teamId: config.teamId,
309
+ platform: config.platform,
310
+ platformMetadata: config.platformMetadata,
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Main execution workflow
316
+ */
317
+ async execute(): Promise<void> {
318
+ const executeStartTime = Date.now();
319
+
320
+ try {
321
+ this.progressProcessor.reset();
322
+
323
+ logger.info(
324
+ `🚀 Starting OpenClaw worker for session: ${this.config.sessionKey}`
325
+ );
326
+ logger.info(
327
+ `[TIMING] Worker execute() started at: ${new Date(executeStartTime).toISOString()}`
328
+ );
329
+
330
+ // Decode user prompt
331
+ const userPrompt = Buffer.from(this.config.userPrompt, "base64").toString(
332
+ "utf-8"
333
+ );
334
+ logger.info(`User prompt: ${userPrompt.substring(0, 100)}...`);
335
+
336
+ // Setup workspace
337
+ logger.info("Setting up workspace...");
338
+
339
+ await Sentry.startSpan(
340
+ {
341
+ name: "worker.workspace_setup",
342
+ op: "worker.setup",
343
+ attributes: {
344
+ "user.id": this.config.userId,
345
+ "session.key": this.config.sessionKey,
346
+ },
347
+ },
348
+ async () => {
349
+ await this.workspaceManager.setupWorkspace(
350
+ this.config.userId,
351
+ this.config.sessionKey
352
+ );
353
+
354
+ const { initModuleWorkspace } = await import("../modules/lifecycle");
355
+ await initModuleWorkspace({
356
+ workspaceDir: this.workspaceManager.getCurrentWorkingDirectory(),
357
+ username: this.config.userId,
358
+ sessionKey: this.config.sessionKey,
359
+ });
360
+ }
361
+ );
362
+
363
+ // Setup I/O directories for file handling
364
+ await this.setupIODirectories();
365
+
366
+ // Download input files if any
367
+ await this.downloadInputFiles();
368
+
369
+ // Generate custom instructions
370
+ let customInstructions = await generateCustomInstructions(
371
+ [
372
+ new OpenClawCoreInstructionProvider(),
373
+ new OpenClawPromptIntentInstructionProvider(),
374
+ new ProjectsInstructionProvider(),
375
+ ],
376
+ {
377
+ userId: this.config.userId,
378
+ agentId: this.config.agentId,
379
+ sessionKey: this.config.sessionKey,
380
+ workingDirectory: this.workspaceManager.getCurrentWorkingDirectory(),
381
+ userPrompt,
382
+ availableProjects: listAppDirectories(
383
+ this.workspaceManager.getCurrentWorkingDirectory()
384
+ ),
385
+ }
386
+ );
387
+
388
+ // Call module onSessionStart hooks to allow modules to modify system prompt
389
+ try {
390
+ const { onSessionStart } = await import("../modules/lifecycle");
391
+ const moduleContext = await onSessionStart({
392
+ platform: this.config.platform,
393
+ channelId: this.config.channelId,
394
+ userId: this.config.userId,
395
+ conversationId: this.config.conversationId,
396
+ messageId: this.config.responseId,
397
+ workingDirectory: this.workspaceManager.getCurrentWorkingDirectory(),
398
+ customInstructions,
399
+ });
400
+ if (moduleContext.customInstructions) {
401
+ customInstructions = moduleContext.customInstructions;
402
+ }
403
+ } catch (error) {
404
+ logger.error("Failed to call onSessionStart hooks:", error);
405
+ }
406
+
407
+ // Add file I/O instructions AFTER module hooks so they aren't overwritten
408
+ customInstructions += this.getFileIOInstructions();
409
+
410
+ // Execute AI session
411
+ logger.info(
412
+ `[TIMING] Starting OpenClaw session at: ${new Date().toISOString()}`
413
+ );
414
+ const aiStartTime = Date.now();
415
+ logger.info(
416
+ `[TIMING] Total worker startup time: ${aiStartTime - executeStartTime}ms`
417
+ );
418
+
419
+ let firstOutputLogged = false;
420
+
421
+ let sawUploadedFileEvent = false;
422
+
423
+ const result = await Sentry.startSpan(
424
+ {
425
+ name: "worker.openclaw_execution",
426
+ op: "ai.inference",
427
+ attributes: {
428
+ "user.id": this.config.userId,
429
+ "session.key": this.config.sessionKey,
430
+ "conversation.id": this.config.conversationId,
431
+ agent: "OpenClaw",
432
+ },
433
+ },
434
+ async () => {
435
+ return await this.runAISession(
436
+ userPrompt,
437
+ customInstructions,
438
+ async (update) => {
439
+ if (!firstOutputLogged && update.type === "output") {
440
+ logger.info(
441
+ `[TIMING] First OpenClaw output at: ${new Date().toISOString()} (${Date.now() - aiStartTime}ms after start)`
442
+ );
443
+ firstOutputLogged = true;
444
+ }
445
+
446
+ if (update.type === "output" && update.data) {
447
+ const delta =
448
+ typeof update.data === "string" ? update.data : null;
449
+ if (delta) {
450
+ await this.workerTransport.sendStreamDelta(delta, false);
451
+ }
452
+ } else if (update.type === "status_update") {
453
+ await this.workerTransport.sendStatusUpdate(
454
+ update.data.elapsedSeconds,
455
+ update.data.state
456
+ );
457
+ } else if (update.type === "custom_event") {
458
+ if (update.data.name === "file-uploaded") {
459
+ sawUploadedFileEvent = true;
460
+ }
461
+ await this.workerTransport.sendCustomEvent(
462
+ update.data.name,
463
+ update.data.payload
464
+ );
465
+ }
466
+ }
467
+ );
468
+ }
469
+ );
470
+
471
+ // Collect module data before sending final response
472
+ const { collectModuleData } = await import("../modules/lifecycle");
473
+ const moduleData = await collectModuleData({
474
+ workspaceDir: this.workspaceManager.getCurrentWorkingDirectory(),
475
+ userId: this.config.userId,
476
+ conversationId: this.config.conversationId,
477
+ });
478
+ this.workerTransport.setModuleData(moduleData);
479
+
480
+ // Handle result
481
+ if (result.success) {
482
+ const outputSnapshot = this.progressProcessor.getOutputSnapshot();
483
+ const hintGatewayUrl = process.env.DISPATCHER_URL;
484
+ const hintWorkerToken = process.env.WORKER_TOKEN;
485
+ const audioPermissionHint =
486
+ hintGatewayUrl && hintWorkerToken
487
+ ? await this.maybeBuildAudioPermissionHintMessage(
488
+ outputSnapshot,
489
+ hintGatewayUrl,
490
+ hintWorkerToken
491
+ )
492
+ : null;
493
+ const finalResult = this.progressProcessor.getFinalResult();
494
+ if (finalResult) {
495
+ const leakCheck = checkSandboxLeak(
496
+ finalResult.text,
497
+ sawUploadedFileEvent
498
+ );
499
+ if (leakCheck.leaked) {
500
+ logger.warn(
501
+ "Detected unfulfilled file-delivery claim in final message; redacting link targets"
502
+ );
503
+ }
504
+ const finalText = audioPermissionHint
505
+ ? `${leakCheck.redactedText}\n\n${audioPermissionHint}`
506
+ : leakCheck.redactedText;
507
+ logger.info(
508
+ `📤 Sending final result (${finalText.length} chars) with deduplication flag`
509
+ );
510
+ // When a leak was redacted, the already-streamed content contains the
511
+ // pre-redaction URLs — a delta-append would leave them on the client.
512
+ // Force a full replacement so the client discards the leaky prefix.
513
+ await this.workerTransport.sendStreamDelta(
514
+ finalText,
515
+ leakCheck.leaked,
516
+ finalResult.isFinal
517
+ );
518
+ } else if (audioPermissionHint) {
519
+ logger.info("📤 Sending audio permission settings hint to user");
520
+ await this.workerTransport.sendStreamDelta(
521
+ `\n\n${audioPermissionHint}`,
522
+ false
523
+ );
524
+ } else {
525
+ logger.info(
526
+ "Session completed successfully - all content already streamed"
527
+ );
528
+ }
529
+ await this.workerTransport.signalDone();
530
+ } else {
531
+ const errorMsg = result.error || "Unknown error";
532
+ const isTimeout = result.exitCode === 124;
533
+
534
+ if (isTimeout) {
535
+ logger.info(
536
+ `Session timed out (exit code 124) - will be retried automatically, not showing error to user`
537
+ );
538
+ throw new Error("SESSION_TIMEOUT");
539
+ } else {
540
+ const isAuthError =
541
+ /no.credentials.configured|no_credentials|invalid.*api.key|incorrect.*api.key|token.*expired/i.test(
542
+ errorMsg
543
+ );
544
+ const userMessage = isAuthError
545
+ ? "Your AI provider credentials are invalid or expired. End-user provider setup is not available in chat yet. Ask an admin to reconnect the base agent provider."
546
+ : `❌ Session failed: ${errorMsg}`;
547
+ await this.workerTransport.sendStreamDelta(userMessage, true, true);
548
+ if (isAuthError) {
549
+ await this.workerTransport.signalDone();
550
+ } else {
551
+ await this.workerTransport.signalError(new Error(errorMsg));
552
+ }
553
+ }
554
+ }
555
+
556
+ logger.info(
557
+ `Worker completed with ${result.success ? "success" : "failure"}`
558
+ );
559
+ } catch (error) {
560
+ await handleExecutionError(error, this.workerTransport);
561
+ }
562
+ }
563
+
564
+ async cleanup(): Promise<void> {
565
+ logger.info("Worker cleanup completed");
566
+ }
567
+
568
+ getWorkerTransport(): WorkerTransport | null {
569
+ return this.workerTransport;
570
+ }
571
+
572
+ private async maybeRunPreCompactionMemoryFlush(params: {
573
+ session: Awaited<ReturnType<typeof createAgentSession>>["session"];
574
+ sessionManager: Awaited<ReturnType<typeof openOrCreateSessionManager>>;
575
+ settingsManager: SettingsManager;
576
+ memoryFlushConfig: ResolvedMemoryFlushConfig;
577
+ incomingPromptText: string;
578
+ incomingImageCount: number;
579
+ runSilentPrompt: (prompt: string) => Promise<void>;
580
+ }): Promise<void> {
581
+ const {
582
+ session,
583
+ sessionManager,
584
+ settingsManager,
585
+ memoryFlushConfig,
586
+ incomingPromptText,
587
+ incomingImageCount,
588
+ runSilentPrompt,
589
+ } = params;
590
+
591
+ if (!memoryFlushConfig.enabled) {
592
+ return;
593
+ }
594
+
595
+ if (!settingsManager.getCompactionEnabled()) {
596
+ return;
597
+ }
598
+
599
+ const contextUsage = session.getContextUsage();
600
+ if (!contextUsage) {
601
+ return;
602
+ }
603
+
604
+ const reserveTokens = settingsManager.getCompactionReserveTokens();
605
+ const currentCompactionCount =
606
+ countCompactionsOnCurrentBranch(sessionManager);
607
+ const lastFlushedCompactionCount =
608
+ readLastFlushedCompactionCount(sessionManager);
609
+
610
+ if (lastFlushedCompactionCount === currentCompactionCount) {
611
+ return;
612
+ }
613
+
614
+ const incomingPromptTokens = estimatePromptTokenCost(
615
+ incomingPromptText,
616
+ incomingImageCount
617
+ );
618
+ const thresholdTokens =
619
+ contextUsage.contextWindow -
620
+ reserveTokens -
621
+ memoryFlushConfig.softThresholdTokens;
622
+ const projectedContextTokens = contextUsage.tokens + incomingPromptTokens;
623
+
624
+ if (projectedContextTokens < thresholdTokens) {
625
+ return;
626
+ }
627
+
628
+ const flushPrompt = `${memoryFlushConfig.systemPrompt}\n\n${memoryFlushConfig.prompt}`;
629
+ logger.info(
630
+ `Running silent pre-compaction memory flush: projected=${projectedContextTokens}, threshold=${thresholdTokens}, compactionCount=${currentCompactionCount}`
631
+ );
632
+
633
+ try {
634
+ await runSilentPrompt(flushPrompt);
635
+ const lastAssistant = getLatestAssistantText(
636
+ session.messages as unknown[]
637
+ );
638
+ const outcome: MemoryFlushStateData["outcome"] =
639
+ lastAssistant?.normalizedNoReply === true ? "no_reply" : "stored";
640
+
641
+ sessionManager.appendCustomEntry(MEMORY_FLUSH_STATE_CUSTOM_TYPE, {
642
+ compactionCount: currentCompactionCount,
643
+ outcome,
644
+ timestamp: Date.now(),
645
+ } satisfies MemoryFlushStateData);
646
+ } catch (error) {
647
+ logger.warn(
648
+ `Silent pre-compaction memory flush failed, continuing main prompt: ${error instanceof Error ? error.message : String(error)}`
649
+ );
650
+ }
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // AI session
655
+ // ---------------------------------------------------------------------------
656
+
657
+ private async runAISession(
658
+ userPrompt: string,
659
+ customInstructions: string,
660
+ onProgress: (update: ProgressUpdate) => Promise<void>
661
+ ): Promise<SessionExecutionResult> {
662
+ let rawOptions: Record<string, unknown>;
663
+ try {
664
+ rawOptions = JSON.parse(this.config.agentOptions) as Record<
665
+ string,
666
+ unknown
667
+ >;
668
+ } catch (error) {
669
+ logger.error(
670
+ `Failed to parse agentOptions: ${error instanceof Error ? error.message : String(error)}`
671
+ );
672
+ rawOptions = {};
673
+ }
674
+ const verboseLogging = rawOptions.verboseLogging === true;
675
+ const memoryFlushConfig = resolveMemoryFlushConfig(rawOptions);
676
+
677
+ this.progressProcessor.setVerboseLogging(verboseLogging);
678
+
679
+ // Resolve how MCP tools should be exposed to the agent. In embedded mode,
680
+ // operators can swap the many first-class MCP tools for a small set of
681
+ // per-server just-bash CLIs (keeps the tool list lean).
682
+ const configuredMcpExposure = (
683
+ rawOptions.toolsConfig as ToolsConfig | undefined
684
+ )?.mcpExposure;
685
+ const envMcpExposure = process.env.LOBU_MCP_EXPOSURE;
686
+ const mcpExposure: "tools" | "cli" =
687
+ configuredMcpExposure === "cli" || envMcpExposure === "cli"
688
+ ? "cli"
689
+ : "tools";
690
+
691
+ // Fetch session context BEFORE model resolution so AGENT_DEFAULT_PROVIDER
692
+ // is available when resolveModelRef() needs a fallback provider. Pass
693
+ // `mcpExposure` so MCP setup instructions use the right call syntax.
694
+ const context = await getOpenClawSessionContext({ mcpExposure });
695
+
696
+ // Sync enabled skills to workspace filesystem so the agent can `cat` them.
697
+ // Remove stale skill directories to avoid serving removed/disabled skills.
698
+ const skillsWorkspaceDir =
699
+ this.workspaceManager.getCurrentWorkingDirectory();
700
+ const skillsRoot = path.join(skillsWorkspaceDir, ".skills");
701
+ await fs.mkdir(skillsRoot, { recursive: true });
702
+
703
+ const nextSkillNames = new Set(
704
+ context.skillsConfig
705
+ .map((skill) => path.basename((skill.name || "").trim()))
706
+ .filter(Boolean)
707
+ );
708
+
709
+ const existingSkillEntries = await fs
710
+ .readdir(skillsRoot, { withFileTypes: true })
711
+ .catch(() => []);
712
+
713
+ for (const entry of existingSkillEntries) {
714
+ if (!entry.isDirectory()) continue;
715
+ if (!nextSkillNames.has(entry.name)) {
716
+ await fs.rm(path.join(skillsRoot, entry.name), {
717
+ recursive: true,
718
+ force: true,
719
+ });
720
+ }
721
+ }
722
+
723
+ for (const skill of context.skillsConfig) {
724
+ const skillName = path.basename((skill.name || "").trim());
725
+ if (!skillName) continue;
726
+ if (!/^[a-zA-Z0-9._-]+$/.test(skillName)) {
727
+ logger.warn(`Skipping skill with invalid name: ${skillName}`);
728
+ continue;
729
+ }
730
+ const skillDir = path.join(skillsRoot, skillName);
731
+ await fs.mkdir(skillDir, { recursive: true });
732
+ await fs.writeFile(
733
+ path.join(skillDir, "SKILL.md"),
734
+ skill.content,
735
+ "utf-8"
736
+ );
737
+ }
738
+
739
+ logger.info(
740
+ `Synced ${context.skillsConfig.length} skill(s) to .skills/ directory`
741
+ );
742
+
743
+ // Store credentials in a local map instead of mutating process.env
744
+ // to prevent leaking secrets between sessions via persistent env vars.
745
+ const credentialStore = new Map<string, string>();
746
+
747
+ const pc = context.providerConfig;
748
+ if (pc.credentialEnvVarName) {
749
+ credentialStore.set("CREDENTIAL_ENV_VAR_NAME", pc.credentialEnvVarName);
750
+ }
751
+ if (pc.providerBaseUrlMappings) {
752
+ for (const [envVar, url] of Object.entries(pc.providerBaseUrlMappings)) {
753
+ credentialStore.set(envVar, url);
754
+ }
755
+ }
756
+ if (pc.credentialPlaceholders) {
757
+ for (const [envVar, placeholder] of Object.entries(
758
+ pc.credentialPlaceholders
759
+ )) {
760
+ credentialStore.set(envVar, placeholder);
761
+ }
762
+ }
763
+
764
+ // Register config-driven providers so resolveModelRef() can handle them
765
+ if (pc.configProviders) {
766
+ for (const [id, meta] of Object.entries(pc.configProviders)) {
767
+ registerDynamicProvider(id, meta);
768
+ }
769
+ }
770
+
771
+ const modelRef =
772
+ typeof rawOptions.model === "string" ? rawOptions.model : "";
773
+
774
+ const { provider: rawProvider, modelId } = resolveModelRef(modelRef, {
775
+ defaultModel: pc.defaultModel,
776
+ defaultProvider: pc.defaultProvider,
777
+ });
778
+ // Map gateway slug to model-registry provider name (e.g. "z-ai" → "zai")
779
+ const provider = PROVIDER_REGISTRY_ALIASES[rawProvider] || rawProvider;
780
+
781
+ // Dynamic provider base URL from agentOptions.providerBaseUrlMappings
782
+ let providerBaseUrl: string | undefined;
783
+ const dynamicMappings = rawOptions.providerBaseUrlMappings as
784
+ | Record<string, string>
785
+ | undefined;
786
+ if (dynamicMappings && typeof dynamicMappings === "object") {
787
+ const fallbackEnvVar = DEFAULT_PROVIDER_BASE_URL_ENV[rawProvider];
788
+ if (fallbackEnvVar && dynamicMappings[fallbackEnvVar]) {
789
+ providerBaseUrl = dynamicMappings[fallbackEnvVar];
790
+ }
791
+ for (const [envVar, url] of Object.entries(dynamicMappings)) {
792
+ if (!credentialStore.has(envVar)) {
793
+ credentialStore.set(envVar, url);
794
+ }
795
+ }
796
+ }
797
+ if (!providerBaseUrl) {
798
+ providerBaseUrl =
799
+ typeof rawOptions.providerBaseUrl === "string"
800
+ ? rawOptions.providerBaseUrl.trim() || undefined
801
+ : undefined;
802
+ }
803
+ if (!providerBaseUrl) {
804
+ const baseUrlEnvVar = DEFAULT_PROVIDER_BASE_URL_ENV[rawProvider];
805
+ if (baseUrlEnvVar) {
806
+ const baseUrlValue = credentialStore.get(baseUrlEnvVar);
807
+ if (baseUrlValue) {
808
+ providerBaseUrl = baseUrlValue;
809
+ }
810
+ }
811
+ }
812
+
813
+ let baseModel = getModel(provider as any, modelId as any) as any;
814
+ if (!baseModel) {
815
+ // For OpenAI-compatible providers (e.g. nvidia, together-ai), create a
816
+ // dynamic model entry since these models aren't in the static registry.
817
+ const registryProvider =
818
+ PROVIDER_REGISTRY_ALIASES[rawProvider] || rawProvider;
819
+ if (registryProvider === "openai" || rawProvider !== provider) {
820
+ logger.info(
821
+ `Creating dynamic model entry for ${rawProvider}/${modelId} (openai-compatible)`
822
+ );
823
+ baseModel = {
824
+ id: modelId,
825
+ name: modelId,
826
+ api: "openai-completions",
827
+ provider: registryProvider,
828
+ baseUrl: providerBaseUrl || "https://api.openai.com/v1",
829
+ reasoning: false,
830
+ input: ["text", "image"],
831
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
832
+ contextWindow: 128000,
833
+ maxTokens: 16384,
834
+ };
835
+ } else {
836
+ throw new Error(
837
+ `Model "${modelId}" not found for provider "${provider}". Check that the model ID is valid and registered in the model registry.`
838
+ );
839
+ }
840
+ }
841
+ const resolvedModel = providerBaseUrl
842
+ ? { ...baseModel, baseUrl: providerBaseUrl }
843
+ : baseModel;
844
+
845
+ // Defensive: any `openai-completions` model whose baseUrl is not real
846
+ // OpenAI is a third-party compat endpoint (Gemini, Nvidia, Together, z.ai,
847
+ // etc.). These reject unknown fields and 400 with "Unknown name 'store'"
848
+ // if pi-ai sends `store: false`. Force it off regardless of whether the
849
+ // model came from the static registry or the dynamic fallback above.
850
+ //
851
+ // Host comparison uses URL parsing (not `.startsWith`) so that a baseUrl
852
+ // like `https://api.openai.com.evil.example/v1` doesn't get mistaken for
853
+ // real OpenAI. Malformed URLs are treated as third-party (safer default).
854
+ const isThirdPartyOpenAICompat =
855
+ resolvedModel.api === "openai-completions" &&
856
+ typeof resolvedModel.baseUrl === "string" &&
857
+ !isRealOpenAIBaseUrl(resolvedModel.baseUrl);
858
+ const model = isThirdPartyOpenAICompat
859
+ ? {
860
+ ...resolvedModel,
861
+ compat: { ...(resolvedModel.compat ?? {}), supportsStore: false },
862
+ }
863
+ : resolvedModel;
864
+
865
+ const workspaceDir = this.workspaceManager.getCurrentWorkingDirectory();
866
+ await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
867
+
868
+ const sessionFile = path.join(workspaceDir, ".openclaw", "session.jsonl");
869
+ const providerStateFile = path.join(
870
+ workspaceDir,
871
+ ".openclaw",
872
+ "provider.json"
873
+ );
874
+
875
+ // Detect provider change and reset session if needed
876
+ let sessionSummary: string | undefined;
877
+ try {
878
+ const raw = await fs.readFile(providerStateFile, "utf-8");
879
+ const prevState = JSON.parse(raw) as {
880
+ provider: string;
881
+ modelId: string;
882
+ };
883
+ if (prevState.provider && prevState.provider !== provider) {
884
+ logger.info(
885
+ `Provider changed from ${prevState.provider} to ${provider}, resetting session`
886
+ );
887
+
888
+ // Read old session content for summary context
889
+ try {
890
+ const sessionContent = await fs.readFile(sessionFile, "utf-8");
891
+ const lineCount = sessionContent.split("\n").filter(Boolean).length;
892
+ if (lineCount > 0) {
893
+ // Provide a brief context note instead of a full summary
894
+ // to avoid an expensive API call to the new model
895
+ sessionSummary = `[System note: The AI provider was just changed from ${prevState.provider} to ${provider}. Previous conversation history (${lineCount} turns) has been cleared. Continue helping the user from this point forward.]`;
896
+ }
897
+ } catch {
898
+ // No existing session file
899
+ }
900
+
901
+ // Delete old session file to start fresh
902
+ try {
903
+ await fs.unlink(sessionFile);
904
+ } catch {
905
+ // File may not exist
906
+ }
907
+ }
908
+ } catch (error) {
909
+ // Log a warning for parse failures (vs. missing file which is expected on first run)
910
+ const isFileNotFound =
911
+ error instanceof Error &&
912
+ (error as NodeJS.ErrnoException).code === "ENOENT";
913
+ if (!isFileNotFound) {
914
+ logger.warn(
915
+ `Failed to read provider state file: ${error instanceof Error ? error.message : String(error)}`
916
+ );
917
+ }
918
+ }
919
+
920
+ // Persist current provider state
921
+ await fs.writeFile(
922
+ providerStateFile,
923
+ JSON.stringify({ provider, modelId }),
924
+ "utf-8"
925
+ );
926
+
927
+ const sessionManager = await openOrCreateSessionManager(
928
+ sessionFile,
929
+ workspaceDir
930
+ );
931
+ const settingsManager = SettingsManager.inMemory();
932
+
933
+ const toolsPolicy = buildToolPolicy({
934
+ toolsConfig: rawOptions.toolsConfig as ToolsConfig | undefined,
935
+ allowedTools: rawOptions.allowedTools as string | string[] | undefined,
936
+ disallowedTools: rawOptions.disallowedTools as
937
+ | string
938
+ | string[]
939
+ | undefined,
940
+ });
941
+
942
+ // Build a mutable snapshot of MCP runtime state. The embedded CLI handlers
943
+ // read through `mcpRuntimeRef.current` so that `auth check` / `logout` can
944
+ // swap in refreshed tools/state without rebuilding Bash. `refresh()` re-
945
+ // fetches session context — `checkMcpLogin`/`logoutMcp` already invalidate
946
+ // the gateway cache, so the next fetch reaches the gateway.
947
+ const mcpRuntimeRef = {
948
+ current: {
949
+ mcpTools: context.mcpTools,
950
+ mcpStatus: context.mcpStatus,
951
+ mcpContext: context.mcpContext,
952
+ },
953
+ ...(mcpExposure === "cli" && {
954
+ refresh: async () => {
955
+ try {
956
+ const fresh = await getOpenClawSessionContext({ mcpExposure });
957
+ return {
958
+ mcpTools: fresh.mcpTools,
959
+ mcpStatus: fresh.mcpStatus,
960
+ mcpContext: fresh.mcpContext,
961
+ };
962
+ } catch (err) {
963
+ logger.warn(
964
+ `Failed to refresh MCP session context after auth: ${err instanceof Error ? err.message : String(err)}`
965
+ );
966
+ return null;
967
+ }
968
+ },
969
+ }),
970
+ };
971
+
972
+ const gwParams: GatewayParams = {
973
+ gatewayUrl: getOptionalEnv("DISPATCHER_URL", ""),
974
+ workerToken: getOptionalEnv("WORKER_TOKEN", ""),
975
+ channelId: this.config.channelId,
976
+ conversationId: this.config.conversationId,
977
+ platform: this.config.platform,
978
+ workspaceDir,
979
+ };
980
+
981
+ const { createEmbeddedBashOps } = await import(
982
+ "../embedded/just-bash-bootstrap"
983
+ );
984
+ const embeddedBashOps: import("@mariozechner/pi-coding-agent").BashOperations =
985
+ await createEmbeddedBashOps({
986
+ workspaceDir,
987
+ mcpRuntimeRef,
988
+ gw: gwParams,
989
+ mcpExposure,
990
+ });
991
+ let tools = createOpenClawTools(workspaceDir, {
992
+ bashOperations: embeddedBashOps,
993
+ }).filter((tool) => isToolAllowedByPolicy(tool.name, toolsPolicy));
994
+
995
+ if (
996
+ toolsPolicy.bashPolicy.allowPrefixes.length > 0 ||
997
+ toolsPolicy.bashPolicy.denyPrefixes.length > 0
998
+ ) {
999
+ tools = tools.map((tool) => {
1000
+ if (tool.name !== "bash") {
1001
+ return tool;
1002
+ }
1003
+ return {
1004
+ ...tool,
1005
+ execute: async (toolCallId, params, signal, onUpdate) => {
1006
+ const command =
1007
+ params && typeof params === "object" && "command" in params
1008
+ ? String((params as { command?: unknown }).command ?? "")
1009
+ : "";
1010
+ enforceBashCommandPolicy(command, toolsPolicy.bashPolicy);
1011
+ return tool.execute(toolCallId, params as any, signal, onUpdate);
1012
+ },
1013
+ };
1014
+ });
1015
+ }
1016
+
1017
+ // Credential injection — resolve API key from the in-memory credential store,
1018
+ // falling back to process.env only for values that were present at startup.
1019
+ const authStorage = new AuthStorage();
1020
+ const credEnvVar = credentialStore.get("CREDENTIAL_ENV_VAR_NAME") || null;
1021
+ const credValue = credEnvVar
1022
+ ? credentialStore.get(credEnvVar) || process.env[credEnvVar]
1023
+ : null;
1024
+ if (credEnvVar && credValue) {
1025
+ authStorage.setRuntimeApiKey(provider, credValue);
1026
+ logger.info(`Set runtime API key for ${provider}`);
1027
+ } else {
1028
+ // Look up the env var by the canonical gateway slug (e.g. "z-ai" → Z_AI_API_KEY),
1029
+ // not the model-registry alias (e.g. "zai" → ZAI_API_KEY which nobody sets).
1030
+ const fallbackEnvVar = getApiKeyEnvVarForProvider(rawProvider);
1031
+ const fallbackValue =
1032
+ credentialStore.get(fallbackEnvVar) || process.env[fallbackEnvVar];
1033
+ if (fallbackValue) {
1034
+ authStorage.setRuntimeApiKey(provider, fallbackValue);
1035
+ logger.info(`Set runtime API key for ${provider}`);
1036
+ }
1037
+ }
1038
+
1039
+ // Re-resolve provider base URL after session context may have updated mappings
1040
+ if (!providerBaseUrl) {
1041
+ const baseUrlEnvVar = DEFAULT_PROVIDER_BASE_URL_ENV[rawProvider];
1042
+ if (baseUrlEnvVar) {
1043
+ const baseUrlValue = credentialStore.get(baseUrlEnvVar);
1044
+ if (baseUrlValue) {
1045
+ providerBaseUrl = baseUrlValue;
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // Merge gateway instructions into custom instructions
1051
+ const instructionParts = [context.gatewayInstructions, customInstructions];
1052
+
1053
+ // Prefer CLI backends from dynamic session context, fall back to env var
1054
+ let cliBackendsFromEnv:
1055
+ | Array<{ name: string; command: string; args?: string[] }>
1056
+ | undefined;
1057
+ if (!pc.cliBackends?.length && process.env.CLI_BACKENDS) {
1058
+ try {
1059
+ cliBackendsFromEnv = JSON.parse(process.env.CLI_BACKENDS) as Array<{
1060
+ name: string;
1061
+ command: string;
1062
+ args?: string[];
1063
+ }>;
1064
+ } catch (error) {
1065
+ logger.error(
1066
+ `Failed to parse CLI_BACKENDS env var: ${error instanceof Error ? error.message : String(error)}`
1067
+ );
1068
+ }
1069
+ }
1070
+ const cliBackends = pc.cliBackends?.length
1071
+ ? pc.cliBackends
1072
+ : cliBackendsFromEnv;
1073
+ if (cliBackends?.length) {
1074
+ const agentList = cliBackends
1075
+ .map((b) => {
1076
+ const cmd = `${b.command} ${(b.args || []).join(" ")}`;
1077
+ const aliases = [b.name, (b as any).providerId].filter(
1078
+ (v, i, a) => v && a.indexOf(v) === i
1079
+ );
1080
+ return `### ${aliases.join(" / ")}
1081
+ Run via Bash exactly as shown (do NOT modify the command):
1082
+ \`\`\`bash
1083
+ ${cmd} "YOUR_PROMPT_HERE"
1084
+ \`\`\``;
1085
+ })
1086
+ .join("\n\n");
1087
+ instructionParts.push(
1088
+ `## Available Coding Agents
1089
+
1090
+ You have access to the following AI coding agents. When the user mentions any of these by name (e.g. "use claude", "ask chatgpt"), you MUST run the exact command shown below via the Bash tool. Do NOT attempt to install or locate the CLI yourself — the command handles everything.
1091
+
1092
+ ${agentList}
1093
+
1094
+ Replace "YOUR_PROMPT_HERE" with the user's request. These agents can read/write files, install packages, and run commands in the working directory.`
1095
+ );
1096
+ }
1097
+
1098
+ instructionParts.push(`## Conversation History
1099
+
1100
+ You have access to GetChannelHistory to view previous messages in this thread.
1101
+ Use it when the user references past discussions or you need context.`);
1102
+
1103
+ const customTools = createOpenClawCustomTools({
1104
+ ...gwParams,
1105
+ workspaceDir,
1106
+ onCustomEvent: async (name, data) => {
1107
+ await onProgress({
1108
+ type: "custom_event",
1109
+ data: { name, payload: data },
1110
+ timestamp: Date.now(),
1111
+ });
1112
+ },
1113
+ });
1114
+
1115
+ // Register first-class MCP tools + auth tools. Skipped entirely in CLI
1116
+ // mode — MCP tools are instead reachable via the per-server just-bash CLI
1117
+ // wired in above, and `<server> auth login|check|logout` supersedes the
1118
+ // `<id>_login` / `<id>_login_check` / `<id>_logout` trio.
1119
+ if (mcpExposure === "cli") {
1120
+ logger.info(
1121
+ "mcpExposure='cli' — skipping first-class MCP tool registration (tools reachable via <server> <tool> in Bash)."
1122
+ );
1123
+ } else {
1124
+ const mcpToolDefs = createMcpToolDefinitions(
1125
+ context.mcpTools,
1126
+ gwParams,
1127
+ context.mcpContext
1128
+ );
1129
+ if (mcpToolDefs.length > 0) {
1130
+ customTools.push(...mcpToolDefs);
1131
+ logger.info(
1132
+ `Registered ${mcpToolDefs.length} MCP tool(s): ${mcpToolDefs.map((t) => t.name).join(", ")}`
1133
+ );
1134
+ }
1135
+ }
1136
+
1137
+ // Load OpenClaw plugins
1138
+ const pluginsConfig = rawOptions.pluginsConfig as PluginsConfig | undefined;
1139
+ const loadedPlugins = await loadPlugins(pluginsConfig, workspaceDir);
1140
+ const pluginTools = loadedPlugins.flatMap((p) => p.tools);
1141
+
1142
+ if (pluginTools.length > 0) {
1143
+ customTools.push(...pluginTools);
1144
+ logger.info(
1145
+ `Loaded ${pluginTools.length} tool(s) from ${loadedPlugins.length} plugin(s)`
1146
+ );
1147
+ }
1148
+
1149
+ if (mcpExposure !== "cli") {
1150
+ const authToolDefs = createMcpAuthToolDefinitions(
1151
+ context.mcpStatus,
1152
+ gwParams,
1153
+ new Set(customTools.map((tool) => tool.name))
1154
+ );
1155
+ if (authToolDefs.length > 0) {
1156
+ customTools.push(...authToolDefs);
1157
+ logger.info(
1158
+ `Registered ${authToolDefs.length} MCP auth tool(s): ${authToolDefs.map((t) => t.name).join(", ")}`
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ // Apply plugin provider registrations to ModelRegistry
1164
+ const modelRegistry = new ModelRegistry(authStorage);
1165
+ const allProviders = loadedPlugins.flatMap((p) => p.providers);
1166
+ for (const reg of allProviders) {
1167
+ try {
1168
+ modelRegistry.registerProvider(reg.name, reg.config as any);
1169
+ logger.info(`Registered provider "${reg.name}" from plugin`);
1170
+ } catch (err) {
1171
+ logger.error(
1172
+ `Failed to register provider "${reg.name}": ${err instanceof Error ? err.message : String(err)}`
1173
+ );
1174
+ }
1175
+ }
1176
+ await startPluginServices(loadedPlugins);
1177
+
1178
+ // Rebuild final instructions after possible login link injection
1179
+ const finalInstructionsUpdated = instructionParts
1180
+ .filter(Boolean)
1181
+ .join("\n\n");
1182
+
1183
+ logger.info(
1184
+ `Starting OpenClaw session: provider=${provider}, model=${modelId}, tools=${tools.length}, customTools=${customTools.length}`
1185
+ );
1186
+
1187
+ // Heartbeat timer to keep connection alive during long API calls
1188
+ const HEARTBEAT_INTERVAL_MS = 20000;
1189
+ let heartbeatTimer: Timer | null = null;
1190
+ let deltaTimer: Timer | null = null;
1191
+ let session:
1192
+ | Awaited<ReturnType<typeof createAgentSession>>["session"]
1193
+ | null = null;
1194
+ const pluginHookContext: Record<string, unknown> = {
1195
+ cwd: workspaceDir,
1196
+ sessionKey: this.config.sessionKey,
1197
+ messageProvider: this.config.platform,
1198
+ };
1199
+
1200
+ try {
1201
+ const createdSession = await createAgentSession({
1202
+ cwd: workspaceDir,
1203
+ model,
1204
+ tools,
1205
+ customTools,
1206
+ sessionManager,
1207
+ settingsManager,
1208
+ authStorage,
1209
+ modelRegistry,
1210
+ });
1211
+ session = createdSession.session;
1212
+
1213
+ // Pi-coding-agent's base prompt opens with "You are an expert coding
1214
+ // assistant operating inside pi, a coding agent harness…" — that anchor
1215
+ // overrides any IDENTITY.md the agent ships with. Replace just that
1216
+ // opener with the agent's real identity (or the lobu default) so the
1217
+ // tools/guidelines/cwd footer below it still applies, but the role on
1218
+ // top is the one we actually want.
1219
+ const basePrompt = session.systemPrompt;
1220
+ const identity = context.agentInstructions?.trim();
1221
+ const finalSystemPrompt = identity
1222
+ ? [
1223
+ replaceBasePromptIdentity(basePrompt, identity),
1224
+ finalInstructionsUpdated,
1225
+ ]
1226
+ .filter(Boolean)
1227
+ .join("\n\n---\n\n")
1228
+ : [basePrompt, finalInstructionsUpdated]
1229
+ .filter(Boolean)
1230
+ .join("\n\n---\n\n");
1231
+ session.agent.setSystemPrompt(finalSystemPrompt);
1232
+
1233
+ let resolveTurnDone: (() => void) | null = null;
1234
+ let turnNonce = 0;
1235
+ let suppressProgressOutput = false;
1236
+
1237
+ // Wire events through progress processor with delta batching
1238
+ let pendingDelta = "";
1239
+ const DELTA_BATCH_INTERVAL_MS = 150;
1240
+
1241
+ const flushDelta = async () => {
1242
+ if (pendingDelta) {
1243
+ const toSend = pendingDelta;
1244
+ pendingDelta = "";
1245
+ await onProgress({
1246
+ type: "output",
1247
+ data: toSend,
1248
+ timestamp: Date.now(),
1249
+ });
1250
+ }
1251
+ if (deltaTimer) {
1252
+ clearTimeout(deltaTimer);
1253
+ deltaTimer = null;
1254
+ }
1255
+ };
1256
+
1257
+ const scheduleDeltaFlush = () => {
1258
+ if (!deltaTimer) {
1259
+ deltaTimer = setTimeout(() => {
1260
+ flushDelta().catch((err) => {
1261
+ logger.error("Failed to flush delta:", err);
1262
+ });
1263
+ }, DELTA_BATCH_INTERVAL_MS);
1264
+ }
1265
+ };
1266
+
1267
+ const runPromptTurn = async (
1268
+ promptText: string,
1269
+ options?: { images?: ImageContent[]; silent?: boolean }
1270
+ ): Promise<void> => {
1271
+ const currentSession = session;
1272
+ if (!currentSession) {
1273
+ throw new Error("OpenClaw session is not initialized");
1274
+ }
1275
+
1276
+ turnNonce += 1;
1277
+ const currentTurnNonce = turnNonce;
1278
+
1279
+ const turnDone = new Promise<void>((resolve) => {
1280
+ resolveTurnDone = () => {
1281
+ if (currentTurnNonce !== turnNonce) {
1282
+ return;
1283
+ }
1284
+ resolveTurnDone = null;
1285
+ resolve();
1286
+ };
1287
+ });
1288
+
1289
+ suppressProgressOutput = options?.silent === true;
1290
+
1291
+ try {
1292
+ if (options?.images) {
1293
+ await currentSession.prompt(promptText, { images: options.images });
1294
+ } else {
1295
+ await currentSession.prompt(promptText);
1296
+ }
1297
+ await turnDone;
1298
+ } finally {
1299
+ suppressProgressOutput = false;
1300
+ if (resolveTurnDone && currentTurnNonce === turnNonce) {
1301
+ resolveTurnDone = null;
1302
+ }
1303
+ }
1304
+ };
1305
+
1306
+ session.subscribe((event) => {
1307
+ if (suppressProgressOutput) {
1308
+ if (event.type === "agent_end") {
1309
+ resolveTurnDone?.();
1310
+ }
1311
+ return;
1312
+ }
1313
+
1314
+ const hasUpdate = this.progressProcessor.processEvent(event);
1315
+ if (hasUpdate) {
1316
+ const delta = this.progressProcessor.getDelta();
1317
+ if (delta) {
1318
+ pendingDelta += delta;
1319
+ scheduleDeltaFlush();
1320
+ }
1321
+ }
1322
+
1323
+ if (event.type === "agent_end") {
1324
+ flushDelta()
1325
+ .then(() => resolveTurnDone?.())
1326
+ .catch((err) => {
1327
+ logger.error("Failed to flush final delta:", err);
1328
+ resolveTurnDone?.();
1329
+ });
1330
+ }
1331
+ });
1332
+
1333
+ let elapsedTime = 0;
1334
+ let lastHeartbeatTime = Date.now();
1335
+ const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 5;
1336
+ let consecutiveHeartbeatFailures = 0;
1337
+
1338
+ const sendHeartbeat = async () => {
1339
+ const now = Date.now();
1340
+ elapsedTime += now - lastHeartbeatTime;
1341
+ lastHeartbeatTime = now;
1342
+ const seconds = Math.floor(elapsedTime / 1000);
1343
+
1344
+ logger.warn(
1345
+ `⏳ Still running after ${seconds}s - no response from API yet`
1346
+ );
1347
+
1348
+ await onProgress({
1349
+ type: "status_update",
1350
+ data: {
1351
+ elapsedSeconds: seconds,
1352
+ state: "is running..",
1353
+ },
1354
+ timestamp: Date.now(),
1355
+ });
1356
+ };
1357
+
1358
+ heartbeatTimer = setInterval(() => {
1359
+ sendHeartbeat()
1360
+ .then(() => {
1361
+ consecutiveHeartbeatFailures = 0;
1362
+ })
1363
+ .catch((err) => {
1364
+ consecutiveHeartbeatFailures += 1;
1365
+ logger.error(
1366
+ `Failed to send heartbeat (${consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES}):`,
1367
+ err
1368
+ );
1369
+ if (
1370
+ consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES
1371
+ ) {
1372
+ logger.error(
1373
+ "Gateway unresponsive after consecutive heartbeat failures, aborting session"
1374
+ );
1375
+ if (heartbeatTimer) {
1376
+ clearInterval(heartbeatTimer);
1377
+ heartbeatTimer = null;
1378
+ }
1379
+ // Unblock any in-flight prompt turn FIRST — disposing the session
1380
+ // without resolving `turnDone` leaves `runPromptTurn` (and the
1381
+ // outer `runAISession`) wedged on `await turnDone` until the
1382
+ // deployment manager force-kills the worker.
1383
+ resolveTurnDone?.();
1384
+ if (session) {
1385
+ session.dispose();
1386
+ }
1387
+ }
1388
+ });
1389
+ }, HEARTBEAT_INTERVAL_MS);
1390
+
1391
+ // Session reset: run unconditional memory flush, delete session file, and return early
1392
+ if ((this.config as any).platformMetadata?.sessionReset === true) {
1393
+ logger.info(
1394
+ "Session reset requested — running unconditional memory flush"
1395
+ );
1396
+
1397
+ const flushPrompt = `${memoryFlushConfig.systemPrompt}\n\n${memoryFlushConfig.prompt}`;
1398
+ try {
1399
+ await runPromptTurn(flushPrompt, { silent: true });
1400
+ logger.info("Memory flush completed for session reset");
1401
+ } catch (error) {
1402
+ logger.warn(
1403
+ `Memory flush failed during session reset: ${error instanceof Error ? error.message : String(error)}`
1404
+ );
1405
+ }
1406
+
1407
+ // Delete session file so next run starts with a clean history
1408
+ try {
1409
+ await fs.unlink(sessionFile);
1410
+ logger.info("Deleted session file for session reset");
1411
+ } catch {
1412
+ // File may not exist
1413
+ }
1414
+
1415
+ // Send visible confirmation to user
1416
+ await onProgress({
1417
+ type: "output",
1418
+ data: "Context saved. Starting fresh.",
1419
+ timestamp: Date.now(),
1420
+ });
1421
+
1422
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1423
+ if (deltaTimer) clearTimeout(deltaTimer);
1424
+ await stopPluginServices(loadedPlugins);
1425
+
1426
+ return {
1427
+ success: true,
1428
+ exitCode: 0,
1429
+ output: "",
1430
+ sessionKey: this.config.sessionKey,
1431
+ };
1432
+ }
1433
+
1434
+ // Consume any pending config change notifications from SSE events
1435
+ const { consumePendingConfigNotifications } = await import(
1436
+ "../gateway/sse-client"
1437
+ );
1438
+ const configNotifications = consumePendingConfigNotifications();
1439
+
1440
+ let configNotice = "";
1441
+ if (configNotifications.length > 0) {
1442
+ const lines = configNotifications.map((n) => {
1443
+ let line = `- ${n.summary}`;
1444
+ if (n.details?.length) {
1445
+ line += `: ${n.details.join("; ")}`;
1446
+ }
1447
+ return line;
1448
+ });
1449
+ configNotice = `[System notice: Your configuration was updated since the last message]\n${lines.join("\n")}\n\n`;
1450
+ }
1451
+
1452
+ const beforeAgentStartResults = await runPluginHooks({
1453
+ plugins: loadedPlugins,
1454
+ hook: "before_agent_start",
1455
+ event: {
1456
+ prompt: userPrompt,
1457
+ messages: session.messages as unknown as Record<string, unknown>[],
1458
+ },
1459
+ ctx: pluginHookContext,
1460
+ });
1461
+ const prependContexts = beforeAgentStartResults
1462
+ .flatMap((result) => {
1463
+ if (!result || typeof result !== "object") return [];
1464
+ const prepend = (result as Record<string, unknown>).prependContext;
1465
+ if (typeof prepend !== "string" || !prepend.trim()) return [];
1466
+ return [prepend.trim()];
1467
+ })
1468
+ .join("\n\n");
1469
+
1470
+ const effectivePromptText = `${configNotice}${sessionSummary ? `${sessionSummary}\n\n` : ""}${prependContexts ? `${prependContexts}\n\n` : ""}${userPrompt}`;
1471
+
1472
+ // Load image attachments for vision-capable models
1473
+ const images = await this.loadImageAttachments();
1474
+ if (images.length > 0) {
1475
+ logger.info(`Including ${images.length} image(s) in prompt for vision`);
1476
+ }
1477
+
1478
+ await this.maybeRunPreCompactionMemoryFlush({
1479
+ session,
1480
+ sessionManager,
1481
+ settingsManager,
1482
+ memoryFlushConfig,
1483
+ incomingPromptText: effectivePromptText,
1484
+ incomingImageCount: images.length,
1485
+ runSilentPrompt: async (prompt) => {
1486
+ await runPromptTurn(prompt, { silent: true });
1487
+ },
1488
+ });
1489
+
1490
+ await runPromptTurn(effectivePromptText, { images });
1491
+
1492
+ const sessionError = this.progressProcessor.consumeFatalErrorMessage();
1493
+ if (sessionError) {
1494
+ await runPluginHooks({
1495
+ plugins: loadedPlugins,
1496
+ hook: "agent_end",
1497
+ event: {
1498
+ success: false,
1499
+ error: sessionError,
1500
+ messages: session.messages as unknown as Record<string, unknown>[],
1501
+ },
1502
+ ctx: pluginHookContext,
1503
+ });
1504
+ const errorWithHint = this.maybeBuildAuthHintMessage(
1505
+ sessionError,
1506
+ rawProvider,
1507
+ modelId
1508
+ );
1509
+ return {
1510
+ success: false,
1511
+ exitCode: 1,
1512
+ output: "",
1513
+ error: errorWithHint,
1514
+ sessionKey: this.config.sessionKey,
1515
+ };
1516
+ }
1517
+
1518
+ await runPluginHooks({
1519
+ plugins: loadedPlugins,
1520
+ hook: "agent_end",
1521
+ event: {
1522
+ success: true,
1523
+ messages: session.messages as unknown as Record<string, unknown>[],
1524
+ },
1525
+ ctx: pluginHookContext,
1526
+ });
1527
+
1528
+ return {
1529
+ success: true,
1530
+ exitCode: 0,
1531
+ output: "",
1532
+ sessionKey: this.config.sessionKey,
1533
+ };
1534
+ } catch (error) {
1535
+ const errorMsg = error instanceof Error ? error.message : String(error);
1536
+ if (session) {
1537
+ await runPluginHooks({
1538
+ plugins: loadedPlugins,
1539
+ hook: "agent_end",
1540
+ event: {
1541
+ success: false,
1542
+ error: errorMsg,
1543
+ messages: session.messages as unknown as Record<string, unknown>[],
1544
+ },
1545
+ ctx: pluginHookContext,
1546
+ });
1547
+ }
1548
+ const errorWithHint = this.maybeBuildAuthHintMessage(
1549
+ errorMsg,
1550
+ provider,
1551
+ modelId
1552
+ );
1553
+
1554
+ return {
1555
+ success: false,
1556
+ exitCode: 1,
1557
+ output: "",
1558
+ error: errorWithHint,
1559
+ sessionKey: this.config.sessionKey,
1560
+ };
1561
+ } finally {
1562
+ if (heartbeatTimer) {
1563
+ clearInterval(heartbeatTimer);
1564
+ heartbeatTimer = null;
1565
+ logger.debug("Heartbeat timer cleared");
1566
+ }
1567
+ if (deltaTimer) {
1568
+ clearTimeout(deltaTimer);
1569
+ deltaTimer = null;
1570
+ logger.debug("Delta batch timer cleared");
1571
+ }
1572
+ if (session) {
1573
+ session.dispose();
1574
+ session = null;
1575
+ }
1576
+ await stopPluginServices(loadedPlugins);
1577
+ }
1578
+ }
1579
+
1580
+ // ---------------------------------------------------------------------------
1581
+ // Helpers
1582
+ // ---------------------------------------------------------------------------
1583
+
1584
+ private async setupIODirectories(): Promise<void> {
1585
+ const workspaceDir = this.workspaceManager.getCurrentWorkingDirectory();
1586
+ const inputDir = path.join(workspaceDir, "input");
1587
+ const outputDir = path.join(workspaceDir, "output");
1588
+ const tempDir = path.join(workspaceDir, "temp");
1589
+
1590
+ await fs.mkdir(inputDir, { recursive: true });
1591
+ await fs.mkdir(outputDir, { recursive: true });
1592
+ await fs.mkdir(tempDir, { recursive: true });
1593
+
1594
+ try {
1595
+ const files = await fs.readdir(outputDir);
1596
+ await Promise.all(
1597
+ files.map((file) =>
1598
+ fs.unlink(path.join(outputDir, file)).catch(() => {
1599
+ /* intentionally empty */
1600
+ })
1601
+ )
1602
+ );
1603
+ } catch (error) {
1604
+ logger.debug("Could not clear output directory:", error);
1605
+ }
1606
+
1607
+ logger.info("I/O directories setup completed");
1608
+ }
1609
+
1610
+ private async downloadInputFiles(): Promise<void> {
1611
+ const files = this.uploadedFiles;
1612
+ if (files.length === 0) {
1613
+ return;
1614
+ }
1615
+
1616
+ logger.info(`Downloading ${files.length} input files...`);
1617
+ const workspaceDir = this.workspaceManager.getCurrentWorkingDirectory();
1618
+ const inputDir = path.join(workspaceDir, "input");
1619
+
1620
+ for (const file of files) {
1621
+ try {
1622
+ if (!file.downloadUrl) {
1623
+ logger.warn(
1624
+ { fileName: file.name, fileId: file.id },
1625
+ "Inbound file has no downloadUrl; gateway must publish it as an artifact before forwarding"
1626
+ );
1627
+ continue;
1628
+ }
1629
+ logger.info(`Downloading file: ${file.name} (${file.id})`);
1630
+
1631
+ // The gateway pre-publishes every inbound attachment as a signed,
1632
+ // time-limited artifact and embeds the URL in `downloadUrl`. We
1633
+ // fetch through the worker's egress proxy — no platform tokens or
1634
+ // worker JWT cross this boundary anymore.
1635
+ const response = await fetch(file.downloadUrl, {
1636
+ signal: AbortSignal.timeout(60_000),
1637
+ });
1638
+
1639
+ if (!response.ok) {
1640
+ logger.error(
1641
+ `Failed to download file ${file.name}: ${response.statusText}`
1642
+ );
1643
+ continue;
1644
+ }
1645
+
1646
+ // Sanitize file name to prevent path traversal
1647
+ const safeName = path.basename(file.name);
1648
+ if (!safeName || safeName === "." || safeName === "..") {
1649
+ logger.warn(`Skipping file with invalid name: ${file.name}`);
1650
+ continue;
1651
+ }
1652
+ if (safeName !== file.name) {
1653
+ logger.warn(
1654
+ `Sanitized file name from "${file.name}" to "${safeName}"`
1655
+ );
1656
+ }
1657
+
1658
+ if (!response.body) {
1659
+ logger.error(`Response body is null for file ${safeName}`);
1660
+ continue;
1661
+ }
1662
+
1663
+ const destPath = path.join(inputDir, safeName);
1664
+ const fileStream = Readable.fromWeb(response.body as any);
1665
+ const writeStream = (await import("node:fs")).createWriteStream(
1666
+ destPath
1667
+ );
1668
+
1669
+ await pipeline(fileStream, writeStream);
1670
+ logger.info(`Downloaded: ${safeName} to input directory`);
1671
+ } catch (error) {
1672
+ logger.error(`Error downloading file ${file.name}:`, error);
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ private get uploadedFiles(): Array<{
1678
+ id: string;
1679
+ name: string;
1680
+ mimetype: string;
1681
+ downloadUrl?: string;
1682
+ }> {
1683
+ return (this.config as any).platformMetadata?.files || [];
1684
+ }
1685
+
1686
+ private static isImage(mimetype?: string): boolean {
1687
+ return !!mimetype?.startsWith("image/");
1688
+ }
1689
+
1690
+ private getFileIOInstructions(): string {
1691
+ const workspaceDir = this.workspaceManager.getCurrentWorkingDirectory();
1692
+ const files = this.uploadedFiles;
1693
+
1694
+ const fileOutputRules = `
1695
+ **Mandatory workflow for ANY file you create or generate:**
1696
+ 1. Write the file to disk (e.g. \`output/report.pdf\`).
1697
+ 2. Call \`UploadUserFile\` with the file path — this is the ONLY way the user can access it.
1698
+ 3. Confirm delivery ONLY after \`UploadUserFile\` succeeds.
1699
+
1700
+ **Workspace paths are not accessible to users.** Paths like \`/workspace/...\` or \`/app/workspaces/...\` are internal sandbox paths. Never show them as file locations, download links, or "saved at" references. The user cannot reach them. Always use \`UploadUserFile\` instead.`;
1701
+
1702
+ const common = `
1703
+
1704
+ ## File Generation & Output
1705
+
1706
+ ${fileOutputRules}
1707
+
1708
+ **When to Create Files:**
1709
+ Create and show files for any output that helps answer the user's request:
1710
+ - **Charts & visualizations**: pie charts, bar graphs, plots, diagrams via \`matplotlib\`
1711
+ - **Reports & documents**: analysis reports, summaries, PDFs
1712
+ - **Data files**: CSV exports, JSON data, spreadsheets
1713
+ - **Code files**: scripts, configurations, examples
1714
+ - **Images**: generated images, processed photos, screenshots.
1715
+ `;
1716
+
1717
+ if (files.length === 0) {
1718
+ return common;
1719
+ }
1720
+
1721
+ const fileListing = files
1722
+ .map(
1723
+ (f) =>
1724
+ `- \`${workspaceDir}/input/${f.name}\` (${f.mimetype || "unknown type"})`
1725
+ )
1726
+ .join("\n");
1727
+
1728
+ const hasImages = files.some((f) => OpenClawWorker.isImage(f.mimetype));
1729
+ const hasNonImages = files.some((f) => !OpenClawWorker.isImage(f.mimetype));
1730
+
1731
+ let hints = "";
1732
+ if (hasImages) {
1733
+ hints +=
1734
+ "\nImage files have been included directly in this message for visual analysis.";
1735
+ }
1736
+ if (hasNonImages) {
1737
+ hints +=
1738
+ "\nYou can read non-image files with standard commands like `cat`, `less`, or `head`.";
1739
+ }
1740
+
1741
+ return `${common}
1742
+ ### User-Uploaded Files
1743
+ The user has uploaded ${files.length} file(s) for you to analyze:
1744
+ ${fileListing}
1745
+
1746
+ **Use these files to answer the user's request.**${hints}
1747
+ `;
1748
+ }
1749
+
1750
+ /** Max image size to embed in prompt (20 MB). Larger files are skipped. */
1751
+ private static readonly MAX_IMAGE_BYTES = 20 * 1024 * 1024;
1752
+
1753
+ private async loadImageAttachments(): Promise<ImageContent[]> {
1754
+ const imageFiles = this.uploadedFiles.filter((f) =>
1755
+ OpenClawWorker.isImage(f.mimetype)
1756
+ );
1757
+ if (imageFiles.length === 0) return [];
1758
+
1759
+ const inputDir = path.join(
1760
+ this.workspaceManager.getCurrentWorkingDirectory(),
1761
+ "input"
1762
+ );
1763
+ const results: ImageContent[] = [];
1764
+
1765
+ for (const file of imageFiles) {
1766
+ try {
1767
+ // Sanitize file name to prevent path traversal
1768
+ const safeName = path.basename(file.name);
1769
+ if (!safeName || safeName === "." || safeName === "..") {
1770
+ logger.warn(`Skipping image with invalid name: ${file.name}`);
1771
+ continue;
1772
+ }
1773
+ if (safeName !== file.name) {
1774
+ logger.warn(
1775
+ `Sanitized image file name from "${file.name}" to "${safeName}"`
1776
+ );
1777
+ }
1778
+ const data = await fs.readFile(path.join(inputDir, safeName));
1779
+ if (data.length > OpenClawWorker.MAX_IMAGE_BYTES) {
1780
+ logger.warn(
1781
+ `Skipping image ${file.name}: ${Math.round(data.length / 1024 / 1024)}MB exceeds limit`
1782
+ );
1783
+ continue;
1784
+ }
1785
+ results.push({
1786
+ type: "image",
1787
+ data: data.toString("base64"),
1788
+ mimeType: file.mimetype,
1789
+ });
1790
+ logger.info(
1791
+ `Loaded image: ${file.name} (${file.mimetype}, ${Math.round(data.length / 1024)}KB)`
1792
+ );
1793
+ } catch (error) {
1794
+ logger.warn(`Failed to load image ${file.name}:`, error);
1795
+ }
1796
+ }
1797
+
1798
+ return results;
1799
+ }
1800
+
1801
+ private maybeBuildAuthHintMessage(
1802
+ errorMessage: string,
1803
+ provider: string,
1804
+ modelId: string
1805
+ ): string {
1806
+ const authHint = getProviderAuthHintFromError(errorMessage, provider);
1807
+ if (!authHint) {
1808
+ return errorMessage;
1809
+ }
1810
+
1811
+ return `To use ${modelId}, an admin needs to connect ${authHint.providerName} on the base agent. Ask an admin to configure ${authHint.providerName} and then try again.`;
1812
+ }
1813
+
1814
+ private async maybeBuildAudioPermissionHintMessage(
1815
+ outputText: string,
1816
+ gatewayUrl: string,
1817
+ workerToken: string
1818
+ ): Promise<string | null> {
1819
+ const lower = outputText.toLowerCase();
1820
+ if (!lower.includes("api.model.audio.request")) {
1821
+ return null;
1822
+ }
1823
+
1824
+ if (
1825
+ lower.includes("settings button has been sent") ||
1826
+ lower.includes("connect button has been sent") ||
1827
+ lower.includes("open settings") ||
1828
+ lower.includes("secure connect link")
1829
+ ) {
1830
+ return null;
1831
+ }
1832
+
1833
+ try {
1834
+ const suggestions = await fetchAudioProviderSuggestions({
1835
+ gatewayUrl,
1836
+ workerToken,
1837
+ });
1838
+ const providerList =
1839
+ suggestions.providerDisplayList || "an audio-capable provider";
1840
+
1841
+ return `Voice generation needs an audio-capable provider (${providerList}) connected on the base agent. Ask an admin to connect one of these providers, then try again.`;
1842
+ } catch (error) {
1843
+ logger.error("Failed to fetch audio provider suggestions", error);
1844
+ return null;
1845
+ }
1846
+ }
1847
+ }