@lobu/worker 3.0.8 → 3.0.12

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