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.
Files changed (52) 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 +7 -1
  7. package/docs/la-protocol-v2.md +4 -2
  8. package/env.example +6 -0
  9. package/package.json +1 -1
  10. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +8 -4
  11. package/src/la-protocol-v2/contract/README.md +51 -2
  12. package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
  13. package/src/la-protocol-v2/contract/index.json +18 -2
  14. package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
  15. package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
  16. package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
  17. package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
  18. package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
  19. package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
  20. package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
  21. package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
  22. package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
  23. package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
  24. package/src/la-protocol-v2/jobs/install.js +9 -3
  25. package/src/la-protocol-v2/jobs/manager.js +17 -12
  26. package/src/la-protocol-v2/llm/client.js +133 -12
  27. package/src/la-protocol-v2/llm/config.js +2 -0
  28. package/src/la-protocol-v2/runtime.js +36 -3
  29. package/src/server.js +4 -0
  30. package/src/utils/approval-token.js +3 -2
  31. package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
  32. package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
  33. package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
  34. package/test/la-protocol-v2/agent2-config.test.js +103 -0
  35. package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
  36. package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
  37. package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
  38. package/test/la-protocol-v2/agent2-runner.test.js +510 -0
  39. package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
  40. package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
  41. package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
  42. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  43. package/test/la-protocol-v2/contract.test.js +8 -7
  44. package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
  45. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
  46. package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
  47. package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
  48. package/test/la-protocol-v2/helpers/fake-llm.js +11 -0
  49. package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
  50. package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
  51. package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
  52. package/test/la-protocol-v2/llm-messages.test.js +132 -0
@@ -82,6 +82,27 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
82
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
83
  let jobsCfg = resolveJobsConfig(env, (m) => log.warn(m));
84
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
+ }
85
106
  if (jobsCfg.enabled) {
86
107
  const { validateRunner } = await import("./jobs/manager.js");
87
108
  const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
@@ -96,12 +117,15 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
96
117
  const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
97
118
  const webhookCaps = webhookCfg.enabled ? ["webhook.forward", "webhook.chunked"] : [];
98
119
  const llmCaps = llmCfg.enabled ? ["llm.key"] : [];
99
- const jobCaps = jobsCfg.enabled ? ["job.assign"] : []; // F3-10: this agent runs jobs (the CP sends job.assign only to a session that announced it)
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)
100
121
  // F3-10 (contract 2.1): `job.options` = the runner understands job.assign `options` (dryRun, createPullRequest, ...). Only a runner that declared supportsOptions
101
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.
102
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"] : [];
103
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
104
- 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"] : [];
105
129
  const link = createV2Link({
106
130
  log,
107
131
  sessionOptions: {
@@ -111,7 +135,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
111
135
  signing,
112
136
  laVersion: agentVersion(),
113
137
  // only what this build implements: the transport, the handshake and the v1 commands on the same connection
114
- capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...v21Caps],
138
+ capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...policyCaps, ...v21Caps],
115
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
116
140
  // valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
117
141
  dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
@@ -148,6 +172,15 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
148
172
  if (jobsCfg.enabled) {
149
173
  const { installJobs } = await import("./jobs/install.js");
150
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
+ }
151
184
  }
152
185
  let webhookSource = null;
153
186
  if (webhookCfg.enabled) {
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
  }