deepdebug-local-agent 1.9.0 → 1.11.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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +5 -0
  3. package/CLAUDE.md +1 -1
  4. package/docs/examples/agent2-runner/docker-compose.yml +53 -0
  5. package/docs/la-agent2-runner.md +115 -0
  6. package/docs/la-jobs-and-llm.md +62 -0
  7. package/docs/la-protocol-v2.md +7 -5
  8. package/env.example +12 -0
  9. package/package.json +1 -1
  10. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  11. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
  12. package/src/la-protocol-v2/contract/README.md +75 -2
  13. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  14. package/src/la-protocol-v2/contract/index.json +18 -2
  15. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  16. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  17. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  18. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  19. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  20. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  21. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  22. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  23. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  24. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  25. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  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 +43 -0
  29. package/src/la-protocol-v2/jobs/manager.js +281 -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 +432 -0
  33. package/src/la-protocol-v2/llm/config.js +63 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  35. package/src/la-protocol-v2/llm-key/store.js +10 -0
  36. package/src/la-protocol-v2/runtime.js +69 -3
  37. package/src/server.js +4 -0
  38. package/src/utils/approval-token.js +3 -2
  39. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
  40. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  41. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  42. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  43. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  44. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  45. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  46. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  47. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  48. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  49. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  50. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  51. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  52. package/test/la-protocol-v2/contract.test.js +8 -7
  53. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  54. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  55. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  56. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  57. package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
  58. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  59. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  60. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  61. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  62. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  63. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  64. package/test/la-protocol-v2/llm-client.test.js +380 -0
  65. package/test/la-protocol-v2/llm-messages.test.js +132 -0
@@ -0,0 +1,432 @@
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 TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
42
+ const TOOL_ID = /^[A-Za-z0-9_-]{1,64}$/;
43
+ const ROLES = new Set(["system", "user", "assistant"]);
44
+ const COST_HEADER = "x-litellm-response-cost";
45
+ const MAX_SSE_LINE_BYTES = 256 * 1024;
46
+ const utf8 = new TextEncoder();
47
+ const byteLength = (s) => utf8.encode(s).length;
48
+ const estimateTokens = (bytes) => Math.ceil(bytes / 3); // pessimistic (code and non-English text are denser than 4 bytes/token)
49
+
50
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
51
+ const t = setTimeout(done, ms);
52
+ function done() { signal?.removeEventListener("abort", done); clearTimeout(t); resolve(); }
53
+ signal?.addEventListener("abort", done, { once: true });
54
+ });
55
+
56
+ function addCounts(into, from) { for (const [k, v] of Object.entries(from || {})) into[k] = (into[k] || 0) + v; }
57
+
58
+ /**
59
+ * @param {object} o
60
+ * @param {{request:Function, describe:Function, held:boolean}} o.holder the llm-key holder (link.llm.holder)
61
+ * @param {object} o.config resolveLlmClientConfig()
62
+ * @param {string} o.tenantId @param {string} o.agentId only for the request tags
63
+ * @param {object} [o.env] LOCAL_AGENT_DLP_* (read per call)
64
+ * @param {object} [o.audit] createAudit() (a ring by default)
65
+ * @param {Function} [o.fetchImpl] tests only; production uses globalThis.fetch through the holder
66
+ */
67
+ export function createLlmClient({ holder, config, tenantId, agentId, env = process.env, audit = null, now = Date.now, sleep = defaultSleep, fetchImpl = undefined }) {
68
+ const auditor = audit || createAudit({ file: config.auditFile, now });
69
+ const stats = { calls: 0, ok: 0, failed: 0, retries: 0, bytesOut: 0, bytesIn: 0, tokensIn: 0, tokensOut: 0, byCode: {} };
70
+ let active = 0;
71
+
72
+ const record = (rec) => { try { auditor.record({ event: "llm_call", ...rec }); } catch { /* the audit never breaks the data path */ } };
73
+ const fail = (code, ctx, attempts = 0) => {
74
+ const err = new LlmClientError(code, { attempts });
75
+ stats.failed++;
76
+ stats.byCode[err.code] = (stats.byCode[err.code] || 0) + 1;
77
+ 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 });
78
+ throw err;
79
+ };
80
+
81
+ /** Masks every message; returns the masked messages, their size and the per-rule counts. Throws a code (string) on refusal. */
82
+ function maskMessages(messages, ctx) {
83
+ const out = [];
84
+ let bytes = 0;
85
+ for (const m of messages) {
86
+ const res = protectEgress(m.content, { env, source: "llm.client" });
87
+ addCounts(ctx.redactions, res.redactions);
88
+ if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
89
+ if (res.blocked) throw "dlp_blocked";
90
+ bytes += byteLength(res.text);
91
+ if (bytes > config.maxPromptBytes) throw "prompt_too_large";
92
+ out.push({ role: m.role, content: res.text });
93
+ }
94
+ return { masked: out, bytes };
95
+ }
96
+
97
+ function parseCost(headers) {
98
+ const raw = headers && typeof headers.get === "function" ? headers.get(COST_HEADER) : null;
99
+ const n = raw === null || raw === undefined || raw === "" ? NaN : Number(raw);
100
+ return Number.isFinite(n) && n >= 0 && n <= 100_000 ? n : null;
101
+ }
102
+
103
+ /** One attempt. Returns the parsed answer, or throws { code, retry, after? } (never text). */
104
+ async function attempt(bodyText, o, ctx) {
105
+ const ac = new AbortController();
106
+ let reason = null;
107
+ const abort = (why) => { if (!reason) { reason = why; ac.abort(); } };
108
+ const onExternal = () => abort("cancelled");
109
+ if (o.signal) { if (o.signal.aborted) throw { code: "cancelled", retry: false }; o.signal.addEventListener("abort", onExternal, { once: true }); }
110
+ let timer = setTimeout(() => abort("timeout"), Math.min(config.timeoutMs, Math.max(1, ctx.deadline - now())));
111
+ const rearm = (ms) => { clearTimeout(timer); timer = setTimeout(() => abort("timeout"), Math.min(ms, Math.max(1, ctx.deadline - now()))); };
112
+ 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" });
113
+ try {
114
+ let res;
115
+ try {
116
+ res = await holder.request(o.path || 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);
117
+ } catch (e) {
118
+ // LlmKeyError codes from the holder (no_key | expired | bad_path); anything else (fetch failure, abort) is a network error
119
+ if (e && e.name === "LlmKeyError") throw { code: e.code === "expired" ? "key_expired" : e.code === "no_key" ? "no_key" : "internal", retry: false };
120
+ throw lift(e);
121
+ }
122
+ const status = res.status;
123
+ if (status < 200 || status >= 300) {
124
+ try { await res.body?.cancel(); } catch { /* the body of an error response is never read: it may echo the prompt */ }
125
+ const ra = res.headers?.get?.("retry-after");
126
+ const retryAfter = ra === null || ra === undefined || ra === "" ? NaN : Number(ra); // seconds; an HTTP date is ignored
127
+ const after = Number.isFinite(retryAfter) && retryAfter >= 0 ? Math.min(retryAfter * 1000, config.maxBackoffMs) : undefined;
128
+ if (status === 401 || status === 403) throw { code: "key_rejected", retry: false };
129
+ if (status === 408) throw { code: "timeout", retry: true, after };
130
+ if (status === 429) throw { code: "rate_limited", retry: true, after };
131
+ if (status === 413) throw { code: "prompt_too_large", retry: false };
132
+ if (status >= 500) throw { code: "upstream_error", retry: true, after };
133
+ throw { code: "bad_request", retry: false }; // other 4xx: the request itself is wrong (alias unknown to the proxy, schema): repeating it changes nothing
134
+ }
135
+ const costUsd = parseCost(res.headers);
136
+ if (!res.body || typeof res.body.getReader !== "function") throw { code: "bad_response", retry: false };
137
+ try {
138
+ const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : o.parseJson ? await o.parseJson(res.body, config.maxResponseBytes) : await readJson(res.body, config.maxResponseBytes);
139
+ return { ...parsed, costUsd };
140
+ } catch (e) {
141
+ if (e && e.code && !e.name) throw e; // our own { code, retry }
142
+ throw lift(e);
143
+ }
144
+ } finally {
145
+ clearTimeout(timer);
146
+ o.signal?.removeEventListener("abort", onExternal);
147
+ }
148
+ }
149
+
150
+ async function readBounded(body, maxBytes) {
151
+ const reader = body.getReader();
152
+ const chunks = [];
153
+ let total = 0;
154
+ for (;;) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+ total += value.byteLength;
158
+ if (total > maxBytes) { try { await reader.cancel(); } catch { /* gone */ } throw { code: "response_too_large", retry: false }; }
159
+ chunks.push(value);
160
+ }
161
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString("utf8");
162
+ }
163
+
164
+ async function readJson(body, maxBytes) {
165
+ const text = await readBounded(body, maxBytes);
166
+ let j;
167
+ try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
168
+ const choice = j && Array.isArray(j.choices) ? j.choices[0] : null;
169
+ const msg = choice && choice.message;
170
+ if (!choice || !msg || (msg.content !== null && msg.content !== undefined && typeof msg.content !== "string")) throw { code: "bad_response", retry: false };
171
+ 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 };
172
+ }
173
+
174
+ async function readStream(body, o, rearm, ctx) {
175
+ const reader = body.getReader();
176
+ const dec = new TextDecoder("utf-8");
177
+ let buf = "";
178
+ let text = "";
179
+ let textBytes = 0;
180
+ let usage = null;
181
+ let model = null;
182
+ let finish = null;
183
+ let done = false;
184
+ const handleData = (data) => {
185
+ if (data === "[DONE]") { done = true; return; }
186
+ let j;
187
+ try { j = JSON.parse(data); } catch { throw { code: "bad_response", retry: false }; }
188
+ if (j && j.error) throw { code: "upstream_error", retry: false };
189
+ if (j && j.usage) usage = usageOf(j.usage);
190
+ if (j && typeof j.model === "string") model = modelOf(j.model);
191
+ const c = j && Array.isArray(j.choices) ? j.choices[0] : null;
192
+ if (!c) return;
193
+ if (typeof c.finish_reason === "string") finish = c.finish_reason.slice(0, 32);
194
+ const d = c.delta && typeof c.delta.content === "string" ? c.delta.content : "";
195
+ if (d) {
196
+ textBytes += byteLength(d);
197
+ if (textBytes > config.maxResponseBytes) throw { code: "response_too_large", retry: false };
198
+ text += d;
199
+ ctx.delivered = true;
200
+ if (typeof o.onDelta === "function") { try { o.onDelta(d); } catch { /* a consumer's bug never breaks the call */ } }
201
+ }
202
+ };
203
+ const handleLine = (line) => { if (line.startsWith("data:")) handleData(line.slice(5).trim()); };
204
+ rearm(config.streamIdleMs);
205
+ while (!done) {
206
+ const { done: eof, value } = await reader.read();
207
+ if (eof) break;
208
+ rearm(config.streamIdleMs); // silence, not total time, is what the idle timeout measures (the total deadline still caps it)
209
+ buf += dec.decode(value, { stream: true });
210
+ let nl;
211
+ while ((nl = buf.search(/\r?\n/)) >= 0) {
212
+ const line = buf.slice(0, nl);
213
+ buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
214
+ handleLine(line);
215
+ if (done) break;
216
+ }
217
+ if (buf.length > MAX_SSE_LINE_BYTES) throw { code: "bad_response", retry: false };
218
+ }
219
+ if (!done && buf.trim()) handleLine(buf.trim());
220
+ try { await reader.cancel(); } catch { /* closed */ }
221
+ if (!done && !finish && !text && !usage) throw { code: "bad_response", retry: false };
222
+ return { text, usage, model, finishReason: finish };
223
+ }
224
+
225
+ function usageOf(u) {
226
+ if (!u || typeof u !== "object") return null;
227
+ const p = Number(u.prompt_tokens);
228
+ const c = Number(u.completion_tokens);
229
+ return Number.isSafeInteger(p) && p >= 0 && Number.isSafeInteger(c) && c >= 0 ? { promptTokens: p, completionTokens: c, estimated: false } : null;
230
+ }
231
+ const modelOf = (m) => (typeof m === "string" && MODEL.test(m) ? m : null);
232
+
233
+ /**
234
+ * @param {object} o
235
+ * @param {string} o.model an alias of the key (`link.llm.holder.describe().models`)
236
+ * @param {{role:"system"|"user"|"assistant",content:string}[]} o.messages
237
+ * @param {number} [o.maxTokens] capped by the config and by what is left of the budget
238
+ * @param {number} [o.temperature]
239
+ * @param {boolean} [o.stream] @param {(delta:string)=>void} [o.onDelta]
240
+ * @param {AbortSignal} [o.signal] cancellation (job.cancel, deadline)
241
+ * @param {{jobId?:string, agent?:string}} [o.job] tags only
242
+ * @param {number} [o.tokensLeft] what is left of the job budget (prompt estimate + max_tokens must fit)
243
+ * @returns {Promise<{text:string, usage:{promptTokens:number,completionTokens:number,estimated:boolean}, costUsd:number|null, model:string, finishReason:string|null, attempts:number}>}
244
+ */
245
+ async function chat(o = {}) {
246
+ stats.calls++;
247
+ 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 };
248
+ // 1. shape
249
+ const msgs = o.messages;
250
+ if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxMessages
251
+ || !msgs.every((m) => m && typeof m === "object" && ROLES.has(m.role) && typeof m.content === "string")) return fail("bad_request", ctx);
252
+ if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 2)) return fail("bad_request", ctx);
253
+ if (o.signal?.aborted) return fail("cancelled", ctx);
254
+ // 2. DLP enforce, per call
255
+ if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
256
+ // 3. key and alias
257
+ if (!holder.held) return fail("no_key", ctx);
258
+ const d = holder.describe();
259
+ if (!d.held) return fail("key_expired", ctx);
260
+ if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
261
+ // 4. mask first, then size and budget
262
+ let masked;
263
+ try {
264
+ const r = maskMessages(msgs, ctx);
265
+ masked = r.masked; ctx.promptBytes = r.bytes;
266
+ } catch (code) {
267
+ return fail(typeof code === "string" ? code : "dlp_denied", ctx);
268
+ }
269
+ let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
270
+ if (o.tokensLeft !== undefined) {
271
+ const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
272
+ if (!(room >= 1)) return fail("budget_exceeded", ctx);
273
+ maxTokens = Math.min(maxTokens, room);
274
+ }
275
+ // 5. slot and the call
276
+ return dispatch(o, ctx, {}, (tags) => JSON.stringify({
277
+ model: o.model, messages: masked, max_tokens: maxTokens, stream: !!o.stream,
278
+ ...(o.stream ? { stream_options: { include_usage: true } } : {}),
279
+ ...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
280
+ ...(tags ? { metadata: tags } : {}),
281
+ }));
282
+ }
283
+
284
+ /** The slot, the HTTP call with its retries and the bookkeeping, shared by chat() and messages(). `hooks` = { path, parseJson } of the API in use. */
285
+ async function dispatch(o, ctx, hooks, makeBody) {
286
+ if (active >= config.maxConcurrent) return fail("busy", ctx);
287
+ active++;
288
+ try {
289
+ 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;
290
+ const bodyText = makeBody(tags);
291
+ stats.bytesOut += byteLength(bodyText);
292
+ let attempts = 0;
293
+ for (;;) {
294
+ attempts++;
295
+ try {
296
+ const r = await attempt(bodyText, { ...o, ...hooks }, ctx);
297
+ const usage = r.usage || { promptTokens: estimateTokens(ctx.promptBytes), completionTokens: estimateTokens(byteLength(r.text)), estimated: true };
298
+ stats.ok++; stats.tokensIn += usage.promptTokens; stats.tokensOut += usage.completionTokens; stats.bytesIn += byteLength(r.text);
299
+ 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, ...(hooks.api ? { api: hooks.api } : {}) });
300
+ return { text: r.text, usage, costUsd: r.costUsd, model: r.model || o.model, finishReason: r.finishReason, attempts, ...(r.message ? { message: r.message } : {}) };
301
+ } catch (e) {
302
+ const code = e && typeof e.code === "string" && LLM_ERROR_CODES.includes(e.code) ? e.code : "internal";
303
+ const canRetry = e && e.retry === true && !ctx.delivered && attempts <= config.maxRetries && !o.signal?.aborted;
304
+ if (!canRetry) return fail(code, ctx, attempts);
305
+ const wait = Math.min(e.after ?? config.backoffMs * 2 ** (attempts - 1), config.maxBackoffMs);
306
+ if (now() + wait >= ctx.deadline) return fail(code, ctx, attempts);
307
+ stats.retries++;
308
+ await sleep(wait, o.signal);
309
+ if (o.signal?.aborted) return fail("cancelled", ctx, attempts);
310
+ }
311
+ }
312
+ } finally {
313
+ active--;
314
+ }
315
+ }
316
+
317
+ // ------------------------------------------------------------------------------------------------ Anthropic Messages API (F3-08c)
318
+
319
+ /** Masks one string and adds its size; refuses (throws a code) like maskMessages. */
320
+ function maskText(text, ctx, acc) {
321
+ const res = protectEgress(text, { env, source: "llm.client" });
322
+ addCounts(ctx.redactions, res.redactions);
323
+ if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
324
+ if (res.blocked) throw "dlp_blocked";
325
+ acc.bytes += byteLength(res.text);
326
+ if (acc.bytes > config.maxPromptBytes) throw "prompt_too_large";
327
+ return res.text;
328
+ }
329
+
330
+ /** Masks every string leaf of a JSON value (tool input), bounded in depth and size. */
331
+ function maskJson(v, ctx, acc, depth = 0) {
332
+ if (depth > 8 || ++acc.nodes > 20_000) throw "bad_request";
333
+ if (typeof v === "string") return maskText(v, ctx, acc);
334
+ if (v === null || typeof v === "number" || typeof v === "boolean") return v;
335
+ if (Array.isArray(v)) return v.map((x) => maskJson(x, ctx, acc, depth + 1));
336
+ if (typeof v === "object") { const out = {}; for (const [k, x] of Object.entries(v)) { if (k === "__proto__" || k.length > 200) throw "bad_request"; out[k] = maskJson(x, ctx, acc, depth + 1); } return out; }
337
+ throw "bad_request";
338
+ }
339
+
340
+ const cacheOf = (b) => (b.cache_control && typeof b.cache_control === "object" && b.cache_control.type === "ephemeral" ? { cache_control: { type: "ephemeral" } } : {});
341
+
342
+ /** A block of the Messages API, rebuilt field by field: a type or a field this agent does not know is refused, never forwarded. */
343
+ function maskBlock(b, ctx, acc, { allowTools }) {
344
+ if (!b || typeof b !== "object" || Array.isArray(b)) throw "bad_request";
345
+ if (b.type === "text" && typeof b.text === "string") return { type: "text", text: maskText(b.text, ctx, acc), ...cacheOf(b) };
346
+ if (allowTools && b.type === "tool_use" && TOOL_ID.test(String(b.id)) && TOOL_NAME.test(String(b.name)) && b.input && typeof b.input === "object" && !Array.isArray(b.input)) return { type: "tool_use", id: b.id, name: b.name, input: maskJson(b.input, ctx, acc) };
347
+ if (allowTools && b.type === "tool_result" && TOOL_ID.test(String(b.tool_use_id))) {
348
+ const content = typeof b.content === "string" ? maskText(b.content, ctx, acc)
349
+ : Array.isArray(b.content) ? b.content.map((x) => maskBlock(x, ctx, acc, { allowTools: false })) : b.content === undefined ? "" : (() => { throw "bad_request"; })();
350
+ return { type: "tool_result", tool_use_id: b.tool_use_id, content, ...(b.is_error === true ? { is_error: true } : {}) };
351
+ }
352
+ throw "bad_request";
353
+ }
354
+
355
+ function maskTools(tools, ctx, acc) {
356
+ if (!Array.isArray(tools) || tools.length > 64) throw "bad_request";
357
+ return tools.map((t) => {
358
+ if (!t || typeof t !== "object" || !TOOL_NAME.test(String(t.name)) || !t.input_schema || typeof t.input_schema !== "object" || Array.isArray(t.input_schema)) throw "bad_request";
359
+ if (t.description !== undefined && (typeof t.description !== "string" || byteLength(t.description) > 16 * 1024)) throw "bad_request";
360
+ if (byteLength(JSON.stringify(t.input_schema)) > 64 * 1024) throw "bad_request";
361
+ return { name: t.name, ...(t.description !== undefined ? { description: maskText(t.description, ctx, acc) } : {}), input_schema: maskJson(t.input_schema, ctx, acc), ...cacheOf(t) };
362
+ });
363
+ }
364
+
365
+ async function parseMessagesJson(body, maxBytes) {
366
+ const text = await readBounded(body, maxBytes);
367
+ let j;
368
+ try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
369
+ if (!j || typeof j !== "object" || !Array.isArray(j.content) || j.content.some((b) => !b || typeof b !== "object" || typeof b.type !== "string")) throw { code: "bad_response", retry: false };
370
+ const u = j.usage && typeof j.usage === "object" ? j.usage : null;
371
+ const n = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : 0);
372
+ const input = u ? n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) : null;
373
+ const usage = u && Number.isSafeInteger(u.input_tokens) && Number.isSafeInteger(u.output_tokens) ? { promptTokens: input, completionTokens: n(u.output_tokens), estimated: false } : null;
374
+ const stop = typeof j.stop_reason === "string" ? j.stop_reason.slice(0, 32) : null;
375
+ const message = { id: typeof j.id === "string" ? j.id.slice(0, 128) : "msg_la", type: "message", role: "assistant", content: j.content, stop_reason: stop, stop_sequence: null,
376
+ usage: u ? { input_tokens: n(u.input_tokens), output_tokens: n(u.output_tokens), ...(Number.isSafeInteger(u.cache_creation_input_tokens) ? { cache_creation_input_tokens: u.cache_creation_input_tokens } : {}), ...(Number.isSafeInteger(u.cache_read_input_tokens) ? { cache_read_input_tokens: u.cache_read_input_tokens } : {}) } : { input_tokens: 0, output_tokens: 0 } };
377
+ return { text: j.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""), usage, model: modelOf(j.model), finishReason: stop, message };
378
+ }
379
+
380
+ /**
381
+ * F3-08c: one turn of the Anthropic Messages API (`POST <endpoint>/v1/messages`, what the packaged agent loop speaks, with tools), through the SAME gates
382
+ * as chat(): DLP enforce, key and alias, EVERY string the model will read masked (system, text, tool_use input, tool_result, tool descriptions), size and
383
+ * token budget, concurrency slot, timeouts and retries, codes-only errors. Not streaming. Blocks and fields outside the known set are refused.
384
+ * @param {object} o model (alias), system (string | text blocks), messages [{role:user|assistant, content:string|blocks}], tools?, toolChoice?, maxTokens?, temperature?, signal?, job?, tokensLeft?
385
+ * @returns {Promise<{text:string, message:object, usage, costUsd, model, finishReason, attempts}>} `message` = the response in the Messages API shape (local use only)
386
+ */
387
+ async function messages(o = {}) {
388
+ stats.calls++;
389
+ 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: false, delivered: false, deadline: now() + config.totalMs };
390
+ const msgs = o.messages;
391
+ if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxApiMessages
392
+ || !msgs.every((m) => m && typeof m === "object" && (m.role === "user" || m.role === "assistant") && (typeof m.content === "string" || (Array.isArray(m.content) && m.content.length >= 1 && m.content.length <= 256)))) return fail("bad_request", ctx);
393
+ if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 1)) return fail("bad_request", ctx);
394
+ if (o.system !== undefined && typeof o.system !== "string" && !(Array.isArray(o.system) && o.system.length <= 64)) return fail("bad_request", ctx);
395
+ if (o.toolChoice !== undefined && !(o.toolChoice && ((["auto", "any", "none"].includes(o.toolChoice.type)) || (o.toolChoice.type === "tool" && TOOL_NAME.test(String(o.toolChoice.name)))))) return fail("bad_request", ctx);
396
+ if (o.signal?.aborted) return fail("cancelled", ctx);
397
+ if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
398
+ if (!holder.held) return fail("no_key", ctx);
399
+ const d = holder.describe();
400
+ if (!d.held) return fail("key_expired", ctx);
401
+ if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
402
+ const acc = { bytes: 0, nodes: 0 };
403
+ let system;
404
+ let masked;
405
+ let tools;
406
+ try {
407
+ if (typeof o.system === "string") system = maskText(o.system, ctx, acc);
408
+ else if (Array.isArray(o.system)) system = o.system.map((b) => maskBlock(b, ctx, acc, { allowTools: false }));
409
+ masked = msgs.map((m) => ({ role: m.role, content: typeof m.content === "string" ? maskText(m.content, ctx, acc) : m.content.map((b) => maskBlock(b, ctx, acc, { allowTools: true })) }));
410
+ if (o.tools !== undefined) tools = maskTools(o.tools, ctx, acc);
411
+ } catch (code) {
412
+ return fail(typeof code === "string" ? code : "bad_request", ctx);
413
+ }
414
+ ctx.promptBytes = acc.bytes;
415
+ let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
416
+ if (o.tokensLeft !== undefined) {
417
+ const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
418
+ if (!(room >= 1)) return fail("budget_exceeded", ctx);
419
+ maxTokens = Math.min(maxTokens, room);
420
+ }
421
+ return dispatch(o, ctx, { path: config.messagesPath, parseJson: parseMessagesJson, api: "messages" }, (tags) => JSON.stringify({
422
+ model: o.model, max_tokens: maxTokens, messages: masked,
423
+ ...(system !== undefined ? { system } : {}),
424
+ ...(tools ? { tools } : {}),
425
+ ...(o.toolChoice ? { tool_choice: o.toolChoice } : {}),
426
+ ...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
427
+ ...(tags ? { metadata: tags } : {}),
428
+ }));
429
+ }
430
+
431
+ return { chat, messages, stats, audit: auditor, get active() { return active; } };
432
+ }
@@ -0,0 +1,63 @@
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
+ messagesPath: PATH.test(String(env.LOCAL_AGENT_LLM_MESSAGES_PATH ?? "").trim()) ? String(env.LOCAL_AGENT_LLM_MESSAGES_PATH).trim() : "/v1/messages", // F3-08c: Anthropic Messages API (the packaged agent loop); set /messages when the endpoint of the key already ends in /v1
48
+ maxApiMessages: int(env.LOCAL_AGENT_LLM_MAX_API_MESSAGES, 256, 1, 1024), // messages per turn of the Messages API (an agent loop re-sends its whole history)
49
+ timeoutMs: int(env.LOCAL_AGENT_LLM_TIMEOUT_MS, 60_000, 1_000, 300_000), // one attempt: headers + body (streaming: until the headers)
50
+ streamIdleMs: int(env.LOCAL_AGENT_LLM_STREAM_IDLE_MS, 30_000, 1_000, 120_000), // streaming: silence between two chunks
51
+ totalMs: int(env.LOCAL_AGENT_LLM_TOTAL_MS, 180_000, 1_000, 900_000), // the whole call, retries included
52
+ maxRetries: int(env.LOCAL_AGENT_LLM_MAX_RETRIES, 2, 0, 5),
53
+ backoffMs: int(env.LOCAL_AGENT_LLM_BACKOFF_MS, 500, 0, 10_000),
54
+ maxBackoffMs: 10_000,
55
+ maxPromptBytes: int(env.LOCAL_AGENT_LLM_MAX_PROMPT_BYTES, 256 * KIB, KIB, 2 * MIB), // all messages, after masking
56
+ maxOutputTokens: int(env.LOCAL_AGENT_LLM_MAX_OUTPUT_TOKENS, 4_096, 1, 32_768),
57
+ maxResponseBytes: int(env.LOCAL_AGENT_LLM_MAX_RESPONSE_BYTES, 1 * MIB, 4 * KIB, 8 * MIB),
58
+ maxMessages: 64,
59
+ maxConcurrent: int(env.LOCAL_AGENT_LLM_MAX_CONCURRENT, 4, 1, 16),
60
+ tags: bool(env.LOCAL_AGENT_LLM_TAGS, true), // request metadata.tags (tenant, agent, job); ids only, never content
61
+ auditFile: String(env.LOCAL_AGENT_LLM_AUDIT_FILE ?? "").trim() || null,
62
+ };
63
+ }
@@ -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(); },