deepdebug-local-agent 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude/context.md +2 -1
  2. package/AGENTS.md +1 -1
  3. package/CLAUDE.md +1 -1
  4. package/docs/la-jobs-and-llm.md +56 -0
  5. package/docs/la-protocol-v2.md +19 -2
  6. package/env.example +11 -0
  7. package/package.json +1 -1
  8. package/src/incident-detector/README.md +2 -1
  9. package/src/incident-detector/aggregator.js +8 -3
  10. package/src/incident-detector/detector.js +2 -1
  11. package/src/incident-detector/emitter.js +16 -6
  12. package/src/incident-detector/index.js +3 -3
  13. package/src/incident-detector/sources/webhook-source.js +109 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
  16. package/src/la-protocol-v2/contract/README.md +127 -3
  17. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  18. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  19. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  20. package/src/la-protocol-v2/contract/index.json +9 -2
  21. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  22. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  23. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  24. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  25. package/src/la-protocol-v2/contract.js +45 -7
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +37 -0
  29. package/src/la-protocol-v2/jobs/manager.js +276 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +311 -0
  33. package/src/la-protocol-v2/llm/config.js +61 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +183 -0
  39. package/src/la-protocol-v2/runtime.js +71 -4
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  50. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  51. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  59. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  60. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  61. package/test/incident-detector/size-model.test.js +127 -0
  62. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  63. package/test/la-protocol-v2/contract.test.js +6 -4
  64. package/test/la-protocol-v2/helpers/fake-llm.js +64 -0
  65. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  66. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  67. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  68. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  69. package/test/la-protocol-v2/llm-client.test.js +380 -0
  70. package/test/la-protocol-v2/llm-key.test.js +509 -0
  71. package/test/la-protocol-v2/traces.test.js +3 -3
  72. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * client.js - the Local Agent's LLM client (F3-10). One job: send a chat to the AI gateway the control plane named, with the virtual key the
3
+ * llm-key holder keeps, and give back the text and the usage. OpenAI-compatible `chat/completions`, optional streaming.
4
+ *
5
+ * Order of every call (nothing leaves before the DLP, nothing is read before the checks):
6
+ * 1. shape of the request (model name, messages) -> `bad_request`
7
+ * 2. DLP must be `enforce` (read per call) -> `dlp_not_enforced`
8
+ * 3. a valid key is held and the model is one of its aliases -> `no_key` | `key_expired` | `model_not_allowed`
9
+ * 4. EVERY message is masked with the agent's DLP (src/dlp); a hit of a blocked class, a DLP failure or an oversize input is refused
10
+ * (`dlp_blocked` | `dlp_denied`); the masked prompt is capped (`prompt_too_large`) and checked against the token budget (`budget_exceeded`)
11
+ * 5. a concurrency slot (`busy`), then the HTTP call through `holder.request()` (the ONLY way out for the key: https endpoint from the
12
+ * control plane, `Authorization` header set by the holder, no redirects) with a timeout per attempt, a total deadline and a few
13
+ * retries (network error, 408, 429, 5xx; never after a streamed delta was delivered; never a 4xx)
14
+ * 6. the response is read under a byte cap; errors are CODES only
15
+ *
16
+ * What it never does: log, audit, throw or return a prompt, a response, the key, its id or the body of an error response. The audit record
17
+ * has the alias, byte counts, token counts, per-rule redaction COUNTS and a short hash of the job id. Tags (`metadata.tags`) carry ids only.
18
+ */
19
+ import { dlpConfigFromEnv, protectEgress, DENIED } from "../../dlp/index.js";
20
+ import { OVERSIZE } from "../../dlp/engine.js";
21
+ import { createAudit, short } from "../content-handlers/audit.js";
22
+
23
+ /** The complete list of error codes of this client (a test pins it: no free text ever travels in an error). */
24
+ export const LLM_ERROR_CODES = Object.freeze([
25
+ "bad_request", "dlp_not_enforced", "no_key", "key_expired", "model_not_allowed", "dlp_blocked", "dlp_denied", "prompt_too_large",
26
+ "budget_exceeded", "busy", "key_rejected", "rate_limited", "timeout", "network_error", "upstream_error", "response_too_large",
27
+ "bad_response", "cancelled", "internal",
28
+ ]);
29
+
30
+ export class LlmClientError extends Error {
31
+ constructor(code, { attempts = 0 } = {}) {
32
+ const known = LLM_ERROR_CODES.includes(code) ? code : "internal"; // the message is the code, never free text
33
+ super(known);
34
+ this.name = "LlmClientError";
35
+ this.code = known;
36
+ this.attempts = attempts;
37
+ }
38
+ }
39
+
40
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$/;
41
+ const ROLES = new Set(["system", "user", "assistant"]);
42
+ const COST_HEADER = "x-litellm-response-cost";
43
+ const MAX_SSE_LINE_BYTES = 256 * 1024;
44
+ const utf8 = new TextEncoder();
45
+ const byteLength = (s) => utf8.encode(s).length;
46
+ const estimateTokens = (bytes) => Math.ceil(bytes / 3); // pessimistic (code and non-English text are denser than 4 bytes/token)
47
+
48
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
49
+ const t = setTimeout(done, ms);
50
+ function done() { signal?.removeEventListener("abort", done); clearTimeout(t); resolve(); }
51
+ signal?.addEventListener("abort", done, { once: true });
52
+ });
53
+
54
+ function addCounts(into, from) { for (const [k, v] of Object.entries(from || {})) into[k] = (into[k] || 0) + v; }
55
+
56
+ /**
57
+ * @param {object} o
58
+ * @param {{request:Function, describe:Function, held:boolean}} o.holder the llm-key holder (link.llm.holder)
59
+ * @param {object} o.config resolveLlmClientConfig()
60
+ * @param {string} o.tenantId @param {string} o.agentId only for the request tags
61
+ * @param {object} [o.env] LOCAL_AGENT_DLP_* (read per call)
62
+ * @param {object} [o.audit] createAudit() (a ring by default)
63
+ * @param {Function} [o.fetchImpl] tests only; production uses globalThis.fetch through the holder
64
+ */
65
+ export function createLlmClient({ holder, config, tenantId, agentId, env = process.env, audit = null, now = Date.now, sleep = defaultSleep, fetchImpl = undefined }) {
66
+ const auditor = audit || createAudit({ file: config.auditFile, now });
67
+ const stats = { calls: 0, ok: 0, failed: 0, retries: 0, bytesOut: 0, bytesIn: 0, tokensIn: 0, tokensOut: 0, byCode: {} };
68
+ let active = 0;
69
+
70
+ const record = (rec) => { try { auditor.record({ event: "llm_call", ...rec }); } catch { /* the audit never breaks the data path */ } };
71
+ const fail = (code, ctx, attempts = 0) => {
72
+ const err = new LlmClientError(code, { attempts });
73
+ stats.failed++;
74
+ stats.byCode[err.code] = (stats.byCode[err.code] || 0) + 1;
75
+ record({ ok: false, code: err.code, model: ctx.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: ctx.stream });
76
+ throw err;
77
+ };
78
+
79
+ /** Masks every message; returns the masked messages, their size and the per-rule counts. Throws a code (string) on refusal. */
80
+ function maskMessages(messages, ctx) {
81
+ const out = [];
82
+ let bytes = 0;
83
+ for (const m of messages) {
84
+ const res = protectEgress(m.content, { env, source: "llm.client" });
85
+ addCounts(ctx.redactions, res.redactions);
86
+ if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
87
+ if (res.blocked) throw "dlp_blocked";
88
+ bytes += byteLength(res.text);
89
+ if (bytes > config.maxPromptBytes) throw "prompt_too_large";
90
+ out.push({ role: m.role, content: res.text });
91
+ }
92
+ return { masked: out, bytes };
93
+ }
94
+
95
+ function parseCost(headers) {
96
+ const raw = headers && typeof headers.get === "function" ? headers.get(COST_HEADER) : null;
97
+ const n = raw === null || raw === undefined || raw === "" ? NaN : Number(raw);
98
+ return Number.isFinite(n) && n >= 0 && n <= 100_000 ? n : null;
99
+ }
100
+
101
+ /** One attempt. Returns the parsed answer, or throws { code, retry, after? } (never text). */
102
+ async function attempt(bodyText, o, ctx) {
103
+ const ac = new AbortController();
104
+ let reason = null;
105
+ const abort = (why) => { if (!reason) { reason = why; ac.abort(); } };
106
+ const onExternal = () => abort("cancelled");
107
+ if (o.signal) { if (o.signal.aborted) throw { code: "cancelled", retry: false }; o.signal.addEventListener("abort", onExternal, { once: true }); }
108
+ let timer = setTimeout(() => abort("timeout"), Math.min(config.timeoutMs, Math.max(1, ctx.deadline - now())));
109
+ const rearm = (ms) => { clearTimeout(timer); timer = setTimeout(() => abort("timeout"), Math.min(ms, Math.max(1, ctx.deadline - now()))); };
110
+ const lift = (e) => ({ code: reason === "cancelled" ? "cancelled" : reason === "timeout" ? "timeout" : e?.code && LLM_ERROR_CODES.includes(e.code) ? e.code : "network_error", retry: reason !== "cancelled" });
111
+ try {
112
+ let res;
113
+ try {
114
+ res = await holder.request(config.path, { method: "POST", headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json" }, body: bodyText, signal: ac.signal }, fetchImpl ? { fetchImpl } : undefined);
115
+ } catch (e) {
116
+ // LlmKeyError codes from the holder (no_key | expired | bad_path); anything else (fetch failure, abort) is a network error
117
+ if (e && e.name === "LlmKeyError") throw { code: e.code === "expired" ? "key_expired" : e.code === "no_key" ? "no_key" : "internal", retry: false };
118
+ throw lift(e);
119
+ }
120
+ const status = res.status;
121
+ if (status < 200 || status >= 300) {
122
+ try { await res.body?.cancel(); } catch { /* the body of an error response is never read: it may echo the prompt */ }
123
+ const ra = res.headers?.get?.("retry-after");
124
+ const retryAfter = ra === null || ra === undefined || ra === "" ? NaN : Number(ra); // seconds; an HTTP date is ignored
125
+ const after = Number.isFinite(retryAfter) && retryAfter >= 0 ? Math.min(retryAfter * 1000, config.maxBackoffMs) : undefined;
126
+ if (status === 401 || status === 403) throw { code: "key_rejected", retry: false };
127
+ if (status === 408) throw { code: "timeout", retry: true, after };
128
+ if (status === 429) throw { code: "rate_limited", retry: true, after };
129
+ if (status === 413) throw { code: "prompt_too_large", retry: false };
130
+ if (status >= 500) throw { code: "upstream_error", retry: true, after };
131
+ throw { code: "bad_request", retry: false }; // other 4xx: the request itself is wrong (alias unknown to the proxy, schema): repeating it changes nothing
132
+ }
133
+ const costUsd = parseCost(res.headers);
134
+ if (!res.body || typeof res.body.getReader !== "function") throw { code: "bad_response", retry: false };
135
+ try {
136
+ const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : await readJson(res.body, config.maxResponseBytes);
137
+ return { ...parsed, costUsd };
138
+ } catch (e) {
139
+ if (e && e.code && !e.name) throw e; // our own { code, retry }
140
+ throw lift(e);
141
+ }
142
+ } finally {
143
+ clearTimeout(timer);
144
+ o.signal?.removeEventListener("abort", onExternal);
145
+ }
146
+ }
147
+
148
+ async function readBounded(body, maxBytes) {
149
+ const reader = body.getReader();
150
+ const chunks = [];
151
+ let total = 0;
152
+ for (;;) {
153
+ const { done, value } = await reader.read();
154
+ if (done) break;
155
+ total += value.byteLength;
156
+ if (total > maxBytes) { try { await reader.cancel(); } catch { /* gone */ } throw { code: "response_too_large", retry: false }; }
157
+ chunks.push(value);
158
+ }
159
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString("utf8");
160
+ }
161
+
162
+ async function readJson(body, maxBytes) {
163
+ const text = await readBounded(body, maxBytes);
164
+ let j;
165
+ try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
166
+ const choice = j && Array.isArray(j.choices) ? j.choices[0] : null;
167
+ const msg = choice && choice.message;
168
+ if (!choice || !msg || (msg.content !== null && msg.content !== undefined && typeof msg.content !== "string")) throw { code: "bad_response", retry: false };
169
+ return { text: msg.content || "", usage: usageOf(j.usage), model: modelOf(j.model), finishReason: typeof choice.finish_reason === "string" ? choice.finish_reason.slice(0, 32) : null };
170
+ }
171
+
172
+ async function readStream(body, o, rearm, ctx) {
173
+ const reader = body.getReader();
174
+ const dec = new TextDecoder("utf-8");
175
+ let buf = "";
176
+ let text = "";
177
+ let textBytes = 0;
178
+ let usage = null;
179
+ let model = null;
180
+ let finish = null;
181
+ let done = false;
182
+ const handleData = (data) => {
183
+ if (data === "[DONE]") { done = true; return; }
184
+ let j;
185
+ try { j = JSON.parse(data); } catch { throw { code: "bad_response", retry: false }; }
186
+ if (j && j.error) throw { code: "upstream_error", retry: false };
187
+ if (j && j.usage) usage = usageOf(j.usage);
188
+ if (j && typeof j.model === "string") model = modelOf(j.model);
189
+ const c = j && Array.isArray(j.choices) ? j.choices[0] : null;
190
+ if (!c) return;
191
+ if (typeof c.finish_reason === "string") finish = c.finish_reason.slice(0, 32);
192
+ const d = c.delta && typeof c.delta.content === "string" ? c.delta.content : "";
193
+ if (d) {
194
+ textBytes += byteLength(d);
195
+ if (textBytes > config.maxResponseBytes) throw { code: "response_too_large", retry: false };
196
+ text += d;
197
+ ctx.delivered = true;
198
+ if (typeof o.onDelta === "function") { try { o.onDelta(d); } catch { /* a consumer's bug never breaks the call */ } }
199
+ }
200
+ };
201
+ const handleLine = (line) => { if (line.startsWith("data:")) handleData(line.slice(5).trim()); };
202
+ rearm(config.streamIdleMs);
203
+ while (!done) {
204
+ const { done: eof, value } = await reader.read();
205
+ if (eof) break;
206
+ rearm(config.streamIdleMs); // silence, not total time, is what the idle timeout measures (the total deadline still caps it)
207
+ buf += dec.decode(value, { stream: true });
208
+ let nl;
209
+ while ((nl = buf.search(/\r?\n/)) >= 0) {
210
+ const line = buf.slice(0, nl);
211
+ buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
212
+ handleLine(line);
213
+ if (done) break;
214
+ }
215
+ if (buf.length > MAX_SSE_LINE_BYTES) throw { code: "bad_response", retry: false };
216
+ }
217
+ if (!done && buf.trim()) handleLine(buf.trim());
218
+ try { await reader.cancel(); } catch { /* closed */ }
219
+ if (!done && !finish && !text && !usage) throw { code: "bad_response", retry: false };
220
+ return { text, usage, model, finishReason: finish };
221
+ }
222
+
223
+ function usageOf(u) {
224
+ if (!u || typeof u !== "object") return null;
225
+ const p = Number(u.prompt_tokens);
226
+ const c = Number(u.completion_tokens);
227
+ return Number.isSafeInteger(p) && p >= 0 && Number.isSafeInteger(c) && c >= 0 ? { promptTokens: p, completionTokens: c, estimated: false } : null;
228
+ }
229
+ const modelOf = (m) => (typeof m === "string" && MODEL.test(m) ? m : null);
230
+
231
+ /**
232
+ * @param {object} o
233
+ * @param {string} o.model an alias of the key (`link.llm.holder.describe().models`)
234
+ * @param {{role:"system"|"user"|"assistant",content:string}[]} o.messages
235
+ * @param {number} [o.maxTokens] capped by the config and by what is left of the budget
236
+ * @param {number} [o.temperature]
237
+ * @param {boolean} [o.stream] @param {(delta:string)=>void} [o.onDelta]
238
+ * @param {AbortSignal} [o.signal] cancellation (job.cancel, deadline)
239
+ * @param {{jobId?:string, agent?:string}} [o.job] tags only
240
+ * @param {number} [o.tokensLeft] what is left of the job budget (prompt estimate + max_tokens must fit)
241
+ * @returns {Promise<{text:string, usage:{promptTokens:number,completionTokens:number,estimated:boolean}, costUsd:number|null, model:string, finishReason:string|null, attempts:number}>}
242
+ */
243
+ async function chat(o = {}) {
244
+ stats.calls++;
245
+ const ctx = { t0: now(), model: typeof o.model === "string" && MODEL.test(o.model) ? o.model : "invalid", jobShort: o.job?.jobId ? short(o.job.jobId) : undefined, promptBytes: 0, redactions: {}, stream: !!o.stream, delivered: false, deadline: now() + config.totalMs };
246
+ // 1. shape
247
+ const msgs = o.messages;
248
+ if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxMessages
249
+ || !msgs.every((m) => m && typeof m === "object" && ROLES.has(m.role) && typeof m.content === "string")) return fail("bad_request", ctx);
250
+ if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 2)) return fail("bad_request", ctx);
251
+ if (o.signal?.aborted) return fail("cancelled", ctx);
252
+ // 2. DLP enforce, per call
253
+ if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
254
+ // 3. key and alias
255
+ if (!holder.held) return fail("no_key", ctx);
256
+ const d = holder.describe();
257
+ if (!d.held) return fail("key_expired", ctx);
258
+ if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
259
+ // 4. mask first, then size and budget
260
+ let masked;
261
+ try {
262
+ const r = maskMessages(msgs, ctx);
263
+ masked = r.masked; ctx.promptBytes = r.bytes;
264
+ } catch (code) {
265
+ return fail(typeof code === "string" ? code : "dlp_denied", ctx);
266
+ }
267
+ let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
268
+ if (o.tokensLeft !== undefined) {
269
+ const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
270
+ if (!(room >= 1)) return fail("budget_exceeded", ctx);
271
+ maxTokens = Math.min(maxTokens, room);
272
+ }
273
+ // 5. slot and the call
274
+ if (active >= config.maxConcurrent) return fail("busy", ctx);
275
+ active++;
276
+ try {
277
+ const tags = config.tags ? { tags: [`vh-tenant:${tenantId}`, `vh-la:${agentId}`, ...(o.job?.jobId ? [`vh-job:${o.job.jobId}`] : []), ...(o.job?.agent && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(o.job.agent) ? [`vh-agent:${o.job.agent}`] : [])] } : null;
278
+ const bodyText = JSON.stringify({
279
+ model: o.model, messages: masked, max_tokens: maxTokens, stream: !!o.stream,
280
+ ...(o.stream ? { stream_options: { include_usage: true } } : {}),
281
+ ...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
282
+ ...(tags ? { metadata: tags } : {}),
283
+ });
284
+ stats.bytesOut += byteLength(bodyText);
285
+ let attempts = 0;
286
+ for (;;) {
287
+ attempts++;
288
+ try {
289
+ const r = await attempt(bodyText, o, ctx);
290
+ const usage = r.usage || { promptTokens: estimateTokens(ctx.promptBytes), completionTokens: estimateTokens(byteLength(r.text)), estimated: true };
291
+ stats.ok++; stats.tokensIn += usage.promptTokens; stats.tokensOut += usage.completionTokens; stats.bytesIn += byteLength(r.text);
292
+ record({ ok: true, model: o.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, outBytes: byteLength(r.text), tokensIn: usage.promptTokens, tokensOut: usage.completionTokens, estimated: usage.estimated, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: !!o.stream });
293
+ return { text: r.text, usage, costUsd: r.costUsd, model: r.model || o.model, finishReason: r.finishReason, attempts };
294
+ } catch (e) {
295
+ const code = e && typeof e.code === "string" && LLM_ERROR_CODES.includes(e.code) ? e.code : "internal";
296
+ const canRetry = e && e.retry === true && !ctx.delivered && attempts <= config.maxRetries && !o.signal?.aborted;
297
+ if (!canRetry) return fail(code, ctx, attempts);
298
+ const wait = Math.min(e.after ?? config.backoffMs * 2 ** (attempts - 1), config.maxBackoffMs);
299
+ if (now() + wait >= ctx.deadline) return fail(code, ctx, attempts);
300
+ stats.retries++;
301
+ await sleep(wait, o.signal);
302
+ if (o.signal?.aborted) return fail("cancelled", ctx, attempts);
303
+ }
304
+ }
305
+ } finally {
306
+ active--;
307
+ }
308
+ }
309
+
310
+ return { chat, stats, audit: auditor, get active() { return active; } };
311
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_LLM_CLIENT flag and limits of the Local Agent's LLM client (F3-10).
3
+ *
4
+ * false (default) there is NO LLM client: `link.llm.client` does not exist, the key holder (F3-09) only holds the key.
5
+ * true `link.llm.client.chat()` calls the AI gateway (the endpoint the control plane delivered with the key, OpenAI-compatible
6
+ * chat/completions) with the virtual key held by the llm-key holder. It needs, all three:
7
+ * LOCAL_AGENT_PROTOCOL_V2=true (a v2 session: nobody delivers a key without it)
8
+ * LOCAL_AGENT_LLM_KEY=true (the holder: the client has no key of its own)
9
+ * LOCAL_AGENT_DLP_MODE=enforce (prompts leave this machine only masked; there is no opt-out)
10
+ * runtime.js refuses (and says so) when one is missing.
11
+ *
12
+ * Unset/empty/unrecognised = false (an unknown value must never open a way out for client content).
13
+ * Every number is clamped: a bad value falls back to the default, never to "unlimited".
14
+ */
15
+ export const LLM_CLIENT_FLAG = "LOCAL_AGENT_LLM_CLIENT";
16
+
17
+ const TRUE = new Set(["true", "1", "yes", "on"]);
18
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
19
+ const KIB = 1024;
20
+ const MIB = 1024 * KIB;
21
+
22
+ function int(raw, def, min, max) {
23
+ const n = Number.parseInt(String(raw ?? ""), 10);
24
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
25
+ }
26
+
27
+ const bool = (raw, def) => {
28
+ const v = String(raw ?? "").trim().toLowerCase();
29
+ if (TRUE.has(v)) return true;
30
+ if (v !== "" && FALSE.has(v)) return false;
31
+ return def;
32
+ };
33
+
34
+ const PATH = /^\/(?!\/)[A-Za-z0-9._~/-]{1,200}$/;
35
+
36
+ export function resolveLlmClientConfig(env = process.env, warn = console.warn) {
37
+ const raw = String(env[LLM_CLIENT_FLAG] ?? "").trim().toLowerCase();
38
+ if (!TRUE.has(raw)) {
39
+ if (!FALSE.has(raw)) warn(`[la-llm] unrecognised ${LLM_CLIENT_FLAG} value; the LLM client stays off`);
40
+ return { enabled: false };
41
+ }
42
+ let path = String(env.LOCAL_AGENT_LLM_PATH ?? "").trim() || "/v1/chat/completions";
43
+ if (!PATH.test(path) || path.split("/").includes("..")) { warn("[la-llm] LOCAL_AGENT_LLM_PATH is not a plain absolute path; using /v1/chat/completions"); path = "/v1/chat/completions"; }
44
+ return {
45
+ enabled: true,
46
+ path, // appended to the endpoint the control plane gave (the holder never shows it); set it to /chat/completions when that endpoint already ends in /v1
47
+ timeoutMs: int(env.LOCAL_AGENT_LLM_TIMEOUT_MS, 60_000, 1_000, 300_000), // one attempt: headers + body (streaming: until the headers)
48
+ streamIdleMs: int(env.LOCAL_AGENT_LLM_STREAM_IDLE_MS, 30_000, 1_000, 120_000), // streaming: silence between two chunks
49
+ totalMs: int(env.LOCAL_AGENT_LLM_TOTAL_MS, 180_000, 1_000, 900_000), // the whole call, retries included
50
+ maxRetries: int(env.LOCAL_AGENT_LLM_MAX_RETRIES, 2, 0, 5),
51
+ backoffMs: int(env.LOCAL_AGENT_LLM_BACKOFF_MS, 500, 0, 10_000),
52
+ maxBackoffMs: 10_000,
53
+ maxPromptBytes: int(env.LOCAL_AGENT_LLM_MAX_PROMPT_BYTES, 256 * KIB, KIB, 2 * MIB), // all messages, after masking
54
+ maxOutputTokens: int(env.LOCAL_AGENT_LLM_MAX_OUTPUT_TOKENS, 4_096, 1, 32_768),
55
+ maxResponseBytes: int(env.LOCAL_AGENT_LLM_MAX_RESPONSE_BYTES, 1 * MIB, 4 * KIB, 8 * MIB),
56
+ maxMessages: 64,
57
+ maxConcurrent: int(env.LOCAL_AGENT_LLM_MAX_CONCURRENT, 4, 1, 16),
58
+ tags: bool(env.LOCAL_AGENT_LLM_TAGS, true), // request metadata.tags (tenant, agent, job); ids only, never content
59
+ auditFile: String(env.LOCAL_AGENT_LLM_AUDIT_FILE ?? "").trim() || null,
60
+ };
61
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * install.js - puts the LLM client on the key holder (F3-10). Called by runtime.js only when LOCAL_AGENT_LLM_CLIENT=true, LOCAL_AGENT_LLM_KEY=true
3
+ * (link.llm exists) and LOCAL_AGENT_DLP_MODE=enforce. Hooks `link.llm.request` (the holder's one way out for the key) into the client:
4
+ * the client has no other way to reach the AI gateway. Returns the client; also at `link.llm.client`.
5
+ */
6
+ import { dlpConfigFromEnv } from "../../dlp/index.js";
7
+ import { createLlmClient } from "./client.js";
8
+
9
+ export function installLlmClient({ link, config, tenantId, agentId, env = process.env, now = Date.now, fetchImpl }) {
10
+ if (!link.llm || !link.llm.holder) throw new Error("the LLM client needs the llm-key holder (LOCAL_AGENT_LLM_KEY=true)");
11
+ // F3-07b/F3-10, defence in depth: runtime.js already refuses; nobody can install the client without DLP enforce
12
+ if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("the LLM client needs LOCAL_AGENT_DLP_MODE=enforce");
13
+ const client = createLlmClient({ holder: { request: link.llm.request, describe: () => link.llm.holder.describe(), get held() { return link.llm.holder.held; } },
14
+ config, tenantId: String(tenantId).toLowerCase(), agentId: String(agentId).toLowerCase(), env, now, fetchImpl });
15
+ link.llm.client = client;
16
+ return client;
17
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_LLM_KEY flag and limits of the LLM key holder (F3-09 consumer, contract 2.1 (a)).
3
+ *
4
+ * false (default) `config.update` keeps being acked `rejected protocol_unsupported`, the capability `llm.key` is NOT announced, so
5
+ * the control plane never sends a key: nothing of this folder is loaded.
6
+ * true the agent handles `config.update.llmKey` (set / revoke) and keeps the virtual key of the AI gateway IN MEMORY.
7
+ * Needs LOCAL_AGENT_PROTOCOL_V2=true as well (without a v2 session nobody sends a key: the flag does nothing).
8
+ *
9
+ * LOCAL_AGENT_LLM_KEY_FILE optional absolute path of a file (mode 0600, directory 0700) where the key survives a restart. It must be
10
+ * OUTSIDE every workspace (a workspace is what the content handlers may serve). Unset = memory only.
11
+ * Unset/empty/unrecognised = false (an unknown value must never open the door to a key).
12
+ */
13
+ import path from "node:path";
14
+
15
+ export const LLM_KEY_FLAG = "LOCAL_AGENT_LLM_KEY";
16
+
17
+ const TRUE = new Set(["true", "1", "yes", "on"]);
18
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
19
+
20
+ export function resolveLlmKeyConfig(env = process.env, warn = console.warn) {
21
+ const raw = String(env[LLM_KEY_FLAG] ?? "").trim().toLowerCase();
22
+ if (!TRUE.has(raw)) {
23
+ if (!FALSE.has(raw)) warn(`[la-llm-key] unrecognised ${LLM_KEY_FLAG} value; the LLM key handler stays off`);
24
+ return { enabled: false };
25
+ }
26
+ let file = String(env.LOCAL_AGENT_LLM_KEY_FILE ?? "").trim() || null;
27
+ if (file && !path.isAbsolute(file)) { warn("[la-llm-key] LOCAL_AGENT_LLM_KEY_FILE must be an absolute path; the key stays in memory only"); file = null; }
28
+ return { enabled: true, file: file ? path.normalize(file) : null };
29
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * handler.js - `config.update` with `llmKey` (F3-09 consumer, contract 2.1 (a)). Registered by install.js only with
3
+ * LOCAL_AGENT_LLM_KEY=true. The frame was already validated against the closed schema (a frame that carries `llmKey` carries nothing else);
4
+ * the session defers the rule `llmKey-id-sha256` to here, so a wrong id is a non-fatal `rejected schema_violation`, as the README says.
5
+ *
6
+ * set accepted | rejected schema_violation (id is not the sha256 of the key, key already expired)
7
+ * revoke accepted (also for an id this agent does not hold: it never says which key it has)
8
+ * others a `config.update` WITHOUT llmKey (flags, dlp hash, ...) is what it was before: rejected protocol_unsupported
9
+ * The answer is the plain ack: it never carries the key, the id or any text. Nothing here logs a value of the frame.
10
+ */
11
+ import { llmKeyIdMismatch } from "../contract.js";
12
+
13
+ export function createLlmKeyHandler({ holder }) {
14
+ const stats = { frames: 0, set: 0, revoke: 0, rejected: 0, other: 0 };
15
+ return {
16
+ stats,
17
+ handle(ctx) {
18
+ stats.frames++;
19
+ const k = ctx.params && ctx.params.llmKey;
20
+ if (!k) { stats.other++; return { reject: "protocol_unsupported" }; }
21
+ if (k.op === "revoke") { stats.revoke++; holder.revoke(k.keyId); return { accept: true }; }
22
+ if (k.op === "set") {
23
+ if (llmKeyIdMismatch(k)) { stats.rejected++; return { reject: "schema_violation" }; }
24
+ const r = holder.set(k);
25
+ if (!r.ok) { stats.rejected++; return { reject: r.code }; }
26
+ stats.set++;
27
+ return { accept: true };
28
+ }
29
+ stats.rejected++;
30
+ return { reject: "schema_violation" };
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * install.js - puts the LLM key holder on a v2 link (F3-09 consumer). Called by runtime.js only when LOCAL_AGENT_LLM_KEY=true and v2 is on.
3
+ * Returns link.llm = { holder, handler, request(path, init) }.
4
+ */
5
+ import { createAudit } from "../content-handlers/audit.js";
6
+ import { createLlmKeyHolder } from "./store.js";
7
+ import { createLlmKeyHandler } from "./handler.js";
8
+
9
+ export const LLM_KEY_CAPABILITIES = Object.freeze(["llm.key"]);
10
+
11
+ export function installLlmKey({ link, config, workspaceRoots = () => [], log = console, now = Date.now }) {
12
+ const audit = createAudit({}); // ring only: a key event never goes to a file the operator did not ask for
13
+ const holder = createLlmKeyHolder({ file: config.file, workspaceRoots, now, audit, log });
14
+ holder.load();
15
+ const handler = createLlmKeyHandler({ holder });
16
+ link.session.setHandler("config.update", (ctx) => handler.handle(ctx));
17
+ const closeLink = link.close.bind(link);
18
+ link.close = () => { holder.close(); closeLink(); };
19
+ link.llm = { holder, handler, audit, request: (p, init, opts) => holder.request(p, init, opts) };
20
+ return link.llm;
21
+ }