lazyclaw 4.2.2 → 5.0.0
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/README.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// HTTP/SSE realisation of the device gateway — Phase 27.
|
|
2
|
+
//
|
|
3
|
+
// The OpenClaw "gateway" is a long-lived process companion nodes connect
|
|
4
|
+
// to and authenticate against. lazyclaw realises that over the EXISTING
|
|
5
|
+
// HTTP daemon (no `ws` dependency, no hand-rolled RFC6455 framing): the
|
|
6
|
+
// device-auth handshake is two POSTs, authenticated routes carry the
|
|
7
|
+
// device's bearer token, and server-pushed events ride an SSE stream.
|
|
8
|
+
// The security model is unchanged from gateway/device_auth.mjs:
|
|
9
|
+
//
|
|
10
|
+
// POST /gateway/connect/challenge {deviceId?} -> { nonce, ts }
|
|
11
|
+
// Mint a single-use, time-boxed challenge (ChallengeRegistry).
|
|
12
|
+
//
|
|
13
|
+
// POST /gateway/connect {payload, signature, publicKey, nonce, ...}
|
|
14
|
+
// verifyConnect (Ed25519 sig + identity-binding + nonce + freshness),
|
|
15
|
+
// then ChallengeRegistry.consume (single-use / anti-replay). On
|
|
16
|
+
// success: an APPROVED device gets its rotated bearer token; an
|
|
17
|
+
// unapproved device gets a 403 `pending` receipt and a pairing
|
|
18
|
+
// request is recorded for the operator to `lazyclaw nodes approve`.
|
|
19
|
+
//
|
|
20
|
+
// GET /gateway/whoami (Authorization: Bearer <deviceToken>,
|
|
21
|
+
// x-device-id: <deviceId>) -> { deviceId }
|
|
22
|
+
// GET /gateway/events (same auth) -> text/event-stream
|
|
23
|
+
// Authenticated push channel. broadcast() fans an event to every live
|
|
24
|
+
// subscriber. Event producers (e.g. remote tool-call approval) are a
|
|
25
|
+
// later pass; the transport + auth are here now.
|
|
26
|
+
//
|
|
27
|
+
// These routes are mounted by daemon.mjs OUTSIDE the daemon's shared
|
|
28
|
+
// `auth-token` gate: device-auth is their own gate, and the only
|
|
29
|
+
// unauthenticated route (challenge) returns nothing but a random nonce.
|
|
30
|
+
|
|
31
|
+
import crypto from 'node:crypto';
|
|
32
|
+
import {
|
|
33
|
+
PairingStore,
|
|
34
|
+
deviceIdFromPublicKey,
|
|
35
|
+
verifyConnect,
|
|
36
|
+
} from './device_auth.mjs';
|
|
37
|
+
import { redactSecrets } from '../mas/redact.mjs';
|
|
38
|
+
|
|
39
|
+
// Matches the daemon's readTextBody cap (1 MiB) so the Content-Length
|
|
40
|
+
// pre-check and the stream reader agree on the limit.
|
|
41
|
+
const GATEWAY_MAX_BODY = 1_048_576;
|
|
42
|
+
|
|
43
|
+
function writeJson(res, status, body) {
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
45
|
+
res.end(JSON.stringify(body));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function bearerToken(req) {
|
|
49
|
+
const h = req.headers?.authorization || req.headers?.Authorization || '';
|
|
50
|
+
const m = /^Bearer\s+(.+)$/i.exec(String(h).trim());
|
|
51
|
+
return m ? m[1].trim() : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a gateway instance. `challengeRegistry` is created ONCE per daemon
|
|
56
|
+
* process and shared across requests — a challenge minted by one request
|
|
57
|
+
* is consumed by a later one, so it must outlive a single call. `nowFn`
|
|
58
|
+
* is injected for deterministic tests.
|
|
59
|
+
*/
|
|
60
|
+
export function createGateway({ configDir, challengeRegistry, nowFn = Date.now, heartbeatMs = 0 } = {}) {
|
|
61
|
+
if (!challengeRegistry) throw new Error('createGateway requires a challengeRegistry');
|
|
62
|
+
// Each entry: { res, deviceId }. Bounded globally and per-device so an
|
|
63
|
+
// authenticated device can't exhaust sockets/fds with open streams.
|
|
64
|
+
const sseClients = new Set();
|
|
65
|
+
const MAX_SSE_GLOBAL = 256;
|
|
66
|
+
const MAX_SSE_PER_DEVICE = 8;
|
|
67
|
+
const deviceStreamCount = (deviceId) => {
|
|
68
|
+
let n = 0;
|
|
69
|
+
for (const c of sseClients) if (c.deviceId === deviceId) n++;
|
|
70
|
+
return n;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// A fresh PairingStore per op re-reads the on-disk file, so an `approve`
|
|
74
|
+
// performed by the CLI in another process is visible to the daemon
|
|
75
|
+
// without a restart.
|
|
76
|
+
const store = () => new PairingStore(configDir);
|
|
77
|
+
|
|
78
|
+
// ── exec-approval producer ───────────────────────────────────────
|
|
79
|
+
// A trusted in-process caller (the daemon's POST /exec/request, which
|
|
80
|
+
// sits behind the daemon auth-token gate) calls requestApproval(); that
|
|
81
|
+
// broadcasts `exec.approval.requested` to every subscribed device and
|
|
82
|
+
// returns a Promise that settles when a device POSTs
|
|
83
|
+
// /gateway/exec/resolve (or on timeout). This is the remote
|
|
84
|
+
// human-in-the-loop gate for sensitive tool calls.
|
|
85
|
+
const approvals = new Map(); // id -> { detail, resolveFn, timer, createdAt }
|
|
86
|
+
const MAX_APPROVALS = 256;
|
|
87
|
+
const APPROVAL_TTL_MS = 5 * 60 * 1000;
|
|
88
|
+
|
|
89
|
+
// What the device is shown about a pending approval. Args may carry
|
|
90
|
+
// secrets (e.g. a bash command with a token), so the summary is redacted
|
|
91
|
+
// and capped before it leaves the process.
|
|
92
|
+
function approvalView(detail = {}) {
|
|
93
|
+
const summary = redactSecrets(String(detail.summary ?? detail.args ?? '')).slice(0, 500);
|
|
94
|
+
return { tool: detail.tool || '', agentId: detail.agentId || '', summary };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestApproval(detail = {}, { timeoutMs = APPROVAL_TTL_MS } = {}) {
|
|
98
|
+
// Bound the table — deny-and-evict the oldest pending if we're full so
|
|
99
|
+
// an approval flood can't grow memory without limit.
|
|
100
|
+
if (approvals.size >= MAX_APPROVALS) {
|
|
101
|
+
const oldest = approvals.keys().next().value;
|
|
102
|
+
if (oldest !== undefined) resolveApproval(oldest, false, 'system:capacity');
|
|
103
|
+
}
|
|
104
|
+
const id = 'ap_' + crypto.randomBytes(9).toString('hex');
|
|
105
|
+
let resolveFn;
|
|
106
|
+
const promise = new Promise((r) => { resolveFn = r; });
|
|
107
|
+
// Clamp both ends: a caller can't pin an HTTP socket + entry for an
|
|
108
|
+
// arbitrary (e.g. multi-day) duration, nor poll faster than 1s.
|
|
109
|
+
const ttl = Math.min(APPROVAL_TTL_MS, Math.max(1000, timeoutMs || APPROVAL_TTL_MS));
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
if (approvals.has(id)) {
|
|
112
|
+
approvals.delete(id);
|
|
113
|
+
resolveFn({ id, approved: false, reason: 'timeout' });
|
|
114
|
+
broadcast('exec.approval.resolved', { id, approved: false, reason: 'timeout' });
|
|
115
|
+
}
|
|
116
|
+
}, ttl);
|
|
117
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
118
|
+
approvals.set(id, { detail, resolveFn, timer, createdAt: nowFn() });
|
|
119
|
+
broadcast('exec.approval.requested', { id, ...approvalView(detail) });
|
|
120
|
+
return { id, promise };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Trust model: every APPROVED device is uniformly trusted — any of them
|
|
124
|
+
// may resolve any pending approval and list pending ids. There is no
|
|
125
|
+
// per-device scoping by design (a paired device is an operator surface).
|
|
126
|
+
// Approval ids are unguessable (crypto.randomBytes), so only an
|
|
127
|
+
// already-trusted, authenticated device can act. If you pair a
|
|
128
|
+
// lower-trust device, revoke it (`lazyclaw nodes revoke`) rather than
|
|
129
|
+
// relying on approval scoping.
|
|
130
|
+
function resolveApproval(id, decision, by = '') {
|
|
131
|
+
const a = approvals.get(id);
|
|
132
|
+
if (!a) return { ok: false, reason: 'unknown or already resolved' };
|
|
133
|
+
clearTimeout(a.timer);
|
|
134
|
+
approvals.delete(id);
|
|
135
|
+
const approved = decision === true || decision === 'approve' || decision === 'allow';
|
|
136
|
+
a.resolveFn({ id, approved, by, reason: approved ? 'approved' : 'denied' });
|
|
137
|
+
broadcast('exec.approval.resolved', { id, approved, by });
|
|
138
|
+
return { ok: true, id, approved };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pendingApprovals() {
|
|
142
|
+
return [...approvals.entries()].map(([id, a]) => ({ id, createdAt: a.createdAt, ...approvalView(a.detail) }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readJsonBody(req, readBody) {
|
|
146
|
+
let raw;
|
|
147
|
+
try { raw = await readBody(req); }
|
|
148
|
+
catch { return { __tooLarge: true }; } // body exceeded the cap
|
|
149
|
+
if (!raw) return {};
|
|
150
|
+
try { return JSON.parse(raw); } catch { return null; } // null => malformed
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function authDevice(req) {
|
|
154
|
+
const token = bearerToken(req);
|
|
155
|
+
const deviceId = String(req.headers?.['x-device-id'] || '').trim();
|
|
156
|
+
if (!token || !deviceId) return null;
|
|
157
|
+
if (!store().verifyToken(deviceId, token)) return null;
|
|
158
|
+
return { deviceId };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handle(req, res, { readBody }) {
|
|
162
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
163
|
+
const p = url.pathname;
|
|
164
|
+
const m = req.method;
|
|
165
|
+
|
|
166
|
+
// ── challenge ────────────────────────────────────────────────
|
|
167
|
+
if (m === 'POST' && p === '/gateway/connect/challenge') {
|
|
168
|
+
const challenge = challengeRegistry.create();
|
|
169
|
+
return writeJson(res, 200, challenge);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── connect ──────────────────────────────────────────────────
|
|
173
|
+
if (m === 'POST' && p === '/gateway/connect') {
|
|
174
|
+
// Reject an oversized body up front via Content-Length so we answer
|
|
175
|
+
// 413 cleanly instead of the body reader destroying the socket
|
|
176
|
+
// (ECONNRESET) once the cap is hit mid-stream.
|
|
177
|
+
const clen = Number(req.headers?.['content-length'] || 0);
|
|
178
|
+
if (Number.isFinite(clen) && clen > GATEWAY_MAX_BODY) {
|
|
179
|
+
return writeJson(res, 413, { ok: false, reason: 'request body too large' });
|
|
180
|
+
}
|
|
181
|
+
const body = await readJsonBody(req, readBody);
|
|
182
|
+
if (body && body.__tooLarge) return writeJson(res, 413, { ok: false, reason: 'request body too large' });
|
|
183
|
+
if (!body) return writeJson(res, 400, { ok: false, reason: 'malformed body' });
|
|
184
|
+
const { payload, signature, publicKey, nonce, platform = '', label = '' } = body;
|
|
185
|
+
if (!payload || !signature || !publicKey || !nonce) {
|
|
186
|
+
return writeJson(res, 400, { ok: false, reason: 'payload, signature, publicKey and nonce are required' });
|
|
187
|
+
}
|
|
188
|
+
let deviceId;
|
|
189
|
+
try {
|
|
190
|
+
deviceId = deviceIdFromPublicKey(publicKey);
|
|
191
|
+
} catch {
|
|
192
|
+
// Generic — don't reflect node:crypto parse internals.
|
|
193
|
+
return writeJson(res, 400, { ok: false, reason: 'invalid public key' });
|
|
194
|
+
}
|
|
195
|
+
const now = nowFn();
|
|
196
|
+
const verdict = verifyConnect({ payload, signature, publicKey, challenge: { nonce }, nowMs: now });
|
|
197
|
+
if (!verdict.ok) {
|
|
198
|
+
return writeJson(res, 401, { ok: false, reason: verdict.reason });
|
|
199
|
+
}
|
|
200
|
+
// Single-use / anti-replay: only AFTER a valid signature, and only
|
|
201
|
+
// once. A replayed (payload,signature) re-presents the same nonce,
|
|
202
|
+
// which consume() has already retired.
|
|
203
|
+
if (!challengeRegistry.consume(nonce, now)) {
|
|
204
|
+
return writeJson(res, 401, { ok: false, reason: 'challenge expired or already used' });
|
|
205
|
+
}
|
|
206
|
+
const st = store();
|
|
207
|
+
if (st.isApproved(deviceId)) {
|
|
208
|
+
return writeJson(res, 200, { ok: true, deviceId, token: st.tokenFor(deviceId) });
|
|
209
|
+
}
|
|
210
|
+
// Not approved — record intent once (don't pile up duplicates) and
|
|
211
|
+
// tell the device to wait for the operator's approval.
|
|
212
|
+
const existing = st.pendingForDevice(deviceId);
|
|
213
|
+
let receipt;
|
|
214
|
+
if (existing) {
|
|
215
|
+
receipt = { requestId: existing.requestId };
|
|
216
|
+
} else {
|
|
217
|
+
try {
|
|
218
|
+
receipt = st.requestPairing({ deviceId, platform, label });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// Pending-requests ceiling hit — shed load rather than 500.
|
|
221
|
+
if (err && err.code === 'PAIRING_CAP') {
|
|
222
|
+
return writeJson(res, 429, { ok: false, reason: 'too many pending pairing requests; try later' });
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return writeJson(res, 403, { ok: false, status: 'pending', deviceId, requestId: receipt.requestId });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── whoami (token-authenticated) ─────────────────────────────
|
|
231
|
+
if (m === 'GET' && p === '/gateway/whoami') {
|
|
232
|
+
const ident = authDevice(req);
|
|
233
|
+
if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
|
|
234
|
+
return writeJson(res, 200, { ok: true, deviceId: ident.deviceId });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── exec approval: device resolves a pending request ─────────
|
|
238
|
+
if (m === 'POST' && p === '/gateway/exec/resolve') {
|
|
239
|
+
const ident = authDevice(req);
|
|
240
|
+
if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
|
|
241
|
+
const body = await readJsonBody(req, readBody);
|
|
242
|
+
if (body && body.__tooLarge) return writeJson(res, 413, { ok: false, reason: 'request body too large' });
|
|
243
|
+
if (!body || !body.id) return writeJson(res, 400, { ok: false, reason: 'id and decision are required' });
|
|
244
|
+
const r = resolveApproval(body.id, body.decision, ident.deviceId);
|
|
245
|
+
return writeJson(res, r.ok ? 200 : 404, r);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── exec approval: device lists what's awaiting a decision ───
|
|
249
|
+
if (m === 'GET' && p === '/gateway/exec/pending') {
|
|
250
|
+
const ident = authDevice(req);
|
|
251
|
+
if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
|
|
252
|
+
return writeJson(res, 200, { pending: pendingApprovals() });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── events (SSE, token-authenticated) ────────────────────────
|
|
256
|
+
if (m === 'GET' && p === '/gateway/events') {
|
|
257
|
+
const ident = authDevice(req);
|
|
258
|
+
if (!ident) return writeJson(res, 401, { ok: false, reason: 'invalid device token' });
|
|
259
|
+
if (sseClients.size >= MAX_SSE_GLOBAL) {
|
|
260
|
+
return writeJson(res, 429, { ok: false, reason: 'event-stream capacity reached' });
|
|
261
|
+
}
|
|
262
|
+
if (deviceStreamCount(ident.deviceId) >= MAX_SSE_PER_DEVICE) {
|
|
263
|
+
return writeJson(res, 429, { ok: false, reason: 'too many event streams for this device' });
|
|
264
|
+
}
|
|
265
|
+
res.writeHead(200, {
|
|
266
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
267
|
+
'cache-control': 'no-cache',
|
|
268
|
+
connection: 'keep-alive',
|
|
269
|
+
});
|
|
270
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ deviceId: ident.deviceId })}\n\n`);
|
|
271
|
+
const entry = { res, deviceId: ident.deviceId };
|
|
272
|
+
sseClients.add(entry);
|
|
273
|
+
const onClose = () => { sseClients.delete(entry); };
|
|
274
|
+
if (typeof req.once === 'function') req.once('close', onClose);
|
|
275
|
+
if (typeof res.once === 'function') res.once('close', onClose);
|
|
276
|
+
return; // connection stays open
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return writeJson(res, 404, { ok: false, reason: 'no such gateway route' });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fan an event out to every live SSE subscriber. Best-effort: a dead
|
|
283
|
+
// socket is dropped, never throws into the caller.
|
|
284
|
+
function broadcast(eventName, data) {
|
|
285
|
+
const frame = `event: ${eventName}\ndata: ${JSON.stringify(data ?? {})}\n\n`;
|
|
286
|
+
for (const entry of sseClients) {
|
|
287
|
+
try { entry.res.write(frame); } catch { sseClients.delete(entry); }
|
|
288
|
+
}
|
|
289
|
+
return sseClients.size;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Optional keep-alive: periodic `tick` event so SSE proxies don't idle
|
|
293
|
+
// out the stream and subscribers can prove the channel is live. Opt-in
|
|
294
|
+
// (heartbeatMs>0) and unref'd so it never holds the process open or
|
|
295
|
+
// leaks in unit tests that don't ask for it.
|
|
296
|
+
let heartbeat = null;
|
|
297
|
+
if (heartbeatMs > 0) {
|
|
298
|
+
heartbeat = setInterval(() => { broadcast('tick', { ts: nowFn() }); }, heartbeatMs);
|
|
299
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
300
|
+
}
|
|
301
|
+
function close() { if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } }
|
|
302
|
+
|
|
303
|
+
return { handle, broadcast, sseClients, requestApproval, resolveApproval, pendingApprovals, close };
|
|
304
|
+
}
|
package/mas/agent_memory.mjs
CHANGED
|
@@ -16,6 +16,9 @@ import fs from 'node:fs';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
|
|
19
|
+
import { runTextCompletion } from './provider_adapters.mjs';
|
|
20
|
+
import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
|
|
21
|
+
|
|
19
22
|
export const DEFAULT_MAX_CHARS = 12 * 1024;
|
|
20
23
|
const AGENTS_MEM_DIR = path.join('memory', 'agents');
|
|
21
24
|
|
|
@@ -53,6 +56,11 @@ export function readMemory(name, configDir = defaultConfigDir(), maxChars = DEFA
|
|
|
53
56
|
let cut = raw.slice(0, maxChars);
|
|
54
57
|
const lastBlank = cut.lastIndexOf('\n\n');
|
|
55
58
|
if (lastBlank > maxChars * 0.6) cut = cut.slice(0, lastBlank);
|
|
59
|
+
// Drop a trailing lone high-surrogate (U+D800..U+DBFF) left behind
|
|
60
|
+
// when the slice landed in the middle of a surrogate pair, so the
|
|
61
|
+
// returned string is never invalid UTF-16.
|
|
62
|
+
const lastUnit = cut.charCodeAt(cut.length - 1);
|
|
63
|
+
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) cut = cut.slice(0, -1);
|
|
56
64
|
return cut + '\n\n…[older entries truncated]\n';
|
|
57
65
|
} catch {
|
|
58
66
|
return '';
|
|
@@ -135,19 +143,28 @@ export function buildMemoryBlock(name, configDir = defaultConfigDir(), maxChars
|
|
|
135
143
|
// to the on-disk file (auto mode) or surface it to the user (manual
|
|
136
144
|
// command). Throws on hard failure; the router catches and logs.
|
|
137
145
|
//
|
|
138
|
-
// We use the agent's own provider via the
|
|
139
|
-
//
|
|
140
|
-
// passthrough. No tools are advertised — reflection is
|
|
146
|
+
// We use the agent's own provider via the shared no-tools text
|
|
147
|
+
// completion (mas/provider_adapters.mjs), which is already wired for
|
|
148
|
+
// apiKey/baseUrl passthrough. No tools are advertised — reflection is
|
|
149
|
+
// pure text. This module owns the reflection-specific transcript +
|
|
150
|
+
// prompt; the call mechanics are shared with skill synthesis.
|
|
141
151
|
export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, maxBullets = 6 } = {}) {
|
|
142
152
|
if (!agent || !task) throw new AgentMemoryError('agent and task are required', 'AGENT_MEMORY_BAD_INPUT');
|
|
143
|
-
const adapter = await pickAdapter(agent.provider);
|
|
144
153
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.
|
|
154
|
+
// Redact secrets from the transcript BEFORE it leaves for the model,
|
|
155
|
+
// so a token pasted into a task turn never reaches the LLM. Symmetric
|
|
156
|
+
// with skill_synth.synthesizeSkill, which already redacts its
|
|
157
|
+
// transcript; both share mas/redact.mjs.
|
|
158
|
+
const transcript = redactSecrets(
|
|
159
|
+
(Array.isArray(task.turns) ? task.turns : [])
|
|
160
|
+
.map((t) => {
|
|
161
|
+
const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
|
|
162
|
+
// Defang any forged role label inside the (model-controlled) body
|
|
163
|
+
// so a turn can't inject its own [System]/[User] authority line.
|
|
164
|
+
return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
|
|
165
|
+
})
|
|
166
|
+
.join('\n\n') || '(no turns)',
|
|
167
|
+
);
|
|
151
168
|
|
|
152
169
|
const userMessage =
|
|
153
170
|
`You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
|
|
@@ -157,33 +174,17 @@ export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, max
|
|
|
157
174
|
`gotchas, teammate preferences. Do NOT repeat generic advice. Do NOT exceed ${maxBullets} ` +
|
|
158
175
|
`bullets. Reply with the bullets only — no headers, no preamble.`;
|
|
159
176
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
: { role: 'user', content: userMessage };
|
|
163
|
-
|
|
164
|
-
const resp = await adapter.callOnce({
|
|
165
|
-
messages: [initialUser],
|
|
166
|
-
tools: [],
|
|
177
|
+
const text = await runTextCompletion({
|
|
178
|
+
provider: agent.provider,
|
|
167
179
|
model: agent.model,
|
|
168
|
-
apiKey,
|
|
169
180
|
system: agent.role || '',
|
|
181
|
+
userMessage,
|
|
182
|
+
apiKey,
|
|
170
183
|
baseUrl,
|
|
171
184
|
fetchImpl,
|
|
172
185
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return text;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function pickAdapter(provider) {
|
|
181
|
-
switch (provider) {
|
|
182
|
-
case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
|
|
183
|
-
case 'openai': return await import('../providers/tool_use/openai.mjs');
|
|
184
|
-
case 'gemini': return await import('../providers/tool_use/gemini.mjs');
|
|
185
|
-
case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
|
|
186
|
-
default:
|
|
187
|
-
throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
|
|
188
|
-
}
|
|
186
|
+
// Redact again on the way back: the model may echo a secret it saw in
|
|
187
|
+
// the transcript, and this body is persisted via prependEntry and
|
|
188
|
+
// replayed into every future system prompt.
|
|
189
|
+
return redactSecrets(text).trim();
|
|
189
190
|
}
|
package/mas/agent_turn.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import * as anthropic from '../providers/tool_use/anthropic.mjs';
|
|
|
25
25
|
import * as openai from '../providers/tool_use/openai.mjs';
|
|
26
26
|
import * as gemini from '../providers/tool_use/gemini.mjs';
|
|
27
27
|
import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
|
|
28
|
+
import { put as _trajPut } from './trajectory_store.mjs';
|
|
28
29
|
|
|
29
30
|
export class AgentTurnError extends Error {
|
|
30
31
|
constructor(message, code) {
|
|
@@ -70,6 +71,8 @@ export async function runAgentTurn({
|
|
|
70
71
|
apiKey,
|
|
71
72
|
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
72
73
|
signal,
|
|
74
|
+
approve,
|
|
75
|
+
trajectoryRef,
|
|
73
76
|
} = {}) {
|
|
74
77
|
if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
|
|
75
78
|
const adapter = adapterFor(agent.provider);
|
|
@@ -91,6 +94,29 @@ export async function runAgentTurn({
|
|
|
91
94
|
const toolCalls = [];
|
|
92
95
|
let iterations = 0;
|
|
93
96
|
let lastText = '';
|
|
97
|
+
if (trajectoryRef && !trajectoryRef.startedAt) trajectoryRef.startedAt = Date.now();
|
|
98
|
+
|
|
99
|
+
const _maybePersistTrajectory = async (outcome) => {
|
|
100
|
+
if (!trajectoryRef) return;
|
|
101
|
+
try {
|
|
102
|
+
await _trajPut({
|
|
103
|
+
taskId, agentName: agent.name || 'agent',
|
|
104
|
+
workerProvider: agent.provider, workerModel: agent.model,
|
|
105
|
+
startedAt: trajectoryRef.startedAt || Date.now(),
|
|
106
|
+
endedAt: Date.now(),
|
|
107
|
+
systemPrompt: agent.role || '',
|
|
108
|
+
userMessages: userMessage ? [String(userMessage)] : [],
|
|
109
|
+
turns: toolCalls.map((c, i) => ({
|
|
110
|
+
turnIdx: i, role: 'tool', content: '',
|
|
111
|
+
toolCalls: [{ name: c.name, args: c.input, result: JSON.stringify(c.result), success: c.ok, durationMs: 0 }],
|
|
112
|
+
})).concat(lastText ? [{
|
|
113
|
+
turnIdx: toolCalls.length, role: 'assistant', content: lastText, toolCalls: [],
|
|
114
|
+
}] : []),
|
|
115
|
+
finalAnswer: lastText,
|
|
116
|
+
outcome,
|
|
117
|
+
}, { configDir });
|
|
118
|
+
} catch { /* trajectory failure must not break the agent turn */ }
|
|
119
|
+
};
|
|
94
120
|
|
|
95
121
|
while (iterations < maxIterations) {
|
|
96
122
|
if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
|
|
@@ -102,6 +128,7 @@ export async function runAgentTurn({
|
|
|
102
128
|
if (resp.text) lastText = resp.text;
|
|
103
129
|
|
|
104
130
|
if (resp.kind === 'final') {
|
|
131
|
+
await _maybePersistTrajectory('done');
|
|
105
132
|
return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
|
|
106
133
|
}
|
|
107
134
|
|
|
@@ -118,7 +145,7 @@ export async function runAgentTurn({
|
|
|
118
145
|
try {
|
|
119
146
|
result = await runTool({
|
|
120
147
|
agent, tool: call.name, args: call.input,
|
|
121
|
-
taskId, configDir, cwd,
|
|
148
|
+
taskId, configDir, cwd, approve,
|
|
122
149
|
});
|
|
123
150
|
if (result && result.ok === false) ok = false;
|
|
124
151
|
} catch (err) {
|
|
@@ -139,9 +166,11 @@ export async function runAgentTurn({
|
|
|
139
166
|
// model so it can recover. Only an extraordinary error (e.g. the
|
|
140
167
|
// provider returned a malformed envelope) bails out here.
|
|
141
168
|
if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
|
|
169
|
+
await _maybePersistTrajectory('failed');
|
|
142
170
|
return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
|
|
143
171
|
}
|
|
144
172
|
}
|
|
145
173
|
|
|
174
|
+
await _maybePersistTrajectory('abandoned');
|
|
146
175
|
return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
|
|
147
176
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Confidence calculator for v5 skills — spec §0.1 H2, §3.5.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, no I/O. Used by skill_synth v2 to stamp frontmatter
|
|
4
|
+
// and by trajectory_store recall ranking to weight near-duplicates.
|
|
5
|
+
//
|
|
6
|
+
// wilsonLowerBound(s, n) — 95% Wilson lower bound on success rate.
|
|
7
|
+
// crossCliDampen(score, trainer, provider, factor?)
|
|
8
|
+
// — multiply by `factor` (default 0.85)
|
|
9
|
+
// when trainer provider differs from
|
|
10
|
+
// worker provider (canonical decision
|
|
11
|
+
// §0.1 H2). Phase H2 makes the factor
|
|
12
|
+
// tunable; same-family scores are
|
|
13
|
+
// always returned unchanged.
|
|
14
|
+
// recencyDecay(ageMs, halfLifeMs) — exponential decay weight (0..1].
|
|
15
|
+
// computeConfidence({successes, trials, ageMs, trainer, provider, dampenFactor?})
|
|
16
|
+
// — composed score in [0, 1].
|
|
17
|
+
// resolveDampenFactor(cfg) — pull `crossCliDampenFactor` out of
|
|
18
|
+
// cfg.orchestra.learning (or legacy
|
|
19
|
+
// cfg.orchestrator.learning); clamps
|
|
20
|
+
// to [0,1]; defaults to 0.85 on miss.
|
|
21
|
+
|
|
22
|
+
const Z = 1.96; // 95% confidence two-sided
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CROSS_CLI_DAMPEN = 0.85;
|
|
25
|
+
|
|
26
|
+
export function wilsonLowerBound(successes, trials) {
|
|
27
|
+
const s = Number(successes) || 0;
|
|
28
|
+
const n = Number(trials) || 0;
|
|
29
|
+
if (n <= 0) return 0;
|
|
30
|
+
const phat = s / n;
|
|
31
|
+
const z2 = Z * Z;
|
|
32
|
+
const denom = 1 + z2 / n;
|
|
33
|
+
const center = phat + z2 / (2 * n);
|
|
34
|
+
const margin = Z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n);
|
|
35
|
+
const lb = (center - margin) / denom;
|
|
36
|
+
return Math.max(0, Math.min(1, lb));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROVIDER_FAMILY = {
|
|
40
|
+
'claude-cli': 'anthropic',
|
|
41
|
+
'anthropic': 'anthropic',
|
|
42
|
+
'codex-cli': 'openai',
|
|
43
|
+
'openai': 'openai',
|
|
44
|
+
'gemini-cli': 'gemini',
|
|
45
|
+
'gemini': 'gemini',
|
|
46
|
+
'ollama': 'ollama',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function sameFamily(a, b) {
|
|
50
|
+
if (!a || !b) return false;
|
|
51
|
+
return PROVIDER_FAMILY[a] === PROVIDER_FAMILY[b];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Normalise a caller-supplied dampening factor. Anything non-finite (NaN,
|
|
55
|
+
// non-numeric strings, undefined) falls back to the canonical 0.85 so a
|
|
56
|
+
// typo in config does not silently zero out every cross-CLI skill.
|
|
57
|
+
function _normalizeFactor(factor) {
|
|
58
|
+
if (factor === null || factor === undefined) return DEFAULT_CROSS_CLI_DAMPEN;
|
|
59
|
+
const n = Number(factor);
|
|
60
|
+
if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
|
|
61
|
+
if (n < 0) return 0;
|
|
62
|
+
if (n > 1) return 1;
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function crossCliDampen(score, trainerProvider, workerProvider, factor) {
|
|
67
|
+
if (!trainerProvider || !workerProvider) return score;
|
|
68
|
+
if (sameFamily(trainerProvider, workerProvider)) return score;
|
|
69
|
+
return score * _normalizeFactor(factor);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recencyDecay(ageMs, halfLifeMs = 30 * 24 * 60 * 60 * 1000) {
|
|
73
|
+
const t = Math.max(0, Number(ageMs) || 0);
|
|
74
|
+
const hl = Math.max(1, Number(halfLifeMs) || 1);
|
|
75
|
+
return Math.pow(0.5, t / hl);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function computeConfidence({
|
|
79
|
+
successes = 0,
|
|
80
|
+
trials = 0,
|
|
81
|
+
ageMs = 0,
|
|
82
|
+
trainerProvider = null,
|
|
83
|
+
workerProvider = null,
|
|
84
|
+
halfLifeMs,
|
|
85
|
+
dampenFactor,
|
|
86
|
+
} = {}) {
|
|
87
|
+
const base = wilsonLowerBound(successes, trials);
|
|
88
|
+
const decayed = base * recencyDecay(ageMs, halfLifeMs);
|
|
89
|
+
const dampened = crossCliDampen(decayed, trainerProvider, workerProvider, dampenFactor);
|
|
90
|
+
return Math.max(0, Math.min(1, dampened));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pull the tunable factor out of config. Honours both the canonical
|
|
94
|
+
// `cfg.orchestra.learning.crossCliDampenFactor` shape and the legacy
|
|
95
|
+
// `cfg.orchestrator.learning.crossCliDampenFactor` (v4 callers). Returns
|
|
96
|
+
// 0.85 when missing/invalid so callers never have to gate on undefined.
|
|
97
|
+
export function resolveDampenFactor(cfg) {
|
|
98
|
+
if (!cfg || typeof cfg !== 'object') return DEFAULT_CROSS_CLI_DAMPEN;
|
|
99
|
+
const orchestra = cfg.orchestra || cfg.orchestrator || {};
|
|
100
|
+
const learning = orchestra.learning || {};
|
|
101
|
+
const raw = learning.crossCliDampenFactor;
|
|
102
|
+
if (raw === undefined || raw === null) return DEFAULT_CROSS_CLI_DAMPEN;
|
|
103
|
+
const n = Number(raw);
|
|
104
|
+
if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
|
|
105
|
+
if (n < 0) return 0;
|
|
106
|
+
if (n > 1) return 1;
|
|
107
|
+
return n;
|
|
108
|
+
}
|