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