deepdebug-local-agent 1.9.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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/docs/la-jobs-and-llm.md +56 -0
- package/docs/la-protocol-v2.md +3 -3
- package/env.example +6 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +4 -4
- package/src/la-protocol-v2/contract/README.md +25 -1
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
- package/src/la-protocol-v2/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +37 -0
- package/src/la-protocol-v2/jobs/manager.js +276 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +311 -0
- package/src/la-protocol-v2/llm/config.js +61 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- package/src/la-protocol-v2/llm-key/store.js +10 -0
- package/src/la-protocol-v2/runtime.js +35 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/la-protocol-v2/contract.test.js +4 -4
- package/test/la-protocol-v2/helpers/fake-llm.js +64 -0
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -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
|
+
}
|
|
@@ -150,6 +150,16 @@ export function createLlmKeyHolder({ file = null, workspaceRoots = () => [], now
|
|
|
150
150
|
return true;
|
|
151
151
|
} catch { return false; }
|
|
152
152
|
},
|
|
153
|
+
/**
|
|
154
|
+
* F3-10: does this reference (`job.assign.llm.keyRef`) name the key held right now? Compares in constant time and answers a boolean only:
|
|
155
|
+
* the id never leaves the holder. A reference is the key id or its first 8..64 hex characters (the `key_short` the control plane logs).
|
|
156
|
+
*/
|
|
157
|
+
matches(ref) {
|
|
158
|
+
if (!active() || typeof ref !== "string" || !/^[0-9a-f]{8,64}$/.test(ref)) return false;
|
|
159
|
+
const a = Buffer.from(held.keyId.slice(0, ref.length));
|
|
160
|
+
const b = Buffer.from(ref);
|
|
161
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
162
|
+
},
|
|
153
163
|
/** Metadata only. */
|
|
154
164
|
describe() { return active() ? { held: true, models: [...held.models], expiresAt: held.expiresAt } : { held: false }; },
|
|
155
165
|
get held() { return active(); },
|
|
@@ -10,6 +10,8 @@ import { createV2Link } from "./link.js";
|
|
|
10
10
|
import { resolveContentHandlersConfig } from "./content-handlers/config.js";
|
|
11
11
|
import { resolveLlmKeyConfig } from "./llm-key/config.js";
|
|
12
12
|
import { resolveWebhookConfig } from "./webhooks/config.js";
|
|
13
|
+
import { resolveLlmClientConfig } from "./llm/config.js";
|
|
14
|
+
import { resolveJobsConfig } from "./jobs/config.js";
|
|
13
15
|
import { loadContract } from "./contract.js";
|
|
14
16
|
|
|
15
17
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -38,7 +40,7 @@ async function diskFreeBytes(dir) {
|
|
|
38
40
|
/**
|
|
39
41
|
* @returns {Promise} the link, or null (with a warning) when v2 cannot run here: then the tunnel is the plain v1 client.
|
|
40
42
|
*/
|
|
41
|
-
export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env }) {
|
|
43
|
+
export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWorkspaceRoot, log = console, env = process.env, jobRunner = null, onJobResult = null, llmFetch = undefined }) {
|
|
42
44
|
const tenant = String(tenantId || "").toLowerCase();
|
|
43
45
|
if (!UUID.test(tenant)) { log.warn("[la-v2] off: the tenant id is not a UUID"); return null; }
|
|
44
46
|
if (!agentId) { log.warn("[la-v2] off: no agent id (authorizationId in ~/.deepdebug/config.json or LOCAL_AGENT_ID)"); return null; }
|
|
@@ -71,10 +73,33 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
71
73
|
}
|
|
72
74
|
// F3-09 consumer: virtual LLM key of the AI gateway (config.update.llmKey), behind LOCAL_AGENT_LLM_KEY (default off)
|
|
73
75
|
const llmCfg = resolveLlmKeyConfig(env, (m) => log.warn(m));
|
|
76
|
+
// F3-10: LLM client (LOCAL_AGENT_LLM_CLIENT) and job.assign receiver (LOCAL_AGENT_JOBS), both default off. Both need DLP enforce (no opt-out);
|
|
77
|
+
// the client needs the key holder; the receiver needs a runner (and the client unless the runner says it does not think).
|
|
78
|
+
const dlpEnforce = async () => (await import("../dlp/index.js")).dlpConfigFromEnv(env).mode === "enforce";
|
|
79
|
+
const err = (m) => (log.error || log.warn).call(log, m);
|
|
80
|
+
let llmClientCfg = resolveLlmClientConfig(env, (m) => log.warn(m));
|
|
81
|
+
if (llmClientCfg.enabled && !llmCfg.enabled) { err("[la-llm] ERROR: LOCAL_AGENT_LLM_CLIENT=true but LOCAL_AGENT_LLM_KEY is not true: there is no key holder, the LLM client is NOT installed"); llmClientCfg = { enabled: false }; }
|
|
82
|
+
if (llmClientCfg.enabled && !(await dlpEnforce())) { err("[la-llm] ERROR: LOCAL_AGENT_LLM_CLIENT=true but LOCAL_AGENT_DLP_MODE is not enforce: prompts would leave this machine unmasked, the LLM client is NOT installed"); llmClientCfg = { enabled: false }; }
|
|
83
|
+
let jobsCfg = resolveJobsConfig(env, (m) => log.warn(m));
|
|
84
|
+
let runner = null;
|
|
85
|
+
if (jobsCfg.enabled) {
|
|
86
|
+
const { validateRunner } = await import("./jobs/manager.js");
|
|
87
|
+
const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
|
|
88
|
+
const needsLlm = given ? given.needsLlm !== false : jobsCfg.runner === "smoke";
|
|
89
|
+
if (jobRunner && !given) err("[la-jobs] ERROR: the runner passed to buildV2Link does not implement start()/cancel(); it is ignored");
|
|
90
|
+
if (!given && !jobsCfg.runner) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true but there is no runner (LOCAL_AGENT_JOBS_RUNNER=smoke or an injected one): job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
91
|
+
else if (!(await dlpEnforce())) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true but LOCAL_AGENT_DLP_MODE is not enforce: job.assign is NOT registered and no job capability is announced"); jobsCfg = { enabled: false }; }
|
|
92
|
+
else if (needsLlm && !llmClientCfg.enabled) { err("[la-jobs] ERROR: LOCAL_AGENT_JOBS=true with a runner that needs the LLM client, but the client is not installed (LOCAL_AGENT_LLM_CLIENT, LOCAL_AGENT_LLM_KEY, DLP enforce): job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
93
|
+
else runner = given;
|
|
94
|
+
}
|
|
74
95
|
const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki || webhookCfg.enabled);
|
|
75
96
|
const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
|
|
76
97
|
const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
|
|
77
98
|
const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
|
|
99
|
+
const jobCaps = jobsCfg.enabled ? ["job.assign"] : []; // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
|
|
100
|
+
// F3-10 (contract 2.1): `job.options` = the runner understands job.assign `options` (dryRun, createPullRequest, ...). Only a runner that declared supportsOptions
|
|
101
|
+
// (the smoke runner does); an injected one that did not never gets options, and the CP, without this capability, refuses a dry run instead of sending it.
|
|
102
|
+
const jobOptionCaps = jobsCfg.enabled && (runner ? runner.supportsOptions === true : jobsCfg.runner === "smoke") ? ["job.options"] : [];
|
|
78
103
|
// contract 2.1: announced only when a 2.1 feature is on, so with every flag off register/hello are exactly what they were
|
|
79
104
|
const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length ? ["contract.v2.1"] : [];
|
|
80
105
|
const link = createV2Link({
|
|
@@ -86,7 +111,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
86
111
|
signing,
|
|
87
112
|
laVersion: agentVersion(),
|
|
88
113
|
// only what this build implements: the transport, the handshake and the v1 commands on the same connection
|
|
89
|
-
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...v21Caps],
|
|
114
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...v21Caps],
|
|
90
115
|
// contract 2.1 (e): `llm.key.held` is a snapshot taken at every connect (register and hello alike): announced only while the holder has a
|
|
91
116
|
// valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
|
|
92
117
|
dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
|
|
@@ -116,6 +141,14 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
116
141
|
const roots = () => [getWorkspaceRoot(), ...(wsManager?.list?.() || []).map((w) => w && w.root)].filter(Boolean);
|
|
117
142
|
installLlmKey({ link, config: llmCfg, workspaceRoots: roots, log });
|
|
118
143
|
}
|
|
144
|
+
if (llmClientCfg.enabled) {
|
|
145
|
+
const { installLlmClient } = await import("./llm/install.js");
|
|
146
|
+
installLlmClient({ link, config: llmClientCfg, tenantId: tenant, agentId, env, ...(llmFetch ? { fetchImpl: llmFetch } : {}) });
|
|
147
|
+
}
|
|
148
|
+
if (jobsCfg.enabled) {
|
|
149
|
+
const { installJobs } = await import("./jobs/install.js");
|
|
150
|
+
installJobs({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: jobsCfg, env, log, runner, onResult: onJobResult });
|
|
151
|
+
}
|
|
119
152
|
let webhookSource = null;
|
|
120
153
|
if (webhookCfg.enabled) {
|
|
121
154
|
const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
|