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
@@ -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
  }