@botcord/daemon 0.2.5 → 0.2.6
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/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +72 -1
- package/dist/provision.js +370 -7
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +2 -1
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +132 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +438 -9
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
package/src/provision.ts
CHANGED
|
@@ -14,16 +14,21 @@ import {
|
|
|
14
14
|
derivePublicKey,
|
|
15
15
|
loadStoredCredentials,
|
|
16
16
|
writeCredentialsFile,
|
|
17
|
+
type AgentIdentitySnapshot,
|
|
17
18
|
type ControlAck,
|
|
18
19
|
type ControlFrame,
|
|
20
|
+
type HelloParams,
|
|
19
21
|
type ListRuntimesResult,
|
|
20
22
|
type ProvisionAgentParams,
|
|
21
23
|
type RevokeAgentParams,
|
|
22
24
|
type RevokeAgentResult,
|
|
23
25
|
type RuntimeProbeResult,
|
|
24
26
|
type StoredBotCordCredentials,
|
|
27
|
+
type UpdateAgentParams,
|
|
25
28
|
} from "@botcord/protocol-core";
|
|
26
29
|
import type { Gateway } from "./gateway/index.js";
|
|
30
|
+
import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
|
|
31
|
+
import type { PolicyUpdatedParams } from "@botcord/protocol-core";
|
|
27
32
|
import type {
|
|
28
33
|
GatewayChannelConfig,
|
|
29
34
|
GatewayRuntimeSnapshot,
|
|
@@ -36,11 +41,16 @@ import {
|
|
|
36
41
|
type RouteRule,
|
|
37
42
|
type RouteRuleMatch,
|
|
38
43
|
} from "./config.js";
|
|
39
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
BOTCORD_CHANNEL_TYPE,
|
|
46
|
+
buildManagedRoutes,
|
|
47
|
+
prepareGatewayProfile,
|
|
48
|
+
} from "./daemon-config-map.js";
|
|
40
49
|
import {
|
|
41
50
|
agentHomeDir,
|
|
42
51
|
agentStateDir,
|
|
43
52
|
agentWorkspaceDir,
|
|
53
|
+
applyAgentIdentity,
|
|
44
54
|
ensureAgentWorkspace,
|
|
45
55
|
} from "./agent-workspace.js";
|
|
46
56
|
import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
|
|
@@ -55,6 +65,14 @@ export interface ProvisionerOptions {
|
|
|
55
65
|
* run without a real Hub.
|
|
56
66
|
*/
|
|
57
67
|
register?: typeof BotCordClient.register;
|
|
68
|
+
/**
|
|
69
|
+
* Optional policy-resolver handle (PR3). When present, the
|
|
70
|
+
* `policy_updated` control frame routes through it: cache is invalidated
|
|
71
|
+
* for the (agent, room?) pair, and any embedded `policy` payload is
|
|
72
|
+
* applied directly so the next inbound sees the fresh policy without an
|
|
73
|
+
* extra round-trip.
|
|
74
|
+
*/
|
|
75
|
+
policyResolver?: PolicyResolverLike;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
/** The value a frame handler returns (minus the `id` which the channel fills in). */
|
|
@@ -70,6 +88,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
70
88
|
) => Promise<AckBody> {
|
|
71
89
|
const gateway = opts.gateway;
|
|
72
90
|
const register = opts.register ?? BotCordClient.register;
|
|
91
|
+
const policyResolver = opts.policyResolver;
|
|
73
92
|
|
|
74
93
|
return async (frame: ControlFrame): Promise<AckBody> => {
|
|
75
94
|
daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
|
|
@@ -77,6 +96,38 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
77
96
|
case CONTROL_FRAME_TYPES.PING:
|
|
78
97
|
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
79
98
|
|
|
99
|
+
case CONTROL_FRAME_TYPES.HELLO: {
|
|
100
|
+
const params = (frame.params ?? {}) as unknown as HelloParams;
|
|
101
|
+
const result = applyHelloIdentitySnapshot(params.agents);
|
|
102
|
+
daemonLog.debug("hello: identity snapshot applied", {
|
|
103
|
+
frameId: frame.id,
|
|
104
|
+
received: params.agents?.length ?? 0,
|
|
105
|
+
updated: result.updated,
|
|
106
|
+
skipped: result.skipped,
|
|
107
|
+
});
|
|
108
|
+
return { ok: true, result };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
|
|
112
|
+
const params = (frame.params ?? {}) as unknown as UpdateAgentParams;
|
|
113
|
+
if (!params.agentId) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: { code: "bad_params", message: "update_agent requires params.agentId" },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const result = applyAgentIdentity(params.agentId, {
|
|
120
|
+
displayName: params.displayName,
|
|
121
|
+
bio: params.bio,
|
|
122
|
+
});
|
|
123
|
+
daemonLog.info("update_agent applied", {
|
|
124
|
+
agentId: params.agentId,
|
|
125
|
+
changed: result.changed,
|
|
126
|
+
skipped: result.skipped ?? null,
|
|
127
|
+
});
|
|
128
|
+
return { ok: true, result };
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
81
132
|
const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
|
|
82
133
|
daemonLog.info("provision_agent: start", {
|
|
@@ -86,6 +137,18 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
86
137
|
name: params.name ?? null,
|
|
87
138
|
});
|
|
88
139
|
const agent = await provisionAgent(params, { gateway, register });
|
|
140
|
+
// Seed the policy resolver from the optional `defaultAttention` /
|
|
141
|
+
// `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
|
|
142
|
+
// don't yet emit these stay backwards-compatible — the resolver just
|
|
143
|
+
// falls back to `mode=always` until a `policy_updated` frame arrives.
|
|
144
|
+
if (policyResolver && params.defaultAttention) {
|
|
145
|
+
policyResolver.put(agent.agentId, null, {
|
|
146
|
+
mode: params.defaultAttention,
|
|
147
|
+
keywords: Array.isArray(params.attentionKeywords)
|
|
148
|
+
? params.attentionKeywords.slice()
|
|
149
|
+
: [],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
89
152
|
return {
|
|
90
153
|
ok: true,
|
|
91
154
|
result: {
|
|
@@ -125,8 +188,57 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
125
188
|
return { ok: true, result: res };
|
|
126
189
|
}
|
|
127
190
|
|
|
191
|
+
case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
|
|
192
|
+
const params = (frame.params ?? {}) as unknown as PolicyUpdatedParams;
|
|
193
|
+
const agentId = params.agent_id;
|
|
194
|
+
if (typeof agentId !== "string" || !agentId) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: { code: "bad_params", message: "policy_updated requires agent_id" },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (!policyResolver) {
|
|
201
|
+
// No resolver wired — quietly succeed; the daemon may be running
|
|
202
|
+
// without the gateway-level attention gate (e.g. legacy boot path).
|
|
203
|
+
daemonLog.debug("policy_updated: no resolver — noop", { agentId });
|
|
204
|
+
return { ok: true, result: { agent_id: agentId, applied: false } };
|
|
205
|
+
}
|
|
206
|
+
const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
|
|
207
|
+
if (params.policy) {
|
|
208
|
+
// Embedded policy payload — install directly to avoid a refetch.
|
|
209
|
+
policyResolver.put(agentId, roomId ?? null, {
|
|
210
|
+
mode: params.policy.mode,
|
|
211
|
+
keywords: Array.isArray(params.policy.keywords)
|
|
212
|
+
? params.policy.keywords.slice()
|
|
213
|
+
: [],
|
|
214
|
+
...(typeof params.policy.muted_until === "number"
|
|
215
|
+
? { muted_until: params.policy.muted_until }
|
|
216
|
+
: {}),
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
policyResolver.invalidate(agentId, roomId);
|
|
220
|
+
}
|
|
221
|
+
daemonLog.info("policy_updated: applied", {
|
|
222
|
+
agentId,
|
|
223
|
+
roomId: roomId ?? null,
|
|
224
|
+
embedded: !!params.policy,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
result: { agent_id: agentId, applied: true, embedded: !!params.policy },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
128
232
|
case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
|
|
129
|
-
|
|
233
|
+
// Async path so the openclaw-acp endpoints get probed inline; gateway
|
|
234
|
+
// / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
|
|
235
|
+
let cfgForProbe: { openclawGateways?: any[] } | undefined;
|
|
236
|
+
try {
|
|
237
|
+
cfgForProbe = loadConfig();
|
|
238
|
+
} catch {
|
|
239
|
+
cfgForProbe = undefined;
|
|
240
|
+
}
|
|
241
|
+
const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
|
|
130
242
|
daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
|
|
131
243
|
return { ok: true, result: snapshot };
|
|
132
244
|
}
|
|
@@ -246,11 +358,37 @@ async function provisionAgent(
|
|
|
246
358
|
// Hot-add the synthesized per-agent managed route so the next turn picks
|
|
247
359
|
// the agent's runtime + workspace cwd without waiting for reload_config.
|
|
248
360
|
try {
|
|
249
|
-
|
|
361
|
+
const synthRoute: import("./gateway/index.js").GatewayRoute = {
|
|
250
362
|
match: { accountId: credentials.agentId },
|
|
251
363
|
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
252
364
|
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
253
|
-
}
|
|
365
|
+
};
|
|
366
|
+
if (synthRoute.runtime === "openclaw-acp") {
|
|
367
|
+
// Resolve gateway from the freshly written credentials + the live
|
|
368
|
+
// openclawGateways registry. A missing/unknown gateway here yields a
|
|
369
|
+
// disabled route (set_route style); next turn for this agent falls
|
|
370
|
+
// back to defaultRoute. Caller already validated via reload semantics.
|
|
371
|
+
const profile = (cfg.openclawGateways ?? []).find(
|
|
372
|
+
(g) => g.name === credentials.openclawGateway,
|
|
373
|
+
);
|
|
374
|
+
if (profile) {
|
|
375
|
+
// Run the same tokenFile-aware resolver `toGatewayConfig` uses so the
|
|
376
|
+
// first turn after provisioning doesn't auth-fail when the gateway
|
|
377
|
+
// ships its bearer via `tokenFile` instead of an inline `token`.
|
|
378
|
+
const prepared = prepareGatewayProfile(profile);
|
|
379
|
+
synthRoute.gateway = {
|
|
380
|
+
name: prepared.name,
|
|
381
|
+
url: prepared.url,
|
|
382
|
+
...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
|
|
383
|
+
...(credentials.openclawAgent
|
|
384
|
+
? { openclawAgent: credentials.openclawAgent }
|
|
385
|
+
: prepared.defaultAgent
|
|
386
|
+
? { openclawAgent: prepared.defaultAgent }
|
|
387
|
+
: {}),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
ctx.gateway.upsertManagedRoute(credentials.agentId, synthRoute);
|
|
254
392
|
} catch (err) {
|
|
255
393
|
// Rollback the channel + config + credentials on managed-route failure
|
|
256
394
|
// (shouldn't happen — pure map op — but keeps the invariant tight).
|
|
@@ -332,6 +470,9 @@ async function materializeCredentials(
|
|
|
332
470
|
if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
|
|
333
471
|
if (runtime) record.runtime = runtime;
|
|
334
472
|
record.cwd = cwd;
|
|
473
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
474
|
+
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
475
|
+
if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
|
|
335
476
|
return record;
|
|
336
477
|
}
|
|
337
478
|
|
|
@@ -361,9 +502,41 @@ async function materializeCredentials(
|
|
|
361
502
|
};
|
|
362
503
|
if (runtime) record.runtime = runtime;
|
|
363
504
|
record.cwd = cwd;
|
|
505
|
+
const openclawSel = pickOpenclawSelection(params);
|
|
506
|
+
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
507
|
+
if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
|
|
364
508
|
return record;
|
|
365
509
|
}
|
|
366
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
|
|
513
|
+
* `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
|
|
514
|
+
* Returning `{}` is fine — only meaningful when the agent's runtime is
|
|
515
|
+
* `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
|
|
516
|
+
* when both are missing.
|
|
517
|
+
*/
|
|
518
|
+
function pickOpenclawSelection(
|
|
519
|
+
params: ProvisionAgentParams,
|
|
520
|
+
): { gateway?: string; agent?: string } {
|
|
521
|
+
const out: { gateway?: string; agent?: string } = {};
|
|
522
|
+
const top = params.openclaw;
|
|
523
|
+
if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
|
|
524
|
+
out.gateway = top.gateway;
|
|
525
|
+
if (typeof top.agent === "string" && top.agent.length > 0) out.agent = top.agent;
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
const flat = params.credentials;
|
|
529
|
+
if (flat) {
|
|
530
|
+
if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
|
|
531
|
+
out.gateway = flat.openclawGateway;
|
|
532
|
+
}
|
|
533
|
+
if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
|
|
534
|
+
out.agent = flat.openclawAgent;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
367
540
|
async function revokeAgent(
|
|
368
541
|
params: RevokeAgentParams,
|
|
369
542
|
ctx: { gateway: Gateway },
|
|
@@ -555,6 +728,250 @@ export function collectRuntimeSnapshot(): ListRuntimesResult {
|
|
|
555
728
|
return { runtimes, probedAt: Date.now() };
|
|
556
729
|
}
|
|
557
730
|
|
|
731
|
+
/** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
|
|
732
|
+
export const RUNTIME_ENDPOINTS_CAP = 32;
|
|
733
|
+
|
|
734
|
+
/** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
|
|
735
|
+
export type WsEndpointProbeFn = (args: {
|
|
736
|
+
url: string;
|
|
737
|
+
token?: string;
|
|
738
|
+
timeoutMs: number;
|
|
739
|
+
}) => Promise<{
|
|
740
|
+
ok: boolean;
|
|
741
|
+
version?: string;
|
|
742
|
+
/**
|
|
743
|
+
* L3 — populated when `agents.list` succeeds. `id` is the stable key
|
|
744
|
+
* consumed by route lookups / `openclawAgent`; `name` is display-only.
|
|
745
|
+
*/
|
|
746
|
+
agents?: Array<{
|
|
747
|
+
id: string;
|
|
748
|
+
name?: string;
|
|
749
|
+
workspace?: string;
|
|
750
|
+
model?: { name?: string; provider?: string };
|
|
751
|
+
}>;
|
|
752
|
+
error?: string;
|
|
753
|
+
}>;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
|
|
757
|
+
* and, when the connection is up, issues a JSON-RPC `agents.list` request to
|
|
758
|
+
* enumerate configured agent profiles. Best-effort: a successful WS open with
|
|
759
|
+
* a failed `agents.list` still reports `ok: true` (just without `agents`),
|
|
760
|
+
* matching the RFC's "agents populated only when listing succeeded" rule.
|
|
761
|
+
*
|
|
762
|
+
* Method name and result shape follow OpenClaw:
|
|
763
|
+
* `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
|
|
764
|
+
* `~/claws/openclaw/src/gateway/session-utils.ts:783` —
|
|
765
|
+
* `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
|
|
766
|
+
*/
|
|
767
|
+
async function defaultWsProbe(args: {
|
|
768
|
+
url: string;
|
|
769
|
+
token?: string;
|
|
770
|
+
timeoutMs: number;
|
|
771
|
+
}): Promise<{
|
|
772
|
+
ok: boolean;
|
|
773
|
+
version?: string;
|
|
774
|
+
agents?: Array<{
|
|
775
|
+
id: string;
|
|
776
|
+
name?: string;
|
|
777
|
+
workspace?: string;
|
|
778
|
+
model?: { name?: string; provider?: string };
|
|
779
|
+
}>;
|
|
780
|
+
error?: string;
|
|
781
|
+
}> {
|
|
782
|
+
type AgentRow = {
|
|
783
|
+
id: string;
|
|
784
|
+
name?: string;
|
|
785
|
+
workspace?: string;
|
|
786
|
+
model?: { name?: string; provider?: string };
|
|
787
|
+
};
|
|
788
|
+
type ProbeResult = {
|
|
789
|
+
ok: boolean;
|
|
790
|
+
version?: string;
|
|
791
|
+
agents?: AgentRow[];
|
|
792
|
+
error?: string;
|
|
793
|
+
};
|
|
794
|
+
const { default: WebSocket } = await import("ws");
|
|
795
|
+
return new Promise<ProbeResult>((resolve) => {
|
|
796
|
+
let settled = false;
|
|
797
|
+
let ws: any;
|
|
798
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
799
|
+
const settle = (v: ProbeResult): void => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
if (timer) clearTimeout(timer);
|
|
803
|
+
try {
|
|
804
|
+
ws?.terminate();
|
|
805
|
+
} catch {
|
|
806
|
+
// ignore
|
|
807
|
+
}
|
|
808
|
+
resolve(v);
|
|
809
|
+
};
|
|
810
|
+
try {
|
|
811
|
+
const headers: Record<string, string> = {};
|
|
812
|
+
if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
|
|
813
|
+
ws = new WebSocket(args.url, { headers });
|
|
814
|
+
} catch (err) {
|
|
815
|
+
resolve({ ok: false, error: (err as Error).message });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
|
|
819
|
+
const requestId = "probe-agents-list";
|
|
820
|
+
ws.on("open", () => {
|
|
821
|
+
// L3: enumerate agent profiles. We don't fail the L2 result if this
|
|
822
|
+
// call fails — the gateway is reachable either way.
|
|
823
|
+
try {
|
|
824
|
+
ws.send(
|
|
825
|
+
JSON.stringify({
|
|
826
|
+
jsonrpc: "2.0",
|
|
827
|
+
id: requestId,
|
|
828
|
+
method: "agents.list",
|
|
829
|
+
params: {},
|
|
830
|
+
}),
|
|
831
|
+
);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
settle({ ok: true, error: `agents.list send failed: ${(err as Error).message}` });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
ws.on("message", (raw: Buffer | string) => {
|
|
837
|
+
try {
|
|
838
|
+
const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
839
|
+
if (msg?.id !== requestId) return; // ignore unrelated frames
|
|
840
|
+
if (msg.error) {
|
|
841
|
+
settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
|
|
845
|
+
const agents: AgentRow[] = [];
|
|
846
|
+
for (const a of list) {
|
|
847
|
+
if (!a || typeof a.id !== "string" || a.id.length === 0) continue;
|
|
848
|
+
const row: AgentRow = { id: a.id };
|
|
849
|
+
if (typeof a.name === "string") row.name = a.name;
|
|
850
|
+
if (typeof a.workspace === "string") row.workspace = a.workspace;
|
|
851
|
+
if (a.model && typeof a.model === "object") {
|
|
852
|
+
const model: { name?: string; provider?: string } = {};
|
|
853
|
+
if (typeof a.model.name === "string") model.name = a.model.name;
|
|
854
|
+
if (typeof a.model.provider === "string") model.provider = a.model.provider;
|
|
855
|
+
if (model.name || model.provider) row.model = model;
|
|
856
|
+
}
|
|
857
|
+
agents.push(row);
|
|
858
|
+
}
|
|
859
|
+
settle({ ok: true, agents });
|
|
860
|
+
} catch (err) {
|
|
861
|
+
settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
ws.on("error", (err: Error) => {
|
|
865
|
+
settle({ ok: false, error: err.message });
|
|
866
|
+
});
|
|
867
|
+
ws.on("close", () => {
|
|
868
|
+
// If the socket closes before `agents.list` resolved we still treat
|
|
869
|
+
// L2 as ok (open fired) and emit no agents.
|
|
870
|
+
settle({ ok: true });
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
877
|
+
* probes for runtimes that talk to external services. Used by the production
|
|
878
|
+
* `list_runtimes` and first-connect snapshot paths.
|
|
879
|
+
*
|
|
880
|
+
* `cfg` is optional so existing callers without a loaded config (e.g. tests)
|
|
881
|
+
* can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
|
|
882
|
+
* is identical to that function.
|
|
883
|
+
*/
|
|
884
|
+
export async function collectRuntimeSnapshotAsync(opts: {
|
|
885
|
+
cfg?: { openclawGateways?: Array<{ name: string; url: string; token?: string; tokenFile?: string }> };
|
|
886
|
+
wsProbe?: WsEndpointProbeFn;
|
|
887
|
+
timeoutMs?: number;
|
|
888
|
+
} = {}): Promise<ListRuntimesResult> {
|
|
889
|
+
const base = collectRuntimeSnapshot();
|
|
890
|
+
const gateways = opts.cfg?.openclawGateways ?? [];
|
|
891
|
+
if (gateways.length === 0) return base;
|
|
892
|
+
const probe = opts.wsProbe ?? defaultWsProbe;
|
|
893
|
+
// Default daemon-side budget is 3s — it must stay below the Hub's
|
|
894
|
+
// `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
|
|
895
|
+
// so a single slow gateway can't blow the whole snapshot to a 504.
|
|
896
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
897
|
+
const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
|
|
898
|
+
const endpoints = await Promise.all(
|
|
899
|
+
capped.map(async (g) => {
|
|
900
|
+
// Resolve `tokenFile` here so token-file-only profiles probe with auth
|
|
901
|
+
// and aren't falsely marked unreachable in the dashboard.
|
|
902
|
+
const prepared = prepareGatewayProfile(g);
|
|
903
|
+
try {
|
|
904
|
+
const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
|
|
905
|
+
const entry: any = { name: g.name, url: g.url, reachable: res.ok };
|
|
906
|
+
if (res.version) entry.version = res.version;
|
|
907
|
+
if (res.error) entry.error = res.error;
|
|
908
|
+
if (res.agents) entry.agents = res.agents;
|
|
909
|
+
return entry;
|
|
910
|
+
} catch (err) {
|
|
911
|
+
return {
|
|
912
|
+
name: g.name,
|
|
913
|
+
url: g.url,
|
|
914
|
+
reachable: false,
|
|
915
|
+
error: (err as Error).message,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}),
|
|
919
|
+
);
|
|
920
|
+
const out: ListRuntimesResult = { ...base };
|
|
921
|
+
out.runtimes = base.runtimes.map((r) =>
|
|
922
|
+
r.id === "openclaw-acp" ? { ...r, endpoints } : r,
|
|
923
|
+
);
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
// hello agents snapshot (lightweight identity sync)
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
interface HelloIdentityResult {
|
|
932
|
+
updated: number;
|
|
933
|
+
skipped: number;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
938
|
+
* against the on-disk `identity.md`. Best-effort: a malformed entry or a
|
|
939
|
+
* file-system error for one agent never aborts the rest.
|
|
940
|
+
*
|
|
941
|
+
* Identity-snapshot semantics intentionally only touch the metadata
|
|
942
|
+
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
943
|
+
* are preserved (see `applyAgentIdentity`). Missing identity.md files
|
|
944
|
+
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
945
|
+
* silently skipped.
|
|
946
|
+
*/
|
|
947
|
+
export function applyHelloIdentitySnapshot(
|
|
948
|
+
snapshot: AgentIdentitySnapshot[] | undefined,
|
|
949
|
+
): HelloIdentityResult {
|
|
950
|
+
const out: HelloIdentityResult = { updated: 0, skipped: 0 };
|
|
951
|
+
if (!Array.isArray(snapshot)) return out;
|
|
952
|
+
for (const entry of snapshot) {
|
|
953
|
+
if (!entry || typeof entry.agentId !== "string") {
|
|
954
|
+
out.skipped += 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const result = applyAgentIdentity(entry.agentId, {
|
|
959
|
+
displayName: entry.displayName,
|
|
960
|
+
bio: entry.bio,
|
|
961
|
+
});
|
|
962
|
+
if (result.changed) out.updated += 1;
|
|
963
|
+
else out.skipped += 1;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
out.skipped += 1;
|
|
966
|
+
daemonLog.warn("hello.identity apply failed", {
|
|
967
|
+
agentId: entry.agentId,
|
|
968
|
+
error: err instanceof Error ? err.message : String(err),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
|
|
558
975
|
// ---------------------------------------------------------------------------
|
|
559
976
|
// reload_config / list_agents / set_route handlers (P3)
|
|
560
977
|
// ---------------------------------------------------------------------------
|
|
@@ -641,17 +1058,19 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
|
|
|
641
1058
|
*/
|
|
642
1059
|
function readAgentRuntimesFromCredentials(
|
|
643
1060
|
agentIds: string[],
|
|
644
|
-
): Record<string, { runtime?: string; cwd?: string }> {
|
|
645
|
-
const out: Record<string, { runtime?: string; cwd?: string }> = {};
|
|
1061
|
+
): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> {
|
|
1062
|
+
const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> = {};
|
|
646
1063
|
for (const id of agentIds) {
|
|
647
1064
|
const file = defaultCredentialsFile(id);
|
|
648
1065
|
try {
|
|
649
1066
|
if (!existsSync(file)) continue;
|
|
650
1067
|
const creds = loadStoredCredentials(file);
|
|
651
|
-
const entry: { runtime?: string; cwd?: string } = {};
|
|
1068
|
+
const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
|
|
652
1069
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
653
1070
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
654
|
-
if (
|
|
1071
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
1072
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
1073
|
+
if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent) out[id] = entry;
|
|
655
1074
|
} catch {
|
|
656
1075
|
// best-effort — skip agents with unreadable credentials
|
|
657
1076
|
}
|
|
@@ -782,11 +1201,21 @@ export function setRoute(params: unknown): SetRouteResult {
|
|
|
782
1201
|
match.conversationPrefix = p.pattern;
|
|
783
1202
|
}
|
|
784
1203
|
|
|
1204
|
+
// Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
|
|
1205
|
+
// above) so dashboard-driven `set_route` calls that only carry agentId +
|
|
1206
|
+
// pattern still pick up operator-wide flags like `--permission-mode
|
|
1207
|
+
// bypassPermissions`. Without this, every newly provisioned agent lost
|
|
1208
|
+
// those flags and Bash/MCP tool calls would deadlock on permission prompts.
|
|
1209
|
+
const extraArgs = Array.isArray(route?.extraArgs)
|
|
1210
|
+
? route!.extraArgs!.slice()
|
|
1211
|
+
: Array.isArray(cfg.defaultRoute.extraArgs)
|
|
1212
|
+
? cfg.defaultRoute.extraArgs.slice()
|
|
1213
|
+
: undefined;
|
|
785
1214
|
const newRule: RouteRule = {
|
|
786
1215
|
match,
|
|
787
1216
|
adapter,
|
|
788
1217
|
cwd,
|
|
789
|
-
...(
|
|
1218
|
+
...(extraArgs ? { extraArgs } : {}),
|
|
790
1219
|
};
|
|
791
1220
|
|
|
792
1221
|
const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
|
package/src/system-context.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* `RuntimeRunOptions.systemContext`. This module composes the daemon's
|
|
7
7
|
* system-context string from:
|
|
8
8
|
*
|
|
9
|
-
* 1. `[BotCord
|
|
10
|
-
* 2. `[BotCord
|
|
11
|
-
* 3. `[BotCord
|
|
12
|
-
* 4. `[BotCord
|
|
9
|
+
* 1. `[BotCord Identity]` (read fresh from workspace/identity.md each turn)
|
|
10
|
+
* 2. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
|
|
11
|
+
* 3. `[BotCord Working Memory]`
|
|
12
|
+
* 4. `[BotCord Room Context]` (group rooms, via optional async fetcher)
|
|
13
|
+
* 5. `[BotCord Cross-Room Awareness]` (optional activity tracker)
|
|
13
14
|
*
|
|
14
15
|
* Behavior:
|
|
15
16
|
* - Working memory is loaded fresh per turn, so a `memory set` from another
|
|
@@ -26,6 +27,7 @@ import type { GatewayInboundMessage, SystemContextBuilder } from "./gateway/inde
|
|
|
26
27
|
import type { ActivityTracker } from "./activity-tracker.js";
|
|
27
28
|
import { buildCrossRoomDigest } from "./cross-room.js";
|
|
28
29
|
import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
|
|
30
|
+
import { readIdentity } from "./agent-workspace.js";
|
|
29
31
|
import { classifyActivitySender } from "./sender-classify.js";
|
|
30
32
|
import { log } from "./log.js";
|
|
31
33
|
|
|
@@ -86,6 +88,31 @@ function safeReadWorkingMemory(agentId: string) {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Read identity.md and wrap it as a system-context block. Placed before
|
|
93
|
+
* every other block so the agent answers "who are you" from this file
|
|
94
|
+
* rather than from the underlying CLI's default persona ("I am Claude
|
|
95
|
+
* Code"). Re-read every turn so dashboard reconcile (`applyAgentIdentity`)
|
|
96
|
+
* and self-edits take effect immediately, mirroring working-memory
|
|
97
|
+
* semantics.
|
|
98
|
+
*/
|
|
99
|
+
function buildIdentityPrompt(agentId: string): string | null {
|
|
100
|
+
let raw: string | null = null;
|
|
101
|
+
try {
|
|
102
|
+
raw = readIdentity(agentId);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.warn("identity read failed", { agentId, err: String(err) });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!raw) return null;
|
|
108
|
+
return [
|
|
109
|
+
"[BotCord Identity]",
|
|
110
|
+
"Your persistent identity card. The fields below are the source of truth — when asked who you are, what you do, or what you will / will not do, answer from this block, not from the underlying CLI's default persona.",
|
|
111
|
+
"",
|
|
112
|
+
raw.trim(),
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
89
116
|
/**
|
|
90
117
|
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
|
91
118
|
*
|
|
@@ -97,10 +124,13 @@ export function createDaemonSystemContextBuilder(
|
|
|
97
124
|
deps: SystemContextDeps,
|
|
98
125
|
): (message: GatewayInboundMessage) => Promise<string | undefined> | string | undefined {
|
|
99
126
|
const gatherSyncBlocks = (message: GatewayInboundMessage): {
|
|
127
|
+
identity: string | null;
|
|
100
128
|
ownerScene: string | null;
|
|
101
129
|
memory: string | null;
|
|
102
130
|
digest: string | null;
|
|
103
131
|
} => {
|
|
132
|
+
const identity = buildIdentityPrompt(deps.agentId);
|
|
133
|
+
|
|
104
134
|
const ownerScene =
|
|
105
135
|
classifyActivitySender(message).kind === "owner"
|
|
106
136
|
? buildOwnerChatSceneContext()
|
|
@@ -118,7 +148,7 @@ export function createDaemonSystemContextBuilder(
|
|
|
118
148
|
}) || null
|
|
119
149
|
: null;
|
|
120
150
|
|
|
121
|
-
return { ownerScene, memory, digest };
|
|
151
|
+
return { identity, ownerScene, memory, digest };
|
|
122
152
|
};
|
|
123
153
|
|
|
124
154
|
const assemble = (parts: Array<string | null | undefined>): string | undefined => {
|
|
@@ -144,11 +174,12 @@ export function createDaemonSystemContextBuilder(
|
|
|
144
174
|
|
|
145
175
|
if (!deps.roomContextBuilder) {
|
|
146
176
|
const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
|
|
147
|
-
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
177
|
+
const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
148
178
|
// Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
|
|
149
179
|
// is the last thing the model sees before the user turn body.
|
|
180
|
+
// Identity sits at the very front so it frames every other block.
|
|
150
181
|
const loopRisk = runLoopRisk(message);
|
|
151
|
-
return assemble([ownerScene, memory, digest, loopRisk]);
|
|
182
|
+
return assemble([identity, ownerScene, memory, digest, loopRisk]);
|
|
152
183
|
};
|
|
153
184
|
// Compile-time witness that the narrower sync signature still satisfies
|
|
154
185
|
// `SystemContextBuilder` (which allows async). Prevents the two contracts
|
|
@@ -162,11 +193,12 @@ export function createDaemonSystemContextBuilder(
|
|
|
162
193
|
const asyncBuilder = async (
|
|
163
194
|
message: GatewayInboundMessage,
|
|
164
195
|
): Promise<string | undefined> => {
|
|
165
|
-
const { ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
196
|
+
const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
|
|
166
197
|
// Room context landing order: after owner-scene / memory, before digest —
|
|
167
198
|
// "what room am I in" belongs with the session's own identity, while the
|
|
168
199
|
// cross-room digest deliberately describes OTHER rooms and should stay
|
|
169
200
|
// last so it doesn't get confused with the current room.
|
|
201
|
+
// Identity stays at the very front; see syncBuilder for rationale.
|
|
170
202
|
let roomBlock: string | null = null;
|
|
171
203
|
try {
|
|
172
204
|
roomBlock = await roomBuilder(message);
|
|
@@ -178,7 +210,7 @@ export function createDaemonSystemContextBuilder(
|
|
|
178
210
|
});
|
|
179
211
|
}
|
|
180
212
|
const loopRisk = runLoopRisk(message);
|
|
181
|
-
return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
|
|
213
|
+
return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
|
|
182
214
|
};
|
|
183
215
|
const _typecheck: SystemContextBuilder = asyncBuilder;
|
|
184
216
|
void _typecheck;
|