@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,199 @@
|
|
|
1
|
+
import { createLogger } from "@lobu/core";
|
|
2
|
+
import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { formatToolExecution } from "../shared/processor-utils";
|
|
4
|
+
|
|
5
|
+
const logger = createLogger("openclaw-processor");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Processes Pi agent streaming events and extracts user-friendly content.
|
|
9
|
+
* Implements chronological display with tool progress and mixed text/tool output.
|
|
10
|
+
*/
|
|
11
|
+
export class OpenClawProgressProcessor {
|
|
12
|
+
private chronologicalOutput = "";
|
|
13
|
+
private lastSentContent = "";
|
|
14
|
+
private currentThinking = "";
|
|
15
|
+
private verboseLogging = false;
|
|
16
|
+
private finalResult: { text: string; isFinal: boolean } | null = null;
|
|
17
|
+
private hasStreamedText = false;
|
|
18
|
+
private fatalErrorMessage: string | null = null;
|
|
19
|
+
|
|
20
|
+
setVerboseLogging(enabled: boolean): void {
|
|
21
|
+
this.verboseLogging = enabled;
|
|
22
|
+
logger.info(`Verbose logging ${enabled ? "enabled" : "disabled"}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Process a Pi agent session event and append to chronological output.
|
|
27
|
+
* Returns true if new content was appended.
|
|
28
|
+
*/
|
|
29
|
+
processEvent(event: AgentSessionEvent): boolean {
|
|
30
|
+
switch (event.type) {
|
|
31
|
+
case "message_update": {
|
|
32
|
+
if (event.message.role !== "assistant") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
36
|
+
if (!assistantEvent) return false;
|
|
37
|
+
|
|
38
|
+
if (assistantEvent.type === "text_delta") {
|
|
39
|
+
this.hasStreamedText = true;
|
|
40
|
+
this.chronologicalOutput += assistantEvent.delta;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (assistantEvent.type === "thinking_delta") {
|
|
45
|
+
this.currentThinking += assistantEvent.delta;
|
|
46
|
+
if (this.verboseLogging) {
|
|
47
|
+
this.chronologicalOutput += assistantEvent.delta;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (assistantEvent.type === "thinking_start" && this.verboseLogging) {
|
|
54
|
+
this.chronologicalOutput += "\n💭 *Reasoning:*\n";
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (assistantEvent.type === "thinking_end" && this.verboseLogging) {
|
|
59
|
+
this.chronologicalOutput += "\n\n";
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case "message_end": {
|
|
67
|
+
if (event.message.role !== "assistant") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const assistantMessage = event.message;
|
|
71
|
+
if (
|
|
72
|
+
assistantMessage.stopReason === "error" &&
|
|
73
|
+
assistantMessage.errorMessage?.trim()
|
|
74
|
+
) {
|
|
75
|
+
this.fatalErrorMessage = assistantMessage.errorMessage.trim();
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
// If text was already streamed via deltas, skip extraction
|
|
79
|
+
if (this.hasStreamedText) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// Fallback: extract text from final message content
|
|
83
|
+
let extracted = false;
|
|
84
|
+
for (const block of assistantMessage.content) {
|
|
85
|
+
if (block.type === "text" && block.text.trim()) {
|
|
86
|
+
this.chronologicalOutput += block.text;
|
|
87
|
+
extracted = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return extracted;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case "tool_execution_start": {
|
|
94
|
+
const params =
|
|
95
|
+
event.args && typeof event.args === "object"
|
|
96
|
+
? (event.args as Record<string, unknown>)
|
|
97
|
+
: {};
|
|
98
|
+
const formatted = formatToolExecution(
|
|
99
|
+
event.toolName,
|
|
100
|
+
params,
|
|
101
|
+
this.verboseLogging
|
|
102
|
+
);
|
|
103
|
+
if (formatted) {
|
|
104
|
+
this.chronologicalOutput += `${formatted}\n`;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "auto_compaction_start": {
|
|
111
|
+
this.chronologicalOutput += "🗜️ *Compacting context...*\n";
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case "auto_compaction_end": {
|
|
116
|
+
if (event.aborted) {
|
|
117
|
+
this.chronologicalOutput += "🗜️ *Compaction aborted*\n";
|
|
118
|
+
} else if (event.result) {
|
|
119
|
+
this.chronologicalOutput += "🗜️ *Context compacted*\n";
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "auto_retry_start": {
|
|
125
|
+
this.chronologicalOutput += `🔄 *Retrying (attempt ${event.attempt}/${event.maxAttempts})...*\n`;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "auto_retry_end": {
|
|
130
|
+
if (!event.success && event.finalError) {
|
|
131
|
+
this.chronologicalOutput += `🔄 *Retry failed: ${event.finalError}*\n`;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get delta since last sent content.
|
|
144
|
+
* Returns null if no new content.
|
|
145
|
+
*/
|
|
146
|
+
getDelta(): string | null {
|
|
147
|
+
const fullContent = this.chronologicalOutput.trim();
|
|
148
|
+
|
|
149
|
+
if (!fullContent) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (fullContent === this.lastSentContent) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.lastSentContent && fullContent.startsWith(this.lastSentContent)) {
|
|
158
|
+
const delta = fullContent.slice(this.lastSentContent.length);
|
|
159
|
+
this.lastSentContent = fullContent;
|
|
160
|
+
return delta;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.lastSentContent = fullContent;
|
|
164
|
+
return fullContent;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setFinalResult(result: { text: string; isFinal: boolean }): void {
|
|
168
|
+
this.finalResult = result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getFinalResult(): { text: string; isFinal: boolean } | null {
|
|
172
|
+
const result = this.finalResult;
|
|
173
|
+
this.finalResult = null;
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
consumeFatalErrorMessage(): string | null {
|
|
178
|
+
const result = this.fatalErrorMessage;
|
|
179
|
+
this.fatalErrorMessage = null;
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
getCurrentThinking(): string | null {
|
|
184
|
+
return this.currentThinking || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getOutputSnapshot(): string {
|
|
188
|
+
return this.chronologicalOutput.trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
reset(): void {
|
|
192
|
+
this.lastSentContent = "";
|
|
193
|
+
this.chronologicalOutput = "";
|
|
194
|
+
this.currentThinking = "";
|
|
195
|
+
this.finalResult = null;
|
|
196
|
+
this.hasStreamedText = false;
|
|
197
|
+
this.fatalErrorMessage = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects and redacts "sandbox leaks" — cases where the agent presents a
|
|
3
|
+
* local workspace path (or a Claude `sandbox://` URL) as if it were a
|
|
4
|
+
* user-downloadable artifact, without having actually called
|
|
5
|
+
* `UploadUserFile`.
|
|
6
|
+
*
|
|
7
|
+
* Catches three structural delivery patterns (links, sandbox:// URLs,
|
|
8
|
+
* HTML attributes) plus a semantic pattern: a workspace path presented as
|
|
9
|
+
* "the file is at …" or "located at …" — the phrasing that tricks users
|
|
10
|
+
* into thinking the path is reachable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Claude's `sandbox://` file-reference scheme — always a delivery claim. */
|
|
14
|
+
const SANDBOX_URL_RE = /\bsandbox:\/{1,2}[^\s)\]}'"<>]+/gi;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Markdown link target pointing at a local workspace path, e.g.
|
|
18
|
+
* `[report](/app/workspaces/foo/bar.pdf)` or `[x](file:///workspace/y)`.
|
|
19
|
+
*/
|
|
20
|
+
const LOCAL_MD_LINK_RE =
|
|
21
|
+
/\]\(\s*((?:file:\/\/)?(?:\/app\/workspaces\/|\/workspace\/)[^\s)]+)\s*\)/gi;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* HTML `href`/`src` pointing at a local workspace path.
|
|
25
|
+
* Group 1 = attribute name, group 2 = URL target.
|
|
26
|
+
*/
|
|
27
|
+
const LOCAL_HREF_RE =
|
|
28
|
+
/\b(href|src)\s*=\s*["']((?:file:\/\/)?(?:\/app\/workspaces\/|\/workspace\/)[^"']+)["']/gi;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A workspace path presented as a file location via delivery-intent phrasing
|
|
32
|
+
* (e.g. "located at", "saved to", "file is at", "available at").
|
|
33
|
+
* Only fires when the path has a file extension so directory descriptions
|
|
34
|
+
* in ls-style probes are not flagged.
|
|
35
|
+
*
|
|
36
|
+
* Matches both bare and back-ticked paths:
|
|
37
|
+
* "The file is located at: /app/workspaces/.../report.pdf"
|
|
38
|
+
* "saved to `/workspace/output/data.csv`"
|
|
39
|
+
*/
|
|
40
|
+
const DELIVERY_PHRASE_RE =
|
|
41
|
+
/(?:located at|saved (?:to|at|in)|file is (?:at|in)|available at|created (?:at|in)|stored (?:at|in)|written to|exported to|generated (?:at|in))[:\s]+`?((?:\/app\/workspaces\/|\/workspace\/)[^\s`]+\.\w{1,10})`?/gi;
|
|
42
|
+
|
|
43
|
+
interface LeakCheckResult {
|
|
44
|
+
/** True if the final message makes an unfulfilled file-delivery claim. */
|
|
45
|
+
leaked: boolean;
|
|
46
|
+
/** `finalText` with offending link/URL targets neutralised. Equal to
|
|
47
|
+
* `finalText` when `leaked` is false. */
|
|
48
|
+
redactedText: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inspect the agent's final user-facing message for unfulfilled file-delivery
|
|
53
|
+
* claims. If `sawUploadedFileEvent` is true (the agent actually called
|
|
54
|
+
* UploadUserFile during this turn), no check is performed — the agent did
|
|
55
|
+
* deliver something, and any remaining path references are assumed
|
|
56
|
+
* descriptive.
|
|
57
|
+
*/
|
|
58
|
+
export function checkSandboxLeak(
|
|
59
|
+
finalText: string,
|
|
60
|
+
sawUploadedFileEvent: boolean
|
|
61
|
+
): LeakCheckResult {
|
|
62
|
+
if (sawUploadedFileEvent || !finalText) {
|
|
63
|
+
return { leaked: false, redactedText: finalText };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hasSandboxUrl = SANDBOX_URL_RE.test(finalText);
|
|
67
|
+
const hasMdLink = LOCAL_MD_LINK_RE.test(finalText);
|
|
68
|
+
const hasHref = LOCAL_HREF_RE.test(finalText);
|
|
69
|
+
const hasDeliveryPhrase = DELIVERY_PHRASE_RE.test(finalText);
|
|
70
|
+
|
|
71
|
+
// Reset lastIndex — `test()` on /g regexes advances state.
|
|
72
|
+
SANDBOX_URL_RE.lastIndex = 0;
|
|
73
|
+
LOCAL_MD_LINK_RE.lastIndex = 0;
|
|
74
|
+
LOCAL_HREF_RE.lastIndex = 0;
|
|
75
|
+
DELIVERY_PHRASE_RE.lastIndex = 0;
|
|
76
|
+
|
|
77
|
+
if (!hasSandboxUrl && !hasMdLink && !hasHref && !hasDeliveryPhrase) {
|
|
78
|
+
return { leaked: false, redactedText: finalText };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Redact: neutralise the link targets so the user doesn't see a broken
|
|
82
|
+
// "clickable" path, but keep the surrounding prose intact.
|
|
83
|
+
let redacted = finalText;
|
|
84
|
+
redacted = redacted.replace(SANDBOX_URL_RE, "[local file, not uploaded]");
|
|
85
|
+
redacted = redacted.replace(LOCAL_MD_LINK_RE, "](about:blank)");
|
|
86
|
+
redacted = redacted.replace(
|
|
87
|
+
LOCAL_HREF_RE,
|
|
88
|
+
(_match, attr: string) => `${attr}="about:blank"`
|
|
89
|
+
);
|
|
90
|
+
redacted = redacted.replace(
|
|
91
|
+
DELIVERY_PHRASE_RE,
|
|
92
|
+
"[file was created but not uploaded — use `UploadUserFile` to deliver it]"
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const note =
|
|
96
|
+
"\n\n_Note: I referenced a local file but did not actually upload it. " +
|
|
97
|
+
"Ask me to retry and I will use `UploadUserFile` to deliver it._";
|
|
98
|
+
|
|
99
|
+
return { leaked: true, redactedText: `${redacted}${note}` };
|
|
100
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigProviderMeta,
|
|
3
|
+
createLogger,
|
|
4
|
+
ensureBaseUrl,
|
|
5
|
+
type McpStatus,
|
|
6
|
+
type McpToolDef,
|
|
7
|
+
} from "@lobu/core";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger("openclaw-session-context");
|
|
10
|
+
|
|
11
|
+
interface ProviderConfig {
|
|
12
|
+
credentialEnvVarName?: string;
|
|
13
|
+
defaultProvider?: string;
|
|
14
|
+
defaultModel?: string;
|
|
15
|
+
cliBackends?: Array<{
|
|
16
|
+
providerId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
command: string;
|
|
19
|
+
args?: string[];
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
modelArg?: string;
|
|
22
|
+
sessionArg?: string;
|
|
23
|
+
}>;
|
|
24
|
+
providerBaseUrlMappings?: Record<string, string>;
|
|
25
|
+
/** Dynamic provider metadata from config-driven providers */
|
|
26
|
+
configProviders?: Record<string, ConfigProviderMeta>;
|
|
27
|
+
/** Credential env var placeholders for proxy mode (e.g. Z_AI_API_KEY → "lobu-proxy") */
|
|
28
|
+
credentialPlaceholders?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SkillContent {
|
|
32
|
+
name: string;
|
|
33
|
+
content: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SessionContextResponse {
|
|
37
|
+
agentInstructions: string;
|
|
38
|
+
platformInstructions: string;
|
|
39
|
+
networkInstructions: string;
|
|
40
|
+
skillsInstructions: string;
|
|
41
|
+
mcpStatus: McpStatus[];
|
|
42
|
+
mcpTools?: Record<string, McpToolDef[]>;
|
|
43
|
+
mcpInstructions?: Record<string, string>;
|
|
44
|
+
mcpContext?: Record<string, string>;
|
|
45
|
+
providerConfig?: ProviderConfig;
|
|
46
|
+
skillsConfig?: SkillContent[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
50
|
+
|
|
51
|
+
const DEFAULT_SESSION_CONTEXT = {
|
|
52
|
+
agentInstructions: "",
|
|
53
|
+
gatewayInstructions: "",
|
|
54
|
+
providerConfig: {} as ProviderConfig,
|
|
55
|
+
skillsConfig: [] as SkillContent[],
|
|
56
|
+
mcpStatus: [] as McpStatus[],
|
|
57
|
+
mcpTools: {} as Record<string, McpToolDef[]>,
|
|
58
|
+
mcpContext: {} as Record<string, string>,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
// Module-level cache for session context
|
|
62
|
+
let cachedResult: {
|
|
63
|
+
agentInstructions: string;
|
|
64
|
+
gatewayInstructions: string;
|
|
65
|
+
providerConfig: ProviderConfig;
|
|
66
|
+
skillsConfig: SkillContent[];
|
|
67
|
+
mcpStatus: McpStatus[];
|
|
68
|
+
mcpTools: Record<string, McpToolDef[]>;
|
|
69
|
+
mcpContext: Record<string, string>;
|
|
70
|
+
mcpExposure: "tools" | "cli";
|
|
71
|
+
cachedAt: number;
|
|
72
|
+
} | null = null;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Invalidate the session context cache.
|
|
76
|
+
* Called by the SSE client when a config_changed event is received.
|
|
77
|
+
*/
|
|
78
|
+
export function invalidateSessionContextCache(): void {
|
|
79
|
+
cachedResult = null;
|
|
80
|
+
logger.info("Session context cache invalidated");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildMcpInstructions(
|
|
84
|
+
mcpStatus: McpStatus[],
|
|
85
|
+
mcpToolIds: Set<string>,
|
|
86
|
+
mcpExposure: "tools" | "cli" = "tools"
|
|
87
|
+
): string {
|
|
88
|
+
if (!mcpStatus || mcpStatus.length === 0) {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const needsAuthentication = mcpStatus.filter(
|
|
93
|
+
(mcp) => mcp.requiresAuth && !mcp.authenticated
|
|
94
|
+
);
|
|
95
|
+
const needsConfiguration = mcpStatus.filter(
|
|
96
|
+
(mcp) => mcp.requiresInput && !mcp.configured
|
|
97
|
+
);
|
|
98
|
+
const undiscoveredMcps = mcpStatus.filter((mcp) => !mcpToolIds.has(mcp.id));
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
needsAuthentication.length === 0 &&
|
|
102
|
+
needsConfiguration.length === 0 &&
|
|
103
|
+
undiscoveredMcps.length === 0
|
|
104
|
+
) {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const lines: string[] = ["## MCP Tools Requiring Setup"];
|
|
109
|
+
|
|
110
|
+
for (const mcp of needsAuthentication) {
|
|
111
|
+
const loginCmd =
|
|
112
|
+
mcpExposure === "cli"
|
|
113
|
+
? `run \`${mcp.id} auth login\` in Bash`
|
|
114
|
+
: `call \`${mcp.id}_login\``;
|
|
115
|
+
const checkCmd =
|
|
116
|
+
mcpExposure === "cli"
|
|
117
|
+
? `run \`${mcp.id} auth check\``
|
|
118
|
+
: `call \`${mcp.id}_login_check\``;
|
|
119
|
+
lines.push(
|
|
120
|
+
`- ⚠️ **${mcp.name}** (id: ${mcp.id}): Authentication is required. To start login, ${loginCmd}. After the user completes login, ${checkCmd}. Newly available MCP tools will refresh on the next message.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const mcp of needsConfiguration) {
|
|
125
|
+
lines.push(
|
|
126
|
+
`- ⚠️ **${mcp.name}** (id: ${mcp.id}): Additional MCP input is required before this server can be used. Tell the user an admin must configure the MCP inputs in settings.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const mcp of undiscoveredMcps) {
|
|
131
|
+
if (mcp.requiresAuth || mcp.requiresInput) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
lines.push(
|
|
135
|
+
`- ⚠️ **${mcp.name}** (id: ${mcp.id}): No tools were discovered for this MCP in the current session. Do not assume a login tool exists unless it is actually registered.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* CLI-mode header introducing the `<server> <tool>` idiom. Appended to gateway
|
|
144
|
+
* instructions when `mcpExposure === "cli"` so the model understands how to
|
|
145
|
+
* invoke MCP tools through bash instead of as first-class function calls.
|
|
146
|
+
*/
|
|
147
|
+
function buildMcpCliInstructions(mcpStatus: McpStatus[]): string {
|
|
148
|
+
if (!mcpStatus || mcpStatus.length === 0) return "";
|
|
149
|
+
const servers = mcpStatus.map((m) => `- \`${m.id}\` — ${m.name}`).join("\n");
|
|
150
|
+
return `## Available MCP CLIs
|
|
151
|
+
|
|
152
|
+
MCP servers are exposed as Bash commands. One command per server. Invoke tools by piping JSON on stdin:
|
|
153
|
+
|
|
154
|
+
\`\`\`bash
|
|
155
|
+
<server> <tool> <<'EOF'
|
|
156
|
+
{ ...json args... }
|
|
157
|
+
EOF
|
|
158
|
+
\`\`\`
|
|
159
|
+
|
|
160
|
+
Discovery:
|
|
161
|
+
- \`<server> --help\` — list a server's tools
|
|
162
|
+
- \`<server> <tool> --schema\` — print the JSON Schema for a tool
|
|
163
|
+
- \`<server> auth login|check|logout\` — manage OAuth where required
|
|
164
|
+
|
|
165
|
+
Servers:
|
|
166
|
+
${servers}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildMcpServerInstructions(
|
|
170
|
+
mcpInstructions: Record<string, string>
|
|
171
|
+
): string {
|
|
172
|
+
const entries = Object.entries(mcpInstructions).filter(([, v]) => v);
|
|
173
|
+
if (entries.length === 0) return "";
|
|
174
|
+
|
|
175
|
+
const lines: string[] = ["## MCP Server Instructions", ""];
|
|
176
|
+
for (const [mcpId, instructions] of entries) {
|
|
177
|
+
lines.push(`### ${mcpId}`, "", instructions, "");
|
|
178
|
+
}
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fetch session context from gateway for OpenClaw worker.
|
|
184
|
+
* Returns gateway instructions and dynamic provider configuration.
|
|
185
|
+
* Caches the result until invalidated by a config_changed SSE event.
|
|
186
|
+
* Skips MCP server config (OpenClaw doesn't use Claude SDK's MCP format).
|
|
187
|
+
*/
|
|
188
|
+
export async function getOpenClawSessionContext(
|
|
189
|
+
opts: { mcpExposure?: "tools" | "cli" } = {}
|
|
190
|
+
): Promise<{
|
|
191
|
+
/**
|
|
192
|
+
* Identity/soul/user instructions for this agent. Returned separately from
|
|
193
|
+
* `gatewayInstructions` so the worker can prepend identity BEFORE the
|
|
194
|
+
* pi-coding-agent base prompt (which would otherwise anchor the model with
|
|
195
|
+
* "You are an expert coding assistant" before the agent's real persona is
|
|
196
|
+
* declared).
|
|
197
|
+
*/
|
|
198
|
+
agentInstructions: string;
|
|
199
|
+
/** Platform / network / skills / MCP setup instructions (no identity). */
|
|
200
|
+
gatewayInstructions: string;
|
|
201
|
+
providerConfig: ProviderConfig;
|
|
202
|
+
skillsConfig: SkillContent[];
|
|
203
|
+
mcpStatus: McpStatus[];
|
|
204
|
+
mcpTools: Record<string, McpToolDef[]>;
|
|
205
|
+
mcpContext: Record<string, string>;
|
|
206
|
+
}> {
|
|
207
|
+
const mcpExposure: "tools" | "cli" = opts.mcpExposure ?? "tools";
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
cachedResult &&
|
|
211
|
+
cachedResult.mcpExposure === mcpExposure &&
|
|
212
|
+
Date.now() - cachedResult.cachedAt < CACHE_TTL_MS
|
|
213
|
+
) {
|
|
214
|
+
logger.debug("Returning cached session context");
|
|
215
|
+
return cachedResult;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const dispatcherUrl = process.env.DISPATCHER_URL;
|
|
219
|
+
const workerToken = process.env.WORKER_TOKEN;
|
|
220
|
+
|
|
221
|
+
if (!dispatcherUrl || !workerToken) {
|
|
222
|
+
logger.warn("Missing dispatcher URL or worker token for session context");
|
|
223
|
+
return { ...DEFAULT_SESSION_CONTEXT };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const url = new URL(
|
|
228
|
+
`${ensureBaseUrl(dispatcherUrl)}/worker/session-context`
|
|
229
|
+
);
|
|
230
|
+
const response = await fetch(url, {
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `Bearer ${workerToken}`,
|
|
233
|
+
},
|
|
234
|
+
// Session context is fetched once per turn; a stalled gateway here would
|
|
235
|
+
// otherwise hang the worker before the agent ever sees the prompt.
|
|
236
|
+
signal: AbortSignal.timeout(30_000),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
logger.warn("Gateway returned non-success status for session context", {
|
|
241
|
+
status: response.status,
|
|
242
|
+
});
|
|
243
|
+
return { ...DEFAULT_SESSION_CONTEXT };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const data = (await response.json()) as SessionContextResponse;
|
|
247
|
+
|
|
248
|
+
logger.info(
|
|
249
|
+
`Received session context: ${data.platformInstructions.length} chars platform instructions, ${data.mcpStatus.length} MCP status entries, provider: ${data.providerConfig?.defaultProvider || "none"}, cliBackends: ${data.providerConfig?.cliBackends?.map((b) => b.name).join(", ") || "none"}`
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const toolMcpIds = new Set(Object.keys(data.mcpTools || {}));
|
|
253
|
+
const mcpSetupInstructions = buildMcpInstructions(
|
|
254
|
+
data.mcpStatus,
|
|
255
|
+
toolMcpIds,
|
|
256
|
+
mcpExposure
|
|
257
|
+
);
|
|
258
|
+
// Include MCP server instructions for all servers (with or without tools).
|
|
259
|
+
// These provide workspace context (available connectors, entity schemas, etc.)
|
|
260
|
+
// that helps the agent use the tools effectively.
|
|
261
|
+
const mcpServerInstructions = buildMcpServerInstructions(
|
|
262
|
+
data.mcpInstructions || {}
|
|
263
|
+
);
|
|
264
|
+
const mcpCliInstructions =
|
|
265
|
+
mcpExposure === "cli" ? buildMcpCliInstructions(data.mcpStatus) : "";
|
|
266
|
+
|
|
267
|
+
// Identity/soul/user instructions are returned separately so the worker
|
|
268
|
+
// can prepend them BEFORE the pi-coding-agent base prompt.
|
|
269
|
+
const agentInstructions = data.agentInstructions || "";
|
|
270
|
+
|
|
271
|
+
const gatewayInstructions = [
|
|
272
|
+
data.platformInstructions,
|
|
273
|
+
data.networkInstructions,
|
|
274
|
+
data.skillsInstructions,
|
|
275
|
+
mcpCliInstructions,
|
|
276
|
+
mcpSetupInstructions,
|
|
277
|
+
mcpServerInstructions,
|
|
278
|
+
]
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.join("\n\n");
|
|
281
|
+
|
|
282
|
+
const mcpTools = data.mcpTools || {};
|
|
283
|
+
|
|
284
|
+
logger.info(
|
|
285
|
+
`Built gateway instructions: agent (${agentInstructions.length} chars, prepended) + platform (${data.platformInstructions.length} chars) + network (${data.networkInstructions.length} chars) + skills (${(data.skillsInstructions || "").length} chars) + MCP setup (${mcpSetupInstructions.length} chars) + MCP server instructions (${mcpServerInstructions.length} chars), mcpTools: ${Object.keys(mcpTools).length} servers`
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const mcpContext = data.mcpContext || {};
|
|
289
|
+
|
|
290
|
+
const result = {
|
|
291
|
+
agentInstructions,
|
|
292
|
+
gatewayInstructions,
|
|
293
|
+
providerConfig: data.providerConfig || {},
|
|
294
|
+
skillsConfig: data.skillsConfig || [],
|
|
295
|
+
mcpStatus: data.mcpStatus || [],
|
|
296
|
+
mcpTools,
|
|
297
|
+
mcpContext,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Don't cache if any authenticated MCP returned no tools — likely a
|
|
301
|
+
// transient upstream failure that should be retried on the next message.
|
|
302
|
+
const hasEmptyAuthenticatedMcp = data.mcpStatus.some(
|
|
303
|
+
(mcp) => mcp.authenticated && !toolMcpIds.has(mcp.id)
|
|
304
|
+
);
|
|
305
|
+
if (!hasEmptyAuthenticatedMcp) {
|
|
306
|
+
cachedResult = { ...result, mcpExposure, cachedAt: Date.now() };
|
|
307
|
+
} else {
|
|
308
|
+
logger.warn(
|
|
309
|
+
"Skipping session context cache — authenticated MCP(s) returned no tools",
|
|
310
|
+
{
|
|
311
|
+
emptyMcps: data.mcpStatus
|
|
312
|
+
.filter((mcp) => mcp.authenticated && !toolMcpIds.has(mcp.id))
|
|
313
|
+
.map((mcp) => mcp.id),
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return result;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
logger.error("Failed to fetch session context from gateway", { error });
|
|
321
|
+
return { ...DEFAULT_SESSION_CONTEXT };
|
|
322
|
+
}
|
|
323
|
+
}
|