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