-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathagentDiscovery.ts
More file actions
332 lines (286 loc) · 12.8 KB
/
Copy pathagentDiscovery.ts
File metadata and controls
332 lines (286 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
/**
* @file agentDiscovery.ts
* @description Agent discovery, configuration I/O, and sync logic.
*
* Agents are discovered and managed directly from `openclaw.json`
* per https://docs.openclaw.ai/concepts/multi-agent.
*
* On each sync agents are discovered, newly added agents receive a
* file-system watcher, and removed agents have theirs torn down.
* Callers receive change notifications via callbacks instead of a
* direct dependency on the watcher or WebSocket modules.
*/
import path from 'path';
import fs from 'fs';
import os from 'os';
import { AgentInfo, AgentSummary } from './types.js';
import { OPENCLAW_CONFIG, ctrlnodePath, PROVIDERS } from './config.js';
import { logger } from './logger.js';
import { ensureDir } from './fileSystem.js';
// ── Runtime state ─────────────────────────────────────────────────────────────
/** Map of agentId → AgentInfo for every currently known agent. */
export let discoveredAgents: Record<string, AgentInfo> = {};
/** Map of agentId → "idle" | "running" tracking filesystem activity. */
export const agentStatuses: Record<string, string> = {};
/**
* Agent IDs that have been explicitly purged/deleted this session.
* Prevents re-discovery from SDK list() calls from adding them back.
*/
export const purgedAgentIds: Set<string> = new Set();
/**
* Canonicalizes a user-provided agent ID for stable storage and lookups.
*/
export function normalizeAgentId(agentId: string | undefined | null): string {
return (agentId ?? '').trim().toLowerCase();
}
// ── Config I/O ────────────────────────────────────────────────────────────────
/**
* Reads and parses the main OpenClaw configuration file (openclaw.json).
*
* @returns Parsed config object, or null if the file is missing or invalid JSON.
*/
export function readOpenClawConfig(): any {
if (!PROVIDERS.includes('openclaw')) return null;
try {
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
} catch (err: any) {
console.error(`[BRIDGE] Cannot read ${OPENCLAW_CONFIG}: ${err.message}`);
return null;
}
}
/**
* Writes the given config object to openclaw.json.
* All agent updates go directly into the main OpenClaw configuration.
*
* @param config - The full config object to serialise as JSON.
*/
export function writeOpenClawConfig(config: any): void {
try {
ensureDir(path.dirname(OPENCLAW_CONFIG));
fs.writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2), 'utf8');
logger.debug('openclaw_config_written', {
path: OPENCLAW_CONFIG,
agentsCount: Array.isArray(config?.agents?.list) ? config.agents.list.length : 0,
});
} catch (err: any) {
logger.error('openclaw_config_write_failed', { path: OPENCLAW_CONFIG, error: err?.message });
console.error(`[BRIDGE] Failed to write ${OPENCLAW_CONFIG}: ${err.message}`);
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/**
* Converts a raw config object (from openclaw.json or agents-config.json)
* into a normalised agents map.
*
* Workspace paths that are relative are resolved against the directory
* that contains openclaw.json. Entries without an `id` field are ignored.
*
* @param config - Parsed JSON config object with an `agents.list` array.
* @returns Map of agentId → {@link AgentInfo}.
*/
function discoverAgents(config: any): Record<string, AgentInfo> {
if (!config?.agents?.list) return {};
const result: Record<string, AgentInfo> = {};
for (const a of config.agents.list.filter((entry: any) => !!entry.id)) {
const normalizedId = normalizeAgentId(a.id);
if (!normalizedId || normalizedId === 'main') continue;
const rawWorkspace = a.workspace || config?.agents?.defaults?.workspace || '/root/.openclaw/workspace';
// Resolve relative workspace paths against the directory that contains openclaw.json.
let resolvedWorkspace: string;
if (path.isAbsolute(rawWorkspace)) {
resolvedWorkspace = rawWorkspace;
} else {
const configDir = path.dirname(OPENCLAW_CONFIG);
const configDirBasename = path.basename(configDir); // e.g. ".openclaw"
// If the relative path starts with the config dir basename (like ".openclaw/"),
// strip it to prevent double-nesting when we resolve against configDir.
let stripped = rawWorkspace;
if (configDirBasename && rawWorkspace.startsWith(configDirBasename + '/')) {
stripped = rawWorkspace.slice(configDirBasename.length + 1);
} else if (configDirBasename && rawWorkspace.startsWith('./' + configDirBasename + '/')) {
stripped = rawWorkspace.slice(configDirBasename.length + 3);
}
resolvedWorkspace = path.resolve(configDir, stripped);
}
const workspace = resolvedWorkspace;
const info: AgentInfo = { workspace, name: a.name || normalizedId, model: a.model || 'default' };
if (a.role) info.role = a.role;
if (a.emoji) info.emoji = a.emoji;
if (a.description) info.description = a.description;
result[normalizedId] = info;
}
return result;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Builds the array of agent summaries that is included in handshake and
* agent_update messages sent to the SaaS.
*
* @returns Array of {@link AgentSummary} objects, one per discovered agent.
*/
export function buildAgentSummaries(): AgentSummary[] {
return Object.entries(discoveredAgents).map(([id, info]) => ({
id,
...info,
provider: info.provider || 'openclaw',
exists: fs.existsSync(info.workspace),
hostname: os.hostname(),
}));
}
/**
* Returns true if the given agent's workspace resides inside the CtrlNode.ai
* ctrlnode directory (i.e. it is a CtrlNode.ai-managed agent).
*
* @param agentId - The agent ID to check.
* @returns True when the agent workspace is under the ctrlnode path.
*/
export function isAgentInCtrlnode(agentId: string): boolean {
const info = discoveredAgents[normalizeAgentId(agentId)];
if (!info) return false;
return path.resolve(info.workspace).startsWith(path.resolve(ctrlnodePath));
}
// ── Sync callbacks ────────────────────────────────────────────────────────────
/**
* Callbacks passed to {@link syncAgentDiscovery} so the discovery module
* does not directly depend on the watcher or WebSocket modules.
*/
export type SyncCallbacks = {
/** Called when a new agent ID is found and a watcher should be started. */
onAgentAdded: (id: string, info: AgentInfo) => void;
/** Called when an agent ID disappears and its watcher should be stopped. */
onAgentRemoved: (id: string) => void;
/** Called after all additions/removals so the caller can push an update to the SaaS. */
onChanged: () => void;
};
/**
* Reads openclaw.json and compares the discovered agents against
* the currently known agent list.
*
* - New agents trigger `onAgentAdded` and default to "idle" status.
* - Removed agents trigger `onAgentRemoved` and have their status deleted.
* - When any change occurred, `onChanged` is called once at the end.
*
* Safe to call repeatedly (on startup, periodic poll, and after config writes).
*
* @param callbacks - {@link SyncCallbacks} to notify about individual changes.
*/
export function syncAgentDiscovery(callbacks: SyncCallbacks): void {
const merged: Record<string, AgentInfo> = discoverAgents(readOpenClawConfig());
let changed = false;
for (const [id, info] of Object.entries(merged)) {
if (!discoveredAgents[id]) {
logger.info('agent_discovered', { agentId: id, workspace: info.workspace });
agentStatuses[id] = 'idle';
callbacks.onAgentAdded(id, info);
changed = true;
}
}
// Only remove agents that belong to OpenClaw (no provider field or provider === 'openclaw').
// SDK agents (cursor, claude, copilot, gemini, codex) are managed by their own
// sync handlers and must not be evicted by an OpenClaw config re-read.
for (const [id, info] of Object.entries(discoveredAgents)) {
const isOpenClawAgent = !info.provider || info.provider === 'openclaw';
if (isOpenClawAgent && !merged[id]) {
logger.debug('agent_removed', { agentId: id });
callbacks.onAgentRemoved(id);
delete agentStatuses[id];
delete discoveredAgents[id];
changed = true;
}
}
// Merge openclaw agents in-place so SDK agents already in discoveredAgents are preserved.
// Do NOT use Object.assign — it would overwrite entries that have a non-openclaw provider
// (e.g. an agent that is both in openclaw.json and registered as a Gemini/Claude/Cursor agent
// via sync_gemini_agents). Those entries must keep their provider field so MultiProvider
// can route dispatches to the correct sub-provider instead of falling back to OpenClaw.
for (const [id, info] of Object.entries(merged)) {
const existing = discoveredAgents[id];
if (existing?.provider && existing.provider !== 'openclaw') {
// SDK-provider agent — keep the richer registration from sync_*_agents.
continue;
}
discoveredAgents[id] = info;
}
if (changed) callbacks.onChanged();
}
// ── Agent config mutations ────────────────────────────────────────────────────
/**
* Creates or updates an agent entry in openclaw.json under agents.list[].
* Only the provided fields are written; existing fields are preserved.
*
* Per https://docs.openclaw.ai/concepts/multi-agent, all agent configuration
* lives in openclaw.json, including model, workspace, and other metadata.
*
* @param agentId - The agent ID to create or update.
* @param fields - Partial agent fields to merge into the stored entry.
*/
export function upsertAgentConfig(
agentId: string,
fields: { name?: string; model?: string; workspace?: string }
): void {
const normalizedId = normalizeAgentId(agentId);
if (!normalizedId) return;
const config = readOpenClawConfig() ?? { agents: { list: [] } };
config.agents ??= { list: [] };
config.agents.list ??= [];
for (const a of config.agents.list) {
if (a?.id) a.id = normalizeAgentId(a.id);
}
const existing = config.agents.list.find((a: any) => a.id === normalizedId);
const operation = existing ? 'update' : 'create';
logger.debug('agent_metadata_update_attempt', {
agentId: normalizedId,
operation,
configPath: OPENCLAW_CONFIG,
hasName: fields.name !== undefined,
hasModel: fields.model !== undefined,
hasWorkspace: fields.workspace !== undefined,
});
if (existing) {
Object.assign(existing, fields);
existing.sandbox = existing.sandbox || { mode: "off" };
} else {
config.agents.list.push({ id: normalizedId, sandbox: { mode: "off" }, ...fields });
}
writeOpenClawConfig(config);
const persisted = readOpenClawConfig();
const persistedAgent = persisted?.agents?.list?.find((a: any) => normalizeAgentId(a?.id) === normalizedId);
if (persistedAgent) {
logger.debug('agent_metadata_persisted', {
agentId: normalizedId,
operation,
configPath: OPENCLAW_CONFIG,
agentsCount: Array.isArray(persisted?.agents?.list) ? persisted.agents.list.length : null,
workspace: persistedAgent.workspace ?? null,
});
} else {
logger.error('agent_metadata_persist_failed', {
agentId: normalizedId,
operation,
configPath: OPENCLAW_CONFIG,
});
}
logger.debug('agent_metadata_updated', { agentId: normalizedId });
}
/**
* Removes an agent entry from openclaw.json's agents.list[].
* Does nothing and returns false if the agent was not found in the file.
*
* @param agentId - The agent ID to remove.
* @returns True if the entry was found and deleted; false otherwise.
*/
export function deleteAgentConfig(agentId: string): boolean {
const normalizedId = normalizeAgentId(agentId);
if (!normalizedId) return false;
const config = readOpenClawConfig();
if (!config?.agents?.list) return false;
for (const a of config.agents.list) {
if (a?.id) a.id = normalizeAgentId(a.id);
}
const before = config.agents.list.length;
config.agents.list = config.agents.list.filter((a: any) => a.id !== normalizedId);
if (config.agents.list.length === before) return false;
writeOpenClawConfig(config);
logger.info('agent_deleted', { agentId: normalizedId });
return true;
}