@geravant/sinain 1.14.0 → 1.15.1
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/.env.example +33 -29
- package/cli.js +38 -14
- package/config-shared.js +172 -30
- package/launcher.js +38 -21
- package/onboard.js +36 -20
- package/package.json +1 -1
- package/sinain-agent/run.sh +567 -126
- package/sinain-core/src/agents-loader.ts +254 -0
- package/sinain-core/src/config.ts +77 -15
- package/sinain-core/src/escalation/escalator.ts +178 -18
- package/sinain-core/src/index.ts +168 -12
- package/sinain-core/src/learning/local-curation.ts +81 -27
- package/sinain-core/src/overlay/commands.ts +25 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +101 -10
- package/sinain-core/src/types.ts +29 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// agents-loader.ts
|
|
2
|
+
//
|
|
3
|
+
// sinain-core's view of sinain-agent/agents.json. Loaded once at startup,
|
|
4
|
+
// gives config.ts a typed snapshot of the bare-agent + OpenClaw config that
|
|
5
|
+
// used to live in .env.
|
|
6
|
+
//
|
|
7
|
+
// Why sinain-core reads it at all: the OpenClaw WS client is constructed
|
|
8
|
+
// before the bare agent ever registers, and stays connected across the
|
|
9
|
+
// session for heartbeat / situation push / feedback. So sinain-core needs
|
|
10
|
+
// the gateway URL/token at startup — it can't wait for run.sh to forward
|
|
11
|
+
// them via /bareagent/register.
|
|
12
|
+
//
|
|
13
|
+
// Path resolution:
|
|
14
|
+
// 1. AGENTS_CONFIG_PATH env var (explicit override)
|
|
15
|
+
// 2. <repo-root>/sinain-agent/agents.json (default)
|
|
16
|
+
// 3. <repo-root>/sinain-agent/agents.example.json (fresh-checkout fallback)
|
|
17
|
+
//
|
|
18
|
+
// Returns null if no candidate exists. config.ts treats null as "use env
|
|
19
|
+
// defaults" so the migration is non-breaking.
|
|
20
|
+
//
|
|
21
|
+
// Field expansion happens in two passes (shell-like ordering):
|
|
22
|
+
// 1. Cross-field literal expansion: ${allowedTools} inside escAllowedTools
|
|
23
|
+
// resolves against other top-level fields. Lets users compose
|
|
24
|
+
// whitelists by reference.
|
|
25
|
+
// 2. Env-var indirection: ${OPENROUTER_API_KEY}, ${HOME}, etc. resolve
|
|
26
|
+
// against process.env. Mirrors run.sh's apply_profile_env regex
|
|
27
|
+
// (\$\{[A-Za-z_][A-Za-z0-9_]*\}).
|
|
28
|
+
|
|
29
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
30
|
+
import { resolve, dirname } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
|
|
34
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
|
|
36
|
+
// Regex shared with run.sh's apply_profile_env. Keep the two implementations
|
|
37
|
+
// in lockstep when changing either side.
|
|
38
|
+
const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
39
|
+
|
|
40
|
+
export interface OpenClawProfileFields {
|
|
41
|
+
type: "openclaw";
|
|
42
|
+
wsUrl?: string;
|
|
43
|
+
wsToken?: string;
|
|
44
|
+
httpUrl?: string;
|
|
45
|
+
httpToken?: string;
|
|
46
|
+
sessionKey?: string;
|
|
47
|
+
phase1TimeoutMs?: number;
|
|
48
|
+
phase2TimeoutMs?: number;
|
|
49
|
+
pingIntervalMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AgentProfile {
|
|
53
|
+
type?: string;
|
|
54
|
+
bin?: string;
|
|
55
|
+
settings?: string;
|
|
56
|
+
model?: string;
|
|
57
|
+
env?: Record<string, string>;
|
|
58
|
+
// openclaw-specific fields (typed loose since profiles are heterogeneous)
|
|
59
|
+
wsUrl?: string;
|
|
60
|
+
wsToken?: string;
|
|
61
|
+
httpUrl?: string;
|
|
62
|
+
httpToken?: string;
|
|
63
|
+
sessionKey?: string;
|
|
64
|
+
phase1TimeoutMs?: number;
|
|
65
|
+
phase2TimeoutMs?: number;
|
|
66
|
+
pingIntervalMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AnalyzerBlock {
|
|
70
|
+
debounceMs?: number;
|
|
71
|
+
maxIntervalMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EscalationBlock {
|
|
75
|
+
mode?: string;
|
|
76
|
+
cooldownMs?: number;
|
|
77
|
+
staleMs?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AgentsConfig {
|
|
81
|
+
default?: string;
|
|
82
|
+
pollIntervalSec?: number;
|
|
83
|
+
agentMaxTurns?: number;
|
|
84
|
+
spawnMaxTurns?: number;
|
|
85
|
+
allowedTools?: string;
|
|
86
|
+
escAllowedTools?: string;
|
|
87
|
+
spawnAllowedTools?: string;
|
|
88
|
+
autoApproveTools?: string;
|
|
89
|
+
analyzer?: AnalyzerBlock;
|
|
90
|
+
escalation?: EscalationBlock;
|
|
91
|
+
profiles?: Record<string, AgentProfile>;
|
|
92
|
+
/** Absolute path of the file actually loaded (for debug logging). */
|
|
93
|
+
loadedFrom?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function expandVars(value: string): string {
|
|
97
|
+
return value.replace(VAR_REF_RE, (_, name: string) => process.env[name] ?? "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Recursively expand ${env} references in any string field of `obj`. */
|
|
101
|
+
function expandEnvDeep<T>(obj: T): T {
|
|
102
|
+
if (obj === null || obj === undefined) return obj;
|
|
103
|
+
if (typeof obj === "string") return expandVars(obj) as unknown as T;
|
|
104
|
+
if (Array.isArray(obj)) return obj.map((x) => expandEnvDeep(x)) as unknown as T;
|
|
105
|
+
if (typeof obj === "object") {
|
|
106
|
+
const out: Record<string, unknown> = {};
|
|
107
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
108
|
+
out[k] = expandEnvDeep(v);
|
|
109
|
+
}
|
|
110
|
+
return out as T;
|
|
111
|
+
}
|
|
112
|
+
return obj;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Cross-field literal expansion at the top level. Lets users write
|
|
117
|
+
* "escAllowedTools": "${allowedTools} Bash(git:*) Edit"
|
|
118
|
+
* and have it compose with the value of `allowedTools`. Only string
|
|
119
|
+
* top-level fields are eligible to be referenced; nested blocks aren't.
|
|
120
|
+
*/
|
|
121
|
+
function expandTopLevelRefs(cfg: AgentsConfig): void {
|
|
122
|
+
const refTargets: Record<string, string | undefined> = {
|
|
123
|
+
allowedTools: cfg.allowedTools,
|
|
124
|
+
escAllowedTools: cfg.escAllowedTools,
|
|
125
|
+
spawnAllowedTools: cfg.spawnAllowedTools,
|
|
126
|
+
autoApproveTools: cfg.autoApproveTools,
|
|
127
|
+
default: cfg.default,
|
|
128
|
+
};
|
|
129
|
+
const expand = (s?: string): string | undefined => {
|
|
130
|
+
if (!s) return s;
|
|
131
|
+
return s.replace(VAR_REF_RE, (m, name: string) => {
|
|
132
|
+
const ref = refTargets[name];
|
|
133
|
+
// Only substitute when the referenced field is a top-level field
|
|
134
|
+
// *that has a value*. Otherwise leave the placeholder untouched so
|
|
135
|
+
// the env-var pass can still pick it up (e.g. ${HOME}).
|
|
136
|
+
return ref !== undefined ? ref : m;
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
cfg.escAllowedTools = expand(cfg.escAllowedTools);
|
|
140
|
+
cfg.spawnAllowedTools = expand(cfg.spawnAllowedTools);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function candidatePaths(): string[] {
|
|
144
|
+
const explicit = process.env.AGENTS_CONFIG_PATH;
|
|
145
|
+
if (explicit) return [resolve(explicit)];
|
|
146
|
+
// ~/.sinain/agents.json is the wizard's write target — works on npm
|
|
147
|
+
// installs where the package dir is read-only. Highest priority after
|
|
148
|
+
// an explicit env override so user-home config wins over the shipped
|
|
149
|
+
// template.
|
|
150
|
+
const userHome = resolve(homedir(), ".sinain", "agents.json");
|
|
151
|
+
// sinain-core compiled output sits at sinain-core/dist/, source at
|
|
152
|
+
// sinain-core/src/. From either, sinain-agent is two levels up.
|
|
153
|
+
const repoRoot = resolve(__dirname, "..", "..");
|
|
154
|
+
return [
|
|
155
|
+
userHome,
|
|
156
|
+
resolve(repoRoot, "sinain-agent", "agents.json"),
|
|
157
|
+
resolve(repoRoot, "sinain-agent", "agents.example.json"),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Load agents.json (or fall back to agents.example.json). Returns null if
|
|
163
|
+
* neither candidate file exists or parsing fails. Caller (config.ts) is
|
|
164
|
+
* expected to treat null as "use env defaults" so the migration is
|
|
165
|
+
* non-breaking.
|
|
166
|
+
*/
|
|
167
|
+
export function loadAgentsConfig(): AgentsConfig | null {
|
|
168
|
+
for (const path of candidatePaths()) {
|
|
169
|
+
if (!existsSync(path)) continue;
|
|
170
|
+
try {
|
|
171
|
+
const raw = readFileSync(path, "utf-8");
|
|
172
|
+
const parsed = JSON.parse(raw) as AgentsConfig;
|
|
173
|
+
// Pass 1: cross-field literal substitution at top level. Has to run
|
|
174
|
+
// before env-var expansion so `${allowedTools}` doesn't accidentally
|
|
175
|
+
// resolve to process.env.allowedTools (which doesn't exist anyway,
|
|
176
|
+
// but the principle of layered substitution is what we want).
|
|
177
|
+
expandTopLevelRefs(parsed);
|
|
178
|
+
// Pass 2: ${ENV_VAR} expansion across the whole tree. Profile-level
|
|
179
|
+
// env blocks are deliberately skipped here — those are applied by
|
|
180
|
+
// run.sh in a subshell at invocation time, not by sinain-core.
|
|
181
|
+
const profiles = parsed.profiles;
|
|
182
|
+
delete parsed.profiles;
|
|
183
|
+
const expanded = expandEnvDeep(parsed);
|
|
184
|
+
// Restore profiles (with their own env blocks expanded for the few
|
|
185
|
+
// openclaw-specific fields that sinain-core does read).
|
|
186
|
+
if (profiles) {
|
|
187
|
+
const expandedProfiles: Record<string, AgentProfile> = {};
|
|
188
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
189
|
+
// Don't expand the per-profile `env` block — those are evaluated
|
|
190
|
+
// at agent invocation time inside run.sh. But DO expand other
|
|
191
|
+
// profile fields (wsToken, etc.) since sinain-core reads them.
|
|
192
|
+
const { env: profileEnv, ...rest } = profile;
|
|
193
|
+
expandedProfiles[name] = {
|
|
194
|
+
...expandEnvDeep(rest),
|
|
195
|
+
...(profileEnv !== undefined ? { env: profileEnv } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
expanded.profiles = expandedProfiles;
|
|
199
|
+
}
|
|
200
|
+
expanded.loadedFrom = path;
|
|
201
|
+
return expanded;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.warn(`[agents-loader] failed to parse ${path}: ${(err as Error).message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Convenience: pull the openclaw profile if present, else null.
|
|
210
|
+
* @deprecated Prefer findGatewayProfile() — it doesn't assume the name. */
|
|
211
|
+
export function getOpenClawProfile(cfg: AgentsConfig | null): AgentProfile | null {
|
|
212
|
+
return cfg?.profiles?.openclaw ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Find a gateway-style profile (any profile with `type: "openclaw"`,
|
|
217
|
+
* regardless of the profile's name). Used by sinain-core to:
|
|
218
|
+
* - load WS connection params at startup (config.ts)
|
|
219
|
+
* - decide whether a lane choice should route via WS or HTTP (escalator.ts)
|
|
220
|
+
*
|
|
221
|
+
* Preference order: the canonical name "openclaw" wins if present, otherwise
|
|
222
|
+
* the first openclaw-typed profile found in the JSON. This lets users add
|
|
223
|
+
* `nemoclaw`, `nanoclaw-prod`, etc. with their own URLs as drop-in replacements.
|
|
224
|
+
*/
|
|
225
|
+
export function findGatewayProfile(
|
|
226
|
+
cfg: AgentsConfig | null,
|
|
227
|
+
): { name: string; profile: AgentProfile } | null {
|
|
228
|
+
const profiles = cfg?.profiles;
|
|
229
|
+
if (!profiles) return null;
|
|
230
|
+
if (profiles.openclaw?.type === "openclaw") {
|
|
231
|
+
return { name: "openclaw", profile: profiles.openclaw };
|
|
232
|
+
}
|
|
233
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
234
|
+
if (profile?.type === "openclaw") return { name, profile };
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Is the given profile name a gateway-style (WS-dispatched) profile? */
|
|
240
|
+
export function isGatewayProfile(
|
|
241
|
+
cfg: AgentsConfig | null,
|
|
242
|
+
name: string,
|
|
243
|
+
): boolean {
|
|
244
|
+
return cfg?.profiles?.[name]?.type === "openclaw";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** All openclaw-typed profile names in the config. */
|
|
248
|
+
export function gatewayProfileNames(cfg: AgentsConfig | null): string[] {
|
|
249
|
+
const profiles = cfg?.profiles;
|
|
250
|
+
if (!profiles) return [];
|
|
251
|
+
return Object.entries(profiles)
|
|
252
|
+
.filter(([_, p]) => p?.type === "openclaw")
|
|
253
|
+
.map(([name]) => name);
|
|
254
|
+
}
|
|
@@ -2,8 +2,9 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { resolve, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode,
|
|
5
|
+
import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, LearningConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
|
|
6
6
|
import { PRESETS } from "./privacy/presets.js";
|
|
7
|
+
import { loadAgentsConfig, findGatewayProfile, type AgentsConfig } from "./agents-loader.js";
|
|
7
8
|
|
|
8
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
|
|
@@ -140,6 +141,26 @@ function loadPrivacyConfig(): PrivacyConfig {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
export function loadConfig(): CoreConfig {
|
|
144
|
+
// sinain-agent/agents.json is the new single source of truth for the
|
|
145
|
+
// bare-agent + OpenClaw config that used to live in .env. Loading
|
|
146
|
+
// happens once at startup; null means the file isn't there (fresh
|
|
147
|
+
// checkout, custom layout, etc.) and we fall back to env defaults.
|
|
148
|
+
const agentsCfg = loadAgentsConfig();
|
|
149
|
+
if (agentsCfg) {
|
|
150
|
+
console.log(`[config] loaded agents config from ${agentsCfg.loadedFrom}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Helpers that prefer agents.json values, then env, then a hardcoded
|
|
154
|
+
// default. Keeps .env working as a safety net during the migration.
|
|
155
|
+
const fromCfgStr = (cfgVal: string | undefined, envKey: string, def: string): string => {
|
|
156
|
+
if (cfgVal !== undefined && cfgVal !== "") return cfgVal;
|
|
157
|
+
return env(envKey, def);
|
|
158
|
+
};
|
|
159
|
+
const fromCfgInt = (cfgVal: number | undefined, envKey: string, def: number): number => {
|
|
160
|
+
if (cfgVal !== undefined) return cfgVal;
|
|
161
|
+
return intEnv(envKey, def);
|
|
162
|
+
};
|
|
163
|
+
|
|
143
164
|
const audioConfig: AudioPipelineConfig = {
|
|
144
165
|
device: env("AUDIO_DEVICE", "BlackHole 2ch"),
|
|
145
166
|
sampleRate: intEnv("AUDIO_SAMPLE_RATE", 16000),
|
|
@@ -196,30 +217,57 @@ export function loadConfig(): CoreConfig {
|
|
|
196
217
|
.split(",").map(s => s.trim()).filter(Boolean),
|
|
197
218
|
timeout: intEnv("ANALYSIS_TIMEOUT", 15000),
|
|
198
219
|
pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
// analyzer pacing: agents.json `analyzer` block, fall back to env
|
|
221
|
+
debounceMs: fromCfgInt(agentsCfg?.analyzer?.debounceMs, "AGENT_DEBOUNCE_MS", 3000),
|
|
222
|
+
maxIntervalMs: fromCfgInt(agentsCfg?.analyzer?.maxIntervalMs, "AGENT_MAX_INTERVAL_MS", 30000),
|
|
201
223
|
cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
|
|
202
224
|
maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
|
|
203
225
|
historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
|
|
204
226
|
};
|
|
205
227
|
|
|
206
|
-
|
|
228
|
+
// escalation policy: agents.json `escalation` block, fall back to env.
|
|
229
|
+
// Mode is runtime-mutable via the overlay's flash-icon selector; this only
|
|
230
|
+
// sets the boot-time default. (Transport is no longer a setting — per-lane
|
|
231
|
+
// agent choice in the overlay determines WS vs HTTP dispatch.)
|
|
232
|
+
const escalationMode = fromCfgStr(agentsCfg?.escalation?.mode, "ESCALATION_MODE", "rich") as EscalationMode;
|
|
207
233
|
const escalationConfig: EscalationConfig = {
|
|
208
234
|
mode: escalationMode,
|
|
209
|
-
cooldownMs:
|
|
210
|
-
staleMs:
|
|
211
|
-
transport: env("ESCALATION_TRANSPORT", "auto") as EscalationTransport,
|
|
235
|
+
cooldownMs: fromCfgInt(agentsCfg?.escalation?.cooldownMs, "ESCALATION_COOLDOWN_MS", 30000),
|
|
236
|
+
staleMs: fromCfgInt(agentsCfg?.escalation?.staleMs, "ESCALATION_STALE_MS", 90000),
|
|
212
237
|
};
|
|
213
238
|
|
|
239
|
+
// OpenClaw gateway config: pick the first openclaw-TYPED profile
|
|
240
|
+
// (preferring the canonical name "openclaw"). This lets users add
|
|
241
|
+
// server-side variants — `nemoclaw`, `nanoclaw-prod`, etc. — as full
|
|
242
|
+
// peers; the WS client connects to whichever shows up first in the
|
|
243
|
+
// profiles list. Removing all openclaw-typed profiles (and unsetting
|
|
244
|
+
// the env vars) leaves gatewayWsUrl empty → registerBareAgent skips
|
|
245
|
+
// injecting any gateway entry into the roster, and escalator.start()
|
|
246
|
+
// skips the WS connect.
|
|
247
|
+
//
|
|
248
|
+
// Limitation: only ONE gateway URL gets a live WS client today. Adding
|
|
249
|
+
// multiple openclaw-typed profiles with different URLs lets the user
|
|
250
|
+
// pick them in the overlay roster, but dispatch always uses the first
|
|
251
|
+
// one's connection params. True per-profile WS clients is a follow-up.
|
|
252
|
+
const gateway = findGatewayProfile(agentsCfg);
|
|
253
|
+
const openclawProfile = gateway?.profile;
|
|
254
|
+
// wsUrl/hookUrl default to empty so "delete the openclaw profile" genuinely
|
|
255
|
+
// disables the gateway. The shipped agents.example.json includes the
|
|
256
|
+
// profile with localhost defaults, so fresh installs still work out of
|
|
257
|
+
// the box; removal is an explicit opt-out.
|
|
214
258
|
const openclawConfig: OpenClawConfig = {
|
|
215
|
-
gatewayWsUrl:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
259
|
+
gatewayWsUrl: fromCfgStr(openclawProfile?.wsUrl, "OPENCLAW_WS_URL",
|
|
260
|
+
envAllowEmpty("OPENCLAW_GATEWAY_WS_URL", undefined, "")),
|
|
261
|
+
gatewayToken: fromCfgStr(openclawProfile?.wsToken, "OPENCLAW_WS_TOKEN",
|
|
262
|
+
env("OPENCLAW_GATEWAY_TOKEN", "")),
|
|
263
|
+
hookUrl: fromCfgStr(openclawProfile?.httpUrl, "OPENCLAW_HTTP_URL",
|
|
264
|
+
envAllowEmpty("OPENCLAW_HOOK_URL", undefined, "")),
|
|
265
|
+
hookToken: fromCfgStr(openclawProfile?.httpToken, "OPENCLAW_HTTP_TOKEN",
|
|
266
|
+
env("OPENCLAW_HOOK_TOKEN", "")),
|
|
267
|
+
sessionKey: fromCfgStr(openclawProfile?.sessionKey, "OPENCLAW_SESSION_KEY", "agent:main:sinain"),
|
|
268
|
+
phase1TimeoutMs: fromCfgInt(openclawProfile?.phase1TimeoutMs, "OPENCLAW_PHASE1_TIMEOUT_MS", 30_000),
|
|
269
|
+
phase2TimeoutMs: fromCfgInt(openclawProfile?.phase2TimeoutMs, "OPENCLAW_PHASE2_TIMEOUT_MS", 120_000),
|
|
270
|
+
pingIntervalMs: fromCfgInt(openclawProfile?.pingIntervalMs, "OPENCLAW_PING_INTERVAL_MS", 30_000),
|
|
223
271
|
};
|
|
224
272
|
|
|
225
273
|
const situationDir = env("OPENCLAW_WORKSPACE_DIR", "~/.openclaw/workspace");
|
|
@@ -236,6 +284,19 @@ export function loadConfig(): CoreConfig {
|
|
|
236
284
|
|
|
237
285
|
const privacyConfig = loadPrivacyConfig();
|
|
238
286
|
|
|
287
|
+
// autoApproveTools: agents.json top-level field, falls back to
|
|
288
|
+
// SINAIN_AUTO_APPROVE_TOOLS env. Space-separated tool names or prefix
|
|
289
|
+
// patterns (suffix *) that sinain-core auto-approves at /spawn/approve
|
|
290
|
+
// without routing to the overlay.
|
|
291
|
+
const autoApproveRaw = fromCfgStr(
|
|
292
|
+
agentsCfg?.autoApproveTools,
|
|
293
|
+
"SINAIN_AUTO_APPROVE_TOOLS",
|
|
294
|
+
"Read Glob Grep Ls Cat mcp__sinain*",
|
|
295
|
+
);
|
|
296
|
+
const permissionsConfig = {
|
|
297
|
+
autoApproveTools: autoApproveRaw.split(/\s+/).filter((t) => t.length > 0),
|
|
298
|
+
};
|
|
299
|
+
|
|
239
300
|
return {
|
|
240
301
|
port: intEnv("PORT", 9500),
|
|
241
302
|
audioConfig,
|
|
@@ -251,5 +312,6 @@ export function loadConfig(): CoreConfig {
|
|
|
251
312
|
costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
|
|
252
313
|
learningConfig,
|
|
253
314
|
privacyConfig,
|
|
315
|
+
permissionsConfig,
|
|
254
316
|
};
|
|
255
317
|
}
|
|
@@ -33,6 +33,23 @@ export interface EscalatorDeps {
|
|
|
33
33
|
feedbackStore?: FeedbackStore;
|
|
34
34
|
signalCollector?: SignalCollector;
|
|
35
35
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
36
|
+
/** Returns the currently-selected spawn-lane agent from the bare-agent
|
|
37
|
+
* roster ("" = Off). When a local agent is selected, dispatchSpawnTask
|
|
38
|
+
* prefers the HTTP bare-agent path over the OpenClaw gateway WS path,
|
|
39
|
+
* so the overlay's agent-selector choice is respected even when the
|
|
40
|
+
* gateway is connected. */
|
|
41
|
+
getSpawnAgent?: () => string;
|
|
42
|
+
/** Returns the currently-selected escalation-lane agent. Gateway-typed
|
|
43
|
+
* profiles (any agent whose `type` is "openclaw" — see isGatewayAgent)
|
|
44
|
+
* route via WS; any other non-empty value routes to the local bare
|
|
45
|
+
* agent via HTTP httpPending. */
|
|
46
|
+
getEscalationAgent?: () => string;
|
|
47
|
+
/** Returns true if the named profile is a gateway-style profile
|
|
48
|
+
* (i.e. dispatched via WS RPC, not invoked as a local CLI). Lookup is
|
|
49
|
+
* by `agentsCfg.profiles[name].type === "openclaw"`. Custom profiles
|
|
50
|
+
* like "nemoclaw" or "nanoclaw-prod" with that type get WS dispatch
|
|
51
|
+
* automatically — the routing key is type, not name. */
|
|
52
|
+
isGatewayAgent?: (name: string) => boolean;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/**
|
|
@@ -52,6 +69,14 @@ export class Escalator {
|
|
|
52
69
|
private slot: EscalationSlot;
|
|
53
70
|
private httpPending: HttpPendingEscalation | null = null;
|
|
54
71
|
|
|
72
|
+
// Grace window for stale escalation IDs — when analyzer rotates the pending
|
|
73
|
+
// slot mid-response (agent takes 10-30s on MCP flow while ticks fire every
|
|
74
|
+
// 3-6s), the agent's respondHttp(oldId) would fail. Keep last 5 IDs for ~60s
|
|
75
|
+
// so those responses still land on HUD instead of being silently dropped.
|
|
76
|
+
private recentHttpIds: Array<{ id: string; ts: number }> = [];
|
|
77
|
+
private static readonly STALE_ID_GRACE_MS = 60_000;
|
|
78
|
+
private static readonly STALE_ID_BUFFER_SIZE = 5;
|
|
79
|
+
|
|
55
80
|
private lastEscalationTs = 0;
|
|
56
81
|
private lastEscalatedDigest = "";
|
|
57
82
|
|
|
@@ -139,9 +164,17 @@ export class Escalator {
|
|
|
139
164
|
log(TAG, `user command set: "${preview}"`);
|
|
140
165
|
}
|
|
141
166
|
|
|
142
|
-
/** Start the WS connection to OpenClaw
|
|
167
|
+
/** Start the WS connection to OpenClaw.
|
|
168
|
+
*
|
|
169
|
+
* Connects whenever the gateway URL is configured AND escalation isn't
|
|
170
|
+
* fully off. WS is the transport for the openclaw lane — the user selects
|
|
171
|
+
* it via the overlay's agent picker, and dispatch routes accordingly.
|
|
172
|
+
* Removing the openclaw profile from agents.json (and unsetting the env
|
|
173
|
+
* vars) leaves gatewayWsUrl empty → no connect attempt.
|
|
174
|
+
*/
|
|
143
175
|
start(): void {
|
|
144
|
-
|
|
176
|
+
const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
|
|
177
|
+
if (this.deps.escalationConfig.mode !== "off" && wsConfigured) {
|
|
145
178
|
this.wsClient.connect();
|
|
146
179
|
const tokenHash = this.deps.openclawConfig.gatewayToken
|
|
147
180
|
? createHash("sha256").update(this.deps.openclawConfig.gatewayToken).digest("hex").slice(0, 12)
|
|
@@ -185,11 +218,16 @@ export class Escalator {
|
|
|
185
218
|
this.pendingUserCommand = null;
|
|
186
219
|
}
|
|
187
220
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
221
|
+
// Early skip when circuit is open AND the user has selected openclaw —
|
|
222
|
+
// saves the cost of building the escalation message just to drop it.
|
|
223
|
+
// Local-agent lanes (claude, openclaude, etc.) bypass this since they
|
|
224
|
+
// route via HTTP and don't depend on WS.
|
|
225
|
+
if (this.wsClient.isCircuitOpen) {
|
|
226
|
+
const escalationAgent = this.deps.getEscalationAgent?.() || "";
|
|
227
|
+
if (this.deps.isGatewayAgent?.(escalationAgent)) {
|
|
228
|
+
log(TAG, `tick #${entry.id}: skipped — circuit breaker open and gateway agent "${escalationAgent}" selected`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
193
231
|
}
|
|
194
232
|
|
|
195
233
|
// If user command is pending, force escalation (bypass score + cooldown)
|
|
@@ -275,9 +313,48 @@ export class Escalator {
|
|
|
275
313
|
ts: entry.ts,
|
|
276
314
|
};
|
|
277
315
|
|
|
278
|
-
|
|
316
|
+
// Per-lane dispatch: agent identity *is* the transport.
|
|
317
|
+
// - profile.type === "openclaw" (gateway-style) → WS dispatch
|
|
318
|
+
// - any other non-empty agent (local CLI: claude, openclaude, ...) → HTTP
|
|
319
|
+
// - empty (Off) → escalator.setMode("off") should have stopped us
|
|
320
|
+
// upstream; defensive bailout.
|
|
321
|
+
//
|
|
322
|
+
// Routing keys off the profile's `type` field, not its name, so custom
|
|
323
|
+
// gateway profiles like "nemoclaw" or "nanoclaw-prod" route via WS
|
|
324
|
+
// automatically as long as they declare `type: "openclaw"` in agents.json.
|
|
325
|
+
//
|
|
326
|
+
// openclaw + WS-disconnected drops with a toast (no HTTP fallback),
|
|
327
|
+
// because the bare agent can't run a gateway profile as a local CLI —
|
|
328
|
+
// the fallback caused infinite skip loops historically.
|
|
329
|
+
const escalationAgent = this.deps.getEscalationAgent?.() || "";
|
|
330
|
+
const isGateway = this.deps.isGatewayAgent?.(escalationAgent) ?? false;
|
|
331
|
+
let useHttp: boolean;
|
|
332
|
+
if (isGateway) {
|
|
333
|
+
if (!this.wsClient.isConnected) {
|
|
334
|
+
log(TAG, `escalation dropped: gateway agent "${escalationAgent}" selected but WS disconnected`);
|
|
335
|
+
this.deps.wsHandler.broadcast(
|
|
336
|
+
`⚠ Gateway disconnected — escalation dropped. Pick a local agent or check the ${escalationAgent} gateway.`,
|
|
337
|
+
"high",
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
useHttp = false;
|
|
342
|
+
} else if (escalationAgent) {
|
|
343
|
+
useHttp = true;
|
|
344
|
+
} else {
|
|
345
|
+
log(TAG, `escalation dropped: lane is Off (escalationAgent="")`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
279
348
|
|
|
280
349
|
if (useHttp) {
|
|
350
|
+
// Remember the outgoing ID before overwriting so late-arriving responses
|
|
351
|
+
// still find a valid match in respondHttp's grace window.
|
|
352
|
+
if (this.httpPending) {
|
|
353
|
+
this.recentHttpIds.push({ id: this.httpPending.id, ts: this.httpPending.ts });
|
|
354
|
+
if (this.recentHttpIds.length > Escalator.STALE_ID_BUFFER_SIZE) {
|
|
355
|
+
this.recentHttpIds.shift();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
281
358
|
// Store in HTTP pending slot (newest wins, like EscalationSlot)
|
|
282
359
|
this.httpPending = {
|
|
283
360
|
id: slotId,
|
|
@@ -287,13 +364,50 @@ export class Escalator {
|
|
|
287
364
|
ts: entry.ts,
|
|
288
365
|
feedbackCtx: slotEntry.feedbackCtx,
|
|
289
366
|
};
|
|
290
|
-
log(TAG, `tick #${entry.id} → httpPending id=${slotId} (
|
|
367
|
+
log(TAG, `tick #${entry.id} → httpPending id=${slotId} (lane=${escalationAgent || "<default>"})`);
|
|
291
368
|
} else {
|
|
292
369
|
log(TAG, `tick #${entry.id} → slot.insert id=${slotId} depth=${this.slot.depth}`);
|
|
293
370
|
this.slot.insert(slotEntry);
|
|
294
371
|
}
|
|
295
372
|
}
|
|
296
373
|
|
|
374
|
+
/** Redispatch a stale httpPending escalation through the WS slot.
|
|
375
|
+
*
|
|
376
|
+
* Called by index.ts when the escalation lane flips to a gateway-typed
|
|
377
|
+
* agent (e.g., openclaude → openclaw): an escalation queued for HTTP
|
|
378
|
+
* before the switch is now mis-routed. Rather than letting the bare
|
|
379
|
+
* agent skip it (which posts a confusing "[skipped: gateway-routed]"
|
|
380
|
+
* to the user's HUD), we move it into the WS slot so the gateway
|
|
381
|
+
* actually handles the user's pending question.
|
|
382
|
+
*
|
|
383
|
+
* If WS isn't connected, silently clear httpPending — the agent loop
|
|
384
|
+
* will produce a new escalation through the proper drop-with-toast
|
|
385
|
+
* path on the next tick. Better than the user seeing the skip message
|
|
386
|
+
* AND the gateway-disconnect toast for the same logical event.
|
|
387
|
+
*
|
|
388
|
+
* Returns true if a redispatch (or clear) actually happened, so the
|
|
389
|
+
* caller can log meaningfully.
|
|
390
|
+
*/
|
|
391
|
+
redispatchHttpPendingToWs(): boolean {
|
|
392
|
+
if (!this.httpPending) return false;
|
|
393
|
+
const stale = this.httpPending;
|
|
394
|
+
this.httpPending = null;
|
|
395
|
+
if (!this.wsClient.isConnected) {
|
|
396
|
+
log(TAG, `redispatch skipped: WS not connected — cleared stale httpPending id=${stale.id}`);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const slotEntry: SlotEntry = {
|
|
400
|
+
id: stale.id,
|
|
401
|
+
message: stale.message,
|
|
402
|
+
sessionKey: this.deps.openclawConfig.sessionKey,
|
|
403
|
+
feedbackCtx: stale.feedbackCtx,
|
|
404
|
+
ts: stale.ts,
|
|
405
|
+
};
|
|
406
|
+
log(TAG, `redispatching stale httpPending id=${stale.id} → WS slot (lane switched to gateway)`);
|
|
407
|
+
this.slot.insert(slotEntry);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
297
411
|
/** Push fresh SITUATION.md content to the gateway server (fire-and-forget). */
|
|
298
412
|
pushSituationMd(content: string): void {
|
|
299
413
|
if (!this.wsClient.isConnected) return;
|
|
@@ -381,11 +495,29 @@ ${recentLines.join("\n")}`;
|
|
|
381
495
|
|
|
382
496
|
/** Respond to an HTTP pending escalation. */
|
|
383
497
|
respondHttp(id: string, response: string): { ok: boolean; error?: string } {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
498
|
+
// Grace path: the agent's response arrived for a stale ID because the
|
|
499
|
+
// analyzer rotated the pending slot mid-flight. Still push to HUD — the
|
|
500
|
+
// response was written against context that was fresh seconds ago and is
|
|
501
|
+
// almost certainly still relevant — but don't clear the current pending,
|
|
502
|
+
// so the agent can still address the newer escalation on its next poll.
|
|
503
|
+
if (!this.httpPending || this.httpPending.id !== id) {
|
|
504
|
+
const recent = this.recentHttpIds.find((e) => e.id === id);
|
|
505
|
+
if (recent && Date.now() - recent.ts < Escalator.STALE_ID_GRACE_MS) {
|
|
506
|
+
// Grace path: response was generated against a context that's now
|
|
507
|
+
// stale (analyzer rotated the slot mid-flight) but still recent
|
|
508
|
+
// enough that the answer is almost certainly still relevant.
|
|
509
|
+
// Push to HUD and return a clean ok=true — don't surface the
|
|
510
|
+
// grace marker on the wire, because generic LLM clients read
|
|
511
|
+
// any non-empty `error` field as a failure signal and write
|
|
512
|
+
// apologetic meta-messages to the user. The breadcrumb stays
|
|
513
|
+
// in this log for debug.
|
|
514
|
+
log(TAG, `respondHttp grace: id=${id} is stale (rotated ${((Date.now() - recent.ts) / 1000).toFixed(1)}s ago) — pushing to HUD anyway`);
|
|
515
|
+
this.pushResponse(response, this.lastEscalationContext);
|
|
516
|
+
return { ok: true };
|
|
517
|
+
}
|
|
518
|
+
return this.httpPending
|
|
519
|
+
? { ok: false, error: `id mismatch: expected ${this.httpPending.id}` }
|
|
520
|
+
: { ok: false, error: "no pending escalation" };
|
|
389
521
|
}
|
|
390
522
|
|
|
391
523
|
this.pushResponse(response, this.lastEscalationContext);
|
|
@@ -446,7 +578,6 @@ ${recentLines.join("\n")}`;
|
|
|
446
578
|
getStats(): Record<string, unknown> {
|
|
447
579
|
return {
|
|
448
580
|
mode: this.deps.escalationConfig.mode,
|
|
449
|
-
transport: this.deps.escalationConfig.transport,
|
|
450
581
|
gatewayConnected: this.wsClient.isConnected,
|
|
451
582
|
circuitOpen: this.wsClient.isCircuitOpen,
|
|
452
583
|
slotDepth: this.slot.depth,
|
|
@@ -501,13 +632,42 @@ ${recentLines.join("\n")}`;
|
|
|
501
632
|
// ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
|
|
502
633
|
this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
|
|
503
634
|
|
|
504
|
-
|
|
505
|
-
|
|
635
|
+
// Route explicitly by the overlay's spawn-agent selection:
|
|
636
|
+
// "openclaw" (or "" with WS connected) → send to remote gateway via WS RPC
|
|
637
|
+
// any other non-empty value → queue for local bare agent HTTP poll
|
|
638
|
+
// "" with WS disconnected → queue for HTTP fallback (same)
|
|
639
|
+
// This makes the overlay's choice authoritative. Before openclaw was a
|
|
640
|
+
// roster option, the old heuristic "if WS connected, use gateway" hijacked
|
|
641
|
+
// every spawn regardless of user intent, which surfaced as 401/credential
|
|
642
|
+
// errors from the gateway's stale OpenRouter key.
|
|
643
|
+
// Per-lane dispatch (mirror of escalation routing above):
|
|
644
|
+
// - profile.type === "openclaw" → WS to gateway (drop with toast if
|
|
645
|
+
// WS down — bare agent can't run gateway profiles as local CLIs)
|
|
646
|
+
// - any other non-empty agent → HTTP queue for bare agent polling
|
|
647
|
+
// - empty (Off) → drop; the spawn poll skip in run.sh should already
|
|
648
|
+
// prevent us from getting here.
|
|
649
|
+
const spawnAgent = this.deps.getSpawnAgent?.() || "";
|
|
650
|
+
const spawnIsGateway = this.deps.isGatewayAgent?.(spawnAgent) ?? false;
|
|
651
|
+
if (spawnIsGateway) {
|
|
652
|
+
if (!this.wsClient.isConnected) {
|
|
653
|
+
log(TAG, `spawn-task ${taskId}: dropped — gateway agent "${spawnAgent}" selected but WS disconnected`);
|
|
654
|
+
this.deps.wsHandler.broadcast(
|
|
655
|
+
`⚠ Gateway disconnected — spawn task dropped. Pick a local agent or check the ${spawnAgent} gateway.`,
|
|
656
|
+
"high",
|
|
657
|
+
);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Fall through to gateway dispatch below.
|
|
661
|
+
} else if (spawnAgent) {
|
|
662
|
+
// Local bare-agent path: queue for polling.
|
|
506
663
|
this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
|
|
507
664
|
const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
|
|
508
665
|
this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
|
|
509
666
|
this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
|
|
510
|
-
log(TAG, `spawn-task ${taskId}:
|
|
667
|
+
log(TAG, `spawn-task ${taskId}: queued for bare agent (lane=${spawnAgent})`);
|
|
668
|
+
return;
|
|
669
|
+
} else {
|
|
670
|
+
log(TAG, `spawn-task ${taskId}: dropped — lane is Off (spawnAgent="")`);
|
|
511
671
|
return;
|
|
512
672
|
}
|
|
513
673
|
|