@botcord/daemon 0.2.77 → 0.2.79
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 +6 -0
- package/dist/agent-discovery.js +6 -0
- package/dist/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-config-map.d.ts +6 -0
- package/dist/daemon-config-map.js +5 -4
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +32 -7
- package/dist/gateway/channels/botcord.js +29 -9
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +86 -19
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/provision.d.ts +9 -3
- package/dist/provision.js +181 -9
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/runtime-models.d.ts +17 -0
- package/dist/runtime-models.js +953 -0
- package/dist/runtime-route-options.d.ts +7 -0
- package/dist/runtime-route-options.js +45 -0
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-config-map.test.ts +26 -1
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +124 -0
- package/src/__tests__/runtime-discovery.test.ts +68 -9
- package/src/__tests__/runtime-models.test.ts +333 -0
- package/src/agent-discovery.ts +9 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-config-map.ts +17 -4
- package/src/daemon.ts +38 -9
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +207 -1
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +32 -8
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +86 -19
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/provision.ts +202 -11
- package/src/room-recovery-context.ts +131 -0
- package/src/runtime-models.ts +972 -0
- package/src/runtime-route-options.ts +52 -0
package/src/gateway/types.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
|
+
import type {
|
|
3
|
+
GatewayInboundEnvelope as CanonicalGatewayInboundEnvelope,
|
|
4
|
+
GatewayInboundMessage as CanonicalGatewayInboundMessage,
|
|
5
|
+
GatewayOutboundAttachment as CanonicalGatewayOutboundAttachment,
|
|
6
|
+
GatewayOutboundMessage as CanonicalGatewayOutboundMessage,
|
|
7
|
+
RuntimeGatewayProvider,
|
|
8
|
+
} from "@botcord/protocol-core";
|
|
9
|
+
|
|
10
|
+
// Canonical gateway message shapes live in `@botcord/protocol-core` so the
|
|
11
|
+
// `gateway-ingress` provider adapters can import the same types without
|
|
12
|
+
// pulling the entire daemon. Daemon re-exports the canonical shapes here so
|
|
13
|
+
// every existing import (`from "./gateway/index.js"`) keeps working.
|
|
14
|
+
export type GatewayInboundMessage = CanonicalGatewayInboundMessage;
|
|
15
|
+
export type GatewayInboundEnvelope = CanonicalGatewayInboundEnvelope;
|
|
16
|
+
export type GatewayOutboundAttachment = CanonicalGatewayOutboundAttachment;
|
|
17
|
+
export type GatewayOutboundMessage = CanonicalGatewayOutboundMessage;
|
|
2
18
|
|
|
3
19
|
// ---------------------------------------------------------------------------
|
|
4
20
|
// Routing (§9)
|
|
@@ -89,42 +105,9 @@ export interface GatewayConfig {
|
|
|
89
105
|
// Inbound / outbound message shape (§7.3, §7.4, §7.5)
|
|
90
106
|
// ---------------------------------------------------------------------------
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/** Channel adapter id (`ChannelAdapter.id`), not channel type. */
|
|
96
|
-
channel: string;
|
|
97
|
-
accountId: string;
|
|
98
|
-
conversation: {
|
|
99
|
-
id: string;
|
|
100
|
-
kind: "direct" | "group";
|
|
101
|
-
title?: string;
|
|
102
|
-
threadId?: string | null;
|
|
103
|
-
};
|
|
104
|
-
sender: {
|
|
105
|
-
id: string;
|
|
106
|
-
name?: string;
|
|
107
|
-
kind: "user" | "agent" | "system";
|
|
108
|
-
};
|
|
109
|
-
text?: string;
|
|
110
|
-
raw: unknown;
|
|
111
|
-
replyTo?: string | null;
|
|
112
|
-
mentioned?: boolean;
|
|
113
|
-
receivedAt: number;
|
|
114
|
-
trace?: {
|
|
115
|
-
id: string;
|
|
116
|
-
streamable?: boolean;
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Inbound envelope wrapping a normalized message with optional upstream ack callbacks. */
|
|
121
|
-
export interface GatewayInboundEnvelope {
|
|
122
|
-
message: GatewayInboundMessage;
|
|
123
|
-
ack?: {
|
|
124
|
-
accept(): Promise<void>;
|
|
125
|
-
reject?(reason: string): Promise<void>;
|
|
126
|
-
};
|
|
127
|
-
}
|
|
108
|
+
// `GatewayInboundMessage` and `GatewayInboundEnvelope` are re-exported from
|
|
109
|
+
// `@botcord/protocol-core` at the top of this file. The wire-level subset is
|
|
110
|
+
// `RuntimeGatewayInboundPayload` in protocol-core/runtime-frame.ts.
|
|
128
111
|
|
|
129
112
|
/**
|
|
130
113
|
* Channel-agnostic hook that produces a system-context string for a turn.
|
|
@@ -170,6 +153,15 @@ export type MemoryContextBuilder = (
|
|
|
170
153
|
message: GatewayInboundMessage,
|
|
171
154
|
) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
|
|
172
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Optional hook used after a runtime session is discarded and retried fresh.
|
|
158
|
+
* The daemon implementation can pull recent room messages from Hub; gateway
|
|
159
|
+
* core treats the returned string as opaque recovery context.
|
|
160
|
+
*/
|
|
161
|
+
export type RuntimeRecoveryContextBuilder = (
|
|
162
|
+
message: GatewayInboundMessage,
|
|
163
|
+
) => Promise<string | null | undefined> | string | null | undefined;
|
|
164
|
+
|
|
173
165
|
/**
|
|
174
166
|
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
175
167
|
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
@@ -179,28 +171,8 @@ export type OutboundObserver = (
|
|
|
179
171
|
message: GatewayOutboundMessage,
|
|
180
172
|
) => Promise<void> | void;
|
|
181
173
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
/** Local daemon-readable file path. */
|
|
185
|
-
filePath?: string;
|
|
186
|
-
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
187
|
-
data?: Uint8Array;
|
|
188
|
-
filename?: string;
|
|
189
|
-
contentType?: string;
|
|
190
|
-
kind?: "image" | "file" | "video";
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface GatewayOutboundMessage {
|
|
194
|
-
channel: string;
|
|
195
|
-
accountId: string;
|
|
196
|
-
conversationId: string;
|
|
197
|
-
threadId?: string | null;
|
|
198
|
-
type?: "message" | "error";
|
|
199
|
-
text: string;
|
|
200
|
-
attachments?: GatewayOutboundAttachment[];
|
|
201
|
-
replyTo?: string | null;
|
|
202
|
-
traceId?: string | null;
|
|
203
|
-
}
|
|
174
|
+
// `GatewayOutboundAttachment` and `GatewayOutboundMessage` are re-exported from
|
|
175
|
+
// `@botcord/protocol-core` at the top of this file.
|
|
204
176
|
|
|
205
177
|
// ---------------------------------------------------------------------------
|
|
206
178
|
// Status (§14)
|
|
@@ -218,7 +190,7 @@ export interface ChannelStatusSnapshot {
|
|
|
218
190
|
lastStopAt?: number;
|
|
219
191
|
lastError?: string | null;
|
|
220
192
|
/** Third-party provider id when this channel is not the built-in BotCord. */
|
|
221
|
-
provider?:
|
|
193
|
+
provider?: RuntimeGatewayProvider;
|
|
222
194
|
/** Last time the adapter polled the upstream provider (ms epoch). */
|
|
223
195
|
lastPollAt?: number;
|
|
224
196
|
/** Last time the adapter accepted an inbound message (ms epoch). */
|
package/src/gateway-control.ts
CHANGED
|
@@ -313,13 +313,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
313
313
|
if (!loginId) {
|
|
314
314
|
return badParams("upsert_gateway: wechat requires loginId");
|
|
315
315
|
}
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
316
|
+
const resolved = sessions.resolve(loginId);
|
|
317
|
+
if (resolved.state !== "live") {
|
|
318
318
|
return {
|
|
319
319
|
ok: false,
|
|
320
|
-
error:
|
|
320
|
+
error:
|
|
321
|
+
resolved.state === "missing"
|
|
322
|
+
? { code: "login_missing", message: `wechat login session "${loginId}" not found` }
|
|
323
|
+
: { code: "login_expired", message: `wechat login session "${loginId}" expired` },
|
|
321
324
|
};
|
|
322
325
|
}
|
|
326
|
+
const session = resolved.session!;
|
|
323
327
|
if (session.provider !== "wechat") {
|
|
324
328
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
|
|
325
329
|
}
|
|
@@ -347,13 +351,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
347
351
|
if (!loginId) {
|
|
348
352
|
return badParams("upsert_gateway: feishu requires loginId");
|
|
349
353
|
}
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
354
|
+
const resolved = sessions.resolve(loginId);
|
|
355
|
+
if (resolved.state !== "live") {
|
|
352
356
|
return {
|
|
353
357
|
ok: false,
|
|
354
|
-
error:
|
|
358
|
+
error:
|
|
359
|
+
resolved.state === "missing"
|
|
360
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
361
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
355
362
|
};
|
|
356
363
|
}
|
|
364
|
+
const session = resolved.session!;
|
|
357
365
|
if (session.provider !== "feishu") {
|
|
358
366
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
359
367
|
}
|
|
@@ -869,13 +877,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
869
877
|
if (!params.accountId || typeof params.accountId !== "string") {
|
|
870
878
|
return badParams("gateway_recent_senders: accountId is required");
|
|
871
879
|
}
|
|
872
|
-
const
|
|
873
|
-
if (
|
|
880
|
+
const resolved = sessions.resolve(params.loginId);
|
|
881
|
+
if (resolved.state !== "live") {
|
|
874
882
|
return {
|
|
875
883
|
ok: false,
|
|
876
|
-
error:
|
|
884
|
+
error:
|
|
885
|
+
resolved.state === "missing"
|
|
886
|
+
? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
|
|
887
|
+
: { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
|
|
877
888
|
};
|
|
878
889
|
}
|
|
890
|
+
const session = resolved.session!;
|
|
879
891
|
if (session.provider !== "wechat") {
|
|
880
892
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
881
893
|
}
|
package/src/provision.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type RuntimeProbeResult,
|
|
27
27
|
type StoredBotCordCredentials,
|
|
28
28
|
type UpdateAgentParams,
|
|
29
|
+
type GatewayInboundFrame,
|
|
29
30
|
} from "@botcord/protocol-core";
|
|
30
31
|
import type { Gateway } from "./gateway/index.js";
|
|
31
32
|
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
@@ -72,6 +73,12 @@ import {
|
|
|
72
73
|
import { log as daemonLog } from "./log.js";
|
|
73
74
|
import { discoverAgentCredentials } from "./agent-discovery.js";
|
|
74
75
|
import { resolveMemoryDir } from "./working-memory.js";
|
|
76
|
+
import { discoverRuntimeModelCatalog } from "./runtime-models.js";
|
|
77
|
+
import {
|
|
78
|
+
buildRuntimeSelectionExtraArgs,
|
|
79
|
+
mergeRuntimeExtraArgs,
|
|
80
|
+
} from "./runtime-route-options.js";
|
|
81
|
+
import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
|
|
75
82
|
|
|
76
83
|
/**
|
|
77
84
|
* Information passed to {@link OnAgentInstalledHook} after a successful
|
|
@@ -159,7 +166,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
159
166
|
|
|
160
167
|
case CONTROL_FRAME_TYPES.HELLO: {
|
|
161
168
|
const params = (frame.params ?? {}) as unknown as HelloParams;
|
|
162
|
-
const result = applyHelloIdentitySnapshot(params.agents);
|
|
169
|
+
const result = applyHelloIdentitySnapshot(params.agents, { gateway });
|
|
163
170
|
daemonLog.debug("hello: identity snapshot applied", {
|
|
164
171
|
frameId: frame.id,
|
|
165
172
|
received: params.agents?.length ?? 0,
|
|
@@ -181,12 +188,19 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
181
188
|
displayName: params.displayName,
|
|
182
189
|
bio: params.bio,
|
|
183
190
|
});
|
|
191
|
+
const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
|
|
192
|
+
const combined = {
|
|
193
|
+
changed: result.changed || runtimeResult.changed,
|
|
194
|
+
identity: result,
|
|
195
|
+
runtime: runtimeResult,
|
|
196
|
+
};
|
|
184
197
|
daemonLog.info("update_agent applied", {
|
|
185
198
|
agentId: params.agentId,
|
|
186
|
-
changed:
|
|
187
|
-
|
|
199
|
+
changed: combined.changed,
|
|
200
|
+
identitySkipped: result.skipped ?? null,
|
|
201
|
+
runtimeSkipped: runtimeResult.skipped ?? null,
|
|
188
202
|
});
|
|
189
|
-
return { ok: true, result };
|
|
203
|
+
return { ok: true, result: combined };
|
|
190
204
|
}
|
|
191
205
|
|
|
192
206
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
@@ -414,6 +428,31 @@ export function createProvisioner(opts: ProvisionerOptions): (
|
|
|
414
428
|
);
|
|
415
429
|
}
|
|
416
430
|
|
|
431
|
+
case "cloud_gateway_runtime_inbound": {
|
|
432
|
+
const params = (frame.params ?? {}) as { frame?: unknown };
|
|
433
|
+
const runtimeFrame = params.frame as GatewayInboundFrame | undefined;
|
|
434
|
+
if (!runtimeFrame || typeof runtimeFrame !== "object") {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
error: {
|
|
438
|
+
code: "bad_params",
|
|
439
|
+
message: "cloud_gateway_runtime_inbound requires params.frame",
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
|
|
444
|
+
return result.accepted
|
|
445
|
+
? { ok: true, result }
|
|
446
|
+
: {
|
|
447
|
+
ok: false,
|
|
448
|
+
result,
|
|
449
|
+
error: result.error ?? {
|
|
450
|
+
code: "runtime_inbound_rejected",
|
|
451
|
+
message: "cloud gateway runtime inbound was rejected",
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
417
456
|
case "list_agent_files": {
|
|
418
457
|
const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
|
|
419
458
|
if (!params.agentId) {
|
|
@@ -1057,6 +1096,11 @@ function upsertManagedRouteForCredentials(
|
|
|
1057
1096
|
runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
|
|
1058
1097
|
cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
|
|
1059
1098
|
};
|
|
1099
|
+
const extraArgs = mergeRuntimeExtraArgs(
|
|
1100
|
+
cfg.defaultRoute.extraArgs,
|
|
1101
|
+
buildRuntimeSelectionExtraArgs(synthRoute.runtime, credentials),
|
|
1102
|
+
);
|
|
1103
|
+
if (extraArgs) synthRoute.extraArgs = extraArgs;
|
|
1060
1104
|
if (synthRoute.runtime === "openclaw-acp") {
|
|
1061
1105
|
const profile = (cfg.openclawGateways ?? []).find(
|
|
1062
1106
|
(g) => g.name === credentials.openclawGateway,
|
|
@@ -1169,6 +1213,10 @@ async function materializeCredentials(
|
|
|
1169
1213
|
if (c.token) record.token = c.token;
|
|
1170
1214
|
if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
|
|
1171
1215
|
if (runtime) record.runtime = runtime;
|
|
1216
|
+
const runtimeSelection = pickRuntimeSelection(params);
|
|
1217
|
+
if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;
|
|
1218
|
+
if (runtimeSelection.reasoningEffort) record.reasoningEffort = runtimeSelection.reasoningEffort;
|
|
1219
|
+
if (typeof runtimeSelection.thinking === "boolean") record.thinking = runtimeSelection.thinking;
|
|
1172
1220
|
record.cwd = cwd;
|
|
1173
1221
|
const openclawSel = pickOpenclawSelection(params);
|
|
1174
1222
|
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
@@ -1203,6 +1251,10 @@ async function materializeCredentials(
|
|
|
1203
1251
|
tokenExpiresAt: reg.expiresAt,
|
|
1204
1252
|
};
|
|
1205
1253
|
if (runtime) record.runtime = runtime;
|
|
1254
|
+
const runtimeSelection = pickRuntimeSelection(params);
|
|
1255
|
+
if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;
|
|
1256
|
+
if (runtimeSelection.reasoningEffort) record.reasoningEffort = runtimeSelection.reasoningEffort;
|
|
1257
|
+
if (typeof runtimeSelection.thinking === "boolean") record.thinking = runtimeSelection.thinking;
|
|
1206
1258
|
record.cwd = cwd;
|
|
1207
1259
|
const openclawSel = pickOpenclawSelection(params);
|
|
1208
1260
|
if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
|
|
@@ -1784,6 +1836,10 @@ export function collectRuntimeSnapshot(opts: { force?: boolean } = {}): ListRunt
|
|
|
1784
1836
|
// style used above.
|
|
1785
1837
|
if (entry.result.version) record.version = entry.result.version;
|
|
1786
1838
|
if (entry.result.path) record.path = entry.result.path;
|
|
1839
|
+
const catalog = discoverRuntimeModelCatalog(entry);
|
|
1840
|
+
const models = catalog.models;
|
|
1841
|
+
if (models?.length) record.models = models.slice(0, RUNTIME_MODELS_CAP);
|
|
1842
|
+
if (catalog.parameters?.length) record.parameters = catalog.parameters.slice(0, RUNTIME_PARAMETERS_CAP);
|
|
1787
1843
|
// Gateway's probe surface doesn't expose an `error` string today — it
|
|
1788
1844
|
// already swallows throws into `{available: false}`. We leave the wire
|
|
1789
1845
|
// field blank in that case and let callers treat `!available` as reason
|
|
@@ -1841,6 +1897,8 @@ export function attachRuntimeHealth(
|
|
|
1841
1897
|
|
|
1842
1898
|
/** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
|
|
1843
1899
|
export const RUNTIME_ENDPOINTS_CAP = 32;
|
|
1900
|
+
export const RUNTIME_MODELS_CAP = 128;
|
|
1901
|
+
export const RUNTIME_PARAMETERS_CAP = 64;
|
|
1844
1902
|
|
|
1845
1903
|
/** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
|
|
1846
1904
|
export type WsEndpointProbeFn = (args: {
|
|
@@ -2386,10 +2444,100 @@ interface HelloIdentityResult {
|
|
|
2386
2444
|
skipped: number;
|
|
2387
2445
|
}
|
|
2388
2446
|
|
|
2447
|
+
interface RuntimeSnapshotResult {
|
|
2448
|
+
changed: boolean;
|
|
2449
|
+
skipped?: string;
|
|
2450
|
+
routeUpdated?: boolean;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
interface RuntimeSnapshotCtx {
|
|
2454
|
+
gateway?: Gateway;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function hasOwnField(obj: object, key: string): boolean {
|
|
2458
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function cleanNullableString(value: string | null | undefined): string | undefined {
|
|
2462
|
+
if (typeof value !== "string") return undefined;
|
|
2463
|
+
const trimmed = value.trim();
|
|
2464
|
+
return trimmed || undefined;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function applyAgentRuntimeSnapshot(
|
|
2468
|
+
snapshot: AgentIdentitySnapshot,
|
|
2469
|
+
ctx: RuntimeSnapshotCtx = {},
|
|
2470
|
+
): RuntimeSnapshotResult {
|
|
2471
|
+
const hasRuntimeFields = (
|
|
2472
|
+
hasOwnField(snapshot, "runtime") ||
|
|
2473
|
+
hasOwnField(snapshot, "runtimeModel") ||
|
|
2474
|
+
hasOwnField(snapshot, "reasoningEffort") ||
|
|
2475
|
+
hasOwnField(snapshot, "thinking")
|
|
2476
|
+
);
|
|
2477
|
+
if (!hasRuntimeFields) return { changed: false, skipped: "no_runtime_fields" };
|
|
2478
|
+
|
|
2479
|
+
const credentialsFile = defaultCredentialsFile(snapshot.agentId);
|
|
2480
|
+
if (!existsSync(credentialsFile)) {
|
|
2481
|
+
return { changed: false, skipped: "credentials_missing" };
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
const credentials = loadStoredCredentials(credentialsFile);
|
|
2485
|
+
let changed = false;
|
|
2486
|
+
|
|
2487
|
+
if (hasOwnField(snapshot, "runtime")) {
|
|
2488
|
+
const runtime = cleanNullableString(snapshot.runtime);
|
|
2489
|
+
if (runtime !== credentials.runtime) {
|
|
2490
|
+
if (runtime) credentials.runtime = runtime;
|
|
2491
|
+
else delete credentials.runtime;
|
|
2492
|
+
changed = true;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
if (hasOwnField(snapshot, "runtimeModel")) {
|
|
2497
|
+
const runtimeModel = cleanNullableString(snapshot.runtimeModel);
|
|
2498
|
+
if (runtimeModel !== credentials.runtimeModel) {
|
|
2499
|
+
if (runtimeModel) credentials.runtimeModel = runtimeModel;
|
|
2500
|
+
else delete credentials.runtimeModel;
|
|
2501
|
+
changed = true;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
if (hasOwnField(snapshot, "reasoningEffort")) {
|
|
2506
|
+
const reasoningEffort = cleanNullableString(snapshot.reasoningEffort);
|
|
2507
|
+
if (reasoningEffort !== credentials.reasoningEffort) {
|
|
2508
|
+
if (reasoningEffort) credentials.reasoningEffort = reasoningEffort;
|
|
2509
|
+
else delete credentials.reasoningEffort;
|
|
2510
|
+
changed = true;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
if (hasOwnField(snapshot, "thinking")) {
|
|
2515
|
+
if (typeof snapshot.thinking === "boolean") {
|
|
2516
|
+
if (credentials.thinking !== snapshot.thinking) {
|
|
2517
|
+
credentials.thinking = snapshot.thinking;
|
|
2518
|
+
changed = true;
|
|
2519
|
+
}
|
|
2520
|
+
} else if (typeof credentials.thinking === "boolean") {
|
|
2521
|
+
delete credentials.thinking;
|
|
2522
|
+
changed = true;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
if (!changed) return { changed: false };
|
|
2527
|
+
|
|
2528
|
+
writeCredentialsFile(credentialsFile, credentials);
|
|
2529
|
+
if (ctx.gateway) {
|
|
2530
|
+
upsertManagedRouteForCredentials(credentials, loadConfig(), ctx.gateway);
|
|
2531
|
+
return { changed: true, routeUpdated: true };
|
|
2532
|
+
}
|
|
2533
|
+
return { changed: true };
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2389
2536
|
/**
|
|
2390
2537
|
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
2391
|
-
* against the on-disk `identity.md
|
|
2392
|
-
* file-system error for one agent never
|
|
2538
|
+
* against the on-disk `identity.md` and credentials runtime selectors.
|
|
2539
|
+
* Best-effort: a malformed entry or a file-system error for one agent never
|
|
2540
|
+
* aborts the rest.
|
|
2393
2541
|
*
|
|
2394
2542
|
* Identity-snapshot semantics intentionally only touch the metadata
|
|
2395
2543
|
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
@@ -2399,6 +2547,7 @@ interface HelloIdentityResult {
|
|
|
2399
2547
|
*/
|
|
2400
2548
|
export function applyHelloIdentitySnapshot(
|
|
2401
2549
|
snapshot: AgentIdentitySnapshot[] | undefined,
|
|
2550
|
+
ctx: RuntimeSnapshotCtx = {},
|
|
2402
2551
|
): HelloIdentityResult {
|
|
2403
2552
|
const out: HelloIdentityResult = { updated: 0, skipped: 0 };
|
|
2404
2553
|
if (!Array.isArray(snapshot)) return out;
|
|
@@ -2412,7 +2561,8 @@ export function applyHelloIdentitySnapshot(
|
|
|
2412
2561
|
displayName: entry.displayName,
|
|
2413
2562
|
bio: entry.bio,
|
|
2414
2563
|
});
|
|
2415
|
-
|
|
2564
|
+
const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
|
|
2565
|
+
if (result.changed || runtimeResult.changed) out.updated += 1;
|
|
2416
2566
|
else out.skipped += 1;
|
|
2417
2567
|
} catch (err) {
|
|
2418
2568
|
out.skipped += 1;
|
|
@@ -2511,20 +2661,34 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
|
|
|
2511
2661
|
*/
|
|
2512
2662
|
function readAgentRuntimesFromCredentials(
|
|
2513
2663
|
agentIds: string[],
|
|
2514
|
-
): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> {
|
|
2515
|
-
const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
|
|
2664
|
+
): Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> {
|
|
2665
|
+
const out: Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
|
|
2516
2666
|
for (const id of agentIds) {
|
|
2517
2667
|
const file = defaultCredentialsFile(id);
|
|
2518
2668
|
try {
|
|
2519
2669
|
if (!existsSync(file)) continue;
|
|
2520
2670
|
const creds = loadStoredCredentials(file);
|
|
2521
|
-
const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string } = {};
|
|
2671
|
+
const entry: { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string } = {};
|
|
2522
2672
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
2673
|
+
if (creds.runtimeModel) entry.runtimeModel = creds.runtimeModel;
|
|
2674
|
+
if (creds.reasoningEffort) entry.reasoningEffort = creds.reasoningEffort;
|
|
2675
|
+
if (typeof creds.thinking === "boolean") entry.thinking = creds.thinking;
|
|
2523
2676
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
2524
2677
|
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
2525
2678
|
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
2526
2679
|
if (creds.hermesProfile) entry.hermesProfile = creds.hermesProfile;
|
|
2527
|
-
if (
|
|
2680
|
+
if (
|
|
2681
|
+
entry.runtime ||
|
|
2682
|
+
entry.runtimeModel ||
|
|
2683
|
+
entry.reasoningEffort ||
|
|
2684
|
+
typeof entry.thinking === "boolean" ||
|
|
2685
|
+
entry.cwd ||
|
|
2686
|
+
entry.openclawGateway ||
|
|
2687
|
+
entry.openclawAgent ||
|
|
2688
|
+
entry.hermesProfile
|
|
2689
|
+
) {
|
|
2690
|
+
out[id] = entry;
|
|
2691
|
+
}
|
|
2528
2692
|
} catch {
|
|
2529
2693
|
// best-effort — skip agents with unreadable credentials
|
|
2530
2694
|
}
|
|
@@ -2769,6 +2933,33 @@ function pickRuntime(params: ProvisionAgentParams): string | undefined {
|
|
|
2769
2933
|
return undefined;
|
|
2770
2934
|
}
|
|
2771
2935
|
|
|
2936
|
+
function pickRuntimeSelection(
|
|
2937
|
+
params: ProvisionAgentParams,
|
|
2938
|
+
): { runtimeModel?: string; reasoningEffort?: string; thinking?: boolean } {
|
|
2939
|
+
const out: { runtimeModel?: string; reasoningEffort?: string; thinking?: boolean } = {};
|
|
2940
|
+
const runtimeModel = pickString(params.runtimeModel, params.credentials?.runtimeModel);
|
|
2941
|
+
const reasoningEffort = pickString(
|
|
2942
|
+
params.reasoningEffort,
|
|
2943
|
+
params.credentials?.reasoningEffort,
|
|
2944
|
+
);
|
|
2945
|
+
if (runtimeModel) out.runtimeModel = runtimeModel;
|
|
2946
|
+
if (reasoningEffort) out.reasoningEffort = reasoningEffort;
|
|
2947
|
+
if (typeof params.thinking === "boolean") {
|
|
2948
|
+
out.thinking = params.thinking;
|
|
2949
|
+
} else if (typeof params.credentials?.thinking === "boolean") {
|
|
2950
|
+
out.thinking = params.credentials.thinking;
|
|
2951
|
+
}
|
|
2952
|
+
return out;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function pickString(...values: Array<string | undefined>): string | undefined {
|
|
2956
|
+
for (const value of values) {
|
|
2957
|
+
const trimmed = value?.trim();
|
|
2958
|
+
if (trimmed) return trimmed;
|
|
2959
|
+
}
|
|
2960
|
+
return undefined;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2772
2963
|
function assertKnownRuntime(runtime: string): void {
|
|
2773
2964
|
const mod = getAdapterModule(runtime);
|
|
2774
2965
|
if (!mod) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a compact, deterministic recovery block from recent Hub room messages.
|
|
3
|
+
* Used when a runtime-native session is discarded and the same turn is retried
|
|
4
|
+
* in a fresh session.
|
|
5
|
+
*/
|
|
6
|
+
import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
|
|
7
|
+
import { sanitizeUntrustedContent } from "./gateway/index.js";
|
|
8
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
9
|
+
|
|
10
|
+
interface CachedClient {
|
|
11
|
+
client: BotCordClient;
|
|
12
|
+
credentialsPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RecentRoomMessagesRecoveryOptions {
|
|
16
|
+
credentialPathByAgentId: Map<string, string>;
|
|
17
|
+
defaultCredentialsPath?: string;
|
|
18
|
+
hubBaseUrl?: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
log?: {
|
|
21
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RoomMessage {
|
|
26
|
+
from?: string;
|
|
27
|
+
from_name?: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
type?: string;
|
|
30
|
+
ts?: string;
|
|
31
|
+
topic_id?: string | null;
|
|
32
|
+
topic_title?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_RECENT_LIMIT = 20;
|
|
36
|
+
const MAX_MESSAGE_TEXT_CHARS = 1200;
|
|
37
|
+
|
|
38
|
+
function stripNewlines(s: string): string {
|
|
39
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function messageLabel(m: RoomMessage): string {
|
|
43
|
+
const name = typeof m.from_name === "string" && m.from_name.trim()
|
|
44
|
+
? m.from_name
|
|
45
|
+
: typeof m.from === "string" && m.from.trim()
|
|
46
|
+
? m.from
|
|
47
|
+
: "unknown";
|
|
48
|
+
return sanitizeUntrustedContent(stripNewlines(name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatRecentMessages(messages: RoomMessage[]): string {
|
|
52
|
+
if (messages.length === 0) return "[Recent Room Messages]\n(none)";
|
|
53
|
+
const chronological = [...messages].reverse();
|
|
54
|
+
const lines = ["[Recent Room Messages]"];
|
|
55
|
+
for (const m of chronological) {
|
|
56
|
+
const text = typeof m.text === "string" ? m.text.trim() : "";
|
|
57
|
+
if (!text) continue;
|
|
58
|
+
const ts = typeof m.ts === "string" ? m.ts : "";
|
|
59
|
+
const topic = typeof m.topic_title === "string" && m.topic_title.trim()
|
|
60
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_title))}`
|
|
61
|
+
: typeof m.topic_id === "string" && m.topic_id
|
|
62
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_id))}`
|
|
63
|
+
: "";
|
|
64
|
+
const safeText = sanitizeUntrustedContent(
|
|
65
|
+
text.length > MAX_MESSAGE_TEXT_CHARS
|
|
66
|
+
? `${text.slice(0, MAX_MESSAGE_TEXT_CHARS)}...`
|
|
67
|
+
: text,
|
|
68
|
+
);
|
|
69
|
+
lines.push(`- ${ts ? `${ts} ` : ""}${messageLabel(m)}${topic}: ${safeText}`);
|
|
70
|
+
}
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createRecentRoomMessagesRecoveryBuilder(
|
|
75
|
+
opts: RecentRoomMessagesRecoveryOptions,
|
|
76
|
+
): (message: GatewayInboundMessage) => Promise<string | null> {
|
|
77
|
+
const clients = new Map<string, CachedClient>();
|
|
78
|
+
const limit = opts.limit ?? DEFAULT_RECENT_LIMIT;
|
|
79
|
+
|
|
80
|
+
function getClient(accountId: string): BotCordClient | null {
|
|
81
|
+
const existing = clients.get(accountId);
|
|
82
|
+
if (existing) return existing.client;
|
|
83
|
+
|
|
84
|
+
const credsPath =
|
|
85
|
+
opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
|
|
86
|
+
if (!credsPath) {
|
|
87
|
+
opts.log?.warn("daemon.recovery-context.no-credentials", { accountId });
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const creds = loadStoredCredentials(credsPath);
|
|
93
|
+
const client = new BotCordClient({
|
|
94
|
+
hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
|
|
95
|
+
agentId: creds.agentId,
|
|
96
|
+
keyId: creds.keyId,
|
|
97
|
+
privateKey: creds.privateKey,
|
|
98
|
+
...(creds.token ? { token: creds.token } : {}),
|
|
99
|
+
...(creds.tokenExpiresAt !== undefined
|
|
100
|
+
? { tokenExpiresAt: creds.tokenExpiresAt }
|
|
101
|
+
: {}),
|
|
102
|
+
});
|
|
103
|
+
clients.set(accountId, { client, credentialsPath: credsPath });
|
|
104
|
+
return client;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
opts.log?.warn("daemon.recovery-context.client-init-failed", {
|
|
107
|
+
accountId,
|
|
108
|
+
credsPath,
|
|
109
|
+
error: err instanceof Error ? err.message : String(err),
|
|
110
|
+
});
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return async (message) => {
|
|
116
|
+
const client = getClient(message.accountId);
|
|
117
|
+
if (!client) return null;
|
|
118
|
+
try {
|
|
119
|
+
const body = await client.roomMessages(message.conversation.id, { limit });
|
|
120
|
+
const messages = Array.isArray(body?.messages) ? body.messages as RoomMessage[] : [];
|
|
121
|
+
return formatRecentMessages(messages);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
opts.log?.warn("daemon.recovery-context.fetch-failed", {
|
|
124
|
+
accountId: message.accountId,
|
|
125
|
+
roomId: message.conversation.id,
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
});
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|