agent-control-openclaw-plugin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +69 -0
- package/index.ts +1 -0
- package/openclaw.plugin.json +61 -0
- package/package.json +48 -0
- package/src/agent-control-plugin.ts +333 -0
- package/src/openclaw-runtime.ts +157 -0
- package/src/session-context.ts +135 -0
- package/src/session-store.ts +162 -0
- package/src/shared.ts +90 -0
- package/src/tool-catalog.ts +326 -0
- package/src/types.ts +98 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { AgentControlClient } from "agent-control";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { resolveStepsForContext } from "./tool-catalog.ts";
|
|
4
|
+
import { buildEvaluationContext } from "./session-context.ts";
|
|
5
|
+
import {
|
|
6
|
+
asPositiveInt,
|
|
7
|
+
asString,
|
|
8
|
+
BOOT_WARMUP_AGENT_ID,
|
|
9
|
+
formatToolArgsForLog,
|
|
10
|
+
hashSteps,
|
|
11
|
+
isRecord,
|
|
12
|
+
isUuid,
|
|
13
|
+
secondsSince,
|
|
14
|
+
trimToMax,
|
|
15
|
+
USER_BLOCK_MESSAGE,
|
|
16
|
+
} from "./shared.ts";
|
|
17
|
+
import type { AgentControlPluginConfig, AgentState } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
function collectDenyControlNames(response: {
|
|
20
|
+
matches?: Array<{ action?: string; controlName?: string }>;
|
|
21
|
+
errors?: Array<{ action?: string; controlName?: string }>;
|
|
22
|
+
}): string[] {
|
|
23
|
+
const names: string[] = [];
|
|
24
|
+
for (const match of [...(response.matches ?? []), ...(response.errors ?? [])]) {
|
|
25
|
+
if (
|
|
26
|
+
match.action === "deny" &&
|
|
27
|
+
typeof match.controlName === "string" &&
|
|
28
|
+
match.controlName.trim()
|
|
29
|
+
) {
|
|
30
|
+
names.push(match.controlName.trim());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return [...new Set(names)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildBlockReason(response: {
|
|
37
|
+
reason?: string | null;
|
|
38
|
+
matches?: Array<{ action?: string; controlName?: string }>;
|
|
39
|
+
errors?: Array<{ action?: string; controlName?: string }>;
|
|
40
|
+
}): string {
|
|
41
|
+
const denyControls = collectDenyControlNames(response);
|
|
42
|
+
if (denyControls.length > 0) {
|
|
43
|
+
return `[agent-control] blocked by deny control(s): ${denyControls.join(", ")}`;
|
|
44
|
+
}
|
|
45
|
+
if (typeof response.reason === "string" && response.reason.trim().length > 0) {
|
|
46
|
+
return `[agent-control] ${response.reason.trim()}`;
|
|
47
|
+
}
|
|
48
|
+
return "[agent-control] blocked by policy evaluation";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveSourceAgentId(agentId: string | undefined): string {
|
|
52
|
+
const normalized = asString(agentId);
|
|
53
|
+
return normalized ?? "default";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadPluginConfig(api: OpenClawPluginApi): AgentControlPluginConfig {
|
|
57
|
+
const raw = isRecord(api.pluginConfig) ? api.pluginConfig : {};
|
|
58
|
+
return raw as unknown as AgentControlPluginConfig;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function register(api: OpenClawPluginApi) {
|
|
62
|
+
const cfg = loadPluginConfig(api);
|
|
63
|
+
if (cfg.enabled === false) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const serverUrl = asString(cfg.serverUrl) ?? asString(process.env.AGENT_CONTROL_SERVER_URL);
|
|
68
|
+
if (!serverUrl) {
|
|
69
|
+
api.logger.warn(
|
|
70
|
+
"agent-control: disabled because serverUrl is not configured (plugins.entries.agent-control.serverUrl)",
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const configuredAgentId = asString(cfg.agentId);
|
|
76
|
+
if (configuredAgentId && !isUuid(configuredAgentId)) {
|
|
77
|
+
api.logger.warn(`agent-control: configured agentId is not a UUID: ${configuredAgentId}`);
|
|
78
|
+
}
|
|
79
|
+
const hasConfiguredAgentId = configuredAgentId ? isUuid(configuredAgentId) : false;
|
|
80
|
+
|
|
81
|
+
const failClosed = cfg.failClosed === true;
|
|
82
|
+
const baseAgentName = asString(cfg.agentName) ?? "openclaw-agent";
|
|
83
|
+
const configuredAgentVersion = asString(cfg.agentVersion);
|
|
84
|
+
const pluginVersion = asString(api.version);
|
|
85
|
+
const clientTimeoutMs = asPositiveInt(cfg.timeoutMs);
|
|
86
|
+
|
|
87
|
+
const clientInitStartedAt = process.hrtime.bigint();
|
|
88
|
+
const client = new AgentControlClient();
|
|
89
|
+
client.init({
|
|
90
|
+
agentName: baseAgentName,
|
|
91
|
+
serverUrl,
|
|
92
|
+
apiKey: asString(cfg.apiKey) ?? asString(process.env.AGENT_CONTROL_API_KEY),
|
|
93
|
+
timeoutMs: clientTimeoutMs,
|
|
94
|
+
userAgent: asString(cfg.userAgent) ?? "openclaw-agent-control-plugin/0.1",
|
|
95
|
+
});
|
|
96
|
+
api.logger.info(
|
|
97
|
+
`agent-control: client_init duration_sec=${secondsSince(clientInitStartedAt)} timeout_ms=${clientTimeoutMs ?? "default"} server_url=${serverUrl}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const states = new Map<string, AgentState>();
|
|
101
|
+
let gatewayWarmupPromise: Promise<void> | null = null;
|
|
102
|
+
let gatewayWarmupStatus: "idle" | "running" | "done" | "failed" = "idle";
|
|
103
|
+
|
|
104
|
+
const getOrCreateState = (sourceAgentId: string): AgentState => {
|
|
105
|
+
const existing = states.get(sourceAgentId);
|
|
106
|
+
if (existing) {
|
|
107
|
+
return existing;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const agentName = hasConfiguredAgentId
|
|
111
|
+
? trimToMax(baseAgentName, 255)
|
|
112
|
+
: trimToMax(`${baseAgentName}:${sourceAgentId}`, 255);
|
|
113
|
+
|
|
114
|
+
const created: AgentState = {
|
|
115
|
+
sourceAgentId,
|
|
116
|
+
agentName,
|
|
117
|
+
steps: [],
|
|
118
|
+
stepsHash: hashSteps([]),
|
|
119
|
+
lastSyncedStepsHash: null,
|
|
120
|
+
syncPromise: null,
|
|
121
|
+
};
|
|
122
|
+
states.set(sourceAgentId, created);
|
|
123
|
+
return created;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const ensureGatewayWarmup = (): Promise<void> => {
|
|
127
|
+
if (gatewayWarmupPromise) {
|
|
128
|
+
return gatewayWarmupPromise;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const warmupStartedAt = process.hrtime.bigint();
|
|
132
|
+
gatewayWarmupStatus = "running";
|
|
133
|
+
api.logger.info(`agent-control: gateway_boot_warmup started agent=${BOOT_WARMUP_AGENT_ID}`);
|
|
134
|
+
|
|
135
|
+
// Warm the exact resolver path used during tool evaluation so the gateway
|
|
136
|
+
// process retains the expensive module graph in memory after startup.
|
|
137
|
+
gatewayWarmupPromise = resolveStepsForContext({
|
|
138
|
+
api,
|
|
139
|
+
sourceAgentId: BOOT_WARMUP_AGENT_ID,
|
|
140
|
+
})
|
|
141
|
+
.then((steps) => {
|
|
142
|
+
gatewayWarmupStatus = "done";
|
|
143
|
+
api.logger.info(
|
|
144
|
+
`agent-control: gateway_boot_warmup done duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} steps=${steps.length}`,
|
|
145
|
+
);
|
|
146
|
+
})
|
|
147
|
+
.catch((err) => {
|
|
148
|
+
gatewayWarmupStatus = "failed";
|
|
149
|
+
api.logger.warn(
|
|
150
|
+
`agent-control: gateway_boot_warmup failed duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} error=${String(err)}`,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return gatewayWarmupPromise;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const syncAgent = async (state: AgentState): Promise<void> => {
|
|
158
|
+
if (state.syncPromise) {
|
|
159
|
+
await state.syncPromise;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (state.lastSyncedStepsHash === state.stepsHash) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const currentHash = state.stepsHash;
|
|
167
|
+
const promise = (async () => {
|
|
168
|
+
await client.agents.init({
|
|
169
|
+
agent: {
|
|
170
|
+
agentName: state.agentName,
|
|
171
|
+
agentVersion: configuredAgentVersion,
|
|
172
|
+
agentMetadata: {
|
|
173
|
+
source: "openclaw",
|
|
174
|
+
openclawAgentId: state.sourceAgentId,
|
|
175
|
+
...(configuredAgentId ? { openclawConfiguredAgentId: configuredAgentId } : {}),
|
|
176
|
+
pluginId: api.id,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
steps: state.steps,
|
|
180
|
+
});
|
|
181
|
+
state.lastSyncedStepsHash = currentHash;
|
|
182
|
+
})().finally(() => {
|
|
183
|
+
state.syncPromise = null;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
state.syncPromise = promise;
|
|
187
|
+
await promise;
|
|
188
|
+
|
|
189
|
+
// If tools changed while we were syncing, reconcile immediately.
|
|
190
|
+
if (state.stepsHash !== state.lastSyncedStepsHash) {
|
|
191
|
+
await syncAgent(state);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
api.on("gateway_start", async () => {
|
|
196
|
+
await ensureGatewayWarmup();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
200
|
+
const beforeToolCallStartedAt = process.hrtime.bigint();
|
|
201
|
+
const sourceAgentId = resolveSourceAgentId(ctx.agentId);
|
|
202
|
+
const state = getOrCreateState(sourceAgentId);
|
|
203
|
+
const argsForLog = formatToolArgsForLog(event.params);
|
|
204
|
+
api.logger.info(
|
|
205
|
+
`agent-control: before_tool_call entered agent=${sourceAgentId} tool=${event.toolName} args=${argsForLog}`,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
if (gatewayWarmupStatus === "running" && gatewayWarmupPromise) {
|
|
210
|
+
const warmupWaitStartedAt = process.hrtime.bigint();
|
|
211
|
+
api.logger.info(
|
|
212
|
+
`agent-control: before_tool_call waiting_for_gateway_boot_warmup=true agent=${sourceAgentId} tool=${event.toolName}`,
|
|
213
|
+
);
|
|
214
|
+
await gatewayWarmupPromise;
|
|
215
|
+
api.logger.info(
|
|
216
|
+
`agent-control: before_tool_call phase=wait_boot_warmup duration_sec=${secondsSince(warmupWaitStartedAt)} agent=${sourceAgentId} tool=${event.toolName} warmup_status=${gatewayWarmupStatus}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const resolveStepsStartedAt = process.hrtime.bigint();
|
|
222
|
+
const nextSteps = await resolveStepsForContext({
|
|
223
|
+
api,
|
|
224
|
+
sourceAgentId,
|
|
225
|
+
sessionKey: ctx.sessionKey,
|
|
226
|
+
sessionId: ctx.sessionId,
|
|
227
|
+
runId: ctx.runId,
|
|
228
|
+
});
|
|
229
|
+
const nextStepsHash = hashSteps(nextSteps);
|
|
230
|
+
if (nextStepsHash !== state.stepsHash) {
|
|
231
|
+
state.steps = nextSteps;
|
|
232
|
+
state.stepsHash = nextStepsHash;
|
|
233
|
+
}
|
|
234
|
+
api.logger.info(
|
|
235
|
+
`agent-control: before_tool_call phase=resolve_steps duration_sec=${secondsSince(resolveStepsStartedAt)} agent=${sourceAgentId} tool=${event.toolName} steps=${nextSteps.length}`,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const syncStartedAt = process.hrtime.bigint();
|
|
239
|
+
await syncAgent(state);
|
|
240
|
+
api.logger.info(
|
|
241
|
+
`agent-control: before_tool_call phase=sync_agent duration_sec=${secondsSince(syncStartedAt)} agent=${sourceAgentId} tool=${event.toolName} step_count=${state.steps.length}`,
|
|
242
|
+
);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
api.logger.warn(
|
|
245
|
+
`agent-control: unable to sync agent=${sourceAgentId} before tool evaluation: ${String(err)}`,
|
|
246
|
+
);
|
|
247
|
+
if (failClosed) {
|
|
248
|
+
return {
|
|
249
|
+
block: true,
|
|
250
|
+
blockReason: USER_BLOCK_MESSAGE,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const contextBuildStartedAt = process.hrtime.bigint();
|
|
258
|
+
const context = await buildEvaluationContext({
|
|
259
|
+
api,
|
|
260
|
+
sourceAgentId,
|
|
261
|
+
state,
|
|
262
|
+
event: {
|
|
263
|
+
runId: event.runId,
|
|
264
|
+
toolCallId: event.toolCallId,
|
|
265
|
+
},
|
|
266
|
+
ctx: {
|
|
267
|
+
sessionKey: ctx.sessionKey,
|
|
268
|
+
sessionId: ctx.sessionId,
|
|
269
|
+
runId: ctx.runId,
|
|
270
|
+
toolCallId: ctx.toolCallId,
|
|
271
|
+
},
|
|
272
|
+
pluginVersion,
|
|
273
|
+
failClosed,
|
|
274
|
+
configuredAgentId,
|
|
275
|
+
configuredAgentVersion,
|
|
276
|
+
});
|
|
277
|
+
api.logger.info(
|
|
278
|
+
`agent-control: before_tool_call phase=build_context duration_sec=${secondsSince(contextBuildStartedAt)} agent=${sourceAgentId} tool=${event.toolName}`,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
api.logger.info(
|
|
282
|
+
`agent-control: before_tool_call evaluated agent=${sourceAgentId} tool=${event.toolName} args=${argsForLog} context=${JSON.stringify(context, null, 2)}`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const evaluateStartedAt = process.hrtime.bigint();
|
|
286
|
+
const evaluation = await client.evaluation.evaluate({
|
|
287
|
+
body: {
|
|
288
|
+
agentName: state.agentName,
|
|
289
|
+
stage: "pre",
|
|
290
|
+
step: {
|
|
291
|
+
type: "tool",
|
|
292
|
+
name: event.toolName,
|
|
293
|
+
input: event.params,
|
|
294
|
+
context,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
api.logger.info(
|
|
299
|
+
`agent-control: before_tool_call phase=evaluate duration_sec=${secondsSince(evaluateStartedAt)} agent=${sourceAgentId} tool=${event.toolName} safe=${evaluation.isSafe}`,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (evaluation.isSafe) {
|
|
303
|
+
api.logger.info("safe !");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
api.logger.info("unsafe !");
|
|
308
|
+
api.logger.warn(
|
|
309
|
+
`agent-control: blocked tool=${event.toolName} agent=${sourceAgentId} reason=${buildBlockReason(evaluation)}`,
|
|
310
|
+
);
|
|
311
|
+
return {
|
|
312
|
+
block: true,
|
|
313
|
+
blockReason: USER_BLOCK_MESSAGE,
|
|
314
|
+
};
|
|
315
|
+
} catch (err) {
|
|
316
|
+
api.logger.warn(
|
|
317
|
+
`agent-control: evaluation failed for agent=${sourceAgentId} tool=${event.toolName}: ${String(err)}`,
|
|
318
|
+
);
|
|
319
|
+
if (failClosed) {
|
|
320
|
+
return {
|
|
321
|
+
block: true,
|
|
322
|
+
blockReason: USER_BLOCK_MESSAGE,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
api.logger.info(
|
|
329
|
+
`agent-control: before_tool_call duration_sec=${secondsSince(beforeToolCallStartedAt)} agent=${sourceAgentId} tool=${event.toolName}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
const requireFromPlugin = createRequire(import.meta.url);
|
|
8
|
+
const jiti = createJiti(import.meta.url, {
|
|
9
|
+
interopDefault: true,
|
|
10
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const PLUGIN_ROOT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
let resolvedOpenClawRootDir: string | null = null;
|
|
16
|
+
|
|
17
|
+
function readPackageField(packageJsonPath: string, field: "name" | "version"): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(packageJsonPath, "utf8");
|
|
20
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
21
|
+
if (!parsed || typeof parsed !== "object") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const value = (parsed as { name?: unknown; version?: unknown })[field];
|
|
25
|
+
return typeof value === "string" ? value : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readPackageName(packageJsonPath: string): string | undefined {
|
|
32
|
+
return readPackageField(packageJsonPath, "name");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readPackageVersion(packageJsonPath: string): string | undefined {
|
|
36
|
+
return readPackageField(packageJsonPath, "version");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function safeStatMtimeMs(filePath: string): number | null {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(filePath).mtimeMs;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeRelativeImportPath(fromDir: string, toFile: string): string {
|
|
48
|
+
const relativePath = path.relative(fromDir, toFile).replaceAll(path.sep, "/");
|
|
49
|
+
if (relativePath.startsWith(".")) {
|
|
50
|
+
return relativePath;
|
|
51
|
+
}
|
|
52
|
+
return `./${relativePath}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findOpenClawRootFrom(startPath: string | undefined): string | undefined {
|
|
56
|
+
if (!startPath) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
let cursor = path.resolve(startPath);
|
|
60
|
+
while (true) {
|
|
61
|
+
const packageJson = path.join(cursor, "package.json");
|
|
62
|
+
if (fs.existsSync(packageJson) && readPackageName(packageJson) === "openclaw") {
|
|
63
|
+
return cursor;
|
|
64
|
+
}
|
|
65
|
+
const parent = path.dirname(cursor);
|
|
66
|
+
if (parent === cursor) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
cursor = parent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveOpenClawRootDir(): string {
|
|
74
|
+
try {
|
|
75
|
+
const pkgJson = requireFromPlugin.resolve("openclaw/package.json");
|
|
76
|
+
return path.dirname(pkgJson);
|
|
77
|
+
} catch {
|
|
78
|
+
// Fall through to process-based probing below.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const argvEntry = process.argv[1];
|
|
82
|
+
const argvEntryRealpath = (() => {
|
|
83
|
+
if (!argvEntry) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return fs.realpathSync(argvEntry);
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
const candidates = [
|
|
94
|
+
argvEntry ? findOpenClawRootFrom(path.dirname(argvEntry)) : undefined,
|
|
95
|
+
argvEntryRealpath ? findOpenClawRootFrom(path.dirname(argvEntryRealpath)) : undefined,
|
|
96
|
+
findOpenClawRootFrom(process.cwd()),
|
|
97
|
+
];
|
|
98
|
+
const found = candidates.find((entry): entry is string => typeof entry === "string");
|
|
99
|
+
if (!found) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"agent-control: unable to resolve openclaw package root for internal tool schema access",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getResolvedOpenClawRootDir(): string {
|
|
108
|
+
if (!resolvedOpenClawRootDir) {
|
|
109
|
+
resolvedOpenClawRootDir = resolveOpenClawRootDir();
|
|
110
|
+
}
|
|
111
|
+
return resolvedOpenClawRootDir;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function tryImportOpenClawInternalModule(
|
|
115
|
+
openClawRoot: string,
|
|
116
|
+
candidates: string[],
|
|
117
|
+
): Promise<Record<string, unknown> | null> {
|
|
118
|
+
for (const relativePath of candidates) {
|
|
119
|
+
const absolutePath = path.join(openClawRoot, relativePath);
|
|
120
|
+
if (!fs.existsSync(absolutePath)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return (await import(pathToFileURL(absolutePath).href)) as Record<string, unknown>;
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function importOpenClawInternalModule(
|
|
133
|
+
openClawRoot: string,
|
|
134
|
+
candidates: string[],
|
|
135
|
+
): Promise<Record<string, unknown>> {
|
|
136
|
+
let lastErr: unknown;
|
|
137
|
+
for (const relativePath of candidates) {
|
|
138
|
+
const absolutePath = path.join(openClawRoot, relativePath);
|
|
139
|
+
if (!fs.existsSync(absolutePath)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
if (absolutePath.endsWith(".ts")) {
|
|
144
|
+
return jiti(absolutePath) as Record<string, unknown>;
|
|
145
|
+
}
|
|
146
|
+
return (await import(pathToFileURL(absolutePath).href)) as Record<string, unknown>;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
lastErr = err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw (
|
|
152
|
+
lastErr ??
|
|
153
|
+
new Error(
|
|
154
|
+
`agent-control: openclaw internal module not found (${candidates.join(", ")}) under ${openClawRoot}`,
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { resolveSessionIdentity } from "./session-store.ts";
|
|
3
|
+
import { asString } from "./shared.ts";
|
|
4
|
+
import type { AgentState, ChannelType, DerivedChannelContext } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function parseAgentSessionKey(sessionKey: string | undefined): { agentId: string; scope: string } | null {
|
|
7
|
+
const normalized = asString(sessionKey)?.toLowerCase();
|
|
8
|
+
if (!normalized) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const parts = normalized.split(":").filter(Boolean);
|
|
12
|
+
if (parts.length < 3 || parts[0] !== "agent") {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const agentId = parts[1];
|
|
16
|
+
const scope = parts.slice(2).join(":");
|
|
17
|
+
if (!agentId || !scope) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return { agentId, scope };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function deriveChannelType(scope: string): ChannelType {
|
|
24
|
+
const tokens = new Set(scope.split(":").filter(Boolean));
|
|
25
|
+
if (tokens.has("group")) {
|
|
26
|
+
return "group";
|
|
27
|
+
}
|
|
28
|
+
if (tokens.has("channel")) {
|
|
29
|
+
return "channel";
|
|
30
|
+
}
|
|
31
|
+
if (tokens.has("direct") || tokens.has("dm")) {
|
|
32
|
+
return "direct";
|
|
33
|
+
}
|
|
34
|
+
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scope)) {
|
|
35
|
+
return "channel";
|
|
36
|
+
}
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deriveChannelContext(sessionKey: string | undefined): DerivedChannelContext {
|
|
41
|
+
const parsed = parseAgentSessionKey(sessionKey);
|
|
42
|
+
if (!parsed) {
|
|
43
|
+
return {
|
|
44
|
+
provider: null,
|
|
45
|
+
type: "unknown",
|
|
46
|
+
scope: null,
|
|
47
|
+
source: "unknown",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scopeTokens = parsed.scope.split(":").filter(Boolean);
|
|
52
|
+
const firstToken = scopeTokens[0];
|
|
53
|
+
const provider =
|
|
54
|
+
firstToken &&
|
|
55
|
+
!["main", "subagent", "cron", "acp", "memory", "heartbeat"].includes(firstToken)
|
|
56
|
+
? firstToken
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
provider,
|
|
61
|
+
type: deriveChannelType(parsed.scope),
|
|
62
|
+
scope: parsed.scope,
|
|
63
|
+
source: "sessionKey",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function buildEvaluationContext(params: {
|
|
68
|
+
api: OpenClawPluginApi;
|
|
69
|
+
sourceAgentId: string;
|
|
70
|
+
state: AgentState;
|
|
71
|
+
event: {
|
|
72
|
+
runId?: string;
|
|
73
|
+
toolCallId?: string;
|
|
74
|
+
};
|
|
75
|
+
ctx: {
|
|
76
|
+
sessionKey?: string;
|
|
77
|
+
sessionId?: string;
|
|
78
|
+
runId?: string;
|
|
79
|
+
toolCallId?: string;
|
|
80
|
+
};
|
|
81
|
+
pluginVersion?: string;
|
|
82
|
+
failClosed: boolean;
|
|
83
|
+
configuredAgentId?: string;
|
|
84
|
+
configuredAgentVersion?: string;
|
|
85
|
+
}): Promise<Record<string, unknown>> {
|
|
86
|
+
const channelFromSessionKey = deriveChannelContext(params.ctx.sessionKey);
|
|
87
|
+
const sessionIdentity = await resolveSessionIdentity(params.ctx.sessionKey);
|
|
88
|
+
const mergedChannelType =
|
|
89
|
+
sessionIdentity.type !== "unknown" ? sessionIdentity.type : channelFromSessionKey.type;
|
|
90
|
+
const mergedChannelProvider = sessionIdentity.provider ?? channelFromSessionKey.provider;
|
|
91
|
+
|
|
92
|
+
const channel = {
|
|
93
|
+
provider: mergedChannelProvider,
|
|
94
|
+
type: mergedChannelType,
|
|
95
|
+
scope: channelFromSessionKey.scope,
|
|
96
|
+
source:
|
|
97
|
+
sessionIdentity.source === "sessionStore"
|
|
98
|
+
? "sessionStore+sessionKey"
|
|
99
|
+
: channelFromSessionKey.source,
|
|
100
|
+
name: sessionIdentity.channelName,
|
|
101
|
+
dmUserName: sessionIdentity.dmUserName,
|
|
102
|
+
label: sessionIdentity.label,
|
|
103
|
+
from: sessionIdentity.from,
|
|
104
|
+
to: sessionIdentity.to,
|
|
105
|
+
accountId: sessionIdentity.accountId,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
openclawAgentId: params.sourceAgentId,
|
|
110
|
+
sessionKey: params.ctx.sessionKey ?? null,
|
|
111
|
+
sessionId: params.ctx.sessionId ?? null,
|
|
112
|
+
runId: params.ctx.runId ?? params.event.runId ?? null,
|
|
113
|
+
toolCallId: params.ctx.toolCallId ?? params.event.toolCallId ?? null,
|
|
114
|
+
channelType: mergedChannelType,
|
|
115
|
+
channelName: sessionIdentity.channelName,
|
|
116
|
+
dmUserName: sessionIdentity.dmUserName,
|
|
117
|
+
senderFrom: sessionIdentity.from,
|
|
118
|
+
channel,
|
|
119
|
+
plugin: {
|
|
120
|
+
id: params.api.id,
|
|
121
|
+
version: params.pluginVersion ?? null,
|
|
122
|
+
},
|
|
123
|
+
policy: {
|
|
124
|
+
failClosed: params.failClosed,
|
|
125
|
+
configuredAgentId: params.configuredAgentId ?? null,
|
|
126
|
+
configuredAgentVersion: params.configuredAgentVersion ?? null,
|
|
127
|
+
},
|
|
128
|
+
sync: {
|
|
129
|
+
agentName: params.state.agentName,
|
|
130
|
+
stepCount: params.state.steps.length,
|
|
131
|
+
stepsHash: params.state.stepsHash,
|
|
132
|
+
lastSyncedStepsHash: params.state.lastSyncedStepsHash,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|