deepdebug-local-agent 1.12.0 → 1.13.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 (47) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/docs/la-chat.md +35 -0
  3. package/package.json +1 -1
  4. package/src/dlp/rules.js +2 -2
  5. package/src/la-protocol-v2/chat/config.js +60 -0
  6. package/src/la-protocol-v2/chat/cursors.js +57 -0
  7. package/src/la-protocol-v2/chat/dlp.js +92 -0
  8. package/src/la-protocol-v2/chat/index.js +28 -0
  9. package/src/la-protocol-v2/chat/store.js +337 -0
  10. package/src/la-protocol-v2/chat/stream-source.js +23 -0
  11. package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +10 -2
  13. package/src/la-protocol-v2/content-handlers/install.js +2 -2
  14. package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
  16. package/src/la-protocol-v2/contract/README.md +42 -0
  17. package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
  18. package/src/la-protocol-v2/contract/index.json +1 -1
  19. package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
  20. package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
  21. package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
  22. package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
  23. package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
  24. package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
  25. package/src/la-protocol-v2/jobs/git-exec.js +5 -4
  26. package/src/la-protocol-v2/jobs/manager.js +4 -2
  27. package/src/la-protocol-v2/jobs/workspace-materializer.js +35 -12
  28. package/src/la-protocol-v2/runtime.js +32 -3
  29. package/src/workspace-manager.js +3 -2
  30. package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
  31. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
  32. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
  33. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
  34. package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
  35. package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
  36. package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
  37. package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
  38. package/test/la-protocol-v2/chat-la.test.js +490 -0
  39. package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
  40. package/test/la-protocol-v2/chat-store.test.js +301 -0
  41. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  42. package/test/la-protocol-v2/contract.test.js +6 -6
  43. package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
  44. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
  45. package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
  46. package/test/la-protocol-v2/traces.test.js +3 -3
  47. package/test/la-protocol-v2/workspace-materializer-hostile.test.js +105 -0
@@ -41,7 +41,7 @@ export function jobErrorCodeOf(err) {
41
41
 
42
42
  /**
43
43
  * Runner interface (F3-08c plugs the packaged agent loop in here):
44
- * { name: string, kinds?: string[] (default: all of fix|implement|quality), needsLlm?: boolean (default true),
44
+ * { name: string, kinds?: string[] (default: all of fix|implement|quality; `chat` only when the runner lists it: contract 2.3, capability job.chat), needsLlm?: boolean (default true),
45
45
  * start(job, ctx): Promise<{ outcome: "pr_opened"|"no_change"|"failed"|"cancelled", errorCode?, prUrl?, branch?, commitSha?, filesChangedCount?,
46
46
  * diffSha256?, tests?, answer? }>,
47
47
  * cancel(jobId): void|Promise<void> }
@@ -54,7 +54,7 @@ export function jobErrorCodeOf(err) {
54
54
  */
55
55
  export function validateRunner(r) {
56
56
  return !!r && typeof r === "object" && typeof r.name === "string" && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(r.name) && typeof r.start === "function" && typeof r.cancel === "function"
57
- && (r.kinds === undefined || (Array.isArray(r.kinds) && r.kinds.every((k) => ["fix", "implement", "quality"].includes(k))))
57
+ && (r.kinds === undefined || (Array.isArray(r.kinds) && r.kinds.every((k) => ["fix", "implement", "quality", "chat"].includes(k))))
58
58
  && (r.supportsOptions === undefined || typeof r.supportsOptions === "boolean");
59
59
  }
60
60
 
@@ -206,6 +206,8 @@ export function createJobManager({ session, tenantId, resolveWorkspace, material
206
206
  stats, audit: auditor, events: ev,
207
207
  get runner() { return theRunner; },
208
208
  get activeCount() { return active.size; },
209
+ /** LA-3: true while a chat job with that id is queued, being prepared (workspace clone) or running: the ChatStore waits for such a job to appear instead of saying `not_found`. */
210
+ isActiveChat(jobId) { const rec = active.get(jobId); return !!rec && rec.kind === "chat" && !rec.finished; },
209
211
  get running() { return running; },
210
212
  get reservedBytes() { return reserved; },
211
213
  /** F3-08c: the packaged agent loop replaces the runner (jobs already running keep the one they started with only as far as their own cancel). */
@@ -88,7 +88,7 @@ export function resolveMaterializeConfig(env = process.env, warn = console.warn)
88
88
  /** Validates a repoRef and builds { host, org, repo, branch, key, url } or null. The url has NO credentials and is only https. */
89
89
  export function repoTarget(repoRef) {
90
90
  if (!repoRef || typeof repoRef !== "object") return null;
91
- const host = HOSTS[repoRef.provider];
91
+ const host = typeof repoRef.provider === "string" && Object.hasOwn(HOSTS, repoRef.provider) ? HOSTS[repoRef.provider] : null;
92
92
  const { org, repo, branch } = repoRef;
93
93
  if (!host || typeof org !== "string" || typeof repo !== "string" || typeof branch !== "string") return null;
94
94
  if (!SEG.test(org) || !SEG.test(repo) || org === "." || org === ".." || repo === "." || repo === "..") return null;
@@ -128,7 +128,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
128
128
  const tenant = String(tenantId).toLowerCase();
129
129
  const git = seams.runGit || runGit;
130
130
  const stats = { admitted: 0, refused: {}, cloned: 0, reused: 0, failed: {} };
131
- const inflight = new Map(); // workspaceId -> Promise<{root}>
131
+ const inflight = new Map(); // workspaceId -> { p:Promise<{root}>, ctl, waiters }
132
132
  let chain = Promise.resolve(); // one clone/fetch at a time on this agent
133
133
  const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
134
134
  const refuse = (why, extra) => { stats.refused[why] = (stats.refused[why] || 0) + 1; note("workspace_refused", { why, ...extra }); return null; };
@@ -179,7 +179,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
179
179
  if (!tokenUsable()) fail("credential_unavailable");
180
180
  try { askpass = ensureAskpassScript(root); } catch { fail("cache_unavailable"); }
181
181
  }
182
- const env2 = gitEnv(env, { askpass, tokenFile: config.tokenFile });
182
+ const env2 = { ...gitEnv(env, { askpass, tokenFile: config.tokenFile, host: t.host }), GIT_CEILING_DIRECTORIES: root }; // git never discovers a repository above the cache
183
183
  const extraConfig = seams.gitConfig || [];
184
184
  const remote = seams.remoteUrl ? seams.remoteUrl(t) : t.url;
185
185
  let st = null;
@@ -189,6 +189,9 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
189
189
  const sc = readWorkspaceSidecar(dir);
190
190
  if (!sc || sc.id !== workspaceId) fail("foreign_directory");
191
191
  if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) fail("tenant_mismatch");
192
+ let gitSt = null;
193
+ try { gitSt = fs.lstatSync(path.join(dir, ".git")); } catch { gitSt = null; }
194
+ if (!gitSt || !gitSt.isDirectory()) fail("not_a_clone"); // a multi-repo parent or non-git workspace: never fetch/checkout/merge in whatever repo is above it
192
195
  // existing clone: fetch the branch; move only a CLEAN tree (the previous job's local work is never reset)
193
196
  const f = await git(["fetch", "--depth", String(config.depth), "--no-tags", "--", "origin", t.branch], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
194
197
  if (!f.ok) fail(f.code === "failed" ? "fetch_failed" : f.code);
@@ -202,10 +205,21 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
202
205
  const tmp = path.join(root, `.tmp-${workspaceId}-${crypto.randomBytes(4).toString("hex")}`);
203
206
  let ok = false;
204
207
  try {
205
- const c = await git(["clone", "--branch", t.branch, "--single-branch", "--depth", String(config.depth), "--no-tags", "--", remote, tmp], { env: env2, cwd: root, timeoutMs: config.cloneTimeoutMs, signal, extraConfig });
208
+ // the size limit is enforced WHILE cloning (not only after): a watcher aborts git as soon as the partial clone passes the limit
209
+ const guard = new AbortController();
210
+ const onAbort = () => guard.abort();
211
+ if (signal) { if (signal.aborted) guard.abort(); else signal.addEventListener("abort", onAbort, { once: true }); }
212
+ let over = false;
213
+ const watch = setInterval(() => { if (dirBytes(tmp, config.maxBytes) > config.maxBytes) { over = true; guard.abort(); } }, 1000);
214
+ let c;
215
+ try { c = await git(["clone", "--branch", t.branch, "--single-branch", "--depth", String(config.depth), "--no-tags", "--", remote, tmp], { env: env2, cwd: root, timeoutMs: config.cloneTimeoutMs, signal: guard.signal, extraConfig }); }
216
+ finally { clearInterval(watch); if (signal) signal.removeEventListener("abort", onAbort); }
217
+ if (over) fail("too_large");
206
218
  if (!c.ok) fail(c.code === "failed" ? "clone_failed" : c.code);
207
219
  if (dirBytes(tmp, config.maxBytes) > config.maxBytes) fail("too_large");
208
220
  // the remote of the clone is the credential-free URL we built; nothing else was ever written to its config
221
+ // a repository may ship names that wsManager.open() writes through (a symlink at `.vh-workspace.json*`): drop them before registering
222
+ for (const n of fs.readdirSync(tmp)) if (n.startsWith(".vh-workspace.json")) fs.rmSync(path.join(tmp, n), { recursive: true, force: true });
209
223
  fs.renameSync(tmp, dir);
210
224
  ok = true;
211
225
  } finally {
@@ -231,14 +245,23 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
231
245
  * One clone at a time; a second call for the same id shares the first one's promise.
232
246
  */
233
247
  ensure(workspaceId, repoRef, signal) {
234
- const running = inflight.get(workspaceId);
235
- if (running) return running;
236
- const p = chain.then(() => { if (signal && signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, signal); });
237
- chain = p.catch(() => {});
238
- inflight.set(workspaceId, p);
239
- const clear = () => { if (inflight.get(workspaceId) === p) inflight.delete(workspaceId); };
240
- p.then(clear, clear);
241
- return p;
248
+ let shared = inflight.get(workspaceId);
249
+ if (!shared) {
250
+ // the clone has its OWN controller: it is aborted only when EVERY job waiting for it has been cancelled
251
+ const ctl = new AbortController();
252
+ const p = chain.then(() => { if (ctl.signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, ctl.signal); });
253
+ chain = p.catch(() => {});
254
+ shared = { p, ctl, waiters: 0 };
255
+ inflight.set(workspaceId, shared);
256
+ const clear = () => { if (inflight.get(workspaceId) === shared) inflight.delete(workspaceId); };
257
+ p.then(clear, clear);
258
+ }
259
+ shared.waiters++;
260
+ if (signal) {
261
+ const left = () => { if (--shared.waiters <= 0) shared.ctl.abort(); };
262
+ if (signal.aborted) left(); else signal.addEventListener("abort", left, { once: true });
263
+ }
264
+ return shared.p;
242
265
  },
243
266
  };
244
267
  }
@@ -13,6 +13,7 @@ import { resolveWebhookConfig } from "./webhooks/config.js";
13
13
  import { resolveLlmClientConfig } from "./llm/config.js";
14
14
  import { resolveJobsConfig } from "./jobs/config.js";
15
15
  import { loadContract } from "./contract.js";
16
+ import { resolveChatConfig, chatRequested } from "./chat/config.js";
16
17
 
17
18
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
19
  const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
@@ -86,6 +87,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
86
87
  // ./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
88
  // missing the job receiver is NOT installed (never silently another runner).
88
89
  let agent2Runner = null;
90
+ // LA-1/LA-3 (contract 2.3, LOCAL_AGENT_CHAT, default off): chat on the Local Agent. It rides on the agent2 runner and on the content handlers (job.stream / chat.session are
91
+ // served by them), so it exists only when both do; otherwise it says why and stays off (never a half-working chat: `job.chat` is announced only by a complete one).
92
+ let chat = null;
93
+ let chatReported = false;
89
94
  if (String(env.LOCAL_AGENT_RUNNER ?? "").trim().toLowerCase() === "agent2") {
90
95
  const a2 = await import("./jobs/agent2/config.js");
91
96
  const a2cfg = a2.resolveAgent2RunnerConfig(env, (m) => log.warn(m));
@@ -97,12 +102,23 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
97
102
  else {
98
103
  const { createAgent2Runner } = await import("./jobs/agent2/runner.js");
99
104
  const { makeWorkspaceResolver } = await import("./jobs/install.js");
105
+ const chatCfg = resolveChatConfig(env, (m) => log.warn(m));
106
+ if (chatCfg.enabled) {
107
+ chatReported = true;
108
+ if (!contentCfg.enabled) err("[la-chat] ERROR: LOCAL_AGENT_CHAT=true but the content handlers are not on (LOCAL_AGENT_CONTENT_HANDLERS=true and LOCAL_AGENT_DLP_MODE=enforce): nothing could serve job.stream / chat.session. The chat stays off");
109
+ else {
110
+ const { createChat } = await import("./chat/index.js");
111
+ try { chat = createChat({ config: chatCfg, tenantId: tenant, env, log, isActive: (id) => !!(link.jobs && link.jobs.isActiveChat(id)) }); } catch { err("[la-chat] ERROR: the chat store could not be created (LOCAL_AGENT_CHAT_DIR): the chat stays off"); }
112
+ }
113
+ }
114
+ a2cfg.chat = !!chat;
100
115
  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 });
116
+ agent2Runner = createAgent2Runner({ config: a2cfg, tenantId: tenant, resolveWorkspaceRoot: (id) => resolveWs(id)?.root || null, getSession: () => link.session, signing, env, log, chat });
102
117
  jobRunner = agent2Runner;
103
118
  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
119
  }
105
120
  }
121
+ if (chatRequested(env) && !chat && !chatReported) err("[la-chat] ERROR: LOCAL_AGENT_CHAT=true needs LOCAL_AGENT_JOBS, LOCAL_AGENT_RUNNER=agent2 (with the LLM client and DLP enforce) and the content handlers: the chat stays off");
106
122
  if (jobsCfg.enabled) {
107
123
  const { validateRunner } = await import("./jobs/manager.js");
108
124
  const given = jobRunner && validateRunner(jobRunner) ? jobRunner : null;
@@ -138,7 +154,10 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
138
154
  capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps, ...webhookCaps, ...llmCaps, ...jobCaps, ...jobOptionCaps, ...policyCaps, ...v21Caps],
139
155
  // 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
140
156
  // valid, unexpired key (a revoked or expired one is not announced from the next connection on). `link` is assigned below, before any attach.
141
- dynamicCapabilities: () => (llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
157
+ // contract 2.3: `job.chat` (+ `job.inline.16k`, a prompt of 16 KiB) only from a complete chat: the store, the content handlers, the agent2 runner and an agent2 whose /health
158
+ // listed `chat` (probeChat below). The CP sends kind:"chat" and a long inline input only to a session that announced them.
159
+ dynamicCapabilities: () => [...(llmCfg.enabled && link.llm && link.llm.holder.held ? ["llm.key.held"] : []),
160
+ ...(chat && agent2Runner && runner === agent2Runner && agent2Runner.chatReady ? ["job.chat", "job.inline.16k"] : [])],
142
161
  getWorkspaces: () => {
143
162
  const open = (wsManager?.list?.() || []).map((w) => ({
144
163
  workspaceId: String(w.id || ""),
@@ -158,7 +177,12 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
158
177
  });
159
178
  if (contentCfg.enabled) {
160
179
  const { installContentHandlers } = await import("./content-handlers/install.js");
161
- installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
180
+ installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log, chat: chat ? chat.content : null });
181
+ }
182
+ if (chat) {
183
+ link.chat = chat;
184
+ const closeBase = link.close.bind(link);
185
+ link.close = () => { chat.close(); closeBase(); };
162
186
  }
163
187
  if (llmCfg.enabled) {
164
188
  const { installLlmKey } = await import("./llm-key/install.js");
@@ -182,6 +206,11 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
182
206
  link.close = () => { closeJobs(); agent2Runner.close().catch(() => {}); };
183
207
  }
184
208
  }
209
+ if (chat && agent2Runner && runner === agent2Runner) {
210
+ // which kinds does agent2 run? (/health). Waits at most LOCAL_AGENT_CHAT_PROBE_MS so the first register already says `job.chat`; in spawn mode this starts agent2 now
211
+ const probe = agent2Runner.probeChat();
212
+ if (chat.config.probeWaitMs > 0) await Promise.race([probe, new Promise((r) => { const t = setTimeout(r, chat.config.probeWaitMs); if (t.unref) t.unref(); })]);
213
+ }
185
214
  let webhookSource = null;
186
215
  if (webhookCfg.enabled) {
187
216
  const { createWebhookSource } = await import("../incident-detector/sources/webhook-source.js");
@@ -1,5 +1,6 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
+ import crypto from "crypto";
3
4
  import { exists, listRecursive, readFile, stat } from "./fs-utils.js";
4
5
  import { WORKSPACE_SIDECAR, readWorkspaceSidecar } from "./utils/open-gate.js";
5
6
  import { detectProject } from "./detectors.js";
@@ -87,13 +88,13 @@ export class WorkspaceManager {
87
88
  try {
88
89
  // Atomic (temp + rename) and owner-only: the sidecar's tenantId is
89
90
  // read by ROOT-RECOVERY, so it must never be observed half-written.
90
- const tmp = `${file}.${process.pid}.tmp`;
91
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
91
92
  fs.writeFileSync(tmp, JSON.stringify({
92
93
  id: state.id,
93
94
  tenantId: state.tenantId || null,
94
95
  root: state.root,
95
96
  openedAt: state.openedAt
96
- }, null, 2) + "\n", { mode: 0o600 });
97
+ }, null, 2) + "\n", { mode: 0o600, flag: "wx" }); // wx: never write through a pre-existing file/symlink
97
98
  fs.renameSync(tmp, file);
98
99
  // Single-repo clones ARE git repos and the PR path runs `git add -A`:
99
100
  // keep the sidecar out of git via .git/info/exclude (never touches