deepdebug-local-agent 1.10.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +7 -1
- package/docs/la-protocol-v2.md +4 -2
- package/env.example +6 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
- package/src/la-protocol-v2/contract/README.md +51 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/install.js +9 -3
- package/src/la-protocol-v2/jobs/manager.js +17 -12
- package/src/la-protocol-v2/llm/client.js +133 -12
- package/src/la-protocol-v2/llm/config.js +2 -0
- package/src/la-protocol-v2/runtime.js +36 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supervisor.js - keeps agent2 (the packaged agent loop, profile local-agent) available for the runner (F3-08c, Local Agent half).
|
|
3
|
+
*
|
|
4
|
+
* spawn (LOCAL_AGENT_AGENT2_CMD) this agent starts the process, with a minimal environment and a NEW random API token at every start; waits for
|
|
5
|
+
* `/local/v1/health`; probes it while it runs; restarts it (at the next job) after a crash, at most `maxRestarts`
|
|
6
|
+
* times inside `restartWindowMs`, then reports `unavailable` instead of looping; kills it above `maxRssBytes`;
|
|
7
|
+
* stops it with the agent (SIGTERM, then SIGKILL after `stopGraceMs`).
|
|
8
|
+
* attach (LOCAL_AGENT_AGENT2_URL) agent2 already runs (sidecar); only health is checked. The token is the one it was started with.
|
|
9
|
+
*
|
|
10
|
+
* The environment of the child is an ALLOWLIST (PATH, HOME, JAVA_HOME, locale, proxies, the git/ssh variables the machine's own credentials need) plus
|
|
11
|
+
* what this module sets and what the operator named in LOCAL_AGENT_AGENT2_ENV_PASSTHROUGH (never a name that looks like a secret). Nothing else of this
|
|
12
|
+
* agent's environment reaches agent2: not the LLM key (it lives in the holder, in memory), not LOCAL_AGENT_TOKEN's siblings, not the signing keys.
|
|
13
|
+
* stdout/stderr of the child are drained and DROPPED: they may hold customer content; agent2's own logging is configured in agent2.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn as nodeSpawn, execFile } from "node:child_process";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { createAgent2Api } from "./api-client.js";
|
|
22
|
+
import { isSecretEnvName } from "../../../utils/child-env.js";
|
|
23
|
+
|
|
24
|
+
const ALLOW = Object.freeze(["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TMP", "TEMP", "JAVA_HOME",
|
|
25
|
+
"SSH_AUTH_SOCK", "GIT_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_TERMINAL_PROMPT",
|
|
26
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "SYSTEMROOT", "PATHEXT", "COMSPEC"]);
|
|
27
|
+
|
|
28
|
+
export class Agent2UnavailableError extends Error {
|
|
29
|
+
constructor(code) { super(code); this.name = "Agent2UnavailableError"; this.code = code; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultSleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });
|
|
33
|
+
const defaultRss = (pid) => new Promise((resolve) => {
|
|
34
|
+
execFile("ps", ["-o", "rss=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
|
|
35
|
+
const kb = err ? 0 : Number.parseInt(String(out).trim(), 10);
|
|
36
|
+
resolve(Number.isFinite(kb) && kb > 0 ? kb * 1024 : 0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The full command line of a process, or null when it cannot be told (no `ps`, no such process). */
|
|
41
|
+
const defaultCmdline = (pid) => new Promise((resolve) => {
|
|
42
|
+
execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], { timeout: 3_000 }, (err, out) => {
|
|
43
|
+
const t = err ? "" : String(out).trim();
|
|
44
|
+
resolve(t || null);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const isAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return !!e && e.code === "EPERM"; } };
|
|
49
|
+
|
|
50
|
+
// Children still running when THIS process exits normally (SIGTERM/SIGINT handlers, process.exit): ended synchronously, best effort. A SIGKILL of this agent skips
|
|
51
|
+
// 'exit' handlers: that case is the pidfile's (the next start ends the orphan).
|
|
52
|
+
const liveChildren = new Set();
|
|
53
|
+
let exitHooked = false;
|
|
54
|
+
function hookExit() {
|
|
55
|
+
if (exitHooked) return;
|
|
56
|
+
exitHooked = true;
|
|
57
|
+
process.once("exit", () => { for (const c of liveChildren) { try { c.kill("SIGKILL"); } catch { /* gone */ } } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The environment agent2 starts with (pure, so a test can pin it). */
|
|
61
|
+
export function agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken }) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const k of ALLOW) if (env[k] !== undefined) out[k] = env[k];
|
|
64
|
+
for (const k of config.passthrough || []) if (env[k] !== undefined && !isSecretEnvName(k)) out[k] = env[k];
|
|
65
|
+
Object.assign(out, {
|
|
66
|
+
AGENT2_LOCAL_MODE: "true",
|
|
67
|
+
AGENT2_LOCAL_BIND: "127.0.0.1",
|
|
68
|
+
AGENT2_LOCAL_PORT: String(config.apiPort),
|
|
69
|
+
AGENT2_LOCAL_API_TOKEN: token,
|
|
70
|
+
AGENT2_LOCAL_TENANT_ID: tenantId,
|
|
71
|
+
AGENT2_LLM_BASE_URL: `${bridgeBase}/v1`, // the loopback bridge: DLP + the LLM client of this agent, never the AI gateway directly
|
|
72
|
+
// agent2 reaches this agent's /workspace API THROUGH the bridge, which signs for it (internal marker) and adds the API's X-Agent-Token itself: the token agent2 holds
|
|
73
|
+
// here is the bridge's callback token, NOT LOCAL_AGENT_TOKEN, and it opens nothing but the bridge
|
|
74
|
+
AGENT2_LA_WORKSPACE_URL: bridgeBase,
|
|
75
|
+
AGENT2_LA_WORKSPACE_TOKEN: callbackToken,
|
|
76
|
+
AGENT2_LA_CALLBACK_URL: bridgeBase, // POST {url}/agent2/cp-request: the PolicyGate door (-> policy.check over the v2 channel)
|
|
77
|
+
AGENT2_LA_CALLBACK_TOKEN: callbackToken,
|
|
78
|
+
DEEPDEBUG_POLICYGATE_TIMEOUT_MS: String(config.policyCheckTimeoutMs + 3_000), // agent2 must wait longer than the channel does (default 3 s would cut a decision off)
|
|
79
|
+
});
|
|
80
|
+
if (config.maxHeapMb > 0) out.JAVA_TOOL_OPTIONS = `-Xmx${config.maxHeapMb}m`;
|
|
81
|
+
if (config.approvalWaitSeconds > 0) out.AGENT2_LOCAL_APPROVAL_WAIT_SECONDS = String(config.approvalWaitSeconds);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAgent2Supervisor({ config, tenantId, bridge, env = process.env, log = console, spawnImpl = nodeSpawn, fetchImpl, now = Date.now, sleep = defaultSleep, rssOf = defaultRss, cmdlineOf = defaultCmdline }) {
|
|
86
|
+
const emitter = new EventEmitter();
|
|
87
|
+
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
88
|
+
const info = (m) => (log.info || log.log).call(log, m);
|
|
89
|
+
const stats = { starts: 0, exits: 0, healthFailed: 0, killedForRss: 0, startFailures: 0, refusedRestartLimit: 0, orphansEnded: 0, orphanChecksSkipped: 0 };
|
|
90
|
+
const cmdSha = config.mode === "spawn" ? crypto.createHash("sha256").update(JSON.stringify(config.cmd)).digest("hex") : "";
|
|
91
|
+
const pidFile = config.mode === "spawn" ? (config.pidFile || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".deepdebug", "agent2-runner.pid")) : null;
|
|
92
|
+
const startTimes = [];
|
|
93
|
+
let state = "idle"; // idle | starting | ready | down | stopped
|
|
94
|
+
let child = null;
|
|
95
|
+
let target = config.mode === "attach" ? { baseUrl: config.url, token: config.token } : null;
|
|
96
|
+
let starting = null;
|
|
97
|
+
let healthTimer = null;
|
|
98
|
+
let healthFails = 0;
|
|
99
|
+
let lastExitAt = 0;
|
|
100
|
+
let stopping = false;
|
|
101
|
+
|
|
102
|
+
// ---- the pidfile: a child that outlived this agent (SIGKILL, crash) would keep the API port and an old token; the next start ends it
|
|
103
|
+
function writePidFile(pid) {
|
|
104
|
+
if (!pidFile || !pid) return;
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true, mode: 0o700 });
|
|
107
|
+
fs.writeFileSync(pidFile, JSON.stringify({ pid, cmdSha, startedAt: now() }), { mode: 0o600 });
|
|
108
|
+
} catch { warn("[la-agent2] could not write the pidfile of agent2; a restart of this agent will not be able to end an orphan"); }
|
|
109
|
+
}
|
|
110
|
+
function removePidFile(onlyPid) {
|
|
111
|
+
if (!pidFile) return;
|
|
112
|
+
try {
|
|
113
|
+
if (onlyPid) { const cur = JSON.parse(fs.readFileSync(pidFile, "utf8")); if (cur && cur.pid !== onlyPid) return; }
|
|
114
|
+
fs.rmSync(pidFile, { force: true });
|
|
115
|
+
} catch { /* nothing to remove */ }
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* An agent2 left by a previous run of this agent: ended (SIGTERM, then SIGKILL after the grace) only when the pid is alive AND its command line is the command
|
|
119
|
+
* this agent starts (a reused pid of another program is never touched). Where the command line cannot be read (no `ps`) nothing is killed.
|
|
120
|
+
*/
|
|
121
|
+
async function reapOrphan() {
|
|
122
|
+
if (!pidFile) return;
|
|
123
|
+
let rec;
|
|
124
|
+
try { rec = JSON.parse(fs.readFileSync(pidFile, "utf8")); } catch { return; }
|
|
125
|
+
const pid = rec && Number.isSafeInteger(rec.pid) && rec.pid > 1 ? rec.pid : 0;
|
|
126
|
+
if (!pid || pid === process.pid || !isAlive(pid)) { removePidFile(); return; }
|
|
127
|
+
const line = await cmdlineOf(pid);
|
|
128
|
+
if (line === null) { stats.orphanChecksSkipped++; warn("[la-agent2] the pidfile names a live process but its command line cannot be read; it is left alone"); return; }
|
|
129
|
+
if (rec.cmdSha !== cmdSha || !config.cmd.slice(0, 8).every((a) => line.includes(a))) { removePidFile(); return; } // not ours (any more): the pid was reused
|
|
130
|
+
warn("[la-agent2] an agent2 from a previous run is still alive; ending it");
|
|
131
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
|
|
132
|
+
const deadline = now() + config.stopGraceMs;
|
|
133
|
+
while (isAlive(pid) && now() < deadline) await sleep(50);
|
|
134
|
+
if (isAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } await sleep(100); }
|
|
135
|
+
if (isAlive(pid)) throw new Agent2UnavailableError("orphan_alive");
|
|
136
|
+
stats.orphansEnded++;
|
|
137
|
+
removePidFile();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const api = createAgent2Api({ getTarget: () => { if (!target) throw new Agent2UnavailableError("agent2_down"); return target; }, requestTimeoutMs: config.requestTimeoutMs, ...(fetchImpl ? { fetchImpl } : {}) });
|
|
141
|
+
|
|
142
|
+
function markDown(why) {
|
|
143
|
+
if (state === "down" || state === "stopped") return;
|
|
144
|
+
state = "down";
|
|
145
|
+
if (config.mode === "spawn") target = null;
|
|
146
|
+
emitter.emit("down", { why });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startHealthTimer() {
|
|
150
|
+
clearInterval(healthTimer);
|
|
151
|
+
healthFails = 0;
|
|
152
|
+
healthTimer = setInterval(async () => {
|
|
153
|
+
if (state !== "ready") return;
|
|
154
|
+
if (await api.health()) healthFails = 0;
|
|
155
|
+
else if (++healthFails >= config.healthFailures) {
|
|
156
|
+
stats.healthFailed++;
|
|
157
|
+
warn("[la-agent2] agent2 stopped answering its health probe");
|
|
158
|
+
if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
|
|
159
|
+
markDown("health");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (config.mode === "spawn" && config.maxRssBytes > 0 && child && child.pid) {
|
|
163
|
+
const rss = await rssOf(child.pid);
|
|
164
|
+
if (rss > config.maxRssBytes) { stats.killedForRss++; warn("[la-agent2] agent2 is above LOCAL_AGENT_AGENT2_MAX_RSS_BYTES; stopping it (a new one starts at the next job)"); try { child.kill("SIGKILL"); } catch { /* gone */ } markDown("rss"); }
|
|
165
|
+
}
|
|
166
|
+
}, config.healthIntervalMs);
|
|
167
|
+
if (healthTimer.unref) healthTimer.unref();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function waitHealthy(proc) {
|
|
171
|
+
const deadline = now() + config.startTimeoutMs;
|
|
172
|
+
for (;;) {
|
|
173
|
+
if (stopping) throw new Agent2UnavailableError("stopped");
|
|
174
|
+
if (proc && proc.exited) throw new Agent2UnavailableError("exited_during_start");
|
|
175
|
+
if (await api.health()) return;
|
|
176
|
+
if (now() >= deadline) throw new Agent2UnavailableError("start_timeout");
|
|
177
|
+
await sleep(Math.min(250, Math.max(10, config.startTimeoutMs / 20)));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function startAttach() {
|
|
182
|
+
state = "starting";
|
|
183
|
+
try { await waitHealthy(null); } catch (e) { state = "down"; stats.startFailures++; throw e; }
|
|
184
|
+
state = "ready";
|
|
185
|
+
startHealthTimer();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function startSpawn() {
|
|
189
|
+
const t = now();
|
|
190
|
+
while (startTimes.length && t - startTimes[0] > config.restartWindowMs) startTimes.shift();
|
|
191
|
+
if (startTimes.length >= config.maxRestarts + 1) { stats.refusedRestartLimit++; throw new Agent2UnavailableError("restart_limit"); }
|
|
192
|
+
if (startTimes.length > 0) { const wait = Math.max(0, lastExitAt + config.restartBackoffMs * startTimes.length - t); if (wait > 0) await sleep(wait); }
|
|
193
|
+
startTimes.push(now());
|
|
194
|
+
state = "starting";
|
|
195
|
+
stats.starts++;
|
|
196
|
+
try { await reapOrphan(); } catch (e) { state = "down"; stats.startFailures++; throw e; }
|
|
197
|
+
const token = crypto.randomBytes(32).toString("base64url"); // a NEW secret per start: nothing from a previous process works
|
|
198
|
+
const callbackToken = crypto.randomBytes(32).toString("base64url");
|
|
199
|
+
const { baseUrl: bridgeBase } = await bridge.listen();
|
|
200
|
+
bridge.setCallbackToken(callbackToken);
|
|
201
|
+
const childEnv = agent2ChildEnv({ config, env, tenantId, token, bridgeBase, callbackToken });
|
|
202
|
+
const proc = { exited: false };
|
|
203
|
+
let c;
|
|
204
|
+
try {
|
|
205
|
+
c = spawnImpl(config.cmd[0], config.cmd.slice(1), { env: childEnv, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
|
|
206
|
+
} catch { state = "down"; stats.startFailures++; throw new Agent2UnavailableError("spawn_failed"); }
|
|
207
|
+
child = c;
|
|
208
|
+
liveChildren.add(c); hookExit();
|
|
209
|
+
writePidFile(c.pid);
|
|
210
|
+
for (const s of [c.stdout, c.stderr]) { if (s && typeof s.on === "function") { s.on("data", () => {}); s.on("error", () => {}); } } // drained and dropped
|
|
211
|
+
c.on("error", () => { proc.exited = true; liveChildren.delete(c); });
|
|
212
|
+
c.on("exit", (code, signal) => {
|
|
213
|
+
proc.exited = true;
|
|
214
|
+
stats.exits++;
|
|
215
|
+
lastExitAt = now();
|
|
216
|
+
liveChildren.delete(c);
|
|
217
|
+
removePidFile(c.pid);
|
|
218
|
+
if (child === c) child = null;
|
|
219
|
+
if (!stopping) warn(`[la-agent2] agent2 exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
|
|
220
|
+
markDown("exit");
|
|
221
|
+
});
|
|
222
|
+
info(`[la-agent2] started agent2 (pid ${c.pid ?? "?"})`);
|
|
223
|
+
target = { baseUrl: `http://127.0.0.1:${config.apiPort}`, token };
|
|
224
|
+
try { await waitHealthy(proc); } catch (e) {
|
|
225
|
+
stats.startFailures++;
|
|
226
|
+
try { c.kill("SIGKILL"); } catch { /* gone */ }
|
|
227
|
+
state = "down"; target = null;
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
if (proc.exited) { state = "down"; target = null; throw new Agent2UnavailableError("exited_during_start"); }
|
|
231
|
+
state = "ready";
|
|
232
|
+
startHealthTimer();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
api, stats,
|
|
237
|
+
on: (ev, fn) => emitter.on(ev, fn),
|
|
238
|
+
off: (ev, fn) => emitter.off(ev, fn),
|
|
239
|
+
get state() { return state; },
|
|
240
|
+
get pid() { return child ? child.pid : null; },
|
|
241
|
+
/** Resolves when agent2 answers its health probe (starting it first in spawn mode). Throws Agent2UnavailableError(code). */
|
|
242
|
+
async ensureReady() {
|
|
243
|
+
if (stopping) throw new Agent2UnavailableError("stopped");
|
|
244
|
+
if (state === "ready") {
|
|
245
|
+
if (await api.health()) return;
|
|
246
|
+
if (child) { try { child.kill("SIGKILL"); } catch { /* gone */ } }
|
|
247
|
+
markDown("health");
|
|
248
|
+
}
|
|
249
|
+
if (!starting) starting = (config.mode === "attach" ? startAttach() : startSpawn()).finally(() => { starting = null; });
|
|
250
|
+
await starting;
|
|
251
|
+
},
|
|
252
|
+
/** Stops the child (SIGTERM, then SIGKILL after the grace) and every timer. */
|
|
253
|
+
async stop() {
|
|
254
|
+
stopping = true;
|
|
255
|
+
state = "stopped";
|
|
256
|
+
clearInterval(healthTimer);
|
|
257
|
+
const c = child;
|
|
258
|
+
child = null;
|
|
259
|
+
if (!c) return;
|
|
260
|
+
await new Promise((resolve) => {
|
|
261
|
+
let done = false;
|
|
262
|
+
const fin = () => { if (!done) { done = true; clearTimeout(t); resolve(); } };
|
|
263
|
+
const t = setTimeout(() => { try { c.kill("SIGKILL"); } catch { /* gone */ } fin(); }, config.stopGraceMs); // not unref'd: whoever awaits stop() waits at most stopGraceMs
|
|
264
|
+
c.once("exit", fin);
|
|
265
|
+
try { if (!c.kill("SIGTERM")) fin(); } catch { fin(); }
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -11,10 +11,10 @@ import { createSmokeRunner } from "./smoke-runner.js";
|
|
|
11
11
|
|
|
12
12
|
export const JOBS_CAPABILITIES = Object.freeze(["job.assign"]);
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/** A workspace "served here": open in the manager (of this tenant, or of none yet) or the default root. -> { root } | null */
|
|
15
|
+
export function makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId }) {
|
|
16
16
|
const tenant = String(tenantId).toLowerCase();
|
|
17
|
-
|
|
17
|
+
return (workspaceId) => {
|
|
18
18
|
if (typeof workspaceId !== "string" || !workspaceId) return null;
|
|
19
19
|
const listed = (wsManager?.list?.() || []).find((w) => w && w.id === workspaceId);
|
|
20
20
|
if (listed) {
|
|
@@ -24,6 +24,12 @@ export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, confi
|
|
|
24
24
|
if (workspaceId === "default") { const root = getWorkspaceRoot(); return root ? { root } : null; }
|
|
25
25
|
return null;
|
|
26
26
|
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, config, env = process.env, log = console, runner = null, onResult = null, now = Date.now, memoryUsage, sleep }) {
|
|
30
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("job.assign needs LOCAL_AGENT_DLP_MODE=enforce");
|
|
31
|
+
const tenant = String(tenantId).toLowerCase();
|
|
32
|
+
const resolveWorkspace = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
27
33
|
const theRunner = runner || (config.runner === "smoke" ? createSmokeRunner({ maxFileBytes: config.smokeMaxFileBytes }) : null);
|
|
28
34
|
if (!theRunner) throw new Error("job.assign needs a runner");
|
|
29
35
|
const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, config, env, llm: link.llm || null,
|
|
@@ -77,7 +77,8 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
77
77
|
const note = (event, extra) => { try { auditor.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
78
78
|
const reject = (rec, code) => { stats.rejected[code] = (stats.rejected[code] || 0) + 1; note("job_rejected", { code, ...rec }); return { reject: code }; };
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// `waiting` (a runner that waits for a person: ADR-0010) shows as `waiting_approval` while the job runs; every other state is the job's own
|
|
81
|
+
const snapshot = (rec, over = {}) => ({ jobId: rec.jobId, state: rec.state === "running" && rec.waiting ? "waiting_approval" : rec.state, phase: rec.phase, iteration: rec.iteration, filesRead: rec.filesRead, filesChanged: rec.filesChanged,
|
|
81
82
|
tokensIn: rec.tokensIn, tokensOut: rec.tokensOut, costUsd: rec.costUsd, durationMs: Math.max(0, now() - rec.acceptedAt), ...over });
|
|
82
83
|
const tokensUsed = (rec) => rec.tokensIn + rec.tokensOut;
|
|
83
84
|
const publish = (rec) => { if (rec.state === "running" || rec.state === "queued") ev.progress(rec, snapshot(rec)); };
|
|
@@ -154,20 +155,23 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
154
155
|
if (!llm || !llm.client) { finish(rec, null, Object.assign(new Error("no llm"), { jobErrorCode: "llm_error" })); return; }
|
|
155
156
|
if (!llm.holder.held || !llm.holder.matches(rec.job.llm.keyRef)) { finish(rec, null, Object.assign(new Error("no key"), { jobErrorCode: "llm_error" })); return; }
|
|
156
157
|
}
|
|
158
|
+
async function llmCall(kind, opts) {
|
|
159
|
+
if (rec.abortReason || rec.finished) throw new LlmClientError("cancelled");
|
|
160
|
+
const r = await llm.client[kind]({ ...opts, model: opts.model ?? rec.job.llm.alias, signal: rec.ac.signal, job: { jobId: rec.jobId, agent: theRunner.name }, tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)) });
|
|
161
|
+
rec.tokensIn += r.usage.promptTokens;
|
|
162
|
+
rec.tokensOut += r.usage.completionTokens;
|
|
163
|
+
if (r.costUsd !== null && r.costUsd !== undefined) rec.costUsd = (rec.costUsd || 0) + r.costUsd;
|
|
164
|
+
publish(rec);
|
|
165
|
+
if (tokensUsed(rec) > rec.budget.tokens) abort(rec, "budget_exceeded"); // the next call is refused at equality (client: budget_exceeded); only an overshoot of the provider aborts
|
|
166
|
+
return r;
|
|
167
|
+
}
|
|
157
168
|
const ctx = Object.freeze({
|
|
158
169
|
signal: rec.ac.signal,
|
|
159
170
|
workspace: Object.freeze({ id: rec.workspaceId }),
|
|
160
171
|
llm: Object.freeze({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
rec.tokensIn += r.usage.promptTokens;
|
|
165
|
-
rec.tokensOut += r.usage.completionTokens;
|
|
166
|
-
if (r.costUsd !== null && r.costUsd !== undefined) rec.costUsd = (rec.costUsd || 0) + r.costUsd;
|
|
167
|
-
publish(rec);
|
|
168
|
-
if (tokensUsed(rec) > rec.budget.tokens) abort(rec, "budget_exceeded"); // the next call is refused at equality (client: budget_exceeded); only an overshoot of the provider aborts
|
|
169
|
-
return r;
|
|
170
|
-
},
|
|
172
|
+
chat: (opts = {}) => llmCall("chat", opts),
|
|
173
|
+
/** F3-08c: one turn of the Anthropic Messages API (tools, content blocks) - what the packaged agent loop speaks. Same gates, same budget. */
|
|
174
|
+
messages: (opts = {}) => llmCall("messages", opts),
|
|
171
175
|
}),
|
|
172
176
|
async readFile(ref, opts = {}) {
|
|
173
177
|
// an abandoned, cancelled or timed-out job reads nothing more (no descriptor opened, no buffer built), and what a read that was already
|
|
@@ -183,6 +187,7 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
183
187
|
if (rec.finished) return;
|
|
184
188
|
if (p.phase) rec.phase = p.phase;
|
|
185
189
|
for (const k of ["iteration", "filesRead", "filesChanged"]) if (Number.isSafeInteger(p[k]) && p[k] >= 0) rec[k] = p[k];
|
|
190
|
+
if (typeof p.waitingApproval === "boolean") rec.waiting = p.waitingApproval;
|
|
186
191
|
publish(rec);
|
|
187
192
|
},
|
|
188
193
|
budget: () => ({ tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)), msLeft: Math.max(0, rec.startedAt + secondsMs - now()) }),
|
|
@@ -229,7 +234,7 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
229
234
|
const budget = { tokens: Math.min(p.budget.tokens, config.maxTokens), seconds: Math.min(p.budget.seconds, config.maxSeconds) };
|
|
230
235
|
const rec = {
|
|
231
236
|
jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
|
|
232
|
-
tokensIn: 0, tokensOut: 0, costUsd: null, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
|
|
237
|
+
tokensIn: 0, tokensOut: 0, costUsd: null, waiting: false, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
|
|
233
238
|
reservedBytes: need, deadlineTimer: null, graceTimer: null, budget,
|
|
234
239
|
job: Object.freeze({ jobId: p.jobId, kind: p.kind, workspaceId: ctx.workspaceId, incidentRef: p.incidentRef, repoRef: p.repoRef, policy: p.policy, budget: Object.freeze({ ...budget }), llm: p.llm, inputs: p.inputs || [], options: opts.options }),
|
|
235
240
|
};
|
|
@@ -38,6 +38,8 @@ export class LlmClientError extends Error {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
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}$/;
|
|
41
43
|
const ROLES = new Set(["system", "user", "assistant"]);
|
|
42
44
|
const COST_HEADER = "x-litellm-response-cost";
|
|
43
45
|
const MAX_SSE_LINE_BYTES = 256 * 1024;
|
|
@@ -111,7 +113,7 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
111
113
|
try {
|
|
112
114
|
let res;
|
|
113
115
|
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);
|
|
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);
|
|
115
117
|
} catch (e) {
|
|
116
118
|
// LlmKeyError codes from the holder (no_key | expired | bad_path); anything else (fetch failure, abort) is a network error
|
|
117
119
|
if (e && e.name === "LlmKeyError") throw { code: e.code === "expired" ? "key_expired" : e.code === "no_key" ? "no_key" : "internal", retry: false };
|
|
@@ -133,7 +135,7 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
133
135
|
const costUsd = parseCost(res.headers);
|
|
134
136
|
if (!res.body || typeof res.body.getReader !== "function") throw { code: "bad_response", retry: false };
|
|
135
137
|
try {
|
|
136
|
-
const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : await readJson(res.body, config.maxResponseBytes);
|
|
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);
|
|
137
139
|
return { ...parsed, costUsd };
|
|
138
140
|
} catch (e) {
|
|
139
141
|
if (e && e.code && !e.name) throw e; // our own { code, retry }
|
|
@@ -271,26 +273,31 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
271
273
|
maxTokens = Math.min(maxTokens, room);
|
|
272
274
|
}
|
|
273
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) {
|
|
274
286
|
if (active >= config.maxConcurrent) return fail("busy", ctx);
|
|
275
287
|
active++;
|
|
276
288
|
try {
|
|
277
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;
|
|
278
|
-
const bodyText =
|
|
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
|
-
});
|
|
290
|
+
const bodyText = makeBody(tags);
|
|
284
291
|
stats.bytesOut += byteLength(bodyText);
|
|
285
292
|
let attempts = 0;
|
|
286
293
|
for (;;) {
|
|
287
294
|
attempts++;
|
|
288
295
|
try {
|
|
289
|
-
const r = await attempt(bodyText, o, ctx);
|
|
296
|
+
const r = await attempt(bodyText, { ...o, ...hooks }, ctx);
|
|
290
297
|
const usage = r.usage || { promptTokens: estimateTokens(ctx.promptBytes), completionTokens: estimateTokens(byteLength(r.text)), estimated: true };
|
|
291
298
|
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 };
|
|
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 } : {}) };
|
|
294
301
|
} catch (e) {
|
|
295
302
|
const code = e && typeof e.code === "string" && LLM_ERROR_CODES.includes(e.code) ? e.code : "internal";
|
|
296
303
|
const canRetry = e && e.retry === true && !ctx.delivered && attempts <= config.maxRetries && !o.signal?.aborted;
|
|
@@ -307,5 +314,119 @@ export function createLlmClient({ holder, config, tenantId, agentId, env = proce
|
|
|
307
314
|
}
|
|
308
315
|
}
|
|
309
316
|
|
|
310
|
-
|
|
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; } };
|
|
311
432
|
}
|
|
@@ -44,6 +44,8 @@ export function resolveLlmClientConfig(env = process.env, warn = console.warn) {
|
|
|
44
44
|
return {
|
|
45
45
|
enabled: true,
|
|
46
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)
|
|
47
49
|
timeoutMs: int(env.LOCAL_AGENT_LLM_TIMEOUT_MS, 60_000, 1_000, 300_000), // one attempt: headers + body (streaming: until the headers)
|
|
48
50
|
streamIdleMs: int(env.LOCAL_AGENT_LLM_STREAM_IDLE_MS, 30_000, 1_000, 120_000), // streaming: silence between two chunks
|
|
49
51
|
totalMs: int(env.LOCAL_AGENT_LLM_TOTAL_MS, 180_000, 1_000, 900_000), // the whole call, retries included
|