@flame0510/project-aether 1.1.13 → 1.1.15
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/app/agents/PageClient.tsx +56 -12
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +17 -10
- package/app/api/agents/image-status/route.ts +92 -85
- package/app/api/agents/route.ts +25 -29
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/route.ts +102 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/skills/route.js +88 -96
- package/app/components/Skeleton.tsx +71 -0
- package/app/crons/PageClient.tsx +32 -7
- package/docs/ARCHITECTURE.md +0 -1
- package/docs/dev/API-REFERENCE.md +4 -4
- package/lib/channelManager.ts +23 -16
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/package.json +1 -1
|
@@ -126,12 +126,14 @@ export async function PUT(request: NextRequest) {
|
|
|
126
126
|
{ timeout: 15000, maxBuffer: 1024 * 1024, input: newConfig },
|
|
127
127
|
);
|
|
128
128
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
// No gateway restart: the Gateway watches openclaw.json and hot-applies this.
|
|
130
|
+
// Confirmed against a live agent by writing the file exactly the way this
|
|
131
|
+
// route does — the container logged:
|
|
132
|
+
// [reload] config change detected; evaluating reload (agents.defaults.model.primary)
|
|
133
|
+
// [reload] config hot reload applied (agents.defaults.model.primary)
|
|
134
|
+
// OpenClaw's docs put `agents` in the no-restart column, and gateway.reload
|
|
135
|
+
// defaults to `hybrid`, which restarts on its own for the sections that do
|
|
136
|
+
// need it. The restart here cost ~7.5s of fully blocked event loop.
|
|
135
137
|
|
|
136
138
|
// Verify
|
|
137
139
|
const verifyRaw = readFileFromContainer(safeContainer, '/root/.openclaw/openclaw.json');
|
package/app/api/gateway/route.ts
CHANGED
|
@@ -2,23 +2,42 @@ import { type NextRequest } from "next/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* Gateway Status API
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Reports the per-agent model settings the Gateway page needs to render its
|
|
6
|
+
* agent cards and pre-fill the "edit default model" form. Only containers with
|
|
7
|
+
* an AGENT_ID label are considered — no hardcoded container names.
|
|
7
8
|
*
|
|
8
9
|
* GET /api/gateway
|
|
10
|
+
*
|
|
11
|
+
* This used to shell out to the OpenClaw CLI inside every container, twice:
|
|
12
|
+
* `openclaw models status --json` (~5.7s) and `openclaw models list --json`
|
|
13
|
+
* (~4.4s), serially, with execSync — so ~10s per agent with the event loop
|
|
14
|
+
* frozen throughout, and roughly 70s on a seven-agent host.
|
|
15
|
+
*
|
|
16
|
+
* Both were avoidable. `models list` produced a model catalogue and alias map
|
|
17
|
+
* that no line of the UI ever read: the page takes its model list from
|
|
18
|
+
* /api/gateway/provider, which reads local files. And `models status` returned,
|
|
19
|
+
* for the two fields actually used, exactly what sits in the agent's own
|
|
20
|
+
* openclaw.json — verified field by field against a live agent. So the CLI was
|
|
21
|
+
* being booted for five seconds to read a file that `cat` returns in 260ms.
|
|
22
|
+
*
|
|
23
|
+
* The response shape is unchanged so the page keeps working untouched; the
|
|
24
|
+
* fields that came only from the discarded CLI output are now reported empty
|
|
25
|
+
* rather than computed at great cost and thrown away.
|
|
9
26
|
*/
|
|
10
27
|
import { NextResponse } from 'next/server';
|
|
11
|
-
import {
|
|
28
|
+
import { execFile } from 'child_process';
|
|
29
|
+
import { promisify } from 'util';
|
|
12
30
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
31
|
+
import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
13
34
|
|
|
14
35
|
export const dynamic = 'force-dynamic';
|
|
15
36
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
available: boolean;
|
|
21
|
-
}
|
|
37
|
+
/** Agents queried at once. Each is a `docker exec … cat`, cheap but still a process. */
|
|
38
|
+
const AGENT_QUERY_CONCURRENCY = 8;
|
|
39
|
+
|
|
40
|
+
const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
|
|
22
41
|
|
|
23
42
|
interface GatewayProvider {
|
|
24
43
|
provider: string;
|
|
@@ -40,93 +59,78 @@ interface GatewayAgentStatus {
|
|
|
40
59
|
error?: string;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
interface
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/** Run a command inside a Docker container */
|
|
49
|
-
function dockerExec(container: string, cmd: string, timeout = 15000): string {
|
|
50
|
-
try {
|
|
51
|
-
return execSync(
|
|
52
|
-
`docker exec ${container} sh -c ${JSON.stringify(cmd)}`,
|
|
53
|
-
{ timeout, maxBuffer: 1024 * 1024, encoding: 'utf-8' },
|
|
54
|
-
);
|
|
55
|
-
} catch {
|
|
56
|
-
return '';
|
|
57
|
-
}
|
|
62
|
+
interface AgentRef {
|
|
63
|
+
agentId: string;
|
|
64
|
+
containerName: string;
|
|
65
|
+
state: string;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Containers carrying an AGENT_ID label, with the label read straight out of
|
|
70
|
+
* `docker ps` rather than a follow-up `docker inspect` per container.
|
|
71
|
+
*/
|
|
72
|
+
async function getAgents(): Promise<AgentRef[]> {
|
|
62
73
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
const { stdout } = await execFileAsync('docker', [
|
|
75
|
+
'ps', '--filter', 'label=AGENT_ID',
|
|
76
|
+
'--format', '{{.Label "AGENT_ID"}}\t{{.Names}}\t{{.State}}',
|
|
77
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
78
|
+
|
|
79
|
+
return stdout.trim().split('\n')
|
|
80
|
+
.map((line) => {
|
|
81
|
+
const [agentId, containerName, state] = line.split('\t');
|
|
82
|
+
if (!containerName?.trim()) return null;
|
|
83
|
+
return {
|
|
84
|
+
agentId: agentId?.trim() || containerName.trim(),
|
|
85
|
+
containerName: containerName.trim(),
|
|
86
|
+
state: state?.trim() || 'unknown',
|
|
87
|
+
};
|
|
88
|
+
})
|
|
89
|
+
.filter(Boolean) as AgentRef[];
|
|
72
90
|
} catch {
|
|
73
91
|
return [];
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Read an agent's model settings out of its openclaw.json.
|
|
97
|
+
*
|
|
98
|
+
* `agents.defaults.model` is the same value `openclaw models status` reports as
|
|
99
|
+
* `defaultModel`/`fallbacks`. The CLI additionally resolves an `imageModel`
|
|
100
|
+
* default when the config sets none, but the base image defines no such default
|
|
101
|
+
* (verified: `imageModel: null`), and agent creation always writes an explicit
|
|
102
|
+
* model — so there is no case where the file and the CLI would disagree.
|
|
103
|
+
*/
|
|
104
|
+
async function readAgentStatus(agent: AgentRef): Promise<GatewayAgentStatus> {
|
|
105
|
+
const raw = await dockerExecNoFail(
|
|
106
|
+
agent.containerName,
|
|
107
|
+
['cat', AGENT_CONFIG_PATH],
|
|
108
|
+
{ timeoutMs: 8000 },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const base: GatewayAgentStatus = {
|
|
112
|
+
agentId: agent.agentId,
|
|
113
|
+
containerName: agent.containerName,
|
|
114
|
+
state: agent.state,
|
|
115
|
+
defaultModel: '',
|
|
116
|
+
fallbacks: [],
|
|
117
|
+
allowed: [],
|
|
118
|
+
aliases: {},
|
|
119
|
+
providers: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!raw) return { ...base, error: 'config unreadable' };
|
|
97
123
|
|
|
124
|
+
try {
|
|
125
|
+
const config = JSON.parse(raw) as Record<string, any>;
|
|
126
|
+
const model = config?.agents?.defaults?.model ?? {};
|
|
98
127
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
defaultModel: String(data.defaultModel ?? ''),
|
|
103
|
-
fallbacks: (data.fallbacks ?? []).map(String),
|
|
104
|
-
allowed: (data.allowed ?? []).map(String),
|
|
105
|
-
aliases,
|
|
106
|
-
providers,
|
|
128
|
+
...base,
|
|
129
|
+
defaultModel: typeof model.primary === 'string' ? model.primary : '',
|
|
130
|
+
fallbacks: Array.isArray(model.fallbacks) ? model.fallbacks.map(String) : [],
|
|
107
131
|
};
|
|
108
132
|
} catch {
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Get available models from an agent */
|
|
114
|
-
function getAgentModels(container: string): GatewayModel[] {
|
|
115
|
-
const raw = dockerExec(container, 'openclaw models list --json', 8000);
|
|
116
|
-
if (!raw) return [];
|
|
117
|
-
try {
|
|
118
|
-
const parsed = JSON.parse(raw);
|
|
119
|
-
const models: Record<string, unknown>[] = parsed.models ?? parsed ?? [];
|
|
120
|
-
return models
|
|
121
|
-
.filter((m: Record<string, unknown>) => m.available !== false && !m.missing)
|
|
122
|
-
.map((m: Record<string, unknown>) => ({
|
|
123
|
-
key: String(m.key ?? ''),
|
|
124
|
-
provider: String(m.key ?? '').split('/')[0] || 'unknown',
|
|
125
|
-
model: String(m.name ?? String(m.key ?? '').split('/').slice(1).join('/')),
|
|
126
|
-
available: m.available !== false,
|
|
127
|
-
}));
|
|
128
|
-
} catch {
|
|
129
|
-
return [];
|
|
133
|
+
return { ...base, error: 'config unparseable' };
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
|
|
@@ -136,64 +140,24 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
136
140
|
try {
|
|
137
141
|
const timestamp = Date.now();
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const agents = agentsRaw
|
|
142
|
-
.map((a) => getAgentStatus(a))
|
|
143
|
-
.filter(Boolean) as GatewayAgentStatus[];
|
|
144
|
-
|
|
145
|
-
// Aggregate models from all agents (deduplicated by key)
|
|
146
|
-
const modelMap = new Map<string, GatewayModel>();
|
|
147
|
-
for (const agent of agents) {
|
|
148
|
-
for (const m of getAgentModels(agent.containerName)) {
|
|
149
|
-
if (!modelMap.has(m.key)) modelMap.set(m.key, m);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
const allModels = Array.from(modelMap.values());
|
|
153
|
-
const availableModels = allModels.filter((m) => m.available);
|
|
154
|
-
|
|
155
|
-
// Aggregate aliases from all agents
|
|
156
|
-
const combinedAliases: Record<string, string[]> = {};
|
|
157
|
-
for (const agent of agents) {
|
|
158
|
-
for (const [alias, model] of Object.entries(agent.aliases)) {
|
|
159
|
-
if (!combinedAliases[alias]) combinedAliases[alias] = [];
|
|
160
|
-
if (!combinedAliases[alias].includes(model)) {
|
|
161
|
-
combinedAliases[alias].push(model);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
143
|
+
const refs = await getAgents();
|
|
144
|
+
const agents = await mapWithConcurrency(refs, AGENT_QUERY_CONCURRENCY, readAgentStatus);
|
|
165
145
|
|
|
166
146
|
// First agent is the "primary" for core model settings
|
|
167
147
|
const primaryAgent = agents[0] ?? null;
|
|
168
148
|
|
|
169
|
-
// API keys from the first agent's providers
|
|
170
|
-
const apiKeys: ApiKeyStatus[] = [];
|
|
171
|
-
if (primaryAgent) {
|
|
172
|
-
for (const p of primaryAgent.providers) {
|
|
173
|
-
// We can't query apiKey presence from openclaw models status (it's redacted).
|
|
174
|
-
// Mark as configured if the provider appears in the auth list.
|
|
175
|
-
apiKeys.push({
|
|
176
|
-
provider: p.provider,
|
|
177
|
-
configured: p.profiles > 0,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Models by provider
|
|
183
|
-
const byProvider = allModels.reduce<Record<string, number>>((acc, m) => {
|
|
184
|
-
acc[m.provider] = (acc[m.provider] ?? 0) + 1;
|
|
185
|
-
return acc;
|
|
186
|
-
}, {});
|
|
187
|
-
|
|
188
149
|
return NextResponse.json({
|
|
189
150
|
timestamp,
|
|
190
151
|
gateway: 'Rev4a Model Gateway',
|
|
191
152
|
status: 'online',
|
|
153
|
+
// The model catalogue lives in /api/gateway/provider, which reads the
|
|
154
|
+
// bundled config plus user overrides. Recomputing it per agent here cost
|
|
155
|
+
// 4.4s each and was never read.
|
|
192
156
|
models: {
|
|
193
|
-
total:
|
|
194
|
-
available:
|
|
195
|
-
byProvider:
|
|
196
|
-
list:
|
|
157
|
+
total: 0,
|
|
158
|
+
available: 0,
|
|
159
|
+
byProvider: [],
|
|
160
|
+
list: [],
|
|
197
161
|
},
|
|
198
162
|
agents: {
|
|
199
163
|
total: agents.length,
|
|
@@ -207,16 +171,18 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
207
171
|
providers: a.providers,
|
|
208
172
|
})),
|
|
209
173
|
},
|
|
210
|
-
aliases:
|
|
174
|
+
aliases: {},
|
|
211
175
|
coreModel: primaryAgent
|
|
212
176
|
? {
|
|
213
177
|
defaultModel: primaryAgent.defaultModel,
|
|
214
178
|
fallbacks: primaryAgent.fallbacks,
|
|
215
179
|
}
|
|
216
180
|
: null,
|
|
181
|
+
// Provider credential state comes from /api/gateway/provider (`configured`
|
|
182
|
+
// per provider), which reads provider-keys.json directly.
|
|
217
183
|
apiKeys: {
|
|
218
|
-
configured:
|
|
219
|
-
all:
|
|
184
|
+
configured: [],
|
|
185
|
+
all: [],
|
|
220
186
|
},
|
|
221
187
|
});
|
|
222
188
|
} catch (e: unknown) {
|
package/app/api/gateway/sync.ts
CHANGED
|
@@ -79,16 +79,6 @@ function writeContainerJson(container: string, remotePath: string, data: Record<
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function restartContainerGateway(container: string): string | null {
|
|
83
|
-
try {
|
|
84
|
-
execSync(`docker exec ${container} openclaw gateway restart`, { timeout: 15000, maxBuffer: 64 * 1024 });
|
|
85
|
-
return null;
|
|
86
|
-
} catch (e: unknown) {
|
|
87
|
-
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
88
|
-
return err.stderr?.toString().trim() || err.message || 'unknown error';
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
82
|
/* ------------------------------------------------------------------ */
|
|
93
83
|
/* Sync functions */
|
|
94
84
|
/* ------------------------------------------------------------------ */
|
|
@@ -181,9 +171,16 @@ function syncContainer(
|
|
|
181
171
|
|
|
182
172
|
writeContainerJson(container, remotePath, config);
|
|
183
173
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
174
|
+
// No gateway restart: this only writes models.providers.rev4a, and the Gateway
|
|
175
|
+
// watches openclaw.json and hot-applies it. OpenClaw's own config tooling says
|
|
176
|
+
// so out loud — "Change will apply without restarting the gateway" — and its
|
|
177
|
+
// docs put `models` in the no-restart column, with `gateway.reload` defaulting
|
|
178
|
+
// to `hybrid`, which additionally restarts by itself for the changes that do
|
|
179
|
+
// need it. Verified against a live agent: every agent here leaves
|
|
180
|
+
// `gateway.reload` unset, so they all run that default.
|
|
181
|
+
//
|
|
182
|
+
// The restart used to cost ~7.5s per container, serially — roughly 52s of
|
|
183
|
+
// fully blocked event loop on a seven-agent host, every time a provider key or
|
|
184
|
+
// model toggle was saved, to do something the Gateway had already done.
|
|
185
|
+
return `${container}: synced`;
|
|
189
186
|
}
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Used by both /api/provider/v1/chat/completions and /api/assistant
|
|
6
6
|
* to avoid HTTP loopback (calling localhost from inside Next.js).
|
|
7
7
|
*/
|
|
8
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
9
|
-
|
|
10
8
|
export const dynamic = 'force-dynamic';
|
|
11
9
|
|
|
12
10
|
export interface ProviderEnvConfig {
|
|
@@ -127,7 +125,18 @@ export async function callUpstream(
|
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
if (provider === 'openrouter') {
|
|
130
|
-
|
|
128
|
+
// Attribution only: OpenRouter uses this pair to identify the app on its
|
|
129
|
+
// public leaderboard. It never resolves or contacts the URL, and it has no
|
|
130
|
+
// effect on routing, rate limits, pricing, or whether the call succeeds.
|
|
131
|
+
//
|
|
132
|
+
// This used to be `http://${detectPublicHost()}:3740`, which shelled out to
|
|
133
|
+
// curl synchronously — blocking the whole event loop for ~270ms before every
|
|
134
|
+
// OpenRouter call, and 55 of the 69 models enabled in the catalogue are
|
|
135
|
+
// OpenRouter ones. It also fragmented the attribution it was supposed to
|
|
136
|
+
// provide: every install reported a different host, so each one showed up as
|
|
137
|
+
// a separate app. A per-install address was never the right value here —
|
|
138
|
+
// X-Title, the other half of the same label, was always a constant.
|
|
139
|
+
headers['HTTP-Referer'] = 'https://rev4a.app';
|
|
131
140
|
headers['X-Title'] = 'Rev4a Gateway';
|
|
132
141
|
}
|
|
133
142
|
|
package/app/api/skills/route.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
5
6
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
7
|
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
8
|
+
import { dockerExecShellNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
7
11
|
|
|
8
12
|
const CONTAINER_BUNDLED_SKILLS_DIR = '/usr/local/lib/node_modules/openclaw/skills';
|
|
13
|
+
const WORKSPACE_SKILLS_DIR = '/root/.openclaw/workspace/skills';
|
|
14
|
+
|
|
15
|
+
/** Agents read at once — each is a single bulk `docker exec`. */
|
|
16
|
+
const AGENT_QUERY_CONCURRENCY = 6;
|
|
9
17
|
|
|
10
18
|
function readSkillMeta(skillDir, name, type, agentId) {
|
|
11
19
|
const skillMdPath = path.join(skillDir, name, 'SKILL.md');
|
|
@@ -41,22 +49,19 @@ function readSkillMeta(skillDir, name, type, agentId) {
|
|
|
41
49
|
* Discover agent containers with AGENT_ID label.
|
|
42
50
|
* Returns [{ containerName, agentId }]
|
|
43
51
|
*/
|
|
44
|
-
function discoverAgentContainers() {
|
|
52
|
+
async function discoverAgentContainers() {
|
|
45
53
|
try {
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
|
|
58
|
-
], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
59
|
-
return { containerName, agentId: agentId || containerName };
|
|
54
|
+
// `docker ps` prints labels itself, so the AGENT_ID comes back with the name.
|
|
55
|
+
// This used to follow up with a `docker inspect` per container to read that
|
|
56
|
+
// same label — N+1 process spawns for data the first call already had.
|
|
57
|
+
const { stdout } = await execFileAsync('docker', [
|
|
58
|
+
'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}'
|
|
59
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
60
|
+
|
|
61
|
+
return stdout.trim().split('\n').map(line => {
|
|
62
|
+
const [agentId, containerName] = line.split('\t');
|
|
63
|
+
if (!containerName?.trim()) return null;
|
|
64
|
+
return { containerName: containerName.trim(), agentId: agentId?.trim() || containerName.trim() };
|
|
60
65
|
}).filter(Boolean);
|
|
61
66
|
} catch {
|
|
62
67
|
return [];
|
|
@@ -64,42 +69,44 @@ function discoverAgentContainers() {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/**
|
|
67
|
-
* Read
|
|
68
|
-
*
|
|
72
|
+
* Read every SKILL.md under `dir` in one shot.
|
|
73
|
+
*
|
|
74
|
+
* The previous approach ran `docker exec … cat` once per skill. With 52 bundled
|
|
75
|
+
* skills in the base image that was 52 sequential process spawns at ~260ms each
|
|
76
|
+
* — roughly 13s of blocked event loop to read 52 text files sitting in the same
|
|
77
|
+
* container.
|
|
78
|
+
*
|
|
79
|
+
* The shell loop emits `path\0content\0` pairs. NUL is the delimiter precisely
|
|
80
|
+
* because it cannot occur inside the text files being read, so no SKILL.md can
|
|
81
|
+
* forge a record boundary — a risk any printable separator would carry.
|
|
82
|
+
*
|
|
83
|
+
* Returns a Map of skill name → SKILL.md contents.
|
|
69
84
|
*/
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*/
|
|
94
|
-
function readContainerSkillMd(containerName, skillName) {
|
|
95
|
-
const filePath = `/root/.openclaw/workspace/skills/${skillName}/SKILL.md`;
|
|
96
|
-
try {
|
|
97
|
-
return execFileSync('docker', ['exec', containerName, 'cat', filePath], {
|
|
98
|
-
encoding: 'utf8', timeout: 5000
|
|
99
|
-
});
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
85
|
+
async function readSkillsBulk(containerName, dir) {
|
|
86
|
+
const script =
|
|
87
|
+
`for d in ${dir}/*/; do ` +
|
|
88
|
+
`[ -f "$d/SKILL.md" ] || continue; ` +
|
|
89
|
+
`n=$(basename "$d"); ` +
|
|
90
|
+
`printf '%s\\0' "$n"; ` +
|
|
91
|
+
`cat "$d/SKILL.md"; ` +
|
|
92
|
+
`printf '\\0'; ` +
|
|
93
|
+
`done`;
|
|
94
|
+
|
|
95
|
+
const raw = await dockerExecShellNoFail(containerName, script, {
|
|
96
|
+
timeoutMs: 15000,
|
|
97
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const out = new Map();
|
|
101
|
+
if (!raw) return out;
|
|
102
|
+
|
|
103
|
+
const parts = raw.split('\0');
|
|
104
|
+
// Pairs of name, content — a trailing empty element from the final NUL is fine.
|
|
105
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
106
|
+
const name = parts[i].trim();
|
|
107
|
+
if (name) out.set(name, parts[i + 1]);
|
|
102
108
|
}
|
|
109
|
+
return out;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
/**
|
|
@@ -136,66 +143,51 @@ export async function GET(request) {
|
|
|
136
143
|
}
|
|
137
144
|
} catch {}
|
|
138
145
|
|
|
139
|
-
// 2. Agent container skills (workspace-local)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
146
|
+
// 2. Agent container skills (workspace-local) — one bulk read per agent,
|
|
147
|
+
// agents queried concurrently.
|
|
148
|
+
const agents = await discoverAgentContainers();
|
|
149
|
+
const perAgent = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, (agent) =>
|
|
150
|
+
readSkillsBulk(agent.containerName, WORKSPACE_SKILLS_DIR),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const [i, found] of perAgent.entries()) {
|
|
154
|
+
const agent = agents[i];
|
|
155
|
+
for (const [name, content] of found) {
|
|
156
|
+
const { description, version } = parseSkillContent(content);
|
|
157
|
+
skills.push({
|
|
158
|
+
name,
|
|
159
|
+
type: 'workspace',
|
|
160
|
+
agentId: agent.agentId,
|
|
161
|
+
containerName: agent.containerName,
|
|
162
|
+
path: `${WORKSPACE_SKILLS_DIR}/${name}`,
|
|
163
|
+
skillMdPath: `${WORKSPACE_SKILLS_DIR}/${name}/SKILL.md`,
|
|
164
|
+
hasSkillMd: true,
|
|
165
|
+
description,
|
|
166
|
+
version,
|
|
167
|
+
});
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
// 3. Bundled skills (read-only) —
|
|
171
|
+
// 3. Bundled skills (read-only) — identical in every agent, so the first
|
|
172
|
+
// container that answers is enough. One bulk read instead of 52 `cat`s.
|
|
163
173
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (agents.length > 0) {
|
|
168
|
-
for (const agent of agents) {
|
|
169
|
-
try {
|
|
170
|
-
const raw = execFileSync('docker', [
|
|
171
|
-
'exec', agent.containerName, 'ls', '-1', CONTAINER_BUNDLED_SKILLS_DIR
|
|
172
|
-
], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
173
|
-
if (raw) {
|
|
174
|
-
bundledDirNames = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
175
|
-
bundledSourceContainer = agent.containerName;
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
} catch { /* try next container */ }
|
|
179
|
-
}
|
|
180
|
-
}
|
|
174
|
+
for (const agent of agents) {
|
|
175
|
+
const found = await readSkillsBulk(agent.containerName, CONTAINER_BUNDLED_SKILLS_DIR);
|
|
176
|
+
if (found.size === 0) continue; // container not ready — try the next one
|
|
181
177
|
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const skillMdPath = `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`;
|
|
185
|
-
const content = execFileSync('docker', [
|
|
186
|
-
'exec', bundledSourceContainer, 'cat', skillMdPath
|
|
187
|
-
], { encoding: 'utf8', timeout: 5000 });
|
|
178
|
+
for (const [name, content] of found) {
|
|
188
179
|
const { description, version } = parseSkillContent(content);
|
|
189
180
|
skills.push({
|
|
190
181
|
name,
|
|
191
182
|
type: 'bundled',
|
|
192
183
|
path: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}`,
|
|
193
|
-
skillMdPath
|
|
184
|
+
skillMdPath: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`,
|
|
194
185
|
hasSkillMd: true,
|
|
195
186
|
description,
|
|
196
187
|
version,
|
|
197
188
|
});
|
|
198
|
-
}
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
199
191
|
}
|
|
200
192
|
} catch {}
|
|
201
193
|
|