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.
- 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 +62 -0
- package/docs/la-protocol-v2.md +7 -5
- package/env.example +12 -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 +10 -6
- package/src/la-protocol-v2/contract/README.md +75 -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/job.assign.schema.json +31 -0
- 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/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +43 -0
- package/src/la-protocol-v2/jobs/manager.js +281 -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 +432 -0
- package/src/la-protocol-v2/llm/config.js +63 -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 +69 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- 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 +75 -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/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
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -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,12 +73,59 @@ 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
|
+
// F3-08c (Local Agent half): LOCAL_AGENT_RUNNER=agent2 plugs the packaged agent loop (agent2, next to this agent) in as the runner. Default off: nothing of
|
|
86
|
+
// ./jobs/agent2 is loaded. It needs jobs, the LLM client (agent2 reaches the model ONLY through this agent: DLP + key holder) and DLP enforce; when one is
|
|
87
|
+
// missing the job receiver is NOT installed (never silently another runner).
|
|
88
|
+
let agent2Runner = null;
|
|
89
|
+
if (String(env.LOCAL_AGENT_RUNNER ?? "").trim().toLowerCase() === "agent2") {
|
|
90
|
+
const a2 = await import("./jobs/agent2/config.js");
|
|
91
|
+
const a2cfg = a2.resolveAgent2RunnerConfig(env, (m) => log.warn(m));
|
|
92
|
+
if (jobRunner) log.warn("[la-agent2] a runner was injected into buildV2Link: LOCAL_AGENT_RUNNER=agent2 is ignored");
|
|
93
|
+
else if (!jobsCfg.enabled) err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 but LOCAL_AGENT_JOBS is not true: the agent2 runner is NOT installed");
|
|
94
|
+
else if (!a2cfg.enabled) { err(`[la-agent2] ERROR: ${a2cfg.error || "the agent2 runner is not configured"}: job.assign is NOT registered`); jobsCfg = { enabled: false }; }
|
|
95
|
+
else if (!llmClientCfg.enabled) { err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 needs the LLM client (LOCAL_AGENT_LLM_CLIENT, LOCAL_AGENT_LLM_KEY, DLP enforce): agent2 reaches the model only through this agent. job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
96
|
+
else if (!(await dlpEnforce())) { err("[la-agent2] ERROR: LOCAL_AGENT_RUNNER=agent2 but LOCAL_AGENT_DLP_MODE is not enforce: job.assign is NOT registered"); jobsCfg = { enabled: false }; }
|
|
97
|
+
else {
|
|
98
|
+
const { createAgent2Runner } = await import("./jobs/agent2/runner.js");
|
|
99
|
+
const { makeWorkspaceResolver } = await import("./jobs/install.js");
|
|
100
|
+
const resolveWs = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
101
|
+
agent2Runner = createAgent2Runner({ config: a2cfg, tenantId: tenant, resolveWorkspaceRoot: (id) => resolveWs(id)?.root || null, getSession: () => link.session, signing, env, log });
|
|
102
|
+
jobRunner = agent2Runner;
|
|
103
|
+
if (jobsCfg.maxConcurrent !== 1) { log.warn("[la-agent2] agent2 runs one job at a time: LOCAL_AGENT_JOBS_MAX_CONCURRENT is forced to 1"); jobsCfg = { ...jobsCfg, maxConcurrent: 1 }; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (jobsCfg.enabled) {
|
|
107
|
+
const { validateRunner } = await import("./jobs/manager.js");
|
|
108
|
+
const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
|
|
109
|
+
const needsLlm = given ? given.needsLlm !== false : jobsCfg.runner === "smoke";
|
|
110
|
+
if (jobRunner && !given) err("[la-jobs] ERROR: the runner passed to buildV2Link does not implement start()/cancel(); it is ignored");
|
|
111
|
+
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 }; }
|
|
112
|
+
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 }; }
|
|
113
|
+
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 }; }
|
|
114
|
+
else runner = given;
|
|
115
|
+
}
|
|
74
116
|
const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki || webhookCfg.enabled);
|
|
75
117
|
const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
|
|
76
118
|
const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
|
|
77
119
|
const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
|
|
120
|
+
const jobCaps = jobsCfg.enabled ? ["job.assign", ...(agent2Runner ? ["approval.decision"] : [])] : []; // approval.decision (F3-08c): this agent delivers a person's decision (ADR-0010) to agent2; proposal, not named in the contract // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
|
|
121
|
+
// F3-10 (contract 2.1): `job.options` = the runner understands job.assign `options` (dryRun, createPullRequest, ...). Only a runner that declared supportsOptions
|
|
122
|
+
// (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.
|
|
123
|
+
const jobOptionCaps = jobsCfg.enabled && (runner ? runner.supportsOptions === true : jobsCfg.runner === "smoke") ? ["job.options"] : [];
|
|
124
|
+
// contract 2.2 (F3-08c): `policy.check` = this agent asks the PolicyGate over the channel (agent2's governed actions). Only the agent2 runner asks, so only then it is announced;
|
|
125
|
+
// the CP lists it back in `ready.capabilities` only with its feature on, and this agent sends a policy.check only after that (without it: DENY, fail closed)
|
|
126
|
+
const policyCaps = jobsCfg.enabled && agent2Runner ? ["policy.check"] : [];
|
|
78
127
|
// 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
|
-
const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length ? ["contract.v2.1"] : [];
|
|
128
|
+
const v21Caps = webhookCaps.length || llmCaps.length || incidentCaps.length || policyCaps.length ? ["contract.v2.1"] : [];
|
|
80
129
|
const link = createV2Link({
|
|
81
130
|
log,
|
|
82
131
|
sessionOptions: {
|
|
@@ -86,7 +135,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
86
135
|
signing,
|
|
87
136
|
laVersion: agentVersion(),
|
|
88
137
|
// 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],
|
|
138
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...policyCaps, ...v21Caps],
|
|
90
139
|
// 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
140
|
// valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
|
|
92
141
|
dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
|
|
@@ -116,6 +165,23 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
116
165
|
const roots = () => [getWorkspaceRoot(), ...(wsManager?.list?.() || []).map((w) => w && w.root)].filter(Boolean);
|
|
117
166
|
installLlmKey({ link, config: llmCfg, workspaceRoots: roots, log });
|
|
118
167
|
}
|
|
168
|
+
if (llmClientCfg.enabled) {
|
|
169
|
+
const { installLlmClient } = await import("./llm/install.js");
|
|
170
|
+
installLlmClient({ link, config: llmClientCfg, tenantId: tenant, agentId, env, ...(llmFetch ? { fetchImpl: llmFetch } : {}) });
|
|
171
|
+
}
|
|
172
|
+
if (jobsCfg.enabled) {
|
|
173
|
+
const { installJobs } = await import("./jobs/install.js");
|
|
174
|
+
installJobs({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: jobsCfg, env, log, runner, onResult: onJobResult });
|
|
175
|
+
if (agent2Runner && runner === agent2Runner) {
|
|
176
|
+
// approval.decision -> POST /local/v1/approvals/{id} of agent2, after the token check; the child process and the bridge stop with the link
|
|
177
|
+
link.session.setHandler("approval.decision", (ctx) => agent2Runner.handleApprovalDecision(ctx), { ackMs: 5_000 });
|
|
178
|
+
// policy.decision (contract 2.2): the CP's signed answer to a policy.check the bridge asked for agent2 (matched by the envelope requestId)
|
|
179
|
+
link.session.setHandler("policy.decision", (ctx) => agent2Runner.handlePolicyDecision(ctx));
|
|
180
|
+
link.agent2 = agent2Runner;
|
|
181
|
+
const closeJobs = link.close.bind(link);
|
|
182
|
+
link.close = () => { closeJobs(); agent2Runner.close().catch(() => {}); };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
119
185
|
let webhookSource = null;
|
|
120
186
|
if (webhookCfg.enabled) {
|
|
121
187
|
const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
|
package/src/server.js
CHANGED
|
@@ -7297,6 +7297,8 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agent
|
|
|
7297
7297
|
process.on('SIGINT', async () => {
|
|
7298
7298
|
isShuttingDown = true;
|
|
7299
7299
|
if (ws) ws.close();
|
|
7300
|
+
// F3-08c: stops what the v2 link started (jobs in flight, the agent2 runner and the agent2 process it supervises). Nothing when v2 is off.
|
|
7301
|
+
try { if (v2Link) v2Link.close(); } catch (_) { /* never block exit */ }
|
|
7300
7302
|
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7301
7303
|
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7302
7304
|
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
@@ -7305,6 +7307,8 @@ async function startWebSocketTunnel(port, gatewayUrl, authToken, tenantId, agent
|
|
|
7305
7307
|
process.on('SIGTERM', async () => {
|
|
7306
7308
|
isShuttingDown = true;
|
|
7307
7309
|
if (ws) ws.close();
|
|
7310
|
+
// F3-08c: stops what the v2 link started (jobs in flight, the agent2 runner and the agent2 process it supervises). Nothing when v2 is off.
|
|
7311
|
+
try { if (v2Link) v2Link.close(); } catch (_) { /* never block exit */ }
|
|
7308
7312
|
// Phase 2: graceful shutdown of legacy connectors (AC-11; bounded to 5 s).
|
|
7309
7313
|
try { await stopAll(_legacyAdapters, 5000); } catch (_) { /* never block exit */ }
|
|
7310
7314
|
// Preserve existing process model: signal trap does NOT call process.exit();
|
|
@@ -23,7 +23,8 @@ export const MAX_APPROVAL_LIFETIME_SECONDS = 15 * 60;
|
|
|
23
23
|
const b64urlDecode = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Returns { ok: true, approvedBy, decision } or { ok: false, reason }.
|
|
26
|
+
* Returns { ok: true, approvedBy, decision, jti } or { ok: false, reason }. (`jti` is additive, F3-08c: a caller that must not consume the single-use
|
|
27
|
+
* nonce before its own action succeeds passes `nonces: null` and remembers the jti itself.)
|
|
27
28
|
* opts: { subject, keys: Map, identity, requestTenantId, nonces, now }
|
|
28
29
|
*/
|
|
29
30
|
export function verifyApprovalToken(token, { subject, keys, identity = {}, requestTenantId, nonces, now = () => Date.now() } = {}) {
|
|
@@ -52,5 +53,5 @@ export function verifyApprovalToken(token, { subject, keys, identity = {}, reque
|
|
|
52
53
|
if (requestTenantId && p.tid !== requestTenantId) return { ok: false, reason: "approval token tenant differs from the signed request" };
|
|
53
54
|
if (!p.jti || !/^[A-Za-z0-9_-]{8,128}$/.test(p.jti)) return { ok: false, reason: "approval token without jti" };
|
|
54
55
|
if (nonces && !nonces.checkAndRemember(`apr:${p.jti}`)) return { ok: false, reason: "approval token already used" };
|
|
55
|
-
return { ok: true, approvedBy: p.by || null, decision: p.decision || null };
|
|
56
|
+
return { ok: true, approvedBy: p.by || null, decision: p.decision || null, jti: p.jti };
|
|
56
57
|
}
|