@securecoms/agent-runtime 0.7.0 → 0.9.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/README.md +87 -7
- package/dist/bin.js +2890 -297
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,17 +1,1052 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
5
5
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6
|
-
import { dirname as
|
|
6
|
+
import { dirname as dirname7, join as join8 } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { accessSync, constants, existsSync as existsSync6, mkdirSync, realpathSync, rmSync, statSync as
|
|
8
|
+
import { accessSync, constants, existsSync as existsSync6, mkdirSync as mkdirSync4, realpathSync, rmSync, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
9
9
|
import { homedir as homedir4, userInfo } from "node:os";
|
|
10
10
|
|
|
11
11
|
// src/config.ts
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname as dirname3, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
// src/service-log.ts
|
|
17
|
+
var LogLabel = class _LogLabel {
|
|
18
|
+
constructor(value) {
|
|
19
|
+
this.value = value;
|
|
20
|
+
}
|
|
21
|
+
static of(value) {
|
|
22
|
+
return new _LogLabel(LABEL.test(value) ? value : "invalid");
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function logLabel(value) {
|
|
26
|
+
return LogLabel.of(value);
|
|
27
|
+
}
|
|
28
|
+
var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
29
|
+
var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
30
|
+
var LABEL = /^[A-Za-z0-9][A-Za-z0-9_.:/@+-]{0,127}$/;
|
|
31
|
+
function errorClass(error) {
|
|
32
|
+
if (error === null) return "NullThrown";
|
|
33
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
34
|
+
const primitive = {
|
|
35
|
+
string: "StringThrown",
|
|
36
|
+
number: "NumberThrown",
|
|
37
|
+
boolean: "BooleanThrown",
|
|
38
|
+
bigint: "BigIntThrown",
|
|
39
|
+
symbol: "SymbolThrown",
|
|
40
|
+
undefined: "UndefinedThrown"
|
|
41
|
+
};
|
|
42
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
46
|
+
return typeof name === "string" && CLASS.test(name) ? name : "Error";
|
|
47
|
+
} catch {
|
|
48
|
+
return "Error";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function formatServiceLog(level, event, fields = {}, error) {
|
|
52
|
+
const safeEvent = TOKEN.test(event) ? event : "invalid_event";
|
|
53
|
+
const parts = [`[runtime] ${level} ${safeEvent}`];
|
|
54
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
55
|
+
if (!TOKEN.test(key)) continue;
|
|
56
|
+
if (typeof value === "boolean") parts.push(`${key}=${value}`);
|
|
57
|
+
else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
|
|
58
|
+
else if (value instanceof LogLabel) parts.push(`${key}=${LABEL.test(value.value) ? value.value : "invalid"}`);
|
|
59
|
+
}
|
|
60
|
+
if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
|
|
61
|
+
return parts.join(" ");
|
|
62
|
+
}
|
|
63
|
+
function failureEpisodeReporter(warn) {
|
|
64
|
+
let lastClass = null;
|
|
65
|
+
return {
|
|
66
|
+
failed(error) {
|
|
67
|
+
const cls = errorClass(error);
|
|
68
|
+
if (cls === lastClass) return;
|
|
69
|
+
lastClass = cls;
|
|
70
|
+
try {
|
|
71
|
+
warn(error);
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
succeeded() {
|
|
76
|
+
lastClass = null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function writeServiceLog(level, event, fields, error) {
|
|
81
|
+
const line = formatServiceLog(level, event, fields, error);
|
|
82
|
+
if (level === "error") console.error(line);
|
|
83
|
+
else if (level === "warn") console.warn(line);
|
|
84
|
+
else console.log(line);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/llm-dispatch.ts
|
|
88
|
+
var MAX_REPORTED_TOKENS_PER_CALL = 1e7;
|
|
89
|
+
function requestTokenBound(encodedBody, cap) {
|
|
90
|
+
return Math.min(Buffer.byteLength(encodedBody, "utf8") + cap.cap, MAX_REPORTED_TOKENS_PER_CALL);
|
|
91
|
+
}
|
|
92
|
+
function tokenCount(value, max) {
|
|
93
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
|
|
94
|
+
return Math.min(Math.floor(value), max);
|
|
95
|
+
}
|
|
96
|
+
var NEVER_SENT_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
|
|
97
|
+
function neverSent(error) {
|
|
98
|
+
const cause = error?.cause;
|
|
99
|
+
if (!cause || typeof cause !== "object") return false;
|
|
100
|
+
if (typeof cause.code === "string" && NEVER_SENT_CODES.has(cause.code)) return true;
|
|
101
|
+
const errors = cause.errors;
|
|
102
|
+
return Array.isArray(errors) && errors.length > 0 && errors.every((e) => typeof e?.code === "string" && NEVER_SENT_CODES.has(e.code));
|
|
103
|
+
}
|
|
104
|
+
var failedCallTokens = /* @__PURE__ */ new WeakMap();
|
|
105
|
+
function reportedTokensOf(error) {
|
|
106
|
+
return typeof error === "object" && error !== null ? failedCallTokens.get(error) : void 0;
|
|
107
|
+
}
|
|
108
|
+
var LLM_REQUEST_MAX_BYTES = 128 * 1024;
|
|
109
|
+
var LLM_RESPONSE_MAX_BYTES = 128 * 1024;
|
|
110
|
+
var LLM_REQUEST_TIMEOUT_MS = 18e4;
|
|
111
|
+
var DEFAULT_MAX_OUTPUT_TOKENS = 8192;
|
|
112
|
+
var CAP_RETRY_FALLBACK_TOKENS = 4096;
|
|
113
|
+
var MIN_PARSED_OUTPUT_LIMIT = 256;
|
|
114
|
+
var CAP_REJECTED_MAX = 256;
|
|
115
|
+
var capOverrides = /* @__PURE__ */ new Map();
|
|
116
|
+
function remember(key, cap) {
|
|
117
|
+
if (capOverrides.size >= CAP_REJECTED_MAX && !capOverrides.has(key)) {
|
|
118
|
+
const oldest = capOverrides.keys().next();
|
|
119
|
+
if (!oldest.done) capOverrides.delete(oldest.value);
|
|
120
|
+
}
|
|
121
|
+
capOverrides.set(key, cap);
|
|
122
|
+
}
|
|
123
|
+
var CAP_ERROR_SCAN_BYTES = 16 * 1024;
|
|
124
|
+
var CAP_FIELD_NAMED = /max_(?:completion_|output_)?tokens/i;
|
|
125
|
+
var CONTEXT_OVERFLOW_NAMED = /prompt|context|input[\s_-]*(?:length|tokens)/i;
|
|
126
|
+
async function readCapRejection(resp, sent, maxTokens) {
|
|
127
|
+
const none = { retry: null, tokens: void 0 };
|
|
128
|
+
const reader = resp.body?.getReader();
|
|
129
|
+
if (!reader) return none;
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let total = 0;
|
|
132
|
+
try {
|
|
133
|
+
while (total < CAP_ERROR_SCAN_BYTES) {
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
if (!value) continue;
|
|
137
|
+
chunks.push(value);
|
|
138
|
+
total += value.byteLength;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
return none;
|
|
142
|
+
} finally {
|
|
143
|
+
await reader.cancel().catch(() => {
|
|
144
|
+
});
|
|
145
|
+
reader.releaseLock();
|
|
146
|
+
}
|
|
147
|
+
const text = new TextDecoder().decode(Buffer.concat(chunks).subarray(0, CAP_ERROR_SCAN_BYTES));
|
|
148
|
+
let tokens;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(text);
|
|
151
|
+
tokens = tokenCount(parsed?.usage?.total_tokens, maxTokens);
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
if (!CAP_FIELD_NAMED.test(text) || CONTEXT_OVERFLOW_NAMED.test(text)) return { retry: null, tokens };
|
|
155
|
+
let stated;
|
|
156
|
+
for (const match of text.matchAll(STATED_LIMIT)) {
|
|
157
|
+
const n = Number(match[1]);
|
|
158
|
+
if (n >= MIN_PARSED_OUTPUT_LIMIT && n < sent.cap && (stated === void 0 || n > stated)) stated = n;
|
|
159
|
+
}
|
|
160
|
+
const other = sent.field === "max_tokens" ? "max_completion_tokens" : "max_tokens";
|
|
161
|
+
const namesOther = other === "max_tokens" ? /(?<![a-z_])max_tokens/i.test(text) : /max_completion_tokens/i.test(text);
|
|
162
|
+
if (namesOther) return { retry: { field: other, cap: stated ?? sent.cap }, tokens };
|
|
163
|
+
const cap = stated ?? capFallback(sent.cap);
|
|
164
|
+
return { retry: cap >= 1 ? { field: sent.field, cap } : null, tokens };
|
|
165
|
+
}
|
|
166
|
+
var STATED_LIMIT = /(?:>|<=|≤|at most|up to|no more than|not exceed|less than or equal to|maximum(?: of| is|:)?|limit(?: of| is|:)?)\s*[`'"]?(\d{1,9})(?![\d.])/gi;
|
|
167
|
+
function capFallback(rejected) {
|
|
168
|
+
return Math.min(CAP_RETRY_FALLBACK_TOKENS, Math.floor(rejected / 2));
|
|
169
|
+
}
|
|
170
|
+
function outputCapField(endpoint) {
|
|
171
|
+
try {
|
|
172
|
+
return new URL(endpoint).hostname === "api.openai.com" ? "max_completion_tokens" : "max_tokens";
|
|
173
|
+
} catch {
|
|
174
|
+
return "max_tokens";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function normalizeEndpoint(endpoint) {
|
|
178
|
+
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
179
|
+
}
|
|
180
|
+
var LLMDispatcher = class {
|
|
181
|
+
endpoint;
|
|
182
|
+
model;
|
|
183
|
+
apiKey;
|
|
184
|
+
chatPath;
|
|
185
|
+
chatAuth;
|
|
186
|
+
authHeaderName;
|
|
187
|
+
fetch;
|
|
188
|
+
timeoutMs;
|
|
189
|
+
requestMaxBytes;
|
|
190
|
+
responseMaxBytes;
|
|
191
|
+
maxOutputTokens;
|
|
192
|
+
log;
|
|
193
|
+
constructor(endpoint, model, opts = {}) {
|
|
194
|
+
this.endpoint = normalizeEndpoint(endpoint);
|
|
195
|
+
this.model = model;
|
|
196
|
+
this.apiKey = opts.apiKey;
|
|
197
|
+
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
198
|
+
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
199
|
+
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
200
|
+
this.fetch = opts.fetch ?? fetch;
|
|
201
|
+
this.timeoutMs = opts.timeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
|
|
202
|
+
this.requestMaxBytes = opts.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
|
|
203
|
+
this.responseMaxBytes = opts.responseMaxBytes ?? LLM_RESPONSE_MAX_BYTES;
|
|
204
|
+
this.maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
205
|
+
this.log = opts.log ?? writeServiceLog;
|
|
206
|
+
}
|
|
207
|
+
async complete(messages, tools, options = {}) {
|
|
208
|
+
const body = {
|
|
209
|
+
model: this.model,
|
|
210
|
+
messages,
|
|
211
|
+
stream: false
|
|
212
|
+
};
|
|
213
|
+
const capKey = `${this.endpoint}\0${this.model}`;
|
|
214
|
+
if (tools && tools.length > 0) {
|
|
215
|
+
body.tools = tools;
|
|
216
|
+
}
|
|
217
|
+
const url = `${this.endpoint}${this.chatPath}`;
|
|
218
|
+
const headers = { "Content-Type": "application/json" };
|
|
219
|
+
if (this.apiKey && this.chatAuth === "bearer") {
|
|
220
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
221
|
+
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
222
|
+
headers[this.authHeaderName] = this.apiKey;
|
|
223
|
+
}
|
|
224
|
+
const encode = (cap2) => {
|
|
225
|
+
const encoded = JSON.stringify({ ...body, [cap2.field]: cap2.cap });
|
|
226
|
+
if (Buffer.byteLength(encoded, "utf8") > this.requestMaxBytes) {
|
|
227
|
+
throw new Error("LLM request exceeded the size limit");
|
|
228
|
+
}
|
|
229
|
+
return encoded;
|
|
230
|
+
};
|
|
231
|
+
const override = capOverrides.get(capKey);
|
|
232
|
+
let cap = override ? { field: override.field, cap: Math.min(override.cap, this.maxOutputTokens) } : { field: outputCapField(this.endpoint), cap: this.maxOutputTokens };
|
|
233
|
+
let encodedBody = encode(cap);
|
|
234
|
+
let reportedTokens = 0;
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
let timedOut = false;
|
|
237
|
+
const cancel = () => controller.abort(options.signal?.reason);
|
|
238
|
+
if (options.signal?.aborted) cancel();
|
|
239
|
+
else options.signal?.addEventListener("abort", cancel, { once: true });
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
timedOut = true;
|
|
242
|
+
controller.abort(new Error("LLM request deadline exceeded"));
|
|
243
|
+
}, this.timeoutMs);
|
|
244
|
+
let settle;
|
|
245
|
+
let abortedAtDispatch = false;
|
|
246
|
+
const settleWith = (outcome) => {
|
|
247
|
+
const s = settle;
|
|
248
|
+
settle = void 0;
|
|
249
|
+
s?.(outcome);
|
|
250
|
+
};
|
|
251
|
+
const usageOutcome = (tokens) => tokens !== void 0 ? { kind: "reported", tokens } : { kind: "unreported" };
|
|
252
|
+
try {
|
|
253
|
+
const post = () => {
|
|
254
|
+
settle = options.meter?.(requestTokenBound(encodedBody, cap));
|
|
255
|
+
abortedAtDispatch = controller.signal.aborted;
|
|
256
|
+
return this.fetch(url, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers,
|
|
259
|
+
body: encodedBody,
|
|
260
|
+
signal: controller.signal,
|
|
261
|
+
redirect: "error"
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
let resp = await post();
|
|
265
|
+
if (resp.status === 400) {
|
|
266
|
+
const rejection = await readCapRejection(resp, cap, requestTokenBound(encodedBody, cap));
|
|
267
|
+
reportedTokens += rejection.tokens ?? 0;
|
|
268
|
+
settleWith(usageOutcome(rejection.tokens));
|
|
269
|
+
if (rejection.retry) {
|
|
270
|
+
const retry = rejection.retry;
|
|
271
|
+
this.log("warn", "llm.output_cap_rejected", {
|
|
272
|
+
retry_cap: retry.cap,
|
|
273
|
+
field_switched: retry.field !== cap.field
|
|
274
|
+
});
|
|
275
|
+
const rejected = cap;
|
|
276
|
+
cap = retry;
|
|
277
|
+
encodedBody = encode(cap);
|
|
278
|
+
options.beforeRetry?.();
|
|
279
|
+
resp = await post();
|
|
280
|
+
if (resp.ok) remember(capKey, retry);
|
|
281
|
+
else if (resp.status === 400) {
|
|
282
|
+
remember(capKey, { field: retry.field, cap: Math.min(retry.cap, capFallback(rejected.cap)) });
|
|
283
|
+
}
|
|
284
|
+
if (resp.status === 400) {
|
|
285
|
+
const second = (await readCapRejection(resp, cap, requestTokenBound(encodedBody, cap))).tokens;
|
|
286
|
+
reportedTokens += second ?? 0;
|
|
287
|
+
settleWith(usageOutcome(second));
|
|
288
|
+
throw new Error("LLM request failed (400)");
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error("LLM request failed (400)");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!resp.ok) {
|
|
295
|
+
await resp.body?.cancel();
|
|
296
|
+
settleWith({ kind: "unreported" });
|
|
297
|
+
throw new Error(`LLM request failed (${resp.status})`);
|
|
298
|
+
}
|
|
299
|
+
const data = await readJsonCapped(resp, this.responseMaxBytes);
|
|
300
|
+
const maxTokens = requestTokenBound(encodedBody, cap);
|
|
301
|
+
const totalTokens = tokenCount(data?.usage?.total_tokens, maxTokens);
|
|
302
|
+
const promptTokens = tokenCount(data?.usage?.prompt_tokens, maxTokens);
|
|
303
|
+
const completionTokens = tokenCount(data?.usage?.completion_tokens, maxTokens);
|
|
304
|
+
if (totalTokens !== void 0) reportedTokens += totalTokens;
|
|
305
|
+
settleWith(usageOutcome(totalTokens));
|
|
306
|
+
const choice = data?.choices?.[0];
|
|
307
|
+
if (!choice?.message) throw new Error("LLM returned no choices");
|
|
308
|
+
return {
|
|
309
|
+
content: choice.message.content,
|
|
310
|
+
toolCalls: choice.message.tool_calls || [],
|
|
311
|
+
finishReason: choice.finish_reason,
|
|
312
|
+
...reportedTokens > 0 || totalTokens !== void 0 ? {
|
|
313
|
+
usage: {
|
|
314
|
+
// Every attempt's reported tokens: a rejected first attempt
|
|
315
|
+
// was billed too.
|
|
316
|
+
totalTokens: reportedTokens,
|
|
317
|
+
...promptTokens !== void 0 ? { promptTokens } : {},
|
|
318
|
+
...completionTokens !== void 0 ? { completionTokens } : {}
|
|
319
|
+
}
|
|
320
|
+
} : {}
|
|
321
|
+
};
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (settle) {
|
|
324
|
+
const provablyNotSent = abortedAtDispatch || !timedOut && !options.signal?.aborted && neverSent(error);
|
|
325
|
+
settleWith(provablyNotSent ? { kind: "not_sent" } : { kind: "unreported" });
|
|
326
|
+
}
|
|
327
|
+
const failure = timedOut ? new Error(`LLM request timed out after ${this.timeoutMs}ms`) : options.signal?.aborted ? new Error("LLM request cancelled") : error;
|
|
328
|
+
if (reportedTokens > 0 && typeof failure === "object" && failure !== null) {
|
|
329
|
+
failedCallTokens.set(failure, reportedTokens);
|
|
330
|
+
}
|
|
331
|
+
throw failure;
|
|
332
|
+
} finally {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
async function readJsonCapped(resp, maxBytes) {
|
|
339
|
+
const reader = resp.body?.getReader();
|
|
340
|
+
if (!reader) throw new Error("LLM returned an empty response");
|
|
341
|
+
const chunks = [];
|
|
342
|
+
let total = 0;
|
|
343
|
+
try {
|
|
344
|
+
while (true) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (done) break;
|
|
347
|
+
if (!value) continue;
|
|
348
|
+
total += value.byteLength;
|
|
349
|
+
if (total > maxBytes) {
|
|
350
|
+
await reader.cancel().catch(() => {
|
|
351
|
+
});
|
|
352
|
+
throw new Error("LLM response exceeded the size limit");
|
|
353
|
+
}
|
|
354
|
+
chunks.push(value);
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
await reader.cancel().catch(() => {
|
|
358
|
+
});
|
|
359
|
+
throw error;
|
|
360
|
+
} finally {
|
|
361
|
+
reader.releaseLock();
|
|
362
|
+
}
|
|
363
|
+
const joined = new Uint8Array(total);
|
|
364
|
+
let offset = 0;
|
|
365
|
+
for (const chunk of chunks) {
|
|
366
|
+
joined.set(chunk, offset);
|
|
367
|
+
offset += chunk.byteLength;
|
|
368
|
+
}
|
|
369
|
+
return JSON.parse(new TextDecoder().decode(joined));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/llm-budget.ts
|
|
373
|
+
import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync2, renameSync, writeSync as writeSync2 } from "node:fs";
|
|
374
|
+
import { basename, dirname as dirname2 } from "node:path";
|
|
375
|
+
|
|
376
|
+
// src/llm-budget-lock.ts
|
|
377
|
+
import { execFileSync } from "node:child_process";
|
|
378
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, utimesSync, writeSync } from "node:fs";
|
|
379
|
+
import { hostname, uptime } from "node:os";
|
|
380
|
+
import { dirname } from "node:path";
|
|
381
|
+
var LOCK_PARTIAL_WRITE_GRACE_MS = 1e4;
|
|
382
|
+
var LOCK_TAKEOVER_GUARD_STALE_MS = 3e4;
|
|
383
|
+
var BOOT_TOLERANCE_SEC = 120;
|
|
384
|
+
var LOCK_HEARTBEAT_MS = 6e4;
|
|
385
|
+
var LOCK_FOREIGN_STALE_MS = 10 * LOCK_HEARTBEAT_MS;
|
|
386
|
+
function errno(error) {
|
|
387
|
+
const code = error?.code;
|
|
388
|
+
return typeof code === "string" ? code : void 0;
|
|
389
|
+
}
|
|
390
|
+
function readTrimmed(path) {
|
|
391
|
+
try {
|
|
392
|
+
const v = readFileSync(path, "utf8").trim();
|
|
393
|
+
return v === "" ? void 0 : v;
|
|
394
|
+
} catch {
|
|
395
|
+
return void 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function procStartFromStat(stat) {
|
|
399
|
+
const close = stat.lastIndexOf(")");
|
|
400
|
+
if (close < 0) return void 0;
|
|
401
|
+
const rest = stat.slice(close + 1).trim().split(/\s+/);
|
|
402
|
+
const v = rest[19];
|
|
403
|
+
return v !== void 0 && /^\d+$/.test(v) ? v : void 0;
|
|
404
|
+
}
|
|
405
|
+
function pidAliveByKill(pid) {
|
|
406
|
+
try {
|
|
407
|
+
process.kill(pid, 0);
|
|
408
|
+
return true;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return errno(error) === "EPERM";
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
var bootIdCache;
|
|
414
|
+
function readBootId() {
|
|
415
|
+
if (bootIdCache) return bootIdCache.v;
|
|
416
|
+
let v = readTrimmed("/proc/sys/kernel/random/boot_id");
|
|
417
|
+
if (v === void 0 && process.platform === "darwin") {
|
|
418
|
+
try {
|
|
419
|
+
const out = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
|
|
420
|
+
encoding: "utf8",
|
|
421
|
+
timeout: 2e3,
|
|
422
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
423
|
+
}).trim();
|
|
424
|
+
v = /^[0-9A-Fa-f-]{16,64}$/.test(out) ? out : void 0;
|
|
425
|
+
} catch {
|
|
426
|
+
v = void 0;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
bootIdCache = { v };
|
|
430
|
+
return v;
|
|
431
|
+
}
|
|
432
|
+
var realLockEnv = {
|
|
433
|
+
pid: process.pid,
|
|
434
|
+
hostname: () => hostname(),
|
|
435
|
+
bootId: readBootId,
|
|
436
|
+
bootEpochSec: () => Math.round(Date.now() / 1e3 - uptime()),
|
|
437
|
+
procStart: (pid) => {
|
|
438
|
+
const stat = readTrimmed(`/proc/${pid}/stat`);
|
|
439
|
+
return stat ? procStartFromStat(stat) : void 0;
|
|
440
|
+
},
|
|
441
|
+
pidAlive: pidAliveByKill
|
|
442
|
+
};
|
|
443
|
+
function parseBody(raw) {
|
|
444
|
+
try {
|
|
445
|
+
const b = JSON.parse(raw);
|
|
446
|
+
if (b && b.v === 2 && Number.isInteger(b.pid) && b.pid > 0 && typeof b.host === "string" && Number.isFinite(b.boot)) {
|
|
447
|
+
return b;
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
return void 0;
|
|
452
|
+
}
|
|
453
|
+
var BudgetLock = class {
|
|
454
|
+
constructor(path, env = realLockEnv, log = () => {
|
|
455
|
+
}, heartbeatMs = LOCK_HEARTBEAT_MS) {
|
|
456
|
+
this.path = path;
|
|
457
|
+
this.env = env;
|
|
458
|
+
this.log = log;
|
|
459
|
+
this.heartbeatMs = heartbeatMs;
|
|
460
|
+
}
|
|
461
|
+
held = false;
|
|
462
|
+
/** The exact body this process wrote (#808 N1): release and the ownership
|
|
463
|
+
* check compare the file against THIS, never against live machine facts
|
|
464
|
+
* (a hostname can change under a running process). */
|
|
465
|
+
written;
|
|
466
|
+
heartbeat;
|
|
467
|
+
/** The error behind the last `unreadable` / `failed`, for the caller's one
|
|
468
|
+
* log line (the lock itself never logs a refusal: it is retried per call). */
|
|
469
|
+
lastError;
|
|
470
|
+
body() {
|
|
471
|
+
const bootId = this.env.bootId();
|
|
472
|
+
const start = this.env.procStart(this.env.pid);
|
|
473
|
+
const b = {
|
|
474
|
+
v: 2,
|
|
475
|
+
pid: this.env.pid,
|
|
476
|
+
host: this.env.hostname(),
|
|
477
|
+
...bootId !== void 0 ? { boot_id: bootId } : {},
|
|
478
|
+
boot: this.env.bootEpochSec(),
|
|
479
|
+
...start !== void 0 ? { start } : {}
|
|
480
|
+
};
|
|
481
|
+
return JSON.stringify(b);
|
|
482
|
+
}
|
|
483
|
+
/** Try to hold the lock. Never throws (#808 L-e: machine reads included). */
|
|
484
|
+
acquire() {
|
|
485
|
+
try {
|
|
486
|
+
return this.acquireUnguarded();
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.lastError = error;
|
|
489
|
+
return "failed";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* True while the file is still exactly the body this process wrote. Read
|
|
494
|
+
* before every reserve (one small read), so a holder whose lock was taken
|
|
495
|
+
* while it was frozen stops before it spends (#808 N1). A lost lock is
|
|
496
|
+
* dropped: the caller then refuses, and later calls try to re-acquire.
|
|
497
|
+
*/
|
|
498
|
+
stillHeld() {
|
|
499
|
+
if (!this.held) return false;
|
|
500
|
+
let current;
|
|
501
|
+
try {
|
|
502
|
+
current = readFileSync(this.path, "utf8");
|
|
503
|
+
} catch {
|
|
504
|
+
current = void 0;
|
|
505
|
+
}
|
|
506
|
+
if (current !== void 0 && current === this.written) return true;
|
|
507
|
+
this.drop();
|
|
508
|
+
this.log("warn", "llm.budget_lock_lost", {});
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
acquireUnguarded() {
|
|
512
|
+
if (this.held) return "acquired";
|
|
513
|
+
const created = this.create();
|
|
514
|
+
if (created === "created") return this.took();
|
|
515
|
+
if (created === "failed") return "failed";
|
|
516
|
+
let raw;
|
|
517
|
+
let ageMs;
|
|
518
|
+
this.env.beforeLockRead?.();
|
|
519
|
+
try {
|
|
520
|
+
raw = readFileSync(this.path, "utf8");
|
|
521
|
+
ageMs = Date.now() - statSync(this.path).mtimeMs;
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (errno(error) === "ENOENT") {
|
|
524
|
+
const again = this.create();
|
|
525
|
+
return again === "created" ? this.took() : again === "failed" ? "failed" : "busy";
|
|
526
|
+
}
|
|
527
|
+
this.lastError = error;
|
|
528
|
+
return "unreadable";
|
|
529
|
+
}
|
|
530
|
+
const b = parseBody(raw);
|
|
531
|
+
if (!b) {
|
|
532
|
+
if (ageMs < LOCK_PARTIAL_WRITE_GRACE_MS) return "busy";
|
|
533
|
+
return this.takeOver(raw, void 0);
|
|
534
|
+
}
|
|
535
|
+
if (b.host !== this.env.hostname()) {
|
|
536
|
+
const bootId = this.env.bootId();
|
|
537
|
+
const sameKernel = b.boot_id !== void 0 && bootId !== void 0 && b.boot_id === bootId;
|
|
538
|
+
if (sameKernel && ageMs > LOCK_FOREIGN_STALE_MS) return this.takeOver(raw, b.pid);
|
|
539
|
+
return "foreign_host";
|
|
540
|
+
}
|
|
541
|
+
if (!this.sameBoot(b)) return this.takeOver(raw, b.pid);
|
|
542
|
+
const liveStart = this.env.procStart(b.pid);
|
|
543
|
+
const reused = b.start !== void 0 && liveStart !== void 0 && liveStart !== b.start;
|
|
544
|
+
if (b.pid === this.env.pid) {
|
|
545
|
+
if (!reused) return this.took();
|
|
546
|
+
return this.takeOver(raw, b.pid);
|
|
547
|
+
}
|
|
548
|
+
if (this.env.pidAlive(b.pid) && !reused) return "held";
|
|
549
|
+
return this.takeOver(raw, b.pid);
|
|
550
|
+
}
|
|
551
|
+
/** Remove the lock only while it is still exactly the body this process
|
|
552
|
+
* wrote (#808 N1: not the live hostname, which can change under us). */
|
|
553
|
+
release() {
|
|
554
|
+
if (!this.held) return;
|
|
555
|
+
const written = this.written;
|
|
556
|
+
this.drop();
|
|
557
|
+
try {
|
|
558
|
+
if (written !== void 0 && readFileSync(this.path, "utf8") === written) unlinkSync(this.path);
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
drop() {
|
|
563
|
+
this.held = false;
|
|
564
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
565
|
+
this.heartbeat = void 0;
|
|
566
|
+
}
|
|
567
|
+
/** We hold it: remember what we wrote (the file as it is now, ours) and
|
|
568
|
+
* start the heartbeat. */
|
|
569
|
+
took() {
|
|
570
|
+
this.held = true;
|
|
571
|
+
try {
|
|
572
|
+
this.written = readFileSync(this.path, "utf8");
|
|
573
|
+
} catch {
|
|
574
|
+
this.written = void 0;
|
|
575
|
+
}
|
|
576
|
+
if (!this.heartbeat && this.heartbeatMs > 0) {
|
|
577
|
+
this.heartbeat = setInterval(() => this.beat(), this.heartbeatMs);
|
|
578
|
+
this.heartbeat.unref?.();
|
|
579
|
+
}
|
|
580
|
+
return "acquired";
|
|
581
|
+
}
|
|
582
|
+
/** Refresh the mtime while the file is still ours; stop if it is not. */
|
|
583
|
+
beat() {
|
|
584
|
+
if (!this.stillHeld()) return;
|
|
585
|
+
try {
|
|
586
|
+
const now = /* @__PURE__ */ new Date();
|
|
587
|
+
utimesSync(this.path, now, now);
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
sameBoot(b) {
|
|
592
|
+
const bootId = this.env.bootId();
|
|
593
|
+
if (b.boot_id !== void 0 && bootId !== void 0) return b.boot_id === bootId;
|
|
594
|
+
return Math.abs(b.boot - this.env.bootEpochSec()) <= BOOT_TOLERANCE_SEC;
|
|
595
|
+
}
|
|
596
|
+
/** O_EXCL create with our body. A write failure removes the half-written
|
|
597
|
+
* file (it is ours: we created it) and reports `failed`, never throws. */
|
|
598
|
+
create() {
|
|
599
|
+
let fd;
|
|
600
|
+
try {
|
|
601
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
602
|
+
fd = openSync(this.path, "wx", 384);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (errno(error) === "EEXIST") return "exists";
|
|
605
|
+
this.lastError = error;
|
|
606
|
+
return "failed";
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
writeSync(fd, this.body());
|
|
610
|
+
fsyncSync(fd);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
try {
|
|
613
|
+
closeSync(fd);
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
unlinkSync(this.path);
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
this.lastError = error;
|
|
621
|
+
return "failed";
|
|
622
|
+
}
|
|
623
|
+
closeSync(fd);
|
|
624
|
+
return "created";
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Replace a stale lock. Only the holder of the O_EXCL takeover guard may
|
|
628
|
+
* remove a lock, and only after re-reading it UNCHANGED, so two runtimes
|
|
629
|
+
* that both saw the same stale lock cannot each delete the other's new one.
|
|
630
|
+
* A fresh guard means a takeover is in progress: busy this time.
|
|
631
|
+
*/
|
|
632
|
+
takeOver(staleRaw, stalePid, retryGuard = true) {
|
|
633
|
+
const guard = `${this.path}.takeover`;
|
|
634
|
+
let fd;
|
|
635
|
+
try {
|
|
636
|
+
fd = openSync(guard, "wx", 384);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (errno(error) !== "EEXIST") return "failed";
|
|
639
|
+
let crashed = false;
|
|
640
|
+
try {
|
|
641
|
+
crashed = Date.now() - statSync(guard).mtimeMs > LOCK_TAKEOVER_GUARD_STALE_MS;
|
|
642
|
+
if (crashed) unlinkSync(guard);
|
|
643
|
+
} catch {
|
|
644
|
+
crashed = false;
|
|
645
|
+
}
|
|
646
|
+
if (crashed && retryGuard) return this.takeOver(staleRaw, stalePid, false);
|
|
647
|
+
return "busy";
|
|
648
|
+
}
|
|
649
|
+
closeSync(fd);
|
|
650
|
+
try {
|
|
651
|
+
this.env.beforeTakeoverReread?.();
|
|
652
|
+
let current;
|
|
653
|
+
try {
|
|
654
|
+
current = readFileSync(this.path, "utf8");
|
|
655
|
+
} catch {
|
|
656
|
+
current = void 0;
|
|
657
|
+
}
|
|
658
|
+
if (current !== void 0 && current !== staleRaw) return "held";
|
|
659
|
+
if (current !== void 0) unlinkSync(this.path);
|
|
660
|
+
this.env.beforeTakeoverCreate?.();
|
|
661
|
+
const created = this.create();
|
|
662
|
+
if (created !== "created") return created === "failed" ? "failed" : "held";
|
|
663
|
+
this.log("warn", "llm.budget_lock_stale_taken_over", stalePid !== void 0 ? { stale_pid: stalePid } : {});
|
|
664
|
+
return this.took();
|
|
665
|
+
} catch (error) {
|
|
666
|
+
this.lastError = error;
|
|
667
|
+
return "failed";
|
|
668
|
+
} finally {
|
|
669
|
+
try {
|
|
670
|
+
unlinkSync(guard);
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/llm-budget.ts
|
|
678
|
+
var DEFAULT_DAILY_CALL_LIMIT = 2e3;
|
|
679
|
+
var DEFAULT_DAILY_TOKEN_LIMIT = 5e6;
|
|
680
|
+
var DailyLimitReachedError = class extends Error {
|
|
681
|
+
constructor(day) {
|
|
682
|
+
super("daily LLM budget reached");
|
|
683
|
+
this.day = day;
|
|
684
|
+
this.name = "DailyLimitReachedError";
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
var RequestExceedsDailyTokenCapError = class extends Error {
|
|
688
|
+
constructor(reserve, tokenLimit) {
|
|
689
|
+
super("one LLM request's worst case exceeds the daily token limit");
|
|
690
|
+
this.reserve = reserve;
|
|
691
|
+
this.tokenLimit = tokenLimit;
|
|
692
|
+
this.name = "RequestExceedsDailyTokenCapError";
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
var BudgetLockHeldError = class extends Error {
|
|
696
|
+
constructor(reason = "held") {
|
|
697
|
+
super("this runtime does not hold its identity's LLM budget lock");
|
|
698
|
+
this.reason = reason;
|
|
699
|
+
this.name = "BudgetLockHeldError";
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
function logLockRefusal(log, level, reason, lockPath, cause) {
|
|
703
|
+
const where = lockPath ? { lock_file: logLabel(basename(lockPath)), lock_path: logLabel(lockPath.replace(/^\/+/, "")) } : {};
|
|
704
|
+
switch (reason) {
|
|
705
|
+
case "held":
|
|
706
|
+
return log(level, "llm.budget_lock_held", where, cause);
|
|
707
|
+
case "foreign_host":
|
|
708
|
+
return log(level, "llm.budget_lock_foreign_host", {
|
|
709
|
+
...where,
|
|
710
|
+
remedy: logLabel("remove_the_lock_file_if_no_other_host_uses_this_data_dir")
|
|
711
|
+
}, cause);
|
|
712
|
+
case "unreadable":
|
|
713
|
+
return log(level, "llm.budget_lock_unreadable", {
|
|
714
|
+
...where,
|
|
715
|
+
remedy: logLabel("fix_the_lock_file_permissions_or_remove_it")
|
|
716
|
+
}, cause);
|
|
717
|
+
case "busy":
|
|
718
|
+
return log(level, "llm.budget_lock_busy", where, cause);
|
|
719
|
+
case "failed":
|
|
720
|
+
return log(level, "llm.budget_lock_failed", where, cause);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
var BudgetStateReadError = class extends Error {
|
|
724
|
+
constructor() {
|
|
725
|
+
super("the LLM budget state could not be read");
|
|
726
|
+
this.name = "BudgetStateReadError";
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
var BudgetStateWriteError = class extends Error {
|
|
730
|
+
constructor() {
|
|
731
|
+
super("the LLM budget state could not be saved");
|
|
732
|
+
this.name = "BudgetStateWriteError";
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
function minimumDailyTokenLimit(maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS, requestMaxBytes = LLM_REQUEST_MAX_BYTES) {
|
|
736
|
+
return Math.min(requestMaxBytes + maxOutputTokens, MAX_REPORTED_TOKENS_PER_CALL);
|
|
737
|
+
}
|
|
738
|
+
function budgetBootRefusal(budget, tokenLimit, maxOutputTokens, log) {
|
|
739
|
+
const min = dailyTokenLimitTooSmall(tokenLimit, maxOutputTokens);
|
|
740
|
+
if (min !== null) {
|
|
741
|
+
log("error", "runtime.config_invalid.llm_daily_token_limit_below_request_bound", {
|
|
742
|
+
llm_daily_token_limit: tokenLimit,
|
|
743
|
+
minimum: min
|
|
744
|
+
});
|
|
745
|
+
return 2;
|
|
746
|
+
}
|
|
747
|
+
const reason = budget.lockRefusal();
|
|
748
|
+
if (reason !== null) {
|
|
749
|
+
logLockRefusal(log, "error", reason, budget.lockPath(), budget.lockError());
|
|
750
|
+
return 3;
|
|
751
|
+
}
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
function dailyTokenLimitTooSmall(tokenLimit, maxOutputTokens) {
|
|
755
|
+
const min = minimumDailyTokenLimit(maxOutputTokens);
|
|
756
|
+
return tokenLimit > 0 && tokenLimit < min ? min : null;
|
|
757
|
+
}
|
|
758
|
+
function utcDay(d) {
|
|
759
|
+
return d.toISOString().slice(0, 10);
|
|
760
|
+
}
|
|
761
|
+
function nextUtcDay(day) {
|
|
762
|
+
return utcDay(new Date(Date.parse(`${day}T00:00:00Z`) + 864e5));
|
|
763
|
+
}
|
|
764
|
+
function errnoOf(error) {
|
|
765
|
+
const code = error?.code;
|
|
766
|
+
return typeof code === "string" ? code : void 0;
|
|
767
|
+
}
|
|
768
|
+
var DailyLlmBudget = class {
|
|
769
|
+
callLimit;
|
|
770
|
+
tokenLimit;
|
|
771
|
+
statePath;
|
|
772
|
+
lock;
|
|
773
|
+
lockRefusalLogged = null;
|
|
774
|
+
stateReadError;
|
|
775
|
+
now;
|
|
776
|
+
log;
|
|
777
|
+
state;
|
|
778
|
+
exhaustionLoggedFor = null;
|
|
779
|
+
constructor(opts) {
|
|
780
|
+
this.callLimit = opts.callLimit;
|
|
781
|
+
this.tokenLimit = opts.tokenLimit;
|
|
782
|
+
this.statePath = opts.statePath;
|
|
783
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
784
|
+
this.log = opts.log ?? (() => {
|
|
785
|
+
});
|
|
786
|
+
if (opts.lockPath) {
|
|
787
|
+
this.lock = new BudgetLock(opts.lockPath, opts.lockEnv ?? realLockEnv, this.log);
|
|
788
|
+
this.lock.acquire();
|
|
789
|
+
}
|
|
790
|
+
this.state = this.load();
|
|
791
|
+
}
|
|
792
|
+
/** True when this runtime may call the provider as far as the lock goes
|
|
793
|
+
* (it holds the lock, or no lock is configured). index.ts checks it before
|
|
794
|
+
* starting the data plane (#808 M2). */
|
|
795
|
+
holdsLock() {
|
|
796
|
+
return !this.lock || this.lock.held;
|
|
797
|
+
}
|
|
798
|
+
/** The error behind the last `unreadable` / `failed` lock refusal. */
|
|
799
|
+
lockError() {
|
|
800
|
+
return this.lock?.lastError;
|
|
801
|
+
}
|
|
802
|
+
lockPath() {
|
|
803
|
+
return this.lock?.path;
|
|
804
|
+
}
|
|
805
|
+
/** Why the lock is not held (retrying once), or null when it is / none is
|
|
806
|
+
* configured. Never throws. */
|
|
807
|
+
lockRefusal() {
|
|
808
|
+
if (!this.lock || this.lock.held && this.lock.stillHeld()) return null;
|
|
809
|
+
const result = this.lock.acquire();
|
|
810
|
+
if (result === "acquired") {
|
|
811
|
+
this.state = this.load();
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Refuse up front, without charging anything, when this runtime may not
|
|
818
|
+
* call the provider at all: another runtime holds the lock, or either daily
|
|
819
|
+
* limit is already spent. The provider is never reached.
|
|
820
|
+
*/
|
|
821
|
+
assertOpen() {
|
|
822
|
+
this.assertLock();
|
|
823
|
+
this.assertStateReadable();
|
|
824
|
+
this.roll();
|
|
825
|
+
const { calls, tokens } = this.state;
|
|
826
|
+
const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
|
|
827
|
+
const tokensSpent = this.tokenLimit > 0 && tokens >= this.tokenLimit;
|
|
828
|
+
if (callsSpent || tokensSpent) this.exhausted();
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* SEC-06 item 10: reserve ONE provider request — one call and its
|
|
832
|
+
* worst-case tokens (`reserve`, the dispatcher's request bound) — and save
|
|
833
|
+
* the charge before the request is sent. Throws, charging nothing, when the
|
|
834
|
+
* lock is held elsewhere ({@link BudgetLockHeldError}), when the reserve
|
|
835
|
+
* alone exceeds the whole token cap ({@link RequestExceedsDailyTokenCapError}),
|
|
836
|
+
* or when it does not fit what is left today ({@link DailyLimitReachedError}).
|
|
837
|
+
*
|
|
838
|
+
* Returns the request's settle function (idempotent): see {@link RequestOutcome}.
|
|
839
|
+
*/
|
|
840
|
+
beginCall(reserve) {
|
|
841
|
+
if (typeof reserve !== "number" || !Number.isFinite(reserve) || reserve <= 0) {
|
|
842
|
+
throw new TypeError("LLM budget reserve must be a positive finite number");
|
|
843
|
+
}
|
|
844
|
+
this.assertLock();
|
|
845
|
+
this.assertStateReadable();
|
|
846
|
+
this.roll();
|
|
847
|
+
const bound = Math.ceil(reserve);
|
|
848
|
+
if (this.tokenLimit > 0 && bound > this.tokenLimit) {
|
|
849
|
+
this.log("warn", "llm.budget_request_exceeds_cap", { reserve: bound, token_limit: this.tokenLimit });
|
|
850
|
+
throw new RequestExceedsDailyTokenCapError(bound, this.tokenLimit);
|
|
851
|
+
}
|
|
852
|
+
const { calls, tokens } = this.state;
|
|
853
|
+
const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
|
|
854
|
+
const tokensSpent = this.tokenLimit > 0 && tokens + bound > this.tokenLimit;
|
|
855
|
+
if (callsSpent || tokensSpent) this.exhausted();
|
|
856
|
+
this.state.calls++;
|
|
857
|
+
this.state.tokens += bound;
|
|
858
|
+
try {
|
|
859
|
+
this.persist();
|
|
860
|
+
} catch (error) {
|
|
861
|
+
this.state.calls--;
|
|
862
|
+
this.state.tokens -= bound;
|
|
863
|
+
this.log("warn", "llm.budget_state_write_failed", { refused: true }, error);
|
|
864
|
+
throw new BudgetStateWriteError();
|
|
865
|
+
}
|
|
866
|
+
const day = this.state.day;
|
|
867
|
+
let settled = false;
|
|
868
|
+
return (outcome) => {
|
|
869
|
+
if (settled) return;
|
|
870
|
+
settled = true;
|
|
871
|
+
this.roll();
|
|
872
|
+
if (this.state.day !== day) return;
|
|
873
|
+
if (outcome.kind === "not_sent") {
|
|
874
|
+
this.state.calls = Math.max(0, this.state.calls - 1);
|
|
875
|
+
this.state.tokens = Math.max(0, this.state.tokens - bound);
|
|
876
|
+
} else if (outcome.kind === "reported") {
|
|
877
|
+
const actual = Number.isFinite(outcome.tokens) && outcome.tokens > 0 ? Math.floor(outcome.tokens) : 0;
|
|
878
|
+
const charged = Math.min(actual, bound);
|
|
879
|
+
this.state.tokens = Math.max(0, this.state.tokens - (bound - charged));
|
|
880
|
+
} else {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
this.save();
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
/** Legacy, after the fact: charge one call a completer did not meter. */
|
|
887
|
+
chargeUnmeteredCall() {
|
|
888
|
+
this.roll();
|
|
889
|
+
this.state.calls++;
|
|
890
|
+
this.save();
|
|
891
|
+
}
|
|
892
|
+
/** Release the lock, if this runtime holds it and it is still ours. */
|
|
893
|
+
close() {
|
|
894
|
+
this.lock?.release();
|
|
895
|
+
}
|
|
896
|
+
exhausted() {
|
|
897
|
+
const { calls, tokens, day } = this.state;
|
|
898
|
+
if (this.exhaustionLoggedFor !== day) {
|
|
899
|
+
this.exhaustionLoggedFor = day;
|
|
900
|
+
this.log("warn", "llm.daily_budget_exhausted", {
|
|
901
|
+
calls,
|
|
902
|
+
tokens,
|
|
903
|
+
call_limit: this.callLimit,
|
|
904
|
+
token_limit: this.tokenLimit
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
throw new DailyLimitReachedError(day);
|
|
908
|
+
}
|
|
909
|
+
assertLock() {
|
|
910
|
+
if (!this.lock || this.lock.held && this.lock.stillHeld()) return;
|
|
911
|
+
const result = this.lock.acquire();
|
|
912
|
+
if (result === "acquired") {
|
|
913
|
+
this.state = this.load();
|
|
914
|
+
this.lockRefusalLogged = null;
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (this.lockRefusalLogged !== result) {
|
|
918
|
+
this.lockRefusalLogged = result;
|
|
919
|
+
const cause = result === "unreadable" || result === "failed" ? this.lock.lastError : void 0;
|
|
920
|
+
logLockRefusal(this.log, "warn", result, this.lock.path, cause);
|
|
921
|
+
}
|
|
922
|
+
throw new BudgetLockHeldError(result);
|
|
923
|
+
}
|
|
924
|
+
/** #808 L-d: the state file exists but cannot be read. LLM calls are
|
|
925
|
+
* refused until it can: starting a fresh day over it would re-grant the
|
|
926
|
+
* whole cap, and the next save would overwrite the real counters. */
|
|
927
|
+
assertStateReadable() {
|
|
928
|
+
if (this.stateReadError === void 0) return;
|
|
929
|
+
this.state = this.load();
|
|
930
|
+
if (this.stateReadError === void 0) return;
|
|
931
|
+
throw new BudgetStateReadError();
|
|
932
|
+
}
|
|
933
|
+
/** Add provider-reported tokens to today's total. */
|
|
934
|
+
recordTokens(totalTokens) {
|
|
935
|
+
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return;
|
|
936
|
+
this.roll();
|
|
937
|
+
this.state.tokens += Math.floor(totalTokens);
|
|
938
|
+
this.save();
|
|
939
|
+
}
|
|
940
|
+
snapshot() {
|
|
941
|
+
this.roll();
|
|
942
|
+
return { ...this.state };
|
|
943
|
+
}
|
|
944
|
+
/** Start a new day only when the clock moves FORWARD past the stored one:
|
|
945
|
+
* a clock stepped back across 00:00 UTC must not wipe today's spend
|
|
946
|
+
* (#808 L1). YYYY-MM-DD compares correctly as a string. */
|
|
947
|
+
roll() {
|
|
948
|
+
const today = utcDay(this.now());
|
|
949
|
+
if (today > this.state.day || this.state.day > nextUtcDay(today)) {
|
|
950
|
+
this.state = { day: today, calls: 0, tokens: 0 };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
load() {
|
|
954
|
+
const fresh = { day: utcDay(this.now()), calls: 0, tokens: 0 };
|
|
955
|
+
if (!this.statePath) return fresh;
|
|
956
|
+
let raw;
|
|
957
|
+
try {
|
|
958
|
+
raw = readFileSync2(this.statePath, "utf8");
|
|
959
|
+
this.stateReadError = void 0;
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (errnoOf(error) === "ENOENT") {
|
|
962
|
+
this.stateReadError = void 0;
|
|
963
|
+
return fresh;
|
|
964
|
+
}
|
|
965
|
+
if (this.stateReadError === void 0) this.log("warn", "llm.budget_state_read_failed", {}, error);
|
|
966
|
+
this.stateReadError = error;
|
|
967
|
+
return fresh;
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
const parsed = JSON.parse(raw);
|
|
971
|
+
if (typeof parsed.day === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.day) && parsed.day >= fresh.day && parsed.day <= nextUtcDay(fresh.day) && Number.isInteger(parsed.calls) && parsed.calls >= 0 && Number.isInteger(parsed.tokens) && parsed.tokens >= 0) {
|
|
972
|
+
return { day: parsed.day, calls: parsed.calls, tokens: parsed.tokens };
|
|
973
|
+
}
|
|
974
|
+
if (typeof parsed.day === "string" && parsed.day > nextUtcDay(fresh.day)) {
|
|
975
|
+
this.log("warn", "llm.budget_state_future_day", {});
|
|
976
|
+
}
|
|
977
|
+
return fresh;
|
|
978
|
+
} catch (error) {
|
|
979
|
+
this.log("warn", "llm.budget_state_unreadable", {}, error);
|
|
980
|
+
return fresh;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** Write the counters durably: tmp file, fsync, rename (#808 L2). Throws. */
|
|
984
|
+
persist() {
|
|
985
|
+
if (!this.statePath) return;
|
|
986
|
+
mkdirSync2(dirname2(this.statePath), { recursive: true });
|
|
987
|
+
const tmp = `${this.statePath}.tmp`;
|
|
988
|
+
const fd = openSync2(tmp, "w", 384);
|
|
989
|
+
try {
|
|
990
|
+
writeSync2(fd, JSON.stringify(this.state));
|
|
991
|
+
fsyncSync2(fd);
|
|
992
|
+
} finally {
|
|
993
|
+
closeSync2(fd);
|
|
994
|
+
}
|
|
995
|
+
renameSync(tmp, this.statePath);
|
|
996
|
+
}
|
|
997
|
+
/** Best effort, for writes that only ever LOWER what is on disk relative to
|
|
998
|
+
* the truth (a settle's refund) or add after the fact: a failure leaves the
|
|
999
|
+
* file conservative, and the in-memory counters still bound this process. */
|
|
1000
|
+
save() {
|
|
1001
|
+
if (this.stateReadError !== void 0) return;
|
|
1002
|
+
try {
|
|
1003
|
+
this.persist();
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
this.log("warn", "llm.budget_state_write_failed", {}, error);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
function withDailyBudget(llm, budget) {
|
|
1010
|
+
return {
|
|
1011
|
+
async complete(messages, tools, options) {
|
|
1012
|
+
budget.assertOpen();
|
|
1013
|
+
let metered = false;
|
|
1014
|
+
const meter = (bound) => {
|
|
1015
|
+
metered = true;
|
|
1016
|
+
const outer = options?.meter?.(bound);
|
|
1017
|
+
let settle;
|
|
1018
|
+
try {
|
|
1019
|
+
settle = budget.beginCall(bound);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
outer?.({ kind: "not_sent" });
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
1024
|
+
return (outcome) => {
|
|
1025
|
+
settle(outcome);
|
|
1026
|
+
outer?.(outcome);
|
|
1027
|
+
};
|
|
1028
|
+
};
|
|
1029
|
+
let result;
|
|
1030
|
+
try {
|
|
1031
|
+
result = await llm.complete(messages, tools, { ...options, meter });
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (!metered) {
|
|
1034
|
+
budget.chargeUnmeteredCall();
|
|
1035
|
+
const spent = reportedTokensOf(error);
|
|
1036
|
+
if (spent !== void 0) budget.recordTokens(spent);
|
|
1037
|
+
}
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
if (!metered) {
|
|
1041
|
+
budget.chargeUnmeteredCall();
|
|
1042
|
+
if (result.usage) budget.recordTokens(result.usage.totalTokens);
|
|
1043
|
+
}
|
|
1044
|
+
return result;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/config.ts
|
|
15
1050
|
var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
|
|
16
1051
|
var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
17
1052
|
var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
|
|
@@ -48,8 +1083,16 @@ function parseList(value) {
|
|
|
48
1083
|
const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
49
1084
|
return items.length > 0 ? items : void 0;
|
|
50
1085
|
}
|
|
1086
|
+
function parseIntSetting(env, name, fallback, min) {
|
|
1087
|
+
const raw = env[name]?.trim();
|
|
1088
|
+
if (!raw) return fallback;
|
|
1089
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min || !Number.isSafeInteger(Number(raw))) {
|
|
1090
|
+
throw new Error(`Invalid ${name} "${raw}": expected a whole number >= ${min}`);
|
|
1091
|
+
}
|
|
1092
|
+
return Number(raw);
|
|
1093
|
+
}
|
|
51
1094
|
function defaultSkillsDir() {
|
|
52
|
-
return join(
|
|
1095
|
+
return join(dirname3(fileURLToPath(import.meta.url)), "..", "skills");
|
|
53
1096
|
}
|
|
54
1097
|
var DEFAULT_TOOL_ALLOW = [
|
|
55
1098
|
"echo",
|
|
@@ -99,7 +1142,10 @@ function loadConfig(argv, env) {
|
|
|
99
1142
|
toolGateEnv: {
|
|
100
1143
|
...parsedAllow !== void 0 ? { allow: parsedAllow } : {},
|
|
101
1144
|
...deny !== void 0 ? { deny } : {}
|
|
102
|
-
}
|
|
1145
|
+
},
|
|
1146
|
+
llmMaxOutputTokens: parseIntSetting(env, "SECURECOMS_LLM_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS, 1),
|
|
1147
|
+
llmDailyCallLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_CALL_LIMIT", DEFAULT_DAILY_CALL_LIMIT, 0),
|
|
1148
|
+
llmDailyTokenLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_TOKEN_LIMIT", DEFAULT_DAILY_TOKEN_LIMIT, 0)
|
|
103
1149
|
};
|
|
104
1150
|
}
|
|
105
1151
|
|
|
@@ -154,9 +1200,23 @@ function parsePersonaVerb(argv) {
|
|
|
154
1200
|
const sub = argv[3];
|
|
155
1201
|
return sub === "init" || sub === "show" ? sub : null;
|
|
156
1202
|
}
|
|
1203
|
+
function parseSideEffectsVerb(argv) {
|
|
1204
|
+
if (argv[2] !== "side-effects") return null;
|
|
1205
|
+
const sub = argv[3];
|
|
1206
|
+
return sub === "list" || sub === "resolve" ? sub : null;
|
|
1207
|
+
}
|
|
1208
|
+
function parseSideEffectResolveFlags(argv) {
|
|
1209
|
+
const outcome = flagOrEnv(argv, "--outcome", {});
|
|
1210
|
+
return {
|
|
1211
|
+
idempotencyKey: flagOrEnv(argv, "--idempotency-key", {}),
|
|
1212
|
+
operationSha256: flagOrEnv(argv, "--operation-sha256", {}),
|
|
1213
|
+
confirmationId: flagOrEnv(argv, "--confirmation-id", {}),
|
|
1214
|
+
outcome: outcome === "completed" || outcome === "not_applied" ? outcome : void 0
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
157
1217
|
|
|
158
1218
|
// src/service/install.ts
|
|
159
|
-
import { dirname as
|
|
1219
|
+
import { dirname as dirname4 } from "node:path";
|
|
160
1220
|
|
|
161
1221
|
// src/service/config.ts
|
|
162
1222
|
import { isAbsolute, resolve } from "node:path";
|
|
@@ -388,8 +1448,8 @@ function installService(input, deps) {
|
|
|
388
1448
|
const plan = prepareInstall(input, deps.platform, deps);
|
|
389
1449
|
const keyFile = plan.keyFile;
|
|
390
1450
|
if (deps.platform === "darwin") {
|
|
391
|
-
deps.mkdirp(
|
|
392
|
-
deps.mkdirp(
|
|
1451
|
+
deps.mkdirp(dirname4(cfg.plistPath));
|
|
1452
|
+
deps.mkdirp(dirname4(cfg.logPath));
|
|
393
1453
|
deps.writeFile(cfg.plistPath, plan.contents);
|
|
394
1454
|
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
395
1455
|
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
@@ -397,7 +1457,7 @@ function installService(input, deps) {
|
|
|
397
1457
|
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
|
|
398
1458
|
}
|
|
399
1459
|
if (deps.platform === "linux") {
|
|
400
|
-
deps.mkdirp(
|
|
1460
|
+
deps.mkdirp(dirname4(cfg.unitPath));
|
|
401
1461
|
deps.writeFile(cfg.unitPath, plan.contents);
|
|
402
1462
|
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
403
1463
|
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
@@ -431,63 +1491,6 @@ function uninstallService(input, deps) {
|
|
|
431
1491
|
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
432
1492
|
}
|
|
433
1493
|
|
|
434
|
-
// src/llm-dispatch.ts
|
|
435
|
-
function normalizeEndpoint(endpoint) {
|
|
436
|
-
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
437
|
-
}
|
|
438
|
-
var LLMDispatcher = class {
|
|
439
|
-
endpoint;
|
|
440
|
-
model;
|
|
441
|
-
apiKey;
|
|
442
|
-
chatPath;
|
|
443
|
-
chatAuth;
|
|
444
|
-
authHeaderName;
|
|
445
|
-
constructor(endpoint, model, opts = {}) {
|
|
446
|
-
this.endpoint = normalizeEndpoint(endpoint);
|
|
447
|
-
this.model = model;
|
|
448
|
-
this.apiKey = opts.apiKey;
|
|
449
|
-
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
450
|
-
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
451
|
-
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
452
|
-
}
|
|
453
|
-
async complete(messages, tools) {
|
|
454
|
-
const body = {
|
|
455
|
-
model: this.model,
|
|
456
|
-
messages,
|
|
457
|
-
stream: false
|
|
458
|
-
};
|
|
459
|
-
if (tools && tools.length > 0) {
|
|
460
|
-
body.tools = tools;
|
|
461
|
-
}
|
|
462
|
-
const url = `${this.endpoint}${this.chatPath}`;
|
|
463
|
-
const headers = { "Content-Type": "application/json" };
|
|
464
|
-
if (this.apiKey && this.chatAuth === "bearer") {
|
|
465
|
-
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
466
|
-
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
467
|
-
headers[this.authHeaderName] = this.apiKey;
|
|
468
|
-
}
|
|
469
|
-
const resp = await fetch(url, {
|
|
470
|
-
method: "POST",
|
|
471
|
-
headers,
|
|
472
|
-
body: JSON.stringify(body)
|
|
473
|
-
});
|
|
474
|
-
if (!resp.ok) {
|
|
475
|
-
await resp.body?.cancel();
|
|
476
|
-
throw new Error(`LLM request failed (${resp.status})`);
|
|
477
|
-
}
|
|
478
|
-
const data = await resp.json();
|
|
479
|
-
const choice = data.choices?.[0];
|
|
480
|
-
if (!choice) {
|
|
481
|
-
throw new Error("LLM returned no choices");
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
content: choice.message.content,
|
|
485
|
-
toolCalls: choice.message.tool_calls || [],
|
|
486
|
-
finishReason: choice.finish_reason
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
};
|
|
490
|
-
|
|
491
1494
|
// src/llm-source.ts
|
|
492
1495
|
import { join as join2 } from "node:path";
|
|
493
1496
|
|
|
@@ -661,34 +1664,21 @@ function resolveLlmSource(cfg, explicit, deps) {
|
|
|
661
1664
|
}
|
|
662
1665
|
|
|
663
1666
|
// src/llm-shim.ts
|
|
664
|
-
function endpointHost(endpoint) {
|
|
665
|
-
try {
|
|
666
|
-
return new URL(endpoint).host;
|
|
667
|
-
} catch {
|
|
668
|
-
return "(unparseable endpoint)";
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
1667
|
function buildLlmShim(cfg, explicit, deps) {
|
|
672
1668
|
return {
|
|
673
|
-
async complete(messages, tools) {
|
|
1669
|
+
async complete(messages, tools, options) {
|
|
674
1670
|
const src = resolveLlmSource(cfg, explicit, deps);
|
|
675
1671
|
const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
|
|
676
1672
|
apiKey: src.apiKey,
|
|
677
1673
|
chatPath: src.chatPath,
|
|
678
|
-
chatAuth: src.chatAuth
|
|
1674
|
+
chatAuth: src.chatAuth,
|
|
1675
|
+
maxOutputTokens: cfg.llmMaxOutputTokens
|
|
679
1676
|
});
|
|
680
|
-
|
|
1677
|
+
const result = await dispatcher.complete(messages, tools, options);
|
|
1678
|
+
return { ...result, source: { provider: src.provider, model: src.model } };
|
|
681
1679
|
}
|
|
682
1680
|
};
|
|
683
1681
|
}
|
|
684
|
-
function bootLine(cfg, explicit, deps) {
|
|
685
|
-
try {
|
|
686
|
-
const src = resolveLlmSource(cfg, explicit, deps);
|
|
687
|
-
return `securecoms-agent ${cfg.identityId.slice(0, 8)} \u2192 llm source=${src.origin} ${endpointHost(src.endpoint)} (${src.model})`;
|
|
688
|
-
} catch (e) {
|
|
689
|
-
return `llm: source unavailable \u2014 ${e instanceof Error ? e.message : String(e)}`;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
1682
|
|
|
693
1683
|
// src/memory.ts
|
|
694
1684
|
var MAX_HISTORY = 50;
|
|
@@ -742,7 +1732,7 @@ ${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
|
|
|
742
1732
|
};
|
|
743
1733
|
|
|
744
1734
|
// src/skill-invoker.ts
|
|
745
|
-
import { readFileSync as
|
|
1735
|
+
import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync3, statSync as statSync2 } from "node:fs";
|
|
746
1736
|
import { join as join4 } from "node:path";
|
|
747
1737
|
|
|
748
1738
|
// src/skill-md.ts
|
|
@@ -866,16 +1856,35 @@ ${guidance}` : description,
|
|
|
866
1856
|
}
|
|
867
1857
|
|
|
868
1858
|
// src/requires.ts
|
|
869
|
-
function hasReadableKeyFile(paths, read) {
|
|
1859
|
+
function hasReadableKeyFile(paths, read, onReadError) {
|
|
870
1860
|
return paths.some((path) => {
|
|
871
1861
|
if (!path) return false;
|
|
872
1862
|
try {
|
|
873
1863
|
return read(path).trim() !== "";
|
|
874
|
-
} catch {
|
|
1864
|
+
} catch (error) {
|
|
1865
|
+
try {
|
|
1866
|
+
onReadError?.(path, error);
|
|
1867
|
+
} catch {
|
|
1868
|
+
}
|
|
875
1869
|
return false;
|
|
876
1870
|
}
|
|
877
1871
|
});
|
|
878
1872
|
}
|
|
1873
|
+
function searchKeyGateReporter(log, isBravePath) {
|
|
1874
|
+
const logged = /* @__PURE__ */ new Set();
|
|
1875
|
+
return (path, error) => {
|
|
1876
|
+
if (error?.code === "ENOENT") return;
|
|
1877
|
+
const signature = `${path}|${errorClass(error)}`;
|
|
1878
|
+
if (logged.has(signature)) return;
|
|
1879
|
+
logged.add(signature);
|
|
1880
|
+
log(
|
|
1881
|
+
"warn",
|
|
1882
|
+
"runtime.search_key_unreadable",
|
|
1883
|
+
{ at_gate: true, provider_brave: isBravePath(path) },
|
|
1884
|
+
error
|
|
1885
|
+
);
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
879
1888
|
function requiresEnvFrom(cfg, hasSearchKey) {
|
|
880
1889
|
return {
|
|
881
1890
|
hasSearchKey,
|
|
@@ -898,7 +1907,7 @@ function resolveRequires(req, env) {
|
|
|
898
1907
|
}
|
|
899
1908
|
|
|
900
1909
|
// src/skills-config.ts
|
|
901
|
-
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
1910
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
902
1911
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
|
|
903
1912
|
|
|
904
1913
|
// src/config-dirs.ts
|
|
@@ -927,13 +1936,13 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
927
1936
|
if (!existsSync2(path)) return empty();
|
|
928
1937
|
let raw;
|
|
929
1938
|
try {
|
|
930
|
-
raw = JSON.parse(
|
|
1939
|
+
raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
931
1940
|
} catch (e) {
|
|
932
|
-
|
|
1941
|
+
writeServiceLog("warn", "skills_config.parse_failed", {}, e);
|
|
933
1942
|
return empty();
|
|
934
1943
|
}
|
|
935
1944
|
if (typeof raw !== "object" || raw === null) {
|
|
936
|
-
|
|
1945
|
+
writeServiceLog("warn", "skills_config.root_invalid");
|
|
937
1946
|
return empty();
|
|
938
1947
|
}
|
|
939
1948
|
const src = raw;
|
|
@@ -941,7 +1950,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
941
1950
|
for (const [name, v] of Object.entries(src.endpoints ?? {})) {
|
|
942
1951
|
const e = v;
|
|
943
1952
|
if (typeof e?.baseUrl !== "string" || e.baseUrl === "") {
|
|
944
|
-
|
|
1953
|
+
writeServiceLog("warn", "skills_config.endpoint_invalid");
|
|
945
1954
|
continue;
|
|
946
1955
|
}
|
|
947
1956
|
endpoints[name] = {
|
|
@@ -955,7 +1964,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
955
1964
|
if (typeof d?.sqlite === "string" && d.sqlite !== "") databases[name] = { sqlite: abs2(d.sqlite, dataDir) };
|
|
956
1965
|
else if (typeof d?.postgresUrlFile === "string" && d.postgresUrlFile !== "") {
|
|
957
1966
|
databases[name] = { postgresUrlFile: abs2(d.postgresUrlFile, dataDir) };
|
|
958
|
-
} else
|
|
1967
|
+
} else writeServiceLog("warn", "skills_config.database_invalid");
|
|
959
1968
|
}
|
|
960
1969
|
const flags = Array.isArray(src.flags) ? src.flags.filter((f) => typeof f === "string") : [];
|
|
961
1970
|
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
|
|
@@ -968,9 +1977,9 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
968
1977
|
const s = src.search;
|
|
969
1978
|
if (s && typeof s === "object") {
|
|
970
1979
|
if (s.provider !== "brave" && s.provider !== "tavily") {
|
|
971
|
-
|
|
1980
|
+
writeServiceLog("warn", "skills_config.search_provider_invalid");
|
|
972
1981
|
} else if (typeof s.keyFile !== "string" || s.keyFile === "") {
|
|
973
|
-
|
|
1982
|
+
writeServiceLog("warn", "skills_config.search_key_file_missing");
|
|
974
1983
|
} else {
|
|
975
1984
|
search = { provider: s.provider, keyFile: abs2(s.keyFile, dataDir) };
|
|
976
1985
|
}
|
|
@@ -997,17 +2006,193 @@ var currentTime = async (args, ctx) => {
|
|
|
997
2006
|
};
|
|
998
2007
|
|
|
999
2008
|
// src/html-to-text.ts
|
|
2009
|
+
var HTML_TO_TEXT_MAX_INPUT = 2e6;
|
|
1000
2010
|
function htmlToText(html) {
|
|
1001
|
-
|
|
2011
|
+
const n = Math.min(html.length, HTML_TO_TEXT_MAX_INPUT);
|
|
2012
|
+
const b = new Uint16Array(n);
|
|
2013
|
+
for (let i = 0; i < n; i++) b[i] = html.charCodeAt(i);
|
|
2014
|
+
let len = stripRawTextElement(b, n, "script");
|
|
2015
|
+
len = stripRawTextElement(b, len, "style");
|
|
2016
|
+
len = stripComments(b, len);
|
|
2017
|
+
len = stripTags(b, len);
|
|
2018
|
+
len = decodeAndCollapse(b, len);
|
|
2019
|
+
return fromCodes(b, len);
|
|
2020
|
+
}
|
|
2021
|
+
var LT = 60;
|
|
2022
|
+
var GT = 62;
|
|
2023
|
+
var SLASH = 47;
|
|
2024
|
+
var BANG = 33;
|
|
2025
|
+
var DASH = 45;
|
|
2026
|
+
var AMP = 38;
|
|
2027
|
+
var SPACE = 32;
|
|
2028
|
+
function lower(c) {
|
|
2029
|
+
return c >= 65 && c <= 90 ? c | 32 : c;
|
|
2030
|
+
}
|
|
2031
|
+
function nameAt(b, len, at, name) {
|
|
2032
|
+
if (at + name.length > len) return false;
|
|
2033
|
+
for (let k = 0; k < name.length; k++) {
|
|
2034
|
+
if (lower(b[at + k]) !== name.charCodeAt(k)) return false;
|
|
2035
|
+
}
|
|
2036
|
+
return true;
|
|
2037
|
+
}
|
|
2038
|
+
function isWordChar(c) {
|
|
2039
|
+
return c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95;
|
|
2040
|
+
}
|
|
2041
|
+
function isSpace(c) {
|
|
2042
|
+
if (c <= 32) return c === 32 || c >= 9 && c <= 13;
|
|
2043
|
+
if (c < 160) return false;
|
|
2044
|
+
return c === 160 || c === 5760 || c >= 8192 && c <= 8202 || c === 8232 || c === 8233 || c === 8239 || c === 8287 || c === 12288 || c === 65279;
|
|
2045
|
+
}
|
|
2046
|
+
function indexIn(b, len, c, from) {
|
|
2047
|
+
for (let k = from; k < len; k++) if (b[k] === c) return k;
|
|
2048
|
+
return -1;
|
|
2049
|
+
}
|
|
2050
|
+
function keep(b, w, from, to) {
|
|
2051
|
+
if (w === from) return to;
|
|
2052
|
+
if (to - from > 32) b.copyWithin(w, from, to);
|
|
2053
|
+
else for (let k = from; k < to; k++) b[w + k - from] = b[k];
|
|
2054
|
+
return w + (to - from);
|
|
2055
|
+
}
|
|
2056
|
+
function stripRawTextElement(b, len, name) {
|
|
2057
|
+
const first = name.charCodeAt(0);
|
|
2058
|
+
let w = 0;
|
|
2059
|
+
let copied = 0;
|
|
2060
|
+
for (let i = 0; i + 1 < len; i++) {
|
|
2061
|
+
if (b[i] !== LT || lower(b[i + 1]) !== first || !nameAt(b, len, i + 1, name)) continue;
|
|
2062
|
+
const afterName = i + 1 + name.length;
|
|
2063
|
+
if (afterName >= len || isWordChar(b[afterName])) continue;
|
|
2064
|
+
const gt = indexIn(b, len, GT, afterName);
|
|
2065
|
+
if (gt < 0) break;
|
|
2066
|
+
const end = findCloseTag(b, len, gt + 1, name);
|
|
2067
|
+
if (end < 0) break;
|
|
2068
|
+
w = keep(b, w, copied, i);
|
|
2069
|
+
b[w++] = SPACE;
|
|
2070
|
+
copied = end;
|
|
2071
|
+
i = end - 1;
|
|
2072
|
+
}
|
|
2073
|
+
return copied === 0 ? len : keep(b, w, copied, len);
|
|
2074
|
+
}
|
|
2075
|
+
function findCloseTag(b, len, from, name) {
|
|
2076
|
+
for (let i = from; i + 1 < len; i++) {
|
|
2077
|
+
if (b[i] !== LT || b[i + 1] !== SLASH || !nameAt(b, len, i + 2, name)) continue;
|
|
2078
|
+
let j = i + 2 + name.length;
|
|
2079
|
+
while (j < len && isSpace(b[j])) j++;
|
|
2080
|
+
if (j < len && b[j] === GT) return j + 1;
|
|
2081
|
+
i = j - 1;
|
|
2082
|
+
}
|
|
2083
|
+
return -1;
|
|
2084
|
+
}
|
|
2085
|
+
function stripComments(b, len) {
|
|
2086
|
+
let w = 0;
|
|
2087
|
+
let copied = 0;
|
|
2088
|
+
for (let i = 0; i + 3 < len; i++) {
|
|
2089
|
+
if (b[i] !== LT || b[i + 1] !== BANG || b[i + 2] !== DASH || b[i + 3] !== DASH) continue;
|
|
2090
|
+
let end = -1;
|
|
2091
|
+
for (let k = i + 4; k + 2 < len; k++) {
|
|
2092
|
+
if (b[k] === DASH && b[k + 1] === DASH && b[k + 2] === GT) {
|
|
2093
|
+
end = k + 3;
|
|
2094
|
+
break;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
if (end < 0) break;
|
|
2098
|
+
w = keep(b, w, copied, i);
|
|
2099
|
+
b[w++] = SPACE;
|
|
2100
|
+
copied = end;
|
|
2101
|
+
i = end - 1;
|
|
2102
|
+
}
|
|
2103
|
+
return copied === 0 ? len : keep(b, w, copied, len);
|
|
2104
|
+
}
|
|
2105
|
+
function stripTags(b, len) {
|
|
2106
|
+
let w = 0;
|
|
2107
|
+
let copied = 0;
|
|
2108
|
+
for (; ; ) {
|
|
2109
|
+
const lt = indexIn(b, len, LT, copied);
|
|
2110
|
+
if (lt < 0) break;
|
|
2111
|
+
const gt = indexIn(b, len, GT, lt + 1);
|
|
2112
|
+
if (gt < 0) break;
|
|
2113
|
+
w = keep(b, w, copied, lt);
|
|
2114
|
+
b[w++] = SPACE;
|
|
2115
|
+
copied = gt + 1;
|
|
2116
|
+
}
|
|
2117
|
+
return copied === 0 ? len : keep(b, w, copied, len);
|
|
2118
|
+
}
|
|
2119
|
+
function decodeAndCollapse(b, len) {
|
|
2120
|
+
let i = 0;
|
|
2121
|
+
while (i < len && isSpace(b[i])) i++;
|
|
2122
|
+
let end = len;
|
|
2123
|
+
while (end > i && isSpace(b[end - 1])) end--;
|
|
2124
|
+
let w = 0;
|
|
2125
|
+
while (i < end) {
|
|
2126
|
+
const c = b[i];
|
|
2127
|
+
if (c === AMP) {
|
|
2128
|
+
const ent = entityAt(b, end, i);
|
|
2129
|
+
if (ent !== 0) {
|
|
2130
|
+
b[w++] = ent >>> 8;
|
|
2131
|
+
i += ent & 255;
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
2134
|
+
} else if (isSpace(c)) {
|
|
2135
|
+
b[w++] = SPACE;
|
|
2136
|
+
i++;
|
|
2137
|
+
while (i < end && isSpace(b[i])) i++;
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
b[w++] = c;
|
|
2141
|
+
i++;
|
|
2142
|
+
}
|
|
2143
|
+
return w;
|
|
2144
|
+
}
|
|
2145
|
+
var ENTITIES = [
|
|
2146
|
+
["lt;", 60],
|
|
2147
|
+
["gt;", 62],
|
|
2148
|
+
["quot;", 34],
|
|
2149
|
+
["#39;", 39],
|
|
2150
|
+
["amp;", 38]
|
|
2151
|
+
];
|
|
2152
|
+
function entityAt(b, end, i) {
|
|
2153
|
+
const c1 = b[i + 1];
|
|
2154
|
+
for (const [body, ch] of ENTITIES) {
|
|
2155
|
+
if (body.charCodeAt(0) !== c1 || i + 1 + body.length > end) continue;
|
|
2156
|
+
let k = 1;
|
|
2157
|
+
while (k < body.length && b[i + 1 + k] === body.charCodeAt(k)) k++;
|
|
2158
|
+
if (k === body.length) return ch << 8 | body.length + 1;
|
|
2159
|
+
}
|
|
2160
|
+
return 0;
|
|
2161
|
+
}
|
|
2162
|
+
function fromCodes(b, len) {
|
|
2163
|
+
const CHUNK = 8192;
|
|
2164
|
+
let out = "";
|
|
2165
|
+
for (let at = 0; at < len; at += CHUNK) {
|
|
2166
|
+
out += String.fromCharCode.apply(null, b.subarray(at, Math.min(at + CHUNK, len)));
|
|
2167
|
+
}
|
|
2168
|
+
return out;
|
|
1002
2169
|
}
|
|
1003
2170
|
|
|
1004
2171
|
// src/net-guard.ts
|
|
1005
|
-
import {
|
|
2172
|
+
import { Resolver as DnsResolver } from "node:dns/promises";
|
|
1006
2173
|
import { isIPv4, isIPv6 } from "node:net";
|
|
1007
2174
|
var BlockedAddressError = class extends Error {
|
|
1008
2175
|
};
|
|
1009
2176
|
function systemResolver() {
|
|
1010
|
-
return async (host
|
|
2177
|
+
return async (host, signal) => {
|
|
2178
|
+
if (isIPv4(host) || isIPv6(host)) return [host];
|
|
2179
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
2180
|
+
const resolver = new DnsResolver();
|
|
2181
|
+
const cancel = () => resolver.cancel();
|
|
2182
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
2183
|
+
try {
|
|
2184
|
+
const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
|
|
2185
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
2186
|
+
const addresses = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
2187
|
+
if (addresses.length === 0) {
|
|
2188
|
+
const rejected = results.find((result) => result.status === "rejected");
|
|
2189
|
+
throw rejected?.reason ?? new Error("DNS lookup returned no addresses");
|
|
2190
|
+
}
|
|
2191
|
+
return addresses;
|
|
2192
|
+
} finally {
|
|
2193
|
+
signal?.removeEventListener("abort", cancel);
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
1011
2196
|
}
|
|
1012
2197
|
function v4Parts(ip) {
|
|
1013
2198
|
if (!isIPv4(ip)) return null;
|
|
@@ -1054,7 +2239,7 @@ function isPrivateAddress(ip) {
|
|
|
1054
2239
|
}
|
|
1055
2240
|
return (v6[0] & 65024) === 64512 || (v6[0] & 65472) === 65152;
|
|
1056
2241
|
}
|
|
1057
|
-
async function assertPublicHost(url, resolve3) {
|
|
2242
|
+
async function assertPublicHost(url, resolve3, signal) {
|
|
1058
2243
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1059
2244
|
throw new BlockedAddressError(`refused ${url.protocol}//: only http and https are allowed`);
|
|
1060
2245
|
}
|
|
@@ -1064,7 +2249,9 @@ async function assertPublicHost(url, resolve3) {
|
|
|
1064
2249
|
}
|
|
1065
2250
|
let addresses;
|
|
1066
2251
|
try {
|
|
1067
|
-
|
|
2252
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
2253
|
+
addresses = await resolve3(host, signal);
|
|
2254
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
1068
2255
|
} catch (e) {
|
|
1069
2256
|
throw new BlockedAddressError(`refused ${url.hostname}: cannot resolve (${e instanceof Error ? e.message : String(e)})`);
|
|
1070
2257
|
}
|
|
@@ -1082,14 +2269,20 @@ async function safeFetch(url, opts) {
|
|
|
1082
2269
|
const maxRedirects = opts.maxRedirects ?? 3;
|
|
1083
2270
|
const maxBytes = opts.maxBytes ?? 2e6;
|
|
1084
2271
|
const controller = new AbortController();
|
|
1085
|
-
const
|
|
2272
|
+
const cancel = () => controller.abort(opts.signal?.reason);
|
|
2273
|
+
if (opts.signal?.aborted) cancel();
|
|
2274
|
+
else opts.signal?.addEventListener("abort", cancel, { once: true });
|
|
2275
|
+
const timer = setTimeout(
|
|
2276
|
+
() => controller.abort(new Error("network request deadline exceeded")),
|
|
2277
|
+
opts.timeoutMs ?? 1e4
|
|
2278
|
+
);
|
|
1086
2279
|
try {
|
|
1087
2280
|
let current = new URL(url);
|
|
1088
2281
|
let method = opts.method ?? "GET";
|
|
1089
2282
|
let headers = opts.headers;
|
|
1090
2283
|
let body = opts.body;
|
|
1091
2284
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1092
|
-
await assertPublicHost(current, opts.resolve);
|
|
2285
|
+
await assertPublicHost(current, opts.resolve, controller.signal);
|
|
1093
2286
|
const res = await opts.fetch(current.toString(), {
|
|
1094
2287
|
method,
|
|
1095
2288
|
headers,
|
|
@@ -1122,6 +2315,7 @@ async function safeFetch(url, opts) {
|
|
|
1122
2315
|
throw new BlockedAddressError(`too many redirects (>${maxRedirects})`);
|
|
1123
2316
|
} finally {
|
|
1124
2317
|
clearTimeout(timer);
|
|
2318
|
+
opts.signal?.removeEventListener("abort", cancel);
|
|
1125
2319
|
}
|
|
1126
2320
|
}
|
|
1127
2321
|
function headerMap(res) {
|
|
@@ -1171,7 +2365,8 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1171
2365
|
fetch: ctx.fetch,
|
|
1172
2366
|
maxRedirects: 3,
|
|
1173
2367
|
timeoutMs: 1e4,
|
|
1174
|
-
maxBytes: 2e6
|
|
2368
|
+
maxBytes: 2e6,
|
|
2369
|
+
signal: ctx.signal
|
|
1175
2370
|
});
|
|
1176
2371
|
const type = res.headers["content-type"] ?? "";
|
|
1177
2372
|
const text = /html|xml/i.test(type) ? htmlToText(res.text) : res.text.replace(/\s+/g, " ").trim();
|
|
@@ -1182,7 +2377,7 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1182
2377
|
};
|
|
1183
2378
|
|
|
1184
2379
|
// src/builtins/http-request.ts
|
|
1185
|
-
import { readFileSync as
|
|
2380
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1186
2381
|
var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
|
|
1187
2382
|
var httpRequest = async (args, ctx) => {
|
|
1188
2383
|
const name = typeof args.name === "string" ? args.name : "";
|
|
@@ -1225,7 +2420,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
1225
2420
|
const headers = { accept: "application/json" };
|
|
1226
2421
|
if (endpoint.headersFile) {
|
|
1227
2422
|
try {
|
|
1228
|
-
for (const [k, v] of Object.entries(JSON.parse(
|
|
2423
|
+
for (const [k, v] of Object.entries(JSON.parse(readFileSync4(endpoint.headersFile, "utf8")))) {
|
|
1229
2424
|
if (typeof v === "string") headers[k] = v;
|
|
1230
2425
|
}
|
|
1231
2426
|
} catch {
|
|
@@ -1242,7 +2437,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1242
2437
|
headers,
|
|
1243
2438
|
body,
|
|
1244
2439
|
timeoutMs: 1e4,
|
|
1245
|
-
maxBytes: 2e6
|
|
2440
|
+
maxBytes: 2e6,
|
|
2441
|
+
signal: ctx.signal
|
|
1246
2442
|
});
|
|
1247
2443
|
return JSON.stringify({ status: res.status, body: capTo(res.text, FETCH_TEXT_CAP) });
|
|
1248
2444
|
} catch (e) {
|
|
@@ -1251,7 +2447,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1251
2447
|
};
|
|
1252
2448
|
|
|
1253
2449
|
// src/builtins/sql-query.ts
|
|
1254
|
-
import { readFileSync as
|
|
2450
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2451
|
+
import { spawn } from "node:child_process";
|
|
1255
2452
|
|
|
1256
2453
|
// src/sql-guard.ts
|
|
1257
2454
|
var SqlNotReadOnlyError = class extends Error {
|
|
@@ -1280,12 +2477,12 @@ var FORBIDDEN = [
|
|
|
1280
2477
|
"EXECUTE",
|
|
1281
2478
|
"INTO"
|
|
1282
2479
|
];
|
|
1283
|
-
function
|
|
2480
|
+
function stripComments2(sql) {
|
|
1284
2481
|
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
|
|
1285
2482
|
}
|
|
1286
2483
|
function assertReadOnlySelect(sql) {
|
|
1287
2484
|
const statement = sql.trim().replace(/;\s*$/, "").trim();
|
|
1288
|
-
const body =
|
|
2485
|
+
const body = stripComments2(sql).trim().replace(/;\s*$/, "").trim();
|
|
1289
2486
|
if (!body) throw new SqlNotReadOnlyError("sql_query needs a SELECT statement");
|
|
1290
2487
|
for (const text of [body, statement]) {
|
|
1291
2488
|
if (text.includes(";")) {
|
|
@@ -1305,16 +2502,160 @@ function assertReadOnlySelect(sql) {
|
|
|
1305
2502
|
}
|
|
1306
2503
|
|
|
1307
2504
|
// src/builtins/sql-query.ts
|
|
2505
|
+
var SQLITE_PROCESS_SOURCE = String.raw`
|
|
2506
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
2507
|
+
|
|
2508
|
+
let input = "";
|
|
2509
|
+
process.stdin.setEncoding("utf8");
|
|
2510
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
2511
|
+
process.stdin.on("end", () => {
|
|
2512
|
+
let handle;
|
|
2513
|
+
try {
|
|
2514
|
+
const request = JSON.parse(input);
|
|
2515
|
+
handle = new DatabaseSync(request.path, {
|
|
2516
|
+
readOnly: true,
|
|
2517
|
+
timeout: request.busyTimeoutMs,
|
|
2518
|
+
});
|
|
2519
|
+
// Keep SQLite-owned allocations bounded too, not only V8's heap. Temp
|
|
2520
|
+
// work stays in the limited heap instead of consuming unbounded disk,
|
|
2521
|
+
// and mmap is disabled so reads cannot bypass the allocation ceiling.
|
|
2522
|
+
const heapLimit = handle.prepare(
|
|
2523
|
+
"PRAGMA hard_heap_limit = " + request.sqliteHeapLimitBytes
|
|
2524
|
+
).get();
|
|
2525
|
+
const effectiveHeapLimit = Number(Object.values(heapLimit ?? {})[0]);
|
|
2526
|
+
if (!(effectiveHeapLimit > 0 && effectiveHeapLimit <= request.sqliteHeapLimitBytes)) {
|
|
2527
|
+
throw new Error("SQLite heap limit could not be enforced");
|
|
2528
|
+
}
|
|
2529
|
+
handle.exec("PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0");
|
|
2530
|
+
const statement = handle.prepare(request.sql);
|
|
2531
|
+
const rows = [];
|
|
2532
|
+
let columns = [];
|
|
2533
|
+
let truncated = false;
|
|
2534
|
+
|
|
2535
|
+
for (const record of statement.iterate()) {
|
|
2536
|
+
if (rows.length === request.rowCap) {
|
|
2537
|
+
truncated = true;
|
|
2538
|
+
break;
|
|
2539
|
+
}
|
|
2540
|
+
if (rows.length === 0) columns = Object.keys(record);
|
|
2541
|
+
rows.push(columns.map((column) => record[column]));
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
process.stdout.write(JSON.stringify({
|
|
2545
|
+
ok: true,
|
|
2546
|
+
result: { columns, rows, truncated },
|
|
2547
|
+
}));
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
process.stdout.write(JSON.stringify({
|
|
2550
|
+
ok: false,
|
|
2551
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2552
|
+
}));
|
|
2553
|
+
} finally {
|
|
2554
|
+
if (handle) handle.close();
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
`;
|
|
2558
|
+
var SQLITE_IPC_CAP_BYTES = 1024 * 1024;
|
|
2559
|
+
var SQLITE_HEAP_CAP_BYTES = 32 * 1024 * 1024;
|
|
2560
|
+
function executeSqliteQuery(path, sql, options = {}) {
|
|
2561
|
+
const timeoutMs = options.timeoutMs ?? SQL_TIMEOUT_MS;
|
|
2562
|
+
if (options.signal?.aborted) return Promise.reject(new Error("SQLite query cancelled"));
|
|
2563
|
+
return new Promise((resolve3, reject) => {
|
|
2564
|
+
const child = spawn(process.execPath, [
|
|
2565
|
+
"--no-warnings",
|
|
2566
|
+
"--max-old-space-size=32",
|
|
2567
|
+
"--input-type=commonjs",
|
|
2568
|
+
"--eval",
|
|
2569
|
+
SQLITE_PROCESS_SOURCE
|
|
2570
|
+
], {
|
|
2571
|
+
// The query process needs no credentials or runtime configuration.
|
|
2572
|
+
// In particular, do not let NODE_OPTIONS weaken its heap boundary.
|
|
2573
|
+
env: {},
|
|
2574
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
2575
|
+
windowsHide: true
|
|
2576
|
+
});
|
|
2577
|
+
let settled = false;
|
|
2578
|
+
let stopError;
|
|
2579
|
+
let stdout = "";
|
|
2580
|
+
let stdoutBytes = 0;
|
|
2581
|
+
const cleanup = () => {
|
|
2582
|
+
clearTimeout(timer);
|
|
2583
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
2584
|
+
};
|
|
2585
|
+
const stop = (error) => {
|
|
2586
|
+
if (settled || stopError) return;
|
|
2587
|
+
stopError = error;
|
|
2588
|
+
cleanup();
|
|
2589
|
+
child.kill("SIGKILL");
|
|
2590
|
+
};
|
|
2591
|
+
const onAbort = () => stop(new Error("SQLite query cancelled"));
|
|
2592
|
+
const timer = setTimeout(
|
|
2593
|
+
() => stop(new Error(`SQLite query timed out after ${timeoutMs}ms`)),
|
|
2594
|
+
timeoutMs
|
|
2595
|
+
);
|
|
2596
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
2597
|
+
child.stdout.setEncoding("utf8");
|
|
2598
|
+
child.stdout.on("data", (chunk) => {
|
|
2599
|
+
if (stopError) return;
|
|
2600
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
2601
|
+
if (stdoutBytes > SQLITE_IPC_CAP_BYTES) {
|
|
2602
|
+
stop(new Error("SQLite query result exceeded the materialization limit"));
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
stdout += chunk;
|
|
2606
|
+
});
|
|
2607
|
+
child.once("error", (error) => {
|
|
2608
|
+
if (settled) return;
|
|
2609
|
+
settled = true;
|
|
2610
|
+
cleanup();
|
|
2611
|
+
reject(error);
|
|
2612
|
+
});
|
|
2613
|
+
child.once("close", (code) => {
|
|
2614
|
+
if (settled) return;
|
|
2615
|
+
settled = true;
|
|
2616
|
+
cleanup();
|
|
2617
|
+
if (stopError) {
|
|
2618
|
+
reject(stopError);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (code !== 0) {
|
|
2622
|
+
reject(new Error(`SQLite query process exited before returning a result (code ${code})`));
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
try {
|
|
2626
|
+
const message = JSON.parse(stdout);
|
|
2627
|
+
if (message.ok && message.result) resolve3(message.result);
|
|
2628
|
+
else reject(new Error(message.error ?? "SQLite query process failed"));
|
|
2629
|
+
} catch {
|
|
2630
|
+
reject(new Error("SQLite query process returned an invalid result"));
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
child.stdin.on("error", () => {
|
|
2634
|
+
});
|
|
2635
|
+
child.stdin.end(JSON.stringify({
|
|
2636
|
+
path,
|
|
2637
|
+
sql,
|
|
2638
|
+
rowCap: SQL_ROW_CAP,
|
|
2639
|
+
busyTimeoutMs: timeoutMs,
|
|
2640
|
+
sqliteHeapLimitBytes: SQLITE_HEAP_CAP_BYTES
|
|
2641
|
+
}));
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
1308
2644
|
function envelope(columns, rows, truncated) {
|
|
1309
2645
|
let kept = rows;
|
|
1310
2646
|
let cut = truncated;
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
2647
|
+
try {
|
|
2648
|
+
let out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
2649
|
+
while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
|
|
2650
|
+
kept = kept.slice(0, Math.floor(kept.length / 2));
|
|
2651
|
+
cut = true;
|
|
2652
|
+
out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
2653
|
+
}
|
|
2654
|
+
if (resultBytes(out) <= RESULT_CAP) return out;
|
|
2655
|
+
} catch {
|
|
2656
|
+
return toolError("database result could not be serialized");
|
|
1316
2657
|
}
|
|
1317
|
-
return
|
|
2658
|
+
return toolError("database result metadata exceeded the response limit");
|
|
1318
2659
|
}
|
|
1319
2660
|
var sqlQuery = async (args, ctx) => {
|
|
1320
2661
|
const name = typeof args.name === "string" ? args.name : "";
|
|
@@ -1332,16 +2673,8 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1332
2673
|
}
|
|
1333
2674
|
try {
|
|
1334
2675
|
if ("sqlite" in db) {
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
try {
|
|
1338
|
-
const rows = handle.prepare(sql).all();
|
|
1339
|
-
const capped = rows.slice(0, SQL_ROW_CAP);
|
|
1340
|
-
const columns = capped.length ? Object.keys(capped[0]) : [];
|
|
1341
|
-
return envelope(columns, capped.map((r) => columns.map((c) => r[c])), rows.length > SQL_ROW_CAP);
|
|
1342
|
-
} finally {
|
|
1343
|
-
handle.close();
|
|
1344
|
-
}
|
|
2676
|
+
const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
|
|
2677
|
+
return envelope(result.columns, result.rows, result.truncated);
|
|
1345
2678
|
}
|
|
1346
2679
|
let pg;
|
|
1347
2680
|
try {
|
|
@@ -1352,19 +2685,32 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1352
2685
|
const Client = pg.Client ?? pg.default?.Client;
|
|
1353
2686
|
if (!Client) return toolError(`the installed "pg" module does not export a Client`);
|
|
1354
2687
|
const client = new Client({
|
|
1355
|
-
connectionString:
|
|
2688
|
+
connectionString: readFileSync5(db.postgresUrlFile, "utf8").trim(),
|
|
1356
2689
|
// never logged (D4)
|
|
1357
|
-
statement_timeout: SQL_TIMEOUT_MS
|
|
2690
|
+
statement_timeout: SQL_TIMEOUT_MS,
|
|
2691
|
+
connectionTimeoutMillis: SQL_TIMEOUT_MS,
|
|
2692
|
+
query_timeout: SQL_TIMEOUT_MS
|
|
1358
2693
|
});
|
|
1359
|
-
|
|
2694
|
+
let cancelled = ctx.signal?.aborted ?? false;
|
|
2695
|
+
const cancel = () => {
|
|
2696
|
+
cancelled = true;
|
|
2697
|
+
void client.end().catch(() => {
|
|
2698
|
+
});
|
|
2699
|
+
};
|
|
2700
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
2701
|
+
ctx.signal?.addEventListener("abort", cancel, { once: true });
|
|
1360
2702
|
try {
|
|
2703
|
+
await client.connect();
|
|
2704
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1361
2705
|
await client.query("BEGIN; SET TRANSACTION READ ONLY;");
|
|
1362
2706
|
const res = await client.query({ text: sql, rowMode: "array" });
|
|
2707
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1363
2708
|
const columns = res.fields.map((f) => f.name);
|
|
1364
2709
|
const rows = res.rows.slice(0, SQL_ROW_CAP);
|
|
1365
2710
|
return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
|
|
1366
2711
|
} finally {
|
|
1367
|
-
|
|
2712
|
+
ctx.signal?.removeEventListener("abort", cancel);
|
|
2713
|
+
if (!cancelled) await client.query("ROLLBACK").catch(() => {
|
|
1368
2714
|
});
|
|
1369
2715
|
await client.end().catch(() => {
|
|
1370
2716
|
});
|
|
@@ -1387,13 +2733,14 @@ var webSearch = async (args, ctx) => {
|
|
|
1387
2733
|
try {
|
|
1388
2734
|
let hits;
|
|
1389
2735
|
if (brave) {
|
|
1390
|
-
ctx.log("debug",
|
|
2736
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: true });
|
|
1391
2737
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
1392
2738
|
const res = await safeFetch(url, {
|
|
1393
2739
|
resolve: ctx.resolve,
|
|
1394
2740
|
fetch: ctx.fetch,
|
|
1395
2741
|
timeoutMs: 1e4,
|
|
1396
2742
|
maxBytes: 2e6,
|
|
2743
|
+
signal: ctx.signal,
|
|
1397
2744
|
headers: { accept: "application/json", "X-Subscription-Token": brave }
|
|
1398
2745
|
});
|
|
1399
2746
|
if (res.status !== 200) return toolError(`brave search returned HTTP ${res.status}`);
|
|
@@ -1404,12 +2751,13 @@ var webSearch = async (args, ctx) => {
|
|
|
1404
2751
|
snippet: r.description ?? ""
|
|
1405
2752
|
}));
|
|
1406
2753
|
} else {
|
|
1407
|
-
ctx.log("debug",
|
|
2754
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: false });
|
|
1408
2755
|
const res = await safeFetch("https://api.tavily.com/search", {
|
|
1409
2756
|
resolve: ctx.resolve,
|
|
1410
2757
|
fetch: ctx.fetch,
|
|
1411
2758
|
timeoutMs: 1e4,
|
|
1412
2759
|
maxBytes: 2e6,
|
|
2760
|
+
signal: ctx.signal,
|
|
1413
2761
|
method: "POST",
|
|
1414
2762
|
headers: { "content-type": "application/json" },
|
|
1415
2763
|
body: JSON.stringify({ api_key: tavily, query, max_results: count })
|
|
@@ -1432,14 +2780,70 @@ var webSearch = async (args, ctx) => {
|
|
|
1432
2780
|
var RESULT_CAP = 2e4;
|
|
1433
2781
|
var TRUNCATION_MARKER = "\u2026[truncated]";
|
|
1434
2782
|
var FETCH_TEXT_CAP = RESULT_CAP - 4096;
|
|
2783
|
+
function resultBytes(s) {
|
|
2784
|
+
return Buffer.byteLength(s, "utf8");
|
|
2785
|
+
}
|
|
2786
|
+
function utf8Prefix(s, maxBytes) {
|
|
2787
|
+
if (maxBytes <= 0) return "";
|
|
2788
|
+
const bytes = Buffer.from(s, "utf8");
|
|
2789
|
+
if (bytes.length <= maxBytes) return s;
|
|
2790
|
+
let end = maxBytes;
|
|
2791
|
+
while (end > 0) {
|
|
2792
|
+
const candidate = bytes.subarray(0, end).toString("utf8");
|
|
2793
|
+
if (!candidate.endsWith("\uFFFD") && resultBytes(candidate) <= maxBytes) return candidate;
|
|
2794
|
+
end--;
|
|
2795
|
+
}
|
|
2796
|
+
return "";
|
|
2797
|
+
}
|
|
2798
|
+
function fitJsonString(value, build, fits = (candidate) => resultBytes(candidate) <= RESULT_CAP) {
|
|
2799
|
+
const codePoints = Array.from(value);
|
|
2800
|
+
let low = 0;
|
|
2801
|
+
let high = codePoints.length;
|
|
2802
|
+
let best = build("");
|
|
2803
|
+
while (low <= high) {
|
|
2804
|
+
const middle = Math.floor((low + high) / 2);
|
|
2805
|
+
const candidate = build(codePoints.slice(0, middle).join(""));
|
|
2806
|
+
if (fits(candidate)) {
|
|
2807
|
+
best = candidate;
|
|
2808
|
+
low = middle + 1;
|
|
2809
|
+
} else {
|
|
2810
|
+
high = middle - 1;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
return best;
|
|
2814
|
+
}
|
|
1435
2815
|
function capTo(s, limit) {
|
|
1436
|
-
|
|
2816
|
+
if (resultBytes(s) <= limit) return s;
|
|
2817
|
+
const markerBytes = resultBytes(TRUNCATION_MARKER);
|
|
2818
|
+
return utf8Prefix(s, Math.max(0, limit - markerBytes)) + TRUNCATION_MARKER;
|
|
1437
2819
|
}
|
|
1438
2820
|
function capResult(s) {
|
|
1439
2821
|
return capTo(s, RESULT_CAP);
|
|
1440
2822
|
}
|
|
2823
|
+
function boundToolResult(content) {
|
|
2824
|
+
if (resultBytes(content) <= RESULT_CAP) return content;
|
|
2825
|
+
try {
|
|
2826
|
+
const parsed = JSON.parse(content);
|
|
2827
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2828
|
+
const record = parsed;
|
|
2829
|
+
if (typeof record.error === "string") {
|
|
2830
|
+
const message = typeof record.message === "string" ? record.message : record.error;
|
|
2831
|
+
return toolError(message);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return fitJsonString(content, (result) => JSON.stringify({ result, truncated: true }));
|
|
2835
|
+
} catch {
|
|
2836
|
+
return capResult(content);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
1441
2839
|
function toolError(message) {
|
|
1442
|
-
|
|
2840
|
+
const plain = JSON.stringify({ error: "tool_failed", message });
|
|
2841
|
+
if (resultBytes(plain) <= RESULT_CAP) return plain;
|
|
2842
|
+
return fitJsonString(message, (prefix) => JSON.stringify({
|
|
2843
|
+
error: "tool_failed",
|
|
2844
|
+
message: prefix,
|
|
2845
|
+
truncated: true
|
|
2846
|
+
}));
|
|
1443
2847
|
}
|
|
1444
2848
|
var NO_CONTEXT = "no runtime context: this built-in needs the runtime's network context";
|
|
1445
2849
|
function offlineContext() {
|
|
@@ -1467,12 +2871,148 @@ var BUILTINS = {
|
|
|
1467
2871
|
sql_query: sqlQuery
|
|
1468
2872
|
};
|
|
1469
2873
|
|
|
2874
|
+
// src/side-effect-ledger.ts
|
|
2875
|
+
import { createHash } from "node:crypto";
|
|
2876
|
+
import { chmodSync, mkdirSync as mkdirSync3 } from "node:fs";
|
|
2877
|
+
import { dirname as dirname5 } from "node:path";
|
|
2878
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2879
|
+
var DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES = 1e4;
|
|
2880
|
+
function canonicalJson(value) {
|
|
2881
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
2882
|
+
return JSON.stringify(value);
|
|
2883
|
+
}
|
|
2884
|
+
if (typeof value === "number") {
|
|
2885
|
+
if (!Number.isFinite(value)) throw new Error("operation arguments must be finite JSON values");
|
|
2886
|
+
return JSON.stringify(value);
|
|
2887
|
+
}
|
|
2888
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
2889
|
+
if (typeof value === "object") {
|
|
2890
|
+
const record = value;
|
|
2891
|
+
return `{${Object.keys(record).sort().map(
|
|
2892
|
+
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
2893
|
+
).join(",")}}`;
|
|
2894
|
+
}
|
|
2895
|
+
throw new Error("operation arguments must be JSON values");
|
|
2896
|
+
}
|
|
2897
|
+
function operationSha256(tool, args) {
|
|
2898
|
+
const normalized = tool === "http_request" && typeof args.method === "string" ? { ...args, method: args.method.toUpperCase() } : args;
|
|
2899
|
+
return createHash("sha256").update(`${tool}
|
|
2900
|
+
${canonicalJson(normalized)}`, "utf8").digest("hex");
|
|
2901
|
+
}
|
|
2902
|
+
var SideEffectLedger = class {
|
|
2903
|
+
constructor(path, maxEntries = DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES) {
|
|
2904
|
+
this.maxEntries = maxEntries;
|
|
2905
|
+
mkdirSync3(dirname5(path), { recursive: true, mode: 448 });
|
|
2906
|
+
this.db = new DatabaseSync(path);
|
|
2907
|
+
chmodSync(path, 384);
|
|
2908
|
+
this.db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;");
|
|
2909
|
+
this.db.exec(`
|
|
2910
|
+
CREATE TABLE IF NOT EXISTS side_effect_claims (
|
|
2911
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
2912
|
+
operation_sha256 TEXT NOT NULL,
|
|
2913
|
+
confirmation_id TEXT NOT NULL UNIQUE,
|
|
2914
|
+
expires_at TEXT NOT NULL,
|
|
2915
|
+
state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
|
|
2916
|
+
created_at TEXT NOT NULL,
|
|
2917
|
+
completed_at TEXT
|
|
2918
|
+
) STRICT
|
|
2919
|
+
`);
|
|
2920
|
+
}
|
|
2921
|
+
db;
|
|
2922
|
+
claim(grant, now) {
|
|
2923
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2924
|
+
try {
|
|
2925
|
+
const prior = this.db.prepare(`
|
|
2926
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, state
|
|
2927
|
+
FROM side_effect_claims WHERE idempotency_key = ? OR confirmation_id = ?
|
|
2928
|
+
`).get(grant.idempotency_key, grant.confirmation_id);
|
|
2929
|
+
if (prior) {
|
|
2930
|
+
this.db.exec("COMMIT");
|
|
2931
|
+
if (prior.operation_sha256 !== grant.operation_sha256 || prior.confirmation_id !== grant.confirmation_id || prior.idempotency_key !== grant.idempotency_key) return { kind: "conflict" };
|
|
2932
|
+
return { kind: prior.state === "completed" ? "completed" : "ambiguous" };
|
|
2933
|
+
}
|
|
2934
|
+
const count = this.db.prepare("SELECT COUNT(*) AS count FROM side_effect_claims").get();
|
|
2935
|
+
if (count.count >= this.maxEntries) {
|
|
2936
|
+
this.db.exec("COMMIT");
|
|
2937
|
+
return { kind: "full" };
|
|
2938
|
+
}
|
|
2939
|
+
this.db.prepare(`
|
|
2940
|
+
INSERT INTO side_effect_claims
|
|
2941
|
+
(idempotency_key, operation_sha256, confirmation_id, expires_at, state, created_at)
|
|
2942
|
+
VALUES (?, ?, ?, ?, 'in_progress', ?)
|
|
2943
|
+
`).run(
|
|
2944
|
+
grant.idempotency_key,
|
|
2945
|
+
grant.operation_sha256,
|
|
2946
|
+
grant.confirmation_id,
|
|
2947
|
+
grant.expires_at,
|
|
2948
|
+
now.toISOString()
|
|
2949
|
+
);
|
|
2950
|
+
this.db.exec("COMMIT");
|
|
2951
|
+
return { kind: "execute" };
|
|
2952
|
+
} catch (error) {
|
|
2953
|
+
try {
|
|
2954
|
+
this.db.exec("ROLLBACK");
|
|
2955
|
+
} catch {
|
|
2956
|
+
}
|
|
2957
|
+
throw error;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
complete(grant, now) {
|
|
2961
|
+
const result = this.db.prepare(`
|
|
2962
|
+
UPDATE side_effect_claims SET state = 'completed', completed_at = ?
|
|
2963
|
+
WHERE idempotency_key = ? AND operation_sha256 = ? AND confirmation_id = ?
|
|
2964
|
+
AND state = 'in_progress'
|
|
2965
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2966
|
+
if (result.changes !== 1) throw new Error("side-effect claim could not be completed");
|
|
2967
|
+
}
|
|
2968
|
+
/** Content-free inventory for an operator investigating an interrupted effect. */
|
|
2969
|
+
listAmbiguous() {
|
|
2970
|
+
return this.db.prepare(`
|
|
2971
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, expires_at, created_at
|
|
2972
|
+
FROM side_effect_claims WHERE state='in_progress' ORDER BY created_at, idempotency_key
|
|
2973
|
+
`).all();
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Record the operator's external reconciliation result. `completed` prevents
|
|
2977
|
+
* replay; `not_applied` removes the pre-effect claim so the exact confirmed
|
|
2978
|
+
* operation may be retried. Every binding field must match the durable row.
|
|
2979
|
+
*/
|
|
2980
|
+
reconcile(grant, outcome, now) {
|
|
2981
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2982
|
+
try {
|
|
2983
|
+
const row = this.db.prepare(`
|
|
2984
|
+
SELECT state FROM side_effect_claims
|
|
2985
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=?
|
|
2986
|
+
`).get(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2987
|
+
if (!row) throw new Error("ambiguous side-effect claim not found or binding mismatch");
|
|
2988
|
+
if (row.state !== "in_progress") throw new Error("side-effect claim is already completed");
|
|
2989
|
+
if (outcome === "completed") {
|
|
2990
|
+
this.db.prepare(`
|
|
2991
|
+
UPDATE side_effect_claims SET state='completed', completed_at=?
|
|
2992
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2993
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2994
|
+
} else {
|
|
2995
|
+
this.db.prepare(`
|
|
2996
|
+
DELETE FROM side_effect_claims
|
|
2997
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2998
|
+
`).run(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2999
|
+
}
|
|
3000
|
+
this.db.exec("COMMIT");
|
|
3001
|
+
} catch (error) {
|
|
3002
|
+
try {
|
|
3003
|
+
this.db.exec("ROLLBACK");
|
|
3004
|
+
} catch {
|
|
3005
|
+
}
|
|
3006
|
+
throw error;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
close() {
|
|
3010
|
+
this.db.close();
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
|
|
1470
3014
|
// src/skill-invoker.ts
|
|
1471
3015
|
var SkillInvoker = class {
|
|
1472
|
-
skills = /* @__PURE__ */ new Map();
|
|
1473
|
-
sources = /* @__PURE__ */ new Map();
|
|
1474
|
-
skillsDir;
|
|
1475
|
-
ctx;
|
|
1476
3016
|
/**
|
|
1477
3017
|
* `ctx` carries everything a built-in may touch: the network, DNS, the clock
|
|
1478
3018
|
* and the key files. A caller that has no runtime (the loader tests, and any
|
|
@@ -1480,10 +3020,15 @@ var SkillInvoker = class {
|
|
|
1480
3020
|
* that need nothing still run and the ones that need the network fail as a
|
|
1481
3021
|
* tool_failed result instead of throwing.
|
|
1482
3022
|
*/
|
|
1483
|
-
constructor(skillsDir, ctx) {
|
|
3023
|
+
constructor(skillsDir, ctx, sideEffectLedger) {
|
|
3024
|
+
this.sideEffectLedger = sideEffectLedger;
|
|
1484
3025
|
this.skillsDir = skillsDir;
|
|
1485
3026
|
this.ctx = ctx ?? offlineContext();
|
|
1486
3027
|
}
|
|
3028
|
+
skills = /* @__PURE__ */ new Map();
|
|
3029
|
+
sources = /* @__PURE__ */ new Map();
|
|
3030
|
+
skillsDir;
|
|
3031
|
+
ctx;
|
|
1487
3032
|
/**
|
|
1488
3033
|
* Load all skill manifests from the skills directory. Supports two
|
|
1489
3034
|
* layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
|
|
@@ -1491,7 +3036,7 @@ var SkillInvoker = class {
|
|
|
1491
3036
|
*/
|
|
1492
3037
|
loadSkills() {
|
|
1493
3038
|
if (!existsSync3(this.skillsDir)) {
|
|
1494
|
-
|
|
3039
|
+
writeServiceLog("warn", "skills.directory_missing");
|
|
1495
3040
|
return;
|
|
1496
3041
|
}
|
|
1497
3042
|
const flat = [], manifests = [], skillMds = [];
|
|
@@ -1500,47 +3045,45 @@ var SkillInvoker = class {
|
|
|
1500
3045
|
flat.push(join4(this.skillsDir, entry.name));
|
|
1501
3046
|
} else if (entry.isDirectory()) {
|
|
1502
3047
|
const m = join4(this.skillsDir, entry.name, "manifest.json");
|
|
1503
|
-
if (existsSync3(m) &&
|
|
3048
|
+
if (existsSync3(m) && statSync2(m).isFile()) manifests.push(m);
|
|
1504
3049
|
const s = join4(this.skillsDir, entry.name, "SKILL.md");
|
|
1505
|
-
if (existsSync3(s) &&
|
|
3050
|
+
if (existsSync3(s) && statSync2(s).isFile()) skillMds.push(s);
|
|
1506
3051
|
}
|
|
1507
3052
|
}
|
|
1508
3053
|
for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
|
|
1509
3054
|
for (const p of skillMds.sort()) this.loadSkillMdFile(p);
|
|
1510
|
-
|
|
3055
|
+
writeServiceLog("info", "skills.loaded", { count: this.skills.size });
|
|
1511
3056
|
}
|
|
1512
3057
|
/** Later file wins, with a warning naming both paths (spec §3). */
|
|
1513
3058
|
register(manifest, path) {
|
|
1514
3059
|
const prior = this.sources.get(manifest.name);
|
|
1515
|
-
if (prior)
|
|
3060
|
+
if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
|
|
1516
3061
|
this.skills.set(manifest.name, manifest);
|
|
1517
3062
|
this.sources.set(manifest.name, path);
|
|
1518
3063
|
}
|
|
1519
3064
|
loadSkillMdFile(path) {
|
|
1520
3065
|
try {
|
|
1521
|
-
this.register(parseSkillMd(
|
|
3066
|
+
this.register(parseSkillMd(readFileSync6(path, "utf-8"), path), path);
|
|
1522
3067
|
} catch (err) {
|
|
1523
|
-
|
|
3068
|
+
writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
|
|
1524
3069
|
}
|
|
1525
3070
|
}
|
|
1526
3071
|
loadManifestFile(path) {
|
|
1527
3072
|
try {
|
|
1528
|
-
const raw =
|
|
3073
|
+
const raw = readFileSync6(path, "utf-8");
|
|
1529
3074
|
const manifest = JSON.parse(raw);
|
|
1530
3075
|
if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
|
|
1531
|
-
|
|
1532
|
-
`[SkillInvoker] Skipping manifest with invalid handler ${path}: expected "builtin:<name>" or "script:<path>"`
|
|
1533
|
-
);
|
|
3076
|
+
writeServiceLog("warn", "skills.manifest_handler_invalid");
|
|
1534
3077
|
return;
|
|
1535
3078
|
}
|
|
1536
3079
|
if (manifest.name && manifest.description) {
|
|
1537
3080
|
this.register(manifest, path);
|
|
1538
3081
|
} else {
|
|
1539
3082
|
const missing = !manifest.name ? "name" : "description";
|
|
1540
|
-
|
|
3083
|
+
writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
|
|
1541
3084
|
}
|
|
1542
3085
|
} catch (err) {
|
|
1543
|
-
|
|
3086
|
+
writeServiceLog("warn", "skills.manifest_load_failed", {}, err);
|
|
1544
3087
|
}
|
|
1545
3088
|
}
|
|
1546
3089
|
/** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
|
|
@@ -1580,27 +3123,76 @@ var SkillInvoker = class {
|
|
|
1580
3123
|
async invoke(name, args, context) {
|
|
1581
3124
|
const skill = this.skills.get(name);
|
|
1582
3125
|
if (!skill) {
|
|
1583
|
-
return JSON.stringify({ error: `Unknown skill: ${name}` });
|
|
3126
|
+
return boundToolResult(JSON.stringify({ error: `Unknown skill: ${name}` }));
|
|
3127
|
+
}
|
|
3128
|
+
const execute = async () => {
|
|
3129
|
+
const handler = skill.handler;
|
|
3130
|
+
if (handler.startsWith("builtin:")) {
|
|
3131
|
+
return this.invokeBuiltin(handler.slice(8), args, context?.signal);
|
|
3132
|
+
}
|
|
3133
|
+
if (handler.startsWith("script:")) {
|
|
3134
|
+
return this.invokeScript(handler.slice(7), args, context?.signal);
|
|
3135
|
+
}
|
|
3136
|
+
return boundToolResult(JSON.stringify({ error: `Unknown handler type: ${handler}` }));
|
|
3137
|
+
};
|
|
3138
|
+
return context?.sideEffect ? this.invokeSideEffect(name, args, context.sideEffect, execute) : execute();
|
|
3139
|
+
}
|
|
3140
|
+
async invokeSideEffect(name, args, grant, execute) {
|
|
3141
|
+
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
3142
|
+
if (name !== "http_request" || !(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
3143
|
+
return toolError("This tool has no supported side-effect replay contract");
|
|
3144
|
+
}
|
|
3145
|
+
if (grant.tool !== name || operationSha256(name, args) !== grant.operation_sha256) {
|
|
3146
|
+
return toolError("Confirmed operation digest does not match this tool call");
|
|
3147
|
+
}
|
|
3148
|
+
const now = this.ctx.now();
|
|
3149
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
3150
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
3151
|
+
return toolError("Confirmed operation grant is expired or invalid");
|
|
3152
|
+
}
|
|
3153
|
+
if (!this.sideEffectLedger) {
|
|
3154
|
+
return toolError("Side-effect replay protection is unavailable");
|
|
3155
|
+
}
|
|
3156
|
+
let claim;
|
|
3157
|
+
try {
|
|
3158
|
+
claim = this.sideEffectLedger.claim(grant, now);
|
|
3159
|
+
} catch {
|
|
3160
|
+
return toolError("Side-effect replay claim could not be persisted");
|
|
3161
|
+
}
|
|
3162
|
+
if (claim.kind === "completed") return JSON.stringify({ status: "already_applied" });
|
|
3163
|
+
if (claim.kind === "ambiguous") {
|
|
3164
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
3165
|
+
}
|
|
3166
|
+
if (claim.kind === "conflict") {
|
|
3167
|
+
return toolError("Idempotency key was already bound to a different confirmed operation");
|
|
3168
|
+
}
|
|
3169
|
+
if (claim.kind === "full") {
|
|
3170
|
+
return toolError("Side-effect replay ledger is full; reconcile it before running new operations");
|
|
1584
3171
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
3172
|
+
let result;
|
|
3173
|
+
try {
|
|
3174
|
+
result = await execute();
|
|
3175
|
+
} catch {
|
|
3176
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
1588
3177
|
}
|
|
1589
|
-
if (
|
|
1590
|
-
|
|
3178
|
+
if (isToolFailure(result)) return result;
|
|
3179
|
+
try {
|
|
3180
|
+
this.sideEffectLedger.complete(grant, this.ctx.now());
|
|
3181
|
+
} catch {
|
|
3182
|
+
return toolError("Side-effect completed but replay state is ambiguous; reconcile before retrying");
|
|
1591
3183
|
}
|
|
1592
|
-
return
|
|
3184
|
+
return result;
|
|
1593
3185
|
}
|
|
1594
|
-
async invokeBuiltin(name, args) {
|
|
3186
|
+
async invokeBuiltin(name, args, signal) {
|
|
1595
3187
|
const builtin = BUILTINS[name];
|
|
1596
3188
|
if (!builtin) return JSON.stringify({ error: `Unknown builtin: ${name}` });
|
|
1597
3189
|
try {
|
|
1598
|
-
return
|
|
3190
|
+
return boundToolResult(await builtin(args, { ...this.ctx, signal }));
|
|
1599
3191
|
} catch (e) {
|
|
1600
3192
|
return toolError(e instanceof Error ? e.message : String(e));
|
|
1601
3193
|
}
|
|
1602
3194
|
}
|
|
1603
|
-
async invokeScript(scriptPath, args) {
|
|
3195
|
+
async invokeScript(scriptPath, args, signal) {
|
|
1604
3196
|
const { resolve: resolve3, relative } = await import("node:path");
|
|
1605
3197
|
const resolved = resolve3(this.skillsDir, scriptPath);
|
|
1606
3198
|
const rel = relative(this.skillsDir, resolved);
|
|
@@ -1620,26 +3212,82 @@ var SkillInvoker = class {
|
|
|
1620
3212
|
try {
|
|
1621
3213
|
accessSync2(resolved, constants2.X_OK);
|
|
1622
3214
|
} catch {
|
|
1623
|
-
return
|
|
3215
|
+
return toolError(`Script not found or not executable: ${scriptPath}`);
|
|
1624
3216
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
timeout: 3e4,
|
|
1630
|
-
maxBuffer: 1024 * 1024,
|
|
1631
|
-
encoding: "utf-8",
|
|
3217
|
+
if (signal?.aborted) return toolError("Script execution cancelled");
|
|
3218
|
+
const { spawn: spawn2 } = await import("node:child_process");
|
|
3219
|
+
return new Promise((resolveResult) => {
|
|
3220
|
+
const child = spawn2(resolved, [], {
|
|
1632
3221
|
cwd: this.skillsDir,
|
|
1633
|
-
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
|
|
3222
|
+
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
|
|
3223
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
3224
|
+
windowsHide: true,
|
|
3225
|
+
// A script may launch children. On POSIX, make one process group so
|
|
3226
|
+
// cancellation kills the whole operation rather than orphaning them.
|
|
3227
|
+
detached: process.platform !== "win32"
|
|
1634
3228
|
});
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
3229
|
+
let stdout = "";
|
|
3230
|
+
let stdoutBytes = 0;
|
|
3231
|
+
let stopMessage = null;
|
|
3232
|
+
let settled = false;
|
|
3233
|
+
const cleanup = () => {
|
|
3234
|
+
clearTimeout(timer);
|
|
3235
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3236
|
+
};
|
|
3237
|
+
const stop = (message) => {
|
|
3238
|
+
if (settled || stopMessage) return;
|
|
3239
|
+
stopMessage = message;
|
|
3240
|
+
if (process.platform !== "win32" && child.pid) {
|
|
3241
|
+
try {
|
|
3242
|
+
process.kill(-child.pid, "SIGKILL");
|
|
3243
|
+
} catch {
|
|
3244
|
+
child.kill("SIGKILL");
|
|
3245
|
+
}
|
|
3246
|
+
} else {
|
|
3247
|
+
child.kill("SIGKILL");
|
|
3248
|
+
}
|
|
3249
|
+
};
|
|
3250
|
+
const onAbort = () => stop("Script execution cancelled");
|
|
3251
|
+
const timer = setTimeout(() => stop("Script execution timed out after 30000ms"), 3e4);
|
|
3252
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3253
|
+
child.stdout.setEncoding("utf8");
|
|
3254
|
+
child.stdout.on("data", (chunk) => {
|
|
3255
|
+
if (stopMessage) return;
|
|
3256
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
3257
|
+
if (stdoutBytes > 1024 * 1024) {
|
|
3258
|
+
stop("Script output exceeded the materialization limit");
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
stdout += chunk;
|
|
1639
3262
|
});
|
|
1640
|
-
|
|
3263
|
+
child.once("error", () => {
|
|
3264
|
+
if (settled) return;
|
|
3265
|
+
settled = true;
|
|
3266
|
+
cleanup();
|
|
3267
|
+
resolveResult(toolError("Script execution failed"));
|
|
3268
|
+
});
|
|
3269
|
+
child.once("close", (code) => {
|
|
3270
|
+
if (settled) return;
|
|
3271
|
+
settled = true;
|
|
3272
|
+
cleanup();
|
|
3273
|
+
if (stopMessage) resolveResult(toolError(stopMessage));
|
|
3274
|
+
else if (code !== 0) resolveResult(toolError(`Script execution failed (code ${code ?? "unknown"})`));
|
|
3275
|
+
else resolveResult(boundToolResult(stdout));
|
|
3276
|
+
});
|
|
3277
|
+
child.stdin.on("error", () => {
|
|
3278
|
+
});
|
|
3279
|
+
child.stdin.end(JSON.stringify(args));
|
|
3280
|
+
});
|
|
1641
3281
|
}
|
|
1642
3282
|
};
|
|
3283
|
+
function isToolFailure(result) {
|
|
3284
|
+
try {
|
|
3285
|
+
const parsed = JSON.parse(result);
|
|
3286
|
+
return parsed.error === "tool_failed";
|
|
3287
|
+
} catch {
|
|
3288
|
+
return false;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
1643
3291
|
|
|
1644
3292
|
// src/tool-gate.ts
|
|
1645
3293
|
function createLocalToolGate(policy) {
|
|
@@ -1688,7 +3336,7 @@ function createDynamicToolGate(policyFor) {
|
|
|
1688
3336
|
}
|
|
1689
3337
|
|
|
1690
3338
|
// src/data-plane.ts
|
|
1691
|
-
import { readFileSync as
|
|
3339
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
1692
3340
|
import { WebSocket } from "node:http";
|
|
1693
3341
|
import { homedir as homedir3 } from "node:os";
|
|
1694
3342
|
import { join as join5 } from "node:path";
|
|
@@ -1733,7 +3381,7 @@ var NO_TURN_AUTHORIZATION = Object.freeze({
|
|
|
1733
3381
|
function parseTurnAuthorization(value) {
|
|
1734
3382
|
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
|
|
1735
3383
|
const raw = value;
|
|
1736
|
-
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants)) return NO_TURN_AUTHORIZATION;
|
|
3384
|
+
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants) || raw.tool_grants.length > 100) return NO_TURN_AUTHORIZATION;
|
|
1737
3385
|
const grants = [];
|
|
1738
3386
|
for (const value2 of raw.tool_grants) {
|
|
1739
3387
|
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
|
|
@@ -1743,7 +3391,7 @@ function parseTurnAuthorization(value) {
|
|
|
1743
3391
|
grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
|
|
1744
3392
|
continue;
|
|
1745
3393
|
}
|
|
1746
|
-
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || typeof grant.expires_at !== "string" || !grant.expires_at) return NO_TURN_AUTHORIZATION;
|
|
3394
|
+
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || grant.confirmation_id.length > 200 || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || grant.idempotency_key.length > 200 || typeof grant.expires_at !== "string" || !grant.expires_at || grant.expires_at.length > 40) return NO_TURN_AUTHORIZATION;
|
|
1747
3395
|
grants.push(Object.freeze({
|
|
1748
3396
|
tool: grant.tool,
|
|
1749
3397
|
mode: "side_effect",
|
|
@@ -1761,9 +3409,8 @@ function parseTurnAuthorization(value) {
|
|
|
1761
3409
|
});
|
|
1762
3410
|
}
|
|
1763
3411
|
var SEEN_MAX = 5e3;
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
}
|
|
3412
|
+
var PENDING_REPLY_MAX = 1e3;
|
|
3413
|
+
var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
|
|
1767
3414
|
var SecureComsDataPlane = class {
|
|
1768
3415
|
identityId;
|
|
1769
3416
|
connectionPath;
|
|
@@ -1773,7 +3420,18 @@ var SecureComsDataPlane = class {
|
|
|
1773
3420
|
log;
|
|
1774
3421
|
sleep;
|
|
1775
3422
|
random;
|
|
3423
|
+
healthTimeoutMs;
|
|
1776
3424
|
onMessage;
|
|
3425
|
+
noticeReplies;
|
|
3426
|
+
/**
|
|
3427
|
+
* Final-wave I1(b): sockets whose daemon advertised the `nack` feature. On
|
|
3428
|
+
* those a failed turn is NACKED — only that message is retried, behind the
|
|
3429
|
+
* daemon's backoff, and the socket (with every other channel's turn) stays
|
|
3430
|
+
* up. Anywhere else a failed turn still closes the socket, as before.
|
|
3431
|
+
*/
|
|
3432
|
+
nackCapable = /* @__PURE__ */ new WeakSet();
|
|
3433
|
+
/** Sockets whose hello is unanswered: a refusal there is expected, not an error. */
|
|
3434
|
+
helloPending = /* @__PURE__ */ new WeakSet();
|
|
1777
3435
|
seen = /* @__PURE__ */ new Set();
|
|
1778
3436
|
stopped = false;
|
|
1779
3437
|
running = false;
|
|
@@ -1800,6 +3458,21 @@ var SecureComsDataPlane = class {
|
|
|
1800
3458
|
* generation is current, so a restart can never end up with two live loops.
|
|
1801
3459
|
*/
|
|
1802
3460
|
generation = 0;
|
|
3461
|
+
/** Turn controllers grouped by the socket whose result would receive them. */
|
|
3462
|
+
turns = /* @__PURE__ */ new Map();
|
|
3463
|
+
/**
|
|
3464
|
+
* Phase 3 F2: per-channel processing chains. A channel's messages are
|
|
3465
|
+
* handled strictly one after another in arrival order (the receipt lookup,
|
|
3466
|
+
* the turn and the reply), never concurrently — even when a daemon flushes a
|
|
3467
|
+
* whole backlog at once. Different channels still run alongside each other.
|
|
3468
|
+
*/
|
|
3469
|
+
channelChains = /* @__PURE__ */ new Map();
|
|
3470
|
+
healthController = null;
|
|
3471
|
+
clock;
|
|
3472
|
+
/** Arrival counter behind InboundMessage.seq. */
|
|
3473
|
+
seq = 0;
|
|
3474
|
+
/** in_reply_to → timing of a reply handed to the daemon, until its `sent` frame. */
|
|
3475
|
+
pendingReplies = /* @__PURE__ */ new Map();
|
|
1803
3476
|
constructor(cfg, onMessage) {
|
|
1804
3477
|
this.identityId = cfg.identityId;
|
|
1805
3478
|
const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
|
|
@@ -1807,18 +3480,28 @@ var SecureComsDataPlane = class {
|
|
|
1807
3480
|
this.establishedMs = cfg.establishedMs ?? 5e3;
|
|
1808
3481
|
this.initialDelayMs = cfg.initialDelayMs ?? 250;
|
|
1809
3482
|
this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
|
|
1810
|
-
this.log = cfg.log ??
|
|
1811
|
-
|
|
1812
|
-
else console.error(msg);
|
|
1813
|
-
});
|
|
1814
|
-
this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve3) => {
|
|
3483
|
+
this.log = cfg.log ?? writeServiceLog;
|
|
3484
|
+
this.sleep = cfg.sleep ?? ((ms2) => new Promise((resolve3) => {
|
|
1815
3485
|
this.sleepTimer = setTimeout(() => {
|
|
1816
3486
|
this.sleepTimer = null;
|
|
1817
3487
|
resolve3();
|
|
1818
|
-
},
|
|
3488
|
+
}, ms2);
|
|
1819
3489
|
}));
|
|
1820
3490
|
this.random = cfg.random ?? Math.random;
|
|
3491
|
+
this.healthTimeoutMs = cfg.healthTimeoutMs ?? 2e3;
|
|
1821
3492
|
this.onMessage = onMessage;
|
|
3493
|
+
this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
|
|
3494
|
+
this.clock = cfg.clock ?? (() => performance.now());
|
|
3495
|
+
}
|
|
3496
|
+
elapsed(from, to = this.clock()) {
|
|
3497
|
+
return Math.max(0, Math.round(to - from));
|
|
3498
|
+
}
|
|
3499
|
+
trackReply(messageId, reply) {
|
|
3500
|
+
if (this.pendingReplies.size >= PENDING_REPLY_MAX) {
|
|
3501
|
+
const oldest = this.pendingReplies.keys().next();
|
|
3502
|
+
if (!oldest.done) this.pendingReplies.delete(oldest.value);
|
|
3503
|
+
}
|
|
3504
|
+
this.pendingReplies.set(messageId, reply);
|
|
1822
3505
|
}
|
|
1823
3506
|
/**
|
|
1824
3507
|
* Resolves once the reconnect loop is running — NOT once attached. The loop
|
|
@@ -1837,13 +3520,19 @@ var SecureComsDataPlane = class {
|
|
|
1837
3520
|
this.generation++;
|
|
1838
3521
|
this.clearSettle();
|
|
1839
3522
|
this.cancelSleep();
|
|
3523
|
+
this.healthController?.abort(new Error("transport stopped"));
|
|
3524
|
+
this.healthController = null;
|
|
3525
|
+
for (const controllers of this.turns.values()) {
|
|
3526
|
+
for (const controller of controllers) controller.abort(new Error("transport stopped"));
|
|
3527
|
+
}
|
|
3528
|
+
this.turns.clear();
|
|
1840
3529
|
const ws = this.socket;
|
|
1841
3530
|
this.socket = null;
|
|
1842
3531
|
if (ws) {
|
|
1843
3532
|
try {
|
|
1844
3533
|
ws.close();
|
|
1845
3534
|
} catch (e) {
|
|
1846
|
-
this.log("warn",
|
|
3535
|
+
this.log("warn", "transport.socket_close_failed", {}, e);
|
|
1847
3536
|
}
|
|
1848
3537
|
}
|
|
1849
3538
|
}
|
|
@@ -1862,7 +3551,7 @@ var SecureComsDataPlane = class {
|
|
|
1862
3551
|
for (const release of this.sleepWaiters.splice(0)) release();
|
|
1863
3552
|
}
|
|
1864
3553
|
/** The backoff sleep, made interruptible by stop(). */
|
|
1865
|
-
wait(
|
|
3554
|
+
wait(ms2) {
|
|
1866
3555
|
if (this.stopped) return Promise.resolve();
|
|
1867
3556
|
return new Promise((resolve3) => {
|
|
1868
3557
|
let settled = false;
|
|
@@ -1873,7 +3562,7 @@ var SecureComsDataPlane = class {
|
|
|
1873
3562
|
resolve3();
|
|
1874
3563
|
};
|
|
1875
3564
|
this.sleepWaiters.push(finish);
|
|
1876
|
-
void this.sleep(
|
|
3565
|
+
void this.sleep(ms2).then(finish, finish);
|
|
1877
3566
|
});
|
|
1878
3567
|
}
|
|
1879
3568
|
clearSettle() {
|
|
@@ -1897,7 +3586,7 @@ var SecureComsDataPlane = class {
|
|
|
1897
3586
|
await this.attemptOnce();
|
|
1898
3587
|
delay = this.initialDelayMs;
|
|
1899
3588
|
} catch (e) {
|
|
1900
|
-
this.log("error",
|
|
3589
|
+
this.log("error", "transport.connection_attempt_failed", {}, e);
|
|
1901
3590
|
}
|
|
1902
3591
|
if (!live()) break;
|
|
1903
3592
|
await this.wait(this.random() * delay);
|
|
@@ -1906,10 +3595,22 @@ var SecureComsDataPlane = class {
|
|
|
1906
3595
|
if (this.generation === generation) this.running = false;
|
|
1907
3596
|
}
|
|
1908
3597
|
async attemptOnce() {
|
|
1909
|
-
const conn = JSON.parse(
|
|
3598
|
+
const conn = JSON.parse(readFileSync7(this.connectionPath, "utf8"));
|
|
1910
3599
|
const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
|
|
1911
|
-
const token =
|
|
1912
|
-
const
|
|
3600
|
+
const token = readFileSync7(conn.token_file, "utf8").trim();
|
|
3601
|
+
const healthController = new AbortController();
|
|
3602
|
+
this.healthController = healthController;
|
|
3603
|
+
const healthTimer = setTimeout(
|
|
3604
|
+
() => healthController.abort(new Error("health gate deadline exceeded")),
|
|
3605
|
+
this.healthTimeoutMs
|
|
3606
|
+
);
|
|
3607
|
+
let health;
|
|
3608
|
+
try {
|
|
3609
|
+
health = await (await fetch(`${httpBase}/healthz`, { signal: healthController.signal })).json();
|
|
3610
|
+
} finally {
|
|
3611
|
+
clearTimeout(healthTimer);
|
|
3612
|
+
if (this.healthController === healthController) this.healthController = null;
|
|
3613
|
+
}
|
|
1913
3614
|
if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
|
|
1914
3615
|
throw new Error(
|
|
1915
3616
|
`gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
|
|
@@ -1923,7 +3624,7 @@ var SecureComsDataPlane = class {
|
|
|
1923
3624
|
try {
|
|
1924
3625
|
await new Promise((resolve3, reject) => {
|
|
1925
3626
|
ws.addEventListener("error", (e) => {
|
|
1926
|
-
reject(new Error(
|
|
3627
|
+
reject(Object.assign(new Error("socket_error"), { cause: e }));
|
|
1927
3628
|
});
|
|
1928
3629
|
ws.addEventListener("close", (e) => {
|
|
1929
3630
|
if (established) resolve3();
|
|
@@ -1936,19 +3637,29 @@ var SecureComsDataPlane = class {
|
|
|
1936
3637
|
}
|
|
1937
3638
|
});
|
|
1938
3639
|
ws.addEventListener("open", () => {
|
|
3640
|
+
this.helloPending.add(ws);
|
|
3641
|
+
try {
|
|
3642
|
+
ws.send(JSON.stringify({ type: "hello", features: ["nack"] }));
|
|
3643
|
+
} catch {
|
|
3644
|
+
}
|
|
1939
3645
|
settle = setTimeout(() => {
|
|
1940
3646
|
established = true;
|
|
1941
|
-
this.log("info",
|
|
3647
|
+
this.log("info", "transport.attached");
|
|
1942
3648
|
}, this.establishedMs);
|
|
1943
3649
|
this.settle = settle;
|
|
1944
3650
|
});
|
|
1945
3651
|
ws.addEventListener("message", (ev) => {
|
|
1946
|
-
void this.handleFrame(ws, ev.data).catch((e) => {
|
|
1947
|
-
this.log("error",
|
|
3652
|
+
void this.handleFrame(ws, ev.data, httpBase, token).catch((e) => {
|
|
3653
|
+
this.log("error", "transport.frame_handling_failed", {}, e);
|
|
1948
3654
|
});
|
|
1949
3655
|
});
|
|
1950
3656
|
});
|
|
1951
3657
|
} finally {
|
|
3658
|
+
const controllers = this.turns.get(ws);
|
|
3659
|
+
if (controllers) {
|
|
3660
|
+
for (const controller of controllers) controller.abort(new Error("transport disconnected"));
|
|
3661
|
+
this.turns.delete(ws);
|
|
3662
|
+
}
|
|
1952
3663
|
if (settle !== null) clearTimeout(settle);
|
|
1953
3664
|
if (this.settle === settle) this.settle = null;
|
|
1954
3665
|
try {
|
|
@@ -1958,53 +3669,175 @@ var SecureComsDataPlane = class {
|
|
|
1958
3669
|
if (this.socket === ws) this.socket = null;
|
|
1959
3670
|
}
|
|
1960
3671
|
}
|
|
1961
|
-
|
|
3672
|
+
/**
|
|
3673
|
+
* Final-wave I1(b): fail ONE message. Against a nack-capable daemon, send a
|
|
3674
|
+
* `nack` — only this message is retried and the socket stays up for every
|
|
3675
|
+
* other channel's turn. Otherwise close the socket, the only failure
|
|
3676
|
+
* signal an older daemon understands (it re-buffers what was in flight).
|
|
3677
|
+
*/
|
|
3678
|
+
failTurn(ws, messageId, reason) {
|
|
3679
|
+
if (this.nackCapable.has(ws) && ws.readyState === ws.OPEN) {
|
|
3680
|
+
try {
|
|
3681
|
+
ws.send(JSON.stringify({ type: "nack", message_id: messageId, reason }));
|
|
3682
|
+
return;
|
|
3683
|
+
} catch {
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
try {
|
|
3687
|
+
ws.close();
|
|
3688
|
+
} catch {
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
async handleFrame(ws, data, httpBase, token) {
|
|
1962
3692
|
let frame;
|
|
1963
3693
|
try {
|
|
1964
3694
|
frame = JSON.parse(String(data));
|
|
1965
3695
|
} catch (e) {
|
|
1966
|
-
this.log("error",
|
|
3696
|
+
this.log("error", "transport.frame_parse_failed", {}, e);
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3699
|
+
if (frame?.type === "hello") {
|
|
3700
|
+
this.helloPending.delete(ws);
|
|
3701
|
+
if (Array.isArray(frame.features) && frame.features.includes("nack")) this.nackCapable.add(ws);
|
|
1967
3702
|
return;
|
|
1968
3703
|
}
|
|
1969
3704
|
if (frame?.type === "error") {
|
|
1970
|
-
|
|
3705
|
+
if (frame.in_reply_to === void 0 && this.helloPending.has(ws)) {
|
|
3706
|
+
this.helloPending.delete(ws);
|
|
3707
|
+
this.log("info", "transport.daemon_without_nack");
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
if (typeof frame.in_reply_to === "string") this.pendingReplies.delete(frame.in_reply_to);
|
|
3711
|
+
if (typeof frame.in_reply_to === "string" && TERMINAL_REPLY_ERRORS.has(String(frame.error))) {
|
|
3712
|
+
this.log("warn", "transport.reply_terminal", { paused: frame.error === "agent_paused" });
|
|
3713
|
+
this.remember(frame.in_reply_to);
|
|
3714
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
this.log("error", "transport.daemon_error_frame");
|
|
3718
|
+
if (typeof frame.in_reply_to === "string") this.failTurn(ws, frame.in_reply_to, "reply_failed");
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
if (frame?.type === "sent" && typeof frame.in_reply_to === "string") {
|
|
3722
|
+
const pending = this.pendingReplies.get(frame.in_reply_to);
|
|
3723
|
+
if (pending) {
|
|
3724
|
+
this.pendingReplies.delete(frame.in_reply_to);
|
|
3725
|
+
const now = this.clock();
|
|
3726
|
+
this.log("info", "transport.reply_published", {
|
|
3727
|
+
seq: pending.seq,
|
|
3728
|
+
publish_ms: this.elapsed(pending.sentAt, now),
|
|
3729
|
+
total_ms: this.elapsed(pending.arrived, now)
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
this.remember(frame.in_reply_to);
|
|
3733
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
1971
3734
|
return;
|
|
1972
3735
|
}
|
|
1973
3736
|
if (frame?.type !== "message") return;
|
|
1974
3737
|
const messageId = frame.message_id;
|
|
1975
3738
|
if (typeof messageId !== "string") {
|
|
1976
|
-
this.log("error", "
|
|
3739
|
+
this.log("error", "transport.message_id_missing");
|
|
1977
3740
|
return;
|
|
1978
3741
|
}
|
|
1979
3742
|
const channelId = String(frame.channel_id);
|
|
3743
|
+
const arrival = { seq: ++this.seq, at: this.clock() };
|
|
3744
|
+
const previous = this.channelChains.get(channelId) ?? Promise.resolve();
|
|
3745
|
+
const run = () => this.handleMessage(ws, frame, messageId, channelId, httpBase, token, arrival);
|
|
3746
|
+
const mine = previous.then(run, run);
|
|
3747
|
+
this.channelChains.set(channelId, mine);
|
|
3748
|
+
try {
|
|
3749
|
+
await mine;
|
|
3750
|
+
} finally {
|
|
3751
|
+
if (this.channelChains.get(channelId) === mine) this.channelChains.delete(channelId);
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
async handleMessage(ws, frame, messageId, channelId, httpBase, token, arrival) {
|
|
3755
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
3756
|
+
let turnController = null;
|
|
3757
|
+
const startedAt = this.clock();
|
|
3758
|
+
let receiptMs = 0;
|
|
3759
|
+
let handlerMs = 0;
|
|
3760
|
+
let sendMs = 0;
|
|
3761
|
+
let outcome = "failed";
|
|
1980
3762
|
try {
|
|
1981
3763
|
if (!this.seen.has(messageId)) {
|
|
1982
|
-
this.
|
|
3764
|
+
const receiptStart = this.clock();
|
|
3765
|
+
const receipt = await fetch(
|
|
3766
|
+
`${httpBase}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`,
|
|
3767
|
+
{
|
|
3768
|
+
headers: { authorization: `Bearer ${token}` },
|
|
3769
|
+
signal: AbortSignal.timeout(this.healthTimeoutMs)
|
|
3770
|
+
}
|
|
3771
|
+
);
|
|
3772
|
+
if (!receipt.ok) throw new Error(`reply receipt lookup failed: HTTP ${receipt.status}`);
|
|
3773
|
+
const found = (await receipt.json()).found === true;
|
|
3774
|
+
receiptMs = this.elapsed(receiptStart);
|
|
3775
|
+
if (found) {
|
|
3776
|
+
outcome = "receipt_found";
|
|
3777
|
+
this.remember(messageId);
|
|
3778
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
turnController = new AbortController();
|
|
3782
|
+
let socketTurns = this.turns.get(ws);
|
|
3783
|
+
if (!socketTurns) {
|
|
3784
|
+
socketTurns = /* @__PURE__ */ new Set();
|
|
3785
|
+
this.turns.set(ws, socketTurns);
|
|
3786
|
+
}
|
|
3787
|
+
socketTurns.add(turnController);
|
|
3788
|
+
const handlerStart = this.clock();
|
|
1983
3789
|
const reply = await this.onMessage({
|
|
1984
3790
|
text: String(frame.text ?? ""),
|
|
1985
3791
|
senderId: String(frame.sender_id ?? ""),
|
|
1986
3792
|
channelId,
|
|
1987
3793
|
messageId,
|
|
1988
3794
|
authorization: parseTurnAuthorization(frame.authorization),
|
|
1989
|
-
memory: parseTurnMemory(frame.memory)
|
|
1990
|
-
|
|
3795
|
+
memory: parseTurnMemory(frame.memory),
|
|
3796
|
+
seq: arrival.seq
|
|
3797
|
+
}, turnController.signal);
|
|
3798
|
+
handlerMs = this.elapsed(handlerStart);
|
|
1991
3799
|
if (reply != null) {
|
|
3800
|
+
const text = String(reply);
|
|
3801
|
+
const sendStart = this.clock();
|
|
1992
3802
|
ws.send(JSON.stringify({
|
|
1993
3803
|
type: "send",
|
|
1994
3804
|
channel_id: channelId,
|
|
1995
|
-
text
|
|
1996
|
-
in_reply_to: messageId
|
|
3805
|
+
text,
|
|
3806
|
+
in_reply_to: messageId,
|
|
3807
|
+
...this.noticeReplies.has(text) ? { notice: true } : {}
|
|
1997
3808
|
}));
|
|
3809
|
+
const sentAt = this.clock();
|
|
3810
|
+
sendMs = this.elapsed(sendStart, sentAt);
|
|
3811
|
+
outcome = "replied";
|
|
3812
|
+
this.trackReply(messageId, { seq: arrival.seq, arrived: arrival.at, sentAt });
|
|
3813
|
+
return;
|
|
1998
3814
|
}
|
|
3815
|
+
outcome = "no_reply";
|
|
3816
|
+
this.remember(messageId);
|
|
3817
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
3818
|
+
} else {
|
|
3819
|
+
outcome = "duplicate";
|
|
3820
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
1999
3821
|
}
|
|
2000
3822
|
} catch (e) {
|
|
2001
|
-
this.log("error",
|
|
3823
|
+
this.log("error", "transport.handler_failed", {}, e);
|
|
3824
|
+
this.failTurn(ws, messageId, "turn_failed");
|
|
2002
3825
|
} finally {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
3826
|
+
if (turnController) {
|
|
3827
|
+
const socketTurns = this.turns.get(ws);
|
|
3828
|
+
socketTurns?.delete(turnController);
|
|
3829
|
+
if (socketTurns?.size === 0) this.turns.delete(ws);
|
|
2007
3830
|
}
|
|
3831
|
+
const end = this.clock();
|
|
3832
|
+
this.log("info", "transport.message_handled", {
|
|
3833
|
+
seq: arrival.seq,
|
|
3834
|
+
chain_wait_ms: this.elapsed(arrival.at, startedAt),
|
|
3835
|
+
receipt_ms: receiptMs,
|
|
3836
|
+
handler_ms: handlerMs,
|
|
3837
|
+
send_ms: sendMs,
|
|
3838
|
+
total_ms: this.elapsed(arrival.at, end),
|
|
3839
|
+
outcome: logLabel(outcome)
|
|
3840
|
+
});
|
|
2008
3841
|
}
|
|
2009
3842
|
}
|
|
2010
3843
|
};
|
|
@@ -2013,9 +3846,22 @@ function createSecureComsDataPlane(cfg, onMessage) {
|
|
|
2013
3846
|
}
|
|
2014
3847
|
|
|
2015
3848
|
// src/tool-runner.ts
|
|
3849
|
+
var ERROR_ENVELOPE = /^\{\s*"error"\s*:/;
|
|
2016
3850
|
async function runToolCalls(toolCalls, skills, context) {
|
|
2017
3851
|
const out = [];
|
|
3852
|
+
const clock = context?.clock ?? (() => performance.now());
|
|
2018
3853
|
for (const call of toolCalls) {
|
|
3854
|
+
if (context?.signal?.aborted) {
|
|
3855
|
+
out.push({
|
|
3856
|
+
role: "tool",
|
|
3857
|
+
content: toolError("Tool execution cancelled"),
|
|
3858
|
+
tool_call_id: call.id
|
|
3859
|
+
});
|
|
3860
|
+
context.onTiming?.({ tool_call_id: call.id, ms: 0, ok: false, cancelled: true });
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
3863
|
+
const started = clock();
|
|
3864
|
+
let threw = false;
|
|
2019
3865
|
let args;
|
|
2020
3866
|
try {
|
|
2021
3867
|
args = JSON.parse(call.function.arguments);
|
|
@@ -2024,13 +3870,22 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2024
3870
|
}
|
|
2025
3871
|
let content;
|
|
2026
3872
|
try {
|
|
2027
|
-
content = await skills.invoke(call.function.name, args,
|
|
3873
|
+
content = boundToolResult(await skills.invoke(call.function.name, args, {
|
|
3874
|
+
roomId: context?.roomId,
|
|
3875
|
+
groupId: context?.groupId,
|
|
3876
|
+
signal: context?.signal,
|
|
3877
|
+
sideEffect: context?.sideEffects?.get(call.id)
|
|
3878
|
+
}));
|
|
2028
3879
|
} catch (err) {
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
message: String(err instanceof Error ? err.message : err)
|
|
2032
|
-
});
|
|
3880
|
+
threw = true;
|
|
3881
|
+
content = toolError(String(err instanceof Error ? err.message : err));
|
|
2033
3882
|
}
|
|
3883
|
+
context?.onTiming?.({
|
|
3884
|
+
tool_call_id: call.id,
|
|
3885
|
+
ms: clock() - started,
|
|
3886
|
+
ok: !threw && !ERROR_ENVELOPE.test(content),
|
|
3887
|
+
cancelled: false
|
|
3888
|
+
});
|
|
2034
3889
|
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
2035
3890
|
}
|
|
2036
3891
|
return out;
|
|
@@ -2039,14 +3894,56 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2039
3894
|
// src/turn-loop.ts
|
|
2040
3895
|
var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
|
|
2041
3896
|
var DEFAULT_MAX_ITERATIONS = 5;
|
|
3897
|
+
var DEFAULT_MAX_CONCURRENT_TURNS = 4;
|
|
3898
|
+
var DEFAULT_TURN_TIMEOUT_MS = 3e5;
|
|
3899
|
+
var DAILY_LIMIT_RESPONSE = "This agent has reached its daily usage limit and will respond again after 00:00 UTC.";
|
|
3900
|
+
var BUDGET_UNAVAILABLE_RESPONSE = "This agent cannot use its language model right now because of a problem on its host. Its owner can find the details in the runtime log.";
|
|
3901
|
+
function isBudgetUnavailable(error) {
|
|
3902
|
+
return error instanceof RequestExceedsDailyTokenCapError || error instanceof BudgetLockHeldError || error instanceof BudgetStateWriteError || error instanceof BudgetStateReadError;
|
|
3903
|
+
}
|
|
3904
|
+
var MESSAGE_TOO_LARGE_RESPONSE = "This message is too large for this agent to process. Please send a shorter message.";
|
|
3905
|
+
var TURN_TOO_LARGE_RESPONSE = "This agent could not finish this request within its size limits.";
|
|
3906
|
+
var MAX_TOOL_CALLS_PER_RESPONSE = 8;
|
|
3907
|
+
var MAX_TOOL_CALLS_PER_TURN = 16;
|
|
3908
|
+
var MAX_TOOL_RESULT_BYTES_PER_TURN = 64 * 1024;
|
|
3909
|
+
var TOOL_RESULT_HEADROOM_BYTES = 12 * 1024;
|
|
3910
|
+
var DEFAULT_TURN_ABANDON_GRACE_MS = 1e4;
|
|
3911
|
+
var REQUEST_ENVELOPE_RESERVE_BYTES = 1024;
|
|
3912
|
+
var MIN_TRUNCATED_TOOL_RESULT_BYTES = 1024;
|
|
3913
|
+
var RESULT_BUDGET_EXCEEDED = JSON.stringify({ error: "tool_result_budget_exceeded" });
|
|
3914
|
+
function jsonBytes(value) {
|
|
3915
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
3916
|
+
}
|
|
3917
|
+
function messageBytes(message) {
|
|
3918
|
+
return jsonBytes(message) + 1;
|
|
3919
|
+
}
|
|
3920
|
+
var PromptTooLargeError = class extends Error {
|
|
3921
|
+
constructor(initial, sizes = {}) {
|
|
3922
|
+
super("prompt exceeds the request size budget");
|
|
3923
|
+
this.initial = initial;
|
|
3924
|
+
this.sizes = sizes;
|
|
3925
|
+
}
|
|
3926
|
+
};
|
|
3927
|
+
var TurnAbandonedError = class extends Error {
|
|
3928
|
+
constructor() {
|
|
3929
|
+
super("Agent turn abandoned");
|
|
3930
|
+
}
|
|
3931
|
+
};
|
|
2042
3932
|
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
|
|
2043
3933
|
function hasReadGrant(auth, toolName) {
|
|
2044
3934
|
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
|
|
2045
3935
|
}
|
|
3936
|
+
function hasSideEffectGrant(auth, toolName) {
|
|
3937
|
+
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "side_effect");
|
|
3938
|
+
}
|
|
2046
3939
|
function toolIsOffered(auth, toolName) {
|
|
2047
|
-
|
|
3940
|
+
if (READ_ONLY_TOOLS.has(toolName)) return hasReadGrant(auth, toolName);
|
|
3941
|
+
if (toolName === "http_request") {
|
|
3942
|
+
return hasReadGrant(auth, toolName) || hasSideEffectGrant(auth, toolName);
|
|
3943
|
+
}
|
|
3944
|
+
return false;
|
|
2048
3945
|
}
|
|
2049
|
-
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
3946
|
+
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames, now = /* @__PURE__ */ new Date()) {
|
|
2050
3947
|
if (auth.sender_membership !== "active") {
|
|
2051
3948
|
return { allowed: false, reason: "Sender membership is not active" };
|
|
2052
3949
|
}
|
|
@@ -2057,8 +3954,26 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
|
2057
3954
|
try {
|
|
2058
3955
|
const args = JSON.parse(argumentsJson);
|
|
2059
3956
|
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
2060
|
-
if (method
|
|
2061
|
-
|
|
3957
|
+
if (method === "GET") {
|
|
3958
|
+
if (!hasReadGrant(auth, toolName)) {
|
|
3959
|
+
return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
|
|
3960
|
+
}
|
|
3961
|
+
} else {
|
|
3962
|
+
if (!(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
3963
|
+
return { allowed: false, reason: `HTTP method "${method}" is not supported` };
|
|
3964
|
+
}
|
|
3965
|
+
const digest = operationSha256(toolName, args);
|
|
3966
|
+
const grant = auth.tool_grants.find(
|
|
3967
|
+
(candidate) => candidate.mode === "side_effect" && candidate.tool === toolName && candidate.operation_sha256 === digest
|
|
3968
|
+
);
|
|
3969
|
+
if (!grant) {
|
|
3970
|
+
return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
|
|
3971
|
+
}
|
|
3972
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
3973
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
3974
|
+
return { allowed: false, reason: "The confirmed operation grant is expired or invalid" };
|
|
3975
|
+
}
|
|
3976
|
+
return { allowed: true, reason: `Tool "${toolName}" has an exact confirmed operation grant`, sideEffect: grant };
|
|
2062
3977
|
}
|
|
2063
3978
|
} catch {
|
|
2064
3979
|
return { allowed: false, reason: "Tool arguments are not valid JSON" };
|
|
@@ -2066,99 +3981,672 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
|
2066
3981
|
}
|
|
2067
3982
|
return { allowed: true, reason: `Tool "${toolName}" has an active channel read grant` };
|
|
2068
3983
|
}
|
|
3984
|
+
var FINISH_REASONS = /* @__PURE__ */ new Set([
|
|
3985
|
+
"stop",
|
|
3986
|
+
"tool_calls",
|
|
3987
|
+
"function_call",
|
|
3988
|
+
"length",
|
|
3989
|
+
"content_filter",
|
|
3990
|
+
"end_turn",
|
|
3991
|
+
"tool_use",
|
|
3992
|
+
"max_tokens",
|
|
3993
|
+
"stop_sequence"
|
|
3994
|
+
]);
|
|
3995
|
+
function finishReasonLabel(reason) {
|
|
3996
|
+
if (typeof reason !== "string" || reason === "") return logLabel("none");
|
|
3997
|
+
return logLabel(FINISH_REASONS.has(reason) ? reason : "other");
|
|
3998
|
+
}
|
|
3999
|
+
function ms(from, to) {
|
|
4000
|
+
return Math.max(0, Math.round(to - from));
|
|
4001
|
+
}
|
|
2069
4002
|
function buildHandler(deps) {
|
|
2070
4003
|
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
4004
|
+
const maxConcurrentTurns = deps.maxConcurrentTurns ?? DEFAULT_MAX_CONCURRENT_TURNS;
|
|
4005
|
+
const turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
4006
|
+
const abandonGraceMs = deps.turnAbandonGraceMs ?? DEFAULT_TURN_ABANDON_GRACE_MS;
|
|
4007
|
+
const requestMaxBytes = deps.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
|
|
2071
4008
|
const log = deps.log ?? (() => {
|
|
2072
4009
|
});
|
|
4010
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
4011
|
+
const clock = deps.clock ?? (() => performance.now());
|
|
2073
4012
|
const { llm, memoryFor, skills, gate } = deps;
|
|
2074
|
-
|
|
4013
|
+
let localSeq = 0;
|
|
4014
|
+
const channelTails = /* @__PURE__ */ new Map();
|
|
4015
|
+
const slotWaiters = [];
|
|
4016
|
+
let activeTurns = 0;
|
|
4017
|
+
let abandonedUnsettled = 0;
|
|
4018
|
+
let wedged = false;
|
|
4019
|
+
let retiring = null;
|
|
4020
|
+
let onIdle = null;
|
|
4021
|
+
const limitNotified = /* @__PURE__ */ new Map();
|
|
4022
|
+
function cancelledError() {
|
|
4023
|
+
return new Error("Agent turn cancelled");
|
|
4024
|
+
}
|
|
4025
|
+
function wedgedError() {
|
|
4026
|
+
return new Error("Agent runtime wedged: too many abandoned turns are still running");
|
|
4027
|
+
}
|
|
4028
|
+
function waitUnlessAborted(gate2, signal) {
|
|
4029
|
+
if (!signal) return gate2;
|
|
4030
|
+
if (signal.aborted) return Promise.reject(cancelledError());
|
|
4031
|
+
return new Promise((resolve3, reject) => {
|
|
4032
|
+
const onAbort = () => reject(cancelledError());
|
|
4033
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
4034
|
+
void gate2.then(() => {
|
|
4035
|
+
signal.removeEventListener("abort", onAbort);
|
|
4036
|
+
resolve3();
|
|
4037
|
+
});
|
|
4038
|
+
});
|
|
4039
|
+
}
|
|
4040
|
+
async function lockChannel(channelId, signal) {
|
|
4041
|
+
const previous = channelTails.get(channelId) ?? Promise.resolve();
|
|
4042
|
+
let release;
|
|
4043
|
+
const mine = new Promise((resolve3) => {
|
|
4044
|
+
release = resolve3;
|
|
4045
|
+
});
|
|
4046
|
+
const tail = previous.then(() => mine);
|
|
4047
|
+
channelTails.set(channelId, tail);
|
|
4048
|
+
let released = false;
|
|
4049
|
+
const done = () => {
|
|
4050
|
+
if (released) return;
|
|
4051
|
+
released = true;
|
|
4052
|
+
release();
|
|
4053
|
+
if (channelTails.get(channelId) === tail) channelTails.delete(channelId);
|
|
4054
|
+
};
|
|
4055
|
+
try {
|
|
4056
|
+
await waitUnlessAborted(previous, signal);
|
|
4057
|
+
} catch (error) {
|
|
4058
|
+
done();
|
|
4059
|
+
throw error;
|
|
4060
|
+
}
|
|
4061
|
+
return done;
|
|
4062
|
+
}
|
|
4063
|
+
function acquireSlot(signal) {
|
|
4064
|
+
let released = false;
|
|
4065
|
+
const release = () => {
|
|
4066
|
+
if (released) return;
|
|
4067
|
+
released = true;
|
|
4068
|
+
activeTurns--;
|
|
4069
|
+
slotWaiters.shift()?.();
|
|
4070
|
+
if (activeTurns === 0) onIdle?.();
|
|
4071
|
+
};
|
|
4072
|
+
if (activeTurns < maxConcurrentTurns) {
|
|
4073
|
+
activeTurns++;
|
|
4074
|
+
return Promise.resolve(release);
|
|
4075
|
+
}
|
|
4076
|
+
if (signal?.aborted) return Promise.reject(cancelledError());
|
|
4077
|
+
return new Promise((resolve3, reject) => {
|
|
4078
|
+
const grant = () => {
|
|
4079
|
+
signal?.removeEventListener("abort", onAbort);
|
|
4080
|
+
activeTurns++;
|
|
4081
|
+
resolve3(release);
|
|
4082
|
+
};
|
|
4083
|
+
const onAbort = () => {
|
|
4084
|
+
const i = slotWaiters.indexOf(grant);
|
|
4085
|
+
if (i >= 0) slotWaiters.splice(i, 1);
|
|
4086
|
+
reject(cancelledError());
|
|
4087
|
+
};
|
|
4088
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
4089
|
+
slotWaiters.push(grant);
|
|
4090
|
+
});
|
|
4091
|
+
}
|
|
4092
|
+
const unavailableNotified = /* @__PURE__ */ new Map();
|
|
4093
|
+
function budgetUnavailableReply(channelId, error) {
|
|
4094
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4095
|
+
log("warn", "turn.budget_unavailable", {
|
|
4096
|
+
config: error instanceof RequestExceedsDailyTokenCapError,
|
|
4097
|
+
lock: error instanceof BudgetLockHeldError,
|
|
4098
|
+
state_write: error instanceof BudgetStateWriteError,
|
|
4099
|
+
state_read: error instanceof BudgetStateReadError
|
|
4100
|
+
});
|
|
4101
|
+
if (unavailableNotified.get(channelId) === day) return null;
|
|
4102
|
+
for (const [ch, d] of unavailableNotified) if (d !== day) unavailableNotified.delete(ch);
|
|
4103
|
+
unavailableNotified.set(channelId, day);
|
|
4104
|
+
return BUDGET_UNAVAILABLE_RESPONSE;
|
|
4105
|
+
}
|
|
4106
|
+
function dailyLimitReply(channelId, day) {
|
|
4107
|
+
if (limitNotified.get(channelId) === day) return null;
|
|
4108
|
+
for (const [ch, d] of limitNotified) if (d !== day) limitNotified.delete(ch);
|
|
4109
|
+
limitNotified.set(channelId, day);
|
|
4110
|
+
log("warn", "turn.daily_limit_reply");
|
|
4111
|
+
return DAILY_LIMIT_RESPONSE;
|
|
4112
|
+
}
|
|
4113
|
+
function logCompleted(timing) {
|
|
4114
|
+
const end = clock();
|
|
4115
|
+
log("info", "turn.completed", {
|
|
4116
|
+
seq: timing.seq,
|
|
4117
|
+
total_ms: ms(timing.received, end),
|
|
4118
|
+
queue_ms: ms(timing.received, timing.started ?? end),
|
|
4119
|
+
run_ms: timing.started === void 0 ? 0 : ms(timing.started, end),
|
|
4120
|
+
context_ms: timing.contextMs,
|
|
4121
|
+
iterations: timing.iterations,
|
|
4122
|
+
model_ms: timing.modelMs,
|
|
4123
|
+
tool_ms: timing.toolMs,
|
|
4124
|
+
tool_calls: timing.toolCalls,
|
|
4125
|
+
tools_denied: timing.toolsDenied,
|
|
4126
|
+
outcome: logLabel(timing.outcome)
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
return async (m, upstreamSignal) => {
|
|
2075
4130
|
const text = m.text?.trim() ? m.text : "";
|
|
2076
4131
|
if (!text) return null;
|
|
2077
|
-
|
|
2078
|
-
"
|
|
2079
|
-
|
|
2080
|
-
|
|
4132
|
+
const timing = {
|
|
4133
|
+
seq: typeof m.seq === "number" && Number.isFinite(m.seq) ? m.seq : ++localSeq,
|
|
4134
|
+
received: clock(),
|
|
4135
|
+
contextMs: 0,
|
|
4136
|
+
iterations: 0,
|
|
4137
|
+
modelMs: 0,
|
|
4138
|
+
toolMs: 0,
|
|
4139
|
+
toolCalls: 0,
|
|
4140
|
+
toolsDenied: 0,
|
|
4141
|
+
// Until the turn holds both locks, the only way out is cancellation.
|
|
4142
|
+
outcome: "cancelled"
|
|
4143
|
+
};
|
|
4144
|
+
try {
|
|
4145
|
+
return await queuedTurn(m, upstreamSignal, timing);
|
|
4146
|
+
} finally {
|
|
4147
|
+
logCompleted(timing);
|
|
4148
|
+
}
|
|
4149
|
+
};
|
|
4150
|
+
async function queuedTurn(m, upstreamSignal, timing) {
|
|
4151
|
+
if (retiring) {
|
|
4152
|
+
await retiring;
|
|
4153
|
+
timing.outcome = "error";
|
|
4154
|
+
throw wedgedError();
|
|
4155
|
+
}
|
|
4156
|
+
if (wedged) {
|
|
4157
|
+
timing.outcome = "error";
|
|
4158
|
+
throw wedgedError();
|
|
4159
|
+
}
|
|
4160
|
+
const sameChannel = channelTails.has(m.channelId);
|
|
4161
|
+
const mustWait = sameChannel || activeTurns >= maxConcurrentTurns;
|
|
4162
|
+
if (mustWait) {
|
|
4163
|
+
log("info", "turn.queued", {
|
|
4164
|
+
same_channel: channelTails.has(m.channelId),
|
|
4165
|
+
active_turns: activeTurns
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
const releaseChannel = await lockChannel(m.channelId, upstreamSignal);
|
|
4169
|
+
const channelReady = clock();
|
|
4170
|
+
let releaseSlot;
|
|
4171
|
+
try {
|
|
4172
|
+
releaseSlot = await acquireSlot(upstreamSignal);
|
|
4173
|
+
} catch (error) {
|
|
4174
|
+
releaseChannel();
|
|
4175
|
+
throw error;
|
|
4176
|
+
}
|
|
4177
|
+
if (retiring) {
|
|
4178
|
+
releaseSlot();
|
|
4179
|
+
releaseChannel();
|
|
4180
|
+
await retiring;
|
|
4181
|
+
timing.outcome = "error";
|
|
4182
|
+
throw wedgedError();
|
|
4183
|
+
}
|
|
4184
|
+
timing.started = clock();
|
|
4185
|
+
timing.outcome = "error";
|
|
4186
|
+
log("info", "turn.started", {
|
|
4187
|
+
seq: timing.seq,
|
|
4188
|
+
queue_ms: ms(timing.received, timing.started),
|
|
4189
|
+
channel_wait_ms: ms(timing.received, channelReady),
|
|
4190
|
+
slot_wait_ms: ms(channelReady, timing.started),
|
|
4191
|
+
queued: mustWait,
|
|
4192
|
+
same_channel: sameChannel
|
|
4193
|
+
});
|
|
4194
|
+
const controller = new AbortController();
|
|
4195
|
+
let timedOut = false;
|
|
4196
|
+
const cancel = () => controller.abort(upstreamSignal?.reason);
|
|
4197
|
+
if (upstreamSignal?.aborted) cancel();
|
|
4198
|
+
else upstreamSignal?.addEventListener("abort", cancel, { once: true });
|
|
4199
|
+
const timer = setTimeout(() => {
|
|
4200
|
+
timedOut = true;
|
|
4201
|
+
controller.abort(new Error("turn deadline exceeded"));
|
|
4202
|
+
}, turnTimeoutMs);
|
|
4203
|
+
const token = { abandoned: false };
|
|
4204
|
+
let abandonTimer;
|
|
4205
|
+
let turn;
|
|
4206
|
+
const abandonment = new Promise((_resolve, reject) => {
|
|
4207
|
+
const armGrace = () => {
|
|
4208
|
+
abandonTimer = setTimeout(() => {
|
|
4209
|
+
token.abandoned = true;
|
|
4210
|
+
abandonedUnsettled++;
|
|
4211
|
+
const abandonedAt = clock();
|
|
4212
|
+
log("warn", "turn.abandoned", {
|
|
4213
|
+
seq: timing.seq,
|
|
4214
|
+
deadline_ms: turnTimeoutMs,
|
|
4215
|
+
grace_ms: abandonGraceMs,
|
|
4216
|
+
timed_out: timedOut,
|
|
4217
|
+
abandoned_unsettled: abandonedUnsettled
|
|
4218
|
+
});
|
|
4219
|
+
if (abandonedUnsettled > maxConcurrentTurns && !wedged) {
|
|
4220
|
+
wedged = true;
|
|
4221
|
+
log("error", "turn.runtime_wedged", { abandoned_unsettled: abandonedUnsettled });
|
|
4222
|
+
if (deps.onWedged && !retiring) {
|
|
4223
|
+
retiring = new Promise((resolve3) => {
|
|
4224
|
+
onIdle = resolve3;
|
|
4225
|
+
});
|
|
4226
|
+
deps.onWedged(retiring);
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
const settled = () => {
|
|
4230
|
+
abandonedUnsettled--;
|
|
4231
|
+
if (wedged && abandonedUnsettled <= maxConcurrentTurns) wedged = false;
|
|
4232
|
+
log("info", "turn.abandoned_settled", {
|
|
4233
|
+
seq: timing.seq,
|
|
4234
|
+
settled_after_ms: ms(abandonedAt, clock()),
|
|
4235
|
+
abandoned_unsettled: abandonedUnsettled
|
|
4236
|
+
});
|
|
4237
|
+
};
|
|
4238
|
+
void turn?.then(settled, settled);
|
|
4239
|
+
reject(new TurnAbandonedError());
|
|
4240
|
+
}, abandonGraceMs);
|
|
4241
|
+
};
|
|
4242
|
+
if (controller.signal.aborted) armGrace();
|
|
4243
|
+
else controller.signal.addEventListener("abort", armGrace, { once: true });
|
|
4244
|
+
});
|
|
4245
|
+
abandonment.catch(() => {
|
|
4246
|
+
});
|
|
4247
|
+
try {
|
|
4248
|
+
turn = runTurn(m, controller.signal, timing, token);
|
|
4249
|
+
turn.catch(() => {
|
|
4250
|
+
});
|
|
4251
|
+
const reply = await Promise.race([turn, abandonment]);
|
|
4252
|
+
if (token.abandoned) throw new TurnAbandonedError();
|
|
4253
|
+
timing.outcome = reply === null ? "no_final_answer" : "answered";
|
|
4254
|
+
unavailableNotified.delete(m.channelId);
|
|
4255
|
+
return reply;
|
|
4256
|
+
} catch (error) {
|
|
4257
|
+
if (error instanceof DailyLimitReachedError) {
|
|
4258
|
+
timing.outcome = "daily_limit";
|
|
4259
|
+
return dailyLimitReply(m.channelId, error.day);
|
|
4260
|
+
}
|
|
4261
|
+
if (isBudgetUnavailable(error)) {
|
|
4262
|
+
timing.outcome = "budget_unavailable";
|
|
4263
|
+
return budgetUnavailableReply(m.channelId, error);
|
|
4264
|
+
}
|
|
4265
|
+
if (error instanceof PromptTooLargeError) {
|
|
4266
|
+
timing.outcome = "too_large";
|
|
4267
|
+
if (error.initial) {
|
|
4268
|
+
log("warn", "turn.message_too_large", { seq: timing.seq, ...error.sizes });
|
|
4269
|
+
return MESSAGE_TOO_LARGE_RESPONSE;
|
|
4270
|
+
}
|
|
4271
|
+
log("warn", "turn.turn_too_large", { seq: timing.seq });
|
|
4272
|
+
return TURN_TOO_LARGE_RESPONSE;
|
|
4273
|
+
}
|
|
4274
|
+
if (timedOut) {
|
|
4275
|
+
timing.outcome = "timeout";
|
|
4276
|
+
throw new Error(`Agent turn timed out after ${turnTimeoutMs}ms`);
|
|
4277
|
+
}
|
|
4278
|
+
if (upstreamSignal?.aborted) {
|
|
4279
|
+
timing.outcome = "cancelled";
|
|
4280
|
+
throw cancelledError();
|
|
4281
|
+
}
|
|
4282
|
+
throw error;
|
|
4283
|
+
} finally {
|
|
4284
|
+
clearTimeout(timer);
|
|
4285
|
+
clearTimeout(abandonTimer);
|
|
4286
|
+
upstreamSignal?.removeEventListener("abort", cancel);
|
|
4287
|
+
releaseSlot();
|
|
4288
|
+
releaseChannel();
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
function logModelCall(timing, iteration, elapsed, result) {
|
|
4292
|
+
const usage = result?.usage;
|
|
4293
|
+
log("info", "turn.model_call", {
|
|
4294
|
+
seq: timing.seq,
|
|
4295
|
+
iteration,
|
|
4296
|
+
ms: elapsed,
|
|
4297
|
+
ok: result !== void 0,
|
|
4298
|
+
tool_calls: result?.toolCalls.length ?? 0,
|
|
4299
|
+
...result?.source ? { provider: logLabel(result.source.provider), model: logLabel(result.source.model) } : {},
|
|
4300
|
+
...result ? { finish_reason: finishReasonLabel(result.finishReason) } : {},
|
|
4301
|
+
...usage ? { total_tokens: usage.totalTokens } : {},
|
|
4302
|
+
...usage?.promptTokens !== void 0 ? { prompt_tokens: usage.promptTokens } : {},
|
|
4303
|
+
...usage?.completionTokens !== void 0 ? { completion_tokens: usage.completionTokens } : {}
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
4306
|
+
async function runTurn(m, signal, timing, token) {
|
|
4307
|
+
const text = m.text;
|
|
4308
|
+
const live = () => {
|
|
4309
|
+
if (token.abandoned) throw new TurnAbandonedError();
|
|
4310
|
+
};
|
|
4311
|
+
log("info", "turn.message_received", { chars: text.length });
|
|
4312
|
+
const contextStart = clock();
|
|
2081
4313
|
const memory = memoryFor(m.channelId);
|
|
2082
4314
|
memory.replace(m.memory);
|
|
2083
|
-
const
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
4315
|
+
const promptStart = clock();
|
|
4316
|
+
const system = { role: "system", content: deps.systemPromptFor() };
|
|
4317
|
+
const recallStart = clock();
|
|
4318
|
+
const recalledText = await memory.recall(text);
|
|
4319
|
+
live();
|
|
4320
|
+
const recalled = recalledText ? { role: "system", content: recalledText } : null;
|
|
4321
|
+
const history = memory.getHistory();
|
|
4322
|
+
const user = { role: "user", content: text };
|
|
4323
|
+
const toolsStart = clock();
|
|
2089
4324
|
const tools = deps.tools().filter((tool) => toolIsOffered(m.authorization, tool.function.name));
|
|
2090
4325
|
const offeredNames = new Set(tools.map((tool) => tool.function.name));
|
|
4326
|
+
const toolsBytes = tools.length > 0 ? jsonBytes(tools) + 1 : 0;
|
|
4327
|
+
const budgetBytes = requestMaxBytes - REQUEST_ENVELOPE_RESERVE_BYTES - toolsBytes;
|
|
4328
|
+
const historyBytes = history.map(messageBytes);
|
|
4329
|
+
const recalledBytes = recalled ? messageBytes(recalled) : 0;
|
|
4330
|
+
const requiredBytes = messageBytes(system) + messageBytes(user);
|
|
4331
|
+
const headroomBytes = tools.length > 0 ? TOOL_RESULT_HEADROOM_BYTES : 0;
|
|
4332
|
+
if (requiredBytes + headroomBytes > budgetBytes) {
|
|
4333
|
+
throw new PromptTooLargeError(true, {
|
|
4334
|
+
system_bytes: messageBytes(system),
|
|
4335
|
+
tools_bytes: toolsBytes,
|
|
4336
|
+
message_bytes: messageBytes(user),
|
|
4337
|
+
budget_bytes: budgetBytes
|
|
4338
|
+
});
|
|
4339
|
+
}
|
|
4340
|
+
let room = budgetBytes - requiredBytes - headroomBytes;
|
|
4341
|
+
let keepRecalled = recalled !== null && recalledBytes <= room;
|
|
4342
|
+
if (keepRecalled) room -= recalledBytes;
|
|
4343
|
+
let historyStart = history.length;
|
|
4344
|
+
let keptHistoryBytes = 0;
|
|
4345
|
+
while (historyStart > 0 && keptHistoryBytes + historyBytes[historyStart - 1] <= room) {
|
|
4346
|
+
historyStart--;
|
|
4347
|
+
keptHistoryBytes += historyBytes[historyStart];
|
|
4348
|
+
}
|
|
4349
|
+
const dropLeadingAssistant = () => {
|
|
4350
|
+
while (historyStart > 0 && historyStart < history.length && history[historyStart].role === "assistant") {
|
|
4351
|
+
keptHistoryBytes -= historyBytes[historyStart];
|
|
4352
|
+
historyStart++;
|
|
4353
|
+
}
|
|
4354
|
+
};
|
|
4355
|
+
dropLeadingAssistant();
|
|
4356
|
+
const turnMessages = [];
|
|
4357
|
+
let turnMessageBytes = 0;
|
|
4358
|
+
const promptBytes = () => requiredBytes + (keepRecalled ? recalledBytes : 0) + keptHistoryBytes + turnMessageBytes;
|
|
4359
|
+
const logTrim = (iteration) => {
|
|
4360
|
+
log("info", "turn.context_trimmed", {
|
|
4361
|
+
seq: timing.seq,
|
|
4362
|
+
iteration,
|
|
4363
|
+
history_kept: history.length - historyStart,
|
|
4364
|
+
history_dropped: historyStart,
|
|
4365
|
+
recalled_dropped: recalled !== null && !keepRecalled,
|
|
4366
|
+
prompt_bytes: promptBytes(),
|
|
4367
|
+
budget_bytes: budgetBytes
|
|
4368
|
+
});
|
|
4369
|
+
};
|
|
4370
|
+
if (historyStart > 0 || recalled !== null && !keepRecalled) logTrim(0);
|
|
4371
|
+
const refit = (iteration) => {
|
|
4372
|
+
let trimmed = false;
|
|
4373
|
+
while (promptBytes() > budgetBytes) {
|
|
4374
|
+
if (historyStart < history.length) {
|
|
4375
|
+
keptHistoryBytes -= historyBytes[historyStart];
|
|
4376
|
+
historyStart++;
|
|
4377
|
+
} else if (keepRecalled) {
|
|
4378
|
+
keepRecalled = false;
|
|
4379
|
+
} else {
|
|
4380
|
+
throw new PromptTooLargeError(false);
|
|
4381
|
+
}
|
|
4382
|
+
trimmed = true;
|
|
4383
|
+
}
|
|
4384
|
+
if (trimmed) {
|
|
4385
|
+
dropLeadingAssistant();
|
|
4386
|
+
logTrim(iteration);
|
|
4387
|
+
}
|
|
4388
|
+
};
|
|
4389
|
+
const buildMessages = () => [
|
|
4390
|
+
system,
|
|
4391
|
+
...keepRecalled && recalled ? [recalled] : [],
|
|
4392
|
+
...history.slice(historyStart),
|
|
4393
|
+
user,
|
|
4394
|
+
...turnMessages
|
|
4395
|
+
];
|
|
4396
|
+
const pushTurnMessage = (message) => {
|
|
4397
|
+
turnMessages.push(message);
|
|
4398
|
+
turnMessageBytes += messageBytes(message);
|
|
4399
|
+
};
|
|
4400
|
+
memory.addMessage({ role: "user", content: text });
|
|
4401
|
+
const contextEnd = clock();
|
|
4402
|
+
timing.contextMs = ms(contextStart, contextEnd);
|
|
4403
|
+
log("info", "turn.context_built", {
|
|
4404
|
+
seq: timing.seq,
|
|
4405
|
+
ms: timing.contextMs,
|
|
4406
|
+
memory_ms: ms(contextStart, promptStart),
|
|
4407
|
+
prompt_ms: ms(promptStart, recallStart),
|
|
4408
|
+
recall_ms: ms(recallStart, toolsStart),
|
|
4409
|
+
tools_ms: ms(toolsStart, contextEnd),
|
|
4410
|
+
history_messages: history.length - historyStart,
|
|
4411
|
+
history_dropped: historyStart,
|
|
4412
|
+
recalled: keepRecalled,
|
|
4413
|
+
tools_offered: tools.length,
|
|
4414
|
+
prompt_bytes: promptBytes()
|
|
4415
|
+
});
|
|
4416
|
+
let toolResultBytes = 0;
|
|
4417
|
+
let turnToolCalls = 0;
|
|
4418
|
+
const callModel = async (n, offer) => {
|
|
4419
|
+
const started = clock();
|
|
4420
|
+
let result;
|
|
4421
|
+
try {
|
|
4422
|
+
result = await llm.complete(buildMessages(), offer, { signal });
|
|
4423
|
+
} finally {
|
|
4424
|
+
const modelMs = ms(started, clock());
|
|
4425
|
+
timing.iterations = n;
|
|
4426
|
+
timing.modelMs += modelMs;
|
|
4427
|
+
logModelCall(timing, n, modelMs, result);
|
|
4428
|
+
}
|
|
4429
|
+
live();
|
|
4430
|
+
return result;
|
|
4431
|
+
};
|
|
2091
4432
|
let response = null;
|
|
2092
4433
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
2093
|
-
|
|
4434
|
+
if (iteration > 0) refit(iteration + 1);
|
|
4435
|
+
const iterationStart = clock();
|
|
4436
|
+
const result = await callModel(iteration + 1, tools.length > 0 ? tools : void 0);
|
|
2094
4437
|
if (result.toolCalls.length === 0) {
|
|
2095
4438
|
response = result.content;
|
|
4439
|
+
log("info", "turn.iteration", {
|
|
4440
|
+
seq: timing.seq,
|
|
4441
|
+
iteration: iteration + 1,
|
|
4442
|
+
ms: ms(iterationStart, clock()),
|
|
4443
|
+
tool_calls: 0,
|
|
4444
|
+
tools_denied: 0,
|
|
4445
|
+
tool_ms: 0,
|
|
4446
|
+
final: true
|
|
4447
|
+
});
|
|
2096
4448
|
break;
|
|
2097
4449
|
}
|
|
2098
|
-
|
|
4450
|
+
pushTurnMessage({
|
|
2099
4451
|
role: "assistant",
|
|
2100
4452
|
content: result.content ?? "",
|
|
2101
4453
|
tool_calls: result.toolCalls
|
|
2102
4454
|
});
|
|
2103
|
-
|
|
4455
|
+
if (result.toolCalls.length > MAX_TOOL_CALLS_PER_RESPONSE) {
|
|
4456
|
+
log("warn", "turn.tool_calls_capped", {
|
|
4457
|
+
seq: timing.seq,
|
|
4458
|
+
iteration: iteration + 1,
|
|
4459
|
+
requested: result.toolCalls.length,
|
|
4460
|
+
over_cap: result.toolCalls.length - MAX_TOOL_CALLS_PER_RESPONSE
|
|
4461
|
+
});
|
|
4462
|
+
}
|
|
4463
|
+
const resultBudgetSpent = toolResultBytes >= MAX_TOOL_RESULT_BYTES_PER_TURN;
|
|
4464
|
+
let turnCapDenied = 0;
|
|
4465
|
+
const verdicts = result.toolCalls.map((call, index) => {
|
|
4466
|
+
if (index >= MAX_TOOL_CALLS_PER_RESPONSE) {
|
|
4467
|
+
return { call, verdict: { allowed: false, reason: "too_many_calls: this response asked for more tool calls than one response may run" } };
|
|
4468
|
+
}
|
|
4469
|
+
if (turnToolCalls >= MAX_TOOL_CALLS_PER_TURN) {
|
|
4470
|
+
turnCapDenied++;
|
|
4471
|
+
return { call, verdict: { allowed: false, reason: "too_many_calls: this turn has run as many tool calls as one turn may" } };
|
|
4472
|
+
}
|
|
4473
|
+
if (resultBudgetSpent) {
|
|
4474
|
+
return { call, verdict: { allowed: false, reason: "tool_result_budget_exhausted: this turn's tool-result budget is spent" } };
|
|
4475
|
+
}
|
|
2104
4476
|
const channelVerdict = authorizeToolExecution(
|
|
2105
4477
|
m.authorization,
|
|
2106
4478
|
call.function.name,
|
|
2107
4479
|
call.function.arguments,
|
|
2108
|
-
offeredNames
|
|
4480
|
+
offeredNames,
|
|
4481
|
+
now()
|
|
2109
4482
|
);
|
|
4483
|
+
const localVerdict = channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict;
|
|
4484
|
+
if (localVerdict.allowed) turnToolCalls++;
|
|
2110
4485
|
return {
|
|
2111
4486
|
call,
|
|
2112
|
-
verdict:
|
|
4487
|
+
verdict: {
|
|
4488
|
+
...localVerdict,
|
|
4489
|
+
...localVerdict.allowed && channelVerdict.sideEffect ? { sideEffect: channelVerdict.sideEffect } : {}
|
|
4490
|
+
}
|
|
2113
4491
|
};
|
|
2114
4492
|
});
|
|
2115
4493
|
const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
|
|
4494
|
+
const sideEffects = new Map(
|
|
4495
|
+
verdicts.flatMap(({ call, verdict }) => verdict.allowed && verdict.sideEffect ? [[call.id, verdict.sideEffect]] : [])
|
|
4496
|
+
);
|
|
4497
|
+
let denied = 0;
|
|
2116
4498
|
for (const v of verdicts) {
|
|
2117
4499
|
if (!v.verdict.allowed) {
|
|
2118
|
-
|
|
4500
|
+
denied++;
|
|
4501
|
+
log("warn", "turn.tool_call_denied");
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
if (turnCapDenied > 0) {
|
|
4505
|
+
log("warn", "turn.turn_tool_calls_capped", {
|
|
4506
|
+
seq: timing.seq,
|
|
4507
|
+
iteration: iteration + 1,
|
|
4508
|
+
denied: turnCapDenied
|
|
4509
|
+
});
|
|
4510
|
+
}
|
|
4511
|
+
const deniedContent = (reason) => JSON.stringify({ error: "tool_call_denied", reason });
|
|
4512
|
+
const minimalResultBytes = verdicts.map(({ call, verdict }) => messageBytes({
|
|
4513
|
+
role: "tool",
|
|
4514
|
+
content: verdict.allowed ? RESULT_BUDGET_EXCEEDED : deniedContent(verdict.reason),
|
|
4515
|
+
tool_call_id: call.id
|
|
4516
|
+
}));
|
|
4517
|
+
const minimalTotal = minimalResultBytes.reduce((a, b) => a + b, 0);
|
|
4518
|
+
if (requiredBytes + turnMessageBytes + minimalTotal > budgetBytes) {
|
|
4519
|
+
if (iteration === 0) throw new PromptTooLargeError(false);
|
|
4520
|
+
const unanswerable = turnMessages.pop();
|
|
4521
|
+
turnMessageBytes -= messageBytes(unanswerable);
|
|
4522
|
+
log("warn", "turn.final_without_tools", { seq: timing.seq, iteration: iteration + 1 });
|
|
4523
|
+
let final;
|
|
4524
|
+
try {
|
|
4525
|
+
final = await callModel(iteration + 2, void 0);
|
|
4526
|
+
} catch (error) {
|
|
4527
|
+
if (error instanceof DailyLimitReachedError || isBudgetUnavailable(error) || error instanceof TurnAbandonedError || signal.aborted) throw error;
|
|
4528
|
+
throw new PromptTooLargeError(false);
|
|
2119
4529
|
}
|
|
4530
|
+
if (!final.content) throw new PromptTooLargeError(false);
|
|
4531
|
+
response = final.content;
|
|
4532
|
+
break;
|
|
4533
|
+
}
|
|
4534
|
+
const toolTimings = [];
|
|
4535
|
+
const toolsStartedAt = clock();
|
|
4536
|
+
const executed = await runToolCalls(allowed, skills, {
|
|
4537
|
+
roomId: m.channelId,
|
|
4538
|
+
signal,
|
|
4539
|
+
sideEffects,
|
|
4540
|
+
clock,
|
|
4541
|
+
onTiming: (t) => toolTimings.push(t)
|
|
4542
|
+
});
|
|
4543
|
+
live();
|
|
4544
|
+
const toolsMs = ms(toolsStartedAt, clock());
|
|
4545
|
+
const nameById = new Map(allowed.map((call) => [call.id, call.function.name]));
|
|
4546
|
+
const indexById = new Map(result.toolCalls.map((call, index) => [call.id, index + 1]));
|
|
4547
|
+
for (const t of toolTimings) {
|
|
4548
|
+
const name = nameById.get(t.tool_call_id);
|
|
4549
|
+
log("info", "turn.tool_call", {
|
|
4550
|
+
seq: timing.seq,
|
|
4551
|
+
iteration: iteration + 1,
|
|
4552
|
+
index: indexById.get(t.tool_call_id) ?? 0,
|
|
4553
|
+
tool: logLabel(name !== void 0 && offeredNames.has(name) ? name : "unoffered"),
|
|
4554
|
+
ms: Math.max(0, Math.round(t.ms)),
|
|
4555
|
+
ok: t.ok,
|
|
4556
|
+
cancelled: t.cancelled
|
|
4557
|
+
});
|
|
2120
4558
|
}
|
|
2121
|
-
|
|
4559
|
+
timing.toolMs += toolsMs;
|
|
4560
|
+
timing.toolCalls += allowed.length;
|
|
4561
|
+
timing.toolsDenied += denied;
|
|
2122
4562
|
const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
|
|
2123
|
-
|
|
4563
|
+
let resultsCut = 0;
|
|
4564
|
+
let reserveAfter = minimalTotal;
|
|
4565
|
+
const toolMessage = (id, content) => ({ role: "tool", content, tool_call_id: id });
|
|
4566
|
+
const charged = (id, content) => {
|
|
4567
|
+
const available = Math.min(
|
|
4568
|
+
MAX_TOOL_RESULT_BYTES_PER_TURN - toolResultBytes,
|
|
4569
|
+
budgetBytes - (requiredBytes + turnMessageBytes) - reserveAfter
|
|
4570
|
+
);
|
|
4571
|
+
const whole = toolMessage(id, content);
|
|
4572
|
+
if (messageBytes(whole) <= available) {
|
|
4573
|
+
toolResultBytes += messageBytes(whole);
|
|
4574
|
+
return whole;
|
|
4575
|
+
}
|
|
4576
|
+
resultsCut++;
|
|
4577
|
+
if (available >= MIN_TRUNCATED_TOOL_RESULT_BYTES) {
|
|
4578
|
+
const cut = toolMessage(id, fitJsonString(
|
|
4579
|
+
content,
|
|
4580
|
+
(prefix) => JSON.stringify({ result: prefix, truncated: true }),
|
|
4581
|
+
(candidate) => messageBytes(toolMessage(id, candidate)) <= available
|
|
4582
|
+
));
|
|
4583
|
+
if (messageBytes(cut) <= available) {
|
|
4584
|
+
toolResultBytes += messageBytes(cut);
|
|
4585
|
+
return cut;
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
toolResultBytes = MAX_TOOL_RESULT_BYTES_PER_TURN;
|
|
4589
|
+
return toolMessage(id, RESULT_BUDGET_EXCEEDED);
|
|
4590
|
+
};
|
|
4591
|
+
for (const [index, { call, verdict }] of verdicts.entries()) {
|
|
4592
|
+
reserveAfter -= minimalResultBytes[index];
|
|
2124
4593
|
if (verdict.allowed) {
|
|
2125
4594
|
const done = byId.get(call.id);
|
|
2126
4595
|
if (done) {
|
|
2127
|
-
|
|
4596
|
+
pushTurnMessage(charged(done.tool_call_id, done.content));
|
|
2128
4597
|
continue;
|
|
2129
4598
|
}
|
|
2130
|
-
|
|
4599
|
+
pushTurnMessage({
|
|
2131
4600
|
role: "tool",
|
|
2132
4601
|
content: JSON.stringify({ error: "tool_call_no_result" }),
|
|
2133
4602
|
tool_call_id: call.id
|
|
2134
4603
|
});
|
|
2135
4604
|
continue;
|
|
2136
4605
|
}
|
|
2137
|
-
|
|
4606
|
+
pushTurnMessage({
|
|
2138
4607
|
role: "tool",
|
|
2139
|
-
content:
|
|
4608
|
+
content: deniedContent(verdict.reason),
|
|
2140
4609
|
tool_call_id: call.id
|
|
2141
4610
|
});
|
|
2142
4611
|
}
|
|
4612
|
+
if (resultsCut > 0) {
|
|
4613
|
+
log("warn", "turn.tool_results_capped", {
|
|
4614
|
+
seq: timing.seq,
|
|
4615
|
+
iteration: iteration + 1,
|
|
4616
|
+
results_cut: resultsCut,
|
|
4617
|
+
result_bytes: toolResultBytes,
|
|
4618
|
+
budget_bytes: MAX_TOOL_RESULT_BYTES_PER_TURN
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4621
|
+
log("info", "turn.iteration", {
|
|
4622
|
+
seq: timing.seq,
|
|
4623
|
+
iteration: iteration + 1,
|
|
4624
|
+
ms: ms(iterationStart, clock()),
|
|
4625
|
+
tool_calls: allowed.length,
|
|
4626
|
+
tools_denied: denied,
|
|
4627
|
+
tool_ms: toolsMs,
|
|
4628
|
+
final: false
|
|
4629
|
+
});
|
|
2143
4630
|
}
|
|
4631
|
+
live();
|
|
2144
4632
|
if (!response) {
|
|
2145
|
-
log("warn",
|
|
4633
|
+
log("warn", "turn.no_final_answer", { iterations: maxIterations });
|
|
2146
4634
|
return null;
|
|
2147
4635
|
}
|
|
2148
4636
|
memory.addMessage({ role: "assistant", content: response });
|
|
2149
|
-
log("info",
|
|
4637
|
+
log("info", "turn.response_ready", { chars: response.length });
|
|
2150
4638
|
return response;
|
|
2151
|
-
}
|
|
4639
|
+
}
|
|
2152
4640
|
}
|
|
2153
4641
|
|
|
2154
4642
|
// src/persona.ts
|
|
2155
|
-
import { existsSync as existsSync5, readFileSync as
|
|
2156
|
-
import { dirname as
|
|
4643
|
+
import { existsSync as existsSync5, readFileSync as readFileSync9, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync } from "node:fs";
|
|
4644
|
+
import { dirname as dirname6, join as join7 } from "node:path";
|
|
2157
4645
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2158
4646
|
|
|
2159
4647
|
// src/long-term-memory.ts
|
|
2160
|
-
import { createHash } from "node:crypto";
|
|
2161
|
-
import { chmodSync, existsSync as existsSync4, lstatSync, readFileSync as
|
|
4648
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
4649
|
+
import { chmodSync as chmodSync2, existsSync as existsSync4, lstatSync, readFileSync as readFileSync8, readdirSync as readdirSync2, renameSync as renameSync2 } from "node:fs";
|
|
2162
4650
|
import { join as join6 } from "node:path";
|
|
2163
4651
|
var MEMORY_FILE = "MEMORY.md";
|
|
2164
4652
|
var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
|
|
@@ -2173,14 +4661,14 @@ function quarantineOrder(a, b) {
|
|
|
2173
4661
|
return index(a) - index(b) || a.localeCompare(b);
|
|
2174
4662
|
}
|
|
2175
4663
|
function sourceId(kind, channelId, path, bytes) {
|
|
2176
|
-
return
|
|
4664
|
+
return createHash2("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
|
|
2177
4665
|
}
|
|
2178
4666
|
function readLegacySource(path, kind, contentBudget, channelId) {
|
|
2179
4667
|
try {
|
|
2180
4668
|
let st = lstatSync(path);
|
|
2181
4669
|
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
2182
4670
|
if ((st.mode & 511) !== 384) {
|
|
2183
|
-
|
|
4671
|
+
chmodSync2(path, 384);
|
|
2184
4672
|
st = lstatSync(path);
|
|
2185
4673
|
}
|
|
2186
4674
|
if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
|
|
@@ -2196,7 +4684,7 @@ function readLegacySource(path, kind, contentBudget, channelId) {
|
|
|
2196
4684
|
};
|
|
2197
4685
|
}
|
|
2198
4686
|
if (st.size > contentBudget) return null;
|
|
2199
|
-
const bytes =
|
|
4687
|
+
const bytes = readFileSync8(path);
|
|
2200
4688
|
if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
|
|
2201
4689
|
return {
|
|
2202
4690
|
id: sourceId(kind, channelId, path, bytes),
|
|
@@ -2281,7 +4769,7 @@ function legacyMemoryFingerprint(managedDir, dataDir) {
|
|
|
2281
4769
|
}
|
|
2282
4770
|
} catch {
|
|
2283
4771
|
}
|
|
2284
|
-
return
|
|
4772
|
+
return createHash2("sha256").update(metadata.join("\n")).digest("hex");
|
|
2285
4773
|
}
|
|
2286
4774
|
function nextQuarantinePath(dir) {
|
|
2287
4775
|
const first = join6(dir, QUARANTINE_MEMORY_FILE);
|
|
@@ -2300,8 +4788,8 @@ function quarantineLegacyMemory(managedDir, dataDir) {
|
|
|
2300
4788
|
throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
|
|
2301
4789
|
}
|
|
2302
4790
|
const target = nextQuarantinePath(dir);
|
|
2303
|
-
|
|
2304
|
-
|
|
4791
|
+
renameSync2(source, target);
|
|
4792
|
+
chmodSync2(target, 384);
|
|
2305
4793
|
moved.push(target);
|
|
2306
4794
|
}
|
|
2307
4795
|
return moved;
|
|
@@ -2322,7 +4810,7 @@ var SAFETY_PREAMBLE = [
|
|
|
2322
4810
|
"- Only use the tools you were given; do not invent capabilities."
|
|
2323
4811
|
].join("\n");
|
|
2324
4812
|
function defaultPersonaDir() {
|
|
2325
|
-
return join7(
|
|
4813
|
+
return join7(dirname6(fileURLToPath2(import.meta.url)), "..", "persona");
|
|
2326
4814
|
}
|
|
2327
4815
|
function assemblePrompt(parts) {
|
|
2328
4816
|
return parts.map((p) => `${p.heading}
|
|
@@ -2335,13 +4823,13 @@ function loadPersona(dirs) {
|
|
|
2335
4823
|
if (!path) continue;
|
|
2336
4824
|
let body;
|
|
2337
4825
|
try {
|
|
2338
|
-
body =
|
|
2339
|
-
} catch {
|
|
2340
|
-
|
|
4826
|
+
body = readFileSync9(path, "utf8");
|
|
4827
|
+
} catch (error) {
|
|
4828
|
+
writeServiceLog("warn", "persona.read_failed", {}, error);
|
|
2341
4829
|
continue;
|
|
2342
4830
|
}
|
|
2343
4831
|
if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) {
|
|
2344
|
-
|
|
4832
|
+
writeServiceLog("warn", "persona.truncated", { max_bytes: PERSONA_MAX_BYTES });
|
|
2345
4833
|
body = body.slice(0, PERSONA_MAX_BYTES);
|
|
2346
4834
|
}
|
|
2347
4835
|
const trimmed = body.trim();
|
|
@@ -2361,7 +4849,7 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
|
2361
4849
|
const path2 = join7(dataDir, file);
|
|
2362
4850
|
if (!existsSync5(path2)) continue;
|
|
2363
4851
|
try {
|
|
2364
|
-
persona[file] =
|
|
4852
|
+
persona[file] = readFileSync9(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
|
|
2365
4853
|
} catch {
|
|
2366
4854
|
}
|
|
2367
4855
|
}
|
|
@@ -2380,10 +4868,10 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
|
2380
4868
|
try {
|
|
2381
4869
|
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
|
|
2382
4870
|
`, { mode: 384 });
|
|
2383
|
-
|
|
4871
|
+
renameSync3(tmp, path);
|
|
2384
4872
|
} catch (e) {
|
|
2385
4873
|
try {
|
|
2386
|
-
|
|
4874
|
+
unlinkSync2(tmp);
|
|
2387
4875
|
} catch {
|
|
2388
4876
|
}
|
|
2389
4877
|
throw e;
|
|
@@ -2399,7 +4887,7 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2399
4887
|
skipped.push(target);
|
|
2400
4888
|
continue;
|
|
2401
4889
|
}
|
|
2402
|
-
writeFile(target,
|
|
4890
|
+
writeFile(target, readFileSync9(join7(defaultsDir, file), "utf8"));
|
|
2403
4891
|
written.push(target);
|
|
2404
4892
|
}
|
|
2405
4893
|
return { written, skipped };
|
|
@@ -2431,32 +4919,33 @@ function installWarningFilter(proc = process) {
|
|
|
2431
4919
|
proc.removeListener("warning", listener);
|
|
2432
4920
|
}
|
|
2433
4921
|
proc.on("warning", (w) => {
|
|
2434
|
-
|
|
2435
|
-
|
|
4922
|
+
try {
|
|
4923
|
+
if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
|
|
4924
|
+
} catch {
|
|
4925
|
+
}
|
|
4926
|
+
writeServiceLog("warn", "runtime.node_warning", {}, w);
|
|
2436
4927
|
});
|
|
2437
4928
|
}
|
|
2438
4929
|
|
|
2439
4930
|
// src/index.ts
|
|
2440
4931
|
if (process.argv.includes("--version")) {
|
|
2441
|
-
const pkg = JSON.parse(
|
|
4932
|
+
const pkg = JSON.parse(readFileSync10(join8(dirname7(dirname7(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
|
|
2442
4933
|
console.log(pkg.version);
|
|
2443
4934
|
process.exit(0);
|
|
2444
4935
|
}
|
|
2445
4936
|
var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2446
4937
|
function makeLogger(cfg) {
|
|
2447
4938
|
const floor = LEVEL_RANK[cfg.logLevel];
|
|
2448
|
-
return (level,
|
|
4939
|
+
return (level, event, fields, error) => {
|
|
2449
4940
|
if (LEVEL_RANK[level] < floor) return;
|
|
2450
|
-
|
|
2451
|
-
if (level === "warn" || level === "error") console.error(line);
|
|
2452
|
-
else console.log(line);
|
|
4941
|
+
writeServiceLog(level, event, fields, error);
|
|
2453
4942
|
};
|
|
2454
4943
|
}
|
|
2455
4944
|
function realLlmSourceDeps() {
|
|
2456
4945
|
return {
|
|
2457
4946
|
home: homedir4(),
|
|
2458
|
-
readFile: (p) =>
|
|
2459
|
-
statFile: (p) => ({ mode:
|
|
4947
|
+
readFile: (p) => readFileSync10(p, "utf8"),
|
|
4948
|
+
statFile: (p) => ({ mode: statSync3(p).mode }),
|
|
2460
4949
|
exists: (p) => existsSync6(p)
|
|
2461
4950
|
};
|
|
2462
4951
|
}
|
|
@@ -2469,7 +4958,7 @@ function realServiceDeps() {
|
|
|
2469
4958
|
},
|
|
2470
4959
|
writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
|
|
2471
4960
|
removeFile: (p) => rmSync(p, { force: true }),
|
|
2472
|
-
mkdirp: (p) =>
|
|
4961
|
+
mkdirp: (p) => mkdirSync4(p, { recursive: true }),
|
|
2473
4962
|
fileExists: (p) => existsSync6(p),
|
|
2474
4963
|
canRead: (p) => {
|
|
2475
4964
|
try {
|
|
@@ -2566,6 +5055,46 @@ async function main() {
|
|
|
2566
5055
|
installWarningFilter();
|
|
2567
5056
|
const verb = parseVerb(process.argv);
|
|
2568
5057
|
if (verb) runServiceVerb(verb);
|
|
5058
|
+
const sideEffectsVerb = parseSideEffectsVerb(process.argv);
|
|
5059
|
+
if (process.argv[2] === "side-effects" && !sideEffectsVerb) {
|
|
5060
|
+
console.error("securecoms-agent: side-effects requires list or resolve");
|
|
5061
|
+
process.exit(2);
|
|
5062
|
+
}
|
|
5063
|
+
if (sideEffectsVerb) {
|
|
5064
|
+
let cfgForLedger;
|
|
5065
|
+
try {
|
|
5066
|
+
cfgForLedger = loadConfig(process.argv.slice(2), process.env);
|
|
5067
|
+
} catch (e) {
|
|
5068
|
+
console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
5069
|
+
process.exit(2);
|
|
5070
|
+
return;
|
|
5071
|
+
}
|
|
5072
|
+
const ledger = new SideEffectLedger(join8(cfgForLedger.dataDir, "side-effect-ledger.sqlite"));
|
|
5073
|
+
try {
|
|
5074
|
+
if (sideEffectsVerb === "list") {
|
|
5075
|
+
console.log(JSON.stringify({ ambiguous: ledger.listAmbiguous() }, null, 2));
|
|
5076
|
+
} else {
|
|
5077
|
+
const flags = parseSideEffectResolveFlags(process.argv);
|
|
5078
|
+
if (!flags.idempotencyKey || !flags.operationSha256 || !/^[0-9a-f]{64}$/.test(flags.operationSha256) || !flags.confirmationId || !flags.outcome) {
|
|
5079
|
+
throw new Error(
|
|
5080
|
+
"resolve requires --idempotency-key, --operation-sha256, --confirmation-id, and --outcome completed|not_applied"
|
|
5081
|
+
);
|
|
5082
|
+
}
|
|
5083
|
+
ledger.reconcile({
|
|
5084
|
+
idempotency_key: flags.idempotencyKey,
|
|
5085
|
+
operation_sha256: flags.operationSha256,
|
|
5086
|
+
confirmation_id: flags.confirmationId
|
|
5087
|
+
}, flags.outcome, /* @__PURE__ */ new Date());
|
|
5088
|
+
console.log(JSON.stringify({ reconciled: true, outcome: flags.outcome }));
|
|
5089
|
+
}
|
|
5090
|
+
} catch (e) {
|
|
5091
|
+
console.error(`securecoms-agent: side-effects ${sideEffectsVerb} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
5092
|
+
process.exitCode = 1;
|
|
5093
|
+
} finally {
|
|
5094
|
+
ledger.close();
|
|
5095
|
+
}
|
|
5096
|
+
process.exit(process.exitCode ?? 0);
|
|
5097
|
+
}
|
|
2569
5098
|
const personaVerb = parsePersonaVerb(process.argv);
|
|
2570
5099
|
if (personaVerb) {
|
|
2571
5100
|
let cfgForPersona;
|
|
@@ -2577,7 +5106,7 @@ async function main() {
|
|
|
2577
5106
|
return;
|
|
2578
5107
|
}
|
|
2579
5108
|
if (personaVerb === "init") {
|
|
2580
|
-
|
|
5109
|
+
mkdirSync4(cfgForPersona.dataDir, { recursive: true });
|
|
2581
5110
|
const r = personaInit(
|
|
2582
5111
|
cfgForPersona.dataDir,
|
|
2583
5112
|
defaultPersonaDir(),
|
|
@@ -2600,12 +5129,36 @@ async function main() {
|
|
|
2600
5129
|
try {
|
|
2601
5130
|
cfg = loadConfig(process.argv.slice(2), process.env);
|
|
2602
5131
|
} catch (e) {
|
|
2603
|
-
|
|
5132
|
+
const argv = process.argv.slice(2);
|
|
5133
|
+
const hasIdentityArg = argv.some(
|
|
5134
|
+
(arg, index) => arg === "--identity" && !!argv[index + 1] || arg.startsWith("--identity=") && arg.length > 11
|
|
5135
|
+
);
|
|
5136
|
+
writeServiceLog(
|
|
5137
|
+
"error",
|
|
5138
|
+
!hasIdentityArg && !process.env.SECURECOMS_IDENTITY ? "runtime.identity_required.use_--identity" : "runtime.config_invalid",
|
|
5139
|
+
{},
|
|
5140
|
+
e
|
|
5141
|
+
);
|
|
2604
5142
|
process.exit(2);
|
|
2605
5143
|
return;
|
|
2606
5144
|
}
|
|
2607
5145
|
const log = makeLogger(cfg);
|
|
2608
|
-
const
|
|
5146
|
+
const budgetBase = join8(cfg.dataDir, `llm-budget-${cfg.identityId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
|
|
5147
|
+
const budgetOpts = {
|
|
5148
|
+
callLimit: cfg.llmDailyCallLimit ?? DEFAULT_DAILY_CALL_LIMIT,
|
|
5149
|
+
tokenLimit: cfg.llmDailyTokenLimit ?? DEFAULT_DAILY_TOKEN_LIMIT,
|
|
5150
|
+
statePath: `${budgetBase}.json`,
|
|
5151
|
+
lockPath: `${budgetBase}.lock`,
|
|
5152
|
+
log
|
|
5153
|
+
};
|
|
5154
|
+
const budget = new DailyLlmBudget(budgetOpts);
|
|
5155
|
+
process.once("exit", () => budget.close());
|
|
5156
|
+
const refusal = budgetBootRefusal(budget, budgetOpts.tokenLimit, cfg.llmMaxOutputTokens, writeServiceLog);
|
|
5157
|
+
if (refusal !== null) {
|
|
5158
|
+
process.exit(refusal);
|
|
5159
|
+
return;
|
|
5160
|
+
}
|
|
5161
|
+
const llm = withDailyBudget(buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()), budget);
|
|
2609
5162
|
const memories = /* @__PURE__ */ new Map();
|
|
2610
5163
|
const memoryFor = (channelId) => {
|
|
2611
5164
|
let mem = memories.get(channelId);
|
|
@@ -2629,18 +5182,29 @@ async function main() {
|
|
|
2629
5182
|
const path = keyFilePath(which);
|
|
2630
5183
|
if (!path) return null;
|
|
2631
5184
|
try {
|
|
2632
|
-
return
|
|
2633
|
-
} catch {
|
|
2634
|
-
log("warn",
|
|
5185
|
+
return readFileSync10(path, "utf8").trim() || null;
|
|
5186
|
+
} catch (e) {
|
|
5187
|
+
log("warn", "runtime.search_key_unreadable", { provider_brave: which === "brave" }, e);
|
|
2635
5188
|
return null;
|
|
2636
5189
|
}
|
|
2637
5190
|
};
|
|
2638
5191
|
const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
|
|
2639
|
-
const
|
|
5192
|
+
const reportSearchKeyGate = searchKeyGateReporter(log, (p) => p === keyFilePath("brave"));
|
|
5193
|
+
const hasSearchKey = () => hasReadableKeyFile(
|
|
5194
|
+
[keyFilePath("brave"), keyFilePath("tavily")],
|
|
5195
|
+
(p) => readFileSync10(p, "utf8"),
|
|
5196
|
+
reportSearchKeyGate
|
|
5197
|
+
);
|
|
5198
|
+
const reportLlmSourceUnresolved = failureEpisodeReporter(
|
|
5199
|
+
(e) => log("warn", "runtime.llm_source_unresolved", {}, e)
|
|
5200
|
+
);
|
|
2640
5201
|
const hasLlmKey = () => {
|
|
2641
5202
|
try {
|
|
2642
|
-
|
|
2643
|
-
|
|
5203
|
+
const source = resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
|
|
5204
|
+
reportLlmSourceUnresolved.succeeded();
|
|
5205
|
+
return hasLlmKeyFor(source);
|
|
5206
|
+
} catch (e) {
|
|
5207
|
+
reportLlmSourceUnresolved.failed(e);
|
|
2644
5208
|
return false;
|
|
2645
5209
|
}
|
|
2646
5210
|
};
|
|
@@ -2659,7 +5223,8 @@ async function main() {
|
|
|
2659
5223
|
now: () => /* @__PURE__ */ new Date(),
|
|
2660
5224
|
log
|
|
2661
5225
|
};
|
|
2662
|
-
const
|
|
5226
|
+
const sideEffectLedger = new SideEffectLedger(join8(cfg.dataDir, "side-effect-ledger.sqlite"));
|
|
5227
|
+
const skills = new SkillInvoker(cfg.skillsDir, builtinCtx, sideEffectLedger);
|
|
2663
5228
|
skills.loadSkills();
|
|
2664
5229
|
const policyFor = () => resolveToolPolicy(cfg.toolGateEnv, currentSkillsConfig().tools, {
|
|
2665
5230
|
default: cfg.toolGate.default,
|
|
@@ -2673,11 +5238,16 @@ async function main() {
|
|
|
2673
5238
|
reportLocalConfig({ hasSearchKey: searchKey, hasLlmKey: hasLlmKey() });
|
|
2674
5239
|
return skills.available(requiresEnvFrom(sc, searchKey)).filter((t) => g.gate(t.function.name).allowed);
|
|
2675
5240
|
};
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
}
|
|
5241
|
+
const unavailableCount = skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey())).length;
|
|
5242
|
+
if (unavailableCount > 0) log("info", "runtime.skills_unavailable", { count: unavailableCount });
|
|
2679
5243
|
const plane = createSecureComsDataPlane(
|
|
2680
|
-
|
|
5244
|
+
// Final-wave I2: the daily-limit reply is a notice, not an agent turn.
|
|
5245
|
+
// SEC-06 #9: so are the fixed size-limit replies.
|
|
5246
|
+
{
|
|
5247
|
+
identityId: cfg.identityId,
|
|
5248
|
+
log,
|
|
5249
|
+
noticeReplies: /* @__PURE__ */ new Set([DAILY_LIMIT_RESPONSE, MESSAGE_TOO_LARGE_RESPONSE, TURN_TOO_LARGE_RESPONSE])
|
|
5250
|
+
},
|
|
2681
5251
|
buildHandler({
|
|
2682
5252
|
llm,
|
|
2683
5253
|
memoryFor,
|
|
@@ -2688,19 +5258,42 @@ async function main() {
|
|
|
2688
5258
|
// or configuration saved in the web app takes effect on the next message,
|
|
2689
5259
|
// with no restart.
|
|
2690
5260
|
systemPromptFor: () => systemPromptFor(configDirs, DEFAULT_SYSTEM_PROMPT),
|
|
2691
|
-
tools: installationEligibleTools
|
|
5261
|
+
tools: installationEligibleTools,
|
|
5262
|
+
// SEC-06 #6: more abandoned turns still running than there are slots
|
|
5263
|
+
// means something inside a turn ignores cancellation. Exit, so launchd
|
|
5264
|
+
// (KeepAlive) / systemd (Restart=always) starts a clean process; the
|
|
5265
|
+
// daemon redelivers what was in flight.
|
|
5266
|
+
// #694 F2: first let the turns still working on other channels finish
|
|
5267
|
+
// and send their replies (the handler starts nothing new meanwhile),
|
|
5268
|
+
// then shut down as SIGTERM does.
|
|
5269
|
+
onWedged: (drained) => {
|
|
5270
|
+
log("error", "runtime.wedged_draining");
|
|
5271
|
+
void drained.then(() => {
|
|
5272
|
+
setImmediate(() => {
|
|
5273
|
+
log("error", "runtime.wedged_exit");
|
|
5274
|
+
plane.stop();
|
|
5275
|
+
sideEffectLedger.close();
|
|
5276
|
+
process.exit(1);
|
|
5277
|
+
});
|
|
5278
|
+
});
|
|
5279
|
+
}
|
|
2692
5280
|
})
|
|
2693
5281
|
);
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
5282
|
+
log("info", "runtime.started", {
|
|
5283
|
+
configured_skills: installationEligibleTools().length,
|
|
5284
|
+
tool_gate_default_deny: cfg.toolGate.default === "deny"
|
|
5285
|
+
});
|
|
2697
5286
|
await plane.start();
|
|
2698
5287
|
const shutdown = () => {
|
|
2699
|
-
log("info", "
|
|
5288
|
+
log("info", "runtime.shutting_down");
|
|
2700
5289
|
plane.stop();
|
|
5290
|
+
sideEffectLedger.close();
|
|
2701
5291
|
process.exit(0);
|
|
2702
5292
|
};
|
|
2703
5293
|
process.on("SIGINT", shutdown);
|
|
2704
5294
|
process.on("SIGTERM", shutdown);
|
|
2705
5295
|
}
|
|
2706
|
-
void main()
|
|
5296
|
+
void main().catch((error) => {
|
|
5297
|
+
writeServiceLog("error", "runtime.fatal", {}, error);
|
|
5298
|
+
process.exit(1);
|
|
5299
|
+
});
|